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(
+ '#]*>.*?#is',
+ "{$escaped}",
+ $html,
+ 1,
+ $count
+ );
+
+ if ($count) {
+ return $replaced;
+ }
+
+ // No to replace, so add one. dompdf only looks inside .
+ $injected = preg_replace('/(]*>)/i', "$1{$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,
+ '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('