@@ -7,16 +7,27 @@ export function FontPreview({
|
||||
fontSize,
|
||||
lineHeight,
|
||||
axes,
|
||||
features,
|
||||
language,
|
||||
direction,
|
||||
}: {
|
||||
fontUrl: string;
|
||||
text: string;
|
||||
fontSize: number;
|
||||
lineHeight: number;
|
||||
axes: Record<string, number>;
|
||||
features: string;
|
||||
language: string;
|
||||
direction: "auto" | "ltr" | "rtl";
|
||||
}) {
|
||||
const source = useMemo(
|
||||
() => previewDocument(fontUrl, text, fontSize, lineHeight, axes),
|
||||
[fontUrl, text, fontSize, lineHeight, axes],
|
||||
() =>
|
||||
previewDocument(fontUrl, text, fontSize, lineHeight, axes, {
|
||||
features,
|
||||
language,
|
||||
direction,
|
||||
}),
|
||||
[fontUrl, text, fontSize, lineHeight, axes, features, language, direction],
|
||||
);
|
||||
return (
|
||||
<iframe
|
||||
|
||||
@@ -32,19 +32,26 @@ export function HelpDialog({
|
||||
</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.
|
||||
Open one local TTF, OTF, TTC, OTC, WOFF or WOFF2 file. Container bounds
|
||||
are checked before a pinned local parser inspects it in a disposable
|
||||
worker. TTC/OTC headers, face offsets and every selected SFNT directory
|
||||
are validated before you switch between at most 64 faces. WOFF2 is
|
||||
decoded there by Fontkit's browser-safe Brotli path, without a
|
||||
service or unsafe-eval.
|
||||
</p>
|
||||
<p>
|
||||
Preview text is rendered in a scriptless sandbox. Coverage results are
|
||||
based on the font's Unicode cmap and do not promise that every
|
||||
shaping sequence or colour glyph will render in every browser.
|
||||
based on the font's Unicode cmap. The shaping lab lets you compare
|
||||
selected OpenType features, direction and language, but browser and
|
||||
colour-glyph support still varies.
|
||||
</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.
|
||||
Collection faces are reconstructed only for the isolated in-memory
|
||||
preview; collection export and subsetting are unavailable because shared
|
||||
tables, face ordering and DSIG data are not round-tripped by this tool.
|
||||
</p>
|
||||
</dialog>
|
||||
);
|
||||
|
||||
+184
-11
@@ -3,6 +3,7 @@ import { triggerBlobDownload } from "@add-ideas/toolbox-helpers";
|
||||
import { generateFontFace, suggestedFamily } from "../core/css";
|
||||
import { FontWorkerClient } from "../core/fontClient";
|
||||
import type {
|
||||
CollectionFaceSummary,
|
||||
CoverageInspection,
|
||||
FontInspection,
|
||||
SubsetResult,
|
||||
@@ -22,13 +23,16 @@ export function Workbench() {
|
||||
[previewText, setPreviewText] = useState(SAMPLE),
|
||||
[fontSize, setFontSize] = useState(42),
|
||||
[lineHeight, setLineHeight] = useState(1.25),
|
||||
[features, setFeatures] = useState('"kern" 1, "liga" 1'),
|
||||
[language, setLanguage] = useState("en"),
|
||||
[direction, setDirection] = useState<"auto" | "ltr" | "rtl">("auto"),
|
||||
[axes, setAxes] = useState<Record<string, number>>({}),
|
||||
[url, setUrl] = useState<string | null>(null),
|
||||
[operation, setOperation] = useState<"load" | "coverage" | "subset" | null>(
|
||||
null,
|
||||
),
|
||||
[operation, setOperation] = useState<
|
||||
"load" | "face" | "coverage" | "subset" | null
|
||||
>(null),
|
||||
[status, setStatus] = useState(
|
||||
"Choose a local TTF, OTF or WOFF font to begin.",
|
||||
"Choose a local TTF, OTF, TTC, OTC, WOFF or WOFF2 font to begin.",
|
||||
),
|
||||
[tableFilter, setTableFilter] = useState(""),
|
||||
[cssFamily, setCssFamily] = useState("Local font"),
|
||||
@@ -70,13 +74,20 @@ export function Workbench() {
|
||||
setOperation("load");
|
||||
setStatus(`Inspecting ${file.name} in an isolated worker…`);
|
||||
try {
|
||||
const next = await candidate.load(file),
|
||||
const result = await candidate.load(file),
|
||||
next = result.inspection,
|
||||
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);
|
||||
const nextUrl = URL.createObjectURL(
|
||||
result.preview
|
||||
? new Blob([result.preview.buffer], {
|
||||
type: result.preview.mediaType,
|
||||
})
|
||||
: file,
|
||||
);
|
||||
previewUrl.current = nextUrl;
|
||||
setUrl(nextUrl);
|
||||
setInspection(next);
|
||||
@@ -89,7 +100,7 @@ export function Workbench() {
|
||||
setSubsetReport(null);
|
||||
setRightsConfirmed(false);
|
||||
setStatus(
|
||||
`Inspected ${next.glyphCount.toLocaleString()} glyphs, ${next.unicodeCount.toLocaleString()} Unicode values and ${next.tables.length} tables locally.`,
|
||||
`${next.collection ? `Inspected collection face ${next.collection.selectedFace + 1} of ${next.collection.faces.length}: ` : "Inspected "}${next.glyphCount.toLocaleString()} glyphs, ${next.unicodeCount.toLocaleString()} Unicode values and ${next.tables.length} tables locally.`,
|
||||
);
|
||||
} catch (error) {
|
||||
candidate.terminate();
|
||||
@@ -100,6 +111,49 @@ export function Workbench() {
|
||||
}
|
||||
}
|
||||
|
||||
async function selectFace(faceIndex: number) {
|
||||
if (!client.current || !inspection?.collection) return;
|
||||
const token = ++generation.current;
|
||||
setOperation("face");
|
||||
setStatus(
|
||||
`Inspecting collection face ${faceIndex + 1} in the existing isolated worker…`,
|
||||
);
|
||||
try {
|
||||
const result = await client.current.selectFace(faceIndex),
|
||||
next = result.inspection,
|
||||
nextCoverage = await client.current.coverage(previewText);
|
||||
if (token !== generation.current) return;
|
||||
if (!result.preview)
|
||||
throw new Error(
|
||||
"The selected collection face has no safe preview font.",
|
||||
);
|
||||
if (previewUrl.current) URL.revokeObjectURL(previewUrl.current);
|
||||
const nextUrl = URL.createObjectURL(
|
||||
new Blob([result.preview.buffer], { type: result.preview.mediaType }),
|
||||
);
|
||||
previewUrl.current = nextUrl;
|
||||
setUrl(nextUrl);
|
||||
setInspection(next);
|
||||
setCoverage(nextCoverage);
|
||||
setAxes(
|
||||
Object.fromEntries(next.axes.map((axis) => [axis.tag, axis.default])),
|
||||
);
|
||||
setCssFamily(suggestedFamily(next));
|
||||
setSubsetReport(null);
|
||||
setRightsConfirmed(false);
|
||||
setStatus(
|
||||
`Selected collection face ${faceIndex + 1} of ${next.collection?.faces.length ?? 0}: ${next.names.fullName || next.names.family || "unnamed face"}.`,
|
||||
);
|
||||
} catch (error) {
|
||||
if (token === generation.current)
|
||||
setStatus(
|
||||
`${message(error)} The previously selected face remains visible.`,
|
||||
);
|
||||
} finally {
|
||||
if (token === generation.current) setOperation(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function analyzeCoverage() {
|
||||
if (!client.current) return;
|
||||
setOperation("coverage");
|
||||
@@ -169,7 +223,7 @@ export function Workbench() {
|
||||
{operation === "load" ? "Inspecting…" : "Open font"}
|
||||
<input
|
||||
type="file"
|
||||
accept=".ttf,.otf,.woff,.woff2,font/ttf,font/otf,font/woff,font/woff2"
|
||||
accept=".ttf,.otf,.ttc,.otc,.woff,.woff2,font/ttf,font/otf,font/collection,font/woff,font/woff2"
|
||||
disabled={operation === "load"}
|
||||
onChange={(event) => {
|
||||
const file = event.currentTarget.files?.[0];
|
||||
@@ -191,8 +245,10 @@ export function Workbench() {
|
||||
</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.
|
||||
TTF, OTF, TTC, OTC, WOFF and WOFF2 up to 16 MiB are accepted.
|
||||
Collections are capped at 64 validated faces. WOFF2 is decoded
|
||||
locally by pinned Fontkit/Brotli code inside the disposable worker,
|
||||
without unsafe-eval.
|
||||
</p>
|
||||
<dl className="limit-grid">
|
||||
<div>
|
||||
@@ -211,6 +267,53 @@ export function Workbench() {
|
||||
</section>
|
||||
) : (
|
||||
<>
|
||||
{inspection.collection && (
|
||||
<section className="panel collection-panel">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Validated TTC/OTC collection</p>
|
||||
<h2>{inspection.collection.faces.length} selectable faces</h2>
|
||||
</div>
|
||||
<span className="isolation">
|
||||
TTC {inspection.collection.version}
|
||||
</span>
|
||||
</div>
|
||||
<label>
|
||||
Selected collection face
|
||||
<select
|
||||
value={inspection.collection.selectedFace}
|
||||
disabled={operation !== null}
|
||||
onChange={(event) =>
|
||||
void selectFace(Number(event.currentTarget.value))
|
||||
}
|
||||
>
|
||||
{inspection.collection.faces.map((face) => (
|
||||
<option
|
||||
key={`${face.index}-${face.offset}`}
|
||||
value={face.index}
|
||||
>
|
||||
{face.index + 1}. {collectionFaceLabel(face)} ·{" "}
|
||||
{face.flavor}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<p className="note">
|
||||
The selected face is reconstructed in memory for this preview;
|
||||
the source collection is never changed. Collection export and
|
||||
static subsetting are deliberately unavailable because this
|
||||
version does not promise round-trip-safe shared-table or DSIG
|
||||
preservation.
|
||||
</p>
|
||||
{inspection.collection.hasDigitalSignature && (
|
||||
<p className="block-reason">
|
||||
The TTC 2.0 header declares a DSIG block. It is bounded and
|
||||
inventoried, but its signature is not cryptographically
|
||||
validated.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
<section className="summary-grid" aria-label="Font summary">
|
||||
<Summary
|
||||
label="Identity"
|
||||
@@ -282,6 +385,39 @@ export function Workbench() {
|
||||
<span>{lineHeight.toFixed(2)}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="preview-controls shaping-controls">
|
||||
<label>
|
||||
OpenType features
|
||||
<input
|
||||
value={features}
|
||||
maxLength={256}
|
||||
onChange={(event) => setFeatures(event.target.value)}
|
||||
placeholder={'"kern" 1, "liga" 1'}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Language
|
||||
<input
|
||||
value={language}
|
||||
maxLength={35}
|
||||
onChange={(event) => setLanguage(event.target.value)}
|
||||
placeholder="en, ar, de…"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Direction
|
||||
<select
|
||||
value={direction}
|
||||
onChange={(event) =>
|
||||
setDirection(event.target.value as typeof direction)
|
||||
}
|
||||
>
|
||||
<option value="auto">Auto</option>
|
||||
<option value="ltr">Left to right</option>
|
||||
<option value="rtl">Right to left</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{inspection.axes.length > 0 && (
|
||||
<fieldset className="axes">
|
||||
<legend>Variable axes</legend>
|
||||
@@ -328,6 +464,9 @@ export function Workbench() {
|
||||
fontSize={fontSize}
|
||||
lineHeight={lineHeight}
|
||||
axes={axes}
|
||||
features={features}
|
||||
language={language}
|
||||
direction={direction}
|
||||
/>
|
||||
{[...previewText].length > 5_000 && (
|
||||
<p className="note">
|
||||
@@ -496,6 +635,27 @@ export function Workbench() {
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{inspection.layout.length > 0 && (
|
||||
<div
|
||||
className="layout-summary"
|
||||
aria-label="OpenType layout inspection"
|
||||
>
|
||||
{inspection.layout.map((item) => (
|
||||
<article key={item.table}>
|
||||
<h3>{item.table}</h3>
|
||||
<p>{item.lookupCount.toLocaleString()} lookups</p>
|
||||
<p>
|
||||
<strong>Scripts:</strong>{" "}
|
||||
{item.scripts.join(", ") || "not named"}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Features:</strong>{" "}
|
||||
{item.features.join(", ") || "not named"}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
@@ -726,15 +886,28 @@ function nameEntries(inspection: FontInspection): [string, string][] {
|
||||
}
|
||||
|
||||
function subsetBlockReason(inspection: FontInspection) {
|
||||
if (inspection.collection)
|
||||
return "TTC/OTC face selection is supported for inspection and in-memory preview only; this version does not export or subset collections safely.";
|
||||
if (!inspection.embedding.subsetAllowed)
|
||||
return `OS/2 fsType ${inspection.embedding.rawHex} declares a restriction that blocks this outline subset.`;
|
||||
if (inspection.container === "woff2")
|
||||
return "WOFF2 inspection and preview are supported, but this subset path cannot yet preserve its source metadata guarantees.";
|
||||
if (inspection.axes.length)
|
||||
return "Variable fonts are inspected and previewed, but v0.1 does not flatten their variation model.";
|
||||
return "Variable fonts are inspected and previewed, but v0.2 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 collectionFaceLabel(face: CollectionFaceSummary) {
|
||||
return (
|
||||
face.fullName ||
|
||||
[face.family, face.subfamily].filter(Boolean).join(" ") ||
|
||||
face.postScriptName ||
|
||||
`Unnamed face at byte ${face.offset.toLocaleString()}`
|
||||
);
|
||||
}
|
||||
|
||||
function safeAssetName(name: string) {
|
||||
return (
|
||||
name.replace(/[^\p{Letter}\p{Number}._-]+/gu, "-").slice(0, 120) ||
|
||||
|
||||
@@ -63,6 +63,10 @@ export function generateFontFace(options: CssOptions): CssResult {
|
||||
warnings.push(
|
||||
"Confirm the actual font licence before deploying this @font-face rule.",
|
||||
);
|
||||
if (options.inspection.collection)
|
||||
warnings.push(
|
||||
'This rule references the original collection with format("collection"). CSS cannot portably identify the currently inspected face by index; verify family/style matching or deploy an authorized standalone face prepared by suitable font tooling.',
|
||||
);
|
||||
return { css: lines.join("\n"), warnings };
|
||||
}
|
||||
|
||||
|
||||
+423
-39
@@ -1,30 +1,62 @@
|
||||
/// <reference lib="webworker" />
|
||||
import { create as createFontkit, type Font as FontkitFont } from "fontkit";
|
||||
import * as opentype from "opentype.js";
|
||||
import type {
|
||||
CoverageBlock,
|
||||
CoverageInspection,
|
||||
CoverageRange,
|
||||
DirectoryInspection,
|
||||
FontCollectionInfo,
|
||||
FontInspection,
|
||||
FontLoadResult,
|
||||
LayoutTableInspection,
|
||||
SubsetResult,
|
||||
VariableAxis,
|
||||
WorkerRequest,
|
||||
WorkerResponse,
|
||||
} from "./model";
|
||||
import { decodeEmbedding, FONT_LIMITS, inspectDirectory } from "./sfnt";
|
||||
import {
|
||||
decodeEmbedding,
|
||||
extractCollectionFace,
|
||||
FONT_LIMITS,
|
||||
inspectCollection,
|
||||
inspectDirectory,
|
||||
} from "./sfnt";
|
||||
|
||||
const worker = self as DedicatedWorkerGlobalScope;
|
||||
let font: opentype.Font | null = null,
|
||||
inspection: FontInspection | null = null;
|
||||
browserFont: FontkitFont | null = null,
|
||||
inspection: FontInspection | null = null,
|
||||
collectionBuffer: ArrayBuffer | null = null,
|
||||
collectionFonts: FontkitFont[] | null = null,
|
||||
collectionDirectory: ReturnType<typeof inspectCollection> | null = null,
|
||||
collectionInfo: Omit<FontCollectionInfo, "selectedFace"> | null = null,
|
||||
collectionFileName = "",
|
||||
collectionFileSize = 0;
|
||||
|
||||
worker.addEventListener("message", (event: MessageEvent<WorkerRequest>) => {
|
||||
const request = event.data;
|
||||
void handle(event.data);
|
||||
});
|
||||
|
||||
async function handle(request: WorkerRequest) {
|
||||
try {
|
||||
if (request.command === "load") {
|
||||
const result = load(request.buffer, request.fileName, request.fileSize);
|
||||
respond(request.id, result);
|
||||
const result = await load(
|
||||
request.buffer,
|
||||
request.fileName,
|
||||
request.fileSize,
|
||||
);
|
||||
respond(
|
||||
request.id,
|
||||
result,
|
||||
result.preview ? [result.preview.buffer] : [],
|
||||
);
|
||||
} else if (request.command === "select-face") {
|
||||
const result = selectCollectionFace(request.faceIndex);
|
||||
respond(request.id, result, [result.preview!.buffer]);
|
||||
} else if (request.command === "coverage") {
|
||||
respond(request.id, inspectTextCoverage(request.text));
|
||||
} else {
|
||||
} else if (request.command === "subset") {
|
||||
const result = subset(
|
||||
request.text,
|
||||
request.familyName,
|
||||
@@ -40,22 +72,29 @@ worker.addEventListener("message", (event: MessageEvent<WorkerRequest>) => {
|
||||
};
|
||||
worker.postMessage(response);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function respond(id: number, result: unknown, transfer: Transferable[] = []) {
|
||||
const response: WorkerResponse = { id, ok: true, result };
|
||||
worker.postMessage(response, transfer);
|
||||
}
|
||||
|
||||
function load(
|
||||
async function load(
|
||||
buffer: ArrayBuffer,
|
||||
fileName: string,
|
||||
fileSize: number,
|
||||
): FontInspection {
|
||||
const directory = inspectDirectory(buffer);
|
||||
): Promise<FontLoadResult> {
|
||||
if (fileSize !== buffer.byteLength)
|
||||
throw new Error("Transferred font size does not match the selected file.");
|
||||
const parsed = opentype.parse(buffer);
|
||||
resetCollection();
|
||||
const signature =
|
||||
buffer.byteLength >= 4 ? new DataView(buffer).getUint32(0, false) : 0;
|
||||
if (signature === 0x7474_6366)
|
||||
return loadCollection(buffer, fileName, fileSize);
|
||||
if (signature === 0x774f4632)
|
||||
return { inspection: loadWoff2(buffer, fileName, fileSize) };
|
||||
const directory = inspectDirectory(buffer),
|
||||
parsed = opentype.parse(buffer);
|
||||
if (parsed.glyphs.length > FONT_LIMITS.glyphs)
|
||||
throw new Error(
|
||||
`Font exceeds the ${FONT_LIMITS.glyphs.toLocaleString()}-glyph limit.`,
|
||||
@@ -66,32 +105,12 @@ function load(
|
||||
os2 = object(tables.os2),
|
||||
rawFsType = finiteInteger(os2.fsType),
|
||||
axes = readAxes(tables.fvar),
|
||||
layout = [
|
||||
readLayoutTable(tables.gsub, "GSUB"),
|
||||
readLayoutTable(tables.gpos, "GPOS"),
|
||||
].filter((value): value is LayoutTableInspection => value !== null),
|
||||
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.",
|
||||
);
|
||||
warnings = directoryWarnings(directory.tables, directory.warnings);
|
||||
const next: FontInspection = {
|
||||
fileName,
|
||||
fileSize,
|
||||
@@ -107,16 +126,252 @@ function load(
|
||||
coverageRangesTruncated: ranges.truncated,
|
||||
coverageBlocks: coverageBlocks(unicode),
|
||||
axes,
|
||||
layout,
|
||||
embedding: decodeEmbedding(rawFsType),
|
||||
tables: directory.tables,
|
||||
expandedSize: directory.expandedSize,
|
||||
warnings,
|
||||
};
|
||||
font = parsed;
|
||||
browserFont = null;
|
||||
inspection = next;
|
||||
return { inspection: next };
|
||||
}
|
||||
|
||||
function loadCollection(
|
||||
buffer: ArrayBuffer,
|
||||
fileName: string,
|
||||
fileSize: number,
|
||||
): FontLoadResult {
|
||||
const directory = inspectCollection(buffer),
|
||||
created = createFontkit(new Uint8Array(buffer) as never);
|
||||
if (!("fonts" in created))
|
||||
throw new Error("The validated TTC header did not decode as a collection.");
|
||||
const fonts = created.fonts;
|
||||
if (fonts.length !== directory.faces.length)
|
||||
throw new Error(
|
||||
"The collection decoder disagrees with the validated TTC face count.",
|
||||
);
|
||||
const faces = fonts.map((parsed, index) => {
|
||||
const names = readFontkitNames(parsed),
|
||||
source = directory.faces[index]!;
|
||||
return {
|
||||
index,
|
||||
offset: source.offset,
|
||||
flavor: source.directory.flavor,
|
||||
family: names.family ?? "",
|
||||
subfamily: names.subfamily ?? "",
|
||||
fullName: names.fullName ?? "",
|
||||
postScriptName: names.postScriptName ?? "",
|
||||
};
|
||||
});
|
||||
collectionBuffer = buffer;
|
||||
collectionFonts = fonts;
|
||||
collectionDirectory = directory;
|
||||
collectionInfo = {
|
||||
version: directory.version,
|
||||
hasDigitalSignature: directory.hasDigitalSignature,
|
||||
faces,
|
||||
};
|
||||
collectionFileName = fileName;
|
||||
collectionFileSize = fileSize;
|
||||
return selectCollectionFace(0);
|
||||
}
|
||||
|
||||
function selectCollectionFace(faceIndex: number): FontLoadResult {
|
||||
if (
|
||||
!collectionBuffer ||
|
||||
!collectionFonts ||
|
||||
!collectionDirectory ||
|
||||
!collectionInfo
|
||||
)
|
||||
throw new Error("Load a TrueType/OpenType Collection first.");
|
||||
if (
|
||||
!Number.isInteger(faceIndex) ||
|
||||
faceIndex < 0 ||
|
||||
faceIndex >= collectionFonts.length
|
||||
)
|
||||
throw new Error(
|
||||
`Collection face must be between 1 and ${collectionFonts.length}.`,
|
||||
);
|
||||
const parsed = collectionFonts[faceIndex]!,
|
||||
face = collectionDirectory.faces[faceIndex]!,
|
||||
previewBuffer = extractCollectionFace(collectionBuffer, faceIndex),
|
||||
next = inspectFontkitFont(
|
||||
parsed,
|
||||
face.directory,
|
||||
collectionFileName,
|
||||
collectionFileSize,
|
||||
"collection",
|
||||
`TTC/OTC face ${faceIndex + 1} · ${face.directory.flavor}`,
|
||||
[
|
||||
...collectionDirectory.warnings,
|
||||
`Selected face ${faceIndex + 1} of ${collectionFonts.length} from TTC ${collectionDirectory.version}.`,
|
||||
"The selected face is reconstructed only as a bounded in-memory preview font. Collection export and static subsetting remain disabled.",
|
||||
],
|
||||
);
|
||||
next.collection = { ...collectionInfo, selectedFace: faceIndex };
|
||||
font = null;
|
||||
browserFont = parsed;
|
||||
inspection = next;
|
||||
return {
|
||||
inspection: next,
|
||||
preview: {
|
||||
buffer: previewBuffer,
|
||||
mediaType:
|
||||
face.directory.container === "opentype-cff" ? "font/otf" : "font/ttf",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function loadWoff2(
|
||||
buffer: ArrayBuffer,
|
||||
fileName: string,
|
||||
fileSize: number,
|
||||
): FontInspection {
|
||||
const directory = inspectDirectory(buffer),
|
||||
created = createFontkit(new Uint8Array(buffer) as never);
|
||||
if ("fonts" in created)
|
||||
throw new Error("WOFF2 font collections are not supported.");
|
||||
const next = inspectFontkitFont(
|
||||
created,
|
||||
directory,
|
||||
fileName,
|
||||
fileSize,
|
||||
"woff2",
|
||||
directory.flavor,
|
||||
[
|
||||
"WOFF2 tables are decoded locally by Fontkit's browser build; static subset export remains disabled for this container.",
|
||||
],
|
||||
);
|
||||
font = null;
|
||||
browserFont = created;
|
||||
inspection = next;
|
||||
return next;
|
||||
}
|
||||
|
||||
function inspectFontkitFont(
|
||||
parsed: FontkitFont,
|
||||
directory: DirectoryInspection,
|
||||
fileName: string,
|
||||
fileSize: number,
|
||||
container: FontInspection["container"],
|
||||
flavor: string,
|
||||
extraWarnings: readonly string[],
|
||||
): FontInspection {
|
||||
if (parsed.numGlyphs > FONT_LIMITS.glyphs)
|
||||
throw new Error(
|
||||
`Font exceeds the ${FONT_LIMITS.glyphs.toLocaleString()}-glyph limit.`,
|
||||
);
|
||||
if (parsed.characterSet.length > FONT_LIMITS.unicodeMappings)
|
||||
throw new Error(
|
||||
`Font exceeds the ${FONT_LIMITS.unicodeMappings.toLocaleString()} Unicode-mapping limit.`,
|
||||
);
|
||||
const unicode = new Set(
|
||||
parsed.characterSet.filter(
|
||||
(code) => Number.isInteger(code) && code >= 0 && code <= 0x10ffff,
|
||||
),
|
||||
),
|
||||
ranges = compressRanges([...unicode].sort((a, b) => a - b)),
|
||||
axes = readFontkitAxes(parsed),
|
||||
warnings = directoryWarnings(directory.tables, [
|
||||
...directory.warnings,
|
||||
...extraWarnings,
|
||||
]);
|
||||
// Force a lookup before accepting the face so lazy table-decoding failures
|
||||
// remain inside this disposable operation.
|
||||
if (unicode.size > 0)
|
||||
parsed.glyphForCodePoint(unicode.values().next().value!);
|
||||
return {
|
||||
fileName,
|
||||
fileSize,
|
||||
container,
|
||||
flavor,
|
||||
names: readFontkitNames(parsed),
|
||||
unitsPerEm: parsed.unitsPerEm,
|
||||
ascender: parsed.ascent,
|
||||
descender: parsed.descent,
|
||||
glyphCount: parsed.numGlyphs,
|
||||
unicodeCount: unicode.size,
|
||||
coverageRanges: ranges.ranges,
|
||||
coverageRangesTruncated: ranges.truncated,
|
||||
coverageBlocks: coverageBlocks(unicode),
|
||||
axes,
|
||||
layout: readFontkitLayout(
|
||||
parsed,
|
||||
directory.tables.map((item) => item.tag),
|
||||
),
|
||||
embedding: decodeEmbedding(readFontkitFsType(parsed)),
|
||||
tables: directory.tables,
|
||||
expandedSize: directory.expandedSize,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
function resetCollection() {
|
||||
collectionBuffer = null;
|
||||
collectionFonts = null;
|
||||
collectionDirectory = null;
|
||||
collectionInfo = null;
|
||||
collectionFileName = "";
|
||||
collectionFileSize = 0;
|
||||
}
|
||||
|
||||
function directoryWarnings(
|
||||
tables: readonly { tag: string }[],
|
||||
initial: readonly string[],
|
||||
): string[] {
|
||||
const warnings = [...initial];
|
||||
if (!tables.some((table) => table.tag === "cmap"))
|
||||
warnings.push("No cmap table is present; Unicode coverage may be empty.");
|
||||
if (!tables.some((table) => table.tag === "name"))
|
||||
warnings.push("No name table is present; identity fields may be empty.");
|
||||
if (tables.some((table) => table.tag === "DSIG"))
|
||||
warnings.push(
|
||||
"A DSIG table is present; this inventory does not validate signatures.",
|
||||
);
|
||||
if (
|
||||
tables.some((table) =>
|
||||
["COLR", "CPAL", "SVG ", "sbix", "CBDT", "CBLC"].includes(table.tag),
|
||||
)
|
||||
)
|
||||
warnings.push(
|
||||
"Colour glyph tables are present; browser preview support varies.",
|
||||
);
|
||||
if (!tables.some((table) => ["glyf", "CFF ", "CFF2"].includes(table.tag)))
|
||||
warnings.push(
|
||||
"No supported outline table was identified; static subsetting is unavailable.",
|
||||
);
|
||||
return warnings;
|
||||
}
|
||||
|
||||
function readLayoutTable(
|
||||
value: unknown,
|
||||
table: LayoutTableInspection["table"],
|
||||
): LayoutTableInspection | null {
|
||||
const source = object(value);
|
||||
if (!Object.keys(source).length) return null;
|
||||
const tags = (input: unknown) =>
|
||||
[
|
||||
...new Set(
|
||||
(Array.isArray(input) ? input : [])
|
||||
.slice(0, 2_048)
|
||||
.map((item) => object(item).tag)
|
||||
.filter((tag): tag is string => typeof tag === "string")
|
||||
.map((tag) => tag.slice(0, 16)),
|
||||
),
|
||||
].sort();
|
||||
return {
|
||||
table,
|
||||
scripts: tags(source.scripts),
|
||||
features: tags(source.features),
|
||||
lookupCount: Math.min(
|
||||
65_535,
|
||||
Array.isArray(source.lookups) ? source.lookups.length : 0,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function collectUnicode(parsed: opentype.Font) {
|
||||
const values = new Set<number>();
|
||||
let mappings = 0;
|
||||
@@ -220,6 +475,114 @@ function readAxes(value: unknown): VariableAxis[] {
|
||||
});
|
||||
}
|
||||
|
||||
function readFontkitAxes(parsed: FontkitFont): VariableAxis[] {
|
||||
const entries = Object.entries(parsed.variationAxes ?? {});
|
||||
if (entries.length > 32)
|
||||
throw new Error("Font exceeds the 32-variable-axis limit.");
|
||||
return entries.flatMap(([tag, value]) => {
|
||||
if (!value) return [];
|
||||
const min = finite(value.min),
|
||||
defaultValue = finite(value.default),
|
||||
max = finite(value.max);
|
||||
if (
|
||||
min === null ||
|
||||
defaultValue === null ||
|
||||
max === null ||
|
||||
min > defaultValue ||
|
||||
defaultValue > max
|
||||
)
|
||||
return [];
|
||||
return [
|
||||
{
|
||||
tag: tag.slice(0, 4),
|
||||
name: String(value.name || tag).slice(0, 200),
|
||||
min,
|
||||
default: defaultValue,
|
||||
max,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
function readFontkitNames(parsed: FontkitFont): Record<string, string> {
|
||||
const fields: Array<[string, string, string | null | undefined]> = [
|
||||
["family", "fontFamily", parsed.familyName],
|
||||
["subfamily", "fontSubfamily", parsed.subfamilyName],
|
||||
["fullName", "fullName", parsed.fullName],
|
||||
["postScriptName", "postscriptName", parsed.postscriptName],
|
||||
["version", "version", parsed.getName("version", "en")],
|
||||
["manufacturer", "manufacturer", parsed.getName("manufacturer", "en")],
|
||||
["designer", "designer", parsed.getName("designer", "en")],
|
||||
["description", "description", parsed.getName("description", "en")],
|
||||
["copyright", "copyright", parsed.copyright],
|
||||
["trademark", "trademark", parsed.getName("trademark", "en")],
|
||||
["license", "license", parsed.getName("license", "en")],
|
||||
["licenseUrl", "licenseURL", parsed.getName("licenseURL", "en")],
|
||||
];
|
||||
return Object.fromEntries(
|
||||
fields.flatMap(([output, input, fallback]) => {
|
||||
const value = parsed.getName(input, "en") ?? fallback;
|
||||
return typeof value === "string" && value.trim()
|
||||
? [[output, value.slice(0, 4_000)]]
|
||||
: [];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function readFontkitFsType(parsed: FontkitFont): number | null {
|
||||
const fsType = object(object(parsed["OS/2"]).fsType);
|
||||
if (Object.keys(fsType).length === 0) return null;
|
||||
let raw = 0;
|
||||
if (fsType.noEmbedding === true) raw |= 0x0002;
|
||||
if (fsType.viewOnly === true) raw |= 0x0004;
|
||||
if (fsType.editable === true) raw |= 0x0008;
|
||||
if (fsType.noSubsetting === true) raw |= 0x0100;
|
||||
if (fsType.bitmapOnly === true) raw |= 0x0200;
|
||||
return raw;
|
||||
}
|
||||
|
||||
function readFontkitLayout(
|
||||
parsed: FontkitFont,
|
||||
tableTags: readonly string[],
|
||||
): LayoutTableInspection[] {
|
||||
const source = parsed as unknown as Record<string, unknown>,
|
||||
available = [...new Set(parsed.availableFeatures ?? [])]
|
||||
.filter((tag): tag is string => typeof tag === "string")
|
||||
.slice(0, 2_048)
|
||||
.map((tag) => tag.slice(0, 16))
|
||||
.sort();
|
||||
return (["GSUB", "GPOS"] as const).flatMap((table) => {
|
||||
if (!tableTags.includes(table)) return [];
|
||||
const decoded = object(source[table]),
|
||||
scripts = tagsFromRecords(decoded.scriptList),
|
||||
features = tagsFromRecords(decoded.featureList),
|
||||
lookups = decoded.lookupList;
|
||||
return [
|
||||
{
|
||||
table,
|
||||
scripts,
|
||||
features: features.length ? features : available,
|
||||
lookupCount: Math.min(
|
||||
65_535,
|
||||
Array.isArray(lookups) ? lookups.length : 0,
|
||||
),
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
function tagsFromRecords(value: unknown): string[] {
|
||||
return [
|
||||
...new Set(
|
||||
(Array.isArray(value) ? value : [])
|
||||
.slice(0, 2_048)
|
||||
.map((item) => object(item).tag)
|
||||
.filter((tag): tag is string => typeof tag === "string")
|
||||
.map((tag) => tag.slice(0, 16)),
|
||||
),
|
||||
].sort();
|
||||
}
|
||||
|
||||
function readNames(names: Record<string, unknown>) {
|
||||
const fields: [string, string][] = [
|
||||
["family", "fontFamily"],
|
||||
@@ -267,13 +630,26 @@ function localized(value: unknown): string {
|
||||
}
|
||||
|
||||
function inspectTextCoverage(text: string): CoverageInspection {
|
||||
const current = requireFont();
|
||||
if (!font && !browserFont) throw new Error("Load a font first.");
|
||||
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)!,
|
||||
const codePoint = character.codePointAt(0)!;
|
||||
if (browserFont) {
|
||||
const glyph = browserFont.glyphForCodePoint(codePoint),
|
||||
covered = browserFont.hasGlyphForCodePoint(codePoint);
|
||||
return {
|
||||
character,
|
||||
codePoint,
|
||||
glyphIndex: glyph.id,
|
||||
glyphName:
|
||||
glyph.name || (glyph.id === 0 ? ".notdef" : `glyph ${glyph.id}`),
|
||||
covered,
|
||||
};
|
||||
}
|
||||
const current = font!,
|
||||
glyphIndex = current.charToGlyphIndex(character),
|
||||
glyph = current.glyphs.get(glyphIndex);
|
||||
return {
|
||||
@@ -310,7 +686,7 @@ function subset(
|
||||
);
|
||||
if (report.axes.length)
|
||||
throw new Error(
|
||||
"Variable fonts are not flattened or subset in v0.1; use a licensed static instance.",
|
||||
"Variable fonts are not flattened or subset in v0.2; use a licensed static instance.",
|
||||
);
|
||||
if (!report.tables.some((table) => ["glyf", "CFF "].includes(table.tag)))
|
||||
throw new Error(
|
||||
@@ -419,6 +795,14 @@ function safeFileName(value: string) {
|
||||
}
|
||||
|
||||
function requireFont() {
|
||||
if (inspection?.collection)
|
||||
throw new Error(
|
||||
"Static subset export from a TTC/OTC collection is disabled. Face selection reconstructs a bounded font only for in-memory preview and does not claim a round-trip-safe collection export.",
|
||||
);
|
||||
if (browserFont)
|
||||
throw new Error(
|
||||
"Static subset export from WOFF2 is disabled because this path cannot yet preserve the source's licence metadata guarantees. Convert an authorized source to TTF/OTF/WOFF first.",
|
||||
);
|
||||
if (!font) throw new Error("Load a font first.");
|
||||
return font;
|
||||
}
|
||||
|
||||
+14
-5
@@ -1,6 +1,6 @@
|
||||
import type {
|
||||
CoverageInspection,
|
||||
FontInspection,
|
||||
FontLoadResult,
|
||||
SubsetResult,
|
||||
WorkerRequest,
|
||||
WorkerResponse,
|
||||
@@ -37,8 +37,13 @@ export class FontWorkerClient {
|
||||
else pending.reject(new Error(response.error));
|
||||
},
|
||||
);
|
||||
this.worker.addEventListener("error", () => {
|
||||
this.failAll(new Error("The isolated font parser stopped unexpectedly."));
|
||||
this.worker.addEventListener("error", (event) => {
|
||||
const detail = event.message?.trim();
|
||||
this.failAll(
|
||||
new Error(
|
||||
`The isolated font parser stopped unexpectedly${detail ? `: ${detail}` : "."}`,
|
||||
),
|
||||
);
|
||||
});
|
||||
this.worker.addEventListener("messageerror", () => {
|
||||
this.failAll(
|
||||
@@ -47,16 +52,20 @@ export class FontWorkerClient {
|
||||
});
|
||||
}
|
||||
|
||||
async load(file: File): Promise<FontInspection> {
|
||||
async load(file: File): Promise<FontLoadResult> {
|
||||
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>(
|
||||
return this.request<FontLoadResult>(
|
||||
{ command: "load", fileName: file.name, fileSize: file.size, buffer },
|
||||
[buffer],
|
||||
);
|
||||
}
|
||||
|
||||
selectFace(faceIndex: number) {
|
||||
return this.request<FontLoadResult>({ command: "select-face", faceIndex });
|
||||
}
|
||||
|
||||
coverage(text: string) {
|
||||
return this.request<CoverageInspection>({ command: "coverage", text });
|
||||
}
|
||||
|
||||
+50
-1
@@ -1,4 +1,5 @@
|
||||
export type FontContainer = "truetype" | "opentype-cff" | "woff";
|
||||
export type FontContainer =
|
||||
"truetype" | "opentype-cff" | "woff" | "woff2" | "collection";
|
||||
|
||||
export interface TableRecord {
|
||||
tag: string;
|
||||
@@ -19,6 +20,36 @@ export interface DirectoryInspection {
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface CollectionDirectoryFace {
|
||||
index: number;
|
||||
offset: number;
|
||||
directory: DirectoryInspection;
|
||||
}
|
||||
|
||||
export interface CollectionDirectoryInspection {
|
||||
version: "1.0" | "2.0";
|
||||
faces: CollectionDirectoryFace[];
|
||||
hasDigitalSignature: boolean;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface CollectionFaceSummary {
|
||||
index: number;
|
||||
offset: number;
|
||||
flavor: string;
|
||||
family: string;
|
||||
subfamily: string;
|
||||
fullName: string;
|
||||
postScriptName: string;
|
||||
}
|
||||
|
||||
export interface FontCollectionInfo {
|
||||
version: "1.0" | "2.0";
|
||||
selectedFace: number;
|
||||
hasDigitalSignature: boolean;
|
||||
faces: CollectionFaceSummary[];
|
||||
}
|
||||
|
||||
export interface VariableAxis {
|
||||
tag: string;
|
||||
name: string;
|
||||
@@ -27,6 +58,13 @@ export interface VariableAxis {
|
||||
max: number;
|
||||
}
|
||||
|
||||
export interface LayoutTableInspection {
|
||||
table: "GSUB" | "GPOS";
|
||||
scripts: string[];
|
||||
features: string[];
|
||||
lookupCount: number;
|
||||
}
|
||||
|
||||
export interface CoverageRange {
|
||||
start: number;
|
||||
end: number;
|
||||
@@ -66,10 +104,20 @@ export interface FontInspection {
|
||||
coverageRangesTruncated: boolean;
|
||||
coverageBlocks: CoverageBlock[];
|
||||
axes: VariableAxis[];
|
||||
layout: LayoutTableInspection[];
|
||||
embedding: EmbeddingInfo;
|
||||
tables: TableRecord[];
|
||||
expandedSize: number;
|
||||
warnings: string[];
|
||||
collection?: FontCollectionInfo;
|
||||
}
|
||||
|
||||
export interface FontLoadResult {
|
||||
inspection: FontInspection;
|
||||
preview?: {
|
||||
buffer: ArrayBuffer;
|
||||
mediaType: "font/ttf" | "font/otf";
|
||||
};
|
||||
}
|
||||
|
||||
export interface GlyphCoverage {
|
||||
@@ -104,6 +152,7 @@ export type WorkerRequest =
|
||||
fileSize: number;
|
||||
buffer: ArrayBuffer;
|
||||
}
|
||||
| { id: number; command: "select-face"; faceIndex: number }
|
||||
| { id: number; command: "coverage"; text: string }
|
||||
| {
|
||||
id: number;
|
||||
|
||||
+32
-7
@@ -4,29 +4,54 @@ export function previewDocument(
|
||||
fontSize: number,
|
||||
lineHeight: number,
|
||||
axes: Record<string, number>,
|
||||
options: {
|
||||
features?: string;
|
||||
language?: string;
|
||||
direction?: "auto" | "ltr" | "rtl";
|
||||
} = {},
|
||||
) {
|
||||
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]) =>
|
||||
`"${escapeHtml(tag)}" ${Number(value.toFixed(4))}`,
|
||||
/^[A-Za-z0-9 ]{1,4}$/u.test(tag) && Number.isFinite(value),
|
||||
)
|
||||
.map(([tag, value]) => `"${tag}" ${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('"', '\\"');
|
||||
const features = featureSettings(options.features ?? ""),
|
||||
language = /^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/u.test(
|
||||
options.language ?? "",
|
||||
)
|
||||
? options.language
|
||||
: "und",
|
||||
direction = ["ltr", "rtl"].includes(options.direction ?? "")
|
||||
? options.direction
|
||||
: "auto";
|
||||
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"}}
|
||||
.sample{overflow-wrap:anywhere;font-size:${size}px;line-height:${leading};font-variation-settings:${variation || "normal"};font-feature-settings:${features || "normal"}}
|
||||
.features-off{font-feature-settings:"kern" 0,"liga" 0,"clig" 0,"calt" 0}
|
||||
.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>`;
|
||||
</style></head><body lang="${language}" dir="${direction}"><div class="row"><span class="label">Loaded font · selected shaping</span><div class="sample loaded">${safeText}</div></div><div class="row"><span class="label">Loaded font · common shaping disabled</span><div class="sample loaded features-off">${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 featureSettings(value: string) {
|
||||
if (!value.trim()) return "";
|
||||
const entries = value.split(",").map((entry) => entry.trim());
|
||||
if (
|
||||
entries.length > 32 ||
|
||||
entries.some(
|
||||
(entry) => !/^"[A-Za-z0-9 ]{1,4}"\s+(?:0|1|on|off)$/u.test(entry),
|
||||
)
|
||||
)
|
||||
return "";
|
||||
return entries.join(", ");
|
||||
}
|
||||
|
||||
function escapeHtml(value: string) {
|
||||
|
||||
+451
-16
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
CollectionDirectoryInspection,
|
||||
DirectoryInspection,
|
||||
EmbeddingInfo,
|
||||
FontContainer,
|
||||
@@ -10,6 +11,7 @@ export const FONT_LIMITS = {
|
||||
expandedBytes: 64 * 1024 * 1024,
|
||||
tableBytes: 32 * 1024 * 1024,
|
||||
tables: 128,
|
||||
collectionFaces: 64,
|
||||
glyphs: 65_535,
|
||||
unicodeMappings: 250_000,
|
||||
previewCharacters: 20_000,
|
||||
@@ -58,19 +60,87 @@ const TABLE_DESCRIPTIONS: Record<string, string> = {
|
||||
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.");
|
||||
const WOFF2_KNOWN_TAGS = [
|
||||
"cmap",
|
||||
"head",
|
||||
"hhea",
|
||||
"hmtx",
|
||||
"maxp",
|
||||
"name",
|
||||
"OS/2",
|
||||
"post",
|
||||
"cvt ",
|
||||
"fpgm",
|
||||
"glyf",
|
||||
"loca",
|
||||
"prep",
|
||||
"CFF ",
|
||||
"VORG",
|
||||
"EBDT",
|
||||
"EBLC",
|
||||
"gasp",
|
||||
"hdmx",
|
||||
"kern",
|
||||
"LTSH",
|
||||
"PCLT",
|
||||
"VDMX",
|
||||
"vhea",
|
||||
"vmtx",
|
||||
"BASE",
|
||||
"GDEF",
|
||||
"GPOS",
|
||||
"GSUB",
|
||||
"EBSC",
|
||||
"JSTF",
|
||||
"MATH",
|
||||
"CBDT",
|
||||
"CBLC",
|
||||
"COLR",
|
||||
"CPAL",
|
||||
"SVG ",
|
||||
"sbix",
|
||||
"acnt",
|
||||
"avar",
|
||||
"bdat",
|
||||
"bloc",
|
||||
"bsln",
|
||||
"cvar",
|
||||
"fdsc",
|
||||
"feat",
|
||||
"fmtx",
|
||||
"fvar",
|
||||
"gvar",
|
||||
"hsty",
|
||||
"just",
|
||||
"lcar",
|
||||
"mort",
|
||||
"morx",
|
||||
"opbd",
|
||||
"prop",
|
||||
"trak",
|
||||
"Zapf",
|
||||
"Silf",
|
||||
"Glat",
|
||||
"Gloc",
|
||||
"Feat",
|
||||
"Sill",
|
||||
] as const;
|
||||
|
||||
export function inspectDirectory(
|
||||
buffer: ArrayBuffer,
|
||||
maximumBytes = FONT_LIMITS.fileBytes,
|
||||
): DirectoryInspection {
|
||||
if (buffer.byteLength > maximumBytes)
|
||||
throw new Error(
|
||||
`Font exceeds the ${(maximumBytes / 1024 / 1024).toLocaleString()} MiB inspection 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 === "wOF2") return inspectWoff2Directory(buffer);
|
||||
if (signature === "ttcf")
|
||||
throw new Error(
|
||||
"TrueType Collections (TTC/OTC) are not supported in v0.1.",
|
||||
"This is a TrueType/OpenType Collection; select and inspect one of its validated faces instead.",
|
||||
);
|
||||
if (signature === "wOFF") return inspectWoff(view);
|
||||
if (
|
||||
@@ -81,18 +151,371 @@ export function inspectDirectory(buffer: ArrayBuffer): DirectoryInspection {
|
||||
)
|
||||
return inspectSfnt(view, signature);
|
||||
throw new Error(
|
||||
`Unsupported font signature ${displayTag(signature)}; expected TTF, OTF or WOFF.`,
|
||||
`Unsupported font signature ${displayTag(signature)}; expected TTF, OTF, WOFF or WOFF2.`,
|
||||
);
|
||||
}
|
||||
|
||||
function inspectSfnt(view: DataView, signature: string): DirectoryInspection {
|
||||
requireBytes(view, 0, 12, "SFNT header");
|
||||
const count = view.getUint16(4, false);
|
||||
export function inspectCollection(
|
||||
buffer: ArrayBuffer,
|
||||
maximumBytes = FONT_LIMITS.fileBytes,
|
||||
): CollectionDirectoryInspection {
|
||||
if (buffer.byteLength > maximumBytes)
|
||||
throw new Error(
|
||||
`Font collection exceeds the ${(maximumBytes / 1024 / 1024).toLocaleString()} MiB inspection limit.`,
|
||||
);
|
||||
const view = new DataView(buffer);
|
||||
requireBytes(view, 0, 12, "TTC header");
|
||||
if (tagAt(view, 0) !== "ttcf")
|
||||
throw new Error("Not a TrueType/OpenType Collection.");
|
||||
const rawVersion = view.getUint32(4, false),
|
||||
version =
|
||||
rawVersion === 0x0001_0000
|
||||
? "1.0"
|
||||
: rawVersion === 0x0002_0000
|
||||
? "2.0"
|
||||
: null;
|
||||
if (!version)
|
||||
throw new Error(
|
||||
`Unsupported TTC header version 0x${rawVersion.toString(16).padStart(8, "0")}; expected 1.0 or 2.0.`,
|
||||
);
|
||||
const count = view.getUint32(8, false);
|
||||
if (count < 1) throw new Error("The font collection declares no faces.");
|
||||
if (count > FONT_LIMITS.collectionFaces)
|
||||
throw new Error(
|
||||
`The font collection exceeds the ${FONT_LIMITS.collectionFaces}-face limit.`,
|
||||
);
|
||||
const offsetsBytes = checkedMultiply(count, 4, "TTC face-offset array"),
|
||||
baseHeaderSize = checkedAdd(12, offsetsBytes, "TTC header"),
|
||||
headerSize =
|
||||
version === "2.0"
|
||||
? checkedAdd(baseHeaderSize, 12, "TTC 2.0 header")
|
||||
: baseHeaderSize;
|
||||
requireBytes(view, 0, headerSize, "TTC header and face offsets");
|
||||
|
||||
let hasDigitalSignature = false;
|
||||
const warnings: string[] = [];
|
||||
if (version === "2.0") {
|
||||
const signatureTag = view.getUint32(baseHeaderSize, false),
|
||||
signatureLength = view.getUint32(baseHeaderSize + 4, false),
|
||||
signatureOffset = view.getUint32(baseHeaderSize + 8, false);
|
||||
if (signatureTag || signatureLength || signatureOffset) {
|
||||
if (signatureTag !== 0x4453_4947)
|
||||
throw new Error("TTC 2.0 has an invalid digital-signature tag.");
|
||||
if (!signatureLength || !signatureOffset)
|
||||
throw new Error(
|
||||
"TTC 2.0 has an incomplete digital-signature boundary.",
|
||||
);
|
||||
requireBytes(
|
||||
view,
|
||||
signatureOffset,
|
||||
signatureLength,
|
||||
"TTC digital signature",
|
||||
);
|
||||
hasDigitalSignature = true;
|
||||
warnings.push(
|
||||
"The collection declares a DSIG block; its presence is inventoried but its signature is not validated.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const offsets = new Set<number>(),
|
||||
faces = [];
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const offset = view.getUint32(12 + index * 4, false);
|
||||
if (offset < headerSize)
|
||||
throw new Error(
|
||||
`TTC face ${index + 1} starts inside the collection header.`,
|
||||
);
|
||||
if (offset % 4 !== 0)
|
||||
throw new Error(`TTC face ${index + 1} offset is not four-byte aligned.`);
|
||||
if (offsets.has(offset))
|
||||
throw new Error(
|
||||
`TTC faces contain a duplicate directory offset ${offset}.`,
|
||||
);
|
||||
offsets.add(offset);
|
||||
const signature = tagAt(view, offset);
|
||||
if (
|
||||
signature !== "OTTO" &&
|
||||
signature !== "true" &&
|
||||
signature !== "typ1" &&
|
||||
view.getUint32(offset, false) !== 0x0001_0000
|
||||
)
|
||||
throw new Error(
|
||||
`TTC face ${index + 1} has unsupported SFNT signature ${displayTag(signature)}.`,
|
||||
);
|
||||
faces.push({
|
||||
index,
|
||||
offset,
|
||||
directory: inspectSfnt(view, signature, offset),
|
||||
});
|
||||
}
|
||||
return { version, faces, hasDigitalSignature, warnings };
|
||||
}
|
||||
|
||||
export function extractCollectionFace(
|
||||
buffer: ArrayBuffer,
|
||||
faceIndex: number,
|
||||
): ArrayBuffer {
|
||||
const collection = inspectCollection(buffer),
|
||||
face = collection.faces[faceIndex];
|
||||
if (!face || !Number.isInteger(faceIndex))
|
||||
throw new Error(
|
||||
`Collection face index must be between 0 and ${collection.faces.length - 1}.`,
|
||||
);
|
||||
const directoryBytes = checkedAdd(
|
||||
12,
|
||||
checkedMultiply(face.directory.tables.length, 16, "SFNT table directory"),
|
||||
"SFNT header",
|
||||
);
|
||||
let outputSize = directoryBytes;
|
||||
for (const table of face.directory.tables)
|
||||
outputSize = checkedAdd(
|
||||
outputSize,
|
||||
align4(table.length),
|
||||
"extracted collection face",
|
||||
);
|
||||
if (outputSize > FONT_LIMITS.expandedBytes)
|
||||
throw new Error(
|
||||
"The selected collection face exceeds the 64 MiB reconstructed-face limit.",
|
||||
);
|
||||
const source = new Uint8Array(buffer),
|
||||
output = new Uint8Array(outputSize),
|
||||
target = new DataView(output.buffer);
|
||||
output.set(source.subarray(face.offset, face.offset + 12), 0);
|
||||
let cursor = directoryBytes;
|
||||
const outputOffsets = new Map<string, number>();
|
||||
face.directory.tables.forEach((table, index) => {
|
||||
const base = 12 + index * 16;
|
||||
for (let offset = 0; offset < 4; offset += 1)
|
||||
target.setUint8(base + offset, table.tag.charCodeAt(offset));
|
||||
target.setUint32(
|
||||
base + 4,
|
||||
Number.parseInt(table.checksum.slice(2), 16),
|
||||
false,
|
||||
);
|
||||
target.setUint32(base + 8, cursor, false);
|
||||
target.setUint32(base + 12, table.length, false);
|
||||
output.set(
|
||||
source.subarray(table.offset, table.offset + table.length),
|
||||
cursor,
|
||||
);
|
||||
outputOffsets.set(table.tag, cursor);
|
||||
cursor += align4(table.length);
|
||||
});
|
||||
const head = face.directory.tables.find((table) => table.tag === "head"),
|
||||
headOffset = outputOffsets.get("head");
|
||||
if (head && headOffset !== undefined && head.length >= 12) {
|
||||
target.setUint32(headOffset + 8, 0, false);
|
||||
target.setUint32(
|
||||
headOffset + 8,
|
||||
(0xb1b0_afba - sfntChecksum(target)) >>> 0,
|
||||
false,
|
||||
);
|
||||
}
|
||||
return output.buffer;
|
||||
}
|
||||
|
||||
export function inspectWoff2Header(buffer: ArrayBuffer) {
|
||||
if (buffer.byteLength > FONT_LIMITS.fileBytes)
|
||||
throw new Error("Font exceeds the 16 MiB input limit.");
|
||||
const view = new DataView(buffer);
|
||||
requireBytes(view, 0, 48, "WOFF2 header");
|
||||
if (tagAt(view, 0) !== "wOF2") throw new Error("Not a WOFF2 font.");
|
||||
const declaredSize = view.getUint32(8, false),
|
||||
count = view.getUint16(12, false),
|
||||
reserved = view.getUint16(14, false),
|
||||
expandedSize = view.getUint32(16, false),
|
||||
compressedSize = view.getUint32(20, false);
|
||||
validateTableCount(count);
|
||||
requireBytes(view, 12, count * 16, "SFNT table directory");
|
||||
if (reserved !== 0)
|
||||
throw new Error("WOFF2 reserved header field is non-zero.");
|
||||
if (declaredSize !== buffer.byteLength)
|
||||
throw new Error("WOFF2 declared length differs from the file length.");
|
||||
if (expandedSize > FONT_LIMITS.expandedBytes)
|
||||
throw new Error("WOFF2 expanded size exceeds the 64 MiB safety limit.");
|
||||
if (expandedSize < 12)
|
||||
throw new Error("WOFF2 expanded SFNT size is too small.");
|
||||
if (compressedSize === 0 || compressedSize > buffer.byteLength - 48)
|
||||
throw new Error("WOFF2 compressed payload has invalid bounds.");
|
||||
return { declaredSize, expandedSize, count };
|
||||
}
|
||||
|
||||
export function inspectWoff2Directory(
|
||||
buffer: ArrayBuffer,
|
||||
): DirectoryInspection {
|
||||
const header = inspectWoff2Header(buffer),
|
||||
view = new DataView(buffer),
|
||||
flavor = tagAt(view, 4);
|
||||
if (flavor === "ttcf")
|
||||
throw new Error("WOFF2 font collections are not supported.");
|
||||
let cursor = 48,
|
||||
expandedTableBytes = 0,
|
||||
transformedBytes = 0;
|
||||
const tables: TableRecord[] = [],
|
||||
seen = new Set<string>();
|
||||
for (let index = 0; index < header.count; index += 1) {
|
||||
requireBytes(view, cursor, 1, "WOFF2 table flags");
|
||||
const flags = view.getUint8(cursor++),
|
||||
tagIndex = flags & 0x3f,
|
||||
transformVersion = flags >>> 6;
|
||||
let tag: string;
|
||||
if (tagIndex === 0x3f) {
|
||||
requireBytes(view, cursor, 4, "WOFF2 custom table tag");
|
||||
tag = tagAt(view, cursor);
|
||||
cursor += 4;
|
||||
} else tag = WOFF2_KNOWN_TAGS[tagIndex] ?? "";
|
||||
if (!/^[\x20-\x7e]{4}$/u.test(tag))
|
||||
throw new Error("WOFF2 contains an invalid table tag.");
|
||||
if (seen.has(tag))
|
||||
throw new Error(`WOFF2 contains duplicate ${displayTag(tag)} tables.`);
|
||||
seen.add(tag);
|
||||
const original = readUIntBase128(view, cursor);
|
||||
cursor = original.next;
|
||||
if (original.value > FONT_LIMITS.tableBytes)
|
||||
throw new Error(
|
||||
`Table ${displayTag(tag)} exceeds the 32 MiB expanded-table limit.`,
|
||||
);
|
||||
const isGlyfOrLoca = tag === "glyf" || tag === "loca";
|
||||
let transformed: boolean;
|
||||
if (isGlyfOrLoca) {
|
||||
if (transformVersion !== 0 && transformVersion !== 3)
|
||||
throw new Error(`WOFF2 ${tag} uses an invalid transform version.`);
|
||||
transformed = transformVersion === 0;
|
||||
} else if (tag === "hmtx") {
|
||||
if (transformVersion !== 0 && transformVersion !== 1)
|
||||
throw new Error("WOFF2 hmtx uses an invalid transform version.");
|
||||
transformed = transformVersion === 1;
|
||||
} else {
|
||||
if (transformVersion !== 0)
|
||||
throw new Error(
|
||||
`WOFF2 ${displayTag(tag)} uses a reserved transform version.`,
|
||||
);
|
||||
transformed = false;
|
||||
}
|
||||
let streamLength = original.value;
|
||||
if (transformed) {
|
||||
const transformedLength = readUIntBase128(view, cursor);
|
||||
cursor = transformedLength.next;
|
||||
streamLength = transformedLength.value;
|
||||
if (streamLength > FONT_LIMITS.tableBytes)
|
||||
throw new Error(
|
||||
`Transformed ${displayTag(tag)} table exceeds the 32 MiB limit.`,
|
||||
);
|
||||
}
|
||||
tables.push({
|
||||
tag,
|
||||
description: TABLE_DESCRIPTIONS[tag] ?? "OpenType table",
|
||||
offset: transformedBytes,
|
||||
length: original.value,
|
||||
storedLength: streamLength,
|
||||
checksum: "WOFF2 stream",
|
||||
compressed: true,
|
||||
});
|
||||
expandedTableBytes = checkedAdd(
|
||||
expandedTableBytes,
|
||||
align4(original.value),
|
||||
"WOFF2 expanded table sizes",
|
||||
);
|
||||
transformedBytes = checkedAdd(
|
||||
transformedBytes,
|
||||
streamLength,
|
||||
"WOFF2 transformed table sizes",
|
||||
);
|
||||
}
|
||||
const expectedSfntSize = checkedAdd(
|
||||
12 + header.count * 16,
|
||||
expandedTableBytes,
|
||||
"WOFF2 expanded SFNT size",
|
||||
);
|
||||
if (expectedSfntSize !== header.expandedSize)
|
||||
throw new Error(
|
||||
"WOFF2 reconstructed SFNT size disagrees with its table directory.",
|
||||
);
|
||||
const compressedSize = view.getUint32(20, false);
|
||||
requireBytes(view, cursor, compressedSize, "WOFF2 compressed table stream");
|
||||
if (transformedBytes > FONT_LIMITS.expandedBytes)
|
||||
throw new Error("WOFF2 transformed table stream exceeds the 64 MiB limit.");
|
||||
validateOptionalBlock(
|
||||
view,
|
||||
view.getUint32(28, false),
|
||||
view.getUint32(32, false),
|
||||
"WOFF2 metadata",
|
||||
);
|
||||
validateOptionalBlock(
|
||||
view,
|
||||
view.getUint32(40, false),
|
||||
view.getUint32(44, false),
|
||||
"WOFF2 private data",
|
||||
);
|
||||
return {
|
||||
container: "woff2",
|
||||
flavor:
|
||||
flavor === "OTTO"
|
||||
? "WOFF2 with CFF outlines"
|
||||
: "WOFF2 with TrueType outlines",
|
||||
declaredSize: header.declaredSize,
|
||||
expandedSize: header.expandedSize,
|
||||
tables,
|
||||
warnings: [
|
||||
"WOFF2 stores one Brotli-compressed table stream; displayed stored lengths are pre-Brotli transform lengths and checksums are not present in the container.",
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function readUIntBase128(
|
||||
view: DataView,
|
||||
start: number,
|
||||
): { value: number; next: number } {
|
||||
let value = 0,
|
||||
cursor = start;
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
requireBytes(view, cursor, 1, "WOFF2 UIntBase128");
|
||||
const byte = view.getUint8(cursor++);
|
||||
if (index === 0 && byte === 0x80)
|
||||
throw new Error("WOFF2 UIntBase128 has a leading zero continuation.");
|
||||
if (value & 0xfe00_0000)
|
||||
throw new Error("WOFF2 UIntBase128 overflows 32 bits.");
|
||||
value = value * 128 + (byte & 0x7f);
|
||||
if ((byte & 0x80) === 0) return { value, next: cursor };
|
||||
}
|
||||
throw new Error("WOFF2 UIntBase128 is longer than five bytes.");
|
||||
}
|
||||
|
||||
function align4(value: number): number {
|
||||
return (value + 3) & ~3;
|
||||
}
|
||||
|
||||
function checkedAdd(left: number, right: number, label: string): number {
|
||||
const result = left + right;
|
||||
if (!Number.isSafeInteger(result)) throw new Error(`${label} overflow.`);
|
||||
return result;
|
||||
}
|
||||
|
||||
function checkedMultiply(left: number, right: number, label: string): number {
|
||||
const result = left * right;
|
||||
if (!Number.isSafeInteger(result)) throw new Error(`${label} overflow.`);
|
||||
return result;
|
||||
}
|
||||
|
||||
function sfntChecksum(view: DataView) {
|
||||
let sum = 0;
|
||||
for (let offset = 0; offset < view.byteLength; offset += 4)
|
||||
sum = (sum + view.getUint32(offset, false)) >>> 0;
|
||||
return sum;
|
||||
}
|
||||
|
||||
function inspectSfnt(
|
||||
view: DataView,
|
||||
signature: string,
|
||||
baseOffset = 0,
|
||||
): DirectoryInspection {
|
||||
requireBytes(view, baseOffset, 12, "SFNT header");
|
||||
const count = view.getUint16(baseOffset + 4, false);
|
||||
validateTableCount(count);
|
||||
requireBytes(view, baseOffset + 12, count * 16, "SFNT table directory");
|
||||
const tables: TableRecord[] = [];
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const base = 12 + index * 16,
|
||||
const base = baseOffset + 12 + index * 16,
|
||||
tag = tagAt(view, base),
|
||||
checksum = view.getUint32(base + 4, false),
|
||||
offset = view.getUint32(base + 8, false),
|
||||
@@ -101,11 +524,20 @@ function inspectSfnt(view: DataView, signature: string): DirectoryInspection {
|
||||
tables.push(record(tag, offset, length, length, checksum));
|
||||
}
|
||||
const warnings = directoryWarnings(tables);
|
||||
let reconstructedSize = 12 + count * 16;
|
||||
for (const table of tables)
|
||||
reconstructedSize = checkedAdd(
|
||||
reconstructedSize,
|
||||
align4(table.length),
|
||||
"reconstructed SFNT size",
|
||||
);
|
||||
if (reconstructedSize > FONT_LIMITS.expandedBytes)
|
||||
throw new Error("SFNT face exceeds the 64 MiB reconstructed-size limit.");
|
||||
return {
|
||||
container: signature === "OTTO" ? "opentype-cff" : "truetype",
|
||||
flavor: signature === "OTTO" ? "OpenType/CFF" : "TrueType outlines",
|
||||
declaredSize: view.byteLength,
|
||||
expandedSize: view.byteLength,
|
||||
declaredSize: baseOffset === 0 ? view.byteLength : reconstructedSize,
|
||||
expandedSize: baseOffset === 0 ? view.byteLength : reconstructedSize,
|
||||
tables,
|
||||
warnings,
|
||||
};
|
||||
@@ -337,7 +769,10 @@ export function decodeEmbedding(raw: number | null): EmbeddingInfo {
|
||||
|
||||
export function extensionFormat(fileName: string, container?: FontContainer) {
|
||||
const extension = fileName.split(".").at(-1)?.toLowerCase();
|
||||
if (extension === "ttc" || extension === "otc" || container === "collection")
|
||||
return "collection";
|
||||
if (extension === "woff" || container === "woff") return "woff";
|
||||
if (extension === "woff2" || container === "woff2") return "woff2";
|
||||
if (extension === "ttf" || container === "truetype") return "truetype";
|
||||
if (extension === "otf" || container === "opentype-cff") return "opentype";
|
||||
return "opentype";
|
||||
|
||||
@@ -227,6 +227,20 @@ summary:focus-visible {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.collection-panel {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
border-left: 4px solid var(--accent);
|
||||
}
|
||||
|
||||
.collection-panel .section-heading {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.collection-panel select {
|
||||
width: min(100%, 52rem);
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
@@ -296,6 +310,10 @@ textarea {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.shaping-controls label {
|
||||
grid-template-columns: auto minmax(8rem, 18rem);
|
||||
}
|
||||
|
||||
input[type="range"] {
|
||||
padding: 0;
|
||||
accent-color: var(--accent);
|
||||
@@ -495,6 +513,29 @@ summary {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.layout-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
|
||||
gap: 0.7rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.layout-summary article {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.7rem;
|
||||
background: Canvas;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.layout-summary h3,
|
||||
.layout-summary p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.layout-summary p + p {
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
.table-scroll {
|
||||
max-height: 35rem;
|
||||
overflow: auto;
|
||||
|
||||
@@ -3,12 +3,22 @@
|
||||
"schemaVersion": 1,
|
||||
"id": "de.add-ideas.font-tools",
|
||||
"name": "Font Tools",
|
||||
"version": "0.1.0",
|
||||
"description": "Inspect and prepare fonts locally.",
|
||||
"version": "0.2.0",
|
||||
"description": "Inspect, compare and prepare fonts locally.",
|
||||
"entry": "./",
|
||||
"icon": "./favicon.svg",
|
||||
"categories": ["design", "developer"],
|
||||
"tags": ["font", "ttf", "otf", "woff", "glyph", "typography", "subset"],
|
||||
"tags": [
|
||||
"font",
|
||||
"ttf",
|
||||
"otf",
|
||||
"ttc",
|
||||
"otc",
|
||||
"woff",
|
||||
"glyph",
|
||||
"typography",
|
||||
"subset"
|
||||
],
|
||||
"integration": {
|
||||
"contextVersion": 1,
|
||||
"launchModes": ["navigate", "new-tab"],
|
||||
@@ -21,6 +31,52 @@
|
||||
"crossOriginIsolated": false,
|
||||
"topLevelContext": false
|
||||
},
|
||||
"io": {
|
||||
"accepts": [
|
||||
{
|
||||
"mediaType": "font/ttf",
|
||||
"extensions": [".ttf"]
|
||||
},
|
||||
{
|
||||
"mediaType": "font/otf",
|
||||
"extensions": [".otf"]
|
||||
},
|
||||
{
|
||||
"mediaType": "font/collection",
|
||||
"extensions": [".ttc", ".otc"]
|
||||
},
|
||||
{
|
||||
"mediaType": "font/woff",
|
||||
"extensions": [".woff"]
|
||||
},
|
||||
{
|
||||
"mediaType": "font/woff2",
|
||||
"extensions": [".woff2"]
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
{
|
||||
"mediaType": "text/css",
|
||||
"extensions": [".css"]
|
||||
},
|
||||
{
|
||||
"mediaType": "application/json",
|
||||
"extensions": [".json"]
|
||||
},
|
||||
{
|
||||
"mediaType": "font/ttf",
|
||||
"extensions": [".ttf"]
|
||||
},
|
||||
{
|
||||
"mediaType": "font/otf",
|
||||
"extensions": [".otf"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"capabilities": {
|
||||
"required": ["workers"],
|
||||
"optional": []
|
||||
},
|
||||
"privacy": {
|
||||
"processing": "local",
|
||||
"fileUploads": true,
|
||||
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
declare module "wawoff2/compress.js" {
|
||||
export default function compress(input: Uint8Array): Promise<Uint8Array>;
|
||||
}
|
||||
+1
-1
@@ -1 +1 @@
|
||||
export const APP_VERSION = "0.1.0";
|
||||
export const APP_VERSION = "0.2.0";
|
||||
|
||||
Reference in New Issue
Block a user