diff --git a/app/Facades/Pdf.php b/app/Facades/Pdf.php index bb96850f..46091f76 100644 --- a/app/Facades/Pdf.php +++ b/app/Facades/Pdf.php @@ -5,7 +5,7 @@ namespace App\Facades; use Illuminate\Support\Facades\Facade; /** - * @method static \App\Support\Pdf\ResponseStream loadView(string $template) + * @method static \App\Support\Pdf\ResponseStream loadView(string $template, array $metadata = []) */ class Pdf extends Facade { diff --git a/app/Http/Controllers/Admin/Settings/PDFConfigurationController.php b/app/Http/Controllers/Admin/Settings/PDFConfigurationController.php index cfe96c59..acc151d0 100644 --- a/app/Http/Controllers/Admin/Settings/PDFConfigurationController.php +++ b/app/Http/Controllers/Admin/Settings/PDFConfigurationController.php @@ -60,7 +60,7 @@ class PDFConfigurationController extends Controller $this->authorize('manage pdf config'); $pdfSettings = Setting::getSettings(array_merge( - ['pdf_driver', 'gotenberg_host'], + ['pdf_driver', 'gotenberg_host', 'gotenberg_pdfa'], self::PAGE_SETTINGS, self::PAGE_BOOLEANS, )); @@ -68,6 +68,7 @@ class PDFConfigurationController extends Controller $config = [ 'pdf_driver' => $pdfSettings['pdf_driver'] ?? config('pdf.driver'), 'gotenberg_host' => $pdfSettings['gotenberg_host'] ?? config('pdf.connections.gotenberg.host'), + 'gotenberg_pdfa' => $pdfSettings['gotenberg_pdfa'] ?? config('pdf.connections.gotenberg.pdfa') ?? '', ]; // Page geometry applies to whichever driver is selected, so it is always @@ -134,6 +135,7 @@ class PDFConfigurationController extends Controller if ($driver === 'gotenberg') { $settings['gotenberg_host'] = $request->get('gotenberg_host'); + $settings['gotenberg_pdfa'] = $request->get('gotenberg_pdfa') ?? ''; } return $settings; diff --git a/app/Http/Requests/PDFConfigurationRequest.php b/app/Http/Requests/PDFConfigurationRequest.php index ba7f0152..98dd4926 100644 --- a/app/Http/Requests/PDFConfigurationRequest.php +++ b/app/Http/Requests/PDFConfigurationRequest.php @@ -10,6 +10,9 @@ use Illuminate\Validation\Rule; class PDFConfigurationRequest extends FormRequest { + /** Formats the Gotenberg image can actually produce, verified against gotenberg:8. */ + public const PDFA_FORMATS = ['PDF/A-1b', 'PDF/A-2b', 'PDF/A-3b']; + /** * Determine if the user is authorized to make this request. */ @@ -67,6 +70,10 @@ class PDFConfigurationRequest extends FormRequest 'url', Rule::when(! $isDeclaredHost, [new PublicHttpUrl]), ], + // A fixed list rather than a free string: the SDK forwards whatever + // it is given, so an unsupported value would only fail later as an + // HTTP error from the Gotenberg service. + 'gotenberg_pdfa' => ['nullable', Rule::in(self::PDFA_FORMATS)], ]; } } diff --git a/app/Providers/AppConfigProvider.php b/app/Providers/AppConfigProvider.php index 2ad0ce5a..a04576c9 100644 --- a/app/Providers/AppConfigProvider.php +++ b/app/Providers/AppConfigProvider.php @@ -57,15 +57,23 @@ class AppConfigProvider extends ServiceProvider ]; $pdfSettings = Setting::getSettings(array_merge( - ['pdf_driver', 'gotenberg_host', 'pdf_page_numbers'], + ['pdf_driver', 'gotenberg_host', 'gotenberg_pdfa', 'pdf_page_numbers'], array_keys($pageSettings), )); if (! empty($pdfSettings['pdf_driver'])) { Config::set('pdf.driver', $pdfSettings['pdf_driver']); - if ($pdfSettings['pdf_driver'] === 'gotenberg' && ! empty($pdfSettings['gotenberg_host'])) { - Config::set('pdf.connections.gotenberg.host', $pdfSettings['gotenberg_host']); + if ($pdfSettings['pdf_driver'] === 'gotenberg') { + if (! empty($pdfSettings['gotenberg_host'])) { + Config::set('pdf.connections.gotenberg.host', $pdfSettings['gotenberg_host']); + } + + // Empty is a real choice here -- it means an ordinary PDF -- + // so an explicitly stored blank must override an env default. + if (isset($pdfSettings['gotenberg_pdfa'])) { + Config::set('pdf.connections.gotenberg.pdfa', $pdfSettings['gotenberg_pdfa'] ?: null); + } } } diff --git a/app/Services/Document/EstimateService.php b/app/Services/Document/EstimateService.php index f7fb5d7d..2a9cc9d9 100644 --- a/app/Services/Document/EstimateService.php +++ b/app/Services/Document/EstimateService.php @@ -13,6 +13,7 @@ use App\Models\Estimate; use App\Models\ExchangeRateLog; use App\Models\Invoice; use App\Services\Mail\CompanyMailConfigService; +use App\Support\Pdf\PdfMetadata; use App\Support\Pdf\PdfTemplateUtils; use Carbon\Carbon; use Illuminate\Http\Request; @@ -201,7 +202,11 @@ class EstimateService return view($templatePath); } - return Pdf::loadView($templatePath); + return Pdf::loadView($templatePath, PdfMetadata::forDocument( + __('pdf_estimate_label'), + $estimate->estimate_number, + $company, + )); } public function clone(Estimate $estimate): Estimate diff --git a/app/Services/Document/InvoiceService.php b/app/Services/Document/InvoiceService.php index d5313037..a302d3f9 100644 --- a/app/Services/Document/InvoiceService.php +++ b/app/Services/Document/InvoiceService.php @@ -13,6 +13,7 @@ use App\Models\Estimate; use App\Models\ExchangeRateLog; use App\Models\Invoice; use App\Services\Mail\CompanyMailConfigService; +use App\Support\Pdf\PdfMetadata; use App\Support\Pdf\PdfTemplateUtils; use Carbon\Carbon; use Illuminate\Http\Request; @@ -265,7 +266,11 @@ class InvoiceService return view($templatePath); } - return Pdf::loadView($templatePath); + return Pdf::loadView($templatePath, PdfMetadata::forDocument( + __('pdf_invoice_label'), + $invoice->invoice_number, + $company, + )); } public function clone(Invoice $invoice): Invoice diff --git a/app/Services/Document/PaymentService.php b/app/Services/Document/PaymentService.php index 5cfe2799..543f6be2 100644 --- a/app/Services/Document/PaymentService.php +++ b/app/Services/Document/PaymentService.php @@ -11,6 +11,7 @@ use App\Models\ExchangeRateLog; use App\Models\Invoice; use App\Models\Payment; use App\Services\Mail\CompanyMailConfigService; +use App\Support\Pdf\PdfMetadata; use App\Support\Pdf\PdfTemplateUtils; use Carbon\Carbon; use Illuminate\Http\Request; @@ -188,7 +189,11 @@ class PaymentService return view($templatePath); } - return Pdf::loadView($templatePath); + return Pdf::loadView($templatePath, PdfMetadata::forDocument( + __('pdf_payment_label'), + $payment->payment_number, + $company, + )); } public function generateFromTransaction($transaction): Payment diff --git a/app/Support/Pdf/DompdfDriver.php b/app/Support/Pdf/DompdfDriver.php index 6bc176bd..25f8deef 100644 --- a/app/Support/Pdf/DompdfDriver.php +++ b/app/Support/Pdf/DompdfDriver.php @@ -15,13 +15,20 @@ use Illuminate\Support\Facades\App; */ class DompdfDriver implements PdfDriver { - public function loadView(string $template): ResponseStream + public function loadView(string $template, array $metadata = []): ResponseStream { $page = PdfPageSetup::fromConfig(); + $html = $this->withPageMargins(view($template)->render(), $page); + $html = $this->withDocumentTitle($html, $metadata['Title'] ?? null); + $pdf = $this->wrapper(); $pdf->setPaper($page->dompdfPaper(), $page->orientation); - $pdf->loadHTML($this->withPageMargins(view($template)->render(), $page)); + $pdf->loadHTML($html); + + if ($metadata !== []) { + $pdf->addInfo($metadata); + } return new DompdfResponse($pdf); } @@ -44,6 +51,39 @@ class DompdfDriver implements PdfDriver return $count ? $injected : $style.$html; } + /** + * dompdf reads the document Title from the element during render(), + * which happens after any addInfo() call, so metadata set through the API is + * silently overwritten by whatever the template happened to put there. The + * only way to make the two drivers agree on the title is to write it into + * the markup. + */ + private function withDocumentTitle(string $html, ?string $title): string + { + if ($title === null || $title === '') { + return $html; + } + + $escaped = htmlspecialchars($title, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + + $replaced = preg_replace( + '#<title\b[^>]*>.*?#is', + "{$escaped}", + $html, + 1, + $count + ); + + if ($count) { + return $replaced; + } + + // No to replace, so add one. dompdf only looks inside <head>. + $injected = preg_replace('/(<head\b[^>]*>)/i', "$1<title>{$escaped}", $html, 1, $count); + + return $count ? $injected : $html; + } + protected function wrapper(): PDF { return App::make('dompdf.wrapper'); diff --git a/app/Support/Pdf/GotenbergPdfDriver.php b/app/Support/Pdf/GotenbergPdfDriver.php index ab080d90..90bb91d3 100644 --- a/app/Support/Pdf/GotenbergPdfDriver.php +++ b/app/Support/Pdf/GotenbergPdfDriver.php @@ -11,9 +11,9 @@ use Psr\Http\Message\RequestInterface; class GotenbergPdfDriver implements PdfDriver { - public function loadView(string $template): ResponseStream + public function loadView(string $template, array $metadata = []): ResponseStream { - return new GotenbergPdfResponse(Gotenberg::send($this->buildRequest($template))); + return new GotenbergPdfResponse(Gotenberg::send($this->buildRequest($template, $metadata))); } /** @@ -23,7 +23,7 @@ class GotenbergPdfDriver implements PdfDriver * below this line used to be inlined into loadView(), which meant the only * way to check that an option was set was to run a Gotenberg service. */ - public function buildRequest(string $template): RequestInterface + public function buildRequest(string $template, array $metadata = []): RequestInterface { $page = PdfPageSetup::fromConfig(); [$width, $height] = $page->gotenbergPaper(); @@ -66,6 +66,19 @@ class GotenbergPdfDriver implements PdfDriver $chromium->landscape(); } + // Archival conformance, converted by LibreOffice inside the Gotenberg + // image. PDF/A-3 is what the EU e-invoicing formats ask for. The value is + // passed through unvalidated by the SDK, so an unsupported one surfaces + // as an HTTP error from the service; the setting is a fixed list for + // that reason. + if ($pdfa = config('pdf.connections.gotenberg.pdfa')) { + $chromium->pdfa($pdfa); + } + + if ($metadata !== []) { + $chromium->metadata($metadata); + } + // Must be attached before html(), which is terminal: it returns the built // request rather than the builder. if ($header = $this->companion($template, '_header')) { diff --git a/app/Support/Pdf/PdfDriver.php b/app/Support/Pdf/PdfDriver.php index 3dbe06c7..e999b353 100644 --- a/app/Support/Pdf/PdfDriver.php +++ b/app/Support/Pdf/PdfDriver.php @@ -4,5 +4,11 @@ namespace App\Support\Pdf; interface PdfDriver { - public function loadView(string $template): ResponseStream; + /** + * @param array $metadata Document properties written into + * the file: Title, Author, Subject, + * Keywords, Creator. Both drivers + * accept the same key names. + */ + public function loadView(string $template, array $metadata = []): ResponseStream; } diff --git a/app/Support/Pdf/PdfMetadata.php b/app/Support/Pdf/PdfMetadata.php new file mode 100644 index 00000000..a2aaae1c --- /dev/null +++ b/app/Support/Pdf/PdfMetadata.php @@ -0,0 +1,33 @@ + + */ + public static function forDocument(string $subject, ?string $number, ?Company $company): array + { + $title = trim($subject.' '.($number ?? '')); + + return array_filter([ + 'Title' => $title, + 'Subject' => $subject, + 'Author' => $company?->name, + 'Creator' => config('app.name', 'InvoiceShelf'), + ], fn ($value) => is_string($value) && $value !== ''); + } +} diff --git a/app/Support/Pdf/PdfService.php b/app/Support/Pdf/PdfService.php index 6548ab43..fc4baf57 100644 --- a/app/Support/Pdf/PdfService.php +++ b/app/Support/Pdf/PdfService.php @@ -4,10 +4,10 @@ namespace App\Support\Pdf; class PdfService { - public static function loadView(string $template): ResponseStream + public static function loadView(string $template, array $metadata = []): ResponseStream { $driver = config('pdf.driver'); - return PdfDriverFactory::create($driver)->loadView($template); + return PdfDriverFactory::create($driver)->loadView($template, $metadata); } } diff --git a/config/pdf.php b/config/pdf.php index 23bdd038..31308154 100644 --- a/config/pdf.php +++ b/config/pdf.php @@ -64,6 +64,14 @@ return [ 'gotenberg' => [ 'host' => env('GOTENBERG_HOST', 'http://pdf:3000'), + /* + * Archival conformance, converted by LibreOffice inside the Gotenberg + * image. Empty means an ordinary PDF. PDF/A-3 is what the EU + * e-invoicing formats ask for. Gotenberg only: dompdf cannot produce + * PDF/A. + */ + 'pdfa' => env('GOTENBERG_PDFA'), + /* * Gotenberg usually runs as a sidecar on a private network, which the * SSRF guard rejects. Name that one host here to exempt it — e.g. diff --git a/lang/en.json b/lang/en.json index d7cf8a84..07e390fd 100644 --- a/lang/en.json +++ b/lang/en.json @@ -1068,7 +1068,10 @@ "margin_left": "Left Margin", "length_hint": "A number and a unit, e.g. \"210mm\". Accepts: pt, px, pc, mm, cm, in.", "page_numbers": "Page Numbers", - "page_numbers_hint": "Repeat the page number at the foot of every page. Needs a bottom margin to sit in." + "page_numbers_hint": "Repeat the page number at the foot of every page. Needs a bottom margin to sit in.", + "pdfa": "Archival Format (PDF/A)", + "pdfa_hint": "Long-term archival conformance. PDF/A-3 is what EU e-invoicing formats expect. Leave off for an ordinary PDF.", + "pdfa_off": "Off" }, "company_info": { "company_info": "Company info", diff --git a/resources/scripts/api/services/pdf.service.ts b/resources/scripts/api/services/pdf.service.ts index da231b8b..9394c27c 100644 --- a/resources/scripts/api/services/pdf.service.ts +++ b/resources/scripts/api/services/pdf.service.ts @@ -30,6 +30,8 @@ export interface DomPdfConfig extends PdfPageSetup { export interface GotenbergConfig extends PdfPageSetup { pdf_driver: string gotenberg_host: string + /** '' for an ordinary PDF, or one of the PDF/A conformance levels. */ + gotenberg_pdfa: string } export type PdfConfig = DomPdfConfig | GotenbergConfig diff --git a/resources/scripts/features/admin/components/settings/AdminPdfGotenbergDriver.vue b/resources/scripts/features/admin/components/settings/AdminPdfGotenbergDriver.vue index 49f740a6..f96c9305 100644 --- a/resources/scripts/features/admin/components/settings/AdminPdfGotenbergDriver.vue +++ b/resources/scripts/features/admin/components/settings/AdminPdfGotenbergDriver.vue @@ -37,9 +37,20 @@ const { t } = useI18n() const form = reactive({ 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 @@ -89,6 +100,10 @@ onMounted(() => { 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)) }) @@ -141,6 +156,20 @@ function saveConfig(): void { @input="v$.gotenberg_host.$touch()" /> + + + + 'gotenberg', + 'gotenberg_host' => 'https://pdf.example.com', + 'gotenberg_pdfa' => 'PDF/A-9z', + 'pdf_paper_width' => '210mm', + 'pdf_paper_height' => '297mm', + 'pdf_orientation' => 'portrait', + ])->assertStatus(422)->assertJsonValidationErrors('gotenberg_pdfa'); +}); + +test('the archival format round trips, and off is a real choice', function () { + postJson('/api/v1/pdf/config', [ + 'pdf_driver' => 'gotenberg', + 'gotenberg_host' => 'https://pdf.example.com', + 'gotenberg_pdfa' => 'PDF/A-3b', + 'pdf_paper_width' => '210mm', + 'pdf_paper_height' => '297mm', + 'pdf_orientation' => 'portrait', + ])->assertOk(); + + getJson('/api/v1/pdf/config')->assertOk()->assertJson(['gotenberg_pdfa' => 'PDF/A-3b']); + + postJson('/api/v1/pdf/config', [ + 'pdf_driver' => 'gotenberg', + 'gotenberg_host' => 'https://pdf.example.com', + 'gotenberg_pdfa' => '', + 'pdf_paper_width' => '210mm', + 'pdf_paper_height' => '297mm', + 'pdf_orientation' => 'portrait', + ])->assertOk(); + + getJson('/api/v1/pdf/config')->assertOk()->assertJson(['gotenberg_pdfa' => '']); +}); diff --git a/tests/Unit/PdfMetadataTest.php b/tests/Unit/PdfMetadataTest.php new file mode 100644 index 00000000..3c6f5f7c --- /dev/null +++ b/tests/Unit/PdfMetadataTest.php @@ -0,0 +1,89 @@ + 'ACME Corp']); + + expect(PdfMetadata::forDocument('Invoice', 'INV-000042', $company))->toBe([ + 'Title' => 'Invoice INV-000042', + 'Subject' => 'Invoice', + 'Author' => 'ACME Corp', + 'Creator' => config('app.name', 'InvoiceShelf'), + ]); +}); + +test('missing pieces are left out rather than written as empty strings', function () { + $metadata = PdfMetadata::forDocument('Invoice', null, null); + + expect($metadata)->not->toHaveKey('Author') + ->and($metadata['Title'])->toBe('Invoice'); +}); + +test('the gotenberg request carries the metadata', function () { + config(['pdf.connections.gotenberg.host' => 'http://gotenberg.example.com:3000']); + + $body = (string) (new GotenbergPdfDriver)->buildRequest( + 'app.pdf.partials.fonts', + ['Title' => 'Invoice INV-000042', 'Author' => 'ACME Corp'] + )->getBody(); + + expect($body)->toContain('metadata') + ->and($body)->toContain('Invoice INV-000042') + ->and($body)->toContain('ACME Corp'); +}); + +/** + * dompdf reads Title from the element during render(), after any + * addInfo() call, so metadata set through the API alone is silently overwritten + * by whatever the template happened to put there -- and the two drivers end up + * disagreeing about what the file is called. + */ +test('dompdf writes the metadata title into the markup so it is not overwritten', function () { + $method = new ReflectionMethod(DompdfDriver::class, 'withDocumentTitle'); + + $result = $method->invoke( + new DompdfDriver, + '<html><head><title>whatever the template said', + 'Invoice INV-000042' + ); + + expect($result)->toContain('Invoice INV-000042') + ->and($result)->not->toContain('whatever the template said'); +}); + +test('a document with no title element gets one', function () { + $method = new ReflectionMethod(DompdfDriver::class, 'withDocumentTitle'); + + $result = $method->invoke(new DompdfDriver, '', 'Invoice 42'); + + expect($result)->toContain('Invoice 42'); +}); + +test('a title is escaped rather than injected as markup', function () { + $method = new ReflectionMethod(DompdfDriver::class, 'withDocumentTitle'); + + $result = $method->invoke( + new DompdfDriver, + 'x', + 'Invoice ' + ); + + expect($result)->not->toContain('