import { layoutText } from "../src/canvas/layout.js"; import { canvasMeasure } from "../src/canvas/measure.js"; import { rectIntersects, type Annotation, type Font } from "../src/canvas/types.js"; import { drawText, drawUnderline } from "../src/canvas/draw.js"; import type { AnnotationBox, UnderlinePattern } from "../src/canvas/types.js"; export function sourceGeometry( text: string, width: number, font: Font = { size: 23, family: '"Noto Serif CJK SC", "Songti SC", Georgia, serif' }, ) { const context = document.createElement("canvas").getContext("2d")!; return layoutText({ text, width, font, measure: canvasMeasure(context), }).lines.flatMap((line) => line.glyphs); } export function verifyBrowserMetrics() { const canvas = document.createElement("canvas"), context = canvas.getContext("2d")!; const text = " 阅读中文,👩🏽‍💻 café é。\n\n另一段阅读材料。"; let cases = 0; for (const width of [30, 60, 120, 320, 640]) for (const size of [18, 23, 34]) for (const count of [2, 8, 30]) { const annotations: Annotation[] = Array.from({ length: count }, (_, i) => ({ id: String(i), feature: "same", start: i % 5, end: 6 + (i % 10), style: "overhead", text: i % 2 ? "Some long and overlapping explanatory text" : "阅读注解", color: "#53796c", fontSize: 10 + (i % 4) * 7, })); const layout = layoutText({ text, width, font: { size, family: '"Noto Serif CJK SC",Georgia,serif' }, annotations, measure: canvasMeasure(context), }); const boxes = layout.lines.flatMap((line) => line.overhead); for (const line of layout.lines) if (new Set(line.overhead.map((box) => box.baseline)).size > 1) throw Error("One annotation source was split into multiple rows"); for (const box of boxes) { if (box.x < 0 || box.x + box.width > width + 0.01) throw Error("Overhead box outside browser viewport"); for (const other of boxes) if (box !== other && rectIntersects(box, other)) throw Error("Overhead text overlap with real browser metrics"); for (const line of layout.lines) for (const glyph of line.glyphs) if (rectIntersects(box, glyph)) throw Error("Overhead text overlaps source with browser metrics"); } cases++; } const pinyin = ["cháng", "xiǎoyǔ", "zǒuchū", "dìtiě", "zhàn"].map( (text, i): Annotation => ({ id: String(i), feature: "pinyin", start: i * 2, end: i * 2 + 2, style: "overhead", text, color: "green", }), ); const adjacent = layoutText({ text: "一场小雨走出地铁车站", width: 640, annotations: pinyin, measure: canvasMeasure(context), }); if ( adjacent.lines[0].overhead.length !== pinyin.length || new Set(adjacent.lines[0].overhead.map((box) => box.baseline)).size !== 1 ) throw Error("Adjacent pinyin zig-zags with real browser metrics"); const long = "This is a very long sentence translation that should share source flow across several physical text lines."; const twoSources = layoutText({ text: "一场小雨。", width: 120, annotations: [ ...pinyin.slice(0, 2), { id: "sentence", feature: "translation", start: 0, end: 5, style: "overhead", text: long, color: "red", priority: 1, }, ], measure: canvasMeasure(context), }); const sentence = twoSources.lines .flatMap((line) => line.overhead) .filter((box) => box.annotation.feature === "translation"); if (sentence.length < 3 || sentence.flatMap((box) => box.lines).join("") !== long) throw Error("Long translation did not flow intact across text lines"); for (const line of twoSources.lines) for (const feature of ["pinyin", "translation"]) if ( new Set(line.overhead.filter((box) => box.annotation.feature === feature).map((box) => box.baseline)).size > 1 ) throw Error("A word/sentence source gained a collision row"); return { cases, fixedRows: true, font: context.font }; } import { readingPages } from "../src/canvas/pages.js"; export function sourcePages(text: string, width: number, height: number) { const context = document.createElement("canvas").getContext("2d")!; return readingPages( layoutText({ text, width, font: { size: 23, family: '"Noto Serif CJK SC", "Songti SC", Georgia, serif' }, measure: canvasMeasure(context), }), height, ); } /** Exercise actual canvas rasterization: patterned strokes must stay in their * band, include gaps/waves, and leave layout and selection geometry unchanged. */ export function verifyUnderlineStyles() { const patterns: UnderlinePattern[] = ["solid", "dashed", "squiggly"]; const canvas = document.createElement("canvas"); canvas.width = 100; canvas.height = 40; const context = canvas.getContext("2d")!; for (const pattern of patterns) { context.clearRect(0, 0, canvas.width, canvas.height); const box: AnnotationBox = { x: 10, y: 10, width: 72, height: 3, layer: 0, baseline: 0, lines: [], font: { family: "serif", size: 23 }, annotation: { id: pattern, feature: "test", start: 0, end: 1, text: "", style: "underline", color: "#53796c", underlinePattern: pattern, }, }; drawUnderline(context, box); const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data; const columns = new Set(), rows = new Set(); for (let y = 0; y < canvas.height; y++) for (let x = 0; x < canvas.width; x++) if (pixels[(y * canvas.width + x) * 4 + 3]) { if (x < 10 || x >= 82 || y < 10 || y >= 13) throw Error("Underline escapes its decoration band"); columns.add(x); rows.add(y); } if (columns.size < 30) throw Error("Underline was not drawn"); if (pattern === "solid" && columns.size !== 72) throw Error("Solid underline has gaps"); if (pattern === "dashed" && columns.size >= 72) throw Error("Dashed underline has no gaps"); if (pattern === "squiggly" && rows.size !== 3) throw Error("Squiggly underline is flat"); if (context.getLineDash().length) throw Error("Underline leaked its dash state"); } const text = " Several words wrapping onto another line.\n\tA new paragraph."; let geometry = ""; for (const pattern of patterns) { const layout = layoutText({ text, width: 120, measure: canvasMeasure(context), annotations: [ { id: "underlined", feature: "test", start: 0, end: text.length, text: "", style: "underline", color: "#53796c", underlinePattern: pattern, }, ], }); const current = JSON.stringify( layout.lines.map(({ glyphs, y, height, decorations }) => ({ glyphs, y, height, decorations: decorations.map(({ x, y, width, height }) => ({ x, y, width, height })), })), ); if (geometry && current !== geometry) throw Error("Underline pattern changes layout"); geometry = current; for (const line of layout.lines) if (/^\s/u.test(line.glyphs[0]?.text ?? "")) throw Error("Rendered line begins with a space"); } return { patterns: patterns.length, clipped: true, stableGeometry: true }; } export function underlinePreview() { const canvas = document.createElement("canvas"); canvas.width = 440; canvas.height = 200; const context = canvas.getContext("2d")!; context.fillStyle = "#fafaf6"; context.fillRect(0, 0, canvas.width, canvas.height); context.translate(20, 10); const text = "Solid underline\nDashed underline\nSquiggly underline"; const starts = [0, text.indexOf("Dashed"), text.indexOf("Squiggly")]; const annotations = (["solid", "dashed", "squiggly"] as const).map( (pattern, i): Annotation => ({ id: pattern, feature: pattern, start: starts[i], end: i === 2 ? text.length : starts[i + 1] - 1, text: "", style: "underline", color: "#53796c", underlinePattern: pattern, }), ); drawText( context, layoutText({ text, width: 400, font: { family: "Georgia, serif", size: 23 }, measure: canvasMeasure(context), annotations, }), ); return canvas.toDataURL(); }