Files

926 lines
37 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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");