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
115 lines
4.0 KiB
TypeScript
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';
|
|
}
|