Release Font Tools v0.1.0
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { Workbench } from "../../src/components/Workbench";
|
||||
|
||||
vi.mock("../../src/core/fontClient", () => ({
|
||||
FontWorkerClient: class FontWorkerClient {},
|
||||
}));
|
||||
|
||||
describe("Font Workbench", () => {
|
||||
it("presents an actionable local-only empty state", () => {
|
||||
render(<Workbench />);
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Know what is inside a font." }),
|
||||
).toBeVisible();
|
||||
expect(screen.getByLabelText("Open font")).toHaveAttribute(
|
||||
"accept",
|
||||
expect.stringContaining(".woff2"),
|
||||
);
|
||||
expect(screen.getByText(/Disposable worker/u)).toBeVisible();
|
||||
expect(screen.getByRole("status")).toHaveTextContent("Choose a local");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { generateFontFace, validateLocalPath } from "../../src/core/css";
|
||||
import type { FontInspection } from "../../src/core/model";
|
||||
|
||||
const inspection: FontInspection = {
|
||||
fileName: "Variable.woff",
|
||||
fileSize: 1_000,
|
||||
container: "woff",
|
||||
flavor: "WOFF",
|
||||
names: { family: "Fixture", subfamily: "Regular" },
|
||||
unitsPerEm: 1_000,
|
||||
ascender: 800,
|
||||
descender: -200,
|
||||
glyphCount: 3,
|
||||
unicodeCount: 3,
|
||||
coverageRanges: [{ start: 0x41, end: 0x43 }],
|
||||
coverageRangesTruncated: false,
|
||||
coverageBlocks: [],
|
||||
axes: [{ tag: "wght", name: "Weight", min: 200, default: 400, max: 900 }],
|
||||
embedding: {
|
||||
raw: 0,
|
||||
rawHex: "0x0000",
|
||||
level: "installable",
|
||||
label: "Installable embedding",
|
||||
noSubsetting: false,
|
||||
bitmapOnly: false,
|
||||
subsetAllowed: true,
|
||||
signals: [],
|
||||
},
|
||||
tables: [],
|
||||
expandedSize: 2_000,
|
||||
warnings: [],
|
||||
};
|
||||
|
||||
describe("safe CSS generation", () => {
|
||||
it("quotes metadata, emits axes and a complete compact range", () => {
|
||||
const result = generateFontFace({
|
||||
family: 'Fixture "Local"',
|
||||
sourcePath: "./fonts/fixture.woff",
|
||||
display: "swap",
|
||||
inspection,
|
||||
includeUnicodeRange: true,
|
||||
});
|
||||
expect(result.css).toContain('font-family: "Fixture \\"Local\\""');
|
||||
expect(result.css).toContain("font-weight: 200 900");
|
||||
expect(result.css).toContain("unicode-range: U+0041-0043");
|
||||
});
|
||||
|
||||
it("rejects remote and control-bearing paths", () => {
|
||||
expect(() => validateLocalPath("https://example.test/font.woff")).toThrow(
|
||||
/local asset paths/u,
|
||||
);
|
||||
expect(() => validateLocalPath("./font\n.woff")).toThrow(/control/u);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { previewDocument } from "../../src/core/preview";
|
||||
|
||||
describe("sandbox preview document", () => {
|
||||
it("escapes text and allows no script capability", () => {
|
||||
const html = previewDocument(
|
||||
"blob:http://localhost/fixture",
|
||||
'<script>alert("x")</script>',
|
||||
42,
|
||||
1.2,
|
||||
{ wght: 550 },
|
||||
);
|
||||
expect(html).toContain("<script>");
|
||||
expect(html).not.toContain('<script>alert("x")</script>');
|
||||
expect(html).toContain("default-src 'none'");
|
||||
expect(html).toContain(""wght" 550");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { decodeEmbedding, inspectDirectory } from "../../src/core/sfnt";
|
||||
|
||||
describe("SFNT directory validation", () => {
|
||||
it("inventories bounded TrueType tables", () => {
|
||||
const report = inspectDirectory(sfnt(["head", "cmap", "OS/2"]));
|
||||
expect(report.container).toBe("truetype");
|
||||
expect(report.tables.map((table) => table.tag)).toEqual([
|
||||
"head",
|
||||
"cmap",
|
||||
"OS/2",
|
||||
]);
|
||||
expect(report.tables[2]?.description).toContain("embedding");
|
||||
});
|
||||
|
||||
it("rejects unsupported and out-of-bounds containers before library parsing", () => {
|
||||
expect(() => inspectDirectory(signature("wOF2"))).toThrow(/WOFF2/u);
|
||||
const invalid = sfnt(["head"]),
|
||||
view = new DataView(invalid);
|
||||
view.setUint32(12 + 8, invalid.byteLength - 2, false);
|
||||
view.setUint32(12 + 12, 12, false);
|
||||
expect(() => inspectDirectory(invalid)).toThrow(/file boundary/u);
|
||||
});
|
||||
});
|
||||
|
||||
describe("embedding flags", () => {
|
||||
it("decodes and enforces subsetting restrictions", () => {
|
||||
expect(decodeEmbedding(0).subsetAllowed).toBe(true);
|
||||
expect(decodeEmbedding(0x0002).level).toBe("restricted");
|
||||
expect(decodeEmbedding(0x0100)).toMatchObject({
|
||||
noSubsetting: true,
|
||||
subsetAllowed: false,
|
||||
});
|
||||
expect(decodeEmbedding(0x0208)).toMatchObject({
|
||||
level: "editable",
|
||||
bitmapOnly: true,
|
||||
subsetAllowed: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function signature(value: string) {
|
||||
const bytes = new Uint8Array(4);
|
||||
[...value].forEach(
|
||||
(character, index) => (bytes[index] = character.charCodeAt(0)),
|
||||
);
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
function sfnt(tags: string[]) {
|
||||
const directoryBytes = 12 + tags.length * 16,
|
||||
tableBytes = 12,
|
||||
buffer = new ArrayBuffer(directoryBytes + tags.length * tableBytes),
|
||||
view = new DataView(buffer);
|
||||
view.setUint32(0, 0x0001_0000, false);
|
||||
view.setUint16(4, tags.length, false);
|
||||
tags.forEach((tag, index) => {
|
||||
const base = 12 + index * 16;
|
||||
[...tag].forEach((character, offset) =>
|
||||
view.setUint8(base + offset, character.charCodeAt(0)),
|
||||
);
|
||||
view.setUint32(base + 4, index + 1, false);
|
||||
view.setUint32(base + 8, directoryBytes + index * tableBytes, false);
|
||||
view.setUint32(base + 12, tableBytes, false);
|
||||
});
|
||||
return buffer;
|
||||
}
|
||||
Reference in New Issue
Block a user