Add font import for Now, Gotham and Open Sauce One overrides; package fonts for Void
Font import: - Migration M6 adds user_fonts; files are stored content-addressed under the local data dir. New Rust commands inspect, import, list, read and remove fonts, refusing WOFF2, collections, variable fonts and restricted fsType, and unpacking WOFF1 to plain sfnt. - Imported fonts register in the render worker as user:<Face> families with an italic alias, and are pinned in the frozen render prefs so issued invoices can still re-render. Fonts referenced by issued invoices are hidden, never deleted. - Size and tracking compensations tuned for the substitutes apply only while the face is not overridden; default output and fingerprint goldens are unchanged. - Settings gets a Fonts section with a licence acknowledgement. Void packaging: - Install fonts to /usr/share/fonts/voiced with per-family OFL licences, set font_dirs, and add INSTALL/REMOVE scripts that run fc-cache. xbps-create carries both scripts (verified in a void-glibc container). - Fix xbps-create dependency specs in build-xbps.sh.
This commit is contained in:
@@ -2,6 +2,7 @@ import { create, type Font } from "fontkit";
|
||||
import { FONT_MANIFEST } from "../fonts/manifest";
|
||||
import { familySources, FONT_STACK, stack, type FamilySource } from "../fonts/stack";
|
||||
import { resolveFace, roleToken, type RoleName, type TypeToken } from "../fonts/roles";
|
||||
import { userFamilySources } from "../fonts/userFontStore";
|
||||
|
||||
export interface MeasureInput {
|
||||
text: string;
|
||||
@@ -29,6 +30,29 @@ export interface Measurer {
|
||||
missing(text: string, face?: string): number[];
|
||||
}
|
||||
|
||||
const userFonts = new Map<string, Font>();
|
||||
|
||||
/**
|
||||
* The fontkit font of a user-imported family at a weight and style (the file react-pdf resolves for it), or
|
||||
* undefined when `familyName` is not a user family. Parsed once per file and shared by every measurer.
|
||||
*/
|
||||
export function userFontkitFont(familyName: string, weight: number, italic: boolean): Font | null | undefined {
|
||||
const sources = userFamilySources(familyName);
|
||||
if (sources.length === 0) return undefined;
|
||||
const src = resolveSource(
|
||||
sources.map((s) => ({ file: s.id, fontWeight: s.fontWeight, fontStyle: s.fontStyle })),
|
||||
weight,
|
||||
italic ? "italic" : "normal",
|
||||
);
|
||||
if (!src) return null;
|
||||
let font = userFonts.get(src.file);
|
||||
if (!font) {
|
||||
font = create((sources.find((s) => s.id === src.file) as { bytes: Uint8Array }).bytes) as Font;
|
||||
userFonts.set(src.file, font);
|
||||
}
|
||||
return font;
|
||||
}
|
||||
|
||||
/** react-pdf's FontFamily.resolve, so the measurer reads the same file the renderer embeds. */
|
||||
function resolveSource(sources: FamilySource[], weight: number, style: "normal" | "italic"): FamilySource | undefined {
|
||||
const styled = sources.filter((s) => s.fontStyle === style);
|
||||
@@ -57,6 +81,8 @@ export function createMeasurer(loadBytes: (file: string) => Uint8Array): Measure
|
||||
const fonts = new Map<string, Font>();
|
||||
|
||||
function load(familyName: string, weight: number, italic: boolean): Font | null {
|
||||
const own = userFontkitFont(familyName, weight, italic);
|
||||
if (own !== undefined) return own;
|
||||
const fam = FONT_MANIFEST.families.find((f) => f.family === familyName);
|
||||
if (!fam) return null;
|
||||
const src = resolveSource(familySources(fam), weight, italic ? "italic" : "normal");
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { FONT_MANIFEST, type FontFileEntry } from "../fonts/manifest";
|
||||
import { userFontkitFont } from "./measure";
|
||||
import { resolveFace, roleToken, type RoleName, type TypeToken } from "../fonts/roles";
|
||||
|
||||
function metricsFile(family: string, weight: number, italic: boolean): FontFileEntry | undefined {
|
||||
@@ -23,6 +24,13 @@ function metricsFile(family: string, weight: number, italic: boolean): FontFileE
|
||||
export function chromeHeight(role: RoleName | TypeToken, opts: { weight?: number; italic?: boolean; faceOverrides?: Readonly<Record<string, string>> } = {}): number {
|
||||
const token = roleToken(role);
|
||||
const family = resolveFace(token.face, opts.faceOverrides);
|
||||
// A user-imported font has no manifest entry; its metrics come from the font itself (hhea, as the manifest's do).
|
||||
const own = userFontkitFont(family, opts.weight ?? token.weight, opts.italic ?? token.italic ?? false);
|
||||
if (own) {
|
||||
// fontkit's types omit the hhea metrics it exposes.
|
||||
const { ascent, descent, lineGap } = own as unknown as { ascent: number; descent: number; lineGap: number };
|
||||
return Math.ceil(((ascent - descent + lineGap) / own.unitsPerEm) * token.size) + 1;
|
||||
}
|
||||
const file = metricsFile(family, opts.weight ?? token.weight, opts.italic ?? token.italic ?? false);
|
||||
if (!file) throw new Error(`chromeHeight: no font metrics for "${family}"`);
|
||||
// The manifest stores descent as a negative number, as the font's hhea table does.
|
||||
|
||||
+101
-16
@@ -1,6 +1,15 @@
|
||||
import { Font } from "@react-pdf/renderer";
|
||||
import { FONT_MANIFEST } from "./manifest";
|
||||
import { familySources } from "./stack";
|
||||
import {
|
||||
assertSfnt,
|
||||
familyNameFor,
|
||||
isUserFamilyKnown,
|
||||
rememberUserFiles,
|
||||
userFamilySources,
|
||||
type UserFace,
|
||||
type UserFontFile,
|
||||
} from "./userFontStore";
|
||||
|
||||
export { FONT_STACK, familySources, stack, type FamilySource } from "./stack";
|
||||
|
||||
@@ -71,7 +80,9 @@ function primeComponents(font: FkFont): void {
|
||||
for (const cp of lowest.values()) font.glyphForCodePoint(cp);
|
||||
}
|
||||
|
||||
let primed: Promise<void> | null = null;
|
||||
/** Descriptors already primed; a font registered after the first render (a user font) is primed on the next call. */
|
||||
const primedKeys = new Set<string>();
|
||||
let primeChain: Promise<void> = Promise.resolve();
|
||||
|
||||
/**
|
||||
* Loads every registered face and gives the component glyphs of its composites their code points. fontkit
|
||||
@@ -79,30 +90,104 @@ let primed: Promise<void> | null = null;
|
||||
* time with no code points: Poppins' colon contains the period, so a document that sets ':' before any '.'
|
||||
* would leave the period glyph with codePoints [] for every later document (amounts extracted as "1,200;00",
|
||||
* textkit's line breaks slipping). Creating those glyphs here first makes a render independent of the renders
|
||||
* before it. Memoised; await it before the first render.
|
||||
* before it. Covers the bundled families and any user family registered so far, each descriptor once, so a
|
||||
* font registered later is not skipped. Await it before the first render.
|
||||
*/
|
||||
export function primeFontGlyphs(): Promise<void> {
|
||||
if (!registered) return Promise.resolve();
|
||||
if (!primed) {
|
||||
primed = run().catch((err) => {
|
||||
primed = null;
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
return primed;
|
||||
if (!registered && primedKeys.size === 0 && userFamilies.length === 0) return Promise.resolve();
|
||||
const next = primeChain.then(run);
|
||||
primeChain = next.catch(() => undefined);
|
||||
return next;
|
||||
|
||||
async function run(): Promise<void> {
|
||||
for (const fam of FONT_MANIFEST.families) {
|
||||
for (const s of familySources(fam)) {
|
||||
const descriptor = { fontFamily: fam.family, fontWeight: s.fontWeight, fontStyle: s.fontStyle };
|
||||
await Font.load(descriptor);
|
||||
const data = (Font as unknown as { getFont(d: object): { data: FkFont | null } }).getFont(descriptor).data;
|
||||
if (data) primeComponents(data);
|
||||
const descriptors: Array<{ fontFamily: string; fontWeight: number; fontStyle: "normal" | "italic" }> = [];
|
||||
if (registered) {
|
||||
for (const fam of FONT_MANIFEST.families) {
|
||||
for (const s of familySources(fam)) descriptors.push({ fontFamily: fam.family, fontWeight: s.fontWeight, fontStyle: s.fontStyle });
|
||||
}
|
||||
}
|
||||
for (const family of userFamilies) {
|
||||
for (const s of userFamilySources(family)) descriptors.push({ fontFamily: family, fontWeight: s.fontWeight, fontStyle: s.fontStyle });
|
||||
}
|
||||
for (const descriptor of descriptors) {
|
||||
const key = `${descriptor.fontFamily}|${descriptor.fontWeight}|${descriptor.fontStyle}`;
|
||||
if (primedKeys.has(key)) continue;
|
||||
await Font.load(descriptor);
|
||||
const data = (Font as unknown as { getFont(d: object): { data: FkFont | null } }).getFont(descriptor).data;
|
||||
if (data) primeComponents(data);
|
||||
primedKeys.add(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- User-imported fonts (Now, Gotham, Open Sauce One) ----
|
||||
|
||||
const userFamilies: string[] = [];
|
||||
const isNode = typeof process !== "undefined" && !!process.versions?.node;
|
||||
|
||||
/**
|
||||
* A src react-pdf can load the bytes from. In a browser or worker that is a blob: URL (react-pdf fetches it;
|
||||
* a data URI would be decoded character by character, which is slow for a multi-MB font). Under Node it is a
|
||||
* temporary file, because react-pdf's Node build opens anything that is not an http(s) URL as a path.
|
||||
*/
|
||||
async function sourceFor(bytes: Uint8Array, id: string): Promise<string> {
|
||||
if (!isNode) return URL.createObjectURL(new Blob([bytes as BlobPart], { type: "font/ttf" }));
|
||||
const nodeFs = "node:fs";
|
||||
const nodeOs = "node:os";
|
||||
const nodePath = "node:path";
|
||||
const [fs, os, path] = await Promise.all([import(/* @vite-ignore */ nodeFs), import(/* @vite-ignore */ nodeOs), import(/* @vite-ignore */ nodePath)]);
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "voiced-userfont-"));
|
||||
const file = path.join(dir, `${id.slice(0, 16)}.ttf`);
|
||||
fs.writeFileSync(file, bytes);
|
||||
return file;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers one face's files as the family `user:<Face>:<set id>` and returns that name. Every file's magic bytes
|
||||
* are checked before fontkit sees it. Each weight without an italic gets its upright file as the italic. Idempotent:
|
||||
* the same set registers once. Never resets the font store; a different set is a different family.
|
||||
*/
|
||||
export async function registerUserFontSet(face: UserFace, files: UserFontFile[]): Promise<string> {
|
||||
if (files.length === 0) throw new Error(`No font files were given for ${face}`);
|
||||
for (const f of files) assertSfnt(f.bytes);
|
||||
const family = familyNameFor(face, files.map((f) => f.sha256));
|
||||
if (isUserFamilyKnown(family)) return family;
|
||||
rememberUserFiles(family, files);
|
||||
const srcs = new Map<string, string>();
|
||||
for (const s of userFamilySources(family)) {
|
||||
if (!srcs.has(s.id)) srcs.set(s.id, await sourceFor(s.bytes, s.id));
|
||||
}
|
||||
Font.register({
|
||||
family,
|
||||
fonts: userFamilySources(family).map((s) => ({ src: srcs.get(s.id) as string, fontWeight: s.fontWeight as never, fontStyle: s.fontStyle })),
|
||||
});
|
||||
userFamilies.push(family);
|
||||
await primeFontGlyphs();
|
||||
return family;
|
||||
}
|
||||
|
||||
/** Cheap non-cryptographic id (two FNV-1a passes, 16 hex digits) for callers that have no sha256, i.e. tests. */
|
||||
function fallbackId(bytes: Uint8Array): string {
|
||||
let a = 0x811c9dc5;
|
||||
let b = 0x01000193;
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
a = Math.imul(a ^ bytes[i], 0x01000193) >>> 0;
|
||||
b = Math.imul(b ^ bytes[i], 0x811c9dc5) >>> 0;
|
||||
}
|
||||
return a.toString(16).padStart(8, "0") + b.toString(16).padStart(8, "0");
|
||||
}
|
||||
|
||||
/** One-file convenience for Node tests and single-weight imports: the font as the only member of the face's set. */
|
||||
export async function registerUserFont(
|
||||
face: UserFace,
|
||||
bytes: Uint8Array,
|
||||
opts: { weight?: number; italic?: boolean; sha256?: string } = {},
|
||||
): Promise<{ family: string; sha256: string }> {
|
||||
const sha256 = opts.sha256 ?? fallbackId(bytes);
|
||||
const family = await registerUserFontSet(face, [{ face, weight: opts.weight ?? 400, italic: opts.italic ?? false, sha256, bytes }]);
|
||||
return { family, sha256 };
|
||||
}
|
||||
|
||||
const MIN_FONT_BYTES = 10 * 1024;
|
||||
|
||||
function hasTrueTypeMagic(b: Uint8Array): boolean {
|
||||
|
||||
+55
-3
@@ -35,6 +35,13 @@ export interface TypeToken {
|
||||
tnum?: boolean;
|
||||
/** Smallest size IdText/FitText may shrink to. */
|
||||
minSize?: number;
|
||||
/**
|
||||
* Size and tracking to use instead of `size`/`tracking` when a user-imported font replaces this token's
|
||||
* substitute face. The token's own values are tuned for the substitute (Gotham drawn in Montserrat, Now in
|
||||
* Jost); the real font takes the design values. defineRoles turns a token that has this into one whose
|
||||
* size/tracking follow the active overrides; without an override its values are exactly the literals.
|
||||
*/
|
||||
ownFont?: { size?: number; tracking?: number };
|
||||
}
|
||||
|
||||
const SANS: FaceKey = "IBM Plex Sans";
|
||||
@@ -63,14 +70,37 @@ export type RoleTable<K extends string = string> = Record<K, TypeToken>;
|
||||
|
||||
/** Checks a role table against TypeToken and keeps its literal keys: `export const ROLES = defineRoles({ ... })`. */
|
||||
export function defineRoles<const T extends Record<string, TypeToken>>(table: T): T {
|
||||
return table;
|
||||
const out: Record<string, TypeToken> = {};
|
||||
for (const [name, token] of Object.entries(table)) out[name] = token.ownFont ? followOverrides(token) : token;
|
||||
return out as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* A copy of `token` whose size and tracking read as `ownFont.*` while a user font overrides its face, and as
|
||||
* the original literals otherwise. Getters, so every plan and layout that reads `token.size` sees the right
|
||||
* value without threading anything through; only the keys `ownFont` names become accessors.
|
||||
*/
|
||||
function followOverrides(token: TypeToken): TypeToken {
|
||||
const own = token.ownFont as NonNullable<TypeToken["ownFont"]>;
|
||||
const copy: TypeToken = { ...token };
|
||||
for (const key of ["size", "tracking"] as const) {
|
||||
const design = own[key];
|
||||
if (design === undefined) continue;
|
||||
const substituted = token[key];
|
||||
Object.defineProperty(copy, key, {
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
get: () => (resolveFace(token.face).startsWith(USER_FAMILY_PREFIX) ? design : substituted),
|
||||
});
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Licensed template faces (Now, Gotham, Open Sauce One, ...) map to the OFL look-alike that is
|
||||
* actually bundled. A user-imported font replaces the licensed name through `faceOverrides`.
|
||||
* Gotham runs about 4% narrower than Montserrat, so tokens that use it carry sizes already scaled by
|
||||
* GOTHAM_SIZE (see templates/northline); a real Gotham import would need them back at 1.
|
||||
* GOTHAM_SIZE (see templates/northline); a real Gotham import takes them back at 1 through TypeToken.ownFont.
|
||||
*/
|
||||
export const SUBSTITUTES: Readonly<Record<string, string>> = {
|
||||
Now: "Jost",
|
||||
@@ -78,8 +108,30 @@ export const SUBSTITUTES: Readonly<Record<string, string>> = {
|
||||
"Open Sauce One": "Poppins",
|
||||
};
|
||||
|
||||
/** Family names of user-imported fonts start with this (`user:Gotham:<set id>`). */
|
||||
export const USER_FAMILY_PREFIX = "user:";
|
||||
|
||||
type FaceOverrides = Readonly<Record<string, string>>;
|
||||
|
||||
let activeOverrides: FaceOverrides | undefined;
|
||||
|
||||
/**
|
||||
* Runs `fn` with `overrides` as the default for every resolveFace call that is not given its own map. A render
|
||||
* sets this for its whole duration so templates, plans and the measurer agree on the faces without each of them
|
||||
* being handed the map. Renders are serialised (the worker's job lane, the main-thread client), so one slot is enough.
|
||||
*/
|
||||
export async function withFaceOverrides<T>(overrides: FaceOverrides | undefined, fn: () => Promise<T>): Promise<T> {
|
||||
const previous = activeOverrides;
|
||||
activeOverrides = overrides && Object.keys(overrides).length > 0 ? overrides : undefined;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
activeOverrides = previous;
|
||||
}
|
||||
}
|
||||
|
||||
/** The family name to register/look up for a face: user override first, then substitute, then itself. */
|
||||
export function resolveFace(face: string, overrides?: Readonly<Record<string, string>>): string {
|
||||
export function resolveFace(face: string, overrides: FaceOverrides | undefined = activeOverrides): string {
|
||||
return overrides?.[face] ?? SUBSTITUTES[face] ?? face;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* The user-imported fonts that rendering knows about: Now, Gotham and Open Sauce One only. This module holds
|
||||
* plain data (no react-pdf), so the measurer can read it too. Registration with react-pdf is in register.ts.
|
||||
*
|
||||
* A face's fonts form a "set" (one file per weight and style) and each set is one react-pdf family named
|
||||
* `user:<Face>:<set id>`. The id comes from the files' hashes, so replacing a font gives a new family instead of
|
||||
* appending to the old one (react-pdf's font store only appends, and Font.reset() must not be called).
|
||||
*/
|
||||
import { USER_FAMILY_PREFIX } from "./roles";
|
||||
|
||||
/** The commercial template faces a user may import. Overrides for any other family are out of scope. */
|
||||
export const USER_FACES = ["Now", "Gotham", "Open Sauce One"] as const;
|
||||
export type UserFace = (typeof USER_FACES)[number];
|
||||
|
||||
export const isUserFace = (face: unknown): face is UserFace => USER_FACES.includes(face as UserFace);
|
||||
|
||||
/** What a font file looks like by its first four bytes. */
|
||||
export type FontFormat = "ttf" | "otf" | "woff" | "woff2" | "ttc" | "unknown";
|
||||
|
||||
export function sniffFontFormat(b: Uint8Array): FontFormat {
|
||||
if (b.length < 4) return "unknown";
|
||||
if (b[0] === 0 && b[1] === 1 && b[2] === 0 && b[3] === 0) return "ttf";
|
||||
const tag = String.fromCharCode(b[0], b[1], b[2], b[3]);
|
||||
switch (tag) {
|
||||
case "true":
|
||||
return "ttf";
|
||||
case "OTTO":
|
||||
return "otf";
|
||||
case "wOFF":
|
||||
return "woff";
|
||||
case "wOF2":
|
||||
return "woff2";
|
||||
case "ttcf":
|
||||
return "ttc";
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
const REFUSALS: Partial<Record<FontFormat, string>> = {
|
||||
woff2: "WOFF2 fonts embed without glyph outlines (invisible text in the PDF). Use the TTF or OTF file.",
|
||||
woff: "A WOFF file must be converted to TTF/OTF first (the importer does this).",
|
||||
ttc: "Font collections (.ttc) are not supported. Use a single TTF or OTF file.",
|
||||
unknown: "This is not a TrueType or OpenType font file.",
|
||||
};
|
||||
|
||||
/** Throws unless the bytes are a plain TrueType or OpenType (sfnt) file; fontkit must never see anything else. */
|
||||
export function assertSfnt(b: Uint8Array): "ttf" | "otf" {
|
||||
const format = sniffFontFormat(b);
|
||||
if (format === "ttf" || format === "otf") return format;
|
||||
throw new Error(REFUSALS[format] ?? REFUSALS.unknown);
|
||||
}
|
||||
|
||||
/** One imported font file, as the renderer needs it. `sha256` identifies it (hex). */
|
||||
export interface UserFontFile {
|
||||
face: UserFace;
|
||||
weight: number;
|
||||
italic: boolean;
|
||||
sha256: string;
|
||||
bytes: Uint8Array;
|
||||
}
|
||||
|
||||
const filesById = new Map<string, UserFontFile>();
|
||||
const familyFiles = new Map<string, UserFontFile[]>();
|
||||
|
||||
/** The family name of a face's set of files. Order does not matter. */
|
||||
export function familyNameFor(face: UserFace, ids: readonly string[]): string {
|
||||
return `${USER_FAMILY_PREFIX}${face}:${[...ids].sort().map((id) => id.slice(0, 8)).join("-")}`;
|
||||
}
|
||||
|
||||
export const hasUserFont = (id: string): boolean => filesById.has(id);
|
||||
export const getUserFontFile = (id: string): UserFontFile | undefined => filesById.get(id);
|
||||
export function rememberUserFile(file: UserFontFile): void {
|
||||
filesById.set(file.sha256, file);
|
||||
}
|
||||
|
||||
export function rememberUserFiles(family: string, files: UserFontFile[]): void {
|
||||
for (const f of files) filesById.set(f.sha256, f);
|
||||
familyFiles.set(family, files);
|
||||
}
|
||||
|
||||
export const isUserFamilyKnown = (family: string): boolean => familyFiles.has(family);
|
||||
|
||||
export interface UserFamilySource {
|
||||
id: string;
|
||||
fontWeight: number;
|
||||
fontStyle: "normal" | "italic";
|
||||
bytes: Uint8Array;
|
||||
}
|
||||
|
||||
/**
|
||||
* The sources of a user family: its files, plus the upright file of each weight reused as italic when that weight
|
||||
* has no italic of its own (every registered family needs an italic, layout rule 8). Same rule as familySources.
|
||||
*/
|
||||
export function userFamilySources(family: string): UserFamilySource[] {
|
||||
const files = familyFiles.get(family);
|
||||
if (!files) return [];
|
||||
const sources: UserFamilySource[] = files.map((f) => ({
|
||||
id: f.sha256,
|
||||
fontWeight: f.weight,
|
||||
fontStyle: f.italic ? "italic" : "normal",
|
||||
bytes: f.bytes,
|
||||
}));
|
||||
for (const weight of new Set(sources.map((s) => s.fontWeight))) {
|
||||
if (sources.some((s) => s.fontWeight === weight && s.fontStyle === "italic")) continue;
|
||||
const upright = sources.find((s) => s.fontWeight === weight && s.fontStyle === "normal");
|
||||
if (upright) sources.push({ ...upright, fontStyle: "italic" });
|
||||
}
|
||||
return sources;
|
||||
}
|
||||
|
||||
/** `prefs.fonts` (face -> ids of the files in use) as the face -> family map resolveFace takes. */
|
||||
export function faceOverridesFor(fonts: Partial<Record<UserFace, string[]>> | undefined): Record<string, string> | undefined {
|
||||
if (!fonts) return undefined;
|
||||
const out: Record<string, string> = {};
|
||||
for (const face of USER_FACES) {
|
||||
const ids = fonts[face];
|
||||
if (!ids || ids.length === 0) continue;
|
||||
const missing = ids.filter((id) => !filesById.has(id));
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`The font imported for ${face} is not loaded in the renderer (${missing.length} file(s) missing).`);
|
||||
}
|
||||
const family = familyNameFor(face, ids);
|
||||
if (!familyFiles.has(family)) throw new Error(`The font set for ${face} was not registered as a family.`);
|
||||
out[face] = family;
|
||||
}
|
||||
return Object.keys(out).length > 0 ? out : undefined;
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { createElement as h } from "react";
|
||||
import { Document, Page, renderToBuffer, Text } from "@react-pdf/renderer";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createMeasurer } from "../engine/measure";
|
||||
import { DEFAULT_PREFS, parsePrefs } from "../model/prefs";
|
||||
import { registerUserFont, registerUserFontSet } from "./register";
|
||||
import { resolveFace, withFaceOverrides } from "./roles";
|
||||
import { assertSfnt, faceOverridesFor, sniffFontFormat } from "./userFontStore";
|
||||
import { CITRUS_ROLES } from "../templates/northline/citrus";
|
||||
import { PURPLE_ROLES } from "../templates/northline/purple";
|
||||
import { MARBLE_ROLES } from "../templates/marble/tokens";
|
||||
import { TEMPLATE_CATALOG } from "../templates/catalog";
|
||||
import { TEMPLATES } from "../templates/registry";
|
||||
import { ensureSetup, prefsFor, renderFixture } from "../templates/harness";
|
||||
import { gotham } from "../templates/northline/variant";
|
||||
import { FONTS_DIR } from "../testing/setup";
|
||||
|
||||
ensureSetup();
|
||||
|
||||
const font = (folder: string, file: string) => new Uint8Array(readFileSync(path.join(FONTS_DIR, folder, file)));
|
||||
const latin1 = (b: Uint8Array) => Buffer.from(b).toString("latin1");
|
||||
const SPEC = { name: "user-font", items: 3, logo: "none" as const };
|
||||
|
||||
describe("font sniffing and refusal", () => {
|
||||
it("recognises sfnt, WOFF, WOFF2 and collections by their magic bytes", () => {
|
||||
const tag = (s: string) => new Uint8Array([...s].map((c) => c.charCodeAt(0)).concat([0, 0, 0, 0]));
|
||||
expect(sniffFontFormat(new Uint8Array([0, 1, 0, 0, 0, 0]))).toBe("ttf");
|
||||
expect(sniffFontFormat(tag("true"))).toBe("ttf");
|
||||
expect(sniffFontFormat(tag("OTTO"))).toBe("otf");
|
||||
expect(sniffFontFormat(tag("wOFF"))).toBe("woff");
|
||||
expect(sniffFontFormat(tag("wOF2"))).toBe("woff2");
|
||||
expect(sniffFontFormat(tag("ttcf"))).toBe("ttc");
|
||||
expect(sniffFontFormat(tag("<htm"))).toBe("unknown");
|
||||
expect(sniffFontFormat(new Uint8Array([0, 1]))).toBe("unknown");
|
||||
});
|
||||
|
||||
it("refuses WOFF2, WOFF, collections and non-fonts before fontkit sees them", async () => {
|
||||
const jost = font("jost", "Jost-Regular.ttf");
|
||||
expect(assertSfnt(jost)).toBe("ttf");
|
||||
const patched = (magic: string) => {
|
||||
const b = jost.slice();
|
||||
b.set([...magic].map((c) => c.charCodeAt(0)));
|
||||
return b;
|
||||
};
|
||||
expect(() => assertSfnt(patched("wOF2"))).toThrow(/WOFF2/);
|
||||
expect(() => assertSfnt(patched("ttcf"))).toThrow(/collections/);
|
||||
expect(() => assertSfnt(patched("wOFF"))).toThrow(/WOFF/);
|
||||
expect(() => assertSfnt(new TextEncoder().encode("<!doctype html>"))).toThrow(/not a TrueType/);
|
||||
await expect(registerUserFont("Gotham", patched("wOF2"))).rejects.toThrow(/WOFF2/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("prefs carry user fonts", () => {
|
||||
it("keeps valid face/hash lists and drops everything else", () => {
|
||||
const sha = "a".repeat(64);
|
||||
const prefs = parsePrefs(JSON.stringify({ version: 1, templateId: "marble", fonts: { Now: [sha, "zz", 5], Gotham: [], Poppins: [sha], "Open Sauce One": "nope" } }));
|
||||
expect(prefs.fonts).toEqual({ Now: [sha] });
|
||||
expect(parsePrefs(JSON.stringify({ version: 1 })).fonts).toBeUndefined();
|
||||
expect(parsePrefs("{}")).toEqual(DEFAULT_PREFS);
|
||||
});
|
||||
});
|
||||
|
||||
describe("override compensation", () => {
|
||||
it("leaves the tuned values alone without an override", () => {
|
||||
expect(PURPLE_ROLES.label.size).toBe(gotham(12));
|
||||
expect(CITRUS_ROLES.tableBody.size).toBe(gotham(12));
|
||||
expect(MARBLE_ROLES.body.tracking).toBe(0.105);
|
||||
expect(Object.keys(MARBLE_ROLES.body)).toContain("tracking");
|
||||
});
|
||||
|
||||
it("takes the design size and tracking back only while a user font replaces the face", async () => {
|
||||
await withFaceOverrides({ Gotham: "user:Gotham:x", Now: "user:Now:x" }, async () => {
|
||||
expect(PURPLE_ROLES.label.size).toBe(12);
|
||||
expect(CITRUS_ROLES.tableBody.size).toBe(12);
|
||||
expect(MARBLE_ROLES.body.tracking).toBe(0.04);
|
||||
expect({ ...MARBLE_ROLES.body }.tracking).toBe(0.04);
|
||||
// A face that is not overridden keeps its tuned values (Open Sauce One stays Poppins).
|
||||
expect(PURPLE_ROLES.wordmark.size).toBe(94.07);
|
||||
});
|
||||
await withFaceOverrides({ Now: "user:Now:x" }, async () => {
|
||||
expect(PURPLE_ROLES.label.size).toBe(gotham(12));
|
||||
expect(MARBLE_ROLES.body.tracking).toBe(0.04);
|
||||
});
|
||||
expect(PURPLE_ROLES.label.size).toBe(gotham(12));
|
||||
expect(MARBLE_ROLES.body.tracking).toBe(0.105);
|
||||
expect(resolveFace("Gotham")).toBe("Montserrat");
|
||||
});
|
||||
|
||||
it("the catalog lists exactly the licensed faces each template's roles use", () => {
|
||||
for (const entry of TEMPLATE_CATALOG) {
|
||||
const used = new Set(Object.values(TEMPLATES[entry.id].roles).map((t) => t.face).filter((f) => f === "Now" || f === "Gotham" || f === "Open Sauce One"));
|
||||
expect([...(entry.faces ?? [])].sort(), entry.id).toEqual([...used].sort());
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("a user font replaces a template face", () => {
|
||||
it("embeds the imported font in a Purple Pop invoice, and only then", async () => {
|
||||
const lustria = font("lustria", "Lustria-Regular.ttf");
|
||||
const plain = await renderFixture(SPEC, prefsFor("A4", { templateId: "purple-pop" }));
|
||||
expect(latin1(plain.result.bytes)).not.toContain("Lustria");
|
||||
expect(plain.result.bytes.length).toBeGreaterThan(1000);
|
||||
|
||||
const { family, sha256 } = await registerUserFont("Gotham", lustria);
|
||||
expect(family).toMatch(/^user:Gotham:[0-9a-f]{8}$/);
|
||||
const over = await renderFixture(SPEC, prefsFor("A4", { templateId: "purple-pop", fonts: { Gotham: [sha256] } }));
|
||||
expect(latin1(over.result.bytes)).toContain("Lustria");
|
||||
expect(over.result.fingerprint).not.toBe(plain.result.fingerprint);
|
||||
expect(over.result.issues.filter((i) => i.severity === "error")).toEqual([]);
|
||||
|
||||
// The override ended with the render: the same prefs without fonts give the original fingerprint again.
|
||||
const after = await renderFixture(SPEC, prefsFor("A4", { templateId: "purple-pop" }));
|
||||
expect(after.result.fingerprint).toBe(plain.result.fingerprint);
|
||||
}, 60_000);
|
||||
|
||||
it("a Marble invoice with Now replaced by a user font lays out and embeds it", async () => {
|
||||
const { sha256 } = await registerUserFont("Now", font("dm-sans", "DMSans-Regular.ttf"));
|
||||
const plain = await renderFixture(SPEC, prefsFor("A4", { templateId: "marble" }));
|
||||
const over = await renderFixture(SPEC, prefsFor("A4", { templateId: "marble", fonts: { Now: [sha256] } }));
|
||||
expect(latin1(over.result.bytes)).toContain("DMSans");
|
||||
expect(latin1(plain.result.bytes)).not.toContain("DMSans");
|
||||
expect(over.pdf.pages[0].items.map((i) => i.str).join(" ")).toContain("₹");
|
||||
}, 60_000);
|
||||
|
||||
it("fails clearly when prefs name a font the renderer was never given", async () => {
|
||||
const prefs = prefsFor("A4", { templateId: "purple-pop", fonts: { Gotham: ["f".repeat(64)] } });
|
||||
await expect(renderFixture(SPEC, prefs)).rejects.toThrow(/not loaded/);
|
||||
});
|
||||
|
||||
it("every weight gets an italic: a set registers its upright files as italics, and italic text does not throw", async () => {
|
||||
const regular = font("montserrat", "Montserrat-Regular.ttf");
|
||||
const bold = font("montserrat", "Montserrat-Bold.ttf");
|
||||
const family = await registerUserFontSet("Open Sauce One", [
|
||||
{ face: "Open Sauce One", weight: 400, italic: false, sha256: "1".repeat(16), bytes: regular },
|
||||
{ face: "Open Sauce One", weight: 700, italic: false, sha256: "2".repeat(16), bytes: bold },
|
||||
]);
|
||||
const render = (fontWeight: number) =>
|
||||
renderToBuffer(h(Document, null, h(Page, { size: "A4" }, h(Text, { style: { fontFamily: [family], fontStyle: "italic", fontWeight } }, "Total ₹1,200.00"))));
|
||||
for (const w of [400, 700]) expect((await render(w)).subarray(0, 5).toString()).toBe("%PDF-");
|
||||
// Registering the same set again is a no-op.
|
||||
expect(await registerUserFontSet("Open Sauce One", [
|
||||
{ face: "Open Sauce One", weight: 700, italic: false, sha256: "2".repeat(16), bytes: bold },
|
||||
{ face: "Open Sauce One", weight: 400, italic: false, sha256: "1".repeat(16), bytes: regular },
|
||||
])).toBe(family);
|
||||
expect(faceOverridesFor({ "Open Sauce One": ["1".repeat(16), "2".repeat(16)] })).toEqual({ "Open Sauce One": family });
|
||||
});
|
||||
|
||||
it("the measurer reads user families from memory, bold and italic included", async () => {
|
||||
const m = createMeasurer((file) => new Uint8Array(readFileSync(path.join(FONTS_DIR, file))));
|
||||
const poppins = font("poppins", "Poppins-Regular.ttf");
|
||||
const { family } = await registerUserFont("Gotham", poppins, { weight: 400 });
|
||||
const own = m.measureWidth({ text: "Consultation", face: family, weight: 400, size: 12 });
|
||||
const plex = m.measureWidth({ text: "Consultation", face: "Poppins", weight: 400, size: 12 });
|
||||
expect(own).toBeCloseTo(plex, 6);
|
||||
expect(m.measureWidth({ text: "Consultation", face: family, weight: 700, italic: true, size: 12 })).toBeCloseTo(plex, 6);
|
||||
expect(m.missing("₹", family)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { MarginPreset, PageId } from "../engine/geometry";
|
||||
import { isUserFace, type UserFace } from "../fonts/userFontStore";
|
||||
|
||||
/** Page and template choices frozen with an invoice at issue (stored as JSON). */
|
||||
export interface RenderPrefsV1 {
|
||||
@@ -12,6 +13,11 @@ export interface RenderPrefsV1 {
|
||||
/** Templates without a Canva logo placeholder (Linea, ...) show the logo only when this is true. */
|
||||
show?: boolean;
|
||||
};
|
||||
/**
|
||||
* User-imported fonts in use, per face (Now, Gotham, Open Sauce One): the sha256 of each file (one per weight/style).
|
||||
* Frozen with the invoice, which is what keeps those font files from being deleted. Absent: the bundled substitutes.
|
||||
*/
|
||||
fonts?: Partial<Record<UserFace, string[]>>;
|
||||
}
|
||||
|
||||
export const DEFAULT_PREFS: RenderPrefsV1 = {
|
||||
@@ -22,6 +28,7 @@ export const DEFAULT_PREFS: RenderPrefsV1 = {
|
||||
};
|
||||
|
||||
const PAGE_SIZE_IDS: readonly PageId[] = ["A4", "LETTER"];
|
||||
const FONT_ID = /^[0-9a-f]{16,64}$/;
|
||||
const MARGIN_IDS: readonly MarginPreset[] = ["template", "narrow", "normal"];
|
||||
|
||||
/** Tolerant: anything unreadable or out of range falls back to the default for that field. */
|
||||
@@ -49,6 +56,16 @@ export function parsePrefs(json: string | null | undefined): RenderPrefsV1 {
|
||||
if (typeof show === "boolean") out.show = show;
|
||||
if (Object.keys(out).length > 0) prefs.logo = out;
|
||||
}
|
||||
const fonts = o.fonts;
|
||||
if (typeof fonts === "object" && fonts !== null) {
|
||||
const out: NonNullable<RenderPrefsV1["fonts"]> = {};
|
||||
for (const [face, ids] of Object.entries(fonts as Record<string, unknown>)) {
|
||||
if (!isUserFace(face) || !Array.isArray(ids)) continue;
|
||||
const valid = ids.filter((id): id is string => typeof id === "string" && FONT_ID.test(id)).slice(0, 16);
|
||||
if (valid.length > 0) out[face] = valid;
|
||||
}
|
||||
if (Object.keys(out).length > 0) prefs.fonts = out;
|
||||
}
|
||||
return prefs;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ class FakeWorker implements WorkerLike {
|
||||
onerror: WorkerLike["onerror"] = null;
|
||||
sent: WorkerRequest[] = [];
|
||||
terminated = false;
|
||||
postMessage(message: WorkerRequest) {
|
||||
postMessage(message: WorkerRequest, _transfer?: Transferable[]) {
|
||||
this.sent.push(message);
|
||||
}
|
||||
terminate() {
|
||||
@@ -378,3 +378,94 @@ describe("main-thread client and fallback", () => {
|
||||
expect((await client.render(job(8))).id).toBe(8);
|
||||
});
|
||||
});
|
||||
|
||||
describe("user fonts in render jobs", () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
const file = (sha: string, bytes: number[]) => ({ face: "Gotham" as const, weight: 400, italic: false, sha256: sha, bytes: new Uint8Array(bytes) });
|
||||
const withFonts = (id: number, files: ReturnType<typeof file>[]): RenderJob => ({
|
||||
id,
|
||||
model: {} as RenderJob["model"],
|
||||
prefs: { fonts: { Gotham: files.map((f) => f.sha256) } } as unknown as RenderJob["prefs"],
|
||||
userFonts: files,
|
||||
});
|
||||
|
||||
it("sends each font to a worker once, as transferred copies, and again to a replacement worker", async () => {
|
||||
const posts: Array<{ job: RenderJob; transfer: Transferable[] | undefined }> = [];
|
||||
const workers: FakeWorker[] = [];
|
||||
const client = createRenderClient({
|
||||
timeoutMs: 1000,
|
||||
workerFactory: () => {
|
||||
const w = new FakeWorker();
|
||||
w.postMessage = (message: WorkerRequest, transfer?: Transferable[]) => {
|
||||
posts.push({ job: (message as { job: RenderJob }).job, transfer });
|
||||
w.sent.push(message);
|
||||
};
|
||||
workers.push(w);
|
||||
return w;
|
||||
},
|
||||
});
|
||||
const master = file("a".repeat(16), [1, 2, 3]);
|
||||
const p1 = client.render(withFonts(1, [master]));
|
||||
workers[0].ready();
|
||||
expect(posts[0].job.userFonts?.map((f) => f.sha256)).toEqual([master.sha256]);
|
||||
expect(posts[0].job.userFonts?.[0].bytes).not.toBe(master.bytes);
|
||||
expect(posts[0].transfer).toHaveLength(1);
|
||||
expect(Array.from(master.bytes)).toEqual([1, 2, 3]);
|
||||
workers[0].reply(1);
|
||||
await p1;
|
||||
|
||||
const extra = file("b".repeat(16), [9]);
|
||||
const p2 = client.render(withFonts(2, [master, extra]));
|
||||
expect(posts[1].job.userFonts?.map((f) => f.sha256)).toEqual([extra.sha256]);
|
||||
workers[0].reply(2);
|
||||
await p2;
|
||||
|
||||
// A worker replaced after a timeout starts with nothing, so every font goes again.
|
||||
const p3 = client.render(withFonts(3, [master]));
|
||||
workers[0].reply(3);
|
||||
await p3;
|
||||
const p4 = client.render(withFonts(4, [master, extra]));
|
||||
const timedOut = settled(p4);
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(await timedOut).toBeInstanceOf(RenderTimeout);
|
||||
expect(workers).toHaveLength(2);
|
||||
const p5 = client.render(withFonts(5, [master, extra]));
|
||||
workers[1].ready();
|
||||
expect(posts[posts.length - 1].job.userFonts?.map((f) => f.sha256).sort()).toEqual([master.sha256, extra.sha256]);
|
||||
workers[1].reply(5);
|
||||
await p5;
|
||||
});
|
||||
|
||||
it("the auto client attaches the bytes from the loader and reads each file once", async () => {
|
||||
vi.useRealTimers();
|
||||
const reads: string[] = [];
|
||||
const workers: FakeWorker[] = [];
|
||||
const client = createAutoClient({
|
||||
workerFactory: () => {
|
||||
const w = new FakeWorker();
|
||||
workers.push(w);
|
||||
return w;
|
||||
},
|
||||
loadUserFont: async (sha) => {
|
||||
reads.push(sha);
|
||||
return file(sha, [7]);
|
||||
},
|
||||
});
|
||||
const prefs = { fonts: { Gotham: ["c".repeat(16)] } } as unknown as RenderJob["prefs"];
|
||||
const p1 = client.render({ id: 1, model: {} as RenderJob["model"], prefs });
|
||||
await vi.waitFor(() => expect(workers).toHaveLength(1));
|
||||
workers[0].ready();
|
||||
await vi.waitFor(() => expect(workers[0].sent).toHaveLength(1));
|
||||
expect((workers[0].sent[0] as { job: RenderJob }).job.userFonts?.map((f) => f.sha256)).toEqual(["c".repeat(16)]);
|
||||
workers[0].reply(1);
|
||||
await p1;
|
||||
const p2 = client.render({ id: 2, model: {} as RenderJob["model"], prefs });
|
||||
await vi.waitFor(() => expect(workers[0].sent).toHaveLength(2));
|
||||
expect((workers[0].sent[1] as { job: RenderJob }).job.userFonts).toEqual([]);
|
||||
workers[0].reply(2);
|
||||
await p2;
|
||||
expect(reads).toEqual(["c".repeat(16)]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { UserFontFile } from "../fonts/userFontStore";
|
||||
import type { AssembleJob, AssembleResult, RenderJob, RenderResult, WorkerMessage, WorkerRequest } from "./jobs";
|
||||
|
||||
/** A newer render call replaced this one before it finished (or before it started). */
|
||||
@@ -35,7 +36,7 @@ export class RenderFailed extends Error {
|
||||
|
||||
/** The part of Worker the client uses, so tests can supply a fake. */
|
||||
export interface WorkerLike {
|
||||
postMessage(message: WorkerRequest): void;
|
||||
postMessage(message: WorkerRequest, transfer?: Transferable[]): void;
|
||||
terminate(): void;
|
||||
onmessage: ((event: { data: WorkerMessage }) => void) | null;
|
||||
onerror: ((event: { message?: string }) => void) | null;
|
||||
@@ -55,6 +56,11 @@ export interface RenderClient {
|
||||
}
|
||||
|
||||
export interface RenderClientOptions {
|
||||
/**
|
||||
* Returns a user font file by its sha256 (the app reads it from the database). Needed only for jobs whose
|
||||
* prefs name user fonts; the result is cached, so each file is read once per session.
|
||||
*/
|
||||
loadUserFont?: (sha256: string) => Promise<UserFontFile>;
|
||||
workerFactory?: () => WorkerLike;
|
||||
/** Per-job watchdog. react-pdf's paginate loop is synchronous, so a layout bug hangs the worker for good. */
|
||||
timeoutMs?: number;
|
||||
@@ -85,8 +91,21 @@ type Running = Slot & {
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
};
|
||||
|
||||
const requestFor = (slot: Slot): WorkerRequest =>
|
||||
slot.kind === "render" ? { type: "render", job: slot.job } : { type: "assemble-images", job: slot.job };
|
||||
/**
|
||||
* The message for a slot plus what to transfer. A render's user fonts are cut down to those this worker has not
|
||||
* been sent (`sent`), and each goes as a transferred copy: the caller's bytes are a master copy that must survive
|
||||
* a worker restart, and a transferred buffer is detached on this side.
|
||||
*/
|
||||
function requestFor(slot: Slot, sent: Set<string>): { request: WorkerRequest; transfer: Transferable[]; ids: string[] } {
|
||||
if (slot.kind !== "render") return { request: { type: "assemble-images", job: slot.job }, transfer: [], ids: [] };
|
||||
const fresh = (slot.job.userFonts ?? []).filter((f) => !sent.has(f.sha256));
|
||||
const copies = fresh.map((f) => ({ ...f, bytes: f.bytes.slice() }));
|
||||
return {
|
||||
request: { type: "render", job: { ...slot.job, userFonts: copies } },
|
||||
transfer: copies.map((f) => f.bytes.buffer),
|
||||
ids: fresh.map((f) => f.sha256),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders in a module worker. Only the newest render matters: a new call rejects the render in flight
|
||||
@@ -105,11 +124,14 @@ export function createRenderClient(opts: RenderClientOptions = {}): RenderClient
|
||||
let running: Running | null = null;
|
||||
let pending: RenderSlot | null = null;
|
||||
const assembles: AssembleSlot[] = [];
|
||||
/** Ids of the user font files the current worker has been sent; a new worker starts with none. */
|
||||
const sent = new Set<string>();
|
||||
|
||||
const stopWorker = () => {
|
||||
const w = worker;
|
||||
worker = null;
|
||||
ready = false;
|
||||
sent.clear();
|
||||
if (w) {
|
||||
w.onmessage = null;
|
||||
w.onerror = null;
|
||||
@@ -147,7 +169,9 @@ export function createRenderClient(opts: RenderClientOptions = {}): RenderClient
|
||||
}
|
||||
if (!slot) return;
|
||||
running = { ...slot, superseded: false, timer: setTimeout(onTimeout, timeoutMs) };
|
||||
worker.postMessage(requestFor(slot));
|
||||
const { request, transfer, ids } = requestFor(slot, sent);
|
||||
worker.postMessage(request, transfer);
|
||||
for (const id of ids) sent.add(id);
|
||||
};
|
||||
|
||||
const spawn = () => {
|
||||
@@ -239,8 +263,13 @@ export type MainExecutor = (job: RenderJob) => Promise<RenderResult>;
|
||||
|
||||
// Loaded on demand so react-pdf stays out of the main bundle unless the worker is unavailable.
|
||||
const runOnMainThread: MainExecutor = async (job) => {
|
||||
const [{ initBrowserRendering }, { renderInvoicePdf }] = await Promise.all([import("./browserInit"), import("./invoice")]);
|
||||
const [{ initBrowserRendering }, { renderInvoicePdf }, { registerJobFonts }] = await Promise.all([
|
||||
import("./browserInit"),
|
||||
import("./invoice"),
|
||||
import("./userFontJob"),
|
||||
]);
|
||||
await initBrowserRendering();
|
||||
await registerJobFonts(job.prefs, job.userFonts);
|
||||
// Let pending input and paint run before the long synchronous layout.
|
||||
await new Promise<void>((r) => setTimeout(r, 0));
|
||||
const t0 = performance.now();
|
||||
@@ -337,6 +366,27 @@ export function createAutoClient(
|
||||
): RenderClient {
|
||||
const primary = createRenderClient(opts);
|
||||
let main: RenderClient | null = null;
|
||||
const fontCache = new Map<string, Promise<UserFontFile>>();
|
||||
let renderCalls = 0;
|
||||
/** Attaches the bytes of the user fonts the job's prefs name (read once, then cached). */
|
||||
const withFonts = async (job: RenderJob): Promise<RenderJob> => {
|
||||
const ids = Object.values(job.prefs.fonts ?? {}).flat();
|
||||
if (ids.length === 0) return job;
|
||||
const load = opts.loadUserFont;
|
||||
if (!load) throw new Error("This render needs user fonts but no font loader is configured");
|
||||
const files = await Promise.all(
|
||||
ids.map((id) => {
|
||||
let p = fontCache.get(id);
|
||||
if (!p) {
|
||||
p = load(id);
|
||||
fontCache.set(id, p);
|
||||
p.catch(() => fontCache.delete(id));
|
||||
}
|
||||
return p;
|
||||
}),
|
||||
);
|
||||
return { ...job, userFonts: files };
|
||||
};
|
||||
const fallback = () => {
|
||||
main ??= createMainThreadClient(opts.mainExecutor, opts.mainAssembleExecutor);
|
||||
primary.dispose();
|
||||
@@ -346,7 +396,11 @@ export function createAutoClient(
|
||||
get mode() {
|
||||
return main ? "main" : "worker";
|
||||
},
|
||||
render(job) {
|
||||
async render(plain) {
|
||||
const call = ++renderCalls;
|
||||
const job = await withFonts(plain);
|
||||
// Reading fonts took time; keep latest-wins: a render requested meanwhile replaces this one.
|
||||
if (call !== renderCalls) throw new Superseded();
|
||||
if (main) return main.render(job);
|
||||
return primary.render(job).catch((err) => {
|
||||
if (!(err instanceof WorkerUnavailable)) throw err;
|
||||
|
||||
@@ -4,6 +4,8 @@ import { computeFrame, PAGE_SIZES, type PageFrame } from "../engine/geometry";
|
||||
import type { AuditIssue } from "../engine/audit";
|
||||
import type { RenderModel } from "../model/build";
|
||||
import type { RenderPrefsV1 } from "../model/prefs";
|
||||
import { withFaceOverrides } from "../fonts/roles";
|
||||
import { faceOverridesFor } from "../fonts/userFontStore";
|
||||
import { CLOSING_ID } from "../engine/ids";
|
||||
import { marginsFor, type PlacedLogo } from "../templates/contract";
|
||||
import { getTemplate } from "../templates/registry";
|
||||
@@ -41,7 +43,12 @@ export function frameForPrefs(prefs: RenderPrefsV1): PageFrame {
|
||||
* model + prefs -> template, frame, logo placement and keep-outs -> InvoicePdf -> renderCore. Needs
|
||||
* fonts registered and a measurer installed (render/browserInit.ts in the app, testing/setup.ts in tests).
|
||||
*/
|
||||
export async function renderInvoicePdf({ model, prefs }: RenderInvoiceInput, bytesOf?: RenderBytes): Promise<RenderInvoiceResult> {
|
||||
export function renderInvoicePdf(input: RenderInvoiceInput, bytesOf?: RenderBytes): Promise<RenderInvoiceResult> {
|
||||
// prefs.fonts names the user fonts (already registered by the caller) that replace the substitute faces.
|
||||
return withFaceOverrides(faceOverridesFor(input.prefs.fonts), () => renderInvoicePdfWith(input, bytesOf));
|
||||
}
|
||||
|
||||
async function renderInvoicePdfWith({ model, prefs }: RenderInvoiceInput, bytesOf?: RenderBytes): Promise<RenderInvoiceResult> {
|
||||
const template = getTemplate(prefs.templateId);
|
||||
const frame = frameForPrefs(prefs);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { AuditIssue } from "../engine/audit";
|
||||
import type { RenderModel } from "../model/build";
|
||||
import type { UserFontFile } from "../fonts/userFontStore";
|
||||
import type { RenderPrefsV1 } from "../model/prefs";
|
||||
|
||||
// Everything here crosses postMessage, so it stays plain data: no functions, classes or Maps.
|
||||
@@ -8,6 +9,11 @@ export interface RenderJob {
|
||||
id: number;
|
||||
model: RenderModel;
|
||||
prefs: RenderPrefsV1;
|
||||
/**
|
||||
* Bytes of the user fonts `prefs.fonts` names. The caller attaches all of them; the worker client sends only the
|
||||
* ones that worker does not hold yet (as transferred copies, so the caller's master copy stays usable).
|
||||
*/
|
||||
userFonts?: UserFontFile[];
|
||||
}
|
||||
|
||||
export interface RenderResult {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { installStreamAsyncIterator } from "../../lib/streamPolyfill";
|
||||
import { initBrowserRendering } from "./browserInit";
|
||||
import { runAssembleJob } from "./assembleJob";
|
||||
import { renderInvoicePdf } from "./invoice";
|
||||
import { registerJobFonts } from "./userFontJob";
|
||||
import type { WorkerMessage, WorkerRequest } from "./jobs";
|
||||
|
||||
interface WorkerScope {
|
||||
@@ -44,6 +45,7 @@ scope.onmessage = async (event) => {
|
||||
const { job } = request;
|
||||
try {
|
||||
await started.catch(() => initBrowserRendering());
|
||||
await registerJobFonts(job.prefs, job.userFonts);
|
||||
const t0 = performance.now();
|
||||
const out = await renderInvoicePdf({ model: job.model, prefs: job.prefs });
|
||||
const ms = Math.round(performance.now() - t0);
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { registerUserFontSet } from "../fonts/register";
|
||||
import { getUserFontFile, rememberUserFile, USER_FACES, type UserFontFile } from "../fonts/userFontStore";
|
||||
import type { RenderPrefsV1 } from "../model/prefs";
|
||||
|
||||
/**
|
||||
* Makes the user fonts a render's prefs name available to react-pdf and the measurer, before the render runs.
|
||||
* `incoming` are the files the client sent with this job (only those this renderer has not been sent yet); the
|
||||
* others are already held from earlier jobs. Registering a set is idempotent.
|
||||
*/
|
||||
export async function registerJobFonts(prefs: RenderPrefsV1, incoming: readonly UserFontFile[] = []): Promise<void> {
|
||||
for (const file of incoming) rememberUserFile(file);
|
||||
for (const face of USER_FACES) {
|
||||
const ids = prefs.fonts?.[face];
|
||||
if (!ids || ids.length === 0) continue;
|
||||
const files = ids.map((id) => getUserFontFile(id));
|
||||
if (files.some((f) => !f)) throw new Error(`The font imported for ${face} was not delivered to the renderer`);
|
||||
await registerUserFontSet(face, files as UserFontFile[]);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,11 @@ export interface TemplateCatalogEntry {
|
||||
badge?: "Matches your original format";
|
||||
/** No Canva placeholder: the logo shows only when the user turns it on. */
|
||||
logoOptIn?: boolean;
|
||||
/**
|
||||
* The licensed faces this template draws (a user-imported font may replace them). Mirrors the roles of the
|
||||
* template; a test keeps the two in step.
|
||||
*/
|
||||
faces?: readonly ("Now" | "Gotham" | "Open Sauce One")[];
|
||||
}
|
||||
|
||||
export const FAMILY_LABELS: Record<TemplateCatalogEntry["family"], string> = {
|
||||
@@ -32,11 +37,11 @@ export const TEMPLATE_CATALOG: readonly TemplateCatalogEntry[] = [
|
||||
{ id: "tangerine-ledger", name: "Tangerine Ledger", family: "ledger", thumbnail: "/templates/tangerine-ledger.png" },
|
||||
{ id: "slate-band", name: "Slate Band", family: "band", thumbnail: "/templates/slate-band.png" },
|
||||
{ id: "teal-swoosh", name: "Teal Swoosh", family: "band", thumbnail: "/templates/teal-swoosh.png" },
|
||||
{ id: "purple-pop", name: "Purple Pop", family: "northline", thumbnail: "/templates/purple-pop.png", logoOptIn: true },
|
||||
{ id: "citrus-split", name: "Citrus Split", family: "northline", thumbnail: "/templates/citrus-split.png" },
|
||||
{ id: "purple-pop", name: "Purple Pop", family: "northline", thumbnail: "/templates/purple-pop.png", logoOptIn: true, faces: ["Gotham", "Open Sauce One"] },
|
||||
{ id: "citrus-split", name: "Citrus Split", family: "northline", thumbnail: "/templates/citrus-split.png", faces: ["Now", "Gotham"] },
|
||||
{ id: "monolith", name: "Monolith", family: "standalone", thumbnail: "/templates/monolith.png" },
|
||||
{ id: "cobalt-stripe", name: "Cobalt Stripe", family: "standalone", thumbnail: "/templates/cobalt-stripe.png" },
|
||||
{ id: "marble", name: "Marble", family: "standalone", thumbnail: "/templates/marble.png", logoOptIn: true },
|
||||
{ id: "marble", name: "Marble", family: "standalone", thumbnail: "/templates/marble.png", logoOptIn: true, faces: ["Now"] },
|
||||
{ id: "highlighter", name: "Highlighter", family: "standalone", thumbnail: "/templates/highlighter.png", logoOptIn: true },
|
||||
];
|
||||
|
||||
|
||||
@@ -18,31 +18,32 @@ export const BAND_TO = "#ECEAEE";
|
||||
* Marble's type (docs/templates/01-marble.md): Lustria in tracked caps for the title, headings, table heads and the
|
||||
* amount due; "Now" (drawn in Jost, the bundled look-alike) for everything else. Jost sets about 12% narrower than Now,
|
||||
* so every Now role carries the tracking that restores the Canva widths (0.10 to 0.13 em on top of the design's
|
||||
* 0.04 em). Lustria ascent 0.955, descent 0.327; Jost 1.07 / 0.375.
|
||||
* 0.04 em). A real Now imported by the user replaces Jost, so those roles take the design's 0.04 em back through
|
||||
* ownFont. Lustria ascent 0.955, descent 0.327; Jost 1.07 / 0.375.
|
||||
*/
|
||||
export const MARBLE_ROLES = defineRoles({
|
||||
/** "INVOICE" / "TAX INVOICE": 25.18 pt, tracked 0.192 em, shrunk to the room the stack has. */
|
||||
title: { face: L, weight: 400, size: 25.18, leading: 25.2, tracking: 0.192, caps: true, minSize: 13 },
|
||||
/** The vendor's name at the header's left (the design leaves it empty). */
|
||||
brand: { face: L, weight: 400, size: 12.65, leading: 18.2, tracking: 0.11, caps: true, minSize: 9 },
|
||||
meta: { face: N, weight: 400, size: 11.55, leading: 17.5, tracking: 0.105, minSize: 8 },
|
||||
meta: { face: N, weight: 400, size: 11.55, leading: 17.5, tracking: 0.105, ownFont: { tracking: 0.04 }, minSize: 8 },
|
||||
/** BILL TO:, PAYABLE TO:, SUPPLIER:. */
|
||||
heading: { face: L, weight: 400, size: 12.65, leading: 18.2, tracking: 0.11, caps: true },
|
||||
body: { face: N, weight: 400, size: 11.55, leading: 18.2, tracking: 0.105, minSize: 8 },
|
||||
body: { face: N, weight: 400, size: 11.55, leading: 18.2, tracking: 0.105, ownFont: { tracking: 0.04 }, minSize: 8 },
|
||||
tableHead: { face: L, weight: 400, size: 11.68, leading: 16, tracking: 0.192, caps: true },
|
||||
tableBody: { face: N, weight: 400, size: 10.57, leading: 17.4, tracking: 0.102 },
|
||||
tableSub: { face: N, weight: 400, size: 8.5, leading: 11.5, tracking: 0.1 },
|
||||
tableBody: { face: N, weight: 400, size: 10.57, leading: 17.4, tracking: 0.102, ownFont: { tracking: 0.04 } },
|
||||
tableSub: { face: N, weight: 400, size: 8.5, leading: 11.5, tracking: 0.1, ownFont: { tracking: 0.04 } },
|
||||
totalLabel: { face: L, weight: 400, size: 9, leading: 16, tracking: 0.192, caps: true },
|
||||
totalValue: { face: N, weight: 400, size: 10.57, leading: 16, tracking: 0.102 },
|
||||
totalValue: { face: N, weight: 400, size: 10.57, leading: 16, tracking: 0.102, ownFont: { tracking: 0.04 } },
|
||||
/** AMOUNT DUE: 14.6 pt, tracked 0.192 em. */
|
||||
due: { face: L, weight: 400, size: 14.6, leading: 20, tracking: 0.192, caps: true, minSize: 10 },
|
||||
small: { face: N, weight: 400, size: 9.6, leading: 15.3, tracking: 0.13 },
|
||||
small: { face: N, weight: 400, size: 9.6, leading: 15.3, tracking: 0.13, ownFont: { tracking: 0.04 } },
|
||||
smallLabel: { face: L, weight: 400, size: 9, leading: 15.3, tracking: 0.192, caps: true },
|
||||
signName: { face: N, weight: 400, size: 10.57, leading: 15, tracking: 0.102 },
|
||||
signDesignation: { face: N, weight: 400, size: 9.6, leading: 14, tracking: 0.13 },
|
||||
fine: { face: N, weight: 400, size: 8, leading: 11, tracking: 0.1 },
|
||||
signName: { face: N, weight: 400, size: 10.57, leading: 15, tracking: 0.102, ownFont: { tracking: 0.04 } },
|
||||
signDesignation: { face: N, weight: 400, size: 9.6, leading: 14, tracking: 0.13, ownFont: { tracking: 0.04 } },
|
||||
fine: { face: N, weight: 400, size: 8, leading: 11, tracking: 0.1, ownFont: { tracking: 0.04 } },
|
||||
/** The footer band's contact lines. */
|
||||
contact: { face: N, weight: 400, size: 9.6, leading: 15.3, tracking: 0.13, minSize: 7 },
|
||||
contact: { face: N, weight: 400, size: 9.6, leading: 15.3, tracking: 0.13, ownFont: { tracking: 0.04 }, minSize: 7 },
|
||||
footer: { face: N, weight: 400, size: 7 },
|
||||
});
|
||||
|
||||
|
||||
@@ -28,21 +28,21 @@ export const CITRUS_ROLES = defineRoles({
|
||||
metaStrong: { face: DM, weight: 700, size: 12.36, leading: 16, minSize: 8 },
|
||||
billedLabel: { face: DM, weight: 700, size: 12.4, leading: 17, caps: true },
|
||||
clientName: { face: DM, weight: 700, size: 14.87, leading: 19, caps: true },
|
||||
clientLine: { face: G, weight: 400, size: gotham(10.5), leading: 15.5 },
|
||||
headService: { face: G, weight: 700, size: gotham(15.17), leading: 18, tracking: 0.15, caps: true },
|
||||
headNum: { face: G, weight: 700, size: gotham(14.17), leading: 18, caps: true },
|
||||
tableBody: { face: G, weight: 400, size: gotham(12), leading: 15 },
|
||||
clientLine: { face: G, weight: 400, size: gotham(10.5), ownFont: { size: 10.5 }, leading: 15.5 },
|
||||
headService: { face: G, weight: 700, size: gotham(15.17), ownFont: { size: 15.17 }, leading: 18, tracking: 0.15, caps: true },
|
||||
headNum: { face: G, weight: 700, size: gotham(14.17), ownFont: { size: 14.17 }, leading: 18, caps: true },
|
||||
tableBody: { face: G, weight: 400, size: gotham(12), ownFont: { size: 12 }, leading: 15 },
|
||||
tableSub: { face: G, weight: 400, size: 9, leading: 11.5 },
|
||||
totalLabel: { face: G, weight: 700, size: gotham(12.5), leading: 17, caps: true },
|
||||
totalValue: { face: G, weight: 400, size: gotham(12.5), leading: 17 },
|
||||
grand: { face: G, weight: 700, size: gotham(14.7), leading: 18, caps: true },
|
||||
grandValue: { face: G, weight: 700, size: gotham(14.7), leading: 18 },
|
||||
totalLabel: { face: G, weight: 700, size: gotham(12.5), ownFont: { size: 12.5 }, leading: 17, caps: true },
|
||||
totalValue: { face: G, weight: 400, size: gotham(12.5), ownFont: { size: 12.5 }, leading: 17 },
|
||||
grand: { face: G, weight: 700, size: gotham(14.7), ownFont: { size: 14.7 }, leading: 18, caps: true },
|
||||
grandValue: { face: G, weight: 700, size: gotham(14.7), ownFont: { size: 14.7 }, leading: 18 },
|
||||
words: { face: DM, weight: 700, size: 11, leading: 14 },
|
||||
wordsLabel: { face: DM, weight: 700, size: 11, leading: 14 },
|
||||
sectionLabel: { face: DM, weight: 700, size: 12, leading: 16, caps: true },
|
||||
bank: { face: G, weight: 400, size: gotham(11), leading: 15 },
|
||||
bullet: { face: G, weight: 400, size: gotham(12), leading: 16.5 },
|
||||
contact: { face: G, weight: 400, size: gotham(12.77), leading: 18, minSize: 7 },
|
||||
bank: { face: G, weight: 400, size: gotham(11), ownFont: { size: 11 }, leading: 15 },
|
||||
bullet: { face: G, weight: 400, size: gotham(12), ownFont: { size: 12 }, leading: 16.5 },
|
||||
contact: { face: G, weight: 400, size: gotham(12.77), ownFont: { size: 12.77 }, leading: 18, minSize: 7 },
|
||||
small: { face: DM, weight: 400, size: 9, leading: 12 },
|
||||
fine: { face: DM, weight: 400, size: 7, leading: 10 },
|
||||
signName: { face: DM, weight: 700, size: 9.5, leading: 13 },
|
||||
|
||||
@@ -22,25 +22,25 @@ export const PURPLE_ROLES = defineRoles({
|
||||
wordmark: { face: "Open Sauce One", weight: 800, size: 94.07, leading: 84, tracking: -0.08, minSize: 48 },
|
||||
taxCaption: { face: DM, weight: 700, size: 14, leading: 18, tracking: 0.15, caps: true },
|
||||
brand: { face: DM, weight: 700, size: 17.77, leading: 21, minSize: 12 },
|
||||
pill: { face: G, weight: 700, size: gotham(13.39), leading: 17, tracking: 0.13, caps: true, minSize: 8.5 },
|
||||
label: { face: G, weight: 700, size: gotham(12), leading: 16, caps: true },
|
||||
clientName: { face: G, weight: 700, size: gotham(14.39), leading: 18, caps: true },
|
||||
clientLine: { face: G, weight: 400, size: gotham(10.16), leading: 15.5 },
|
||||
pill: { face: G, weight: 700, size: gotham(13.39), ownFont: { size: 13.39 }, leading: 17, tracking: 0.13, caps: true, minSize: 8.5 },
|
||||
label: { face: G, weight: 700, size: gotham(12), ownFont: { size: 12 }, leading: 16, caps: true },
|
||||
clientName: { face: G, weight: 700, size: gotham(14.39), ownFont: { size: 14.39 }, leading: 18, caps: true },
|
||||
clientLine: { face: G, weight: 400, size: gotham(10.16), ownFont: { size: 10.16 }, leading: 15.5 },
|
||||
amount: { face: DM, weight: 700, size: 32.74, leading: 36, minSize: 20, tnum: true },
|
||||
date: { face: DM, weight: 700, size: 14.05, leading: 19.5, caps: true, minSize: 10 },
|
||||
tableHead: { face: DM, weight: 700, size: 14, leading: 18, caps: true },
|
||||
tableBody: { face: DM, weight: 400, size: 11.83, leading: 15 },
|
||||
tableSub: { face: DM, weight: 400, size: 9, leading: 11.5 },
|
||||
totalLabel: { face: G, weight: 700, size: gotham(13), leading: 17, caps: true },
|
||||
totalValue: { face: G, weight: 400, size: gotham(13), leading: 17 },
|
||||
grand: { face: G, weight: 700, size: gotham(13), leading: 17, caps: true },
|
||||
grandValue: { face: G, weight: 700, size: gotham(13), leading: 17 },
|
||||
totalLabel: { face: G, weight: 700, size: gotham(13), ownFont: { size: 13 }, leading: 17, caps: true },
|
||||
totalValue: { face: G, weight: 400, size: gotham(13), ownFont: { size: 13 }, leading: 17 },
|
||||
grand: { face: G, weight: 700, size: gotham(13), ownFont: { size: 13 }, leading: 17, caps: true },
|
||||
grandValue: { face: G, weight: 700, size: gotham(13), ownFont: { size: 13 }, leading: 17 },
|
||||
words: { face: DM, weight: 400, size: 10, leading: 13 },
|
||||
wordsLabel: { face: DM, weight: 700, size: 10, leading: 13 },
|
||||
termsLabel: { face: DM, weight: 700, size: 13, leading: 17, caps: true },
|
||||
bullet: { face: DM, weight: 400, size: 11, leading: 14.2 },
|
||||
bank: { face: G, weight: 400, size: gotham(11), leading: 12.5 },
|
||||
contact: { face: G, weight: 400, size: gotham(10.14), leading: 15, minSize: 7 },
|
||||
bank: { face: G, weight: 400, size: gotham(11), ownFont: { size: 11 }, leading: 12.5 },
|
||||
contact: { face: G, weight: 400, size: gotham(10.14), ownFont: { size: 10.14 }, leading: 15, minSize: 7 },
|
||||
small: { face: DM, weight: 400, size: 9, leading: 12 },
|
||||
fine: { face: DM, weight: 400, size: 7, leading: 10 },
|
||||
signName: { face: DM, weight: 700, size: 9.5, leading: 13 },
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { GapRange } from "../neutral/variant";
|
||||
/**
|
||||
* Gotham (commercial) is drawn with Montserrat, which runs about 4% wider at the same size. Every Gotham-derived
|
||||
* size is multiplied by this when a token is defined, so the substitute takes the width the design has. A real
|
||||
* Gotham imported over the licensed name would need these tokens at factor 1.
|
||||
* Gotham imported over the licensed name takes the design size back: each such token carries it as `ownFont.size`.
|
||||
*/
|
||||
export const GOTHAM_SIZE = 0.96;
|
||||
const round2 = (n: number) => Math.round(n * 100) / 100;
|
||||
|
||||
Reference in New Issue
Block a user