From 74a9e495e4ab15ee6033e103cb4b03c5d11cb24a Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Fri, 31 Jul 2026 07:09:47 +0200 Subject: [PATCH] #209: pandoc reference documents carry the VS-NfD marking for DOCX/ODT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reference-vs-nfd.docx/.odt ship as derived binaries: the pinned pandoc's default reference documents plus a header and footer with the marking — part of the document's page setup, so it repeats on every page in Word and LibreOffice and is not deletable body text. Source of truth is scripts/gen-classified-reference-docs.mjs (wording from shared classificationMarking(); maintenance documented in assets/README.md). The converter passes reference docs to pandoc-server via in-request files + reference-doc; the worker attaches them for marked docx/odt jobs (job option {marking}, as in #208). Unclassified exports pass nothing and are unchanged (pinned by fake-converter test). Fidelity suite asserts against real pandoc 3.6 that marked outputs carry the header/footer parts and unmarked ones do not; per-page repetition verified via LibreOffice 25.8 headless PDF (5/5 pages, 2 markings each, both formats). Word: quick manual look pending (sample files in the workspace), procedure documented in assets/README.md. Co-Authored-By: Claude Fable 5 (1M context) --- apps/api/Dockerfile | 1 + apps/api/assets/README.md | 45 +++++++ apps/api/assets/reference-vs-nfd.docx | Bin 0 -> 11898 bytes apps/api/assets/reference-vs-nfd.odt | Bin 0 -> 7374 bytes apps/api/prisma/schema.prisma | 4 +- .../scripts/gen-classified-reference-docs.mjs | 118 ++++++++++++++++++ .../conversion-worker.service.ts | 27 ++++ .../src/import-export/export.fidelity.test.ts | 68 ++++++++++ .../import-export/export.service.db.test.ts | 26 ++++ apps/api/src/import-export/export.service.ts | 5 + .../api/src/import-export/pandoc.converter.ts | 13 ++ docs/vs-nfd/20-massnahmenplan.md | 2 +- eslint.config.mjs | 7 +- 13 files changed, 312 insertions(+), 4 deletions(-) create mode 100644 apps/api/assets/README.md create mode 100644 apps/api/assets/reference-vs-nfd.docx create mode 100644 apps/api/assets/reference-vs-nfd.odt create mode 100644 apps/api/scripts/gen-classified-reference-docs.mjs diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index f8dfe32..5631b8c 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -21,6 +21,7 @@ RUN pnpm install --frozen-lockfile --filter @dorfteich/api... \ # needed for migrate-on-start) at /out. && pnpm --filter @dorfteich/api deploy --prod --legacy /out \ && cp -r apps/api/dist /out/dist \ + && cp -r apps/api/assets /out/assets \ && cp -r /repo/fonts /out/fonts FROM node:22.15.1-alpine diff --git a/apps/api/assets/README.md b/apps/api/assets/README.md new file mode 100644 index 0000000..4f99bc0 --- /dev/null +++ b/apps/api/assets/README.md @@ -0,0 +1,45 @@ +# Runtime assets + +## `reference-vs-nfd.docx` / `reference-vs-nfd.odt` (issue #209, ADR 0022) + +Pandoc reference documents for the DOCX/ODT export of a **classified** +page: their page setup defines a header and footer carrying the VS-NfD +marking, which pandoc copies into its output — so the marking repeats on +every page in Word and LibreOffice and is not deletable body text. +Unclassified exports pass no reference document and are unchanged. + +These are **derived binaries — never edit them by hand.** Source of truth +is `../scripts/gen-classified-reference-docs.mjs`: it takes the default +reference documents of the pinned sidecar (`pandoc/core:3.6`, the exact +image the stages run) and injects the header/footer, with the wording from +`classificationMarking()` in `@dorfteich/shared` (single source, ADR +0022). Regenerate — after a pandoc pin bump, a wording change, or a layout +tweak in the script — with Docker running: + +```sh +pnpm --filter @dorfteich/shared build # the script imports the wording +node apps/api/scripts/gen-classified-reference-docs.mjs +``` + +Commit script and binaries together. The fidelity suite +(`export.fidelity.test.ts`) asserts against the real pinned pandoc that a +marked export carries the header/footer parts and an unmarked one does +not. + +### Per-page verification in the office suites + +After regenerating, confirm the marking repeats on **every** page of a +multi-page export (not just structurally in the XML): + +1. Produce a marked multi-page export (any classified page with a few + screens of text, exported to `.docx` and `.odt`). +2. **LibreOffice** (scriptable): + `soffice --headless --convert-to pdf ` and check every PDF page + shows the marking twice (header + footer) — e.g. with `pypdf`. +3. **Word**: open the `.docx`, check header and footer on every page + (print preview). Word's AppleScript/sandbox makes this hard to script — + this step is a quick manual look. + +Last verified 2026-07-31 (pandoc 3.6 output): LibreOffice 25.8, both +formats, 5/5 pages with 2 markings each. Word: manual check pending — +sample files in the workspace under `doku/209-marked-sample.docx/.odt`. diff --git a/apps/api/assets/reference-vs-nfd.docx b/apps/api/assets/reference-vs-nfd.docx new file mode 100644 index 0000000000000000000000000000000000000000..40f48cab445dd49e6bc553f99aff4635d665bcc1 GIT binary patch literal 11898 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 QQlkKczTDVV%AcS92St|+BLDyZ literal 0 HcmV?d00001 diff --git a/apps/api/assets/reference-vs-nfd.odt b/apps/api/assets/reference-vs-nfd.odt new file mode 100644 index 0000000000000000000000000000000000000000..adca54da5abd48e4838901c487e131b57c982769 GIT binary patch literal 7374 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 literal 0 HcmV?d00001 diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index e7003a1..da2d37c 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -786,8 +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}`; a PDF export of a - /// classified page carries `{marking}` (issue #208). Null otherwise. + /// `{parentPageId, labelIds, frontmatterMode}`; a PDF/DOCX/ODT export of a + /// classified page carries `{marking}` (issues #208/#209). Null otherwise. options Json? createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") diff --git a/apps/api/scripts/gen-classified-reference-docs.mjs b/apps/api/scripts/gen-classified-reference-docs.mjs new file mode 100644 index 0000000..fc6296f --- /dev/null +++ b/apps/api/scripts/gen-classified-reference-docs.mjs @@ -0,0 +1,118 @@ +/** + * Regenerate the classified reference documents (issue #209, ADR 0022): + * `apps/api/assets/reference-vs-nfd.docx` / `.odt`. + * + * The DOCX/ODT export of a classified page passes these to pandoc via + * `--reference-doc`; pandoc copies the reference's page setup — including + * headers and footers — into its output, which is how the VS-NfD marking + * repeats on every page in Word and LibreOffice without being deletable + * body text. + * + * The binaries are DERIVED files: base = the default reference documents of + * the PINNED pandoc (`pandoc/core:3.6`, the exact sidecar the stages run), + * plus a header and footer carrying the marking. Never edit the binaries by + * hand — edit this script and re-run it (Docker required): + * + * node apps/api/scripts/gen-classified-reference-docs.mjs + * + * The marking wording comes from @dorfteich/shared (single source, ADR + * 0022); the shared package must be built (`pnpm --filter @dorfteich/shared + * build`). + */ +import { execFileSync } from 'node:child_process'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { classificationMarking } from '@dorfteich/shared'; +import { strToU8, strFromU8, unzipSync, zipSync } from 'fflate'; + +const PANDOC_IMAGE = 'pandoc/core:3.6'; +const MARKING = classificationMarking('vs_nfd'); +const outDir = join(dirname(fileURLToPath(import.meta.url)), '../assets'); + +function defaultReference(name) { + return execFileSync('docker', ['run', '--rm', PANDOC_IMAGE, '--print-default-data-file', name], { + maxBuffer: 64 * 1024 * 1024, + }); +} + +function escapeXml(value) { + return value.replace(/&/g, '&').replace(//g, '>'); +} + +/** DOCX: add word/header1.xml + word/footer1.xml, register them in the + * content types and document relationships, and reference them from the + * document's sectPr — Word repeats them on every page. */ +function patchDocx(bytes) { + const zip = unzipSync(new Uint8Array(bytes)); + const marking = escapeXml(MARKING); + + const partXml = (root) => + `\n` + + `` + + `` + + `${marking}` + + ``; + zip['word/header1.xml'] = strToU8(partXml('hdr')); + zip['word/footer1.xml'] = strToU8(partXml('ftr')); + + const types = strFromU8(zip['[Content_Types].xml']); + zip['[Content_Types].xml'] = strToU8( + types.replace( + '', + '' + + '' + + '', + ), + ); + + const rels = strFromU8(zip['word/_rels/document.xml.rels']); + zip['word/_rels/document.xml.rels'] = strToU8( + rels.replace( + '', + '' + + '' + + '', + ), + ); + + const doc = strFromU8(zip['word/document.xml']); + if (!doc.includes('')) throw new Error('reference.docx has no sectPr'); + zip['word/document.xml'] = strToU8( + doc.replace( + '', + '' + + '' + + '', + ), + ); + + return zipSync(zip); +} + +/** ODT: give the Standard master page a header with the marking and put the + * marking next to the existing page number in its footer — LibreOffice + * repeats master-page headers/footers on every page. */ +function patchOdt(bytes) { + const zip = unzipSync(new Uint8Array(bytes)); + const marking = escapeXml(MARKING); + const styles = strFromU8(zip['styles.xml']); + if (!styles.includes('')) throw new Error('reference.odt has no footer'); + const patched = styles + .replace( + '', + `${marking}`, + ) + .replace( + '\n ', + `\n ${marking} · `, + ); + zip['styles.xml'] = strToU8(patched); + return zipSync(zip); +} + +mkdirSync(outDir, { recursive: true }); +writeFileSync(join(outDir, 'reference-vs-nfd.docx'), patchDocx(defaultReference('reference.docx'))); +writeFileSync(join(outDir, 'reference-vs-nfd.odt'), patchOdt(defaultReference('reference.odt'))); +console.log(`generated reference-vs-nfd.docx/.odt in ${outDir} (marking: ${MARKING})`); diff --git a/apps/api/src/import-export/conversion-worker.service.ts b/apps/api/src/import-export/conversion-worker.service.ts index 82e537a..72989a9 100644 --- a/apps/api/src/import-export/conversion-worker.service.ts +++ b/apps/api/src/import-export/conversion-worker.service.ts @@ -1,3 +1,6 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; import { ModuleRef } from '@nestjs/core'; import { ConversionJob } from '@prisma/client'; @@ -66,10 +69,34 @@ export class ConversionWorker implements OnModuleInit, OnModuleDestroy { to: job.targetFormat, input: Buffer.from(conversionInputOf(job)), standalone: job.standalone, + referenceDoc: await this.classifiedReferenceDoc(job), }); return { bytes: result.output, mimeType: result.mimeType }; } + /** + * The classified reference document for a marked docx/odt export (issue + * #209, ADR 0022): pandoc copies its header/footer — which carry the + * VS-NfD marking — into the output, so the marking repeats on every page + * in Word/LibreOffice and is not deletable body text. Only present when + * the enqueue put a `marking` into the job options; the binaries ship in + * `apps/api/assets/` (see `scripts/gen-classified-reference-docs.mjs`). + */ + private async classifiedReferenceDoc( + job: ConversionJob, + ): Promise<{ name: string; bytes: Buffer } | undefined> { + const marked = Boolean((job.options as { marking?: string } | null)?.marking); + if (!marked || (job.targetFormat !== 'docx' && job.targetFormat !== 'odt')) return undefined; + const name = `reference-vs-nfd.${job.targetFormat}`; + const cached = this.referenceDocs.get(name); + if (cached) return { name, bytes: cached }; + const bytes = await readFile(join(__dirname, '../../assets', name)); + this.referenceDocs.set(name, bytes); + return { name, bytes }; + } + + private readonly referenceDocs = new Map(); + onModuleInit(): void { if (this.config.env.NODE_ENV === 'test') return; // tests drive drain() directly this.timer = setInterval(() => this.drainSafely(), SWEEP_MS); diff --git a/apps/api/src/import-export/export.fidelity.test.ts b/apps/api/src/import-export/export.fidelity.test.ts index 08858d7..29c8c3d 100644 --- a/apps/api/src/import-export/export.fidelity.test.ts +++ b/apps/api/src/import-export/export.fidelity.test.ts @@ -1,6 +1,8 @@ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; +import { classificationMarking } from '@dorfteich/shared'; +import { strFromU8, unzipSync } from 'fflate'; import { beforeAll, describe, expect, it, TestContext } from 'vitest'; import { AppConfig } from '../config/app-config.service'; @@ -8,6 +10,8 @@ import { AppConfig } from '../config/app-config.service'; import { markdownForDocument } from './export-markdown'; import { PandocServerConverter } from './pandoc.converter'; +const MARKING = classificationMarking('vs_nfd')!; + /** * Export fidelity regression (issue #69, ADR 0009): exports the committed * Markdown corpus to `.docx`/`.odt` through the real pinned pandoc and reads @@ -70,4 +74,68 @@ describe('export fidelity corpus (real pandoc, issue #69)', () => { }); } } + + // The classified reference documents (issue #209, ADR 0022): a marked + // export must carry the VS-NfD marking in the document's own header/footer + // definition (repeats per page in Word/LibreOffice, not deletable body + // text); an unmarked export must not. Asserted structurally against the + // real pinned pandoc; the body round-trip above stays untouched by the + // reference doc (headers are outside the content pandoc reads back). + it('a marked docx export carries the marking in header1.xml/footer1.xml; unmarked does not', async (ctx: TestContext) => { + if (!reachable) ctx.skip(); + const referenceDoc = { + name: 'reference-vs-nfd.docx', + bytes: readFileSync(join(process.cwd(), 'assets/reference-vs-nfd.docx')), + }; + const marked = await converter.convert({ + from: 'gfm', + to: 'docx', + input: Buffer.from('# Marked\n\nbody', 'utf8'), + standalone: true, + referenceDoc, + }); + const parts = unzipSync(new Uint8Array(marked.output)); + const header = strFromU8(parts['word/header1.xml']!); + const footer = strFromU8(parts['word/footer1.xml']!); + expect(header).toContain(MARKING); + expect(footer).toContain(MARKING); + expect(strFromU8(parts['word/document.xml']!)).toContain('headerReference'); + + const unmarked = await converter.convert({ + from: 'gfm', + to: 'docx', + input: Buffer.from('# Open\n\nbody', 'utf8'), + standalone: true, + }); + const openParts = unzipSync(new Uint8Array(unmarked.output)); + expect(openParts['word/header1.xml']).toBeUndefined(); + }); + + it('a marked odt export carries the marking in its master-page header/footer; unmarked does not', async (ctx: TestContext) => { + if (!reachable) ctx.skip(); + const referenceDoc = { + name: 'reference-vs-nfd.odt', + bytes: readFileSync(join(process.cwd(), 'assets/reference-vs-nfd.odt')), + }; + const marked = await converter.convert({ + from: 'gfm', + to: 'odt', + input: Buffer.from('# Marked\n\nbody', 'utf8'), + standalone: true, + referenceDoc, + }); + const styles = strFromU8(unzipSync(new Uint8Array(marked.output))['styles.xml']!); + expect(styles).toContain(''); + const occurrences = styles.split(MARKING).length - 1; + expect(occurrences).toBeGreaterThanOrEqual(2); // header + footer + + const unmarked = await converter.convert({ + from: 'gfm', + to: 'odt', + input: Buffer.from('# Open\n\nbody', 'utf8'), + standalone: true, + }); + const openStyles = strFromU8(unzipSync(new Uint8Array(unmarked.output))['styles.xml']!); + expect(openStyles).not.toContain(MARKING); + }); }); 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 a29e00e..c281d1c 100644 --- a/apps/api/src/import-export/export.service.db.test.ts +++ b/apps/api/src/import-export/export.service.db.test.ts @@ -31,8 +31,10 @@ const PNG_BASE64 = class RecordingConverter extends PandocConverter { lastInput = ''; + lastReferenceDoc: string | null = null; convert(request: ConversionRequest): Promise { this.lastInput = request.input.toString('utf8'); + this.lastReferenceDoc = request.referenceDoc?.name ?? null; return Promise.resolve({ output: Buffer.from('OFFICE-BYTES'), mimeType: 'application/x-test' }); } reachable(): Promise { @@ -329,6 +331,30 @@ describe.skipIf(!hasTestDb)('export (e2e, issue #65)', () => { .set('Cookie', ownerCookie) .expect(200); expect(result.text).toBe('OFFICE-BYTES'); + // An unclassified page converts without a reference doc (issue #209). + expect(fake.lastReferenceDoc).toBeNull(); + }); + + it('hands pandoc the classified reference doc for a marked page (issue #209)', async () => { + const slug = await seedPage(personalPondId, 'Classified Docx', '# Classified Docx\n\nbody'); + 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: 'docx' }) + .expect(201); + await worker.drain(); + + expect(fake.lastReferenceDoc).toBe('reference-vs-nfd.docx'); + const done = await api() + .get(`/api/v1/jobs/${enqueued.body.id}`) + .set('Cookie', ownerCookie) + .expect(200); + expect(done.body.status).toBe('succeeded'); }); it('exports a page to PDF: content + image inlined, font CSS, via Gotenberg', async () => { diff --git a/apps/api/src/import-export/export.service.ts b/apps/api/src/import-export/export.service.ts index e4b7340..129ba6f 100644 --- a/apps/api/src/import-export/export.service.ts +++ b/apps/api/src/import-export/export.service.ts @@ -186,6 +186,10 @@ export class ExportService { const dataUriById = await this.inlineImages(page.pondId, imageFileIds(markdown)); const document = markdownForDocument(markdown, dataUriById); + // A classified page's export records its marking as a job option (#209): + // the worker then hands pandoc the classified reference document whose + // header/footer carry the marking on every page in Word/LibreOffice. + const marking = classificationMarking(page.classification.toLowerCase() as PageClassification); const job = await this.jobs.enqueue({ ownerId: user.id, kind: `export_${format}`, @@ -193,6 +197,7 @@ export class ExportService { to: format, input: Buffer.from(document, 'utf8'), standalone: true, + ...(marking ? { options: { marking } } : {}), }); this.logger.info( { jobId: job.id, pageId, format, userId: user.id }, diff --git a/apps/api/src/import-export/pandoc.converter.ts b/apps/api/src/import-export/pandoc.converter.ts index ab5bc49..4162c71 100644 --- a/apps/api/src/import-export/pandoc.converter.ts +++ b/apps/api/src/import-export/pandoc.converter.ts @@ -17,6 +17,11 @@ export interface ConversionRequest { /** Line-wrapping of the writer's output. Import uses `none` so a paragraph * stays on one line (no soft breaks inside image alt text or links). */ wrap?: 'none' | 'auto' | 'preserve'; + /** Reference document for the docx/odt writers (issue #209, ADR 0022): + * pandoc copies its page setup — including the header/footer that carry + * the VS-NfD marking — into the output. Sent to pandoc-server as an + * in-request file plus the `reference-doc` option. */ + referenceDoc?: { name: string; bytes: Buffer }; } export interface ConversionResult { @@ -141,6 +146,14 @@ export class PandocServerConverter extends PandocConverter { // so these are only present when the import pipeline sets them. ...(request.embedResources ? { 'embed-resources': true } : {}), ...(request.wrap ? { wrap: request.wrap } : {}), + ...(request.referenceDoc + ? { + 'reference-doc': request.referenceDoc.name, + files: { + [request.referenceDoc.name]: request.referenceDoc.bytes.toString('base64'), + }, + } + : {}), }), signal: controller.signal, }); diff --git a/docs/vs-nfd/20-massnahmenplan.md b/docs/vs-nfd/20-massnahmenplan.md index 12648d0..e67bbbb 100644 --- a/docs/vs-nfd/20-massnahmenplan.md +++ b/docs/vs-nfd/20-massnahmenplan.md @@ -55,7 +55,7 @@ _Meilenstein: `M26 — VS-NfD: classification metadata`_ - [x] Web-Ansicht (Kopf/Fuß) · 1 AT · #206 - [x] **Print-CSS** (`@media print`, Kopf/Fuß je Seite) — fehlt komplett · 1 AT · #207 - [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 + - [x] 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 - [ ] Attachment-Download (Dateiname-Präfix + Begleitdatei) · 1–2 AT · #212 diff --git a/eslint.config.mjs b/eslint.config.mjs index 8968a3f..77c0a0f 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -24,7 +24,12 @@ export default tseslint.config( prettier, { // Plain-Node maintenance/build scripts (no TypeScript, no bundler). - files: ['scripts/**/*.mjs', 'deploy/**/*.mjs', 'packages/plugins/*/build.mjs'], + files: [ + 'scripts/**/*.mjs', + 'deploy/**/*.mjs', + 'packages/plugins/*/build.mjs', + 'apps/api/scripts/**/*.mjs', + ], languageOptions: { globals: { console: 'readonly',