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.
41 lines
1.6 KiB
TypeScript
41 lines
1.6 KiB
TypeScript
import { useCustomFonts } from './use-custom-fonts';
|
|
|
|
/**
|
|
* A family name is free text the operator typed. It ends up inside a CSS
|
|
* string, so quote and backslash are escaped and everything that could end
|
|
* the declaration, the rule or the `<style>` element is dropped. Site Admins
|
|
* are trusted with far more than this, but a rule that silently breaks the
|
|
* whole stylesheet on an apostrophe would be a bug either way.
|
|
*/
|
|
function cssFamily(family: string): string {
|
|
return family.replace(/[\\'<>{};\r\n]/g, '');
|
|
}
|
|
|
|
/**
|
|
* `@font-face` rules for the operator-uploaded families (issue #304).
|
|
*
|
|
* Catalog families are declared in the generated `public/fonts/catalog.css`,
|
|
* which the build writes and `index.html` links. Uploaded ones only exist at
|
|
* runtime, so their rules are injected here — same shape, same `swap`
|
|
* behaviour, bytes from the api's public font route.
|
|
*
|
|
* Without this the pickers would offer families the browser cannot resolve:
|
|
* `fontStack` names them, nothing declares them, and the text renders in the
|
|
* system fallback.
|
|
*/
|
|
export function CustomFontFaces(): React.JSX.Element | null {
|
|
const fonts = useCustomFonts();
|
|
if (fonts.length === 0) return null;
|
|
const css = fonts
|
|
.flatMap((font) =>
|
|
font.weights.map(
|
|
(weight) =>
|
|
`@font-face { font-family: '${cssFamily(font.family)}'; font-style: normal;` +
|
|
` font-weight: ${weight}; font-display: swap;` +
|
|
` src: url('/api/v1/fonts/custom/${font.slug}/${font.slug}-${weight}.woff2') format('woff2'); }`,
|
|
),
|
|
)
|
|
.join('\n');
|
|
return <style data-custom-fonts="">{css}</style>;
|
|
}
|