From 8a68ef68e7254c1c5398a7e9e92078d4941cb8ea Mon Sep 17 00:00:00 2001 From: "Claude Opus 4.8" Date: Fri, 10 Jul 2026 12:11:12 +0200 Subject: [PATCH] Add PDF export via Gotenberg (#67) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server-side PDF export for reading/sharing (ADR 0009), rendered by a new internal Gotenberg (headless Chromium) sidecar. - Sidecar: `gotenberg/gotenberg:8` in the compose stack (internal, pinned, healthcheck); api `GOTENBERG_URL` env; a `renderer` readyz check at warning-level (mirrors the converter) so PDF export degrades gracefully when Gotenberg is down without failing readyz. - Export HTML: `buildPdfHtml` renders a self-contained document (no app chrome) — the page's content with images inlined as data URIs, the pond's fonts inlined as base64 `@font-face` + applied via CSS variables (ADR 0016), print CSS (A4, page-break rules, a title header), and page numbers from Gotenberg's footer. Plugin-block fallbacks are a marked TODO(#79) for M7. - Fonts in the api image: the api Dockerfile now bakes the font catalog in (`build-fonts.mjs` with FONTS_OUT) so the exporter can read a pond's chosen WOFF2 and inline them; a missing file falls back to the system stack. - Job flow: `POST /pages/:id/export {format: pdf}` builds the HTML (read permission checked by the guard) and enqueues an `export_pdf` job on the #62 queue with the HTML as input; the worker branches `to === 'pdf'` to the `GotenbergRenderer` (html → pdf) instead of pandoc, retrying an unreachable sidecar and failing a refused render (`renderer_unavailable`/`render_failed`, de+en). The client polls and downloads `GET /jobs/:id/result`. - Frontend: the page-menu PDF button is now a real export (PDF added to EXPORT_FORMATS; the disabled placeholder removed). - Tests: export.service.db PDF cases (HTML has title/font-variable/inlined image; renderer-down fails with `render_failed`); e2e PDF export self-skips without a Gotenberg sidecar (like the .docx case). Verified locally against real Gotenberg — a valid PDF with the pond font embedded. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1 --- apps/api/Dockerfile | 8 +- apps/api/src/health/readiness.service.ts | 20 +++ .../conversion-worker.service.ts | 38 ++++-- .../import-export/export.service.db.test.ts | 112 +++++++++++++++-- apps/api/src/import-export/export.service.ts | 98 ++++++++++++++- .../src/import-export/gotenberg.renderer.ts | 114 ++++++++++++++++++ .../src/import-export/import-export.module.ts | 3 + apps/api/src/import-export/pdf-html.ts | 80 ++++++++++++ apps/web/e2e/export.spec.ts | 24 ++++ apps/web/src/export/DocumentExportMenu.tsx | 10 +- apps/web/src/export/use-document-export.ts | 6 +- deploy/compose/docker-compose.yml | 18 +++ deploy/fonts/build-fonts.mjs | 4 +- packages/shared/i18n/de/errors.json | 2 + packages/shared/i18n/de/export.json | 1 - packages/shared/i18n/en/errors.json | 2 + packages/shared/i18n/en/export.json | 1 - packages/shared/src/conversion.ts | 10 +- packages/shared/src/env.ts | 13 ++ 19 files changed, 526 insertions(+), 38 deletions(-) create mode 100644 apps/api/src/import-export/gotenberg.renderer.ts create mode 100644 apps/api/src/import-export/pdf-html.ts diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index 0d6439f..90a37a7 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -7,13 +7,19 @@ RUN npm install -g pnpm@11 COPY pnpm-workspace.yaml pnpm-lock.yaml package.json tsconfig.base.json ./ COPY packages/shared ./packages/shared COPY apps/api ./apps/api +COPY deploy/fonts ./deploy/fonts RUN pnpm install --frozen-lockfile --filter @dorfteich/api... \ && pnpm --filter @dorfteich/shared build \ && pnpm --filter @dorfteich/api build \ + # Bake the self-hosted font catalog in so the PDF exporter can inline a + # pond's fonts as base64 (ADR 0016). Same download as the web image; fails + # the build if a family lacks license info. + && FONTS_OUT=/repo/fonts node deploy/fonts/build-fonts.mjs \ # Self-contained production bundle (prod deps only, incl. the prisma CLI # 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/dist /out/dist \ + && cp -r /repo/fonts /out/fonts FROM node:22.15-alpine ARG APP_VERSION=0.0.0-dev diff --git a/apps/api/src/health/readiness.service.ts b/apps/api/src/health/readiness.service.ts index 43136ac..eaf190b 100644 --- a/apps/api/src/health/readiness.service.ts +++ b/apps/api/src/health/readiness.service.ts @@ -40,6 +40,7 @@ export class ReadinessService { await this.databaseReachable(), await this.migrationsApplied(), await this.converterReachable(), + await this.rendererReachable(), ]; return { status: checks.some((c) => c.status === 'failed') ? 'unready' : 'ok', @@ -64,6 +65,25 @@ export class ReadinessService { } } + /** The Gotenberg PDF renderer (issue #67) — warning-level like the converter: + * PDF export degrades when it is down, but the instance stays ready. */ + private async rendererReachable(): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), CONVERTER_PROBE_TIMEOUT_MS); + try { + const response = await fetch(`${this.config.env.GOTENBERG_URL}/health`, { + signal: controller.signal, + }); + return response.ok + ? { name: 'renderer', status: 'ok' } + : { name: 'renderer', status: 'warn', detail: `gotenberg returned ${response.status}` }; + } catch (error) { + return { name: 'renderer', status: 'warn', detail: shortMessage(error) }; + } finally { + clearTimeout(timer); + } + } + private async databaseReachable(): Promise { try { await this.prisma.$queryRaw`SELECT 1`; diff --git a/apps/api/src/import-export/conversion-worker.service.ts b/apps/api/src/import-export/conversion-worker.service.ts index 97d4964..b8805b2 100644 --- a/apps/api/src/import-export/conversion-worker.service.ts +++ b/apps/api/src/import-export/conversion-worker.service.ts @@ -6,8 +6,9 @@ import { PinoLogger } from 'nestjs-pino'; import { AppConfig } from '../config/app-config.service'; import { PrismaService } from '../prisma/prisma.service'; +import { GotenbergRenderer, RenderError } from './gotenberg.renderer'; import { IMPORT_PROCESSOR, ImportProcessor, isImportKind } from './import.constants'; -import { ConversionError, PandocConverter } from './pandoc.converter'; +import { ConversionError, ConversionResult, PandocConverter } from './pandoc.converter'; /** How often the worker sweeps for pending jobs on its own — the safety net * that makes a queued conversion survive an API restart even if no new @@ -38,6 +39,7 @@ export class ConversionWorker implements OnModuleInit, OnModuleDestroy { constructor( private readonly prisma: PrismaService, private readonly converter: PandocConverter, + private readonly renderer: GotenbergRenderer, private readonly config: AppConfig, private readonly logger: PinoLogger, // Resolved lazily to break the construction cycle (the import service @@ -47,6 +49,16 @@ export class ConversionWorker implements OnModuleInit, OnModuleDestroy { this.logger.setContext(ConversionWorker.name); } + private async convert(job: ConversionJob): Promise<{ bytes: Buffer; mimeType: string }> { + const result: ConversionResult = await this.converter.convert({ + from: job.sourceFormat, + to: job.targetFormat, + input: Buffer.from(job.input), + standalone: job.standalone, + }); + return { bytes: result.output, mimeType: result.mimeType }; + } + onModuleInit(): void { if (this.config.env.NODE_ENV === 'test') return; // tests drive drain() directly this.timer = setInterval(() => void this.drain(), SWEEP_MS); @@ -115,19 +127,22 @@ export class ConversionWorker implements OnModuleInit, OnModuleDestroy { await this.moduleRef.get(IMPORT_PROCESSOR, { strict: false }).run(job); return; } - const result = await this.converter.convert({ - from: job.sourceFormat, - to: job.targetFormat, - input: Buffer.from(job.input), - standalone: job.standalone, - }); + // PDF export renders HTML through Gotenberg (#67); everything else is a + // pandoc byte→byte conversion (#62/#65). + const output = + job.targetFormat === 'pdf' + ? { + bytes: await this.renderer.renderHtmlToPdf(Buffer.from(job.input).toString('utf8')), + mimeType: 'application/pdf', + } + : await this.convert(job); await this.prisma.conversionJob.update({ where: { id: job.id }, data: { status: 'SUCCEEDED', // Prisma Bytes = Uint8Array; copy the Buffer in (#40). - result: new Uint8Array(result.output), - resultMimeType: result.mimeType, + result: new Uint8Array(output.bytes), + resultMimeType: output.mimeType, errorCode: null, }, }); @@ -141,8 +156,9 @@ export class ConversionWorker implements OnModuleInit, OnModuleDestroy { } private async recordFailure(job: ConversionJob, error: unknown): Promise { - const code = error instanceof ConversionError ? error.code : 'conversion_failed'; - const retryable = error instanceof ConversionError ? error.retryable : false; + const typed = error instanceof ConversionError || error instanceof RenderError ? error : null; + const code = typed?.code ?? 'conversion_failed'; + const retryable = typed?.retryable ?? false; // `attempts` was already incremented by the claim, so it reflects this try. if (retryable && job.attempts < MAX_ATTEMPTS) { await this.prisma.conversionJob.update({ 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 fac324c..35904e6 100644 --- a/apps/api/src/import-export/export.service.db.test.ts +++ b/apps/api/src/import-export/export.service.db.test.ts @@ -12,12 +12,13 @@ import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db'; import { UsersService } from '../users/users.service'; import { ConversionWorker } from './conversion-worker.service'; +import { GotenbergRenderer, RenderError } from './gotenberg.renderer'; import { ConversionRequest, ConversionResult, PandocConverter } from './pandoc.converter'; -/** Export (issue #65): pond ZIP of Markdown + per-page docx/odt job. The ZIP - * path needs no converter; the docx path uses an injected fake that records the - * Markdown it is handed, so the image-inlining / wikilink-flattening is checked - * without a live pandoc. */ +/** Export (issues #65/#67): pond ZIP of Markdown, per-page docx/odt (pandoc), + * and per-page PDF (Gotenberg). The office/PDF paths use injected fakes that + * record the input they are handed, so image-inlining / font-inlining is checked + * without a live sidecar. */ const PNG_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='; @@ -33,6 +34,19 @@ class RecordingConverter extends PandocConverter { } } +class RecordingRenderer extends GotenbergRenderer { + lastHtml = ''; + failWith: RenderError | null = null; + renderHtmlToPdf(html: string): Promise { + this.lastHtml = html; + if (this.failWith) return Promise.reject(this.failWith); + return Promise.resolve(Buffer.from('%PDF-1.7 fake')); + } + reachable(): Promise { + return Promise.resolve(true); + } +} + /** Filenames of every entry in a ZIP buffer. */ function zipEntries(buffer: Buffer): Record { return unzipSync(new Uint8Array(buffer)); @@ -44,6 +58,7 @@ describe.skipIf(!hasTestDb)('export (e2e, issue #65)', () => { let worker: ConversionWorker; let files: FilesService; let fake: RecordingConverter; + let renderer: RecordingRenderer; const suffix = uniqueSuffix(); const password = 'exportiere meine sachen 1'; @@ -64,7 +79,12 @@ describe.skipIf(!hasTestDb)('export (e2e, issue #65)', () => { /** Create a page with a crafted content-cache Markdown (the exporter reads the * cache, not the Yjs state), returning its slug. */ - async function seedPage(pondId: string, title: string, markdown: string): Promise { + async function seedPage( + pondId: string, + title: string, + markdown: string, + html = '', + ): Promise { const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-'); await prisma.page.create({ data: { @@ -74,7 +94,7 @@ describe.skipIf(!hasTestDb)('export (e2e, issue #65)', () => { ydocState: new Uint8Array(), sortKey: title, createdBy: ownerId, - contentCache: { create: { plainText: markdown, markdown, html: '', outline: [] } }, + contentCache: { create: { plainText: markdown, markdown, html, outline: [] } }, }, }); return slug; @@ -84,8 +104,13 @@ describe.skipIf(!hasTestDb)('export (e2e, issue #65)', () => { prisma = createTestPrisma(); await prisma.rateLimit.deleteMany({}); fake = new RecordingConverter(); + renderer = new RecordingRenderer(); app = await createTestApp((builder) => - builder.overrideProvider(PandocConverter).useValue(fake), + builder + .overrideProvider(PandocConverter) + .useValue(fake) + .overrideProvider(GotenbergRenderer) + .useValue(renderer), ); worker = app.get(ConversionWorker); files = app.get(FilesService); @@ -298,6 +323,79 @@ describe.skipIf(!hasTestDb)('export (e2e, issue #65)', () => { expect(result.text).toBe('OFFICE-BYTES'); }); + it('exports a page to PDF: content + image inlined, font CSS, via Gotenberg', async () => { + const image = await files.upload({ id: ownerId } as never, personalPondId, { + buffer: Buffer.from(PNG_BASE64, 'base64'), + size: 70, + originalname: 'pdf.png', + }); + const slug = await seedPage( + personalPondId, + 'Pdf Source', + '# Pdf Source\n\nbody', + `

A PDF body paragraph.

pic`, + ); + const page = await prisma.page.findFirstOrThrow({ + where: { pondId: personalPondId, slug }, + }); + + const enqueued = await api() + .post(`/api/v1/pages/${page.id}/export`) + .set('Cookie', ownerCookie) + .send({ format: 'pdf' }) + .expect(201); + expect(enqueued.body.kind).toBe('export_pdf'); + + await worker.drain(); + + // The HTML handed to Gotenberg carries the title, the pond's font stack as a + // CSS variable, and the image inlined as a data URI (no network needed). + expect(renderer.lastHtml).toContain('Pdf Source'); + expect(renderer.lastHtml).toContain("--font-body: 'Roboto'"); + expect(renderer.lastHtml).toContain('src="data:image/png;base64,'); + + const done = await api() + .get(`/api/v1/jobs/${enqueued.body.id}`) + .set('Cookie', ownerCookie) + .expect(200); + expect(done.body.status).toBe('succeeded'); + const result = await api() + .get(`/api/v1/jobs/${enqueued.body.id}/result`) + .set('Cookie', ownerCookie) + .buffer(true) + .parse((r, cb) => { + const chunks: Buffer[] = []; + r.on('data', (c: Buffer) => chunks.push(c)); + r.on('end', () => cb(null, Buffer.concat(chunks))); + }) + .expect(200); + expect(result.headers['content-type']).toContain('application/pdf'); + expect((result.body as Buffer).toString('utf8')).toContain('%PDF'); + }); + + it('fails a PDF export when the renderer is down', async () => { + renderer.failWith = new RenderError('render_failed', false, 'gotenberg exploded'); + const slug = await seedPage(personalPondId, 'Pdf Fails', 'x', '

x

'); + const page = await prisma.page.findFirstOrThrow({ + where: { pondId: personalPondId, slug }, + }); + + const enqueued = await api() + .post(`/api/v1/pages/${page.id}/export`) + .set('Cookie', ownerCookie) + .send({ format: 'pdf' }) + .expect(201); + await worker.drain(); + renderer.failWith = null; + + const done = await api() + .get(`/api/v1/jobs/${enqueued.body.id}`) + .set('Cookie', ownerCookie) + .expect(200); + expect(done.body.status).toBe('failed'); + expect(done.body.errorCode).toBe('render_failed'); + }); + it('streams a large pond export (500 pages) without buffering it all', async () => { const big = await prisma.pond.create({ data: { diff --git a/apps/api/src/import-export/export.service.ts b/apps/api/src/import-export/export.service.ts index a203618..380fcba 100644 --- a/apps/api/src/import-export/export.service.ts +++ b/apps/api/src/import-export/export.service.ts @@ -1,10 +1,20 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + import { Injectable, NotFoundException } from '@nestjs/common'; -import { ConversionJobView, ExportFormat } from '@dorfteich/shared'; +import { + ConversionJobView, + ExportFormat, + PondFonts, + fontSlug, + pondSettingsSchema, +} from '@dorfteich/shared'; import { User } from '@prisma/client'; import archiver from 'archiver'; import type { Response } from 'express'; import { PinoLogger } from 'nestjs-pino'; +import { AppConfig } from '../config/app-config.service'; import { FileStorageService } from '../files/file-storage.service'; import { PermissionService } from '../permissions/permission.service'; import { PrismaService } from '../prisma/prisma.service'; @@ -16,6 +26,7 @@ import { markdownForDocument, markdownForZip, } from './export-markdown'; +import { buildPdfHtml } from './pdf-html'; /** * Export (ADR 0009, issue #65): a whole pond as a ZIP of Markdown (one `.md` @@ -31,6 +42,7 @@ export class ExportService { private readonly permissions: PermissionService, private readonly storage: FileStorageService, private readonly jobs: ConversionJobService, + private readonly config: AppConfig, private readonly logger: PinoLogger, ) { this.logger.setContext(ExportService.name); @@ -136,6 +148,8 @@ export class ExportService { pageId: string, format: ExportFormat, ): Promise { + if (format === 'pdf') return this.enqueuePdfExport(user, pageId); + const page = await this.prisma.page.findFirst({ where: { id: pageId, deletedAt: null }, include: { contentCache: { select: { markdown: true } } }, @@ -161,6 +175,88 @@ export class ExportService { return this.jobs.viewOf(job); } + /** + * Enqueue a PDF export (issue #67). The self-contained export HTML — content + * with images inlined, the pond's fonts inlined as base64 `@font-face`, print + * CSS — is built here (permission already checked by the guard) and stored as + * the job input; the worker sends it to Gotenberg (`html → pdf`). Building it + * up front keeps the job a plain byte→byte render the worker can retry. + */ + private async enqueuePdfExport(user: User, pageId: string): Promise { + const page = await this.prisma.page.findFirst({ + where: { id: pageId, deletedAt: null }, + include: { + pond: { select: { name: true, settings: true } }, + contentCache: { select: { html: true } }, + }, + }); + if (!page) throw new NotFoundException(); + + const fonts = pondSettingsSchema.parse(page.pond.settings ?? {}).fonts; + const bodyHtml = await this.inlineHtmlImages(page.pondId, page.contentCache?.html ?? ''); + const html = buildPdfHtml({ + title: page.title, + pondName: page.pond.name, + bodyHtml, + fonts, + fontFaceCss: await this.fontFaceCss(fonts), + }); + + const job = await this.jobs.enqueue({ + ownerId: user.id, + kind: 'export_pdf', + from: 'html', + to: 'pdf', + input: Buffer.from(html, 'utf8'), + standalone: true, + }); + this.logger.info( + { jobId: job.id, pageId, format: 'pdf', userId: user.id }, + 'audit: page export enqueued', + ); + return this.jobs.viewOf(job); + } + + /** Replace each `` with an inlined `data:` URI so the + * PDF render (isolated from the api) needs no network to fetch media. */ + private async inlineHtmlImages(pondId: string, html: string): Promise { + const ids = [...html.matchAll(/data-file-id="([A-Za-z0-9-]+)"/g)].map((m) => m[1]!); + if (ids.length === 0) return html; + const dataUriById = await this.inlineImages(pondId, ids); + return html.replace(/data-file-id="([A-Za-z0-9-]+)"/g, (whole, id: string) => { + const uri = dataUriById.get(id); + return uri ? `src="${uri}" ${whole}` : whole; + }); + } + + /** Base64 `@font-face` rules for the pond's three fonts, read from the + * catalog baked into the image (ADR 0016). A font file that is absent (a + * native dev run without `FONTS_DIR` populated) is skipped — the render falls + * back to the system stack rather than failing. */ + private async fontFaceCss(fonts: PondFonts): Promise { + const slots = [fonts.heading, fonts.body, fonts.mono]; + // Dedup identical family+weight so a doc that repeats a font embeds it once. + const seen = new Set(); + const faces: string[] = []; + for (const slot of slots) { + const key = `${slot.family}:${slot.weight}`; + if (seen.has(key)) continue; + seen.add(key); + const slug = fontSlug(slot.family); + const file = join(this.config.env.FONTS_DIR, slug, `${slug}-${slot.weight}.woff2`); + try { + const bytes = await readFile(file); + faces.push( + `@font-face { font-family: '${slot.family}'; font-style: normal; font-weight: ${slot.weight};` + + ` src: url('data:font/woff2;base64,${bytes.toString('base64')}') format('woff2'); }`, + ); + } catch { + this.logger.warn({ font: key }, 'pdf export: catalog font file missing, using fallback'); + } + } + return faces.join('\n'); + } + /** Read each referenced attachment's bytes into a `data:` URI (a page has few * images, so holding them briefly is fine — unlike the streamed pond ZIP). */ private async inlineImages(pondId: string, ids: string[]): Promise> { diff --git a/apps/api/src/import-export/gotenberg.renderer.ts b/apps/api/src/import-export/gotenberg.renderer.ts new file mode 100644 index 0000000..f06d199 --- /dev/null +++ b/apps/api/src/import-export/gotenberg.renderer.ts @@ -0,0 +1,114 @@ +import { Injectable } from '@nestjs/common'; + +import { AppConfig } from '../config/app-config.service'; + +import { MAX_CONVERSION_OUTPUT_BYTES } from './pandoc.converter'; + +export type RenderErrorCode = 'renderer_unavailable' | 'render_failed'; + +/** A PDF render failure with a stable, localizable code. Like the converter, + * an unreachable sidecar is retryable; a render Gotenberg refuses is not. */ +export class RenderError extends Error { + constructor( + readonly code: RenderErrorCode, + readonly retryable: boolean, + message?: string, + ) { + super(message ?? code); + this.name = 'RenderError'; + } +} + +/** A single-page footer that prints "n / total" bottom-centre — Gotenberg's + * Chromium route substitutes the `pageNumber`/`totalPages` spans. */ +const FOOTER_HTML = + '
' + + ' / ' + + '
'; + +/** Per ADR 0009: a single render may run for at most 60 s. */ +const RENDER_TIMEOUT_MS = 60_000; + +/** + * Typed client for the Gotenberg PDF sidecar (ADR 0009, issue #67). Abstract so + * the export worker and its tests depend on the contract, not the HTTP + * transport — tests inject a fake, this real implementation is exercised against + * a running container in the integration test. + */ +export abstract class GotenbergRenderer { + /** Render a standalone HTML document (fonts/images already inlined) to PDF. */ + abstract renderHtmlToPdf(html: string): Promise; + abstract reachable(): Promise; +} + +@Injectable() +export class GotenbergHttpRenderer extends GotenbergRenderer { + protected timeoutMs = RENDER_TIMEOUT_MS; + + constructor(private readonly config: AppConfig) { + super(); + } + + private get baseUrl(): string { + return this.config.env.GOTENBERG_URL; + } + + async reachable(): Promise { + try { + const response = await fetch(`${this.baseUrl}/health`); + return response.ok; + } catch { + return false; + } + } + + async renderHtmlToPdf(html: string): Promise { + 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'); + // 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('marginBottom', '0.8'); + form.append('marginLeft', '0.7'); + form.append('marginRight', '0.7'); + form.append('printBackground', 'true'); + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.timeoutMs); + let response: Response; + try { + response = await fetch(`${this.baseUrl}/forms/chromium/convert/html`, { + method: 'POST', + body: form, + signal: controller.signal, + }); + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + throw new RenderError('renderer_unavailable', true, 'gotenberg timed out'); + } + throw new RenderError('renderer_unavailable', true, shortMessage(error)); + } finally { + clearTimeout(timer); + } + + if (!response.ok) { + const detail = (await response.text().catch(() => '')).slice(0, 300); + throw new RenderError('render_failed', false, detail || `gotenberg ${response.status}`); + } + const output = Buffer.from(await response.arrayBuffer()); + if (output.byteLength > MAX_CONVERSION_OUTPUT_BYTES) { + throw new RenderError('render_failed', false, 'PDF exceeds size limit'); + } + return output; + } +} + +function shortMessage(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + return message.split('\n')[0]?.slice(0, 200) ?? 'unknown error'; +} diff --git a/apps/api/src/import-export/import-export.module.ts b/apps/api/src/import-export/import-export.module.ts index 6299cdc..24768c4 100644 --- a/apps/api/src/import-export/import-export.module.ts +++ b/apps/api/src/import-export/import-export.module.ts @@ -7,6 +7,7 @@ import { ConversionJobService } from './conversion-job.service'; import { ConversionWorker } from './conversion-worker.service'; import { ExportController } from './export.controller'; import { ExportService } from './export.service'; +import { GotenbergHttpRenderer, GotenbergRenderer } from './gotenberg.renderer'; import { IMPORT_PROCESSOR } from './import.constants'; import { ImportController } from './import.controller'; import { ImportService } from './import.service'; @@ -33,6 +34,8 @@ import { PandocConverter, PandocServerConverter } from './pandoc.converter'; // Bind the abstract converter to the HTTP implementation; tests override // this provider with a fake so the queue mechanics need no live sidecar. { provide: PandocConverter, useClass: PandocServerConverter }, + // Same pattern for the Gotenberg PDF renderer (#67). + { provide: GotenbergRenderer, useClass: GotenbergHttpRenderer }, ], exports: [ConversionJobService, PandocConverter], }) diff --git a/apps/api/src/import-export/pdf-html.ts b/apps/api/src/import-export/pdf-html.ts new file mode 100644 index 0000000..92b0064 --- /dev/null +++ b/apps/api/src/import-export/pdf-html.ts @@ -0,0 +1,80 @@ +import { PondFonts, fontStack } from '@dorfteich/shared'; + +export interface PdfHtmlParams { + title: string; + pondName: string; + /** The page's cached body HTML with images already inlined as data URIs. */ + bodyHtml: string; + fonts: PondFonts; + /** Pre-built `@font-face` rules (base64 WOFF2) for the pond's fonts. */ + fontFaceCss: string; +} + +function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +/** + * Build the standalone HTML sent to Gotenberg for PDF export (ADR 0009/0016). + * No app chrome; the pond's fonts are applied via CSS variables (their + * `@font-face` rules are inlined as base64 so the render makes no network + * request), a title header sits above the content, and print CSS sets the page + * size and sensible break behaviour. Page numbers come from Gotenberg's footer. + * + * TODO(#79): once plugins land (M7), plugin blocks must render their declared + * static `fallback` here (ADR 0008) instead of whatever the content cache holds. + */ +export function buildPdfHtml(params: PdfHtmlParams): string { + const { fonts } = params; + return ` + + + +${escapeHtml(params.title)} + + + +
+

${escapeHtml(params.pondName)}

+

${escapeHtml(params.title)}

+
+
+${params.bodyHtml} +
+ + +`; +} diff --git a/apps/web/e2e/export.spec.ts b/apps/web/e2e/export.spec.ts index ae53c94..19f7756 100644 --- a/apps/web/e2e/export.spec.ts +++ b/apps/web/e2e/export.spec.ts @@ -58,3 +58,27 @@ test('exports a page to .docx from the page menu', async ({ browser }) => { await context.close(); }); + +test('exports a page to PDF from the page menu', async ({ browser }) => { + // PDF needs the Gotenberg sidecar reachable by the api (like the .docx case + // needs pandoc). CI's e2e stack has none, so this runs locally / on a stage + // with E2E_GOTENBERG set. + test.skip(!process.env.E2E_GOTENBERG, 'needs a reachable Gotenberg sidecar'); + const context = await contextForUser(browser, BASE_URL, 'fixture-user'); + const pond = await personalPond(context); + const created = await context.request.post(`/api/v1/ponds/${pond.id}/pages`, { + data: { title: `Pdf Export ${Date.now()}` }, + }); + const created_page = await created.json(); + const page = await context.newPage(); + await page.goto(`/p/${pond.slug}/${created_page.slug}`); + + // The third export button is PDF (docx, odt, pdf). + const [download] = await Promise.all([ + page.waitForEvent('download', { timeout: 30000 }), + page.locator('.editor-page__export button').nth(2).click(), + ]); + expect(download.suggestedFilename()).toBe(`${created_page.slug}.pdf`); + + await context.close(); +}); diff --git a/apps/web/src/export/DocumentExportMenu.tsx b/apps/web/src/export/DocumentExportMenu.tsx index 04f9a77..7d833fc 100644 --- a/apps/web/src/export/DocumentExportMenu.tsx +++ b/apps/web/src/export/DocumentExportMenu.tsx @@ -9,10 +9,9 @@ interface DocumentExportMenuProps { } /** - * Office-format export buttons for the page menu (issue #65): `.docx`/`.odt` - * run a conversion job and download the result; PDF is a disabled placeholder - * until Gotenberg (#67). Markdown copy/download live in the page menu already - * (#30). + * Document export buttons for the page menu (issues #65/#67): `.docx`/`.odt` + * (pandoc) and PDF (Gotenberg) each run a conversion job and download the + * result. Markdown copy/download live in the page menu already (#30). */ export function DocumentExportMenu({ pageId, slug }: DocumentExportMenuProps): React.JSX.Element { const { t } = useTranslation('export'); @@ -37,9 +36,6 @@ export function DocumentExportMenu({ pageId, slug }: DocumentExportMenuProps): R {label(format)} ))} - ); } diff --git a/apps/web/src/export/use-document-export.ts b/apps/web/src/export/use-document-export.ts index 3a4b121..941eb36 100644 --- a/apps/web/src/export/use-document-export.ts +++ b/apps/web/src/export/use-document-export.ts @@ -33,9 +33,9 @@ export interface UseDocumentExport { } /** - * Export a page to `.docx`/`.odt` (issue #65): enqueue the conversion job, poll - * it, and download the result when it succeeds. Markdown export stays a direct - * link (#30); PDF waits for Gotenberg (#67). + * Export a page to `.docx`/`.odt`/`.pdf` (issues #65/#67): enqueue the + * conversion job, poll it, and download the result when it succeeds. Markdown + * export stays a direct link (#30). */ export function useDocumentExport(): UseDocumentExport { const [status, setStatus] = useState>>({}); diff --git a/deploy/compose/docker-compose.yml b/deploy/compose/docker-compose.yml index 2982b1f..9a3f882 100644 --- a/deploy/compose/docker-compose.yml +++ b/deploy/compose/docker-compose.yml @@ -62,6 +62,8 @@ services: UPLOADS_DIR: /data/uploads # Internal pandoc-server sidecar for import/export (ADR 0009, issue #62). PANDOC_URL: http://pandoc:3030 + # Internal Gotenberg sidecar for PDF export (ADR 0009, issue #67). + GOTENBERG_URL: http://gotenberg:3000 ports: - '127.0.0.1:${API_PORT:-8101}:3000' networks: [frontend, internal] @@ -72,6 +74,8 @@ services: condition: service_healthy pandoc: condition: service_healthy + gotenberg: + condition: service_healthy <<: *logging collab: @@ -136,6 +140,20 @@ services: retries: 3 <<: *logging + # PDF export renderer (ADR 0009, issue #67): Gotenberg wraps headless Chromium + # on the internal network only — never exposed. Pinned image; the api reaches + # it at http://gotenberg:3000 and posts export HTML to its Chromium route. + gotenberg: + image: gotenberg/gotenberg:8 + restart: unless-stopped + networks: [internal] + healthcheck: + test: ['CMD-SHELL', 'curl -sf http://127.0.0.1:3000/health || exit 1'] + interval: 30s + timeout: 5s + retries: 3 + <<: *logging + networks: frontend: internal: diff --git a/deploy/fonts/build-fonts.mjs b/deploy/fonts/build-fonts.mjs index 4cc0580..11746c3 100644 --- a/deploy/fonts/build-fonts.mjs +++ b/deploy/fonts/build-fonts.mjs @@ -20,7 +20,9 @@ const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); const { FONT_CATALOG, fontSlug } = await import( join(ROOT, 'packages', 'shared', 'dist', 'index.mjs') ); -const OUT_DIR = join(ROOT, 'apps', 'web', 'public', 'fonts'); +// Default output is the web app's public/fonts (served by the web image); the +// api image build overrides FONTS_OUT to bake the same catalog in for PDF export. +const OUT_DIR = process.env.FONTS_OUT ?? join(ROOT, 'apps', 'web', 'public', 'fonts'); const GWFH = 'https://gwfh.mranftl.com/api/fonts'; const SUBSETS = 'latin,latin-ext'; diff --git a/packages/shared/i18n/de/errors.json b/packages/shared/i18n/de/errors.json index bb0fd2f..252de85 100644 --- a/packages/shared/i18n/de/errors.json +++ b/packages/shared/i18n/de/errors.json @@ -34,6 +34,8 @@ "converter_unavailable": "Der Dokument-Konverter ist derzeit nicht verfügbar. Bitte versuche es später erneut.", "converter_timeout": "Die Konvertierung hat zu lange gedauert und wurde abgebrochen.", "conversion_failed": "Dieses Dokument konnte nicht konvertiert werden.", + "renderer_unavailable": "Der PDF-Renderer ist derzeit nicht verfügbar. Bitte versuche es später erneut.", + "render_failed": "Diese Seite konnte nicht als PDF gerendert werden.", "import_unsupported_format": "Nur Word- (.docx) und OpenDocument-Dokumente (.odt) können importiert werden.", "network": "Der Server war nicht erreichbar.", "grant_exists": "Diese Berechtigung existiert bereits.", diff --git a/packages/shared/i18n/de/export.json b/packages/shared/i18n/de/export.json index 6aa4533..721385f 100644 --- a/packages/shared/i18n/de/export.json +++ b/packages/shared/i18n/de/export.json @@ -3,7 +3,6 @@ "docx": "Word (.docx)", "odt": "OpenDocument (.odt)", "pdf": "PDF", - "pdfSoon": "PDF-Export folgt in Kürze", "exporting": "Wird exportiert…", "failed": "Export fehlgeschlagen", "pond": { diff --git a/packages/shared/i18n/en/errors.json b/packages/shared/i18n/en/errors.json index 6f90499..5c99e40 100644 --- a/packages/shared/i18n/en/errors.json +++ b/packages/shared/i18n/en/errors.json @@ -34,6 +34,8 @@ "converter_unavailable": "The document converter is currently unavailable. Please try again later.", "converter_timeout": "The conversion took too long and was cancelled.", "conversion_failed": "This document could not be converted.", + "renderer_unavailable": "The PDF renderer is currently unavailable. Please try again later.", + "render_failed": "This page could not be rendered as a PDF.", "import_unsupported_format": "Only Word (.docx) and OpenDocument (.odt) documents can be imported.", "network": "The server could not be reached.", "grant_exists": "This grant already exists.", diff --git a/packages/shared/i18n/en/export.json b/packages/shared/i18n/en/export.json index a235fb0..4139664 100644 --- a/packages/shared/i18n/en/export.json +++ b/packages/shared/i18n/en/export.json @@ -3,7 +3,6 @@ "docx": "Word (.docx)", "odt": "OpenDocument (.odt)", "pdf": "PDF", - "pdfSoon": "PDF export is coming soon", "exporting": "Exporting…", "failed": "Export failed", "pond": { diff --git a/packages/shared/src/conversion.ts b/packages/shared/src/conversion.ts index eb63bff..ea7cd9e 100644 --- a/packages/shared/src/conversion.ts +++ b/packages/shared/src/conversion.ts @@ -31,11 +31,11 @@ export interface ConversionJobView { export const IMPORT_EXTENSIONS = ['docx', 'odt', 'md', 'markdown'] as const; export type ImportExtension = (typeof IMPORT_EXTENSIONS)[number]; -/** Office formats a single page exports to (issue #65) — each runs a - * `markdown → pandoc → file` conversion job whose result is downloaded from - * `GET /jobs/:id/result`. Markdown export is a separate direct download (#30); - * PDF is a placeholder until Gotenberg (#67). */ -export const EXPORT_FORMATS = ['docx', 'odt'] as const; +/** Formats a single page exports to via a conversion job whose result is + * downloaded from `GET /jobs/:id/result` (issues #65/#67). `docx`/`odt` run + * `markdown → pandoc`; `pdf` renders HTML through Gotenberg. Markdown export is + * a separate direct download (#30). */ +export const EXPORT_FORMATS = ['docx', 'odt', 'pdf'] as const; export type ExportFormat = (typeof EXPORT_FORMATS)[number]; export const pageExportInputSchema = z.object({ diff --git a/packages/shared/src/env.ts b/packages/shared/src/env.ts index da8c80c..d016b39 100644 --- a/packages/shared/src/env.ts +++ b/packages/shared/src/env.ts @@ -69,6 +69,19 @@ export const apiEnvSchema = z.object({ * readiness check is warning-level, so an unset sidecar never fails readyz). */ PANDOC_URL: z.string().url().default('http://pandoc:3030'), + /** + * Base URL of the internal Gotenberg sidecar for PDF export (ADR 0009, issue + * #67). Like the converter, its readiness check is warning-level, so an + * unreachable renderer degrades PDF export without failing readyz. The default + * matches the compose service name. + */ + GOTENBERG_URL: z.string().url().default('http://gotenberg:3000'), + /** + * Directory of the self-hosted font catalog (WOFF2, ADR 0016), baked into the + * api image so the PDF exporter can inline a pond's fonts as base64. Native + * dev/test runs point this at the web app's built `public/fonts`. + */ + FONTS_DIR: z.string().min(1).default('./fonts'), }); export type ApiEnv = z.infer;