Release Font Tools 0.2.0
Verify / verify (push) Canceled after 0s

This commit is contained in:
2026-09-02 12:18:37 +02:00
parent 25ff321f2a
commit 873cf3c04b
39 changed files with 2431 additions and 181 deletions
+25
View File
@@ -17,6 +17,7 @@ const inspection: FontInspection = {
coverageRangesTruncated: false,
coverageBlocks: [],
axes: [{ tag: "wght", name: "Weight", min: 200, default: 400, max: 900 }],
layout: [],
embedding: {
raw: 0,
rawHex: "0x0000",
@@ -52,4 +53,28 @@ describe("safe CSS generation", () => {
);
expect(() => validateLocalPath("./font\n.woff")).toThrow(/control/u);
});
it("labels collection CSS as non-portable face selection", () => {
const result = generateFontFace({
family: "Fixture collection face",
sourcePath: "./fonts/fixture.ttc",
display: "swap",
inspection: {
...inspection,
fileName: "fixture.ttc",
container: "collection",
collection: {
version: "1.0",
selectedFace: 0,
hasDigitalSignature: false,
faces: [],
},
},
includeUnicodeRange: false,
});
expect(result.css).toContain('format("collection")');
expect(result.warnings).toContainEqual(
expect.stringMatching(/cannot portably identify/u),
);
});
});
+4 -1
View File
@@ -9,10 +9,13 @@ describe("sandbox preview document", () => {
42,
1.2,
{ wght: 550 },
{ features: '"liga" 1', language: "ar", direction: "rtl" },
);
expect(html).toContain("<script>");
expect(html).not.toContain('<script>alert("x")</script>');
expect(html).toContain("default-src 'none'");
expect(html).toContain("&quot;wght&quot; 550");
expect(html).toContain('"wght" 550');
expect(html).toContain('font-feature-settings:"liga" 1');
expect(html).toContain('lang="ar" dir="rtl"');
});
});
+151 -1
View File
@@ -1,5 +1,12 @@
import { describe, expect, it } from "vitest";
import { decodeEmbedding, inspectDirectory } from "../../src/core/sfnt";
import {
decodeEmbedding,
extractCollectionFace,
inspectCollection,
inspectDirectory,
inspectWoff2Directory,
inspectWoff2Header,
} from "../../src/core/sfnt";
describe("SFNT directory validation", () => {
it("inventories bounded TrueType tables", () => {
@@ -21,6 +28,93 @@ describe("SFNT directory validation", () => {
view.setUint32(12 + 12, 12, false);
expect(() => inspectDirectory(invalid)).toThrow(/file boundary/u);
});
it("validates WOFF2 container sizes before decompression", () => {
const buffer = new ArrayBuffer(52),
bytes = new Uint8Array(buffer),
view = new DataView(buffer);
bytes.set(new TextEncoder().encode("wOF2"), 0);
bytes.set(new TextEncoder().encode("\u0000\u0001\u0000\u0000"), 4);
view.setUint32(8, buffer.byteLength, false);
view.setUint16(12, 1, false);
view.setUint32(16, 32, false);
view.setUint32(20, 4, false);
expect(inspectWoff2Header(buffer)).toEqual({
declaredSize: 52,
expandedSize: 32,
count: 1,
});
view.setUint32(16, 70 * 1024 * 1024, false);
expect(() => inspectWoff2Header(buffer)).toThrow(/64 MiB/u);
});
it("rejects reserved WOFF2 transform versions before decoding", () => {
expect(() => inspectWoff2Directory(woff2Table(5, 1))).toThrow(
/reserved transform version/u,
);
expect(() => inspectWoff2Directory(woff2Table(3, 2))).toThrow(
/invalid transform version/u,
);
expect(inspectWoff2Directory(woff2Table(5, 0)).tables[0]).toMatchObject({
tag: "name",
storedLength: 4,
});
expect(inspectWoff2Directory(woff2Table(3, 1)).tables[0]).toMatchObject({
tag: "hmtx",
storedLength: 4,
});
});
it("validates TTC face offsets and reconstructs a standalone selected face", () => {
const source = ttc([
sfnt(["head", "cmap"]),
sfnt(["head", "name", "CFF "]),
]),
collection = inspectCollection(source);
expect(collection).toMatchObject({
version: "1.0",
hasDigitalSignature: false,
});
expect(collection.faces.map((face) => face.directory.flavor)).toEqual([
"TrueType outlines",
"TrueType outlines",
]);
const extracted = extractCollectionFace(source, 1),
report = inspectDirectory(extracted);
expect(report.tables.map((table) => table.tag)).toEqual([
"head",
"name",
"CFF ",
]);
expect(
report.tables.every((table) => table.offset < extracted.byteLength),
).toBe(true);
});
it("rejects malformed TTC versions, counts, duplicate and unaligned offsets", () => {
const invalidVersion = ttc([sfnt(["head"])]),
invalidCount = ttc([sfnt(["head"])]),
duplicate = ttc([sfnt(["head"]), sfnt(["head"])]),
unaligned = ttc([sfnt(["head"])]);
new DataView(invalidVersion).setUint32(4, 0x0003_0000, false);
expect(() => inspectCollection(invalidVersion)).toThrow(/version/u);
new DataView(invalidCount).setUint32(8, 65, false);
expect(() => inspectCollection(invalidCount)).toThrow(/64-face/u);
const duplicateView = new DataView(duplicate);
duplicateView.setUint32(16, duplicateView.getUint32(12, false), false);
expect(() => inspectCollection(duplicate)).toThrow(/duplicate/u);
new DataView(unaligned).setUint32(12, 17, false);
expect(() => inspectCollection(unaligned)).toThrow(/aligned/u);
});
it("validates the optional TTC 2.0 DSIG boundary", () => {
const source = ttc([sfnt(["head"])], 0x0002_0000),
headerEnd = 12 + 4;
expect(inspectCollection(source).version).toBe("2.0");
const view = new DataView(source);
view.setUint32(headerEnd, 0x4453_4947, false);
expect(() => inspectCollection(source)).toThrow(/incomplete/u);
});
});
describe("embedding flags", () => {
@@ -47,6 +141,27 @@ function signature(value: string) {
return bytes.buffer;
}
function woff2Table(tagIndex: number, transformVersion: number) {
const glyfOrLoca = tagIndex === 10 || tagIndex === 11,
declaresTransform = glyfOrLoca
? transformVersion === 0
: transformVersion !== 0,
directoryBytes = declaresTransform ? 3 : 2,
buffer = new ArrayBuffer(48 + directoryBytes + 1),
bytes = new Uint8Array(buffer),
view = new DataView(buffer);
bytes.set(new TextEncoder().encode("wOF2"), 0);
view.setUint32(4, 0x0001_0000, false);
view.setUint32(8, buffer.byteLength, false);
view.setUint16(12, 1, false);
view.setUint32(16, 32, false);
view.setUint32(20, 1, false);
bytes[48] = (transformVersion << 6) | tagIndex;
bytes[49] = 4;
if (declaresTransform) bytes[50] = 4;
return buffer;
}
function sfnt(tags: string[]) {
const directoryBytes = 12 + tags.length * 16,
tableBytes = 12,
@@ -65,3 +180,38 @@ function sfnt(tags: string[]) {
});
return buffer;
}
function ttc(fonts: ArrayBuffer[], version = 0x0001_0000) {
const extra = version === 0x0002_0000 ? 12 : 0,
headerSize = 12 + fonts.length * 4 + extra,
offsets: number[] = [];
let size = align4(headerSize);
for (const font of fonts) {
offsets.push(size);
size += align4(font.byteLength);
}
const buffer = new ArrayBuffer(size),
output = new Uint8Array(buffer),
view = new DataView(buffer);
output.set(new TextEncoder().encode("ttcf"), 0);
view.setUint32(4, version, false);
view.setUint32(8, fonts.length, false);
fonts.forEach((font, index) => {
const faceOffset = offsets[index]!,
source = new Uint8Array(font),
sourceView = new DataView(font);
view.setUint32(12 + index * 4, faceOffset, false);
output.set(source, faceOffset);
const tableCount = sourceView.getUint16(4, false);
for (let tableIndex = 0; tableIndex < tableCount; tableIndex += 1) {
const recordOffset = faceOffset + 12 + tableIndex * 16,
sourceOffset = sourceView.getUint32(12 + tableIndex * 16 + 8, false);
view.setUint32(recordOffset + 8, faceOffset + sourceOffset, false);
}
});
return buffer;
}
function align4(value: number) {
return (value + 3) & ~3;
}