dorfteich/apps/api/src/import-export/pdf-html.ts
Claude Fable 5 9e8ebfe49c
All checks were successful
CI / Lint, typecheck, test (push) Successful in 2m57s
CD / Promote to Int (push) Successful in 9s
CI / Auth e2e pack (push) Successful in 5m2s
CI / Import/export fidelity gate (push) Successful in 46s
CI / Build container images (push) Has been skipped
CD / Build and push images (push) Successful in 3m10s
CD / Deploy to Test (push) Successful in 14s
CD / Smoke tests against Test (push) Successful in 1m12s
Degrade plugin content gracefully in HTML, PDF, and office exports (#79)
Completes M7: exports and the public read view no longer show raw plugin
placeholders (ADR 0008/0009).

- PluginFallbackRenderer (api): replaces each plugin-block placeholder in
  content-cache HTML with its best static form — the block's stored SVG
  snapshot (block data is author-controlled, so it passes the same
  DOMPurify sanitizer as uploaded SVG files before entering host HTML),
  else the manifest fallback from the stored snapshot (text, or an image
  inlined as a data URI so network-isolated renderers work; tombstone-safe
  for uninstalled plugins), else the literal '[plugin content]' marker.
- Office exports (docx/odt): the export markdown is degraded before
  pandoc — GFM knows neither the dorfteich-plugin fence nor the section
  fenced div, so blocks become their fallback text and sections plain
  quoted blocks (shared replacePluginNodesForExport, AST-level so nesting
  and embedded blocks inside sections survive).
- PDF export applies the HTML fallback pass before building the Gotenberg
  document — resolving the TODO left in #67.
- Public read view: the same fallback pass plus the pond's active
  section-style CSS inlined as a <style> block, so public pages show
  styled sections and static plugin content without any plugin runtime.
- Covered in export.service.db.test (snapshot SVG sanitized — hostile
  <script> stripped; manifest text; tombstone text; quoted sections and
  no fence artifacts in the pandoc input) and shared export-fallbacks
  tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-11 14:13:09 +02:00

87 lines
3.1 KiB
TypeScript

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;
/** The pond's active section-style plugin CSS (issue #75), already validated
* at install time (scoped selectors, no external fetches, no `</style>`).
* Sections of a disabled plugin render neutrally — their class matches
* nothing. */
sectionStyleCss?: 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.
*
* Plugin blocks arrive already degraded to their static form — the caller runs
* `PluginFallbackRenderer.applyToHtml` (#79) before building this document.
*/
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; }
${params.sectionStyleCss ?? ''}
</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>
`;
}