The backend from #303 could store an operator's font but nothing could choose one: no list endpoint outside the Site-Admin routes, no @font-face rules for a family that only exists at runtime, and no management UI. Found while wiring it up — a real defect in #303, invisible to its tests: `fontStack` cannot tell an uploaded family from a deleted one, so the PDF exporter embedded the face and then never named it. Every export of a pond using an operator font rendered in the system font while the job reported success. Both `fontStack` call sites now take the uploaded families (`buildPdfHtml`, `pondFontVariables`); `pdf-html.test.ts` pins the regression from both sides. Verified against a real Gotenberg: with the families the PDF embeds PlayfairDisplay-Bold, without them NotoSans-Bold — that was the whole bug, in one diff of two PDFs. - `GET /fonts/custom` is readable by any signed-in user, not Site Admins only: the pickers, the licence page and the injected `@font-face` rules all need it, and gating it would have forced a second, admin-only UI. - Bundled and uploaded families are told apart by their `<optgroup>`, not by a badge — the grouping is then part of the control's semantics, so a screen reader announces it and the native mobile select keeps it. Within each source the catalog's category grouping is preserved. - The delete confirmation names how many ponds use the family and what happens to them; focus moves to it and back on cancel. Deletion stays unblocked (the api's decision, #303) — the ponds degrade, they do not break. - The licence page grew a second table. That is what makes an attribution obligation satisfiable: a commercial licence that requires naming the foundry needs a page to name it on. Verified in the browser end to end (upload two weights → listed and rendered in its own font → chosen in a pond → page renders in it → deleted → pond falls back): api suite for fonts/export 77 passed, a11y pack 11/11 locally in both schemes, lint/typecheck/i18n:check green.
94 lines
3.6 KiB
TypeScript
94 lines
3.6 KiB
TypeScript
import { FontCatalogEntry, 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 instance's operator-uploaded families (issue #303), so a pond set to
|
|
* one gets it NAMED in the `font-family` stack. Without them `fontStack`
|
|
* cannot tell a custom family from a typo and yields the bare system
|
|
* fallback — the `@font-face` rule would then be embedded but never
|
|
* referenced, and the PDF would silently render in the system font. */
|
|
customFonts?: readonly FontCatalogEntry[];
|
|
/** 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;
|
|
const extra = params.customFonts ?? [];
|
|
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, extra)};
|
|
--font-body: ${fontStack(fonts.body.family, extra)};
|
|
--font-mono: ${fontStack(fonts.mono.family, extra)};
|
|
}
|
|
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>
|
|
`;
|
|
}
|