#208: classification in the Gotenberg PDF header/footer #267
@ -786,7 +786,8 @@ model ConversionJob {
|
|||||||
/// other job kind, whose payload the general retention (#233) prunes.
|
/// other job kind, whose payload the general retention (#233) prunes.
|
||||||
expiresAt DateTime? @map("expires_at")
|
expiresAt DateTime? @map("expires_at")
|
||||||
/// Kind-specific job options (issue #117): a vault import carries
|
/// 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?
|
options Json?
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|||||||
@ -161,9 +161,13 @@ export class ConversionWorker implements OnModuleInit, OnModuleDestroy {
|
|||||||
.build(job);
|
.build(job);
|
||||||
expiresAt = new Date(Date.now() + DATA_EXPORT_TTL_MS);
|
expiresAt = new Date(Date.now() + DATA_EXPORT_TTL_MS);
|
||||||
} else if (job.targetFormat === 'pdf') {
|
} 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 = {
|
output = {
|
||||||
bytes: await this.renderer.renderHtmlToPdf(
|
bytes: await this.renderer.renderHtmlToPdf(
|
||||||
Buffer.from(conversionInputOf(job)).toString('utf8'),
|
Buffer.from(conversionInputOf(job)).toString('utf8'),
|
||||||
|
{ marking },
|
||||||
),
|
),
|
||||||
mimeType: 'application/pdf',
|
mimeType: 'application/pdf',
|
||||||
};
|
};
|
||||||
|
|||||||
@ -42,9 +42,11 @@ class RecordingConverter extends PandocConverter {
|
|||||||
|
|
||||||
class RecordingRenderer extends GotenbergRenderer {
|
class RecordingRenderer extends GotenbergRenderer {
|
||||||
lastHtml = '';
|
lastHtml = '';
|
||||||
|
lastMarking: string | null = null;
|
||||||
failWith: RenderError | null = null;
|
failWith: RenderError | null = null;
|
||||||
renderHtmlToPdf(html: string): Promise<Buffer> {
|
renderHtmlToPdf(html: string, options?: { marking?: string | null }): Promise<Buffer> {
|
||||||
this.lastHtml = html;
|
this.lastHtml = html;
|
||||||
|
this.lastMarking = options?.marking ?? null;
|
||||||
if (this.failWith) return Promise.reject(this.failWith);
|
if (this.failWith) return Promise.reject(this.failWith);
|
||||||
return Promise.resolve(Buffer.from('%PDF-1.7 fake'));
|
return Promise.resolve(Buffer.from('%PDF-1.7 fake'));
|
||||||
}
|
}
|
||||||
@ -377,6 +379,35 @@ describe.skipIf(!hasTestDb)('export (e2e, issue #65)', () => {
|
|||||||
.expect(200);
|
.expect(200);
|
||||||
expect(result.headers['content-type']).toContain('application/pdf');
|
expect(result.headers['content-type']).toContain('application/pdf');
|
||||||
expect((result.body as Buffer).toString('utf8')).toContain('%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 () => {
|
it('inlines active section-style plugin CSS into the PDF html (#75)', async () => {
|
||||||
|
|||||||
@ -7,6 +7,8 @@ import {
|
|||||||
ExportFormat,
|
ExportFormat,
|
||||||
PondFonts,
|
PondFonts,
|
||||||
fontSlug,
|
fontSlug,
|
||||||
|
PageClassification,
|
||||||
|
classificationMarking,
|
||||||
pondSettingsSchema,
|
pondSettingsSchema,
|
||||||
} from '@dorfteich/shared';
|
} from '@dorfteich/shared';
|
||||||
import { User } from '@prisma/client';
|
import { User } from '@prisma/client';
|
||||||
@ -232,6 +234,10 @@ export class ExportService {
|
|||||||
sectionStyleCss: await this.plugins.sectionStyleCssForPond(page.pondId),
|
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({
|
const job = await this.jobs.enqueue({
|
||||||
ownerId: user.id,
|
ownerId: user.id,
|
||||||
kind: 'export_pdf',
|
kind: 'export_pdf',
|
||||||
@ -239,6 +245,7 @@ export class ExportService {
|
|||||||
to: 'pdf',
|
to: 'pdf',
|
||||||
input: Buffer.from(html, 'utf8'),
|
input: Buffer.from(html, 'utf8'),
|
||||||
standalone: true,
|
standalone: true,
|
||||||
|
...(marking ? { options: { marking } } : {}),
|
||||||
});
|
});
|
||||||
this.logger.info(
|
this.logger.info(
|
||||||
{ jobId: job.id, pageId, format: 'pdf', userId: user.id },
|
{ jobId: job.id, pageId, format: 'pdf', userId: user.id },
|
||||||
|
|||||||
@ -27,6 +27,40 @@ const FOOTER_HTML =
|
|||||||
'<span class="pageNumber"></span> / <span class="totalPages"></span>' +
|
'<span class="pageNumber"></span> / <span class="totalPages"></span>' +
|
||||||
'</div></body></html>';
|
'</div></body></html>';
|
||||||
|
|
||||||
|
function escapeHtml(value: string): string {
|
||||||
|
return value.replace(/&/g, '&').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 (
|
||||||
|
'<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. */
|
/** Per ADR 0009: a single render may run for at most 60 s. */
|
||||||
const RENDER_TIMEOUT_MS = 60_000;
|
const RENDER_TIMEOUT_MS = 60_000;
|
||||||
|
|
||||||
@ -38,7 +72,7 @@ const RENDER_TIMEOUT_MS = 60_000;
|
|||||||
*/
|
*/
|
||||||
export abstract class GotenbergRenderer {
|
export abstract class GotenbergRenderer {
|
||||||
/** Render a standalone HTML document (fonts/images already inlined) to PDF. */
|
/** 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>;
|
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();
|
const form = new FormData();
|
||||||
// Gotenberg's Chromium route requires the main document to be `index.html`.
|
// 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([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
|
// 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.
|
// document's own `@page`/print CSS controls the rest of the layout.
|
||||||
form.append('paperWidth', '8.27');
|
form.append('paperWidth', '8.27');
|
||||||
form.append('paperHeight', '11.7');
|
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('marginBottom', '0.8');
|
||||||
form.append('marginLeft', '0.7');
|
form.append('marginLeft', '0.7');
|
||||||
form.append('marginRight', '0.7');
|
form.append('marginRight', '0.7');
|
||||||
|
|||||||
@ -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 { PDFParse } from 'pdf-parse';
|
||||||
import { beforeAll, describe, expect, it, TestContext } from 'vitest';
|
import { beforeAll, describe, expect, it, TestContext } from 'vitest';
|
||||||
|
|
||||||
@ -22,12 +22,12 @@ const renderer = new GotenbergHttpRenderer({ env: { GOTENBERG_URL } } as unknown
|
|||||||
|
|
||||||
let reachable = false;
|
let reachable = false;
|
||||||
|
|
||||||
/** Extract the concatenated text and page count from PDF bytes. */
|
/** Extract the concatenated text, per-page texts and page count from PDF bytes. */
|
||||||
async function readPdf(pdf: Buffer): Promise<{ text: string; pages: number }> {
|
async function readPdf(pdf: Buffer): Promise<{ text: string; pages: number; pageTexts: string[] }> {
|
||||||
const parser = new PDFParse({ data: new Uint8Array(pdf) });
|
const parser = new PDFParse({ data: new Uint8Array(pdf) });
|
||||||
try {
|
try {
|
||||||
const result = await parser.getText();
|
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 {
|
} finally {
|
||||||
await parser.destroy();
|
await parser.destroy();
|
||||||
}
|
}
|
||||||
@ -69,5 +69,36 @@ describe('PDF export smoke (real Gotenberg, issue #69)', () => {
|
|||||||
// regression would blow well past that.
|
// regression would blow well past that.
|
||||||
expect(pages).toBeGreaterThanOrEqual(2);
|
expect(pages).toBeGreaterThanOrEqual(2);
|
||||||
expect(pages).toBeLessThanOrEqual(3);
|
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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -54,7 +54,7 @@ _Meilenstein: `M26 — VS-NfD: classification metadata`_
|
|||||||
- [ ] Durchreichen in alle Ausgabekanäle · 8–12 AT · #206–#212
|
- [ ] Durchreichen in alle Ausgabekanäle · 8–12 AT · #206–#212
|
||||||
- [x] Web-Ansicht (Kopf/Fuß) · 1 AT · #206
|
- [x] Web-Ansicht (Kopf/Fuß) · 1 AT · #206
|
||||||
- [x] **Print-CSS** (`@media print`, Kopf/Fuß je Seite) — fehlt komplett · 1 AT · #207
|
- [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
|
- [ ] DOCX/ODT via pandoc (Reference-Doc mit Kopf-/Fußzeile) · 2–3 AT · #209
|
||||||
- [ ] Markdown-ZIP (Frontmatter + Aufdruck) · 1 AT · #210
|
- [ ] Markdown-ZIP (Frontmatter + Aufdruck) · 1 AT · #210
|
||||||
- [ ] Atom-Feeds, Public-API, Suchergebnisse, No-JS-Shell · 2–3 AT · #211
|
- [ ] Atom-Feeds, Public-API, Suchergebnisse, No-JS-Shell · 2–3 AT · #211
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user