Files
InvoiceShelf/resources/scripts/features/admin/components/settings/AdminPdfGotenbergDriver.vue
Darko Gjorgjijoski 773670c18f feat(pdf): archival PDF/A output and document properties (#732)
Generated files carried no document properties at all, so an archive of them
showed a column of blank titles and no author. Title, Subject, Author and
Creator are now written from the document number and company, on both drivers:
dompdf via addInfo(), Gotenberg via metadata().

dompdf needed more than the API call. It reads Title from the <title> element
during render(), which happens after addInfo(), so metadata set through the API
alone was silently overwritten by whatever the template put there and the two
drivers disagreed about what the file was called. The title is written into the
markup as well, escaped.

Also adds an archival format setting for Gotenberg: off, PDF/A-1b, -2b or -3b.
PDF/A-3 is what the EU e-invoicing formats expect. Verified against a stock
gotenberg:8 -- LibreOffice inside the image does the conversion and the output
carries the right pdfaid:part in its XMP -- so no extra components are needed.

A fixed list rather than free text, because the SDK forwards whatever it is
given and an unsupported value would surface only as an HTTP error from the
service at render time. Empty is a real choice meaning an ordinary PDF, so it
overrides an env default rather than falling through it.

Gotenberg only: dompdf cannot produce PDF/A.

Claude-Session: https://claude.ai/code/session_01QmECndmNZwzN65Zz9P87dF
2026-08-01 14:37:22 +02:00

203 lines
5.2 KiB
Vue

<script setup lang="ts">
import { computed, onMounted, reactive } from 'vue'
import { useI18n } from 'vue-i18n'
import { required, helpers } from '@vuelidate/validators'
import useVuelidate from '@vuelidate/core'
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<{
configData?: Record<string, unknown>
isSaving?: boolean
isFetchingInitialData?: boolean
drivers?: PdfDriver[]
}>(),
{
configData: () => ({}),
isSaving: false,
isFetchingInitialData: false,
drivers: () => [],
}
)
const emit = defineEmits<{
'submit-data': [config: GotenbergConfig]
'on-change-driver': [driver: string]
}>()
const { t } = useI18n()
const form = reactive<GotenbergConfig>({
pdf_driver: 'gotenberg',
gotenberg_host: '',
gotenberg_pdfa: '',
...pageSetupDefaults(),
})
// Only what the Gotenberg image can actually produce, checked against
// gotenberg:8. The SDK forwards the value unvalidated, so anything else would
// fail as an HTTP error at render time.
const pdfaFormats = computed(() => [
{ label: t('settings.pdf.pdfa_off'), value: '' },
{ label: 'PDF/A-1b', value: 'PDF/A-1b' },
{ label: 'PDF/A-2b', value: 'PDF/A-2b' },
{ label: 'PDF/A-3b', value: 'PDF/A-3b' },
])
function isValidServiceUrl(value: string): boolean {
if (!helpers.req(value)) {
return true
}
try {
const parsedUrl = new URL(value)
return (
(parsedUrl.protocol === 'http:' || parsedUrl.protocol === 'https:') &&
parsedUrl.hostname.length > 0
)
} catch {
return false
}
}
const rules = computed(() => ({
pdf_driver: {
required: helpers.withMessage(t('validation.required'), required),
},
gotenberg_host: {
required: helpers.withMessage(t('validation.required'), required),
validServiceUrl: helpers.withMessage(
t('validation.invalid_url'),
isValidServiceUrl
),
},
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
}
if (typeof props.configData.gotenberg_host === 'string') {
form.gotenberg_host = props.configData.gotenberg_host
}
if (typeof props.configData.gotenberg_pdfa === 'string') {
form.gotenberg_pdfa = props.configData.gotenberg_pdfa
}
Object.assign(form, pageSetupFrom(props.configData))
})
function onChangeDriver(): void {
v$.value.pdf_driver.$touch()
emit('on-change-driver', form.pdf_driver)
}
function saveConfig(): void {
v$.value.$touch()
if (v$.value.$invalid) {
return
}
emit('submit-data', { ...form })
}
</script>
<template>
<form @submit.prevent="saveConfig">
<BaseInputGrid>
<BaseInputGroup
:label="$t('settings.pdf.driver')"
:error="v$.pdf_driver.$error && v$.pdf_driver.$errors[0]?.$message"
required
>
<BaseMultiselect
v-model="form.pdf_driver"
:content-loading="isFetchingInitialData"
:options="drivers"
:can-deselect="false"
:invalid="v$.pdf_driver.$error"
@update:model-value="onChangeDriver"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.pdf.gotenberg_host')"
:error="
v$.gotenberg_host.$error && v$.gotenberg_host.$errors[0]?.$message
"
required
>
<BaseInput
v-model.trim="form.gotenberg_host"
:content-loading="isFetchingInitialData"
:invalid="v$.gotenberg_host.$error"
type="text"
name="gotenberg_host"
@input="v$.gotenberg_host.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.pdf.pdfa')"
:help-text="$t('settings.pdf.pdfa_hint')"
>
<BaseMultiselect
v-model="form.gotenberg_pdfa"
:content-loading="isFetchingInitialData"
:options="pdfaFormats"
label="label"
value-prop="value"
:can-deselect="false"
/>
</BaseInputGroup>
</BaseInputGrid>
<AdminPdfPageSetup
v-model="form"
class="mt-6"
:is-fetching-initial-data="isFetchingInitialData"
:errors="pageErrors"
supports-page-numbers
/>
<div class="flex my-10">
<BaseButton
:disabled="isSaving"
:content-loading="isFetchingInitialData"
:loading="isSaving"
type="submit"
variant="primary"
>
<template #left="slotProps">
<BaseIcon
v-if="!isSaving"
name="ArrowDownOnSquareIcon"
:class="slotProps.class"
/>
</template>
{{ $t('general.save') }}
</BaseButton>
</div>
</form>
</template>