diff --git a/packages/fallbacks/acquire.test.ts b/packages/fallbacks/acquire.test.ts index d8fab5b..226be46 100644 --- a/packages/fallbacks/acquire.test.ts +++ b/packages/fallbacks/acquire.test.ts @@ -12,15 +12,28 @@ describe("source acquisition catalog", () => { SOURCE_RELEASES.length, ); + const allowedLicenses = new Set(["GUST-FL", "OFL-1.1", "AGPL-3.0-FE"]); 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(allowedLicenses.has(source.licenseFamily)).toBe(true); + expect(source.family.length).toBeGreaterThan(0); + expect(source.project.length).toBeGreaterThan(0); expect(source.expectedFiles.length).toBeGreaterThan(0); expect(source.targetFamilies.length).toBeGreaterThan(0); } }); + test("spans more than one project and license family", () => { + expect( + new Set(SOURCE_RELEASES.map((source) => source.project)).size, + ).toBeGreaterThan(1); + expect( + new Set(SOURCE_RELEASES.map((source) => source.licenseFamily)).size, + ).toBeGreaterThan(1); + }); + test("is source metadata only, not fallback evidence", () => { const forbidden = [ "verdict", diff --git a/packages/fallbacks/compare.test.ts b/packages/fallbacks/compare.test.ts index dd19b0a..e6c0cff 100644 --- a/packages/fallbacks/compare.test.ts +++ b/packages/fallbacks/compare.test.ts @@ -354,6 +354,30 @@ describe("renderReport", () => { expect(lines[2]).toContain("visual_only"); }); + test("can limit the rendered table to the top rows", () => { + const reference = sampleMetrics(mockFont(0.5), [0x41]); + const close = scoreAdvances( + reference, + sampleMetrics(mockFont(0.5), [0x41]), + [0x41], + ); + const far = scoreAdvances( + reference, + sampleMetrics(mockFont(0.7), [0x41]), + [0x41], + ); + const report = renderReport( + [ + { sourceId: "far-src", file: "far.otf", score: far }, + { sourceId: "near-src", file: "near.otf", score: close }, + ], + { limit: 1 }, + ); + expect(report.split("\n")).toHaveLength(2); + expect(report).toContain("near-src"); + expect(report).not.toContain("far-src"); + }); + test("prints coverage and the missing count", () => { const sample = [0x41, 0x42, 0x43]; const reference = sampleMetrics(mockFont(0.5), sample); diff --git a/packages/fallbacks/scripts/acquire.ts b/packages/fallbacks/scripts/acquire.ts index 0898119..7a0c512 100644 --- a/packages/fallbacks/scripts/acquire.ts +++ b/packages/fallbacks/scripts/acquire.ts @@ -3,14 +3,16 @@ import { createHash } from "node:crypto"; import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -const LICENSE_URL = +const GUST_LICENSE_URL = "https://www.gust.org.pl/projects/e-foundry/licenses/GUST-FONT-LICENSE.txt/at_download/file"; +export type LicenseFamily = "GUST-FL" | "OFL-1.1" | "AGPL-3.0-FE"; + export interface SourceRelease { sourceId: string; family: string; project: string; - licenseFamily: "GUST-FL"; + licenseFamily: LicenseFamily; downloadUrl: string; licenseUrl: string; expectedFiles: string[]; @@ -25,7 +27,7 @@ export const SOURCE_RELEASES: SourceRelease[] = [ 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, + licenseUrl: GUST_LICENSE_URL, expectedFiles: [ "texgyreadventor-regular.otf", "texgyreadventor-bold.otf", @@ -41,7 +43,7 @@ export const SOURCE_RELEASES: SourceRelease[] = [ 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, + licenseUrl: GUST_LICENSE_URL, expectedFiles: [ "texgyrebonum-regular.otf", "texgyrebonum-bold.otf", @@ -57,7 +59,7 @@ export const SOURCE_RELEASES: SourceRelease[] = [ 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, + licenseUrl: GUST_LICENSE_URL, expectedFiles: ["texgyrechorus-mediumitalic.otf"], targetFamilies: ["Monotype Corsiva", "ITC Zapf Chancery"], }, @@ -68,7 +70,7 @@ export const SOURCE_RELEASES: SourceRelease[] = [ 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, + licenseUrl: GUST_LICENSE_URL, expectedFiles: [ "texgyrecursor-regular.otf", "texgyrecursor-bold.otf", @@ -84,7 +86,7 @@ export const SOURCE_RELEASES: SourceRelease[] = [ 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, + licenseUrl: GUST_LICENSE_URL, expectedFiles: [ "texgyreheros-regular.otf", "texgyreheros-bold.otf", @@ -100,7 +102,7 @@ export const SOURCE_RELEASES: SourceRelease[] = [ 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, + licenseUrl: GUST_LICENSE_URL, expectedFiles: [ "texgyrepagella-regular.otf", "texgyrepagella-bold.otf", @@ -116,7 +118,7 @@ export const SOURCE_RELEASES: SourceRelease[] = [ 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, + licenseUrl: GUST_LICENSE_URL, expectedFiles: [ "texgyreschola-regular.otf", "texgyreschola-bold.otf", @@ -132,7 +134,7 @@ export const SOURCE_RELEASES: SourceRelease[] = [ 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, + licenseUrl: GUST_LICENSE_URL, expectedFiles: [ "texgyretermes-regular.otf", "texgyretermes-bold.otf", @@ -141,10 +143,202 @@ export const SOURCE_RELEASES: SourceRelease[] = [ ], targetFamilies: ["Times New Roman", "Times"], }, + + { + sourceId: "urw-base35", + family: "URW Base 35", + project: "URW Base 35", + licenseFamily: "AGPL-3.0-FE", + downloadUrl: + "https://github.com/ArtifexSoftware/urw-base35-fonts/archive/refs/tags/20200910.zip", + licenseUrl: + "https://raw.githubusercontent.com/ArtifexSoftware/urw-base35-fonts/20200910/LICENSE", + expectedFiles: [ + "NimbusRoman-Regular.otf", + "NimbusSans-Regular.otf", + "NimbusMonoPS-Regular.otf", + "URWBookman-Light.otf", + "P052-Roman.otf", + "C059-Roman.otf", + ], + targetFamilies: [ + "Times New Roman", + "Arial", + "Helvetica", + "Courier New", + "Bookman Old Style", + "Palatino Linotype", + "Century Schoolbook", + ], + }, + + { + sourceId: "source-serif-4", + family: "Source Serif 4", + project: "Adobe Source", + licenseFamily: "OFL-1.1", + downloadUrl: + "https://github.com/adobe-fonts/source-serif/releases/download/4.005R/source-serif-4.005_Desktop.zip", + licenseUrl: + "https://raw.githubusercontent.com/adobe-fonts/source-serif/release/LICENSE.md", + expectedFiles: [ + "SourceSerif4-Regular.otf", + "SourceSerif4-Bold.otf", + "SourceSerif4-It.otf", + "SourceSerif4-BoldIt.otf", + ], + targetFamilies: ["Georgia", "Cambria"], + }, + { + sourceId: "source-sans-3", + family: "Source Sans 3", + project: "Adobe Source", + licenseFamily: "OFL-1.1", + downloadUrl: + "https://github.com/adobe-fonts/source-sans/releases/download/3.052R/OTF-source-sans-3.052R.zip", + licenseUrl: + "https://raw.githubusercontent.com/adobe-fonts/source-sans/release/LICENSE.md", + expectedFiles: [ + "SourceSans3-Regular.otf", + "SourceSans3-Bold.otf", + "SourceSans3-It.otf", + "SourceSans3-BoldIt.otf", + ], + targetFamilies: ["Calibri", "Segoe UI"], + }, + { + sourceId: "source-code-pro", + family: "Source Code Pro", + project: "Adobe Source", + licenseFamily: "OFL-1.1", + downloadUrl: + "https://github.com/adobe-fonts/source-code-pro/releases/download/2.042R-u/1.062R-i/1.026R-vf/OTF-source-code-pro-2.042R-u_1.062R-i.zip", + licenseUrl: + "https://raw.githubusercontent.com/adobe-fonts/source-code-pro/release/LICENSE.md", + expectedFiles: [ + "SourceCodePro-Regular.otf", + "SourceCodePro-Bold.otf", + "SourceCodePro-It.otf", + "SourceCodePro-BoldIt.otf", + ], + targetFamilies: ["Consolas", "Courier New"], + }, + + { + sourceId: "sil-charis", + family: "Charis SIL", + project: "SIL", + licenseFamily: "OFL-1.1", + downloadUrl: + "https://github.com/silnrsi/font-charis/releases/download/v7.000/Charis-7.000.zip", + licenseUrl: + "https://raw.githubusercontent.com/silnrsi/font-charis/v7.000/OFL.txt", + expectedFiles: [ + "Charis-Regular.ttf", + "Charis-Bold.ttf", + "Charis-Italic.ttf", + "Charis-BoldItalic.ttf", + ], + targetFamilies: ["Charter", "Bitstream Charter"], + }, + { + sourceId: "sil-gentium", + family: "Gentium Plus", + project: "SIL", + licenseFamily: "OFL-1.1", + downloadUrl: + "https://github.com/silnrsi/font-gentium/releases/download/v7.000/Gentium-7.000.zip", + licenseUrl: + "https://raw.githubusercontent.com/silnrsi/font-gentium/v7.000/OFL.txt", + expectedFiles: [ + "Gentium-Regular.ttf", + "Gentium-Bold.ttf", + "Gentium-Italic.ttf", + "Gentium-BoldItalic.ttf", + ], + targetFamilies: ["Times New Roman"], + }, + { + sourceId: "sil-doulos", + family: "Doulos SIL", + project: "SIL", + licenseFamily: "OFL-1.1", + downloadUrl: + "https://github.com/silnrsi/font-doulos/releases/download/v7.000/DoulosSIL-7.000.zip", + licenseUrl: + "https://raw.githubusercontent.com/silnrsi/font-doulos/v7.000/OFL.txt", + expectedFiles: ["DoulosSIL-Regular.ttf"], + targetFamilies: ["Times New Roman"], + }, + { + sourceId: "sil-andika", + family: "Andika", + project: "SIL", + licenseFamily: "OFL-1.1", + downloadUrl: + "https://github.com/silnrsi/font-andika/releases/download/v7.000/Andika-7.000.zip", + licenseUrl: + "https://raw.githubusercontent.com/silnrsi/font-andika/v7.000/OFL.txt", + expectedFiles: [ + "Andika-Regular.ttf", + "Andika-Bold.ttf", + "Andika-Italic.ttf", + "Andika-BoldItalic.ttf", + ], + targetFamilies: ["Verdana", "Tahoma"], + }, + + { + sourceId: "noto-sans", + family: "Noto Sans", + project: "Noto", + licenseFamily: "OFL-1.1", + downloadUrl: + "https://github.com/notofonts/latin-greek-cyrillic/releases/download/NotoSans-v2.015/NotoSans-v2.015.zip", + licenseUrl: + "https://raw.githubusercontent.com/notofonts/latin-greek-cyrillic/main/OFL.txt", + expectedFiles: [ + "NotoSans-Regular.otf", + "NotoSans-Bold.otf", + "NotoSans-Italic.otf", + "NotoSans-BoldItalic.otf", + ], + targetFamilies: ["Calibri", "Segoe UI"], + }, + { + sourceId: "noto-serif", + family: "Noto Serif", + project: "Noto", + licenseFamily: "OFL-1.1", + downloadUrl: + "https://github.com/notofonts/latin-greek-cyrillic/releases/download/NotoSerif-v2.015/NotoSerif-v2.015.zip", + licenseUrl: + "https://raw.githubusercontent.com/notofonts/latin-greek-cyrillic/main/OFL.txt", + expectedFiles: [ + "NotoSerif-Regular.otf", + "NotoSerif-Bold.otf", + "NotoSerif-Italic.otf", + "NotoSerif-BoldItalic.otf", + ], + targetFamilies: ["Georgia", "Cambria"], + }, + { + sourceId: "noto-sans-mono", + family: "Noto Sans Mono", + project: "Noto", + licenseFamily: "OFL-1.1", + downloadUrl: + "https://github.com/notofonts/latin-greek-cyrillic/releases/download/NotoSansMono-v2.014/NotoSansMono-v2.014.zip", + licenseUrl: + "https://raw.githubusercontent.com/notofonts/latin-greek-cyrillic/main/OFL.txt", + expectedFiles: ["NotoSansMono-Regular.otf", "NotoSansMono-Bold.otf"], + targetFamilies: ["Consolas", "Courier New"], + }, ]; interface FileSnapshot { name: string; + path: string; sha256: string; } @@ -194,9 +388,15 @@ function listArchive(zipPath: string): string[] { .filter(Boolean); } +// `unzip -p` matches its member argument as a glob, so members with literal glob +// metacharacters (e.g. variable-font names like `NotoSans-Italic[wdth,wght].ttf`) +// must be escaped to extract by exact name. +const escapeArchiveMember = (name: string): string => + name.replace(/[\\*?[\]]/g, "\\$&"); + function readArchiveMember(zipPath: string, name: string): Uint8Array { return new Uint8Array( - execFileSync("unzip", ["-p", zipPath, name], { + execFileSync("unzip", ["-p", zipPath, escapeArchiveMember(name)], { maxBuffer: 256 * 1024 * 1024, }), ); @@ -217,9 +417,10 @@ async function acquireSource( const files = members .map((member) => ({ name: basename(member), + path: member, sha256: sha256(readArchiveMember(zipPath, member)), })) - .sort((a, b) => a.name.localeCompare(b.name)); + .sort((a, b) => a.path.localeCompare(b.path)); const fileNames = new Set(files.map((file) => file.name)); const missing = source.expectedFiles.filter((name) => !fileNames.has(name)); diff --git a/packages/fallbacks/scripts/compare.ts b/packages/fallbacks/scripts/compare.ts index 10a35da..0a83416 100644 --- a/packages/fallbacks/scripts/compare.ts +++ b/packages/fallbacks/scripts/compare.ts @@ -368,9 +368,15 @@ function listFontMembers(zipPath: string): string[] { .filter(isFontFile); } +// `unzip -p` matches its member argument as a glob, so members with literal glob +// metacharacters (e.g. variable-font names like `NotoSans-Italic[wdth,wght].ttf`) +// must be escaped to extract by exact name. +const escapeArchiveMember = (name: string): string => + name.replace(/[\\*?[\]]/g, "\\$&"); + function readArchiveMember(zipPath: string, member: string): Uint8Array { return new Uint8Array( - execFileSync("unzip", ["-p", zipPath, member], { + execFileSync("unzip", ["-p", zipPath, escapeArchiveMember(member)], { maxBuffer: 256 * 1024 * 1024, }), ); @@ -407,11 +413,12 @@ interface CompareRow { interface ParsedArgs { reference?: string; family?: string; + limit: number | null; sources: string[]; } function parseArgs(argv: string[]): ParsedArgs { - const args: ParsedArgs = { sources: [] }; + const args: ParsedArgs = { limit: 50, sources: [] }; const readValue = (flag: string, index: number): string => { const value = argv[index + 1]; if (!value || value.startsWith("--")) @@ -437,6 +444,19 @@ function parseArgs(argv: string[]): ParsedArgs { args.sources.push(id); i++; break; + case "--limit": { + const value = readValue(flag, i); + if (value === "all") { + args.limit = null; + } else { + const limit = Number(value); + if (!Number.isInteger(limit) || limit <= 0) + throw new Error("--limit requires a positive integer or 'all'"); + args.limit = limit; + } + i++; + break; + } default: throw new Error(`unknown argument: ${flag}`); } @@ -459,8 +479,15 @@ function formatWorst(worst: GlyphDelta[]): string { .join("; "); } +interface RenderOptions { + limit?: number | null; +} + /** Render the ranked table. Returned as a string so it can be tested without capturing stdout. */ -export function renderReport(rows: CompareRow[]): string { +export function renderReport( + rows: CompareRow[], + options: RenderOptions = {}, +): string { const ranked = [...rows].sort((a, b) => { const tierDiff = TIER_RANK[a.score.tier] - TIER_RANK[b.score.tier]; if (tierDiff !== 0) return tierDiff; @@ -473,6 +500,9 @@ export function renderReport(rows: CompareRow[]): string { return aMean - bMean; }); + const visible = + options.limit === null ? ranked : ranked.slice(0, options.limit); + const header = [ "source", "file", @@ -485,7 +515,7 @@ export function renderReport(rows: CompareRow[]): string { "over2.5", "worst", ]; - const body = ranked.map((row) => [ + const body = visible.map((row) => [ row.sourceId, row.file, formatDelta(row.score.meanDelta), @@ -509,6 +539,14 @@ export function renderReport(rows: CompareRow[]): string { return [line(header), ...body.map(line)].join("\n"); } +function displayNameForMember( + member: string, + duplicateBasenames: Set, +): string { + const file = basename(member); + return duplicateBasenames.has(file) ? member : file; +} + function main(): void { const args = parseArgs(process.argv.slice(2)); @@ -540,6 +578,7 @@ function main(): void { const reference = sampleMetrics(parseFont(readFileSync(args.reference))); const rows: CompareRow[] = []; + let skipped = 0; for (const source of selected) { const zipPath = join(cacheDir, `${source.sourceId}.zip`); if (!existsSync(zipPath)) @@ -549,18 +588,39 @@ function main(): void { const members = listFontMembers(zipPath); if (members.length === 0) throw new Error(`no candidate font files in ${zipPath}`); + const basenameCounts = new Map(); for (const member of members) { - const font = parseFont(readArchiveMember(zipPath, member)); - const score = scoreAdvances(reference, sampleMetrics(font)); - rows.push({ sourceId: source.sourceId, file: basename(member), score }); + const file = basename(member); + basenameCounts.set(file, (basenameCounts.get(file) ?? 0) + 1); + } + const duplicateBasenames = new Set( + [...basenameCounts] + .filter(([, count]) => count > 1) + .map(([file]) => file), + ); + for (const member of members) { + try { + const font = parseFont(readArchiveMember(zipPath, member)); + const score = scoreAdvances(reference, sampleMetrics(font)); + rows.push({ + sourceId: source.sourceId, + file: displayNameForMember(member, duplicateBasenames), + score, + }); + } catch { + skipped++; + } } } const label = args.family ?? "(family not specified)"; + const shown = + args.limit === null ? rows.length : Math.min(args.limit, rows.length); + const skippedText = skipped === 0 ? "" : `; skipped ${skipped} unsupported`; console.log( - `reference ${basename(args.reference)} as "${label}" vs ${rows.length} candidate(s) over ${LATIN_SAMPLE.length} Latin codepoints\n`, + `reference ${basename(args.reference)} as "${label}" vs ${rows.length} candidate(s) over ${LATIN_SAMPLE.length} Latin codepoints; showing ${shown}${skippedText}\n`, ); - console.log(renderReport(rows)); + console.log(renderReport(rows, { limit: args.limit })); } if (import.meta.main) {