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
+245
View File
@@ -0,0 +1,245 @@
import { layoutText } from "../src/canvas/layout.js";
import { canvasMeasure } from "../src/canvas/measure.js";
import { rectIntersects, type Annotation, type Font } from "../src/canvas/types.js";
import { drawText, drawUnderline } from "../src/canvas/draw.js";
import type { AnnotationBox, UnderlinePattern } from "../src/canvas/types.js";
export function sourceGeometry(
text: string,
width: number,
font: Font = { size: 23, family: '"Noto Serif CJK SC", "Songti SC", Georgia, serif' },
) {
const context = document.createElement("canvas").getContext("2d")!;
return layoutText({
text,
width,
font,
measure: canvasMeasure(context),
}).lines.flatMap((line) => line.glyphs);
}
export function verifyBrowserMetrics() {
const canvas = document.createElement("canvas"),
context = canvas.getContext("2d")!;
const text = " 阅读中文,👩🏽‍💻 café é。\n\n另一段阅读材料。";
let cases = 0;
for (const width of [30, 60, 120, 320, 640])
for (const size of [18, 23, 34])
for (const count of [2, 8, 30]) {
const annotations: Annotation[] = Array.from({ length: count }, (_, i) => ({
id: String(i),
feature: "same",
start: i % 5,
end: 6 + (i % 10),
style: "overhead",
text: i % 2 ? "Some long and overlapping explanatory text" : "阅读注解",
color: "#53796c",
fontSize: 10 + (i % 4) * 7,
}));
const layout = layoutText({
text,
width,
font: { size, family: '"Noto Serif CJK SC",Georgia,serif' },
annotations,
measure: canvasMeasure(context),
});
const boxes = layout.lines.flatMap((line) => line.overhead);
for (const line of layout.lines)
if (new Set(line.overhead.map((box) => box.baseline)).size > 1)
throw Error("One annotation source was split into multiple rows");
for (const box of boxes) {
if (box.x < 0 || box.x + box.width > width + 0.01) throw Error("Overhead box outside browser viewport");
for (const other of boxes)
if (box !== other && rectIntersects(box, other))
throw Error("Overhead text overlap with real browser metrics");
for (const line of layout.lines)
for (const glyph of line.glyphs)
if (rectIntersects(box, glyph)) throw Error("Overhead text overlaps source with browser metrics");
}
cases++;
}
const pinyin = ["cháng", "xiǎoyǔ", "zǒuchū", "dìtiě", "zhàn"].map(
(text, i): Annotation => ({
id: String(i),
feature: "pinyin",
start: i * 2,
end: i * 2 + 2,
style: "overhead",
text,
color: "green",
}),
);
const adjacent = layoutText({
text: "一场小雨走出地铁车站",
width: 640,
annotations: pinyin,
measure: canvasMeasure(context),
});
if (
adjacent.lines[0].overhead.length !== pinyin.length ||
new Set(adjacent.lines[0].overhead.map((box) => box.baseline)).size !== 1
)
throw Error("Adjacent pinyin zig-zags with real browser metrics");
const long =
"This is a very long sentence translation that should share source flow across several physical text lines.";
const twoSources = layoutText({
text: "一场小雨。",
width: 120,
annotations: [
...pinyin.slice(0, 2),
{
id: "sentence",
feature: "translation",
start: 0,
end: 5,
style: "overhead",
text: long,
color: "red",
priority: 1,
},
],
measure: canvasMeasure(context),
});
const sentence = twoSources.lines
.flatMap((line) => line.overhead)
.filter((box) => box.annotation.feature === "translation");
if (sentence.length < 3 || sentence.flatMap((box) => box.lines).join("") !== long)
throw Error("Long translation did not flow intact across text lines");
for (const line of twoSources.lines)
for (const feature of ["pinyin", "translation"])
if (
new Set(line.overhead.filter((box) => box.annotation.feature === feature).map((box) => box.baseline)).size > 1
)
throw Error("A word/sentence source gained a collision row");
return { cases, fixedRows: true, font: context.font };
}
import { readingPages } from "../src/canvas/pages.js";
export function sourcePages(text: string, width: number, height: number) {
const context = document.createElement("canvas").getContext("2d")!;
return readingPages(
layoutText({
text,
width,
font: { size: 23, family: '"Noto Serif CJK SC", "Songti SC", Georgia, serif' },
measure: canvasMeasure(context),
}),
height,
);
}
/** Exercise actual canvas rasterization: patterned strokes must stay in their
* band, include gaps/waves, and leave layout and selection geometry unchanged. */
export function verifyUnderlineStyles() {
const patterns: UnderlinePattern[] = ["solid", "dashed", "squiggly"];
const canvas = document.createElement("canvas");
canvas.width = 100;
canvas.height = 40;
const context = canvas.getContext("2d")!;
for (const pattern of patterns) {
context.clearRect(0, 0, canvas.width, canvas.height);
const box: AnnotationBox = {
x: 10,
y: 10,
width: 72,
height: 3,
layer: 0,
baseline: 0,
lines: [],
font: { family: "serif", size: 23 },
annotation: {
id: pattern,
feature: "test",
start: 0,
end: 1,
text: "",
style: "underline",
color: "#53796c",
underlinePattern: pattern,
},
};
drawUnderline(context, box);
const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data;
const columns = new Set<number>(),
rows = new Set<number>();
for (let y = 0; y < canvas.height; y++)
for (let x = 0; x < canvas.width; x++)
if (pixels[(y * canvas.width + x) * 4 + 3]) {
if (x < 10 || x >= 82 || y < 10 || y >= 13) throw Error("Underline escapes its decoration band");
columns.add(x);
rows.add(y);
}
if (columns.size < 30) throw Error("Underline was not drawn");
if (pattern === "solid" && columns.size !== 72) throw Error("Solid underline has gaps");
if (pattern === "dashed" && columns.size >= 72) throw Error("Dashed underline has no gaps");
if (pattern === "squiggly" && rows.size !== 3) throw Error("Squiggly underline is flat");
if (context.getLineDash().length) throw Error("Underline leaked its dash state");
}
const text = " Several words wrapping onto another line.\n\tA new paragraph.";
let geometry = "";
for (const pattern of patterns) {
const layout = layoutText({
text,
width: 120,
measure: canvasMeasure(context),
annotations: [
{
id: "underlined",
feature: "test",
start: 0,
end: text.length,
text: "",
style: "underline",
color: "#53796c",
underlinePattern: pattern,
},
],
});
const current = JSON.stringify(
layout.lines.map(({ glyphs, y, height, decorations }) => ({
glyphs,
y,
height,
decorations: decorations.map(({ x, y, width, height }) => ({ x, y, width, height })),
})),
);
if (geometry && current !== geometry) throw Error("Underline pattern changes layout");
geometry = current;
for (const line of layout.lines)
if (/^\s/u.test(line.glyphs[0]?.text ?? "")) throw Error("Rendered line begins with a space");
}
return { patterns: patterns.length, clipped: true, stableGeometry: true };
}
export function underlinePreview() {
const canvas = document.createElement("canvas");
canvas.width = 440;
canvas.height = 200;
const context = canvas.getContext("2d")!;
context.fillStyle = "#fafaf6";
context.fillRect(0, 0, canvas.width, canvas.height);
context.translate(20, 10);
const text = "Solid underline\nDashed underline\nSquiggly underline";
const starts = [0, text.indexOf("Dashed"), text.indexOf("Squiggly")];
const annotations = (["solid", "dashed", "squiggly"] as const).map(
(pattern, i): Annotation => ({
id: pattern,
feature: pattern,
start: starts[i],
end: i === 2 ? text.length : starts[i + 1] - 1,
text: "",
style: "underline",
color: "#53796c",
underlinePattern: pattern,
}),
);
drawText(
context,
layoutText({
text,
width: 400,
font: { family: "Georgia, serif", size: 23 },
measure: canvasMeasure(context),
annotations,
}),
);
return canvas.toDataURL();
}
+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");
+521
View File
@@ -0,0 +1,521 @@
import { configWithDefaults, type ConfigNode, type ConfigValues } from "../src/config-schema.js";
import { applyOptionEdits, optionEdits } from "../src/config-edits.js";
import assert from "node:assert/strict";
import {
HoverIntent,
sameScope,
processorMatches,
preparationQueue,
selectSuccessfulAnnotationJobs,
mergeCachedAnnotationEntries,
selectAnnotationRetryJobs,
annotationRetryStatus,
explicitAnnotationRetry,
} from "../src/work.js";
const oldProcessor = { key: "processor-v1:scope" };
assert(processorMatches(oldProcessor, "processor-v1"));
assert(
!processorMatches(oldProcessor, "processor-v2"),
"A checked processor edit invalidates saved results even when document and feature settings are unchanged",
);
assert(!processorMatches({ key: "processor-v10:scope" }, "processor-v1"));
const feature = {
processor: "definition",
generation: "focus",
scope: "word",
reveal: "hover",
configRevision: 2,
enabled: true,
};
assert(
sameScope(
{ sourceRevision: "s", configuration: "compute", featureRevision: 2, start: 0, end: 2, status: "ready" },
"s",
"compute",
{
start: 0,
end: 2,
},
),
);
assert(
!sameScope(
{ sourceRevision: "s", configuration: "different", featureRevision: 1, start: 0, end: 2, status: "ready" },
"s",
"compute",
{
start: 0,
end: 2,
},
),
);
const intent = new HoverIntent(10);
let calls: string[] = [];
intent.schedule(() => calls.push("old"));
intent.schedule(() => calls.push("new"));
await new Promise((resolve) => setTimeout(resolve, 30));
assert.deepEqual(calls, ["new"]);
intent.schedule(() => calls.push("left"));
intent.cancel();
await new Promise((resolve) => setTimeout(resolve, 30));
assert.deepEqual(calls, ["new"]);
intent.schedule(() => calls.push("unmounted"));
intent.cancel();
console.log("PASS heavy hover intent, cancellation, scopes and stale cache guards");
const scope = (key: string, configuration: string, start: number, local = false) => ({
key,
configuration,
range: { start, end: start + 10 },
local,
});
const empty = new Set<string>();
const a = scope("a", "pinyin", 0),
b = scope("b", "pinyin", 10),
c = scope("c", "pinyin", 20);
assert.deepEqual(
preparationQueue([a, b, c], { start: 0, end: 30 }, 0, empty, empty).admit,
[a, b],
"Independent sections of the same annotator can use both model slots",
);
const d = scope("d", "names", 0);
assert.deepEqual(
preparationQueue([a, b, c, d], { start: 0, end: 30 }, 0, empty, empty).admit,
[a, d],
"Features take turns when their scopes are equally close",
);
const full = preparationQueue([a, b, c], { start: 0, end: 30 }, 2, empty, empty);
assert.deepEqual(full.admit, []);
assert.deepEqual(full.waiting, [a, b, c], "Unadmitted scopes remain available for queued outlines");
assert.deepEqual(
preparationQueue([a, a, b, c], { start: 0, end: 30 }, 0, new Set(["b"]), new Set(["a"])).waiting,
[c],
"Cached ready or failed scopes, admitted intents and shared-computation duplicates do not get queued outlines",
);
assert.deepEqual(
preparationQueue([a, b, c], { start: 10, end: 20 }, 1, empty, empty).admit,
[b],
"Visible work takes the released slot before neighboring scopes",
);
const local = scope("local", "local", 20, true);
assert.deepEqual(
preparationQueue([a, local], { start: 0, end: 30 }, 2, empty, empty).admit,
[local],
"Local annotations do not wait for inference slots",
);
console.log("PASS queued outlines, bounded independent scope admission, fair features and cache exclusion");
import { presentAnnotations } from "../src/annotation-display.js";
const readings = [
{ start: 0, end: 2, text: "Běijīng", tag: "hsk:3" },
{ start: 2, end: 4, text: "xīngqīliù", tag: "hsk:1" },
];
assert.equal(presentAnnotations("pinyin", readings, { minimumLevel: "2" }).length, 1);
assert.equal(presentAnnotations("pinyin", readings, { minimumLevel: "0" }).length, 2);
assert.equal(
presentAnnotations("pinyinWord", readings, { minimumLevel: "8" }).length,
2,
"Hover includes easy words too",
);
assert.equal(
presentAnnotations("names", [{ ...readings[0], tag: "place" }], { placeColor: "#123456" })[0].color,
"#123456",
);
console.log("PASS local HSK filtering, all-word hover pinyin and category recoloring from raw cache");
import { renderAnnotations } from "../src/render-hooks.js";
import type { Annotation } from "../src/canvas/types.js";
const source = "🙂 Un mot littéraire.";
const semantic: Annotation = {
id: "item",
feature: "source",
start: 6,
end: 9,
text: "word",
style: "replace",
color: "#123456",
};
const outputs = renderAnnotations(semantic, source, [
{ style: "replace", reveal: "always" },
{ style: "color", reveal: "always" },
{ style: "tooltip", reveal: "hover", content: "source" },
]);
assert.deepEqual(
outputs.map(({ style, text, reveal }) => ({ style, text, reveal })),
[
{ style: "replace", text: "word", reveal: "always" },
{ style: "color", text: "word", reveal: "always" },
{ style: "tooltip", text: "mot", reveal: "hover" },
],
);
assert.equal(new Set(outputs.map((output) => output.id)).size, 3);
assert(outputs.every((output) => output.feature === semantic.feature && output.start === 6 && output.end === 9));
assert.equal(semantic.text, "word", "Rendering never mutates cached semantic data");
assert.deepEqual(renderAnnotations(semantic, source, undefined), [], "Missing channels never invent an appearance");
console.log("PASS one source rendered as replacement, color and original-text hover tooltip");
assert(
sameScope(
{
sourceRevision: "doc@1",
featureRevision: 4,
start: 0,
end: 6,
status: "ready",
configuration: JSON.stringify({ processor: "pinyin", scope: "word", generation: "section", options: {} }),
},
"doc@1",
JSON.stringify({ processor: "pinyin", scope: "word", options: {} }),
{ start: 0, end: 6 },
),
"Changing preparation timing reuses a persisted semantic result",
);
assert(processorMatches({ key: "verified-previous:light-fast:result" }, "current", ["verified-previous"]));
assert(!processorMatches({ key: "unknown:light-fast:result" }, "current", ["verified-previous"]));
const scopeConfiguration = JSON.stringify({ processor: "pinyin", scope: "word", options: {} });
const savedJob = (object: string, createdAt: string, items: unknown[], key = "current:scope") => ({
_camino: { ref: { object } },
configuration: scopeConfiguration,
data: {
key,
kind: "annotation",
status: "ready",
sourceRevision: "doc@1",
start: 0,
end: 6,
createdAt,
output: JSON.stringify({ items }),
},
});
const oldFacts = savedJob("old", "2026-10-01", [
{ start: 0, end: 2 },
{ start: 2, end: 4 },
]),
clearedFacts = savedJob("cleared", "2026-10-02", []),
narrowedFacts = savedJob("narrowed", "2026-10-03", [{ start: 0, end: 2 }]),
failedFacts = {
...savedJob("failed", "2026-10-04", []),
data: { ...oldFacts.data, status: "failed", createdAt: "2026-10-04" },
};
const accepts = (job: typeof oldFacts) => processorMatches(job.data, "current", ["verified-previous"]);
assert.deepEqual(
selectSuccessfulAnnotationJobs([oldFacts, clearedFacts], accepts),
[clearedFacts],
"An empty successful regeneration clears its old items",
);
assert.deepEqual(
selectSuccessfulAnnotationJobs([oldFacts, clearedFacts, narrowedFacts, failedFacts], accepts),
[narrowedFacts],
"A smaller successful output replaces the whole scope; failure does not erase success",
);
const oldAlias = {
...oldFacts,
data: { ...oldFacts.data, key: "verified-previous:light-fast:legacy" },
configuration: JSON.stringify({ processor: "pinyin", scope: "word", generation: "section", options: {} }),
};
const unknown = savedJob("unknown", "2026-10-05", [{ start: 4, end: 6 }], "unknown:scope");
assert.deepEqual(
selectSuccessfulAnnotationJobs([oldAlias, clearedFacts, unknown], accepts),
[clearedFacts],
"Verified revision aliases and old generation formatting describe the same semantic scope; unknown revisions cannot replace it",
);
const anotherRange = { ...oldFacts, data: { ...oldFacts.data, start: 6, end: 8 } },
anotherSource = { ...oldFacts, data: { ...oldFacts.data, sourceRevision: "doc@2" } },
anotherInput = {
...oldFacts,
configuration: JSON.stringify({ processor: "pinyin", scope: "word", options: { targetLanguage: "French" } }),
};
assert.equal(
selectSuccessfulAnnotationJobs([clearedFacts, anotherRange, anotherSource, anotherInput], accepts).length,
4,
"Different semantic inputs, ranges and sources retain independent results",
);
const canonical = mergeCachedAnnotationEntries([{ node: oldFacts }], [{ node: narrowedFacts }, { node: failedFacts }]);
assert.deepEqual(
canonical.map(({ node }) => node._camino.ref.object),
["old", "failed"],
"Indexed success takes precedence over newer attempt history for its exact key",
);
assert.deepEqual(
selectSuccessfulAnnotationJobs(
canonical.map(({ node }) => node),
accepts,
),
[oldFacts],
);
assert.deepEqual(
mergeCachedAnnotationEntries([{ node: clearedFacts }, { node: oldFacts }], []),
[{ node: clearedFacts }],
"Overlapping pagination snapshots retain one canonical pointer per key",
);
assert.deepEqual(
JSON.parse(oldFacts.data.output).items,
[
{ start: 0, end: 2 },
{ start: 2, end: 4 },
],
"Selection never mutates retained historical output",
);
console.log(
"PASS canonical cache pointers and whole-scope replacement, including empty/narrowed successes, aliases and failed retries",
);
const retryFeature = {
feature: "pinyin",
configuration: scopeConfiguration,
revision: "current",
aliases: ["verified-previous"],
styles: ["overhead"],
};
const retryAttempt = (object: string, status: string, start = 0, createdAt = "2026-10-04") => ({
...savedJob(object, createdAt, []),
data: { ...savedJob(object, createdAt, []).data, status, start, end: start + 6 },
});
const skipped = retryAttempt("skipped", "skipped"),
failed = retryAttempt("failed", "failed", 6),
uncertain = retryAttempt("uncertain", "uncertain", 12),
offPage = retryAttempt("off-page", "skipped", 30),
ready = retryAttempt("ready", "ready", 18),
queued = retryAttempt("queued", "queued", 24);
const retryHistory = [skipped, failed, uncertain, offPage, ready, queued];
const retryHistorySnapshot = structuredClone(retryHistory);
const visiblePage = { start: 0, end: 30 };
assert.deepEqual(
selectAnnotationRetryJobs(
retryHistory,
[retryFeature, { ...retryFeature, feature: "hover pinyin", styles: ["tooltip"] }],
"doc@1",
visiblePage,
).map(({ feature, job }) => [feature, job._camino.ref.object]),
[
["pinyin", "skipped"],
["pinyin", "failed"],
["pinyin", "uncertain"],
],
"Failed/skipped/uncertain visible scopes appear once across shared annotators; queued/ready/off-page scopes do not",
);
const legacySkipped = {
...skipped,
configuration: oldAlias.configuration,
data: { ...skipped.data, key: "verified-previous:legacy", createdAt: "2026-10-03" },
};
assert.equal(
selectAnnotationRetryJobs([legacySkipped, skipped], [retryFeature], "doc@1", visiblePage).length,
1,
"Verified aliases and legacy scheduling configuration deduplicate the same scope",
);
for (const status of ["ready", "queued"]) {
assert.deepEqual(
selectAnnotationRetryJobs(
[skipped, legacySkipped, retryAttempt("replacement", status, 0, "2026-10-05")],
[retryFeature],
"doc@1",
visiblePage,
),
[],
"Newer queued or successful work supersedes old errors",
);
}
assert.deepEqual(
selectAnnotationRetryJobs([skipped], [{ ...retryFeature, styles: ["sidebar"] }], "doc@1", visiblePage),
[],
);
assert.deepEqual(
selectAnnotationRetryJobs([skipped], [{ ...retryFeature, styles: ["underline", "sidebar"] }], "doc@1", visiblePage),
[],
"Multi-hook sidebar processors already have controls",
);
assert.deepEqual(
selectAnnotationRetryJobs(
[skipped],
[retryFeature, { ...retryFeature, feature: "notes", styles: ["sidebar"] }],
"doc@1",
visiblePage,
),
[],
"A shared sidebar renderer does not get duplicate controls",
);
assert.deepEqual(selectAnnotationRetryJobs([skipped], [retryFeature], "doc@2", visiblePage), []);
assert.deepEqual(selectAnnotationRetryJobs([skipped], [retryFeature], "doc@1", { start: 0, end: 0 }), []);
assert.deepEqual(
selectAnnotationRetryJobs(
[{ ...skipped, data: { ...skipped.data, kind: "variant" } }],
[retryFeature],
"doc@1",
visiblePage,
),
[],
);
assert.deepEqual(
selectAnnotationRetryJobs(
[{ ...unknown, data: { ...unknown.data, status: "failed" } }],
[retryFeature],
"doc@1",
visiblePage,
),
[],
);
assert.deepEqual(
selectAnnotationRetryJobs(
[{ ...anotherInput, data: { ...anotherInput.data, status: "failed" } }],
[retryFeature],
"doc@1",
visiblePage,
),
[],
);
assert.deepEqual(
retryHistory,
retryHistorySnapshot,
"Status selection preserves all cached values and attempt history",
);
assert.equal(
annotationRetryStatus("skipped", "AGENT_SETUP_UNAVAILABLE: Authentication required"),
"Setup was unavailable. Try again when connected.",
);
assert.equal(annotationRetryStatus("uncertain", "raw transport error"), "Preparation could not be confirmed.");
const retrying = new Set<string>();
let explicitCalls = 0,
finishRetry: () => void = () => {},
rerenders = 0;
const dispatch = (settled: () => void) => {
explicitCalls++;
// The view delegates the original scope with retry:true, without replanning.
const { feature, job } = selectAnnotationRetryJobs([skipped], [retryFeature], "doc@1", visiblePage)[0];
assert.deepEqual(
{ feature, start: job.data.start, end: job.data.end, retry: true },
{ feature: "pinyin", start: 0, end: 6, retry: true },
);
finishRetry = settled;
return true;
};
assert.equal(explicitCalls, 0, "Selecting failed statuses never dispatches inference");
assert(explicitAnnotationRetry("skipped", retrying, dispatch, () => rerenders++));
assert(!explicitAnnotationRetry("skipped", retrying, dispatch, () => rerenders++));
assert.equal(explicitCalls, 1, "Double clicks cannot repeat an in-flight retry");
finishRetry();
assert.equal(explicitCalls, 1, "Settlement never automatically retries");
assert.equal(retrying.size, 0);
assert.equal(rerenders, 2);
assert(
!explicitAnnotationRetry(
"not-ready",
retrying,
() => false,
() => {},
),
);
assert.equal(retrying.size, 0, "An unavailable operation releases the button");
assert.throws(() =>
explicitAnnotationRetry(
"rejected",
retrying,
() => {
throw Error("rejected");
},
() => {},
),
);
assert.equal(retrying.size, 0);
console.log(
"PASS explicit nearby annotator retries, shared identities/aliases, sidebar/off-page exclusion and superseded attempts",
);
// Two channels use independently declared structs, not a single feature type.
import { configDefaults } from "../src/config-schema.js";
const raw = { ...semantic, color: "" };
const multi = renderAnnotations(
raw,
source,
[
{ style: "underline", reveal: "always", options: "primary" },
{ style: "underline", reveal: "always", options: "secondary" },
{ style: "replace", reveal: "click" },
{ style: "tooltip", reveal: "hover", content: "source" },
],
{
primary: { color: "#112233", underlinePattern: "squiggly" },
secondary: { color: "#445566", underlinePattern: "dashed" },
},
);
assert.deepEqual(
multi.slice(0, 2).map((a) => [a.color, a.underlinePattern]),
[
["#112233", "squiggly"],
["#445566", "dashed"],
],
);
const changed = renderAnnotations(raw, source, [{ style: "underline", reveal: "always", options: "primary" }], {
primary: { color: "#112233", underlinePattern: "solid" },
});
assert.equal(changed[0].underlinePattern, "solid");
assert.equal(raw.text, "word");
assert.throws(
() =>
renderAnnotations(raw, source, [{ style: "overhead", reveal: "always", options: "label" }], {
label: { fontSize: 500 },
}),
/Invalid annotator render channel/,
);
assert.throws(
() => renderAnnotations(raw, source, [{ style: "overhead", reveal: "hover" }]),
/Invalid annotator render channel/,
);
assert.deepEqual(
configDefaults([
{
kind: "group",
key: "underlines",
label: "Underlines",
fields: [{ kind: "select", key: "pattern", label: "Pattern", default: "dashed" }],
},
]),
{ underlines: { pattern: "dashed" } },
);
console.log("PASS code-owned settings bind independent render channels without replacing semantic data");
// Two group controls may fire from the same render before either query update.
const shown = { label: { fontSize: 13, fontFamily: "serif", color: "#112233" } };
const sizeEdit = optionEdits(shown.label, { ...shown.label, fontSize: 20 }, ["label"]);
const fontEdit = optionEdits(shown.label, { ...shown.label, fontFamily: "monospace" }, ["label"]);
const concurrent = { ...shown, label: { ...shown.label, color: "#334455" } };
assert.deepEqual(
applyOptionEdits(applyOptionEdits(concurrent, sizeEdit), fontEdit),
{
label: { fontSize: 20, fontFamily: "monospace", color: "#334455" },
},
"Queued group edits preserve each other and a concurrent change to another leaf",
);
assert.equal(shown.label.fontSize, 13, "Applying an edit never mutates the displayed snapshot");
// New controls can have defaults without their values being stored yet.
const labelFields: ConfigNode[] = [
{
kind: "group",
key: "label",
label: "Label",
fields: [
{ kind: "number", key: "fontSize", label: "Size", default: 13 },
{ kind: "text", key: "fontFamily", label: "Font", default: "serif" },
{ kind: "color", key: "color", label: "Color", default: "#112233" },
],
},
];
const sparseOptions: ConfigValues[] = [{}, { label: { color: "#556677" } }];
for (const persisted of sparseOptions) {
const effective = configWithDefaults(labelFields, persisted);
const label = effective.label as ConfigValues;
const size = optionEdits(label, { ...label, fontSize: 20 }, ["label"]);
const font = optionEdits(label, { ...label, fontFamily: "monospace" }, ["label"]);
const latest = { label: { color: "#abcdef" }, unrelated: "preserved" };
assert.deepEqual(
applyOptionEdits(applyOptionEdits(latest, size), font),
{
label: { color: "#abcdef", fontSize: 20, fontFamily: "monospace" },
unrelated: "preserved",
},
"Defaults in sparse groups must not undo queued edits or a concurrent color change",
);
}