import assert from "node:assert/strict"; import { layoutText } from "../src/canvas/layout.js"; import { graphemes, snapOffset, wordAt, hoverWordAt } from "../src/canvas/text.js"; import { textInteractionAt } from "../src/canvas/interaction.js"; import { contextTarget, hitTest, moveSelection, selectionRange, selectionRects } from "../src/canvas/selection.js"; import { lineSpans } from "../src/canvas/line-breaks.js"; import { rectIntersects, type Annotation, type Font, type Layout } from "../src/canvas/types.js"; const measure = (text: string, font: Font) => ({ width: Array.from(text).length * font.size * 0.6, ascent: font.size * 0.9, descent: font.size * 0.3, }); function verify(layout: Layout) { assert(Number.isFinite(layout.height)); const boxes = layout.lines.flatMap((line) => line.overhead); for (const box of boxes) { assert(box.x >= 0 && box.x + box.width <= layout.width + 0.001); assert(box.y >= 0 && box.y + box.height < layout.height); for (const other of boxes) if (box !== other) assert(!rectIntersects(box, other), JSON.stringify({ box, other })); for (const line of layout.lines) for (const glyph of line.glyphs) assert(!rectIntersects(box, glyph)); } for (const line of layout.lines) for (const feature of new Set( line.overhead.map((box) => `${box.annotation.placement ?? "above"}:${box.annotation.feature}`), )) { const members = line.overhead.filter( (box) => `${box.annotation.placement ?? "above"}:${box.annotation.feature}` === feature, ); assert.equal(new Set(members.map((box) => box.layer)).size, 1, "A source has exactly one annotation row"); assert.equal(new Set(members.map((box) => box.baseline)).size, 1, "Same-source labels share their baseline"); } for (const line of layout.lines) for (const glyph of line.glyphs) { assert.equal(layout.text.slice(glyph.start, glyph.end), glyph.text); assert.equal(hitTest(layout, glyph.x + glyph.advance * 0.2, glyph.y + glyph.height / 2), glyph.start); assert.equal(hitTest(layout, glyph.x + glyph.advance * 0.8, glyph.y + glyph.height / 2), glyph.end); } } const text = " 她在北京学习中文。👩🏽‍💻 e\u0301\n\n一杯咖啡,两个朋友。\n最后一行"; for (const width of [30, 45, 80, 140, 320, 680]) { for (const count of [0, 1, 3, 7, 20]) { const units = graphemes(text).filter((unit) => !/[\r\n]/.test(unit.text)); const annotations: Annotation[] = Array.from({ length: count }, (_, index) => { const start = units[index % units.length].start; return { id: String(index), feature: String(index % 3), start, end: Math.min(text.length, start + 10), text: index % 2 ? "a long overhead explanation" : "běi jīng", style: "overhead", color: "blue", fontSize: [10, 13, 30][index % 3], priority: index % 4, }; }); verify(layoutText({ text, width, annotations, measure })); } } // Exhaust all pairs of nested/adjacent source spans on a short paragraph. let cases = 0; for (const width of [36, 100, 280]) for (let a = 0; a < 6; a++) for (let b = a + 1; b <= 6; b++) for (let c = 0; c < 6; c++) for (let d = c + 1; d <= 6; d++) { const annotations: Annotation[] = [ { id: "a", feature: "same", start: a, end: b, text: "explanation one", fontSize: 11, style: "overhead", color: "red", }, { id: "b", feature: "same", start: c, end: d, text: "另一层注解", fontSize: 23, style: "overhead", color: "green", }, ]; verify(layoutText({ text: "阅读中文的人", width, annotations, measure })); cases++; } // Ordinary adjacent pinyin never alternates vertical positions. Source padding // grows around the word, keeping its characters together rather than spreading // the word's internal glyphs to make room for a pronunciation. const pinyin: Annotation[] = ["cháng", "xiǎoyǔ", "zǒuchū", "dìtiě", "zhàn"].map((text, index) => ({ id: String(index), feature: "pinyin", start: index * 2, end: index * 2 + 2, text, style: "overhead", color: "green", })); const aligned = layoutText({ text: "一场小雨走出地铁车站", width: 680, annotations: pinyin, measure }); verify(aligned); assert.equal(aligned.lines.filter((line) => line.glyphs.length).length, 1); assert.equal(new Set(aligned.lines[0].overhead.map((box) => box.baseline)).size, 1); assert.equal(aligned.lines[0].overhead.length, pinyin.length); for (const annotation of pinyin) { const glyphs = aligned.lines[0].glyphs.filter( (glyph) => glyph.start >= annotation.start && glyph.end <= annotation.end, ); assert(Math.abs(glyphs[1].x - glyphs[0].x - glyphs[0].width) < 0.001); } // Long word explanations and sentence translations flow through physical text // lines, with word/sentence sources on two fixed rows instead of a wrapped box // creating more rows above a single line of source text. const longWord = "a surprisingly long explanation with several complete words"; const longSentence = "The complete sentence has an even longer translation which asks the source text for more horizontal space."; const flowing = layoutText({ text: "一本书。", width: 90, annotations: [ { id: "word", feature: "words", start: 1, end: 2, text: longWord, style: "overhead", color: "green", priority: 0 }, { id: "sentence", feature: "sentences", start: 0, end: 4, text: longSentence, style: "overhead", color: "red", priority: 1, }, ], measure, }); verify(flowing); for (const [feature, expected] of [ ["words", longWord], ["sentences", longSentence], ]) { const boxes = flowing.lines.flatMap((line) => line.overhead).filter((box) => box.annotation.feature === feature); assert(boxes.length > 1, "Long labels use subsequent physical lines"); assert.equal(boxes.flatMap((box) => box.lines).join(""), expected, "Wrapping retains all label content"); assert.equal(new Set(boxes.map((box) => box.layer)).size, 1); } assert(flowing.height > layoutText({ text: "一本书。", width: 90, measure }).height * 4); const mixed = layoutText({ text: "阅读中文的人", width: 600, annotations: [ { id: "small", feature: "words", start: 0, end: 2, text: "read", fontSize: 10, style: "overhead", color: "red" }, { id: "large", feature: "words", start: 2, end: 4, text: "Chinese", fontSize: 30, style: "overhead", color: "red" }, ], measure, }); verify(mixed); assert.equal( new Set(mixed.lines[0].overhead.map((box) => box.baseline)).size, 1, "Mixed font sizes share a source baseline", ); const shortAtWrap = layoutText({ text: "阅读中", width: 30, annotations: [{ id: "short", feature: "words", start: 2, end: 3, text: "x", style: "overhead", color: "green" }], measure, }); // Word glosses sit nearest the base; a sentence-only continuation must not // reserve the absent word lane. Nested reservations center labels on the ink. const frenchText = "Un libraire lui propose un café.\nIls parlent des voyages et des livres qu'ils aiment."; const sentenceEnd = frenchText.indexOf("\n"); const frenchAnnotations: Annotation[] = [ { id: "word", feature: "words", start: 0, end: 2, text: "a longer article gloss", style: "overhead", color: "blue", priority: 0, }, { id: "sentence", feature: "sentences", start: 0, end: sentenceEnd, text: "A bookseller offers her a coffee.", style: "overhead", color: "brown", priority: 1, }, { id: "next", feature: "sentences", start: sentenceEnd + 1, end: frenchText.length, text: "They talk about trips and books.", style: "overhead", color: "brown", priority: 1, }, ]; const frenchLayout = layoutText({ text: frenchText, width: 1600, annotations: frenchAnnotations, measure }); verify(frenchLayout); const firstFrench = frenchLayout.lines[0]; const wordBox = firstFrench.overhead.find((box) => box.annotation.id === "word")!; const sentenceBox = firstFrench.overhead.find((box) => box.annotation.id === "sentence")!; assert(wordBox.y > sentenceBox.y, "Word lane sits closer to source than sentence lane"); const firstInk = firstFrench.glyphs[0], lastInk = firstFrench.glyphs.findLast((glyph) => glyph.end === sentenceEnd)!; assert( Math.abs(sentenceBox.x + sentenceBox.width / 2 - (firstInk.x + lastInk.x + lastInk.width) / 2) < 0.001, "Sentence label centers on source ink despite asymmetric word padding", ); const secondFrench = frenchLayout.lines.find((line) => line.glyphs.some((glyph) => glyph.start === sentenceEnd + 1))!; assert.equal(secondFrench.overhead.length, 1); assert( Math.abs(secondFrench.y - secondFrench.overhead[0].y - secondFrench.overhead[0].height - 5) < 0.001, "A sentence-only line has no empty word lane", ); const beforeFrench = layoutText({ text: frenchText, width: 1600, measure }); const afterFrench = layoutText({ text: frenchText, width: 1600, annotations: [], measure }); assert.deepEqual(afterFrench, beforeFrench, "Dismissed annotations release all horizontal and vertical reservations"); assert( shortAtWrap.lines.find((line) => line.glyphs.some((glyph) => glyph.start === 2))!.overhead.length, "A short annotation follows its wrapped source glyph", ); const layout = layoutText({ text, width: 160, measure }); let selection = { anchor: 0, focus: 0 }; for (const unit of graphemes(text)) { selection = moveSelection(layout, selection, "ArrowRight", true); assert.equal(selection.focus, unit.end); } assert.equal(text.slice(selectionRange(selection).start, selectionRange(selection).end), text); assert(selectionRects(layout, selection).length > 1); assert.equal(snapOffset("👩🏽‍💻", 2), "👩🏽‍💻".length); assert.equal(snapOffset("e\u0301", 1, -1), 0); assert.equal(wordAt("北京的朋友", 1).end, 2); assert.equal(layoutText({ text: "", width: 100, measure }).lines.length, 1); assert.throws(() => layoutText({ text: "a", width: 0, measure })); console.log(`PASS canvas: ${cases} exhaustive overlap cases, mixed heights, wrapping, Unicode, offsets and selection`); import { rangeOutline, rangeRects } from "../src/canvas/outline.js"; import { drawText, labelGuideSegments } from "../src/canvas/draw.js"; const fontCalls: string[] = []; const mixedFonts = layoutText({ text: "你好世界", width: 400, measure: (text, font) => { fontCalls.push(font.family); return measure(text, font); }, annotations: [ { ...pinyin[0], start: 0, end: 2, fontFamily: "serif", fontSize: 12 }, { ...pinyin[1], start: 2, end: 4, fontFamily: "monospace", fontSize: 30 }, ], }); verify(mixedFonts); assert(fontCalls.includes("serif") && fontCalls.includes("monospace")); assert.equal(mixedFonts.lines[0].overhead[0].baseline, mixedFonts.lines[0].overhead[1].baseline); const ink = layoutText({ text: "你好", width: 100, measure, annotations: [{ ...pinyin[0], start: 0, end: 1, style: "color", color: "#112233" }], }); const draws: { text: string; color: string }[] = []; let rectangles = 0; const context = { save() {}, restore() {}, translate() {}, fillStyle: "", beginPath() {}, rect() {}, clip() {}, globalAlpha: 1, font: "", fillRect() { rectangles++; }, fillText(text: string) { draws.push({ text, color: this.fillStyle }); }, } as unknown as CanvasRenderingContext2D; drawText(context, ink); assert.deepEqual(draws, [ { text: "你", color: "#112233" }, { text: "好", color: "#252923" }, ]); assert.equal(rectangles, 0, "Text color never draws a background"); draws.length = 0; drawText(context, ink, { visible: () => false }); assert( draws.every((draw) => draw.color === "#252923"), "Reveal timing also controls source ink", ); for (const width of [30, 80, 200, 500]) { const layout = layoutText({ text: "第一行阅读中文。\n第二行。\n第三行继续阅读。", width, measure }); for (let start = 0; start < layout.text.length; start++) for (let end = start + 1; end <= layout.text.length; end++) { const path = rangeOutline(layout, { start, end }); assert(!/NaN|Infinity/.test(path)); assert.equal( (path.match(/M/g) ?? []).length, rangeRects(layout, { start, end }).length ? 1 : 0, "One contour across wrapped lines", ); if (path) assert(path.endsWith("Z")); } } assert.equal(rangeOutline(ink, { start: 2, end: 2 }), ""); const sourceRects = rangeRects(mixedFonts, { start: 0, end: 4 }); assert.equal(sourceRects[0].y, mixedFonts.lines[0].glyphs[0].y, "Contours surround source, below overhead rows"); console.log("PASS source ink reveal, mixed annotation fonts and exhaustive multi-line contour geometry"); import { readingPages, readingWindow, pageAt } from "../src/canvas/pages.js"; const wrapSource = "Before. Le titre me séduisit: je les emportai chez moi, avec la permission de M. le conservateur; bien entendu, je les dévorai."; const wrappedLabel: Annotation = { id: "wrapped-sentence", feature: "sentence", start: 8, end: wrapSource.length, text: "The title enticed me: I took them home, with the curator's permission; of course, I devoured them.", style: "overhead", color: "brown", }; const centeredWrap = layoutText({ text: wrapSource, width: 380, measure, annotations: [wrappedLabel] }); verify(centeredWrap); const labelBoxes = centeredWrap.lines.flatMap((l) => l.overhead); assert(labelBoxes.length > 1, "Sentence translation wraps over source lines rather than sitting at its start"); assert( centeredWrap.lines.findIndex((l) => l.overhead.includes(labelBoxes[0])) * centeredWrap.width + labelBoxes[0].x > centeredWrap.lines[0].glyphs.find((g) => g.start === 8)!.x + 10, "Wrapped translation consumes left centering space", ); const pageSource = Array.from({ length: 70 }, (_, i) => `A sentence number ${i} with a few words.`).join(" "); let pageOffset = 0, visited = ""; for (let i = 0; pageOffset < pageSource.length && i < 200; i++) { const dynamic = layoutText({ text: pageSource, width: i % 2 ? 270 : 380, measure, breakAt: pageOffset }); const ps = readingPages(dynamic, 180, pageOffset), current = ps[pageAt(ps, pageOffset)]; assert.equal(current.start, pageOffset, "Reflow preserves the source page start"); assert(current.end > pageOffset, "Next advances the source position"); visited += pageSource.slice(pageOffset, current.end); pageOffset = current.end; } assert.equal(visited, pageSource, "Repeated resize and next loses or repeats no text"); draws.length = 0; drawText(context, ink, { hover: { start: 0, end: 1 } }); assert.deepEqual(draws, [ { text: "你", color: "#39745b" }, { text: "好", color: "#252923" }, ]); for (const width of [60, 250, 600]) for (const height of [80, 300, 600]) { const text = Array.from({ length: 100 }, (_, i) => `第${i}行,阅读👩🏽‍💻原文。`).join("\n"); const layout = layoutText({ text, width, measure }); const pages = readingPages(layout, height); assert(pages.length > 1); assert.equal(pages.map((page) => text.slice(page.start, page.end)).join(""), text); for (const [i, page] of pages.entries()) { assert.equal(pageAt(pages, page.start), i); assert.equal(pageAt(pages, page.end - 1), i); const window = readingWindow(layout, pages, i, "pages", page.top, height); assert.deepEqual(window.visible, { start: page.start, end: page.end }); assert.equal(window.nearby.start, pages[Math.max(0, i - 1)].start); assert.equal(window.nearby.end, pages[Math.min(pages.length - 1, i + 1)].end); assert(window.nearby.end - window.nearby.start < text.length || pages.length <= 3); } const middle = pages[Math.floor(pages.length / 2)]; const scroll = readingWindow(layout, pages, 0, "scroll", middle.top, height); assert(scroll.nearby.start <= scroll.visible.start && scroll.nearby.end >= scroll.visible.end); assert(scroll.nearby.start > 0 && scroll.nearby.end < text.length); } const tallPages = readingPages(flowing, 30); assert.equal(tallPages.map((page) => flowing.text.slice(page.start, page.end)).join(""), flowing.text); assert( tallPages.every((page) => page.bottom > page.top), "Continuation labels stay with a source page", ); console.log( "PASS hover glyph color, source-stable pages, neighboring-page windows, bounded scroll windows and annotation continuation", ); // Intermediate rows and paragraph gaps share a quiet, full-width band. for (const separator of ["\n\n", "\r\n\r\n", "\n \n", "\n\n\n"]) { const text = "一个很长的段落需要换行,继续阅读。" + separator + "第二段落也需要换行。" + separator + "结束。"; for (const width of [80, 200, 1000]) { const layout = layoutText({ text, width, measure }); const path = rangeOutline(layout, { start: 0, end: text.length }); assert.equal((path.match(/M/g) ?? []).length, 1, "One contour spans paragraphs"); assert.equal((path.match(/Z/g) ?? []).length, 1); assert(!/NaN|Infinity/.test(path)); assert((path.match(/Q/g) ?? []).length <= 8, "At most two end-row notches, irrespective of paragraph count"); assert(path.includes(String(width - 1) + ","), "Intermediate rows use the full available width"); } } console.log("PASS processing contours span paragraph gaps with bounded, rounded corners"); import { tooltipPosition } from "../src/canvas/tooltip-position.js"; for (const viewport of [ { width: 1440, height: 1000 }, { width: 320, height: 480 }, ]) { const width = Math.min(300, viewport.width - 24), height = 120; for (const x of [0, 50, viewport.width - 10]) for (const y of [0, 50, viewport.height - 20]) { const position = tooltipPosition({ x, y, width: 20, height: 30 }, width, height, viewport); assert(position.left >= 12 && position.left + width <= viewport.width - 12); assert(position.top >= 12 && position.top + height <= viewport.height - 12); assert(position.arrow >= 16 && position.arrow <= width - 16); } } assert.equal( tooltipPosition({ x: 100, y: 200, width: 50, height: 20 }, 200, 100, { width: 800, height: 600 }).side, "above", ); assert.equal( tooltipPosition({ x: 100, y: 10, width: 50, height: 20 }, 200, 100, { width: 800, height: 600 }).side, "below", ); const tooltipLayout = layoutText({ text: "你好", width: 200, measure, annotations: [ { id: "tip", feature: "tip", start: 0, end: 2, text: "A long tooltip", style: "tooltip", color: "#53796c" }, ], }); assert.equal( tooltipLayout.height, layoutText({ text: "你好", width: 200, measure }).height, "React tooltip never displaces canvas text", ); assert.equal(tooltipLayout.lines[0].decorations.length, 0); console.log("PASS tooltip flipping, viewport containment, arrow anchoring and unchanged canvas layout"); // Short source spans and their one-line overhead labels move together. const welcome: Annotation = { id: "welcome", feature: "words", start: 8, end: 10, text: "huānyíng", style: "overhead", color: "green", }; for (const width of [100, 140, 180]) { const layout = layoutText({ text: "星期六我们读书。欢迎来到书店。", width, annotations: [welcome], measure }); const sourceRows = layout.lines.filter((line) => line.glyphs.some((g) => g.start >= 8 && g.end <= 10)); assert.equal(sourceRows.length, 1, "Welcome stays on one physical line"); const labelRow = layout.lines.find((line) => line.overhead.some((box) => box.annotation.id === "welcome")); assert.equal(labelRow, sourceRows[0], "Short overhead text stays with its source"); for (const page of readingPages(layout, 60)) { for (const part of new Intl.Segmenter(undefined, { granularity: "word" }).segment(layout.text)) { if (part.isWordLike && part.segment.length <= 2) assert( !(page.end > part.index && page.end < part.index + part.segment.length), "Page doesn't cut a fitting source word", ); } } } const replacements = layoutText({ text: "我爱书店。", width: 300, measure, annotations: [ { id: "r", feature: "r", start: 2, end: 4, text: "bookstore", style: "replace", color: "green" }, { id: "b", feature: "b", start: 0, end: 1, text: "", style: "bold", color: "green" }, { id: "i", feature: "i", start: 1, end: 2, text: "", style: "italic", color: "green" }, ], }); assert.equal( replacements.lines .flatMap((l) => l.glyphs) .map((g) => g.text) .join(""), "我爱bookstore。", ); assert.equal(replacements.text, "我爱书店。", "Canonical copy/selection source is unchanged"); assert.equal(replacements.lines[0].glyphs[0].font?.weight, "bold"); assert.equal(replacements.lines[0].glyphs[1].font?.italic, true); const replacement = replacements.lines[0].glyphs.find((g) => g.text === "bookstore")!; assert.equal(hitTest(replacements, replacement.x + replacement.advance * 0.2, replacement.y + 2), 2); assert.equal(hitTest(replacements, replacement.x + replacement.advance * 0.8, replacement.y + 2), 4); const layeredOutline = layoutText({ text: "阅读中文。欢迎继续阅读。", width: 100, measure, annotations: [ { id: "lane", feature: "lane", start: 0, end: 12, text: "a long label that wraps over multiple lines", fontSize: 30, style: "overhead", color: "green", }, ], }); const paths = rangeOutline(layeredOutline, { start: 0, end: layeredOutline.text.length }); assert(!/NaN|Infinity/.test(paths)); assert.equal((paths.match(/M/g) ?? []).length, 1, "Overhead rows stay inside the broad contour"); console.log( "PASS word/label affinity, whole-word page boundaries, text replacement source mapping, bold/italic, and overhead-safe processing outlines", ); const expanded = layoutText({ text: "书。", width: 50, measure, annotations: [ { id: "long-replacement", feature: "replace", start: 0, end: 1, text: "a very long explanation\nwith another line", style: "replace", color: "green", }, { id: "bold-replacement", feature: "bold", start: 0, end: 1, text: "", style: "bold", color: "green" }, ], }); assert.equal( expanded.lines .flatMap((l) => l.glyphs) .map((g) => g.text) .join("") .replace(/\s/g, ""), "averylongexplanationwithanotherline。", ); assert( expanded.lines .flatMap((l) => l.glyphs) .filter((g) => g.start === 0) .every((g) => g.font?.weight === "bold"), ); assert(readingPages(expanded, 60).length > 1, "Expanded replacements can span physical pages"); const expandedPages = readingPages(expanded, 60); assert( expandedPages.every((page) => page.end > page.start), "Replacement pages retain their shared source span", ); assert.equal(pageAt(expandedPages, 0), 0, "Opening an expanded replacement starts on its first physical page"); console.log("PASS multiline and expanded replacements retain content and inherited typography"); import { sharedPageSize } from "../src/canvas/page-size.js"; const localSize = { width: 900, height: 700 }; const reports = [ { client: "wide", width: 900, height: 700, expiresAt: 200 }, { client: "narrow", width: 480, height: 600, expiresAt: 150 }, { client: "short", width: 800, height: 360, expiresAt: 200 }, { client: "dead", width: 10, height: 10, expiresAt: 50 }, ]; assert.deepEqual(sharedPageSize(localSize, reports, "wide", 100), { width: 480, height: 360 }); assert.deepEqual(sharedPageSize(localSize, reports, "wide", 160), { width: 800, height: 360 }); assert.deepEqual(sharedPageSize(localSize, reports, "wide", 200), localSize); assert.deepEqual( sharedPageSize({ width: 1100, height: 800 }, [reports[0]], "wide", 100), { width: 1100, height: 800 }, "Resize supersedes our earlier report", ); const sharedSize = sharedPageSize(localSize, reports, "wide", 100); const otherSize = sharedPageSize({ width: 480, height: 600 }, reports, "narrow", 100); assert.deepEqual(sharedSize, otherSize); const sharedText = "Un texte avec plusieurs phrases.\n\n".repeat(80); const pageRanges = (size: typeof localSize) => readingPages(layoutText({ text: sharedText, width: size.width, measure }), size.height).map(({ start, end }) => ({ start, end, })); assert.deepEqual( pageRanges(sharedSize), pageRanges(otherSize), "Different windows paginate to identical source ranges", ); console.log("PASS smallest live viewport determines shared pages; expired and own stale reports don't constrain"); // Whitespace belongs to the source, even when it isn't drawn at a line edge. for (const text of [" one two three\n\t four five", "\t北京 欢迎 你\n 中文", "one two", " \n "]) for (const width of [20, 30, 47, 80, 300]) { const layout = layoutText({ text, width, measure }); assert.equal(layout.text, text); for (const line of layout.lines) { assert(!/^\s/u.test(line.glyphs[0]?.text ?? ""), "No line begins with a drawn separator or indentation"); for (const glyph of line.glyphs) assert.equal(text.slice(glyph.start, glyph.end), glyph.text); } const pages = readingPages(layout, 70); assert.equal(pages.map((page) => text.slice(page.start, page.end)).join(""), text, "Copy retains all whitespace"); } const indent = layoutText({ text: " word", width: 100, measure }); assert.equal(indent.lines[0].glyphs[0].start, 2); assert.equal(indent.lines[0].glyphs[0].x, 0); const wrappedSeparator = layoutText({ text: "one two", width: 60, measure, annotations: [ { id: "spaced-span", feature: "words", start: 3, end: 7, text: "label", style: "overhead", color: "green" }, ], }); assert(wrappedSeparator.lines.length > 1); assert( wrappedSeparator.lines.every((line) => !/^\s/u.test(line.glyphs[0]?.text ?? "")), "Annotation affinity cannot introduce a leading space", ); console.log("PASS visual line-leading whitespace suppression preserves original offsets and page/copy content"); import { coalesceProcessing, type ProcessingRegion } from "../src/canvas/processing.js"; const pendingRegion = (feature: string, start: number, end: number): ProcessingRegion => ({ feature, range: { start, end }, label: `Preparing ${feature}`, color: feature === "pinyin" ? "#53796c" : "#526c86", }); const pendingRegions = [ pendingRegion("pinyin", 10, 15), pendingRegion("translation", 5, 10), pendingRegion("pinyin", 0, 5), pendingRegion("pinyin", 5, 10), pendingRegion("pinyin", 3, 7), pendingRegion("pinyin", 10, 15), ]; const unchangedRegions = JSON.stringify(pendingRegions); assert.deepEqual( coalesceProcessing(pendingRegions, "a".repeat(30)), [pendingRegion("pinyin", 0, 15), pendingRegion("translation", 5, 10)], "Adjacent, overlapping and duplicate chunks join only within their annotator", ); assert.equal(JSON.stringify(pendingRegions), unchangedRegions, "Coalescing never alters jobs or scheduler ranges"); assert.deepEqual( coalesceProcessing([pendingRegion("pinyin", 0, 3), pendingRegion("pinyin", 6, 9)], "one\n\n two"), [pendingRegion("pinyin", 0, 9)], "Paragraph separators don't create seams between pending regions", ); assert.deepEqual( coalesceProcessing([pendingRegion("pinyin", 0, 3), pendingRegion("pinyin", 6, 9)], "oneXYZtwo"), [pendingRegion("pinyin", 0, 3), pendingRegion("pinyin", 6, 9)], "Completed intervening text splits the outline again", ); assert.deepEqual(coalesceProcessing([pendingRegion("pinyin", 2, 2)], "one"), []); console.log("PASS processing outlines coalesce by annotator without covering completed text or mutating work"); // Line breaking and word targeting are different Unicode operations. Chinese // closing punctuation must stay with preceding ink even when pinyin padding // causes a wrap; opening punctuation must stay with what follows it. assert.deepEqual(lineSpans("hello world"), [ { start: 0, end: 6 }, { start: 6, end: 11 }, ]); assert.deepEqual(lineSpans("hello\u00a0world"), [{ start: 0, end: 11 }], "Nonbreaking spaces remain unbreakable"); assert(!lineSpans("北京。朋友").some((span) => span.end === 2), "No legal break immediately before the period"); assert( !lineSpans("北京(朋友)").some((span) => span.end === 3), "No legal break immediately after an opening bracket", ); assert(lineSpans("👩🏽‍💻朋友").every((span) => span.start === 0 || span.start >= "👩🏽‍💻".length)); const punctuated = "林雨走出地铁站,发现街角有一家新书店。门口的木牌上写着:(欢迎进来,慢慢读书。)"; for (const width of [45, 70, 100, 130, 210]) { for (const annotations of [ [], [ { id: "sign", feature: "pinyin", start: 22, end: 24, text: "a very long pronunciation", style: "overhead", color: "green", }, { id: "shop", feature: "pinyin", start: 16, end: 18, text: "shūdiàn", style: "overhead", color: "green" }, ] as Annotation[], ]) { const result = layoutText({ text: punctuated, width, annotations, measure }); verify(result); for (const line of result.lines.filter((line) => line.glyphs.length)) { assert(!/^[。,、)]/u.test(line.glyphs[0].text), `Closing punctuation begins a line at width ${width}`); assert(!/[(]$/u.test(line.glyphs.at(-1)!.text), `Opening punctuation ends a line at width ${width}`); } assert.equal( result.lines .flatMap((line) => line.glyphs) .map((glyph) => glyph.text) .join(""), punctuated, ); } } const latinPunctuation = layoutText({ text: "A short (French word), and another word.", width: 130, measure }); for (const line of latinPunctuation.lines.filter((line) => line.glyphs.length)) { assert(!/^[.,)]/u.test(line.glyphs[0].text)); assert.notEqual(line.glyphs.at(-1)!.text, "("); } const annotationPunctuation = layoutText({ text: "中文", width: 60, measure, annotations: [ { id: "punctuated-label", feature: "gloss", start: 0, end: 2, text: "读书。中文,朋友。", style: "overhead", color: "green", }, ], }); verify(annotationPunctuation); assert(annotationPunctuation.lines.flatMap((line) => line.overhead).every((box) => !/^[。,]/u.test(box.lines[0]))); verify( layoutText({ text: "书。中文,友", width: 100, measure, annotations: [ { id: "word-label", feature: "gloss", start: 0, end: 1, text: "explanation one", fontSize: 11, style: "overhead", color: "green", }, { id: "punctuation-label", feature: "gloss", start: 1, end: 2, text: "another explanation", fontSize: 23, style: "overhead", color: "green", }, ], }), ); assert.deepEqual(contextTarget({ anchor: 8, focus: 2 }, { start: 3, end: 5 }, true), { range: { start: 2, end: 8 }, kind: "selection", }); assert.deepEqual(contextTarget({ anchor: 2, focus: 8 }, { start: 12, end: 15 }, false), { range: { start: 12, end: 15 }, kind: "word", }); assert.equal(contextTarget({ anchor: 3, focus: 3 }, null, false), null); console.log( "PASS Unicode line-breaking opportunities, Chinese/Latin punctuation with overhead reservations, and context-menu selection targets", ); // The same label kind may sit above or below, with one fixed lane per // source/side. Neither labels nor their source reservations collide. for (const width of [40, 90, 180, 500]) { const paired = layoutText({ text: "书店欢迎朋友。\n我们一起读书。", width, measure, annotations: [ { ...pinyin[0], id: "above-a", feature: "words", start: 0, end: 2, text: "bookshop", placement: "above", fontSize: 12, }, { ...pinyin[0], id: "above-b", feature: "words", start: 2, end: 4, text: "welcomes", placement: "above", fontSize: 18, }, { ...pinyin[0], id: "below-a", feature: "words", start: 0, end: 2, text: "a place with books and friends", placement: "below", fontSize: 14, }, { ...pinyin[0], id: "below-b", feature: "sentences", start: 0, end: 7, text: "The bookshop welcomes friends.", placement: "below", fontSize: 22, }, ], }); verify(paired); for (const line of paired.lines) { for (const box of line.overhead) { if (box.annotation.placement === "below") { assert(box.y >= line.y + (line.glyphs[0]?.height ?? 0)); assert(box.y + box.height <= line.y + line.height); } else assert(box.y + box.height < line.y); assert.deepEqual( textInteractionAt(paired, box.x + box.width / 2, box.y + box.height / 2, () => true), { kind: "label", range: { start: box.annotation.start, end: box.annotation.end }, }, ); assert.equal( textInteractionAt(paired, box.x + box.width / 2, box.y + box.height / 2, () => false), null, "Hidden labels are not interactive", ); } } const pages = readingPages(paired, 150); for (const box of paired.lines.flatMap((line) => line.overhead)) assert( pages.some((page) => box.y >= page.top && box.y + box.height <= page.bottom), "Both label sides belong to their source page", ); } const targetText = "北京,l’ami! 你好。"; assert.deepEqual(hoverWordAt(targetText, 0), { start: 0, end: 2 }); assert.equal(hoverWordAt(targetText, 2), null); assert.equal(hoverWordAt(targetText, 8), null); assert.equal(hoverWordAt(targetText, 9), null); assert.deepEqual(hoverWordAt(targetText, 4), { start: 3, end: 8 }); const hoverLayout = layoutText({ text: "北京,朋友。", width: 300, measure }); for (const glyph of hoverLayout.lines.flatMap((line) => line.glyphs)) { const hit = textInteractionAt(hoverLayout, glyph.x + glyph.width / 2, glyph.y + glyph.height / 2, () => true); assert.equal(hit?.kind ?? null, /\p{P}/u.test(glyph.text) ? null : "word"); } // Guide tolerance follows source font size, rather than screen pixels. A long // gloss marks the source projection; normal word-sized labels stay unadorned. const guideLayout = layoutText({ text: "书店", width: 500, measure, annotations: [{ ...pinyin[0], start: 0, end: 2, text: "a surprisingly long bookshop gloss" }], }); const guideBox = guideLayout.lines[0].overhead[0]; assert(guideBox.guide); assert.equal(guideBox.guide.start, guideLayout.lines[0].glyphs[0].x); assert.equal(guideBox.guide.end, guideLayout.lines[0].glyphs.at(-1)!.x + guideLayout.lines[0].glyphs.at(-1)!.width); const guides = labelGuideSegments(guideBox); assert.equal(guides.length, 2); for (const guide of guides) { assert( guide.end < guideBox.x || guide.start > guideBox.x + guideBox.width, "Alignment brackets never cross label text", ); assert.equal(guide.y, guideBox.y + guideBox.height / 2); assert(guide.cap > 0, "Above-text brackets point toward their source"); } assert( labelGuideSegments({ ...guideBox, annotation: { ...guideBox.annotation, placement: "below" } }).every( (guide) => guide.cap < 0, ), ); const shortGuide = layoutText({ text: "书店", width: 500, measure, annotations: [{ ...pinyin[0], start: 0, end: 2, text: "shop" }], }); assert.equal(shortGuide.lines[0].overhead[0].guide, undefined); assert.deepEqual(labelGuideSegments(shortGuide.lines[0].overhead[0]), []); draws.length = 0; drawText(context, hoverLayout, { hover: { start: 0, end: 6 } }); assert( draws.filter((item) => /\p{P}/u.test(item.text)).every((item) => item.color === "#252923"), "Reference emphasis leaves punctuation unchanged", ); console.log( "PASS above/below fixed lanes, source-page containment, isolated label hover, Unicode word targeting and font-relative alignment guides", ); // A sidebar reference is fallback emphasis. It must never mask the current // source pointer when the old panel remains hovered or disappears mid-request. const multipleRows = layoutText({ text: "北京。\n朋友。\n书店。\n读书。", width: 200, measure }); for (const range of [ { start: 4, end: 6 }, { start: 8, end: 10 }, { start: 12, end: 14 }, ]) { draws.length = 0; drawText(context, multipleRows, { hover: range, emphasis: { start: 0, end: 2 } }); const expected = Array.from(multipleRows.text.slice(range.start, range.end)); assert.deepEqual( draws.filter((item) => item.color === "#39745b").map((item) => item.text), expected, "Source hover on every row takes priority over an unrelated sidebar reference", ); } draws.length = 0; drawText(context, multipleRows, { emphasis: { start: 0, end: 2 } }); assert.deepEqual( draws.filter((item) => item.color === "#39745b").map((item) => item.text), ["北", "京"], "Sidebar reference still emphasizes source while the pointer is outside the canvas", ); console.log("PASS current source pointer takes priority over stale sidebar references across multiple rows");