Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions artifacts/api-server/scripts/derive-name-letters.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* Regenerates `lib/api-zod/src/personalNameLetters.generated.ts`: the letters
* the name rule admits, derived from the shipped caption fonts. Run after a
* caption face is added, removed or replaced:
*
* npx tsx artifacts/api-server/scripts/derive-name-letters.ts
*
* `nameLetterDerivation.test.ts` fails until the file matches the fonts.
*/
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { CAPTION_FONTS } from "@workspace/api-zod";
import { deriveNameLetters, toRanges } from "../src/lib/fontCoverage";

const here = path.dirname(fileURLToPath(import.meta.url));
const fonts = path.resolve(here, "..", "src", "assets", "fonts");
const out = path.resolve(here, "..", "..", "..", "lib", "api-zod", "src", "personalNameLetters.generated.ts");

const hex = (cp: number) => `0x${cp.toString(16).toUpperCase().padStart(4, "0")}`;
const letters = deriveNameLetters(CAPTION_FONTS.map((f) => fs.readFileSync(path.join(fonts, f.file))));
const body = toRanges(letters).map(([a, b]) => ` [${hex(a)}, ${hex(b)}],`).join("\n");

fs.writeFileSync(out, `/**
* GENERATED by artifacts/api-server/scripts/derive-name-letters.ts from the
* shipped caption fonts. Do not edit by hand: change a font, then re-run it.
*
* The letters a personal name may hold: every Latin-script letter that all
* caption faces draw, in both cases (${letters.length} letters).
*
* @purpose The name rule's admitted letters as code-point ranges, derived from the caption font files.
* @subsystem accounts-auth
*/
export const PERSONAL_NAME_LETTER_RANGES: readonly (readonly [number, number])[] = [
${body}
];
`);
console.log(`${letters.length} letters written to ${path.relative(process.cwd(), out)}`);
113 changes: 113 additions & 0 deletions artifacts/api-server/src/__tests__/captionFonts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/**
* The caption fonts (names-and-fonts plan): every face in the shared catalogue
* ships with the built server and with the web app as the same file, registers,
* and draws differently from every other face; a new request can only name a
* catalogue face; a stored recipe from before the catalogue renders byte for
* byte as it did; and a face that failed to register refuses to render rather
* than saving a meme in a fallback face.
*
* @purpose Proves each caption font ships to the server build and the web app as one identical file, registers and renders distinctly, that requests accept catalogue ids only while stored legacy font strings render exactly as before, and that an unregistered face refuses to render.
* @subsystem meme-studio
*/
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { GlobalFonts, createCanvas } from "@napi-rs/canvas";
import { CAPTION_FONTS, CAPTION_FONT_IDS } from "@workspace/api-zod";

import { captionCanvasFont, fontFileCandidates, generateMemeBuffer, withCaptionFontUnregisteredForTest, type TextOptions } from "../lib/memeGenerator.js";
import { StoredTextOptionsSchema, TextOptionsSchema } from "../lib/validators/memeBuilder.js";

const here = path.dirname(fileURLToPath(import.meta.url));
const artifactDir = path.resolve(here, "..", "..");
const serverFonts = path.join(artifactDir, "src", "assets", "fonts");
const webFonts = path.resolve(artifactDir, "..", "overhype-me", "public", "fonts", "caption");

const sha = (b: Buffer) => crypto.createHash("sha256").update(b).digest("hex");

function background(): Buffer {
const c = createCanvas(320, 320);
const g = c.getContext("2d");
g.fillStyle = "#336699";
g.fillRect(0, 0, 320, 320);
g.fillStyle = "#cc9933";
g.fillRect(40, 40, 120, 200);
return c.toBuffer("image/png");
}

const render = (opts: TextOptions) => generateMemeBuffer({ type: "image", imageData: background() }, "x", opts, "square");

describe("caption fonts", () => {
it("each face is one file, identical on the server and in the web app, under src/assets where the build copies it", () => {
for (const font of CAPTION_FONTS) {
const server = path.join(serverFonts, font.file);
assert.ok(fs.existsSync(server), `${font.file} is in src/assets/fonts`);
assert.equal(sha(fs.readFileSync(path.join(webFonts, font.file))), sha(fs.readFileSync(server)), `${font.file} matches in the web app`);
const [built] = fontFileCandidates(path.join(artifactDir, "dist"), font.file);
assert.ok(!path.relative(path.join(artifactDir, "dist", "assets"), built!).startsWith(".."), "the built path is inside dist/assets");
}
});

it("every face renders, and no two faces draw the same caption", async () => {
const seen = new Map<string, string>();
for (const font of CAPTION_FONTS) {
const hash = sha(await render({ topText: "Zoë Nguyễn counted", bottomText: "to infinity twice", fontFamily: font.id }));
assert.ok(!seen.has(hash), `${font.id} draws differently from ${seen.get(hash)}`);
seen.set(hash, font.id);
}
});

it("draws a catalogue face at its own fixed weight, whatever bold says", () => {
for (const font of CAPTION_FONTS) {
for (const bold of [true, false, undefined]) {
assert.equal(captionCanvasFont({ fontFamily: font.id, bold }, 40), `${font.weight} 40px "${font.family}", sans-serif`);
}
}
});

it("a request names a catalogue face or nothing; a stored recipe may hold an old string", () => {
for (const id of CAPTION_FONT_IDS) assert.ok(TextOptionsSchema.safeParse({ fontFamily: id }).success, id);
assert.ok(TextOptionsSchema.safeParse({}).success);
for (const bad of ["Impact", "'Bebas Neue', Impact, sans-serif", "OH Block", "Comic Sans MS"]) {
assert.equal(TextOptionsSchema.safeParse({ fontFamily: bad }).success, false, `${bad} is refused on a request`);
assert.ok(StoredTextOptionsSchema.safeParse({ fontFamily: bad }).success, `${bad} is kept on a stored recipe`);
}
});

// A stored recipe keeps its face: the renderer gives it the exact canvas
// font string the code before the catalogue built (memeGenerator.ts at main
// 61a2d8b3: `${italic}${bold}${size}px "${fontFamily ?? "Impact"}", sans-serif`,
// bold by default), Impact is still Anton, and the old picker's stacks still
// draw in Anton, so no new caption face can capture them. Machine-independent
// by design: hard-coded image hashes from one machine failed on CI's.
it("a stored recipe with an old font string, or none, renders as it always has", async () => {
for (const fontFamily of [undefined, "Impact", "'Bebas Neue', Impact, sans-serif", "Anton"]) {
for (const bold of [true, false, undefined]) {
for (const italic of [true, false]) {
const before = `${italic ? "italic " : ""}${(bold ?? true) ? "bold " : ""}40px "${fontFamily ?? "Impact"}", sans-serif`;
assert.equal(captionCanvasFont({ fontFamily, bold, italic }, 40), before, JSON.stringify({ fontFamily, bold, italic }));
}
}
}
const text = { topText: "Zoë counted", bottomText: "to infinity twice" };
assert.equal(sha(await render(text)), sha(await render({ ...text, fontFamily: "Impact" })), "no font is Impact, as before");
assert.ok(GlobalFonts.has("Impact") && GlobalFonts.has("Anton"), "Anton is still registered as Impact");
// The canvas reads "Impact" inside the old picker's stack, so those
// recipes have always drawn in Anton; they still do.
assert.equal(
sha(await render({ ...text, fontFamily: "'Bebas Neue', Impact, sans-serif" })),
sha(await render({ ...text, fontFamily: "Impact" })),
"an old picker stack still draws in Anton, not a caption face",
);
});

it("a face that did not register refuses to render instead of saving a fallback face", async () => {
await withCaptionFontUnregisteredForTest("script", async () => {
await assert.rejects(render({ topText: "Zoë", fontFamily: "script" }), /caption font "script" is not available/);
});
assert.ok((await render({ topText: "Zoë", fontFamily: "script" })).length > 0, "it renders once registered again");
});
});
6 changes: 3 additions & 3 deletions artifacts/api-server/src/__tests__/memeFontPackaging.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import { GlobalFonts, createCanvas } from "@napi-rs/canvas";

import { antonFontCandidates, generateMemeBuffer } from "../lib/memeGenerator.js";
import { fontFileCandidates, generateMemeBuffer } from "../lib/memeGenerator.js";

const here = path.dirname(fileURLToPath(import.meta.url));
const artifactDir = path.resolve(here, "..", "..");
Expand All @@ -25,15 +25,15 @@ const distDir = path.join(artifactDir, "dist");

describe("meme font packaging", () => {
it("the built layout's font path is a file the build copies from src/assets", () => {
const [built] = antonFontCandidates(distDir);
const [built] = fontFileCandidates(distDir, "Anton-Regular.ttf");
const underDistAssets = path.relative(path.join(distDir, "assets"), built!);
assert.ok(!underDistAssets.startsWith(".."), `built candidate ${built} is inside dist/assets`);
const source = path.join(srcAssets, underDistAssets);
assert.ok(fs.existsSync(source), `the build copies ${source} into dist/assets; it must exist`);
});

it("the source-tree path finds the same file", () => {
const candidates = antonFontCandidates(path.join(artifactDir, "src", "lib"));
const candidates = fontFileCandidates(path.join(artifactDir, "src", "lib"), "Anton-Regular.ttf");
assert.ok(candidates.some((c) => fs.existsSync(c)), `one of ${candidates.join(", ")} exists`);
});

Expand Down
89 changes: 59 additions & 30 deletions artifacts/api-server/src/__tests__/nameRuleFontCoverage.test.ts
Original file line number Diff line number Diff line change
@@ -1,47 +1,76 @@
/**
* The name rule admits only characters the meme font draws (David,
* 2026-11-02: English-script names only, no fallback fonts). This draws every
* character the rule admits in Anton and compares it with the missing-glyph
* box, so a widened rule that admits a character Anton lacks fails here.
* The name rule admits only letters every caption font draws (David,
* 2026-11-02: no fallback fonts; names-and-fonts plan, 2026-11-03: the letters
* are derived from the eleven caption faces). Two checks hold the rule to the
* shipped files:
*
* @purpose Proves every character the shared name rule admits renders as a real glyph in the bundled Anton font rather than as the missing-glyph box.
* - the pinned letters (`personalNameLetters.generated.ts`) equal the letters
* derived from the font files' own character maps, in both directions, so a
* supported letter can't be silently left out and an unsupported one can't
* be let in;
* - every admitted character, and its uppercase (captions are usually drawn in
* capitals), is drawn in every face as something other than that face's
* missing-glyph box.
*
* @purpose Proves the name rule's letters equal those derived from the shipped caption fonts, and that every admitted character and its uppercase renders as a real glyph in every caption face.
* @subsystem meme-studio
*/
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { GlobalFonts, createCanvas } from "@napi-rs/canvas";
import { hasOnlyAllowedNameChars } from "@workspace/api-zod";
import { CAPTION_FONTS, hasOnlyAllowedNameChars } from "@workspace/api-zod";
import { PERSONAL_NAME_LETTER_RANGES } from "../../../../lib/api-zod/src/personalNameLetters.generated";
import { deriveNameLetters } from "../lib/fontCoverage.js";

const here = path.dirname(fileURLToPath(import.meta.url));
GlobalFonts.registerFromPath(path.resolve(here, "..", "assets", "fonts", "Anton-Regular.ttf"), "AntonCoverage");
const fontDir = path.resolve(here, "..", "assets", "fonts");
const hex = (cp: number) => `U+${cp.toString(16).toUpperCase().padStart(4, "0")}`;

function bitmap(ch: string): string {
const c = createCanvas(48, 48);
const ctx = c.getContext("2d");
ctx.fillStyle = "#000";
ctx.fillRect(0, 0, 48, 48);
ctx.fillStyle = "#fff";
ctx.font = "32px AntonCoverage";
ctx.fillText(ch, 8, 38);
return Buffer.from(ctx.getImageData(0, 0, 48, 48).data).toString("base64");
}
describe("the name rule and the caption fonts", () => {
it("admits exactly the letters every caption face draws in both cases", () => {
const derived = deriveNameLetters(CAPTION_FONTS.map((f) => fs.readFileSync(path.join(fontDir, f.file))));
const pinned: number[] = [];
for (const [a, b] of PERSONAL_NAME_LETTER_RANGES) for (let cp = a; cp <= b; cp++) pinned.push(cp);
const missing = derived.filter((cp) => !pinned.includes(cp)).map(hex);
const extra = pinned.filter((cp) => !derived.includes(cp)).map(hex);
assert.deepEqual({ missing, extra }, { missing: [], extra: [] },
"the pinned letters differ from the fonts; run artifacts/api-server/scripts/derive-name-letters.ts");
});

describe("the name rule and the meme font", () => {
it("every admitted letter and digit has a glyph in Anton", () => {
const missing = bitmap("\u{10FFFD}");
const blank = bitmap(" ");
const bad: string[] = [];
let checked = 0;
for (let cp = 0x21; cp <= 0x24f; cp++) {
it("every admitted character and its uppercase has a real glyph in every face", () => {
const admitted: string[] = [];
for (let cp = 0x21; cp <= 0x2ffff; cp++) {
if (cp >= 0xd800 && cp <= 0xdfff) continue;
const ch = String.fromCodePoint(cp);
if (!hasOnlyAllowedNameChars(ch)) continue;
checked++;
const b = bitmap(ch);
if (b === missing || b === blank) bad.push(`U+${cp.toString(16).toUpperCase().padStart(4, "0")} ${ch}`);
if (hasOnlyAllowedNameChars(ch)) admitted.push(ch);
}
assert.ok(admitted.length > 340, `checked ${admitted.length} admitted characters`);
const bad: string[] = [];
for (const font of CAPTION_FONTS) {
const family = `Coverage ${font.id}`;
assert.ok(GlobalFonts.registerFromPath(path.join(fontDir, font.file), family), `${font.file} registers`);
const bitmap = (ch: string) => {
const c = createCanvas(64, 64);
const ctx = c.getContext("2d");
ctx.fillStyle = "#000";
ctx.fillRect(0, 0, 64, 64);
ctx.fillStyle = "#fff";
ctx.font = `${font.weight} 36px "${family}"`;
ctx.fillText(ch, 8, 48);
return Buffer.from(ctx.getImageData(0, 0, 64, 64).data).toString("base64");
};
const missingGlyph = bitmap("\u{10FFFD}");
const blank = bitmap(" ");
for (const ch of admitted) {
for (const form of new Set([ch, ch.toUpperCase()])) {
const b = bitmap(form);
if (b === missingGlyph || b === blank) bad.push(`${font.id}: ${form} (${Array.from(form, (c) => hex(c.codePointAt(0)!)).join(" ")})`);
}
}
}
assert.ok(checked > 180, `checked ${checked} admitted characters`);
assert.deepEqual(bad, [], `admitted but not drawn by Anton: ${bad.join(", ")}`);
assert.deepEqual(bad, [], `admitted but not drawn: ${bad.join(", ")}`);
});
});
19 changes: 14 additions & 5 deletions artifacts/api-server/src/__tests__/personalNameRule.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
/**
* @purpose Runs the shared name table against the rule, and proves a provider name is cleaned rather than refused and that typed input is filtered without cutting a paste.
* @purpose Runs the shared name table against the rule, and proves a provider name is kept only whole (never stripped, spaced out or cut) and that typed input is filtered without cutting a paste.
* @subsystem accounts-auth
*/
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { checkPersonalName, cleanProvidedPersonalName, filterNameInput } from "@workspace/api-zod";
import { acceptProvidedPersonalName, checkPersonalName, filterNameInput } from "@workspace/api-zod";
import { PERSONAL_NAME_ACCEPTED, PERSONAL_NAME_REFUSED } from "../../../../lib/api-zod/src/personalNameRule.fixtures";

describe("the shared name rule", () => {
Expand All @@ -20,10 +20,19 @@ describe("the shared name rule", () => {
}
});

// Names-and-fonts plan: a Google or Apple name is kept whole or not at all,
// so nothing garbled is ever stored ("Nguyễn" was once stored "Nguy n").
describe("a provider-supplied name", () => {
it("is cleaned, not refused", () => assert.equal(cleanProvidedPersonalName("Ada 😀 (Countess)"), "Ada Countess"));
it("is cut at 50 graphemes", () => assert.equal(cleanProvidedPersonalName("b".repeat(60)), "b".repeat(50)));
it("is dropped when nothing is left", () => assert.equal(cleanProvidedPersonalName("😀😀"), null));
it("is kept whole when the rule admits it", () => assert.equal(acceptProvidedPersonalName("Nguyễn"), "Nguyễn"));
it("is normalized like a typed one", () => assert.equal(acceptProvidedPersonalName(" O\u2019Brien "), "O'Brien"));
it("is not stored at all when any character is outside the rule", () => {
for (const raw of ["Иван", "John 王", "Ada 😀 (Countess)"]) assert.equal(acceptProvidedPersonalName(raw), null, raw);
});
it("is not stored, rather than cut, when it is over 50 characters", () => assert.equal(acceptProvidedPersonalName("b".repeat(60)), null));
it("stays absent when the provider sent none", () => {
assert.equal(acceptProvidedPersonalName(null), null);
assert.equal(acceptProvidedPersonalName(""), null);
});
});

describe("typed input", () => {
Expand Down
Loading
Loading