05-完整代码预览
"use client";
// 从你的库导入核心函数(确保路径正确)
import { prepareWithSegments } from "@chenglou/pretext";
import { useCallback, useEffect, useRef } from "react";
// 固定文本内容
const PARAGRAPHS = [
`The relationship between typographic colour and reading comfort has been studied extensively since the early twentieth century. When lines of justified text contain excessive inter-word spacing, the eye perceives pale horizontal streaks — "rivers" — that cut vertically through the paragraph, disrupting the smooth lateral scanning motion that skilled readers depend upon. These rivers are not merely an aesthetic blemish; they constitute a measurable impediment to reading speed and comprehension.`,
`Traditional typesetting systems addressed this problem through a combination of techniques: hyphenation dictionaries that permitted words to break at syllable boundaries, letterspacing adjustments that distributed small amounts of additional space between individual characters, and — most significantly — global optimization algorithms that evaluated thousands of possible line-break combinations to find the arrangement minimizing total spacing deviation across the entire paragraph.`,
`The Knuth-Plass algorithm, developed by Donald Knuth and Michael Plass for the TeX typesetting system in 1981, remains the gold standard for paragraph optimization. Rather than greedily filling each line from left to right, the algorithm constructs a graph of all feasible breakpoints and finds the shortest path — the combination of breaks that produces the most uniform spacing throughout. Even a simplified implementation produces dramatically better results than the greedy approach used by web browsers and most word processors.`,
`Modern CSS justification operates on a strictly greedy, line-by-line basis: the browser fills each line with as many words as will fit, then distributes the remaining space uniformly between words. This approach requires no lookahead and executes quickly, but it produces wildly inconsistent spacing — particularly in narrow columns where a single long word can force enormous gaps across the preceding line. The result: rivers of white space that would have horrified any compositor working with metal type.`,
];
// 连字符拆分词典
const HYPHEN_EXCEPTIONS = {
extensively: ["ex", "ten", "sive", "ly"],
relationship: ["re", "la", "tion", "ship"],
typographic: ["ty", "po", "graph", "ic"],
comfortable: ["com", "fort", "a", "ble"],
horizontal: ["hor", "i", "zon", "tal"],
vertically: ["ver", "ti", "cal", "ly"],
disrupting: ["dis", "rupt", "ing"],
comprehension: ["com", "pre", "hen", "sion"],
traditional: ["tra", "di", "tion", "al"],
combination: ["com", "bi", "na", "tion"],
techniques: ["tech", "niques"],
hyphenation: ["hy", "phen", "a", "tion"],
dictionaries: ["dic", "tion", "ar", "ies"],
permitted: ["per", "mit", "ted"],
syllable: ["syl", "la", "ble"],
boundaries: ["bound", "a", "ries"],
letterspacing: ["let", "ter", "spac", "ing"],
adjustments: ["ad", "just", "ments"],
distributed: ["dis", "trib", "u", "ted"],
additional: ["ad", "di", "tion", "al"],
individual: ["in", "di", "vid", "u", "al"],
characters: ["char", "ac", "ters"],
significantly: ["sig", "nif", "i", "cant", "ly"],
optimization: ["op", "ti", "mi", "za", "tion"],
evaluated: ["e", "val", "u", "at", "ed"],
thousands: ["thou", "sands"],
possible: ["pos", "si", "ble"],
arrangement: ["ar", "range", "ment"],
minimizing: ["min", "i", "miz", "ing"],
deviation: ["de", "vi", "a", "tion"],
paragraph: ["par", "a", "graph"],
algorithm: ["al", "go", "rithm"],
developed: ["de", "vel", "oped"],
typesetting: ["type", "set", "ting"],
constructs: ["con", "structs"],
feasible: ["fea", "si", "ble"],
breakpoints: ["break", "points"],
produces: ["pro", "du", "ces"],
uniform: ["u", "ni", "form"],
throughout: ["through", "out"],
simplified: ["sim", "pli", "fied"],
implementation: ["im", "ple", "men", "ta", "tion"],
dramatically: ["dra", "mat", "i", "cal", "ly"],
processors: ["proc", "es", "sors"],
justification: ["jus", "ti", "fi", "ca", "tion"],
operates: ["op", "er", "ates"],
strictly: ["strict", "ly"],
distributes: ["dis", "trib", "utes"],
remaining: ["re", "main", "ing"],
uniformly: ["u", "ni", "form", "ly"],
requires: ["re", "quires"],
lookahead: ["look", "a", "head"],
executes: ["ex", "e", "cutes"],
quickly: ["quick", "ly"],
inconsistent: ["in", "con", "sis", "tent"],
particularly: ["par", "tic", "u", "lar", "ly"],
enormous: ["e", "nor", "mous"],
preceding: ["pre", "ced", "ing"],
compositor: ["com", "pos", "i", "tor"],
twentieth: ["twen", "ti", "eth"],
century: ["cen", "tu", "ry"],
perceived: ["per", "ceived"],
streaks: ["streaks"],
scanning: ["scan", "ning"],
impediment: ["im", "ped", "i", "ment"],
addressed: ["ad", "dressed"],
combinations: ["com", "bi", "na", "tions"],
measuring: ["meas", "ur", "ing"],
measurable: ["meas", "ur", "a", "ble"],
reading: ["read", "ing"],
spacing: ["spac", "ing"],
between: ["be", "tween"],
excessive: ["ex", "ces", "sive"],
aesthetic: ["aes", "thet", "ic"],
merely: ["mere", "ly"],
constitute: ["con", "sti", "tute"],
lateral: ["lat", "er", "al"],
skilled: ["skilled"],
readers: ["read", "ers"],
depend: ["de", "pend"],
studying: ["stud", "y", "ing"],
studied: ["stud", "ied"],
comfort: ["com", "fort"],
colour: ["col", "our"],
working: ["work", "ing"],
horrified: ["hor", "ri", "fied"],
especially: ["es", "pe", "cial", "ly"],
precisely: ["pre", "cise", "ly"],
browsers: ["brows", "ers"],
modern: ["mod", "ern"],
approach: ["ap", "proach"],
wildly: ["wild", "ly"],
columns: ["col", "umns"],
single: ["sin", "gle"],
standard: ["stan", "dard"],
Michael: ["Mi", "cha", "el"],
Donald: ["Don", "ald"],
remains: ["re", "mains"],
system: ["sys", "tem"],
rather: ["rath", "er"],
greedily: ["greed", "i", "ly"],
filling: ["fill", "ing"],
shortest: ["short", "est"],
results: ["re", "sults"],
greedy: ["greed", "y"],
number: ["num", "ber"],
completely: ["com", "plete", "ly"],
different: ["dif", "fer", "ent"],
problem: ["prob", "lem"],
amounts: ["a", "mounts"],
entire: ["en", "tire"],
global: ["glob", "al"],
metal: ["met", "al"],
every: ["ev", "ery"],
inter: ["in", "ter"],
};
const PREFIXES = [
"anti",
"auto",
"be",
"bi",
"co",
"com",
"con",
"contra",
"counter",
"de",
"dis",
"en",
"em",
"ex",
"extra",
"fore",
"hyper",
"il",
"im",
"in",
"inter",
"intra",
"ir",
"macro",
"mal",
"micro",
"mid",
"mis",
"mono",
"multi",
"non",
"omni",
"out",
"over",
"para",
"poly",
"post",
"pre",
"pro",
"pseudo",
"quasi",
"re",
"retro",
"semi",
"sub",
"super",
"sur",
"syn",
"tele",
"trans",
"tri",
"ultra",
"un",
"under",
];
const SUFFIXES = [
"able",
"ible",
"tion",
"sion",
"ment",
"ness",
"ous",
"ious",
"eous",
"ful",
"less",
"ive",
"ative",
"itive",
"al",
"ial",
"ical",
"ical",
"ing",
"ling",
"ed",
"er",
"est",
"ism",
"ist",
"ity",
"ety",
"ty",
"ence",
"ance",
"ly",
"fy",
"ify",
"ize",
"ise",
"ure",
"ture",
];
export default function OptimalTypography() {
const canvasRef = useRef<HTMLCanvasElement>(null);
// 排版样式配置
const FONT_FAMILY = 'Georgia, "Times New Roman", serif';
const FONT_SIZE = 15;
const LINE_HEIGHT = 24;
const FONT = `${FONT_SIZE}px ${FONT_FAMILY}`;
const PAD = 12;
const PARA_GAP = LINE_HEIGHT * 0.6;
// 单词连字符拆分
const hyphenateWord = (word: string) => {
const lower = word.toLowerCase().replace(/[.,;:!?"'""''—–-]/g, "");
if (lower.length < 5) return [word];
const exc = HYPHEN_EXCEPTIONS[lower as keyof typeof HYPHEN_EXCEPTIONS];
if (exc) {
const parts: string[] = [];
let pos = 0;
for (const part of exc) {
parts.push(word.slice(pos, pos + part.length));
pos += part.length;
}
if (pos < word.length) parts[parts.length - 1] += word.slice(pos);
return parts.filter((p) => p.length > 0);
}
for (const prefix of PREFIXES) {
if (lower.startsWith(prefix) && lower.length - prefix.length >= 3) {
return [word.slice(0, prefix.length), word.slice(prefix.length)];
}
}
for (const suffix of SUFFIXES) {
if (lower.endsWith(suffix) && lower.length - suffix.length >= 3) {
const cut = word.length - suffix.length;
return [word.slice(0, cut), word.slice(cut)];
}
}
return [word];
};
// 画布高清适配
const setupCanvas = (canvas: HTMLCanvasElement, w: number, h: number) => {
const dpr = window.devicePixelRatio || 1;
canvas.width = w * dpr;
canvas.height = h * dpr;
canvas.style.width = w + "px";
canvas.style.height = h + "px";
const ctx = canvas.getContext("2d")!;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
return ctx;
};
// 最优排版算法(Knuth-Plass)
const optimalLayout = (
prepared: { segments: string[]; widths: number[] },
maxWidth: number,
NORMAL_SPACE_W: number,
HYPHEN_WIDTH: number,
) => {
const segs = prepared.segments;
const widths = prepared.widths;
const n = segs.length;
if (n === 0) return [];
const breakCandidates = [{ segIndex: 0, isSoftHyphen: false }];
for (let i = 0; i < n; i++) {
const text = segs[i];
if (text === "") {
if (i + 1 < n)
breakCandidates.push({ segIndex: i + 1, isSoftHyphen: true });
} else if (text.trim().length === 0 && i + 1 < n) {
breakCandidates.push({ segIndex: i + 1, isSoftHyphen: false });
}
}
breakCandidates.push({ segIndex: n, isSoftHyphen: false });
const numCandidates = breakCandidates.length;
const getLineInfo = (fromIdx: number, toIdx: number) => {
const from = breakCandidates[fromIdx].segIndex;
const to = breakCandidates[toIdx].segIndex;
const endsWithHyphen = breakCandidates[toIdx].isSoftHyphen;
let wordWidth = 0,
spaceCount = 0;
for (let si = from; si < to; si++) {
const t = segs[si];
if (t === "") continue;
t.trim().length === 0 ? spaceCount++ : (wordWidth += widths[si]);
}
if (to > from && segs[to - 1].trim().length === 0) spaceCount--;
if (endsWithHyphen) wordWidth += HYPHEN_WIDTH;
return { wordWidth, spaceCount, endsWithHyphen };
};
const lineBadness = (info: any, isLastLine: boolean) => {
if (isLastLine) return info.wordWidth > maxWidth ? 1e8 : 0;
if (info.spaceCount <= 0) {
const slack = maxWidth - info.wordWidth;
return slack < 0 ? 1e8 : slack * slack * 10;
}
const js = (maxWidth - info.wordWidth) / info.spaceCount;
if (js < 0 || js < NORMAL_SPACE_W * 0.4) return 1e8;
const ratio = (js - NORMAL_SPACE_W) / NORMAL_SPACE_W;
const badness = Math.abs(ratio) ** 3 * 1000;
const river = Math.max(0, js / NORMAL_SPACE_W - 1.5);
const tight = Math.max(0, NORMAL_SPACE_W * 0.65 - js);
return (
badness +
(river ** 2 * 1e4 + 5000) +
(tight ** 2 * 1e4 + 3000) +
(info.endsWithHyphen ? 50 : 0)
);
};
const dp = new Array(numCandidates).fill(Infinity);
const prev = new Array(numCandidates).fill(-1);
dp[0] = 0;
for (let j = 1; j < numCandidates; j++) {
const last = j === numCandidates - 1;
for (let i = j - 1; i >= 0; i--) {
if (dp[i] === Infinity) continue;
const info = getLineInfo(i, j);
const total = info.wordWidth + info.spaceCount * NORMAL_SPACE_W;
if (total > maxWidth * 2) break;
const cost = dp[i] + lineBadness(info, last);
if (cost < dp[j]) {
dp[j] = cost;
prev[j] = i;
}
}
}
const breaks: number[] = [];
let cur = numCandidates - 1;
while (cur > 0) {
if (prev[cur] === -1) {
cur--;
continue;
}
breaks.push(cur);
cur = prev[cur];
}
breaks.reverse();
const lines: any[] = [];
let from = 0;
for (const toIdx of breaks) {
const f = breakCandidates[from].segIndex;
const t = breakCandidates[toIdx].segIndex;
const hyphen = breakCandidates[toIdx].isSoftHyphen;
const last = toIdx === numCandidates - 1;
const segsLine: any[] = [];
for (let si = f; si < t; si++) {
const text = segs[si];
if (text === "") continue;
segsLine.push({
text,
width: widths[si],
isSpace: text.trim().length === 0,
});
}
if (hyphen)
segsLine.push({ text: "-", width: HYPHEN_WIDTH, isSpace: false });
while (segsLine.length && segsLine.at(-1).isSpace) segsLine.pop();
const lw = segsLine.reduce((s, c) => s + c.width, 0);
lines.push({
segments: segsLine,
y: 0,
maxWidth,
isLast: last,
lineWidth: lw,
});
from = toIdx;
}
return lines;
};
// 渲染到画布
const renderCanvas = useCallback(async () => {
const canvas = canvasRef.current;
if (!canvas) return;
await document.fonts.ready;
const containerWidth = canvas.parentElement!.clientWidth || 400;
const colWidth = Math.min(containerWidth, 1024);
const innerWidth = colWidth - PAD * 2;
// 测量
const m = document.createElement("canvas").getContext("2d")!;
m.font = FONT;
const NORMAL_SPACE_W = m.measureText(" ").width;
const HYPHEN_WIDTH = m.measureText("-").width;
// 预处理文本(添加软连字符)
const prepared = PARAGRAPHS.map((p) => {
const tokens = p.split(/(\s+)/);
const hyphenated = tokens
.map((t) => {
if (/^\s+$/.test(t)) return t;
const parts = hyphenateWord(t);
return parts.length <= 1 ? t : parts.join("");
})
.join("");
return prepareWithSegments(hyphenated, FONT);
});
// 生成最优排版
const allLines = prepared.map((p) =>
optimalLayout(p, innerWidth, NORMAL_SPACE_W, HYPHEN_WIDTH),
);
// 计算总高度
let y = PAD;
allLines.forEach((para, i) => {
para.forEach(() => (y += LINE_HEIGHT));
if (i < allLines.length - 1) y += PARA_GAP;
});
const totalHeight = y + PAD;
// 绘制
const ctx = setupCanvas(canvas, colWidth, totalHeight);
ctx.fillStyle = "#fff";
ctx.fillRect(0, 0, colWidth, totalHeight);
ctx.save();
ctx.beginPath();
ctx.rect(0, 0, colWidth, totalHeight);
ctx.clip();
ctx.font = FONT;
ctx.textBaseline = "top";
let curY = PAD;
allLines.forEach((para) => {
para.forEach((line) => {
line.y = curY;
curY += LINE_HEIGHT;
const justify = !line.isLast && line.lineWidth >= innerWidth * 0.6;
if (!justify) {
let x = PAD;
line.segments.forEach((s: any) => {
if (!s.isSpace) ctx.fillText(s.text, x, line.y);
x += s.width;
});
return;
}
let w = 0,
sp = 0;
line.segments.forEach((s: any) => (s.isSpace ? sp++ : (w += s.width)));
const js = Math.max((innerWidth - w) / sp, NORMAL_SPACE_W * 0.75);
let x = PAD;
line.segments.forEach((s: any) => {
if (s.isSpace) x += js;
else {
ctx.fillStyle = "#222";
ctx.fillText(s.text, x, line.y);
x += s.width;
}
});
});
});
ctx.restore();
}, []);
// 生命周期
useEffect(() => {
renderCanvas();
const resize = () => renderCanvas();
window.addEventListener("resize", resize);
return () => window.removeEventListener("resize", resize);
}, [renderCanvas]);
return (
<div className="w-full max-w-5xl mx-auto bg-white pt-2">
<canvas ref={canvasRef} className="w-full h-auto" />
</div>
);
}