Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ dev/
.wrangler/
.mcp.json
mockups/
packages/fallbacks/.cache/
STATE.md
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ Built by the team behind [SuperDoc](https://github.com/superdoc-dev/superdoc). S

- `packages/fallbacks` - runtime fallback decisions and lookup helpers.

## Source Acquisition

`bun run --cwd packages/fallbacks acquire:sources` downloads reviewed open-font source archives into
an ignored local cache and writes hash snapshots there. No downloaded fonts or snapshots are committed.

## API

- `getRenderableFallback` - returns the open family to render, or `null` when none is renderable.
Expand Down
1 change: 1 addition & 0 deletions biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"**",
"!mockups",
"!.internal",
"!**/.cache",
"!**/dist",
"!**/.astro",
"!packages/fallbacks/src/data.ts"
Expand Down
1 change: 1 addition & 0 deletions packages/fallbacks/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.cache/
58 changes: 58 additions & 0 deletions packages/fallbacks/acquire-sources.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { SOURCE_RELEASES } from "./scripts/acquire-sources";

const joined = (...parts: string[]) => parts.join("");

describe("source acquisition catalog", () => {
test("has unique source ids and https release URLs", () => {
expect(SOURCE_RELEASES.length).toBeGreaterThan(0);
expect(new Set(SOURCE_RELEASES.map((source) => source.sourceId)).size).toBe(
SOURCE_RELEASES.length,
);

for (const source of SOURCE_RELEASES) {
expect(source.sourceId).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/);
expect(source.downloadUrl.startsWith("https://")).toBe(true);
expect(source.licenseUrl.startsWith("https://")).toBe(true);
expect(source.expectedFiles.length).toBeGreaterThan(0);
expect(source.targetFamilies.length).toBeGreaterThan(0);
}
});

test("is source metadata only, not fallback evidence", () => {
const forbidden = [
"verdict",
"policyAction",
"physicalFamily",
"measurementRefs",
"gates",
"advance",
"faceVerdicts",
"glyphExceptions",
];
for (const source of SOURCE_RELEASES)
for (const field of forbidden)
expect(field in (source as unknown as Record<string, unknown>)).toBe(
false,
);
});

test("does not include private paths or measurement environment details", () => {
const script = readFileSync(
join(import.meta.dir, "scripts", "acquire-sources.ts"),
"utf8",
);
for (const needle of [
joined("/", "Users", "/"),
joined("/", "Applications", "/"),
joined("Microsoft ", "Word"),
joined("or", "acle"),
"macOS",
])
expect(script.includes(needle), `script contains "${needle}"`).toBe(
false,
);
});
});
2 changes: 2 additions & 0 deletions packages/fallbacks/pack.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ describe("publish tarball hygiene", () => {
/data\/measurements/,
/data\/corpus/,
/records\.json/,
/\.cache\//,
/\.(otf|ttf|woff2?|pfb|pfa)$/,
/tsconfig/,
];
for (const f of files)
Expand Down
1 change: 1 addition & 0 deletions packages/fallbacks/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
},
"scripts": {
"gen:data": "bun run scripts/generate-data.ts",
"acquire:sources": "bun run scripts/acquire-sources.ts",
"build": "tsc -p tsconfig.build.json",
"prepack": "bun run build"
},
Expand Down
274 changes: 274 additions & 0 deletions packages/fallbacks/scripts/acquire-sources.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,274 @@
import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";

const LICENSE_URL =
"https://www.gust.org.pl/projects/e-foundry/licenses/GUST-FONT-LICENSE.txt/at_download/file";

export interface SourceRelease {
sourceId: string;
family: string;
project: string;
licenseFamily: "GUST-FL";
downloadUrl: string;
licenseUrl: string;
expectedFiles: string[];
targetFamilies: string[];
}

export const SOURCE_RELEASES: SourceRelease[] = [
{
sourceId: "tex-gyre-adventor",
family: "TeX Gyre Adventor",
project: "TeX Gyre",
licenseFamily: "GUST-FL",
downloadUrl:
"https://www.gust.org.pl/projects/e-foundry/tex-gyre/adventor/tg_adventor-otf-2_609-31_03_2026.zip",
licenseUrl: LICENSE_URL,
expectedFiles: [
"texgyreadventor-regular.otf",
"texgyreadventor-bold.otf",
"texgyreadventor-italic.otf",
"texgyreadventor-bolditalic.otf",
],
targetFamilies: ["Century Gothic", "ITC Avant Garde Gothic"],
},
{
sourceId: "tex-gyre-bonum",
family: "TeX Gyre Bonum",
project: "TeX Gyre",
licenseFamily: "GUST-FL",
downloadUrl:
"https://www.gust.org.pl/projects/e-foundry/tex-gyre/bonum/tg_bonum-otf-2_609-31_03_2026.zip",
licenseUrl: LICENSE_URL,
expectedFiles: [
"texgyrebonum-regular.otf",
"texgyrebonum-bold.otf",
"texgyrebonum-italic.otf",
"texgyrebonum-bolditalic.otf",
],
targetFamilies: ["Bookman Old Style", "ITC Bookman"],
},
{
sourceId: "tex-gyre-chorus",
family: "TeX Gyre Chorus",
project: "TeX Gyre",
licenseFamily: "GUST-FL",
downloadUrl:
"https://www.gust.org.pl/projects/e-foundry/tex-gyre/chorus/tg_chorus-otf-2_609-31_03_2026.zip",
licenseUrl: LICENSE_URL,
expectedFiles: ["texgyrechorus-mediumitalic.otf"],
targetFamilies: ["Monotype Corsiva", "ITC Zapf Chancery"],
},
{
sourceId: "tex-gyre-cursor",
family: "TeX Gyre Cursor",
project: "TeX Gyre",
licenseFamily: "GUST-FL",
downloadUrl:
"https://www.gust.org.pl/projects/e-foundry/tex-gyre/cursor/tg_cursor-otf-2_609-31_03_2026.zip",
licenseUrl: LICENSE_URL,
expectedFiles: [
"texgyrecursor-regular.otf",
"texgyrecursor-bold.otf",
"texgyrecursor-italic.otf",
"texgyrecursor-bolditalic.otf",
],
targetFamilies: ["Courier New", "Courier"],
},
{
sourceId: "tex-gyre-heros",
family: "TeX Gyre Heros",
project: "TeX Gyre",
licenseFamily: "GUST-FL",
downloadUrl:
"https://www.gust.org.pl/projects/e-foundry/tex-gyre/heros/tg_heros-otf-2_609-31_03_2026.zip",
licenseUrl: LICENSE_URL,
expectedFiles: [
"texgyreheros-regular.otf",
"texgyreheros-bold.otf",
"texgyreheros-italic.otf",
"texgyreheros-bolditalic.otf",
],
targetFamilies: ["Arial", "Helvetica", "Arial Narrow"],
},
{
sourceId: "tex-gyre-pagella",
family: "TeX Gyre Pagella",
project: "TeX Gyre",
licenseFamily: "GUST-FL",
downloadUrl:
"https://www.gust.org.pl/projects/e-foundry/tex-gyre/pagella/tg_pagella-otf-2_609-31_03_2026.zip",
licenseUrl: LICENSE_URL,
expectedFiles: [
"texgyrepagella-regular.otf",
"texgyrepagella-bold.otf",
"texgyrepagella-italic.otf",
"texgyrepagella-bolditalic.otf",
],
targetFamilies: ["Palatino Linotype", "Book Antiqua"],
},
{
sourceId: "tex-gyre-schola",
family: "TeX Gyre Schola",
project: "TeX Gyre",
licenseFamily: "GUST-FL",
downloadUrl:
"https://www.gust.org.pl/projects/e-foundry/tex-gyre/schola/tg_schola-otf-2_609-31_03_2026.zip",
licenseUrl: LICENSE_URL,
expectedFiles: [
"texgyreschola-regular.otf",
"texgyreschola-bold.otf",
"texgyreschola-italic.otf",
"texgyreschola-bolditalic.otf",
],
targetFamilies: ["Century Schoolbook", "New Century Schoolbook"],
},
{
sourceId: "tex-gyre-termes",
family: "TeX Gyre Termes",
project: "TeX Gyre",
licenseFamily: "GUST-FL",
downloadUrl:
"https://www.gust.org.pl/projects/e-foundry/tex-gyre/termes/tg_termes-otf-2_609-31_03_2026.zip",
licenseUrl: LICENSE_URL,
expectedFiles: [
"texgyretermes-regular.otf",
"texgyretermes-bold.otf",
"texgyretermes-italic.otf",
"texgyretermes-bolditalic.otf",
],
targetFamilies: ["Times New Roman", "Times"],
},
];

interface FileSnapshot {
name: string;
sha256: string;
}

interface SourceSnapshot {
sourceId: string;
family: string;
project: string;
licenseFamily: string;
downloadUrl: string;
archiveSha256: string;
licenseUrl: string;
licenseSha256: string;
files: FileSnapshot[];
targetFamilies: string[];
}

const PKG_DIR = join(import.meta.dir, "..");
const DEFAULT_CACHE_DIR = join(PKG_DIR, ".cache", "sources");
const FONT_EXTENSIONS = [".otf", ".ttf", ".otc", ".ttc", ".woff2", ".woff"];

const sha256 = (bytes: Uint8Array): string =>
createHash("sha256").update(bytes).digest("hex");

const basename = (path: string): string => path.split("/").pop() ?? path;

const isFontFile = (path: string): boolean =>
FONT_EXTENSIONS.some((ext) => path.toLowerCase().endsWith(ext));

async function fetchBytes(url: string): Promise<Uint8Array> {
const res = await fetch(url);
if (!res.ok) throw new Error(`GET ${url} -> ${res.status} ${res.statusText}`);
return new Uint8Array(await res.arrayBuffer());
}

function requireUnzip(): void {
try {
execFileSync("unzip", ["-v"], { stdio: "ignore" });
} catch {
throw new Error("`unzip` is required on PATH.");
}
}

function listArchive(zipPath: string): string[] {
return execFileSync("unzip", ["-Z1", zipPath], { encoding: "utf8" })
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
}

function readArchiveMember(zipPath: string, name: string): Uint8Array {
return new Uint8Array(
execFileSync("unzip", ["-p", zipPath, name], {
maxBuffer: 256 * 1024 * 1024,
}),
);
}

async function acquireSource(
source: SourceRelease,
cacheDir: string,
): Promise<SourceSnapshot> {
const archive = await fetchBytes(source.downloadUrl);
const zipPath = join(cacheDir, `${source.sourceId}.zip`);
writeFileSync(zipPath, archive);

const members = listArchive(zipPath).filter(isFontFile);
if (members.length === 0)
throw new Error(`${source.sourceId}: archive has no font files`);

const files = members
.map((member) => ({
name: basename(member),
sha256: sha256(readArchiveMember(zipPath, member)),
}))
.sort((a, b) => a.name.localeCompare(b.name));

const fileNames = new Set(files.map((file) => file.name));
const missing = source.expectedFiles.filter((name) => !fileNames.has(name));
if (missing.length > 0)
throw new Error(
`${source.sourceId}: missing expected files: ${missing.join(", ")}`,
);

const license = await fetchBytes(source.licenseUrl);
writeFileSync(join(cacheDir, `${source.sourceId}.license.txt`), license);

return {
sourceId: source.sourceId,
family: source.family,
project: source.project,
licenseFamily: source.licenseFamily,
downloadUrl: source.downloadUrl,
archiveSha256: sha256(archive),
licenseUrl: source.licenseUrl,
licenseSha256: sha256(license),
files,
targetFamilies: source.targetFamilies,
};
}

async function main(): Promise<void> {
requireUnzip();

const cacheDir = process.env.DOCFONTS_SOURCE_CACHE ?? DEFAULT_CACHE_DIR;
mkdirSync(cacheDir, { recursive: true });

const snapshots: SourceSnapshot[] = [];
for (const source of SOURCE_RELEASES) {
console.log(`acquiring ${source.sourceId}`);
snapshots.push(await acquireSource(source, cacheDir));
}

snapshots.sort((a, b) => a.sourceId.localeCompare(b.sourceId));
const outPath = join(cacheDir, "source-snapshot.json");
writeFileSync(
outPath,
`${JSON.stringify({ generatedBy: "scripts/acquire-sources.ts", snapshots }, null, 2)}\n`,
);
console.log(`wrote ${outPath}`);
}

if (import.meta.main) {
main().catch((err) => {
console.error(err instanceof Error ? err.message : err);
process.exit(1);
});
}
Loading