Export template source from 488f7e229afed8f03fc3bfa4abe707da0c613460:examples/local-first/packages/language-reader-presentation
This commit is contained in:
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user