@@ -1,6 +1,7 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import opentype from "opentype.js";
|
||||
import compressWoff2 from "wawoff2/compress.js";
|
||||
|
||||
const ORIGIN = "http://127.0.0.1:4202";
|
||||
const { Font, Glyph, Path } = opentype;
|
||||
@@ -82,6 +83,65 @@ test("enforces the no-subsetting fsType bit", async ({ page }) => {
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("decompresses and inspects a local WOFF2 font", async ({ page }) => {
|
||||
await page.goto("/font/");
|
||||
const compressed = await compressWoff2(fixtureFont(0));
|
||||
await page.locator('input[type="file"]').setInputFiles({
|
||||
name: "fixture.woff2",
|
||||
mimeType: "font/woff2",
|
||||
buffer: Buffer.from(compressed),
|
||||
});
|
||||
await expect(page.locator('p[role="status"]')).toContainText("Inspected");
|
||||
await expect(page.getByText("woff2", { exact: false }).first()).toBeVisible();
|
||||
await expect(
|
||||
page.getByText("Fixture Sans", { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("validates a real TTC container and switches its selected face", async ({
|
||||
page,
|
||||
}) => {
|
||||
const external = await blockExternal(page);
|
||||
await page.goto("/font/");
|
||||
await page.locator('input[type="file"]').setInputFiles({
|
||||
name: "fixture.ttc",
|
||||
mimeType: "font/collection",
|
||||
buffer: fixtureCollection([
|
||||
fixtureFont(0, "Fixture Sans"),
|
||||
fixtureFont(0, "Fixture Serif"),
|
||||
]),
|
||||
});
|
||||
await expect(page.locator('p[role="status"]')).toContainText(
|
||||
"collection face 1 of 2",
|
||||
);
|
||||
await expect(page.getByLabel("Selected collection face")).toHaveValue("0");
|
||||
await expect(
|
||||
page.getByText("Fixture Sans", { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await page.getByLabel("Selected collection face").selectOption("1");
|
||||
await expect(page.locator('p[role="status"]')).toContainText(
|
||||
"Selected collection face 2 of 2",
|
||||
);
|
||||
await expect(
|
||||
page.getByText("Fixture Serif", { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Build & download .otf subset" }),
|
||||
).toBeDisabled();
|
||||
await expect(
|
||||
page.getByText(/does not export or subset collections/u),
|
||||
).toBeVisible();
|
||||
await expect(page.getByLabel("Generated CSS")).toContainText(
|
||||
'format("collection")',
|
||||
);
|
||||
await expect(page.getByText(/cannot portably identify/u)).toBeVisible();
|
||||
await expect(page.locator("iframe.font-preview-frame")).toHaveAttribute(
|
||||
"srcdoc",
|
||||
/blob:/u,
|
||||
);
|
||||
expect(external).toEqual([]);
|
||||
});
|
||||
|
||||
test("shell, CSP, manifest and nested offline PWA", async ({
|
||||
page,
|
||||
context,
|
||||
@@ -116,7 +176,7 @@ test("shell, CSP, manifest and nested offline PWA", async ({
|
||||
const manifest = await request.get("/font/toolbox-app.json");
|
||||
await expect(manifest.json()).resolves.toMatchObject({
|
||||
id: "de.add-ideas.font-tools",
|
||||
version: "0.1.0",
|
||||
version: "0.2.0",
|
||||
requirements: { workers: true },
|
||||
});
|
||||
});
|
||||
@@ -133,7 +193,7 @@ async function blockExternal(page: Page) {
|
||||
return requests;
|
||||
}
|
||||
|
||||
function fixtureFont(fsType: number) {
|
||||
function fixtureFont(fsType: number, familyName = "Fixture Sans") {
|
||||
const notdef = new Glyph({
|
||||
name: ".notdef",
|
||||
advanceWidth: 600,
|
||||
@@ -158,7 +218,7 @@ function fixtureFont(fsType: number) {
|
||||
path: rectanglePath(60, 0, 530, 700),
|
||||
}),
|
||||
font = new Font({
|
||||
familyName: "Fixture Sans",
|
||||
familyName,
|
||||
styleName: "Regular",
|
||||
unitsPerEm: 1_000,
|
||||
ascender: 800,
|
||||
@@ -172,6 +232,37 @@ function fixtureFont(fsType: number) {
|
||||
return Buffer.from(bytes);
|
||||
}
|
||||
|
||||
function fixtureCollection(fonts: Buffer[]) {
|
||||
const headerSize = 12 + fonts.length * 4,
|
||||
offsets: number[] = [];
|
||||
let size = align4(headerSize);
|
||||
for (const font of fonts) {
|
||||
offsets.push(size);
|
||||
size += align4(font.byteLength);
|
||||
}
|
||||
const output = Buffer.alloc(size);
|
||||
output.write("ttcf", 0, "ascii");
|
||||
output.writeUInt32BE(0x0001_0000, 4);
|
||||
output.writeUInt32BE(fonts.length, 8);
|
||||
fonts.forEach((font, index) => {
|
||||
const faceOffset = offsets[index]!;
|
||||
output.writeUInt32BE(faceOffset, 12 + index * 4);
|
||||
font.copy(output, faceOffset);
|
||||
const tableCount = font.readUInt16BE(4);
|
||||
for (let tableIndex = 0; tableIndex < tableCount; tableIndex += 1) {
|
||||
const sourceRecord = 12 + tableIndex * 16,
|
||||
targetRecord = faceOffset + sourceRecord,
|
||||
sourceOffset = font.readUInt32BE(sourceRecord + 8);
|
||||
output.writeUInt32BE(faceOffset + sourceOffset, targetRecord + 8);
|
||||
}
|
||||
});
|
||||
return output;
|
||||
}
|
||||
|
||||
function align4(value: number) {
|
||||
return (value + 3) & ~3;
|
||||
}
|
||||
|
||||
function patchFsType(bytes: Uint8Array, fsType: number) {
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength),
|
||||
count = view.getUint16(4, false);
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("keeps the primary workspace inside a narrow viewport", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/font/");
|
||||
await expect(page.locator("main").first()).toBeVisible();
|
||||
await expect(
|
||||
page.locator("main .loading, main .workbench-loading"),
|
||||
).toHaveCount(0);
|
||||
|
||||
const widths = await page.evaluate(() => ({
|
||||
content: document.documentElement.scrollWidth,
|
||||
viewport: document.documentElement.clientWidth,
|
||||
}));
|
||||
expect(widths.viewport).toBeLessThanOrEqual(430);
|
||||
expect(widths.content).toBeLessThanOrEqual(widths.viewport + 1);
|
||||
});
|
||||
@@ -16,6 +16,10 @@ describe("Font Workbench", () => {
|
||||
"accept",
|
||||
expect.stringContaining(".woff2"),
|
||||
);
|
||||
expect(screen.getByLabelText("Open font")).toHaveAttribute(
|
||||
"accept",
|
||||
expect.stringContaining(".ttc"),
|
||||
);
|
||||
expect(screen.getByText(/Disposable worker/u)).toBeVisible();
|
||||
expect(screen.getByRole("status")).toHaveTextContent("Choose a local");
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(""wght" 550");
|
||||
expect(html).toContain('"wght" 550');
|
||||
expect(html).toContain('font-feature-settings:"liga" 1');
|
||||
expect(html).toContain('lang="ar" dir="rtl"');
|
||||
});
|
||||
});
|
||||
|
||||
+151
-1
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user