#208: classification in the Gotenberg PDF header/footer #267

Merged
fable-5 merged 1 commits from issue-208-pdf-marking into main 2026-07-31 07:24:53 +02:00
7 changed files with 136 additions and 11 deletions
Showing only changes of commit c2df7c0c23 - Show all commits

View File

@ -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")

View File

@ -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',
};

View File

@ -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<Buffer> {
renderHtmlToPdf(html: string, options?: { marking?: string | null }): Promise<Buffer> {
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',
'<p>Classified body.</p>',
);
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 () => {

View File

@ -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 },

View File

@ -27,6 +27,40 @@ const FOOTER_HTML =
'<span class="pageNumber"></span> / <span class="totalPages"></span>' +
'</div></body></html>';
function escapeHtml(value: string): string {
return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
/** 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 (
'<html><head><style>body{margin:0;font:bold 9px system-ui;color:#000;width:100%;' +
'letter-spacing:0.08em;}div{text-align:center;}</style></head><body><div>' +
escapeHtml(marking) +
'</div></body></html>'
);
}
/** Footer variant with the marking next to the existing page numbers. */
function markingFooterHtml(marking: string): string {
return (
'<html><head><style>body{margin:0;font:9px system-ui;color:#64748b;width:100%;}' +
'div{text-align:center;}b{color:#000;letter-spacing:0.08em;}</style></head><body><div>' +
`<b>${escapeHtml(marking)}</b> · ` +
'<span class="pageNumber"></span> / <span class="totalPages"></span>' +
'</div></body></html>'
);
}
/** 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<Buffer>;
abstract renderHtmlToPdf(html: string, options?: RenderPdfOptions): Promise<Buffer>;
abstract reachable(): Promise<boolean>;
}
@ -63,16 +97,33 @@ export class GotenbergHttpRenderer extends GotenbergRenderer {
}
}
async renderHtmlToPdf(html: string): Promise<Buffer> {
async renderHtmlToPdf(html: string, options: RenderPdfOptions = {}): Promise<Buffer> {
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');

View File

@ -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:
'<p>First page of classified content.</p>' +
'<div style="page-break-before: always"></div>' +
'<p>Second page of classified content.</p>',
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');
});
});

View File

@ -54,7 +54,7 @@ _Meilenstein: `M26 — VS-NfD: classification metadata`_
- [ ] Durchreichen in alle Ausgabekanäle · 812 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) · 23 AT · #209
- [ ] Markdown-ZIP (Frontmatter + Aufdruck) · 1 AT · #210
- [ ] Atom-Feeds, Public-API, Suchergebnisse, No-JS-Shell · 23 AT · #211