feat(pdf): one page setup, honoured by both drivers (#728)

Paper size was a Gotenberg-only setting stored as a single "210mm 297mm"
string. dompdf had no page settings at all: size was pinned to config/dompdf.php's
fixed 'a4', its top-level `orientation` key was read by nothing (the installed
barryvdh v3 builds options only from `defines`), and margins were whatever
dompdf's own stylesheet said. So the two drivers disagreed about margins by
default -- dompdf 1.2cm, Gotenberg hardcoded to zero -- and selecting dompdf
silently discarded the paper size.

Replaces gotenberg_papersize with pdf_paper_width / pdf_paper_height /
pdf_orientation / pdf_margin_{top,right,bottom,left}, saved and applied for
either driver. Width and height are separate CSS lengths because that is the
only lossless shared notation: Gotenberg has no named sizes, and dompdf's named
table cannot express everything Gotenberg accepts. Named presets (A3/A4/A5/
Letter/Legal) are a convenience in the UI that resolve to a pair of lengths.

PdfPageSetup resolves it once and translates: a points array plus an orientation
argument for dompdf, CSS lengths plus landscape() for Gotenberg. Both are handed
the portrait pair, since each swaps the axes itself. Gotenberg's margins() takes
top, bottom, left, right, which is not the CSS order.

dompdf exposes no margin API, so DompdfDriver injects an @page rule -- at the
top of <head>, so a template declaring its own still wins. Doing it in the driver
rather than a Blade partial means custom templates get it without including
anything.

Margins default to 1.2cm, dompdf's existing default, so Gotenberg starts
matching it rather than rendering edge-to-edge. Verified against a live
gotenberg:8: A4 portrait, A4 landscape and Letter at zero margins all come out
with the same page box and the same ink offsets on both drivers.

A malformed length now throws rather than being ignored. Blank still falls back,
but a value that is set and wrong is an operator mistake, and the drivers would
otherwise fail differently: dompdf throws converting to points, Gotenberg would
forward the string and render at some other size.

Also here:
- Migration splits an existing gotenberg_papersize into the new pair. It earns
  its place because that key ships in 2.x, not just a 3.x alpha, so a stable
  install that chose Letter would otherwise come back up on A4. Drops
  gotenberg_margins, which 2.x also stores and neither driver ever read.
- Removes EnvironmentManager::savePDFVariables/getPDFConfiguration, which had no
  caller anywhere, and the unused EnvironmentManager injection in the controller.
- config/dompdf.php: drops the dead `orientation` key and defaults enable_remote
  to false, matching .env.example, which sets it explicitly and explains why.
  Installs predating that line were falling back to true.
- Retires the settings.pdf.footer_text and pdf_layout strings, which no component
  referenced.

Claude-Session: https://claude.ai/code/session_01QmECndmNZwzN65Zz9P87dF
This commit is contained in:
Darko Gjorgjijoski
2026-08-01 12:48:51 +02:00
committed by GitHub
parent 6cb754da60
commit a54a5ee007
23 changed files with 1104 additions and 251 deletions

View File

@@ -3,11 +3,14 @@ import { computed, onMounted, reactive } from 'vue'
import { useI18n } from 'vue-i18n'
import { required, helpers } from '@vuelidate/validators'
import useVuelidate from '@vuelidate/core'
import type { PdfDriver } from '@/scripts/api/services/pdf.service'
interface DomPdfForm {
pdf_driver: string
}
import type { DomPdfConfig, PdfDriver } from '@/scripts/api/services/pdf.service'
import AdminPdfPageSetup from '@/scripts/features/admin/components/settings/AdminPdfPageSetup.vue'
import {
cssLength,
pageSetupDefaults,
pageSetupErrors,
pageSetupFrom,
} from '@/scripts/features/admin/components/settings/pdfPageSetup'
const props = withDefaults(
defineProps<{
@@ -25,28 +28,39 @@ const props = withDefaults(
)
const emit = defineEmits<{
'submit-data': [config: DomPdfForm]
'submit-data': [config: DomPdfConfig]
'on-change-driver': [driver: string]
}>()
const { t } = useI18n()
const form = reactive<DomPdfForm>({
const form = reactive<DomPdfConfig>({
pdf_driver: 'dompdf',
...pageSetupDefaults(),
})
const rules = computed(() => ({
pdf_driver: {
required: helpers.withMessage(t('validation.required'), required),
},
pdf_paper_width: cssLength(t),
pdf_paper_height: cssLength(t),
pdf_margin_top: cssLength(t),
pdf_margin_right: cssLength(t),
pdf_margin_bottom: cssLength(t),
pdf_margin_left: cssLength(t),
}))
const v$ = useVuelidate(rules, form)
const pageErrors = computed(() => pageSetupErrors(v$.value))
onMounted(() => {
if (typeof props.configData.pdf_driver === 'string') {
form.pdf_driver = props.configData.pdf_driver
}
Object.assign(form, pageSetupFrom(props.configData))
})
function onChangeDriver(): void {
@@ -83,6 +97,13 @@ function saveConfig(): void {
</BaseInputGroup>
</BaseInputGrid>
<AdminPdfPageSetup
v-model="form"
class="mt-6"
:is-fetching-initial-data="isFetchingInitialData"
:errors="pageErrors"
/>
<div class="flex my-10">
<BaseButton
:disabled="isSaving"

View File

@@ -3,13 +3,14 @@ import { computed, onMounted, reactive } from 'vue'
import { useI18n } from 'vue-i18n'
import { required, helpers } from '@vuelidate/validators'
import useVuelidate from '@vuelidate/core'
import type { PdfDriver } from '@/scripts/api/services/pdf.service'
interface GotenbergForm {
pdf_driver: string
gotenberg_host: string
gotenberg_papersize: string
}
import type { GotenbergConfig, PdfDriver } from '@/scripts/api/services/pdf.service'
import AdminPdfPageSetup from '@/scripts/features/admin/components/settings/AdminPdfPageSetup.vue'
import {
cssLength,
pageSetupDefaults,
pageSetupErrors,
pageSetupFrom,
} from '@/scripts/features/admin/components/settings/pdfPageSetup'
const props = withDefaults(
defineProps<{
@@ -27,16 +28,16 @@ const props = withDefaults(
)
const emit = defineEmits<{
'submit-data': [config: GotenbergForm]
'submit-data': [config: GotenbergConfig]
'on-change-driver': [driver: string]
}>()
const { t } = useI18n()
const form = reactive<GotenbergForm>({
const form = reactive<GotenbergConfig>({
pdf_driver: 'gotenberg',
gotenberg_host: '',
gotenberg_papersize: '210mm 297mm',
...pageSetupDefaults(),
})
function isValidServiceUrl(value: string): boolean {
@@ -67,13 +68,18 @@ const rules = computed(() => ({
isValidServiceUrl
),
},
gotenberg_papersize: {
required: helpers.withMessage(t('validation.required'), required),
},
pdf_paper_width: cssLength(t),
pdf_paper_height: cssLength(t),
pdf_margin_top: cssLength(t),
pdf_margin_right: cssLength(t),
pdf_margin_bottom: cssLength(t),
pdf_margin_left: cssLength(t),
}))
const v$ = useVuelidate(rules, form)
const pageErrors = computed(() => pageSetupErrors(v$.value))
onMounted(() => {
if (typeof props.configData.pdf_driver === 'string') {
form.pdf_driver = props.configData.pdf_driver
@@ -83,9 +89,7 @@ onMounted(() => {
form.gotenberg_host = props.configData.gotenberg_host
}
if (typeof props.configData.gotenberg_papersize === 'string') {
form.gotenberg_papersize = props.configData.gotenberg_papersize
}
Object.assign(form, pageSetupFrom(props.configData))
})
function onChangeDriver(): void {
@@ -137,27 +141,15 @@ function saveConfig(): void {
@input="v$.gotenberg_host.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.pdf.papersize')"
:help-text="$t('settings.pdf.papersize_hint')"
:error="
v$.gotenberg_papersize.$error &&
v$.gotenberg_papersize.$errors[0]?.$message
"
required
>
<BaseInput
v-model.trim="form.gotenberg_papersize"
:content-loading="isFetchingInitialData"
:invalid="v$.gotenberg_papersize.$error"
type="text"
name="gotenberg_papersize"
@input="v$.gotenberg_papersize.$touch()"
/>
</BaseInputGroup>
</BaseInputGrid>
<AdminPdfPageSetup
v-model="form"
class="mt-6"
:is-fetching-initial-data="isFetchingInitialData"
:errors="pageErrors"
/>
<div class="flex my-10">
<BaseButton
:disabled="isSaving"

View File

@@ -0,0 +1,171 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { PdfPageSetup } from '@/scripts/api/services/pdf.service'
const { t } = useI18n()
/**
* Page geometry, shared by both drivers.
*
* Paper size used to be Gotenberg-only and stored as a single "210mm 297mm"
* string, so dompdf had no paper size at all and switching drivers lost it.
* These fields are rendered identically for either driver and applied to both.
*/
const form = defineModel<PdfPageSetup>({ required: true })
defineProps<{
isFetchingInitialData?: boolean
errors?: Record<string, string | false | undefined>
}>()
// Convenience only. Storage is always a pair of CSS lengths, because Gotenberg
// has no concept of a named size and dompdf's named table cannot express
// everything Gotenberg accepts.
const PRESETS = [
{ label: 'A3', width: '297mm', height: '420mm' },
{ label: 'A4', width: '210mm', height: '297mm' },
{ label: 'A5', width: '148mm', height: '210mm' },
{ label: 'Letter', width: '8.5in', height: '11in' },
{ label: 'Legal', width: '8.5in', height: '14in' },
]
const CUSTOM = 'Custom'
const presetOptions = [...PRESETS.map((p) => p.label), CUSTOM]
const selectedPreset = computed({
get() {
const match = PRESETS.find(
(p) => p.width === form.value.pdf_paper_width && p.height === form.value.pdf_paper_height
)
return match?.label ?? CUSTOM
},
set(label: string) {
const preset = PRESETS.find((p) => p.label === label)
if (preset) {
form.value.pdf_paper_width = preset.width
form.value.pdf_paper_height = preset.height
}
},
})
const isCustom = computed(() => selectedPreset.value === CUSTOM)
const orientations = computed(() => [
{ label: t('settings.pdf.portrait'), value: 'portrait' },
{ label: t('settings.pdf.landscape'), value: 'landscape' },
])
</script>
<template>
<BaseInputGrid>
<BaseInputGroup :label="$t('settings.pdf.paper_size')">
<BaseMultiselect
v-model="selectedPreset"
:content-loading="isFetchingInitialData"
:options="presetOptions"
:can-deselect="false"
/>
</BaseInputGroup>
<BaseInputGroup :label="$t('settings.pdf.orientation')">
<BaseMultiselect
v-model="form.pdf_orientation"
:content-loading="isFetchingInitialData"
:options="orientations"
label="label"
value-prop="value"
:can-deselect="false"
/>
</BaseInputGroup>
<BaseInputGroup
v-if="isCustom"
:label="$t('settings.pdf.paper_width')"
:help-text="$t('settings.pdf.length_hint')"
:error="errors?.pdf_paper_width"
>
<BaseInput
v-model.trim="form.pdf_paper_width"
:content-loading="isFetchingInitialData"
:invalid="!!errors?.pdf_paper_width"
type="text"
name="pdf_paper_width"
/>
</BaseInputGroup>
<BaseInputGroup
v-if="isCustom"
:label="$t('settings.pdf.paper_height')"
:help-text="$t('settings.pdf.length_hint')"
:error="errors?.pdf_paper_height"
>
<BaseInput
v-model.trim="form.pdf_paper_height"
:content-loading="isFetchingInitialData"
:invalid="!!errors?.pdf_paper_height"
type="text"
name="pdf_paper_height"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.pdf.margin_top')"
:help-text="$t('settings.pdf.length_hint')"
:error="errors?.pdf_margin_top"
>
<BaseInput
v-model.trim="form.pdf_margin_top"
:content-loading="isFetchingInitialData"
:invalid="!!errors?.pdf_margin_top"
type="text"
name="pdf_margin_top"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.pdf.margin_bottom')"
:help-text="$t('settings.pdf.length_hint')"
:error="errors?.pdf_margin_bottom"
>
<BaseInput
v-model.trim="form.pdf_margin_bottom"
:content-loading="isFetchingInitialData"
:invalid="!!errors?.pdf_margin_bottom"
type="text"
name="pdf_margin_bottom"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.pdf.margin_left')"
:help-text="$t('settings.pdf.length_hint')"
:error="errors?.pdf_margin_left"
>
<BaseInput
v-model.trim="form.pdf_margin_left"
:content-loading="isFetchingInitialData"
:invalid="!!errors?.pdf_margin_left"
type="text"
name="pdf_margin_left"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.pdf.margin_right')"
:help-text="$t('settings.pdf.length_hint')"
:error="errors?.pdf_margin_right"
>
<BaseInput
v-model.trim="form.pdf_margin_right"
:content-loading="isFetchingInitialData"
:invalid="!!errors?.pdf_margin_right"
type="text"
name="pdf_margin_right"
/>
</BaseInputGroup>
</BaseInputGrid>
</template>

View File

@@ -0,0 +1,68 @@
import { helpers } from '@vuelidate/validators'
import type { PdfPageSetup } from '@/scripts/api/services/pdf.service'
/**
* Shared bits of the page-setup form, so the dompdf and Gotenberg components
* validate and seed the same fields the same way rather than drifting apart.
*/
export const PAGE_SETUP_KEYS = [
'pdf_paper_width',
'pdf_paper_height',
'pdf_orientation',
'pdf_margin_top',
'pdf_margin_right',
'pdf_margin_bottom',
'pdf_margin_left',
] as const
/** Mirrors App\Rules\CssLength, so a bad value is caught before the round trip. */
const CSS_LENGTH = /^\d+(\.\d+)?(pt|px|pc|mm|cm|in)$/
export function cssLength(t: (key: string) => string) {
return {
cssLength: helpers.withMessage(t('validation.invalid_length'), (value: string) =>
!helpers.req(value) ? true : CSS_LENGTH.test(String(value).trim())
),
}
}
/** Matches the defaults in config/pdf.php, including dompdf's own 1.2cm margin. */
export function pageSetupDefaults(): PdfPageSetup {
return {
pdf_paper_width: '210mm',
pdf_paper_height: '297mm',
pdf_orientation: 'portrait',
pdf_margin_top: '1.2cm',
pdf_margin_right: '1.2cm',
pdf_margin_bottom: '1.2cm',
pdf_margin_left: '1.2cm',
}
}
/** Pulls the page-setup keys out of the API payload, skipping anything absent. */
export function pageSetupFrom(configData: Record<string, unknown>): Partial<PdfPageSetup> {
const setup: Record<string, string> = {}
for (const key of PAGE_SETUP_KEYS) {
if (typeof configData[key] === 'string' && configData[key]) {
setup[key] = configData[key] as string
}
}
return setup as Partial<PdfPageSetup>
}
/** Flattens Vuelidate state into the shape AdminPdfPageSetup renders. */
export function pageSetupErrors(
v$: Record<string, { $error?: boolean; $errors?: { $message: unknown }[] }>
): Record<string, string | false> {
const errors: Record<string, string | false> = {}
for (const key of PAGE_SETUP_KEYS) {
const field = v$[key]
errors[key] = field?.$error ? String(field.$errors?.[0]?.$message ?? '') : false
}
return errors
}