From c2df7c0c23eeb69acebc126262f175c9bdf2bf28 Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Fri, 31 Jul 2026 06:55:52 +0200 Subject: [PATCH] #208: VS-NfD marking in the Gotenberg per-page header and footer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A classified page's PDF export carries its marking as a job option; the renderer hands it to Gotenberg's Chromium header/footer templates, so it repeats on every page — bold centered in the running header and next to the existing page numbers in the footer. Unclassified pages send exactly the pre-#208 forms (unchanged PDF, asserted by the fidelity smoke and a lastMarking=null check). New real-Gotenberg fidelity test asserts the marking appears twice on EVERY page of a multi-page render while the document-level header keeps working. Co-Authored-By: Claude Fable 5 (1M context) --- apps/api/prisma/schema.prisma | 3 +- .../conversion-worker.service.ts | 4 ++ .../import-export/export.service.db.test.ts | 33 ++++++++++- apps/api/src/import-export/export.service.ts | 7 +++ .../src/import-export/gotenberg.renderer.ts | 59 +++++++++++++++++-- .../src/import-export/pdf.fidelity.test.ts | 39 ++++++++++-- docs/vs-nfd/20-massnahmenplan.md | 2 +- 7 files changed, 136 insertions(+), 11 deletions(-) diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 2762fbc..e7003a1 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -786,7 +786,8 @@ model ConversionJob { /// other job kind, whose payload the general retention (#233) prunes. expiresAt DateTime? @map("expires_at") /// Kind-specific job options (issue #117): a vault import carries - /// `{parentPageId, labelIds, frontmatterMode}`. Null for other kinds. + /// `{parentPageId, labelIds, frontmatterMode}`; a PDF export of a + /// classified page carries `{marking}` (issue #208). Null otherwise. options Json? createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") diff --git a/apps/api/src/import-export/conversion-worker.service.ts b/apps/api/src/import-export/conversion-worker.service.ts index ed7372e..82e537a 100644 --- a/apps/api/src/import-export/conversion-worker.service.ts +++ b/apps/api/src/import-export/conversion-worker.service.ts @@ -161,9 +161,13 @@ export class ConversionWorker implements OnModuleInit, OnModuleDestroy { .build(job); expiresAt = new Date(Date.now() + DATA_EXPORT_TTL_MS); } else if (job.targetFormat === 'pdf') { + // A classified page's export carries its marking as a job option + // (issue #208) — Gotenberg repeats it in header/footer of every page. + const marking = (job.options as { marking?: string } | null)?.marking ?? null; output = { bytes: await this.renderer.renderHtmlToPdf( Buffer.from(conversionInputOf(job)).toString('utf8'), + { marking }, ), mimeType: 'application/pdf', }; diff --git a/apps/api/src/import-export/export.service.db.test.ts b/apps/api/src/import-export/export.service.db.test.ts index 513c82b..a29e00e 100644 --- a/apps/api/src/import-export/export.service.db.test.ts +++ b/apps/api/src/import-export/export.service.db.test.ts @@ -42,9 +42,11 @@ class RecordingConverter extends PandocConverter { class RecordingRenderer extends GotenbergRenderer { lastHtml = ''; + lastMarking: string | null = null; failWith: RenderError | null = null; - renderHtmlToPdf(html: string): Promise { + renderHtmlToPdf(html: string, options?: { marking?: string | null }): Promise { this.lastHtml = html; + this.lastMarking = options?.marking ?? null; if (this.failWith) return Promise.reject(this.failWith); return Promise.resolve(Buffer.from('%PDF-1.7 fake')); } @@ -377,6 +379,35 @@ describe.skipIf(!hasTestDb)('export (e2e, issue #65)', () => { .expect(200); expect(result.headers['content-type']).toContain('application/pdf'); expect((result.body as Buffer).toString('utf8')).toContain('%PDF'); + // An unclassified page renders without any marking option (issue #208). + expect(renderer.lastMarking).toBeNull(); + }); + + it('hands the VS-NfD marking of a classified page to the renderer (issue #208)', async () => { + const slug = await seedPage( + personalPondId, + 'Classified Pdf', + '# Classified Pdf\n\nbody', + '

Classified body.

', + ); + const page = await prisma.page.findFirstOrThrow({ + where: { pondId: personalPondId, slug }, + }); + await prisma.page.update({ where: { id: page.id }, data: { classification: 'VS_NFD' } }); + + const enqueued = await api() + .post(`/api/v1/pages/${page.id}/export`) + .set('Cookie', ownerCookie) + .send({ format: 'pdf' }) + .expect(201); + await worker.drain(); + + expect(renderer.lastMarking).toBe('VS – NUR FÜR DEN DIENSTGEBRAUCH'); + const done = await api() + .get(`/api/v1/jobs/${enqueued.body.id}`) + .set('Cookie', ownerCookie) + .expect(200); + expect(done.body.status).toBe('succeeded'); }); it('inlines active section-style plugin CSS into the PDF html (#75)', async () => { diff --git a/apps/api/src/import-export/export.service.ts b/apps/api/src/import-export/export.service.ts index eb52662..e4b7340 100644 --- a/apps/api/src/import-export/export.service.ts +++ b/apps/api/src/import-export/export.service.ts @@ -7,6 +7,8 @@ import { ExportFormat, PondFonts, fontSlug, + PageClassification, + classificationMarking, pondSettingsSchema, } from '@dorfteich/shared'; import { User } from '@prisma/client'; @@ -232,6 +234,10 @@ export class ExportService { sectionStyleCss: await this.plugins.sectionStyleCssForPond(page.pondId), }); + // The VS-NfD marking (issue #208, ADR 0022) travels as a job option so + // the worker can hand it to Gotenberg's per-page header/footer templates + // — an unclassified page carries none and renders exactly as before. + const marking = classificationMarking(page.classification.toLowerCase() as PageClassification); const job = await this.jobs.enqueue({ ownerId: user.id, kind: 'export_pdf', @@ -239,6 +245,7 @@ export class ExportService { to: 'pdf', input: Buffer.from(html, 'utf8'), standalone: true, + ...(marking ? { options: { marking } } : {}), }); this.logger.info( { jobId: job.id, pageId, format: 'pdf', userId: user.id }, diff --git a/apps/api/src/import-export/gotenberg.renderer.ts b/apps/api/src/import-export/gotenberg.renderer.ts index f06d199..ed2e8a5 100644 --- a/apps/api/src/import-export/gotenberg.renderer.ts +++ b/apps/api/src/import-export/gotenberg.renderer.ts @@ -27,6 +27,40 @@ const FOOTER_HTML = ' / ' + ''; +function escapeHtml(value: string): string { + return value.replace(/&/g, '&').replace(//g, '>'); +} + +/** Per-page running header carrying the VS-NfD marking (issue #208, + * ADR 0022) — rendered by Gotenberg's Chromium header template on EVERY + * page, so a printed/filed PDF stays marked even as single sheets. */ +function markingHeaderHtml(marking: string): string { + return ( + '
' + + escapeHtml(marking) + + '
' + ); +} + +/** Footer variant with the marking next to the existing page numbers. */ +function markingFooterHtml(marking: string): string { + return ( + '
' + + `${escapeHtml(marking)} · ` + + ' / ' + + '
' + ); +} + +/** Options for a render; `marking` = the classification wording to repeat + * in header and footer of every page, `null`/absent = no marking and an + * output byte-identical in layout to the pre-#208 renderer. */ +export interface RenderPdfOptions { + marking?: string | null; +} + /** Per ADR 0009: a single render may run for at most 60 s. */ const RENDER_TIMEOUT_MS = 60_000; @@ -38,7 +72,7 @@ const RENDER_TIMEOUT_MS = 60_000; */ export abstract class GotenbergRenderer { /** Render a standalone HTML document (fonts/images already inlined) to PDF. */ - abstract renderHtmlToPdf(html: string): Promise; + abstract renderHtmlToPdf(html: string, options?: RenderPdfOptions): Promise; abstract reachable(): Promise; } @@ -63,16 +97,33 @@ export class GotenbergHttpRenderer extends GotenbergRenderer { } } - async renderHtmlToPdf(html: string): Promise { + async renderHtmlToPdf(html: string, options: RenderPdfOptions = {}): Promise { + const marking = options.marking ?? null; const form = new FormData(); // Gotenberg's Chromium route requires the main document to be `index.html`. form.append('files', new Blob([html], { type: 'text/html' }), 'index.html'); - form.append('files', new Blob([FOOTER_HTML], { type: 'text/html' }), 'footer.html'); + // A marked page (issue #208) gets the classification as a per-page + // running header AND next to the page numbers in the footer; without a + // marking the forms are exactly the pre-#208 ones (unchanged output). + if (marking) { + form.append( + 'files', + new Blob([markingHeaderHtml(marking)], { type: 'text/html' }), + 'header.html', + ); + form.append( + 'files', + new Blob([markingFooterHtml(marking)], { type: 'text/html' }), + 'footer.html', + ); + } else { + form.append('files', new Blob([FOOTER_HTML], { type: 'text/html' }), 'footer.html'); + } // Page geometry: A4 with room at the bottom for the page-number footer. The // document's own `@page`/print CSS controls the rest of the layout. form.append('paperWidth', '8.27'); form.append('paperHeight', '11.7'); - form.append('marginTop', '0.6'); + form.append('marginTop', marking ? '0.8' : '0.6'); form.append('marginBottom', '0.8'); form.append('marginLeft', '0.7'); form.append('marginRight', '0.7'); diff --git a/apps/api/src/import-export/pdf.fidelity.test.ts b/apps/api/src/import-export/pdf.fidelity.test.ts index aec39ee..0a346fe 100644 --- a/apps/api/src/import-export/pdf.fidelity.test.ts +++ b/apps/api/src/import-export/pdf.fidelity.test.ts @@ -1,4 +1,4 @@ -import { DEFAULT_FONTS, PondFonts } from '@dorfteich/shared'; +import { DEFAULT_FONTS, PondFonts, classificationMarking } from '@dorfteich/shared'; import { PDFParse } from 'pdf-parse'; import { beforeAll, describe, expect, it, TestContext } from 'vitest'; @@ -22,12 +22,12 @@ const renderer = new GotenbergHttpRenderer({ env: { GOTENBERG_URL } } as unknown let reachable = false; -/** Extract the concatenated text and page count from PDF bytes. */ -async function readPdf(pdf: Buffer): Promise<{ text: string; pages: number }> { +/** Extract the concatenated text, per-page texts and page count from PDF bytes. */ +async function readPdf(pdf: Buffer): Promise<{ text: string; pages: number; pageTexts: string[] }> { const parser = new PDFParse({ data: new Uint8Array(pdf) }); try { const result = await parser.getText(); - return { text: result.text, pages: result.total }; + return { text: result.text, pages: result.total, pageTexts: result.pages.map((p) => p.text) }; } finally { await parser.destroy(); } @@ -69,5 +69,36 @@ describe('PDF export smoke (real Gotenberg, issue #69)', () => { // regression would blow well past that. expect(pages).toBeGreaterThanOrEqual(2); expect(pages).toBeLessThanOrEqual(3); + // An unmarked render carries no classification anywhere (issue #208: + // unclassified pages produce an unchanged PDF). + expect(text).not.toContain('DIENSTGEBRAUCH'); + }); + + it('repeats the VS-NfD marking in header and footer of EVERY page (issue #208)', async (ctx: TestContext) => { + if (!reachable) ctx.skip(); + const marking = classificationMarking('vs_nfd')!; + const html = buildPdfHtml({ + title: 'Marked Fidelity Report', + pondName: 'Fidelity Pond', + bodyHtml: + '

First page of classified content.

' + + '
' + + '

Second page of classified content.

', + fonts: DEFAULT_FONTS as PondFonts, + fontFaceCss: '', + }); + + const pdf = await renderer.renderHtmlToPdf(html, { marking }); + const { pages, pageTexts } = await readPdf(pdf); + expect(pages).toBeGreaterThanOrEqual(2); + for (const pageText of pageTexts) { + // Once from the running header, once from the footer next to the + // page numbers — on every single page. + const occurrences = pageText.split(marking).length - 1; + expect(occurrences).toBe(2); + } + // The document-level header keeps working alongside the marking. + expect(pageTexts[0]).toContain('Marked Fidelity Report'); + expect(pageTexts[0]).toContain('Fidelity Pond'); }); }); diff --git a/docs/vs-nfd/20-massnahmenplan.md b/docs/vs-nfd/20-massnahmenplan.md index 7b5991b..12648d0 100644 --- a/docs/vs-nfd/20-massnahmenplan.md +++ b/docs/vs-nfd/20-massnahmenplan.md @@ -54,7 +54,7 @@ _Meilenstein: `M26 — VS-NfD: classification metadata`_ - [ ] Durchreichen in alle Ausgabekanäle · 8–12 AT · #206–#212 - [x] Web-Ansicht (Kopf/Fuß) · 1 AT · #206 - [x] **Print-CSS** (`@media print`, Kopf/Fuß je Seite) — fehlt komplett · 1 AT · #207 - - [ ] PDF via gotenberg (`pdf-html.ts` Header/Footer-Template) · 1 AT · #208 + - [x] PDF via gotenberg (`pdf-html.ts` Header/Footer-Template) · 1 AT · #208 - [ ] DOCX/ODT via pandoc (Reference-Doc mit Kopf-/Fußzeile) · 2–3 AT · #209 - [ ] Markdown-ZIP (Frontmatter + Aufdruck) · 1 AT · #210 - [ ] Atom-Feeds, Public-API, Suchergebnisse, No-JS-Shell · 2–3 AT · #211 -- 2.45.2