dorfteich/apps/api/src/import-export/gotenberg.renderer.ts
Claude Opus 4.8 8a68ef68e7
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
Add PDF export via Gotenberg (#67)
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
2026-07-10 12:11:12 +02:00

115 lines
4.0 KiB
TypeScript

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