Files
InvoiceShelf/resources/scripts/features/admin/components/settings/AdminPdfPageSetup.vue
Darko Gjorgjijoski 713e0bc2e8 feat(pdf): repeating page headers and footers, and page numbers (#729)
Takes over #690 by csoscd. The companion-view idea is theirs; this reworks it
onto the shared page setup and fills in the gaps that stopped it landing.

A `{template}_header` or `{template}_footer` view next to a template is rendered
alongside it and repeated by Chromium on every page. The suffix resolves through
the pdf_templates:: namespace too, so custom templates get it with no extra
wiring.

Two things had to change for that to be useful.

Companion views are now hidden from the template picker. getFormattedTemplates()
lists every .blade.php it finds, so an invoice1_footer would otherwise appear as
a separately selectable template with no preview image -- the feature would have
introduced that the moment anyone used it.

And it does something out of the box. #690 shipped no companion views, so both
of its margin settings were visible no-ops until someone hand-wrote a Blade file.
Instead there is a pdf_page_numbers setting, off by default, that supplies a
footer when a template has none. A template's own companion still wins, so
turning page numbers on cannot overwrite a designed footer.

The setting sits under Gotenberg because only Chromium can repeat a footer;
dompdf has no equivalent. Its value is still carried by the dompdf form so
saving from there cannot clear the choice -- the field is absent from that
payload, and the controller only writes it when present.

Margins come from the page setup rather than #690's separate header_margin and
footer_margin. Chromium draws header and footer inside the page margin, so the
existing margins are the space they occupy; two more settings for the same
distance would have been a second way to say the same thing.

Verified against a live gotenberg:8 on a two-page document: off produces no
footer, on produces "1/2" and "2/2" on the respective pages, and a companion
footer replaces both.

#690's own test is not carried over. It asserted nothing: a bare
View::shouldReceive('exists') is an allowance rather than an expectation,
andReturn(false) never entered the companion branch, and the call sat inside
try { } catch (Throwable) { }, so it passed with the feature deleted.

Claude-Session: https://claude.ai/code/session_01QmECndmNZwzN65Zz9P87dF
2026-08-01 12:52:47 +02:00

186 lines
5.4 KiB
Vue

<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>
/**
* Page numbers rely on Chromium repeating a footer template, which dompdf has
* no equivalent of, so the control only appears for Gotenberg. The value is
* still carried by both forms so saving from dompdf cannot clear it.
*/
supportsPageNumbers?: boolean
}>()
// 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>
<BaseInputGroup
v-if="supportsPageNumbers"
:label="$t('settings.pdf.page_numbers')"
:help-text="$t('settings.pdf.page_numbers_hint')"
>
<BaseSwitch v-model="form.pdf_page_numbers" class="flex" />
</BaseInputGroup>
</BaseInputGrid>
</template>