299 lines
9.6 KiB
TypeScript
299 lines
9.6 KiB
TypeScript
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;
|
|
|
|
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("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,
|
|
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.2.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, familyName = "Fixture Sans") {
|
|
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,
|
|
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 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);
|
|
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;
|
|
}
|