Release Font Tools v0.1.0

This commit is contained in:
2026-09-01 14:35:39 +02:00
commit 25ff321f2a
65 changed files with 10590 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
import { useState } from "react";
import { AppShell } from "@add-ideas/toolbox-shell-react";
import "@add-ideas/toolbox-shell-react/styles.css";
import "./styles.css";
import { ErrorBoundary } from "./components/ErrorBoundary";
import { HelpDialog } from "./components/HelpDialog";
import { Workbench } from "./components/Workbench";
import { manifest } from "./toolbox/manifest";
export function App() {
const [help, setHelp] = useState(false);
return (
<ErrorBoundary>
<AppShell
app={manifest}
manifestUrl="./toolbox-app.json"
helpAction={{ onClick: () => setHelp(true) }}
>
<Workbench />
</AppShell>
<HelpDialog open={help} onClose={() => setHelp(false)} />
</ErrorBoundary>
);
}
+27
View File
@@ -0,0 +1,27 @@
import { Component, type ErrorInfo, type ReactNode } from "react";
export class ErrorBoundary extends Component<
{ children: ReactNode },
{ error?: Error }
> {
state: { error?: Error } = {};
static getDerivedStateFromError(error: Error) {
return { error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error("Application failure", error, info);
}
render() {
if (this.state.error)
return (
<main className="fatal">
<h1>Font Tools could not continue</h1>
<p>{this.state.error.message}</p>
<button type="button" onClick={() => location.reload()}>
Reload
</button>
</main>
);
return this.props.children;
}
}
+30
View File
@@ -0,0 +1,30 @@
import { useMemo } from "react";
import { previewDocument } from "../core/preview";
export function FontPreview({
fontUrl,
text,
fontSize,
lineHeight,
axes,
}: {
fontUrl: string;
text: string;
fontSize: number;
lineHeight: number;
axes: Record<string, number>;
}) {
const source = useMemo(
() => previewDocument(fontUrl, text, fontSize, lineHeight, axes),
[fontUrl, text, fontSize, lineHeight, axes],
);
return (
<iframe
className="font-preview-frame"
title="Isolated font and fallback comparison"
sandbox="allow-same-origin"
referrerPolicy="no-referrer"
srcDoc={source}
/>
);
}
+51
View File
@@ -0,0 +1,51 @@
import { useEffect, useRef } from "react";
export function HelpDialog({
open,
onClose,
}: {
open: boolean;
onClose: () => void;
}) {
const ref = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialog = ref.current;
if (!dialog) return;
if (open && !dialog.open) dialog.showModal();
if (!open && dialog.open) dialog.close();
}, [open]);
return (
<dialog
ref={ref}
className="help-dialog"
onClose={onClose}
onCancel={onClose}
aria-labelledby="help-title"
>
<div className="dialog-heading">
<div>
<p className="eyebrow">Local-first help</p>
<h2 id="help-title">About Font Tools</h2>
</div>
<button onClick={onClose} aria-label="Close help">
×
</button>
</div>
<p>
Open one local TTF, OTF or WOFF file. The table directory is checked
before opentype.js parses it in a disposable worker. WOFF2 and font
collections are identified but deliberately unsupported in v0.1.
</p>
<p>
Preview text is rendered in a scriptless sandbox. Coverage results are
based on the font&apos;s Unicode cmap and do not promise that every
shaping sequence or colour glyph will render in every browser.
</p>
<p>
OS/2 embedding flags are shown as technical evidence, not legal advice.
Static subsetting is blocked for restricted, no-subsetting, bitmap-only
and variable fonts, and always requires a rights confirmation.
</p>
</dialog>
);
}
+767
View File
@@ -0,0 +1,767 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { triggerBlobDownload } from "@add-ideas/toolbox-helpers";
import { generateFontFace, suggestedFamily } from "../core/css";
import { FontWorkerClient } from "../core/fontClient";
import type {
CoverageInspection,
FontInspection,
SubsetResult,
} from "../core/model";
import { FONT_LIMITS } from "../core/sfnt";
import { FontPreview } from "./FontPreview";
const SAMPLE =
"Hamburgefontsiv 0123456789\nÀ bientôt · Καλημέρα · Привет · مرحبًا · 日本語 · 🙂";
export function Workbench() {
const client = useRef<FontWorkerClient | null>(null),
generation = useRef(0),
previewUrl = useRef<string | null>(null),
[inspection, setInspection] = useState<FontInspection | null>(null),
[coverage, setCoverage] = useState<CoverageInspection | null>(null),
[previewText, setPreviewText] = useState(SAMPLE),
[fontSize, setFontSize] = useState(42),
[lineHeight, setLineHeight] = useState(1.25),
[axes, setAxes] = useState<Record<string, number>>({}),
[url, setUrl] = useState<string | null>(null),
[operation, setOperation] = useState<"load" | "coverage" | "subset" | null>(
null,
),
[status, setStatus] = useState(
"Choose a local TTF, OTF or WOFF font to begin.",
),
[tableFilter, setTableFilter] = useState(""),
[cssFamily, setCssFamily] = useState("Local font"),
[cssPath, setCssPath] = useState("./fonts/local-font.woff"),
[fontDisplay, setFontDisplay] = useState<
"auto" | "block" | "swap" | "fallback" | "optional"
>("swap"),
[unicodeRange, setUnicodeRange] = useState(false),
[subsetText, setSubsetText] = useState(SAMPLE),
[rightsConfirmed, setRightsConfirmed] = useState(false),
[subsetReport, setSubsetReport] = useState<SubsetResult | null>(null);
useEffect(
() => () => {
client.current?.terminate();
if (previewUrl.current) URL.revokeObjectURL(previewUrl.current);
},
[],
);
const cssResult = useMemo(() => {
if (!inspection) return null;
try {
return generateFontFace({
family: cssFamily,
sourcePath: cssPath,
display: fontDisplay,
inspection,
includeUnicodeRange: unicodeRange,
});
} catch (error) {
return { css: "", warnings: [message(error)] };
}
}, [cssFamily, cssPath, fontDisplay, inspection, unicodeRange]);
async function openFont(file: File) {
const token = ++generation.current,
candidate = new FontWorkerClient();
setOperation("load");
setStatus(`Inspecting ${file.name} in an isolated worker…`);
try {
const next = await candidate.load(file),
nextCoverage = await candidate.coverage(previewText);
if (token !== generation.current) return candidate.terminate();
client.current?.terminate();
client.current = candidate;
if (previewUrl.current) URL.revokeObjectURL(previewUrl.current);
const nextUrl = URL.createObjectURL(file);
previewUrl.current = nextUrl;
setUrl(nextUrl);
setInspection(next);
setCoverage(nextCoverage);
setAxes(
Object.fromEntries(next.axes.map((axis) => [axis.tag, axis.default])),
);
setCssFamily(suggestedFamily(next));
setCssPath(`./fonts/${safeAssetName(file.name)}`);
setSubsetReport(null);
setRightsConfirmed(false);
setStatus(
`Inspected ${next.glyphCount.toLocaleString()} glyphs, ${next.unicodeCount.toLocaleString()} Unicode values and ${next.tables.length} tables locally.`,
);
} catch (error) {
candidate.terminate();
if (token === generation.current)
setStatus(`${message(error)} The last valid font remains visible.`);
} finally {
if (token === generation.current) setOperation(null);
}
}
async function analyzeCoverage() {
if (!client.current) return;
setOperation("coverage");
setStatus("Checking unique preview characters against the font cmap…");
try {
const next = await client.current.coverage(previewText);
setCoverage(next);
setStatus(
`${next.covered} unique characters map to glyphs; ${next.missing} use .notdef/fallback${next.truncated ? "; the per-character list is capped" : ""}.`,
);
} catch (error) {
setStatus(`${message(error)} The last coverage result remains visible.`);
} finally {
setOperation(null);
}
}
async function buildSubset() {
if (!client.current || !inspection) return;
setOperation("subset");
setStatus("Rebuilding and validating a bounded static subset locally…");
try {
const result = await client.current.subset(
subsetText,
cssFamily,
rightsConfirmed,
);
setSubsetReport(result);
triggerBlobDownload(
new Blob([result.buffer], { type: "font/otf" }),
result.fileName,
);
setStatus(
`Created ${result.subsetGlyphs} glyphs from ${result.sourceGlyphs.toLocaleString()} source glyphs and started the local download.`,
);
} catch (error) {
setStatus(`${message(error)} No subset was downloaded.`);
} finally {
setOperation(null);
}
}
const filteredTables = inspection?.tables.filter((table) => {
const query = tableFilter.trim().toLowerCase();
return (
!query ||
`${table.tag} ${table.description}`.toLowerCase().includes(query)
);
});
const subsetBlocked = inspection
? subsetBlockReason(inspection)
: "Load a font first.";
return (
<main className="workbench">
<section className="hero panel">
<div>
<p className="eyebrow">Bounded local font laboratory</p>
<h1>Know what is inside a font.</h1>
<p>
Inspect tables and rights signals, test real text and fallbacks,
then generate deployment CSS or an honest static subsetwithout
uploading the font.
</p>
</div>
<label className={`file-button ${operation === "load" ? "busy" : ""}`}>
{operation === "load" ? "Inspecting…" : "Open font"}
<input
type="file"
accept=".ttf,.otf,.woff,.woff2,font/ttf,font/otf,font/woff,font/woff2"
disabled={operation === "load"}
onChange={(event) => {
const file = event.currentTarget.files?.[0];
if (file) void openFont(file);
event.currentTarget.value = "";
}}
/>
</label>
</section>
<p className="status" role="status" aria-live="polite">
{status}
</p>
{!inspection || !url ? (
<section className="empty panel">
<div className="drop-icon" aria-hidden="true">
Aa
</div>
<h2>Everything stays on this device</h2>
<p>
TTF, OTF and WOFF up to 16 MiB are accepted. WOFF2 is identified and
rejected clearly because opentype.js does not decode it.
</p>
<dl className="limit-grid">
<div>
<dt>Parser</dt>
<dd>Disposable worker · 8 s</dd>
</div>
<div>
<dt>Expanded data</dt>
<dd>64 MiB maximum</dd>
</div>
<div>
<dt>Output</dt>
<dd>Explicit downloads only</dd>
</div>
</dl>
</section>
) : (
<>
<section className="summary-grid" aria-label="Font summary">
<Summary
label="Identity"
value={
inspection.names.fullName ||
inspection.names.family ||
inspection.fileName
}
detail={inspection.names.version || "Version not named"}
/>
<Summary
label="Format"
value={inspection.flavor}
detail={`${formatBytes(inspection.fileSize)} source · ${formatBytes(inspection.expandedSize)} expanded`}
/>
<Summary
label="Coverage"
value={`${inspection.unicodeCount.toLocaleString()} Unicode values`}
detail={`${inspection.glyphCount.toLocaleString()} glyphs`}
/>
<Summary
label="Embedding signal"
value={inspection.embedding.label}
detail={`${inspection.embedding.rawHex} · ${inspection.embedding.subsetAllowed ? "subset not blocked" : "subset blocked"}`}
tone={inspection.embedding.subsetAllowed ? "ok" : "warn"}
/>
</section>
<section className="panel preview-panel">
<div className="section-heading">
<div>
<p className="eyebrow">Preview & fallback comparison</p>
<h2>Your text, four ways</h2>
</div>
<span className="isolation">Scriptless sandbox</span>
</div>
<label>
Preview text
<textarea
value={previewText}
maxLength={FONT_LIMITS.previewCharacters}
onChange={(event) => setPreviewText(event.target.value)}
/>
</label>
<div className="preview-controls">
<label>
Size{" "}
<input
type="range"
min="12"
max="160"
value={fontSize}
onChange={(event) => setFontSize(event.target.valueAsNumber)}
/>
<span>{fontSize}px</span>
</label>
<label>
Line height{" "}
<input
type="range"
min="0.8"
max="2.5"
step="0.05"
value={lineHeight}
onChange={(event) =>
setLineHeight(event.target.valueAsNumber)
}
/>
<span>{lineHeight.toFixed(2)}</span>
</label>
</div>
{inspection.axes.length > 0 && (
<fieldset className="axes">
<legend>Variable axes</legend>
{inspection.axes.map((axis) => (
<label key={axis.tag}>
<span>
<strong>{axis.name}</strong> <code>{axis.tag}</code>
</span>
<input
type="range"
min={axis.min}
max={axis.max}
step={(axis.max - axis.min) / 200 || 1}
value={axes[axis.tag] ?? axis.default}
onChange={(event) =>
setAxes((current) => ({
...current,
[axis.tag]: event.target.valueAsNumber,
}))
}
/>
<span>
{Number((axes[axis.tag] ?? axis.default).toFixed(3))}
</span>
</label>
))}
<button
type="button"
onClick={() =>
setAxes(
Object.fromEntries(
inspection.axes.map((axis) => [axis.tag, axis.default]),
),
)
}
>
Reset axes
</button>
</fieldset>
)}
<FontPreview
fontUrl={url}
text={previewText}
fontSize={fontSize}
lineHeight={lineHeight}
axes={axes}
/>
{[...previewText].length > 5_000 && (
<p className="note">
The visual preview shows the first 5,000 characters; cmap
analysis accepts all 20,000.
</p>
)}
</section>
<div className="two-column">
<section className="panel">
<div className="section-heading">
<div>
<p className="eyebrow">Names & metrics</p>
<h2>Identity records</h2>
</div>
</div>
<dl className="metadata-list">
{nameEntries(inspection).map(([label, value]) => (
<div key={label}>
<dt>{label}</dt>
<dd>{value}</dd>
</div>
))}
<div>
<dt>Units per em</dt>
<dd>{inspection.unitsPerEm.toLocaleString()}</dd>
</div>
<div>
<dt>Ascender / descender</dt>
<dd>
{inspection.ascender.toLocaleString()} /{" "}
{inspection.descender.toLocaleString()}
</dd>
</div>
</dl>
</section>
<section
className={`panel rights ${inspection.embedding.subsetAllowed ? "allowed" : "blocked"}`}
>
<div className="section-heading">
<div>
<p className="eyebrow">OS/2 fsType</p>
<h2>Embedding & licence signals</h2>
</div>
<code>{inspection.embedding.rawHex}</code>
</div>
<p className="rights-label">{inspection.embedding.label}</p>
<ul>
{inspection.embedding.signals.map((signal) => (
<li key={signal}>{signal}</li>
))}
</ul>
{inspection.names.license && (
<div className="license-text">
<strong>Embedded licence text</strong>
<p>{inspection.names.license}</p>
</div>
)}
{inspection.names.licenseUrl && (
<p>
<strong>Recorded licence URL:</strong>{" "}
<span className="break">{inspection.names.licenseUrl}</span>
</p>
)}
</section>
</div>
<section className="panel coverage-panel">
<div className="section-heading">
<div>
<p className="eyebrow">Unicode cmap</p>
<h2>Coverage</h2>
</div>
<button
onClick={() => void analyzeCoverage()}
disabled={operation !== null}
>
{operation === "coverage"
? "Checking…"
: "Analyze preview text"}
</button>
</div>
<div className="block-grid">
{inspection.coverageBlocks.map((block) => (
<div className="coverage-block" key={block.id}>
<div>
<strong>{block.label}</strong>
<span>
{block.covered.toLocaleString()} /{" "}
{block.total.toLocaleString()}
</span>
</div>
<meter min="0" max={block.total} value={block.covered}>
{Math.round((block.covered / block.total) * 100)}%
</meter>
</div>
))}
</div>
{coverage && (
<>
<p className="coverage-summary">
<strong>{coverage.covered}</strong> mapped ·{" "}
<strong>{coverage.missing}</strong> missing among unique
preview characters
</p>
<div
className="glyph-strip"
aria-label="Preview character coverage"
>
{coverage.characters.slice(0, 256).map((entry) => (
<span
className={entry.covered ? "covered" : "missing"}
key={`${entry.codePoint}-${entry.character}`}
title={`U+${hex(entry.codePoint)} · ${entry.glyphName} · ${entry.covered ? "mapped" : "missing"}`}
>
<span className="glyph-char">
{visibleCharacter(entry.character)}
</span>
<small>U+{hex(entry.codePoint)}</small>
</span>
))}
</div>
{(coverage.characters.length > 256 || coverage.truncated) && (
<p className="note">
Showing the first 256 unique characters; analysis records at
most 2,048.
</p>
)}
</>
)}
<details>
<summary>
Unicode range inventory ({inspection.coverageRanges.length}
{inspection.coverageRangesTruncated ? "+" : ""})
</summary>
<div className="range-list">
{inspection.coverageRanges.map((range) => (
<code key={`${range.start}-${range.end}`}>
U+{hex(range.start)}
{range.end === range.start ? "" : `U+${hex(range.end)}`}
</code>
))}
</div>
{inspection.coverageRangesTruncated && (
<p>
The range list is capped at 512 disjoint ranges; total Unicode
count remains exact.
</p>
)}
</details>
</section>
<section className="panel tables-panel">
<div className="section-heading">
<div>
<p className="eyebrow">Validated directory</p>
<h2>{inspection.tables.length} font tables</h2>
</div>
<label className="compact">
Filter tables
<input
value={tableFilter}
onChange={(event) => setTableFilter(event.target.value)}
placeholder="cmap, variation…"
/>
</label>
</div>
<div className="table-scroll">
<table>
<thead>
<tr>
<th scope="col">Tag</th>
<th scope="col">Purpose</th>
<th scope="col">Stored</th>
<th scope="col">Expanded</th>
<th scope="col">Offset</th>
<th scope="col">Checksum</th>
</tr>
</thead>
<tbody>
{filteredTables?.map((table) => (
<tr key={`${table.tag}-${table.offset}`}>
<th scope="row">
<code>{JSON.stringify(table.tag)}</code>
</th>
<td>{table.description}</td>
<td>
{formatBytes(table.storedLength)}
{table.compressed ? " compressed" : ""}
</td>
<td>{formatBytes(table.length)}</td>
<td>{table.offset.toLocaleString()}</td>
<td>
<code>{table.checksum}</code>
</td>
</tr>
))}
</tbody>
</table>
</div>
{inspection.warnings.length > 0 && (
<div className="diagnostics">
<h3>Inspection notes</h3>
<ul>
{inspection.warnings.map((warning) => (
<li key={warning}>{warning}</li>
))}
</ul>
</div>
)}
</section>
<div className="two-column output-grid">
<section className="panel">
<div className="section-heading">
<div>
<p className="eyebrow">Inert output</p>
<h2>Safe @font-face CSS</h2>
</div>
</div>
<label>
Font family
<input
value={cssFamily}
maxLength={200}
onChange={(event) => setCssFamily(event.target.value)}
/>
</label>
<label>
Local asset path
<input
value={cssPath}
maxLength={1024}
onChange={(event) => setCssPath(event.target.value)}
/>
</label>
<div className="inline-controls">
<label>
font-display
<select
value={fontDisplay}
onChange={(event) =>
setFontDisplay(event.target.value as typeof fontDisplay)
}
>
<option>swap</option>
<option>block</option>
<option>fallback</option>
<option>optional</option>
<option>auto</option>
</select>
</label>
<label className="check">
<input
type="checkbox"
checked={unicodeRange}
onChange={(event) => setUnicodeRange(event.target.checked)}
/>{" "}
Include complete unicode-range when compact
</label>
</div>
<textarea
className="code-output"
readOnly
aria-label="Generated CSS"
value={cssResult?.css ?? ""}
/>
{cssResult?.warnings.map((warning) => (
<p className="note" key={warning}>
{warning}
</p>
))}
<button
disabled={!cssResult?.css}
onClick={() =>
cssResult &&
triggerBlobDownload(
new Blob([`${cssResult.css}\n`], { type: "text/css" }),
"font-face.css",
)
}
>
Download CSS
</button>
</section>
<section className="panel subset-panel">
<div className="section-heading">
<div>
<p className="eyebrow">Bounded derivative</p>
<h2>Simple static subset</h2>
</div>
</div>
<p>
This output intentionally does not preserve layout, variation,
hinting, colour or signature tables. It is suitable only after
target-software review.
</p>
{subsetBlocked && (
<p className="block-reason">
<strong>Unavailable:</strong> {subsetBlocked}
</p>
)}
<label>
Characters to retain
<textarea
value={subsetText}
maxLength={FONT_LIMITS.previewCharacters}
onChange={(event) => setSubsetText(event.target.value)}
/>
</label>
<label className="check rights-check">
<input
type="checkbox"
checked={rightsConfirmed}
onChange={(event) => setRightsConfirmed(event.target.checked)}
/>{" "}
I have reviewed the actual font licence and have rights to
create and use this derivative.
</label>
<button
className="primary"
disabled={
Boolean(subsetBlocked) ||
!rightsConfirmed ||
operation !== null
}
onClick={() => void buildSubset()}
>
{operation === "subset"
? "Building…"
: "Build & download .otf subset"}
</button>
{subsetReport && (
<div className="loss-report">
<h3>Last subset loss report</h3>
<p>
{subsetReport.subsetGlyphs} of{" "}
{subsetReport.sourceGlyphs.toLocaleString()} glyphs ·{" "}
{subsetReport.requestedCodePoints} requested code points
</p>
<ul>
{subsetReport.losses.map((loss) => (
<li key={loss}>{loss}</li>
))}
</ul>
</div>
)}
</section>
</div>
</>
)}
</main>
);
}
function Summary({
label,
value,
detail,
tone,
}: {
label: string;
value: string;
detail: string;
tone?: "ok" | "warn";
}) {
return (
<article className={`summary-card panel ${tone ?? ""}`}>
<p className="eyebrow">{label}</p>
<strong>{value}</strong>
<span>{detail}</span>
</article>
);
}
function nameEntries(inspection: FontInspection): [string, string][] {
const labels: Record<string, string> = {
family: "Family",
subfamily: "Subfamily",
fullName: "Full name",
postScriptName: "PostScript name",
version: "Version",
manufacturer: "Manufacturer",
designer: "Designer",
description: "Description",
copyright: "Copyright",
trademark: "Trademark",
};
return Object.entries(labels).flatMap(([key, label]) =>
inspection.names[key]
? [[label, inspection.names[key]!] as [string, string]]
: [],
);
}
function subsetBlockReason(inspection: FontInspection) {
if (!inspection.embedding.subsetAllowed)
return `OS/2 fsType ${inspection.embedding.rawHex} declares a restriction that blocks this outline subset.`;
if (inspection.axes.length)
return "Variable fonts are inspected and previewed, but v0.1 does not flatten their variation model.";
if (!inspection.tables.some((table) => ["glyf", "CFF "].includes(table.tag)))
return "No supported static glyf or CFF outline table is present.";
return "";
}
function safeAssetName(name: string) {
return (
name.replace(/[^\p{Letter}\p{Number}._-]+/gu, "-").slice(0, 120) ||
"local-font.otf"
);
}
function formatBytes(value: number) {
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KiB`;
return `${(value / 1024 / 1024).toFixed(2)} MiB`;
}
function visibleCharacter(character: string) {
return character === " "
? "␠"
: character === "\n"
? "↵"
: character === "\t"
? "⇥"
: character;
}
function hex(value: number) {
return value.toString(16).toUpperCase().padStart(4, "0");
}
function message(error: unknown) {
return error instanceof Error ? error.message : String(error);
}
+131
View File
@@ -0,0 +1,131 @@
import type { FontInspection, VariableAxis } from "./model";
import { extensionFormat } from "./sfnt";
export interface CssOptions {
family: string;
sourcePath: string;
display: "auto" | "block" | "swap" | "fallback" | "optional";
inspection: FontInspection;
includeUnicodeRange: boolean;
}
export interface CssResult {
css: string;
warnings: string[];
}
export function generateFontFace(options: CssOptions): CssResult {
const family = options.family.trim();
if (!family) throw new Error("Font family is required.");
if (family.length > 200)
throw new Error("Font family exceeds 200 characters.");
const sourcePath = validateLocalPath(options.sourcePath),
axes = new Map(options.inspection.axes.map((axis) => [axis.tag, axis])),
lines = [
"@font-face {",
` font-family: ${cssString(family)};`,
` src: url(${cssString(sourcePath)}) format(${cssString(
extensionFormat(
options.inspection.fileName,
options.inspection.container,
),
)});`,
` font-display: ${options.display};`,
` font-weight: ${axisRange(axes.get("wght"), "400")};`,
` font-stretch: ${axisRange(axes.get("wdth"), "100", "%")};`,
` font-style: ${styleValue(options.inspection, axes)};`,
],
warnings: string[] = [];
if (options.includeUnicodeRange) {
if (options.inspection.coverageRangesTruncated) {
warnings.push(
"unicode-range was omitted because the displayed range inventory is truncated.",
);
} else if (options.inspection.coverageRanges.length > 128) {
warnings.push(
"unicode-range was omitted because more than 128 disjoint ranges would make the rule misleadingly large.",
);
} else if (options.inspection.coverageRanges.length) {
const values = options.inspection.coverageRanges.map((range) =>
range.start === range.end
? `U+${hex(range.start)}`
: `U+${hex(range.start)}-${hex(range.end)}`,
);
lines.push(` unicode-range: ${values.join(", ")};`);
}
}
lines.push("}");
if (options.inspection.embedding.level === "restricted")
warnings.push(
"The font declares restricted embedding. Generating inert text does not grant web-font rights.",
);
else
warnings.push(
"Confirm the actual font licence before deploying this @font-face rule.",
);
return { css: lines.join("\n"), warnings };
}
export function validateLocalPath(value: string) {
const path = value.trim();
if (!path) throw new Error("A local font asset path is required.");
if (path.length > 1_024)
throw new Error("Font asset path exceeds 1,024 characters.");
if (/^[a-z][a-z\d+.-]*:/iu.test(path) || path.startsWith("//"))
throw new Error(
"Only relative or root-relative local asset paths are accepted.",
);
if (/\p{Cc}/u.test(path))
throw new Error("Font asset path contains control characters.");
return path;
}
function cssString(value: string) {
return `"${value
.replaceAll("\\", "\\\\")
.replaceAll('"', '\\"')
.replace(/[\n\r\f]/gu, (character) =>
character === "\n" ? "\\a " : character === "\r" ? "\\d " : "\\c ",
)}"`;
}
function axisRange(
axis: VariableAxis | undefined,
fallback: string,
suffix = "",
) {
if (!axis) return fallback + suffix;
const minimum = `${trimNumber(axis.min)}${suffix}`,
maximum = `${trimNumber(axis.max)}${suffix}`;
return minimum === maximum ? minimum : `${minimum} ${maximum}`;
}
function styleValue(
inspection: FontInspection,
axes: Map<string, VariableAxis>,
) {
const slant = axes.get("slnt");
if (slant)
return `oblique ${trimNumber(Math.min(slant.min, slant.max))}deg ${trimNumber(
Math.max(slant.min, slant.max),
)}deg`;
const subfamily = inspection.names.subfamily?.toLowerCase() ?? "";
return subfamily.includes("italic") ? "italic" : "normal";
}
function trimNumber(value: number) {
return Number(value.toFixed(4)).toString();
}
function hex(value: number) {
return value.toString(16).toUpperCase().padStart(4, "0");
}
export function suggestedFamily(inspection: FontInspection) {
return (
inspection.names.family ||
inspection.names.fullName ||
inspection.fileName.replace(/\.[^.]+$/u, "") ||
"Local font"
);
}
+449
View File
@@ -0,0 +1,449 @@
/// <reference lib="webworker" />
import * as opentype from "opentype.js";
import type {
CoverageBlock,
CoverageInspection,
CoverageRange,
FontInspection,
SubsetResult,
VariableAxis,
WorkerRequest,
WorkerResponse,
} from "./model";
import { decodeEmbedding, FONT_LIMITS, inspectDirectory } from "./sfnt";
const worker = self as DedicatedWorkerGlobalScope;
let font: opentype.Font | null = null,
inspection: FontInspection | null = null;
worker.addEventListener("message", (event: MessageEvent<WorkerRequest>) => {
const request = event.data;
try {
if (request.command === "load") {
const result = load(request.buffer, request.fileName, request.fileSize);
respond(request.id, result);
} else if (request.command === "coverage") {
respond(request.id, inspectTextCoverage(request.text));
} else {
const result = subset(
request.text,
request.familyName,
request.rightsConfirmed,
);
respond(request.id, result, [result.buffer]);
}
} catch (error) {
const response: WorkerResponse = {
id: request.id,
ok: false,
error: error instanceof Error ? error.message : String(error),
};
worker.postMessage(response);
}
});
function respond(id: number, result: unknown, transfer: Transferable[] = []) {
const response: WorkerResponse = { id, ok: true, result };
worker.postMessage(response, transfer);
}
function load(
buffer: ArrayBuffer,
fileName: string,
fileSize: number,
): FontInspection {
const directory = inspectDirectory(buffer);
if (fileSize !== buffer.byteLength)
throw new Error("Transferred font size does not match the selected file.");
const parsed = opentype.parse(buffer);
if (parsed.glyphs.length > FONT_LIMITS.glyphs)
throw new Error(
`Font exceeds the ${FONT_LIMITS.glyphs.toLocaleString()}-glyph limit.`,
);
const unicode = collectUnicode(parsed),
ranges = compressRanges([...unicode].sort((a, b) => a - b)),
tables = parsed.tables as unknown as Record<string, unknown>,
os2 = object(tables.os2),
rawFsType = finiteInteger(os2.fsType),
axes = readAxes(tables.fvar),
names = readNames(parsed.names as unknown as Record<string, unknown>),
warnings = [...directory.warnings];
if (!directory.tables.some((table) => table.tag === "cmap"))
warnings.push("No cmap table is present; Unicode coverage may be empty.");
if (!directory.tables.some((table) => table.tag === "name"))
warnings.push("No name table is present; identity fields may be empty.");
if (directory.tables.some((table) => table.tag === "DSIG"))
warnings.push(
"A DSIG table is present; this inventory does not validate signatures.",
);
if (
directory.tables.some((table) =>
["COLR", "CPAL", "SVG ", "sbix", "CBDT", "CBLC"].includes(table.tag),
)
)
warnings.push(
"Colour glyph tables are present; browser preview support varies.",
);
if (
!directory.tables.some((table) =>
["glyf", "CFF ", "CFF2"].includes(table.tag),
)
)
warnings.push(
"No supported outline table was identified; static subsetting is unavailable.",
);
const next: FontInspection = {
fileName,
fileSize,
container: directory.container,
flavor: directory.flavor,
names,
unitsPerEm: parsed.unitsPerEm,
ascender: parsed.ascender,
descender: parsed.descender,
glyphCount: parsed.glyphs.length,
unicodeCount: unicode.size,
coverageRanges: ranges.ranges,
coverageRangesTruncated: ranges.truncated,
coverageBlocks: coverageBlocks(unicode),
axes,
embedding: decodeEmbedding(rawFsType),
tables: directory.tables,
expandedSize: directory.expandedSize,
warnings,
};
font = parsed;
inspection = next;
return next;
}
function collectUnicode(parsed: opentype.Font) {
const values = new Set<number>();
let mappings = 0;
for (let index = 0; index < parsed.glyphs.length; index += 1) {
const glyph = parsed.glyphs.get(index),
codes = glyph.unicodes?.length
? glyph.unicodes
: glyph.unicode === undefined
? []
: [glyph.unicode];
for (const code of codes) {
mappings += 1;
if (mappings > FONT_LIMITS.unicodeMappings)
throw new Error(
`Font exceeds the ${FONT_LIMITS.unicodeMappings.toLocaleString()} Unicode-mapping limit.`,
);
if (Number.isInteger(code) && code >= 0 && code <= 0x10ffff)
values.add(code);
}
}
return values;
}
function compressRanges(sorted: number[]) {
const ranges: CoverageRange[] = [];
let start: number | undefined,
end: number | undefined,
truncated = false;
for (const value of sorted) {
if (start === undefined) start = end = value;
else if (value === end! + 1) end = value;
else {
if (ranges.length === 512) {
truncated = true;
break;
}
ranges.push({ start, end: end! });
start = end = value;
}
}
if (!truncated && start !== undefined) ranges.push({ start, end: end! });
return { ranges, truncated };
}
const BLOCKS = [
["basic-latin", "Basic Latin", 0x0020, 0x007e],
["latin-1", "Latin-1 Supplement", 0x00a0, 0x00ff],
["latin-ext", "Latin Extended A/B", 0x0100, 0x024f],
["greek", "Greek and Coptic", 0x0370, 0x03ff],
["cyrillic", "Cyrillic", 0x0400, 0x04ff],
["hebrew", "Hebrew", 0x0590, 0x05ff],
["arabic", "Arabic", 0x0600, 0x06ff],
["devanagari", "Devanagari", 0x0900, 0x097f],
["thai", "Thai", 0x0e00, 0x0e7f],
["hiragana", "Hiragana", 0x3040, 0x309f],
["katakana", "Katakana", 0x30a0, 0x30ff],
["cjk", "CJK Unified Ideographs", 0x4e00, 0x9fff],
["hangul", "Hangul Syllables", 0xac00, 0xd7af],
["symbols", "Miscellaneous Symbols", 0x2600, 0x26ff],
["emoji", "Emoji pictographs", 0x1f300, 0x1faff],
] as const;
function coverageBlocks(values: Set<number>): CoverageBlock[] {
return BLOCKS.map(([id, label, start, end]) => {
let covered = 0;
for (let value = start; value <= end; value += 1)
if (values.has(value)) covered += 1;
return { id, label, covered, total: end - start + 1 };
});
}
function readAxes(value: unknown): VariableAxis[] {
const fvar = object(value),
axes = Array.isArray(fvar.axes) ? fvar.axes : [];
if (axes.length > 32)
throw new Error("Font exceeds the 32-variable-axis limit.");
return axes.flatMap((raw, index) => {
const axis = object(raw),
tag =
typeof axis.tag === "string" ? axis.tag.slice(0, 4) : `axis${index}`,
min = finite(axis.minValue),
defaultValue = finite(axis.defaultValue),
max = finite(axis.maxValue);
if (
min === null ||
defaultValue === null ||
max === null ||
min > defaultValue ||
defaultValue > max
)
return [];
return [
{
tag,
name: localized(axis.name) || tag,
min,
default: defaultValue,
max,
},
];
});
}
function readNames(names: Record<string, unknown>) {
const fields: [string, string][] = [
["family", "fontFamily"],
["subfamily", "fontSubfamily"],
["fullName", "fullName"],
["postScriptName", "postScriptName"],
["version", "version"],
["manufacturer", "manufacturer"],
["designer", "designer"],
["description", "description"],
["copyright", "copyright"],
["trademark", "trademark"],
["license", "license"],
["licenseUrl", "licenseURL"],
];
return Object.fromEntries(
fields.flatMap(([output, input]) => {
const value = namedValue(names, input);
return value ? [[output, value.slice(0, 4_000)]] : [];
}),
);
}
function namedValue(names: Record<string, unknown>, field: string) {
const direct = localized(names[field]).trim();
if (direct) return direct;
for (const platform of ["unicode", "windows", "macintosh"]) {
const value = localized(object(names[platform])[field]).trim();
if (value) return value;
}
return "";
}
function localized(value: unknown): string {
if (typeof value === "string") return value;
if (!value || typeof value !== "object") return "";
const record = value as Record<string, unknown>,
english = record.en;
if (typeof english === "string") return english;
return (
Object.values(record).find(
(entry): entry is string => typeof entry === "string",
) ?? ""
);
}
function inspectTextCoverage(text: string): CoverageInspection {
const current = requireFont();
if ([...text].length > FONT_LIMITS.previewCharacters)
throw new Error("Preview text exceeds 20,000 Unicode characters.");
const unique = [...new Set([...text])],
truncated = unique.length > 2_048,
characters = unique.slice(0, 2_048).map((character) => {
const codePoint = character.codePointAt(0)!,
glyphIndex = current.charToGlyphIndex(character),
glyph = current.glyphs.get(glyphIndex);
return {
character,
codePoint,
glyphIndex,
glyphName:
glyph.name || (glyphIndex === 0 ? ".notdef" : `glyph ${glyphIndex}`),
covered: glyphIndex !== 0,
};
});
return {
characters,
covered: characters.filter((entry) => entry.covered).length,
missing: characters.filter((entry) => !entry.covered).length,
truncated,
};
}
function subset(
text: string,
familyName: string,
rightsConfirmed: boolean,
): SubsetResult {
const current = requireFont(),
report = requireInspection();
if (!rightsConfirmed)
throw new Error(
"Confirm that you have rights to create and use a derivative subset.",
);
if (!report.embedding.subsetAllowed)
throw new Error(
`Subsetting is blocked by OS/2 fsType ${report.embedding.rawHex}.`,
);
if (report.axes.length)
throw new Error(
"Variable fonts are not flattened or subset in v0.1; use a licensed static instance.",
);
if (!report.tables.some((table) => ["glyf", "CFF "].includes(table.tag)))
throw new Error(
"The font has no supported static outline table to subset.",
);
const characters = [...text];
if (!characters.length)
throw new Error("Enter at least one character to subset.");
if (characters.length > FONT_LIMITS.previewCharacters)
throw new Error("Subset text exceeds 20,000 Unicode characters.");
const requested = new Set(
characters.map((character) => character.codePointAt(0)!),
),
indexes = new Set<number>([0]);
for (const character of characters) {
const index = current.charToGlyphIndex(character);
if (index !== 0) indexes.add(index);
if (indexes.size > FONT_LIMITS.subsetGlyphs)
throw new Error(
`Subset exceeds the ${FONT_LIMITS.subsetGlyphs.toLocaleString()}-glyph limit.`,
);
}
if (indexes.size === 1)
throw new Error("None of the requested characters map to a font glyph.");
const glyphs = [...indexes]
.sort((a, b) => a - b)
.map((index) => cloneGlyph(current.glyphs.get(index), requested));
const cleanFamily = familyName.trim().slice(0, 200);
if (!cleanFamily) throw new Error("Subset family name is required.");
const subsetFont = new opentype.Font({
familyName: cleanFamily,
styleName: report.names.subfamily || "Regular",
unitsPerEm: current.unitsPerEm,
ascender: current.ascender,
descender: current.descender,
glyphs,
license: report.names.license,
licenseURL: report.names.licenseUrl,
copyright: report.names.copyright,
trademark: report.names.trademark,
manufacturer: report.names.manufacturer,
designer: report.names.designer,
version: report.names.version,
});
// opentype.js rebuilds OS/2 from this object. Preserve the machine-readable
// embedding/subsetting declaration so a derivative never becomes less
// restrictive merely because it passed through this tool.
if (report.embedding.raw !== null)
(subsetFont.tables.os2 as unknown as Record<string, unknown>).fsType =
report.embedding.raw;
const buffer = subsetFont.toArrayBuffer();
inspectDirectory(buffer);
const reparsed = opentype.parse(buffer),
outputFsType = finiteInteger(
object(object(reparsed.tables as unknown as Record<string, unknown>).os2)
.fsType,
);
if (report.embedding.raw !== null && outputFsType !== report.embedding.raw)
throw new Error(
"The subset writer did not preserve the source embedding restrictions.",
);
const losses = [
"Rebuilt as a static OpenType outline font; the source container is not preserved.",
"Only directly mapped requested Unicode glyphs and .notdef are retained.",
"OpenType shaping/positioning, ligatures, kerning, hinting and variation tables are not retained.",
"Colour, bitmap, SVG, signature and most source metadata tables are not retained.",
"Family/style, readable licence fields, fsType restrictions and basic horizontal metrics are retained; verify rendering in the target software.",
"The output is a technical derivative. The source licence still governs use and redistribution.",
];
return {
buffer,
fileName: `${safeFileName(cleanFamily)}-subset.otf`,
sourceGlyphs: report.glyphCount,
subsetGlyphs: glyphs.length,
requestedCodePoints: requested.size,
losses,
};
}
function cloneGlyph(glyph: opentype.Glyph, requested: Set<number>) {
const unicodes = (glyph.unicodes ?? []).filter((code) => requested.has(code));
if (
!unicodes.length &&
glyph.unicode !== undefined &&
requested.has(glyph.unicode)
)
unicodes.push(glyph.unicode);
return new opentype.Glyph({
name: glyph.name ?? undefined,
unicode: unicodes[0],
unicodes,
advanceWidth: glyph.advanceWidth,
leftSideBearing: glyph.leftSideBearing,
path: glyph.path,
});
}
function safeFileName(value: string) {
return (
value
.normalize("NFKD")
.replace(/[^\p{Letter}\p{Number}._-]+/gu, "-")
.replace(/^-+|-+$/gu, "")
.slice(0, 80) || "font"
);
}
function requireFont() {
if (!font) throw new Error("Load a font first.");
return font;
}
function requireInspection() {
if (!inspection) throw new Error("Load a font first.");
return inspection;
}
function object(value: unknown): Record<string, unknown> {
return value && typeof value === "object"
? (value as Record<string, unknown>)
: {};
}
function finite(value: unknown) {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function finiteInteger(value: unknown) {
const number = finite(value);
return number !== null &&
Number.isInteger(number) &&
number >= 0 &&
number <= 0xffff
? number
: null;
}
+110
View File
@@ -0,0 +1,110 @@
import type {
CoverageInspection,
FontInspection,
SubsetResult,
WorkerRequest,
WorkerResponse,
} from "./model";
import { FONT_LIMITS } from "./sfnt";
import FontParserWorker from "./font.worker?worker&inline";
interface Pending {
resolve(value: unknown): void;
reject(error: Error): void;
timer: ReturnType<typeof setTimeout>;
}
type WithoutId<T> = T extends unknown ? Omit<T, "id"> : never;
type WorkerCommand = WithoutId<WorkerRequest>;
export class FontWorkerClient {
private readonly worker: Worker;
private readonly pending = new Map<number, Pending>();
private sequence = 0;
private stopped = false;
constructor() {
this.worker = new FontParserWorker({ name: "font-tools-parser" });
this.worker.addEventListener(
"message",
(event: MessageEvent<WorkerResponse>) => {
const response = event.data,
pending = this.pending.get(response.id);
if (!pending) return;
clearTimeout(pending.timer);
this.pending.delete(response.id);
if (response.ok) pending.resolve(response.result);
else pending.reject(new Error(response.error));
},
);
this.worker.addEventListener("error", () => {
this.failAll(new Error("The isolated font parser stopped unexpectedly."));
});
this.worker.addEventListener("messageerror", () => {
this.failAll(
new Error("The isolated font parser returned an unreadable result."),
);
});
}
async load(file: File): Promise<FontInspection> {
if (file.size > FONT_LIMITS.fileBytes)
throw new Error("Font exceeds the 16 MiB input limit.");
const buffer = await file.arrayBuffer();
return this.request<FontInspection>(
{ command: "load", fileName: file.name, fileSize: file.size, buffer },
[buffer],
);
}
coverage(text: string) {
return this.request<CoverageInspection>({ command: "coverage", text });
}
subset(text: string, familyName: string, rightsConfirmed: boolean) {
return this.request<SubsetResult>({
command: "subset",
text,
familyName,
rightsConfirmed,
});
}
terminate() {
if (this.stopped) return;
this.stopped = true;
this.worker.terminate();
this.failAll(new Error("Font operation cancelled."));
}
private request<T>(
payload: WorkerCommand,
transfer: Transferable[] = [],
): Promise<T> {
if (this.stopped)
return Promise.reject(new Error("Font parser is no longer active."));
const id = ++this.sequence,
request = { ...payload, id } as WorkerRequest;
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(id);
this.terminate();
reject(new Error("Font operation exceeded its 8-second safety limit."));
}, FONT_LIMITS.operationMilliseconds);
this.pending.set(id, {
resolve: (value) => resolve(value as T),
reject,
timer,
});
this.worker.postMessage(request, transfer);
});
}
private failAll(error: Error) {
for (const pending of this.pending.values()) {
clearTimeout(pending.timer);
pending.reject(error);
}
this.pending.clear();
}
}
+118
View File
@@ -0,0 +1,118 @@
export type FontContainer = "truetype" | "opentype-cff" | "woff";
export interface TableRecord {
tag: string;
description: string;
offset: number;
length: number;
storedLength: number;
checksum: string;
compressed: boolean;
}
export interface DirectoryInspection {
container: FontContainer;
flavor: string;
declaredSize: number;
expandedSize: number;
tables: TableRecord[];
warnings: string[];
}
export interface VariableAxis {
tag: string;
name: string;
min: number;
default: number;
max: number;
}
export interface CoverageRange {
start: number;
end: number;
}
export interface CoverageBlock {
id: string;
label: string;
covered: number;
total: number;
}
export interface EmbeddingInfo {
raw: number | null;
rawHex: string;
level:
"installable" | "restricted" | "preview-print" | "editable" | "unknown";
label: string;
noSubsetting: boolean;
bitmapOnly: boolean;
subsetAllowed: boolean;
signals: string[];
}
export interface FontInspection {
fileName: string;
fileSize: number;
container: FontContainer;
flavor: string;
names: Record<string, string>;
unitsPerEm: number;
ascender: number;
descender: number;
glyphCount: number;
unicodeCount: number;
coverageRanges: CoverageRange[];
coverageRangesTruncated: boolean;
coverageBlocks: CoverageBlock[];
axes: VariableAxis[];
embedding: EmbeddingInfo;
tables: TableRecord[];
expandedSize: number;
warnings: string[];
}
export interface GlyphCoverage {
character: string;
codePoint: number;
glyphIndex: number;
glyphName: string;
covered: boolean;
}
export interface CoverageInspection {
characters: GlyphCoverage[];
covered: number;
missing: number;
truncated: boolean;
}
export interface SubsetResult {
buffer: ArrayBuffer;
fileName: string;
sourceGlyphs: number;
subsetGlyphs: number;
requestedCodePoints: number;
losses: string[];
}
export type WorkerRequest =
| {
id: number;
command: "load";
fileName: string;
fileSize: number;
buffer: ArrayBuffer;
}
| { id: number; command: "coverage"; text: string }
| {
id: number;
command: "subset";
text: string;
familyName: string;
rightsConfirmed: boolean;
};
export type WorkerResponse =
| { id: number; ok: true; result: unknown }
| { id: number; ok: false; error: string };
+39
View File
@@ -0,0 +1,39 @@
export function previewDocument(
fontUrl: string,
text: string,
fontSize: number,
lineHeight: number,
axes: Record<string, number>,
) {
const safeText = escapeHtml([...text].slice(0, 5_000).join("")),
variation = Object.entries(axes)
.filter(
([tag, value]) =>
/^[\x20-\x7e]{1,4}$/u.test(tag) && Number.isFinite(value),
)
.map(
([tag, value]) =>
`&quot;${escapeHtml(tag)}&quot; ${Number(value.toFixed(4))}`,
)
.join(", "),
size = Math.min(160, Math.max(12, fontSize)),
leading = Math.min(2.5, Math.max(0.8, lineHeight)),
url = fontUrl.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
return `<!doctype html><html><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; font-src blob: data:"><style>
@font-face{font-family:FontToolsPreview;src:url("${url}");font-display:block}
:root{color-scheme:light dark}body{margin:0;padding:1rem;background:transparent;color:CanvasText;font:14px system-ui,sans-serif}
.row{border-bottom:1px solid color-mix(in srgb,CanvasText 18%,transparent);padding:.7rem 0}.row:last-child{border:0}
.label{display:block;font:600 11px/1.2 system-ui,sans-serif;letter-spacing:.08em;text-transform:uppercase;opacity:.7;margin-bottom:.35rem}
.sample{overflow-wrap:anywhere;font-size:${size}px;line-height:${leading};font-variation-settings:${variation || "normal"}}
.loaded{font-family:FontToolsPreview,system-ui,sans-serif}.system{font-family:system-ui,sans-serif}.serif{font-family:serif}.mono{font-family:monospace}
</style></head><body><div class="row"><span class="label">Loaded font, then system fallback</span><div class="sample loaded">${safeText}</div></div><div class="row"><span class="label">System sans-serif</span><div class="sample system">${safeText}</div></div><div class="row"><span class="label">System serif</span><div class="sample serif">${safeText}</div></div><div class="row"><span class="label">System monospace</span><div class="sample mono">${safeText}</div></div></body></html>`;
}
function escapeHtml(value: string) {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
+344
View File
@@ -0,0 +1,344 @@
import type {
DirectoryInspection,
EmbeddingInfo,
FontContainer,
TableRecord,
} from "./model";
export const FONT_LIMITS = {
fileBytes: 16 * 1024 * 1024,
expandedBytes: 64 * 1024 * 1024,
tableBytes: 32 * 1024 * 1024,
tables: 128,
glyphs: 65_535,
unicodeMappings: 250_000,
previewCharacters: 20_000,
subsetGlyphs: 1_024,
operationMilliseconds: 8_000,
} as const;
const TABLE_DESCRIPTIONS: Record<string, string> = {
"CFF ": "Compact Font Format outlines",
CFF2: "Compact Font Format 2 outlines",
COLR: "Layered colour glyphs",
CPAL: "Colour palettes",
DSIG: "Digital signature record",
GDEF: "Glyph definition data",
GPOS: "Glyph positioning",
GSUB: "Glyph substitution",
HVAR: "Horizontal metric variations",
JSTF: "Justification data",
MATH: "Mathematical layout",
OS2: "OS/2 metrics and embedding flags",
"OS/2": "OS/2 metrics and embedding flags",
SVG: "SVG glyph documents",
SVG_: "SVG glyph documents",
avar: "Axis variations",
cmap: "Unicode character mapping",
cvt: "TrueType control values",
"cvt ": "TrueType control values",
fpgm: "TrueType font program",
fvar: "Variable-font axes",
glyf: "TrueType outlines",
gasp: "Rasterisation behaviour",
gvar: "Glyph variations",
head: "Font header",
hhea: "Horizontal header",
hmtx: "Horizontal metrics",
kern: "Legacy kerning",
loca: "Glyph locations",
maxp: "Maximum profile",
meta: "Metadata",
name: "Naming records",
post: "PostScript data",
prep: "TrueType control program",
sbix: "Bitmap colour glyphs",
stat: "Style attributes",
vhea: "Vertical header",
vmtx: "Vertical metrics",
};
export function inspectDirectory(buffer: ArrayBuffer): DirectoryInspection {
if (buffer.byteLength > FONT_LIMITS.fileBytes)
throw new Error("Font exceeds the 16 MiB input limit.");
if (buffer.byteLength < 4) throw new Error("File is too short to be a font.");
const view = new DataView(buffer),
signature = tagAt(view, 0);
if (signature === "wOF2")
throw new Error(
"WOFF2 is recognized but not supported by the local v0.1 parser. Convert it to WOFF, OTF or TTF first.",
);
if (signature === "ttcf")
throw new Error(
"TrueType Collections (TTC/OTC) are not supported in v0.1.",
);
if (signature === "wOFF") return inspectWoff(view);
if (
signature === "OTTO" ||
signature === "true" ||
signature === "typ1" ||
view.getUint32(0, false) === 0x0001_0000
)
return inspectSfnt(view, signature);
throw new Error(
`Unsupported font signature ${displayTag(signature)}; expected TTF, OTF or WOFF.`,
);
}
function inspectSfnt(view: DataView, signature: string): DirectoryInspection {
requireBytes(view, 0, 12, "SFNT header");
const count = view.getUint16(4, false);
validateTableCount(count);
requireBytes(view, 12, count * 16, "SFNT table directory");
const tables: TableRecord[] = [];
for (let index = 0; index < count; index += 1) {
const base = 12 + index * 16,
tag = tagAt(view, base),
checksum = view.getUint32(base + 4, false),
offset = view.getUint32(base + 8, false),
length = view.getUint32(base + 12, false);
validateTable(view, tag, offset, length, length);
tables.push(record(tag, offset, length, length, checksum));
}
const warnings = directoryWarnings(tables);
return {
container: signature === "OTTO" ? "opentype-cff" : "truetype",
flavor: signature === "OTTO" ? "OpenType/CFF" : "TrueType outlines",
declaredSize: view.byteLength,
expandedSize: view.byteLength,
tables,
warnings,
};
}
function inspectWoff(view: DataView): DirectoryInspection {
requireBytes(view, 0, 44, "WOFF header");
const flavorTag = tagAt(view, 4),
declaredSize = view.getUint32(8, false),
count = view.getUint16(12, false),
expandedSize = view.getUint32(16, false);
validateTableCount(count);
if (declaredSize > view.byteLength)
throw new Error("WOFF header declares bytes beyond the file boundary.");
if (expandedSize > FONT_LIMITS.expandedBytes)
throw new Error("WOFF expanded size exceeds the 64 MiB safety limit.");
requireBytes(view, 44, count * 20, "WOFF table directory");
validateOptionalBlock(
view,
view.getUint32(24, false),
view.getUint32(28, false),
"WOFF metadata",
);
validateOptionalBlock(
view,
view.getUint32(36, false),
view.getUint32(40, false),
"WOFF private data",
);
const tables: TableRecord[] = [];
for (let index = 0; index < count; index += 1) {
const base = 44 + index * 20,
tag = tagAt(view, base),
offset = view.getUint32(base + 4, false),
storedLength = view.getUint32(base + 8, false),
length = view.getUint32(base + 12, false),
checksum = view.getUint32(base + 16, false);
if (storedLength > length)
throw new Error(
`WOFF table ${displayTag(tag)} is larger than its original length.`,
);
if (storedLength > 0 && length / storedLength > 200)
throw new Error(
`WOFF table ${displayTag(tag)} exceeds the 200:1 expansion-ratio limit.`,
);
validateTable(view, tag, offset, length, storedLength);
tables.push(record(tag, offset, length, storedLength, checksum));
}
const warnings = directoryWarnings(tables);
if (declaredSize !== view.byteLength)
warnings.push(
`WOFF header length (${declaredSize.toLocaleString()}) differs from the file length (${view.byteLength.toLocaleString()}).`,
);
return {
container: "woff",
flavor:
flavorTag === "OTTO"
? "WOFF with CFF outlines"
: "WOFF with TrueType outlines",
declaredSize,
expandedSize,
tables,
warnings,
};
}
function validateTableCount(count: number) {
if (count < 1) throw new Error("The font declares no tables.");
if (count > FONT_LIMITS.tables)
throw new Error(`The font exceeds the ${FONT_LIMITS.tables}-table limit.`);
}
function validateTable(
view: DataView,
tag: string,
offset: number,
length: number,
storedLength: number,
) {
if (!/^[\x20-\x7e]{4}$/u.test(tag))
throw new Error("A table has a non-ASCII tag.");
if (length > FONT_LIMITS.tableBytes)
throw new Error(
`Table ${displayTag(tag)} exceeds the 32 MiB expanded-table limit.`,
);
if (length > 0 && storedLength === 0)
throw new Error(`Table ${displayTag(tag)} has an empty stored payload.`);
requireBytes(view, offset, storedLength, `table ${displayTag(tag)}`);
}
function validateOptionalBlock(
view: DataView,
offset: number,
length: number,
label: string,
) {
if (offset === 0 && length === 0) return;
if (offset === 0 || length === 0)
throw new Error(`${label} has an incomplete boundary.`);
requireBytes(view, offset, length, label);
}
function requireBytes(
view: DataView,
offset: number,
length: number,
label: string,
) {
if (
!Number.isSafeInteger(offset) ||
!Number.isSafeInteger(length) ||
offset < 0 ||
length < 0 ||
offset > view.byteLength ||
length > view.byteLength - offset
)
throw new Error(`${label} extends beyond the file boundary.`);
}
function directoryWarnings(tables: TableRecord[]) {
const warnings: string[] = [],
seen = new Set<string>();
for (const table of tables) {
if (seen.has(table.tag))
warnings.push(`Duplicate ${displayTag(table.tag)} table.`);
seen.add(table.tag);
if (table.offset % 4 !== 0)
warnings.push(`${displayTag(table.tag)} table is not four-byte aligned.`);
}
const byOffset = [...tables].sort((a, b) => a.offset - b.offset);
for (let index = 1; index < byOffset.length; index += 1) {
const previous = byOffset[index - 1]!,
current = byOffset[index]!;
if (previous.offset + previous.storedLength > current.offset)
warnings.push(
`${displayTag(previous.tag)} and ${displayTag(current.tag)} table payloads overlap.`,
);
}
return warnings;
}
function record(
tag: string,
offset: number,
length: number,
storedLength: number,
checksum: number,
): TableRecord {
return {
tag,
description: TABLE_DESCRIPTIONS[tag] ?? "OpenType table",
offset,
length,
storedLength,
checksum: `0x${checksum.toString(16).padStart(8, "0")}`,
compressed: storedLength < length,
};
}
function tagAt(view: DataView, offset: number) {
requireBytes(view, offset, 4, "font signature");
return String.fromCharCode(
view.getUint8(offset),
view.getUint8(offset + 1),
view.getUint8(offset + 2),
view.getUint8(offset + 3),
);
}
function displayTag(tag: string) {
return JSON.stringify(tag);
}
export function decodeEmbedding(raw: number | null): EmbeddingInfo {
if (raw === null)
return {
raw,
rawHex: "not present",
level: "unknown",
label: "No OS/2 embedding signal",
noSubsetting: false,
bitmapOnly: false,
subsetAllowed: true,
signals: [
"The font has no readable OS/2 fsType field; verify its licence manually.",
],
};
const restricted = Boolean(raw & 0x0002),
previewPrint = Boolean(raw & 0x0004),
editable = Boolean(raw & 0x0008),
noSubsetting = Boolean(raw & 0x0100),
bitmapOnly = Boolean(raw & 0x0200),
level: EmbeddingInfo["level"] = restricted
? "restricted"
: editable
? "editable"
: previewPrint
? "preview-print"
: "installable",
label =
level === "restricted"
? "Restricted licence embedding"
: level === "editable"
? "Editable embedding"
: level === "preview-print"
? "Preview & print embedding"
: "Installable embedding";
const signals = [
`${label} is declared by OS/2 fsType.`,
noSubsetting
? "The no-subsetting bit is set."
: "The no-subsetting bit is clear.",
bitmapOnly
? "Only bitmap embedding is declared; outline export is blocked."
: "The bitmap-only bit is clear.",
"fsType is a technical signal, not a substitute for the font licence.",
];
return {
raw,
rawHex: `0x${raw.toString(16).padStart(4, "0")}`,
level,
label,
noSubsetting,
bitmapOnly,
subsetAllowed: !restricted && !noSubsetting && !bitmapOnly,
signals,
};
}
export function extensionFormat(fileName: string, container?: FontContainer) {
const extension = fileName.split(".").at(-1)?.toLowerCase();
if (extension === "woff" || container === "woff") return "woff";
if (extension === "ttf" || container === "truetype") return "truetype";
if (extension === "otf" || container === "opentype-cff") return "opentype";
return "opentype";
}
+18
View File
@@ -0,0 +1,18 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);
if ("serviceWorker" in navigator && import.meta.env.PROD) {
window.addEventListener("load", () => {
const url = new URL("./sw.js", document.baseURI);
void navigator.serviceWorker
.register(url, { scope: new URL("./", document.baseURI).pathname })
.catch(() => undefined);
});
}
+661
View File
@@ -0,0 +1,661 @@
:root {
color-scheme: light dark;
font-family:
Inter,
ui-sans-serif,
system-ui,
-apple-system,
sans-serif;
--surface: color-mix(in srgb, Canvas 94%, #d8b4fe 6%);
--surface-strong: color-mix(in srgb, Canvas 86%, #c084fc 14%);
--line: color-mix(in srgb, CanvasText 18%, transparent);
--line-strong: color-mix(in srgb, CanvasText 34%, transparent);
--muted: color-mix(in srgb, CanvasText 66%, transparent);
--accent: #7e22a8;
--accent-ink: #ffffff;
--success: #167448;
--warning: #b45309;
background: Canvas;
color: CanvasText;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
}
button,
input,
select,
textarea {
font: inherit;
}
button,
input,
select,
textarea,
.file-button {
border: 1px solid var(--line);
border-radius: 0.68rem;
background: Canvas;
color: CanvasText;
padding: 0.62rem 0.75rem;
}
button,
.file-button {
min-height: 2.55rem;
cursor: pointer;
background: var(--surface);
font-weight: 650;
}
button:hover,
.file-button:hover {
border-color: var(--accent);
background: var(--surface-strong);
}
button:disabled,
input:disabled {
cursor: not-allowed;
opacity: 0.55;
}
button:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible,
summary:focus-visible {
outline: 3px solid color-mix(in srgb, var(--accent) 48%, transparent);
outline-offset: 2px;
}
.toolbox-shell__main {
width: min(100%, 90rem);
}
.workbench {
display: grid;
gap: 1rem;
padding: clamp(0.75rem, 2vw, 1.5rem);
}
.panel {
min-width: 0;
border: 1px solid var(--line);
border-radius: 1rem;
background: var(--surface);
padding: clamp(0.85rem, 2vw, 1.2rem);
box-shadow: 0 0.5rem 1.8rem color-mix(in srgb, CanvasText 5%, transparent);
}
.hero,
.section-heading,
.dialog-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.hero {
background:
radial-gradient(
circle at 90% 10%,
color-mix(in srgb, #e879f9 24%, transparent),
transparent 38%
),
linear-gradient(135deg, var(--surface), var(--surface-strong));
}
.hero h1 {
margin: 0.15rem 0;
font-size: clamp(1.75rem, 4vw, 2.8rem);
letter-spacing: -0.035em;
}
.hero p:not(.eyebrow) {
max-width: 70ch;
margin: 0.35rem 0 0;
color: var(--muted);
}
.eyebrow {
margin: 0;
color: var(--accent);
font-size: 0.7rem;
font-weight: 850;
letter-spacing: 0.13em;
text-transform: uppercase;
}
.file-button {
position: relative;
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
background: var(--accent);
color: var(--accent-ink);
border-color: var(--accent);
padding-inline: 1.1rem;
}
.file-button input {
position: absolute;
width: 1px;
height: 1px;
clip: rect(0 0 0 0);
}
.status {
min-height: 1.5em;
margin: 0;
color: var(--muted);
}
.empty {
display: grid;
justify-items: center;
gap: 0.5rem;
padding-block: clamp(2.5rem, 8vw, 6rem);
text-align: center;
}
.empty h2,
.empty p {
margin: 0;
}
.empty p {
max-width: 62ch;
color: var(--muted);
}
.drop-icon {
display: grid;
width: 5rem;
height: 5rem;
place-items: center;
border: 1px solid var(--line-strong);
border-radius: 1.25rem;
background: Canvas;
color: var(--accent);
font:
800 1.8rem/1 Georgia,
serif;
transform: rotate(-3deg);
}
.limit-grid,
.summary-grid,
.two-column,
.block-grid {
display: grid;
gap: 1rem;
}
.limit-grid {
width: min(100%, 48rem);
grid-template-columns: repeat(3, 1fr);
margin: 1rem 0 0;
}
.limit-grid div {
border-top: 1px solid var(--line);
padding-top: 0.7rem;
}
.limit-grid dt {
color: var(--muted);
font-size: 0.75rem;
text-transform: uppercase;
}
.limit-grid dd {
margin: 0.25rem 0 0;
font-weight: 700;
}
.summary-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.summary-card {
display: grid;
align-content: start;
gap: 0.3rem;
}
.summary-card strong {
font-size: clamp(1rem, 2vw, 1.18rem);
overflow-wrap: anywhere;
}
.summary-card span {
color: var(--muted);
font-size: 0.82rem;
}
.summary-card.ok {
border-top: 3px solid var(--success);
}
.summary-card.warn {
border-top: 3px solid var(--warning);
}
.section-heading {
margin-bottom: 0.8rem;
}
.section-heading h2 {
margin: 0.15rem 0 0;
}
.isolation {
border: 1px solid var(--line);
border-radius: 99rem;
padding: 0.35rem 0.65rem;
color: var(--muted);
font-size: 0.75rem;
white-space: nowrap;
}
label {
display: grid;
gap: 0.35rem;
color: var(--muted);
font-size: 0.84rem;
}
textarea {
min-height: 6rem;
resize: vertical;
line-height: 1.45;
}
.preview-controls,
.inline-controls {
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
margin-block: 0.8rem;
}
.preview-controls label {
display: grid;
grid-template-columns: auto minmax(8rem, 16rem) 4rem;
align-items: center;
}
input[type="range"] {
padding: 0;
accent-color: var(--accent);
}
.axes {
display: grid;
gap: 0.65rem;
margin: 0.8rem 0;
border: 1px solid var(--line);
border-radius: 0.75rem;
padding: 0.8rem;
}
.axes legend {
color: var(--muted);
font-weight: 750;
}
.axes label {
display: grid;
grid-template-columns: minmax(9rem, 1fr) minmax(10rem, 3fr) 5rem;
align-items: center;
}
.font-preview-frame {
display: block;
width: 100%;
height: clamp(25rem, 48vh, 36rem);
border: 1px solid var(--line);
border-radius: 0.8rem;
background: Canvas;
}
.two-column {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.metadata-list {
margin: 0;
}
.metadata-list div {
display: grid;
grid-template-columns: minmax(8rem, 0.7fr) minmax(0, 1.5fr);
gap: 0.75rem;
border-top: 1px solid var(--line);
padding: 0.55rem 0;
}
.metadata-list div:first-child {
border: 0;
}
.metadata-list dt {
color: var(--muted);
}
.metadata-list dd {
margin: 0;
overflow-wrap: anywhere;
}
.rights {
border-left: 4px solid var(--success);
}
.rights.blocked {
border-left-color: var(--warning);
}
.rights-label {
font-size: 1.15rem;
font-weight: 800;
}
.rights li,
.loss-report li,
.diagnostics li {
margin-block: 0.35rem;
}
.license-text {
max-height: 12rem;
overflow: auto;
border: 1px solid var(--line);
border-radius: 0.7rem;
background: Canvas;
padding: 0.75rem;
}
.license-text p {
white-space: pre-wrap;
}
.break {
overflow-wrap: anywhere;
}
.block-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.coverage-block {
display: grid;
gap: 0.4rem;
border: 1px solid var(--line);
border-radius: 0.7rem;
background: Canvas;
padding: 0.65rem;
}
.coverage-block div {
display: flex;
justify-content: space-between;
gap: 0.5rem;
font-size: 0.78rem;
}
.coverage-block span {
color: var(--muted);
white-space: nowrap;
}
meter {
width: 100%;
accent-color: var(--accent);
}
.coverage-summary {
margin: 1rem 0 0.5rem;
}
.glyph-strip {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(4.5rem, 1fr));
gap: 0.4rem;
max-height: 25rem;
overflow: auto;
}
.glyph-strip > span {
display: grid;
min-height: 4.5rem;
place-items: center;
border: 1px solid var(--line);
border-radius: 0.55rem;
background: Canvas;
padding: 0.3rem;
}
.glyph-strip .missing {
border-color: color-mix(in srgb, var(--warning) 65%, var(--line));
background: color-mix(in srgb, var(--warning) 8%, Canvas);
}
.glyph-char {
font-size: 1.35rem;
}
.glyph-strip small {
color: var(--muted);
font:
0.65rem ui-monospace,
monospace;
}
details {
margin-top: 1rem;
border-top: 1px solid var(--line);
padding-top: 0.8rem;
}
summary {
cursor: pointer;
font-weight: 750;
}
.range-list {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
max-height: 16rem;
overflow: auto;
margin-top: 0.65rem;
}
.range-list code {
border-radius: 0.35rem;
background: Canvas;
padding: 0.25rem 0.4rem;
}
.compact {
display: grid;
grid-template-columns: auto minmax(9rem, 18rem);
align-items: center;
}
.table-scroll {
max-height: 35rem;
overflow: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.84rem;
}
th,
td {
max-width: 25rem;
border-bottom: 1px solid var(--line);
padding: 0.58rem;
text-align: left;
vertical-align: top;
overflow-wrap: anywhere;
}
thead th {
position: sticky;
top: 0;
background: var(--surface-strong);
z-index: 1;
}
.diagnostics,
.loss-report {
margin-top: 0.8rem;
border: 1px solid var(--line);
border-radius: 0.7rem;
padding: 0.8rem;
}
.diagnostics h3,
.loss-report h3 {
margin-top: 0;
}
.output-grid > section {
display: grid;
align-content: start;
gap: 0.7rem;
}
.check {
display: flex;
align-items: flex-start;
gap: 0.55rem;
}
.check input {
width: 1.1rem;
height: 1.1rem;
flex: 0 0 auto;
margin-top: 0.1rem;
accent-color: var(--accent);
}
.code-output {
min-height: 14rem;
font:
0.78rem/1.5 ui-monospace,
SFMono-Regular,
Consolas,
monospace;
}
.primary {
background: var(--accent);
color: var(--accent-ink);
border-color: var(--accent);
}
.block-reason {
border-left: 3px solid var(--warning);
border-radius: 0.35rem;
background: color-mix(in srgb, var(--warning) 9%, Canvas);
padding: 0.65rem;
}
.note {
margin: 0.5rem 0;
color: var(--muted);
font-size: 0.82rem;
}
.help-dialog {
width: min(44rem, calc(100% - 2rem));
border: 1px solid var(--line);
border-radius: 1rem;
background: Canvas;
color: CanvasText;
padding: 1.2rem;
}
.help-dialog::backdrop {
background: #13091cbb;
backdrop-filter: blur(3px);
}
.dialog-heading button {
width: 2.5rem;
height: 2.5rem;
padding: 0;
}
.fatal {
width: min(100% - 2rem, 50rem);
margin: 3rem auto;
border: 1px solid var(--line);
border-radius: 1rem;
padding: 1.5rem;
}
@media (max-width: 1000px) {
.summary-grid,
.block-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 760px) {
.hero,
.section-heading {
align-items: stretch;
flex-direction: column;
}
.summary-grid,
.two-column,
.block-grid,
.limit-grid {
grid-template-columns: 1fr;
}
.preview-controls,
.inline-controls {
align-items: stretch;
flex-direction: column;
}
.preview-controls label,
.axes label,
.compact,
.metadata-list div {
grid-template-columns: 1fr;
}
.isolation {
align-self: flex-start;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
}
}
+8
View File
@@ -0,0 +1,8 @@
import "@testing-library/jest-dom/vitest";
import { afterEach } from "vitest";
import { cleanup } from "@testing-library/react";
afterEach(() => {
cleanup();
localStorage.clear();
});
+41
View File
@@ -0,0 +1,41 @@
{
"$schema": "https://git.add-ideas.de/lotobo/toolbox-sdk/raw/branch/main/schemas/toolbox-app.v1.schema.json",
"schemaVersion": 1,
"id": "de.add-ideas.font-tools",
"name": "Font Tools",
"version": "0.1.0",
"description": "Inspect and prepare fonts locally.",
"entry": "./",
"icon": "./favicon.svg",
"categories": ["design", "developer"],
"tags": ["font", "ttf", "otf", "woff", "glyph", "typography", "subset"],
"integration": {
"contextVersion": 1,
"launchModes": ["navigate", "new-tab"],
"embedding": "unsupported"
},
"requirements": {
"secureContext": false,
"workers": true,
"indexedDb": false,
"crossOriginIsolated": false,
"topLevelContext": false
},
"privacy": {
"processing": "local",
"fileUploads": true,
"telemetry": false,
"label": "Font files and preview text remain in this browser."
},
"source": {
"repository": "https://git.add-ideas.de/lotobo/font-tools",
"license": "GPL-3.0-or-later"
},
"actions": [
{
"id": "source",
"label": "Source",
"url": "https://git.add-ideas.de/lotobo/font-tools"
}
]
}
+4
View File
@@ -0,0 +1,4 @@
import { defineToolboxApp, parseToolboxApp } from "@add-ideas/toolbox-contract";
import source from "./manifest.source.json";
export const manifest = defineToolboxApp(parseToolboxApp(source));
+1
View File
@@ -0,0 +1 @@
export const APP_VERSION = "0.1.0";
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />