Export template source from e33714a5ca38429d1bdb77a961a14461697944f5:examples/local-first/packages/language-reader-presentation

This commit is contained in:
Quixos Template Publisher
2000-01-01 00:00:00 +00:00
commit 8f327c9ac8
63 changed files with 10474 additions and 0 deletions
+925
View File
@@ -0,0 +1,925 @@
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");