Add PDF export via Gotenberg (#67)
All checks were successful
CD / Build and push images (push) Successful in 4m3s
CI / Lint, typecheck, test (push) Successful in 3m5s
CI / Auth e2e pack (push) Successful in 4m7s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m17s
CD / Promote to Int (push) Successful in 12s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
This commit is contained in:
Claude Opus 4.8 2026-07-10 12:11:12 +02:00
parent f500198c5d
commit 8a68ef68e7
19 changed files with 526 additions and 38 deletions

View File

@ -7,13 +7,19 @@ RUN npm install -g pnpm@11
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json tsconfig.base.json ./ COPY pnpm-workspace.yaml pnpm-lock.yaml package.json tsconfig.base.json ./
COPY packages/shared ./packages/shared COPY packages/shared ./packages/shared
COPY apps/api ./apps/api COPY apps/api ./apps/api
COPY deploy/fonts ./deploy/fonts
RUN pnpm install --frozen-lockfile --filter @dorfteich/api... \ RUN pnpm install --frozen-lockfile --filter @dorfteich/api... \
&& pnpm --filter @dorfteich/shared build \ && pnpm --filter @dorfteich/shared build \
&& pnpm --filter @dorfteich/api 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 # Self-contained production bundle (prod deps only, incl. the prisma CLI
# needed for migrate-on-start) at /out. # needed for migrate-on-start) at /out.
&& pnpm --filter @dorfteich/api deploy --prod --legacy /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 FROM node:22.15-alpine
ARG APP_VERSION=0.0.0-dev ARG APP_VERSION=0.0.0-dev

View File

@ -40,6 +40,7 @@ export class ReadinessService {
await this.databaseReachable(), await this.databaseReachable(),
await this.migrationsApplied(), await this.migrationsApplied(),
await this.converterReachable(), await this.converterReachable(),
await this.rendererReachable(),
]; ];
return { return {
status: checks.some((c) => c.status === 'failed') ? 'unready' : 'ok', 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<ReadinessCheck> {
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<ReadinessCheck> { private async databaseReachable(): Promise<ReadinessCheck> {
try { try {
await this.prisma.$queryRaw`SELECT 1`; await this.prisma.$queryRaw`SELECT 1`;

View File

@ -6,8 +6,9 @@ import { PinoLogger } from 'nestjs-pino';
import { AppConfig } from '../config/app-config.service'; import { AppConfig } from '../config/app-config.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { GotenbergRenderer, RenderError } from './gotenberg.renderer';
import { IMPORT_PROCESSOR, ImportProcessor, isImportKind } from './import.constants'; 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 /** 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 * that makes a queued conversion survive an API restart even if no new
@ -38,6 +39,7 @@ export class ConversionWorker implements OnModuleInit, OnModuleDestroy {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly converter: PandocConverter, private readonly converter: PandocConverter,
private readonly renderer: GotenbergRenderer,
private readonly config: AppConfig, private readonly config: AppConfig,
private readonly logger: PinoLogger, private readonly logger: PinoLogger,
// Resolved lazily to break the construction cycle (the import service // 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); 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 { onModuleInit(): void {
if (this.config.env.NODE_ENV === 'test') return; // tests drive drain() directly if (this.config.env.NODE_ENV === 'test') return; // tests drive drain() directly
this.timer = setInterval(() => void this.drain(), SWEEP_MS); this.timer = setInterval(() => void this.drain(), SWEEP_MS);
@ -115,19 +127,22 @@ export class ConversionWorker implements OnModuleInit, OnModuleDestroy {
await this.moduleRef.get<ImportProcessor>(IMPORT_PROCESSOR, { strict: false }).run(job); await this.moduleRef.get<ImportProcessor>(IMPORT_PROCESSOR, { strict: false }).run(job);
return; return;
} }
const result = await this.converter.convert({ // PDF export renders HTML through Gotenberg (#67); everything else is a
from: job.sourceFormat, // pandoc byte→byte conversion (#62/#65).
to: job.targetFormat, const output =
input: Buffer.from(job.input), job.targetFormat === 'pdf'
standalone: job.standalone, ? {
}); bytes: await this.renderer.renderHtmlToPdf(Buffer.from(job.input).toString('utf8')),
mimeType: 'application/pdf',
}
: await this.convert(job);
await this.prisma.conversionJob.update({ await this.prisma.conversionJob.update({
where: { id: job.id }, where: { id: job.id },
data: { data: {
status: 'SUCCEEDED', status: 'SUCCEEDED',
// Prisma Bytes = Uint8Array<ArrayBuffer>; copy the Buffer in (#40). // Prisma Bytes = Uint8Array<ArrayBuffer>; copy the Buffer in (#40).
result: new Uint8Array(result.output), result: new Uint8Array(output.bytes),
resultMimeType: result.mimeType, resultMimeType: output.mimeType,
errorCode: null, errorCode: null,
}, },
}); });
@ -141,8 +156,9 @@ export class ConversionWorker implements OnModuleInit, OnModuleDestroy {
} }
private async recordFailure(job: ConversionJob, error: unknown): Promise<void> { private async recordFailure(job: ConversionJob, error: unknown): Promise<void> {
const code = error instanceof ConversionError ? error.code : 'conversion_failed'; const typed = error instanceof ConversionError || error instanceof RenderError ? error : null;
const retryable = error instanceof ConversionError ? error.retryable : false; const code = typed?.code ?? 'conversion_failed';
const retryable = typed?.retryable ?? false;
// `attempts` was already incremented by the claim, so it reflects this try. // `attempts` was already incremented by the claim, so it reflects this try.
if (retryable && job.attempts < MAX_ATTEMPTS) { if (retryable && job.attempts < MAX_ATTEMPTS) {
await this.prisma.conversionJob.update({ await this.prisma.conversionJob.update({

View File

@ -12,12 +12,13 @@ import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { UsersService } from '../users/users.service'; import { UsersService } from '../users/users.service';
import { ConversionWorker } from './conversion-worker.service'; import { ConversionWorker } from './conversion-worker.service';
import { GotenbergRenderer, RenderError } from './gotenberg.renderer';
import { ConversionRequest, ConversionResult, PandocConverter } from './pandoc.converter'; import { ConversionRequest, ConversionResult, PandocConverter } from './pandoc.converter';
/** Export (issue #65): pond ZIP of Markdown + per-page docx/odt job. The ZIP /** Export (issues #65/#67): pond ZIP of Markdown, per-page docx/odt (pandoc),
* path needs no converter; the docx path uses an injected fake that records the * and per-page PDF (Gotenberg). The office/PDF paths use injected fakes that
* Markdown it is handed, so the image-inlining / wikilink-flattening is checked * record the input they are handed, so image-inlining / font-inlining is checked
* without a live pandoc. */ * without a live sidecar. */
const PNG_BASE64 = const PNG_BASE64 =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='; 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==';
@ -33,6 +34,19 @@ class RecordingConverter extends PandocConverter {
} }
} }
class RecordingRenderer extends GotenbergRenderer {
lastHtml = '';
failWith: RenderError | null = null;
renderHtmlToPdf(html: string): Promise<Buffer> {
this.lastHtml = html;
if (this.failWith) return Promise.reject(this.failWith);
return Promise.resolve(Buffer.from('%PDF-1.7 fake'));
}
reachable(): Promise<boolean> {
return Promise.resolve(true);
}
}
/** Filenames of every entry in a ZIP buffer. */ /** Filenames of every entry in a ZIP buffer. */
function zipEntries(buffer: Buffer): Record<string, Uint8Array> { function zipEntries(buffer: Buffer): Record<string, Uint8Array> {
return unzipSync(new Uint8Array(buffer)); return unzipSync(new Uint8Array(buffer));
@ -44,6 +58,7 @@ describe.skipIf(!hasTestDb)('export (e2e, issue #65)', () => {
let worker: ConversionWorker; let worker: ConversionWorker;
let files: FilesService; let files: FilesService;
let fake: RecordingConverter; let fake: RecordingConverter;
let renderer: RecordingRenderer;
const suffix = uniqueSuffix(); const suffix = uniqueSuffix();
const password = 'exportiere meine sachen 1'; 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 /** Create a page with a crafted content-cache Markdown (the exporter reads the
* cache, not the Yjs state), returning its slug. */ * cache, not the Yjs state), returning its slug. */
async function seedPage(pondId: string, title: string, markdown: string): Promise<string> { async function seedPage(
pondId: string,
title: string,
markdown: string,
html = '',
): Promise<string> {
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-'); const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-');
await prisma.page.create({ await prisma.page.create({
data: { data: {
@ -74,7 +94,7 @@ describe.skipIf(!hasTestDb)('export (e2e, issue #65)', () => {
ydocState: new Uint8Array(), ydocState: new Uint8Array(),
sortKey: title, sortKey: title,
createdBy: ownerId, createdBy: ownerId,
contentCache: { create: { plainText: markdown, markdown, html: '', outline: [] } }, contentCache: { create: { plainText: markdown, markdown, html, outline: [] } },
}, },
}); });
return slug; return slug;
@ -84,8 +104,13 @@ describe.skipIf(!hasTestDb)('export (e2e, issue #65)', () => {
prisma = createTestPrisma(); prisma = createTestPrisma();
await prisma.rateLimit.deleteMany({}); await prisma.rateLimit.deleteMany({});
fake = new RecordingConverter(); fake = new RecordingConverter();
renderer = new RecordingRenderer();
app = await createTestApp((builder) => app = await createTestApp((builder) =>
builder.overrideProvider(PandocConverter).useValue(fake), builder
.overrideProvider(PandocConverter)
.useValue(fake)
.overrideProvider(GotenbergRenderer)
.useValue(renderer),
); );
worker = app.get(ConversionWorker); worker = app.get(ConversionWorker);
files = app.get(FilesService); files = app.get(FilesService);
@ -298,6 +323,79 @@ describe.skipIf(!hasTestDb)('export (e2e, issue #65)', () => {
expect(result.text).toBe('OFFICE-BYTES'); 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',
`<p>A PDF body paragraph.</p><img data-file-id="${image.id}" alt="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', '<p>x</p>');
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 () => { it('streams a large pond export (500 pages) without buffering it all', async () => {
const big = await prisma.pond.create({ const big = await prisma.pond.create({
data: { data: {

View File

@ -1,10 +1,20 @@
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { Injectable, NotFoundException } from '@nestjs/common'; 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 { User } from '@prisma/client';
import archiver from 'archiver'; import archiver from 'archiver';
import type { Response } from 'express'; import type { Response } from 'express';
import { PinoLogger } from 'nestjs-pino'; import { PinoLogger } from 'nestjs-pino';
import { AppConfig } from '../config/app-config.service';
import { FileStorageService } from '../files/file-storage.service'; import { FileStorageService } from '../files/file-storage.service';
import { PermissionService } from '../permissions/permission.service'; import { PermissionService } from '../permissions/permission.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
@ -16,6 +26,7 @@ import {
markdownForDocument, markdownForDocument,
markdownForZip, markdownForZip,
} from './export-markdown'; } from './export-markdown';
import { buildPdfHtml } from './pdf-html';
/** /**
* Export (ADR 0009, issue #65): a whole pond as a ZIP of Markdown (one `.md` * 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 permissions: PermissionService,
private readonly storage: FileStorageService, private readonly storage: FileStorageService,
private readonly jobs: ConversionJobService, private readonly jobs: ConversionJobService,
private readonly config: AppConfig,
private readonly logger: PinoLogger, private readonly logger: PinoLogger,
) { ) {
this.logger.setContext(ExportService.name); this.logger.setContext(ExportService.name);
@ -136,6 +148,8 @@ export class ExportService {
pageId: string, pageId: string,
format: ExportFormat, format: ExportFormat,
): Promise<ConversionJobView> { ): Promise<ConversionJobView> {
if (format === 'pdf') return this.enqueuePdfExport(user, pageId);
const page = await this.prisma.page.findFirst({ const page = await this.prisma.page.findFirst({
where: { id: pageId, deletedAt: null }, where: { id: pageId, deletedAt: null },
include: { contentCache: { select: { markdown: true } } }, include: { contentCache: { select: { markdown: true } } },
@ -161,6 +175,88 @@ export class ExportService {
return this.jobs.viewOf(job); 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 bytebyte render the worker can retry.
*/
private async enqueuePdfExport(user: User, pageId: string): Promise<ConversionJobView> {
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 `<img data-file-id="X">` 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<string> {
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<string> {
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<string>();
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 /** 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). */ * images, so holding them briefly is fine unlike the streamed pond ZIP). */
private async inlineImages(pondId: string, ids: string[]): Promise<Map<string, string>> { private async inlineImages(pondId: string, ids: string[]): Promise<Map<string, string>> {

View File

@ -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 =
'<html><head><style>body{margin:0;font:9px system-ui;color:#64748b;width:100%;}' +
'div{text-align:center;}</style></head><body><div>' +
'<span class="pageNumber"></span> / <span class="totalPages"></span>' +
'</div></body></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<Buffer>;
abstract reachable(): Promise<boolean>;
}
@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<boolean> {
try {
const response = await fetch(`${this.baseUrl}/health`);
return response.ok;
} catch {
return false;
}
}
async renderHtmlToPdf(html: string): Promise<Buffer> {
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';
}

View File

@ -7,6 +7,7 @@ import { ConversionJobService } from './conversion-job.service';
import { ConversionWorker } from './conversion-worker.service'; import { ConversionWorker } from './conversion-worker.service';
import { ExportController } from './export.controller'; import { ExportController } from './export.controller';
import { ExportService } from './export.service'; import { ExportService } from './export.service';
import { GotenbergHttpRenderer, GotenbergRenderer } from './gotenberg.renderer';
import { IMPORT_PROCESSOR } from './import.constants'; import { IMPORT_PROCESSOR } from './import.constants';
import { ImportController } from './import.controller'; import { ImportController } from './import.controller';
import { ImportService } from './import.service'; 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 // Bind the abstract converter to the HTTP implementation; tests override
// this provider with a fake so the queue mechanics need no live sidecar. // this provider with a fake so the queue mechanics need no live sidecar.
{ provide: PandocConverter, useClass: PandocServerConverter }, { provide: PandocConverter, useClass: PandocServerConverter },
// Same pattern for the Gotenberg PDF renderer (#67).
{ provide: GotenbergRenderer, useClass: GotenbergHttpRenderer },
], ],
exports: [ConversionJobService, PandocConverter], exports: [ConversionJobService, PandocConverter],
}) })

View File

@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
/**
* 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 `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>${escapeHtml(params.title)}</title>
<style>
${params.fontFaceCss}
@page { size: A4; }
:root {
--font-heading: ${fontStack(fonts.heading.family)};
--font-body: ${fontStack(fonts.body.family)};
--font-mono: ${fontStack(fonts.mono.family)};
}
html { font-size: 11pt; }
body {
margin: 0;
font-family: var(--font-body);
font-weight: ${fonts.body.weight};
line-height: 1.55;
color: #111827;
}
h1, h2, h3, h4 { font-family: var(--font-heading); font-weight: ${fonts.heading.weight}; line-height: 1.25; page-break-after: avoid; }
code, pre { font-family: var(--font-mono); font-weight: ${fonts.mono.weight}; }
pre { background: #f3f4f6; padding: 0.6em 0.8em; border-radius: 4px; white-space: pre-wrap; word-wrap: break-word; }
code { background: #f3f4f6; border-radius: 3px; padding: 0 0.25em; }
pre code { background: none; padding: 0; }
img { max-width: 100%; height: auto; }
table { border-collapse: collapse; }
td, th { border: 1px solid #d1d5db; padding: 0.3em 0.5em; }
blockquote { margin: 1em 0; padding-left: 1em; border-left: 3px solid #d1d5db; color: #4b5563; }
figure, img, table, pre { page-break-inside: avoid; }
.pdf-header { margin-bottom: 1.5rem; border-bottom: 1px solid #e5e7eb; padding-bottom: 0.75rem; }
.pdf-header__pond { color: #64748b; font-size: 0.85rem; margin: 0 0 0.25rem; }
.pdf-header__title { margin: 0; }
</style>
</head>
<body>
<header class="pdf-header">
<p class="pdf-header__pond">${escapeHtml(params.pondName)}</p>
<h1 class="pdf-header__title">${escapeHtml(params.title)}</h1>
</header>
<main>
${params.bodyHtml}
</main>
</body>
</html>
`;
}

View File

@ -58,3 +58,27 @@ test('exports a page to .docx from the page menu', async ({ browser }) => {
await context.close(); 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();
});

View File

@ -9,10 +9,9 @@ interface DocumentExportMenuProps {
} }
/** /**
* Office-format export buttons for the page menu (issue #65): `.docx`/`.odt` * Document export buttons for the page menu (issues #65/#67): `.docx`/`.odt`
* run a conversion job and download the result; PDF is a disabled placeholder * (pandoc) and PDF (Gotenberg) each run a conversion job and download the
* until Gotenberg (#67). Markdown copy/download live in the page menu already * result. Markdown copy/download live in the page menu already (#30).
* (#30).
*/ */
export function DocumentExportMenu({ pageId, slug }: DocumentExportMenuProps): React.JSX.Element { export function DocumentExportMenu({ pageId, slug }: DocumentExportMenuProps): React.JSX.Element {
const { t } = useTranslation('export'); const { t } = useTranslation('export');
@ -37,9 +36,6 @@ export function DocumentExportMenu({ pageId, slug }: DocumentExportMenuProps): R
{label(format)} {label(format)}
</button> </button>
))} ))}
<button type="button" className="button" disabled title={t('pdfSoon')}>
{t('pdf')}
</button>
</span> </span>
); );
} }

View File

@ -33,9 +33,9 @@ export interface UseDocumentExport {
} }
/** /**
* Export a page to `.docx`/`.odt` (issue #65): enqueue the conversion job, poll * Export a page to `.docx`/`.odt`/`.pdf` (issues #65/#67): enqueue the
* it, and download the result when it succeeds. Markdown export stays a direct * conversion job, poll it, and download the result when it succeeds. Markdown
* link (#30); PDF waits for Gotenberg (#67). * export stays a direct link (#30).
*/ */
export function useDocumentExport(): UseDocumentExport { export function useDocumentExport(): UseDocumentExport {
const [status, setStatus] = useState<Partial<Record<ExportFormat, ExportStatus>>>({}); const [status, setStatus] = useState<Partial<Record<ExportFormat, ExportStatus>>>({});

View File

@ -62,6 +62,8 @@ services:
UPLOADS_DIR: /data/uploads UPLOADS_DIR: /data/uploads
# Internal pandoc-server sidecar for import/export (ADR 0009, issue #62). # Internal pandoc-server sidecar for import/export (ADR 0009, issue #62).
PANDOC_URL: http://pandoc:3030 PANDOC_URL: http://pandoc:3030
# Internal Gotenberg sidecar for PDF export (ADR 0009, issue #67).
GOTENBERG_URL: http://gotenberg:3000
ports: ports:
- '127.0.0.1:${API_PORT:-8101}:3000' - '127.0.0.1:${API_PORT:-8101}:3000'
networks: [frontend, internal] networks: [frontend, internal]
@ -72,6 +74,8 @@ services:
condition: service_healthy condition: service_healthy
pandoc: pandoc:
condition: service_healthy condition: service_healthy
gotenberg:
condition: service_healthy
<<: *logging <<: *logging
collab: collab:
@ -136,6 +140,20 @@ services:
retries: 3 retries: 3
<<: *logging <<: *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: networks:
frontend: frontend:
internal: internal:

View File

@ -20,7 +20,9 @@ const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const { FONT_CATALOG, fontSlug } = await import( const { FONT_CATALOG, fontSlug } = await import(
join(ROOT, 'packages', 'shared', 'dist', 'index.mjs') 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 GWFH = 'https://gwfh.mranftl.com/api/fonts';
const SUBSETS = 'latin,latin-ext'; const SUBSETS = 'latin,latin-ext';

View File

@ -34,6 +34,8 @@
"converter_unavailable": "Der Dokument-Konverter ist derzeit nicht verfügbar. Bitte versuche es später erneut.", "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.", "converter_timeout": "Die Konvertierung hat zu lange gedauert und wurde abgebrochen.",
"conversion_failed": "Dieses Dokument konnte nicht konvertiert werden.", "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.", "import_unsupported_format": "Nur Word- (.docx) und OpenDocument-Dokumente (.odt) können importiert werden.",
"network": "Der Server war nicht erreichbar.", "network": "Der Server war nicht erreichbar.",
"grant_exists": "Diese Berechtigung existiert bereits.", "grant_exists": "Diese Berechtigung existiert bereits.",

View File

@ -3,7 +3,6 @@
"docx": "Word (.docx)", "docx": "Word (.docx)",
"odt": "OpenDocument (.odt)", "odt": "OpenDocument (.odt)",
"pdf": "PDF", "pdf": "PDF",
"pdfSoon": "PDF-Export folgt in Kürze",
"exporting": "Wird exportiert…", "exporting": "Wird exportiert…",
"failed": "Export fehlgeschlagen", "failed": "Export fehlgeschlagen",
"pond": { "pond": {

View File

@ -34,6 +34,8 @@
"converter_unavailable": "The document converter is currently unavailable. Please try again later.", "converter_unavailable": "The document converter is currently unavailable. Please try again later.",
"converter_timeout": "The conversion took too long and was cancelled.", "converter_timeout": "The conversion took too long and was cancelled.",
"conversion_failed": "This document could not be converted.", "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.", "import_unsupported_format": "Only Word (.docx) and OpenDocument (.odt) documents can be imported.",
"network": "The server could not be reached.", "network": "The server could not be reached.",
"grant_exists": "This grant already exists.", "grant_exists": "This grant already exists.",

View File

@ -3,7 +3,6 @@
"docx": "Word (.docx)", "docx": "Word (.docx)",
"odt": "OpenDocument (.odt)", "odt": "OpenDocument (.odt)",
"pdf": "PDF", "pdf": "PDF",
"pdfSoon": "PDF export is coming soon",
"exporting": "Exporting…", "exporting": "Exporting…",
"failed": "Export failed", "failed": "Export failed",
"pond": { "pond": {

View File

@ -31,11 +31,11 @@ export interface ConversionJobView {
export const IMPORT_EXTENSIONS = ['docx', 'odt', 'md', 'markdown'] as const; export const IMPORT_EXTENSIONS = ['docx', 'odt', 'md', 'markdown'] as const;
export type ImportExtension = (typeof IMPORT_EXTENSIONS)[number]; export type ImportExtension = (typeof IMPORT_EXTENSIONS)[number];
/** Office formats a single page exports to (issue #65) each runs a /** Formats a single page exports to via a conversion job whose result is
* `markdown → pandoc → file` conversion job whose result is downloaded from * downloaded from `GET /jobs/:id/result` (issues #65/#67). `docx`/`odt` run
* `GET /jobs/:id/result`. Markdown export is a separate direct download (#30); * `markdown → pandoc`; `pdf` renders HTML through Gotenberg. Markdown export is
* PDF is a placeholder until Gotenberg (#67). */ * a separate direct download (#30). */
export const EXPORT_FORMATS = ['docx', 'odt'] as const; export const EXPORT_FORMATS = ['docx', 'odt', 'pdf'] as const;
export type ExportFormat = (typeof EXPORT_FORMATS)[number]; export type ExportFormat = (typeof EXPORT_FORMATS)[number];
export const pageExportInputSchema = z.object({ export const pageExportInputSchema = z.object({

View File

@ -69,6 +69,19 @@ export const apiEnvSchema = z.object({
* readiness check is warning-level, so an unset sidecar never fails readyz). * readiness check is warning-level, so an unset sidecar never fails readyz).
*/ */
PANDOC_URL: z.string().url().default('http://pandoc:3030'), 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<typeof apiEnvSchema>; export type ApiEnv = z.infer<typeof apiEnvSchema>;