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
+207
View File
@@ -0,0 +1,207 @@
import { expect, test, type Page } from "@playwright/test";
import { readFile } from "node:fs/promises";
import opentype from "opentype.js";
const ORIGIN = "http://127.0.0.1:4202";
const { Font, Glyph, Path } = opentype;
test("inspects, compares, covers and subsets a local font", async ({
page,
}) => {
const external = await blockExternal(page);
await page.setViewportSize({ width: 1800, height: 1000 });
await page.goto("/font/");
await expect(page.getByRole("heading", { name: "Font Tools" })).toBeVisible();
await page.locator('input[type="file"]').setInputFiles({
name: "fixture.otf",
mimeType: "font/otf",
buffer: fixtureFont(0x0004),
});
await expect(page.locator('p[role="status"]')).toContainText("Inspected");
await expect(
page.getByText("Fixture Sans", { exact: true }).first(),
).toBeVisible();
await expect(page.locator("iframe.font-preview-frame")).toHaveAttribute(
"sandbox",
"allow-same-origin",
);
const preview = page.getByLabel("Preview text");
await preview.fill("ABZ");
await page.getByRole("button", { name: "Analyze preview text" }).click();
await expect(page.locator('p[role="status"]')).toContainText(
"2 unique characters map to glyphs; 1",
);
await expect(page.getByLabel("Generated CSS")).toContainText(
'font-family: "Fixture Sans"',
);
await page.getByLabel("Characters to retain").fill("AB");
await page.getByLabel(/I have reviewed the actual font licence/u).check();
const download = page.waitForEvent("download");
await page
.getByRole("button", { name: "Build & download .otf subset" })
.click();
const completedDownload = await download;
expect(completedDownload.suggestedFilename()).toBe("Fixture-Sans-subset.otf");
const downloadedPath = await completedDownload.path();
expect(downloadedPath).not.toBeNull();
const downloadedBytes = await readFile(downloadedPath!);
const downloadedBuffer = downloadedBytes.buffer.slice(
downloadedBytes.byteOffset,
downloadedBytes.byteOffset + downloadedBytes.byteLength,
),
subset = opentype.parse(downloadedBuffer);
expect(subset.glyphs.length).toBe(3);
expect(subset.tables.os2?.fsType).toBe(0x0004);
await expect(
page.getByRole("heading", { name: "Last subset loss report" }),
).toBeVisible();
expect(external).toEqual([]);
expect(
await page
.locator(".toolbox-shell__main")
.evaluate((element) => getComputedStyle(element).width),
).toBe("1440px");
});
test("enforces the no-subsetting fsType bit", async ({ page }) => {
await page.goto("/font/");
await page.locator('input[type="file"]').setInputFiles({
name: "restricted.otf",
mimeType: "font/otf",
buffer: fixtureFont(0x0100),
});
await expect(page.locator('p[role="status"]')).toContainText("Inspected");
await expect(
page.getByText("0x0100", { exact: false }).first(),
).toBeVisible();
await expect(
page.getByRole("button", { name: "Build & download .otf subset" }),
).toBeDisabled();
await expect(
page.getByText(/declares a restriction that blocks/u),
).toBeVisible();
});
test("shell, CSP, manifest and nested offline PWA", async ({
page,
context,
request,
}) => {
await page.goto("/font/");
await page.getByRole("button", { name: "Help" }).click();
await expect(page.getByRole("dialog")).toContainText("disposable worker");
await page.keyboard.press("Escape");
await page.getByRole("button", { name: "Personalize" }).click();
await page.getByRole("button", { name: "Dark" }).click();
await expect(page.locator(".toolbox-shell").first()).toHaveAttribute(
"data-toolbox-theme",
"dark",
);
expect(
await page.evaluate(async () =>
Boolean(await navigator.serviceWorker.ready),
),
).toBe(true);
await context.setOffline(true);
await page.reload();
await expect(page.getByRole("heading", { name: "Font Tools" })).toBeVisible();
await context.setOffline(false);
const response = await request.get("/font/");
expect(response.headers()["content-security-policy"]).toContain(
"connect-src 'self'",
);
expect(response.headers()["content-security-policy"]).toContain(
"font-src 'self' blob: data:",
);
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",
requirements: { workers: true },
});
});
async function blockExternal(page: Page) {
const requests: string[] = [];
await page.route("**/*", async (route) => {
const url = new URL(route.request().url());
if (url.protocol !== "blob:" && url.origin !== ORIGIN) {
requests.push(url.href);
await route.abort();
} else await route.continue();
});
return requests;
}
function fixtureFont(fsType: number) {
const notdef = new Glyph({
name: ".notdef",
advanceWidth: 600,
path: rectanglePath(40, 0, 520, 700),
}),
space = new Glyph({
name: "space",
unicode: 32,
advanceWidth: 300,
path: new Path(),
}),
a = new Glyph({
name: "A",
unicode: 65,
advanceWidth: 650,
path: trianglePath(),
}),
b = new Glyph({
name: "B",
unicode: 66,
advanceWidth: 650,
path: rectanglePath(60, 0, 530, 700),
}),
font = new Font({
familyName: "Fixture Sans",
styleName: "Regular",
unitsPerEm: 1_000,
ascender: 800,
descender: -200,
glyphs: [notdef, space, a, b],
license: "Fixture font for automated local tests only.",
version: "Version 1.000",
}),
bytes = new Uint8Array(font.toArrayBuffer());
patchFsType(bytes, fsType);
return Buffer.from(bytes);
}
function patchFsType(bytes: Uint8Array, fsType: number) {
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength),
count = view.getUint16(4, false);
for (let index = 0; index < count; index += 1) {
const base = 12 + index * 16,
tag = String.fromCharCode(...bytes.slice(base, base + 4));
if (tag === "OS/2") {
const offset = view.getUint32(base + 8, false);
view.setUint16(offset + 8, fsType, false);
return;
}
}
throw new Error("Generated fixture has no OS/2 table.");
}
function trianglePath() {
const path = new Path();
path.moveTo(40, 0);
path.lineTo(325, 700);
path.lineTo(610, 0);
path.close();
return path;
}
function rectanglePath(x: number, y: number, width: number, height: number) {
const path = new Path();
path.moveTo(x, y);
path.lineTo(x + width, y);
path.lineTo(x + width, y + height);
path.lineTo(x, y + height);
path.close();
return path;
}