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
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
87 lines
3.1 KiB
TypeScript
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, '&')
|
|
.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.
|
|
*
|
|
* 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>
|
|
`;
|
|
}
|