From 114257d1a870e211c94854c77578fe01b0f7fe74 Mon Sep 17 00:00:00 2001 From: EzraEn <7076802+ezra-en@users.noreply.github.com> Date: Wed, 8 Apr 2026 03:06:30 +0800 Subject: [PATCH] demo: add Knuth-Plass optimal line-breaking with fitness classification --- pages/demos/index.html | 5 + pages/demos/optimal-line-breaking.html | 217 ++++++++++++++ pages/demos/optimal-line-breaking.model.ts | 328 +++++++++++++++++++++ pages/demos/optimal-line-breaking.ts | 135 +++++++++ pages/demos/optimal-line-breaking.ui.ts | 124 ++++++++ 5 files changed, 809 insertions(+) create mode 100644 pages/demos/optimal-line-breaking.html create mode 100644 pages/demos/optimal-line-breaking.model.ts create mode 100644 pages/demos/optimal-line-breaking.ts create mode 100644 pages/demos/optimal-line-breaking.ui.ts diff --git a/pages/demos/index.html b/pages/demos/index.html index c03d4a80..9fdeee6e 100644 --- a/pages/demos/index.html +++ b/pages/demos/index.html @@ -141,6 +141,11 @@

Markdown Chat

Masonry

A text-card occlusion demo where height prediction comes from Pretext instead of DOM reads.

+ + +

Optimal Line Breaking

+

Full Knuth-Plass with badness, penalties, fitness classification, and river detection.

+
diff --git a/pages/demos/optimal-line-breaking.html b/pages/demos/optimal-line-breaking.html new file mode 100644 index 00000000..96756084 --- /dev/null +++ b/pages/demos/optimal-line-breaking.html @@ -0,0 +1,217 @@ + + + + + + + Optimal Line Breaking — Pretext Demo + + + +
+ Built with Pretext +
+ +
+

Optimal Line Breaking

+

+ A full Knuth-Plass implementation showing dynamic programming for optimal line breaks, + badness calculation, fitness classification, and typography river detection. +

+ +
+ + + 460px + + + +
+ +
+ + + +
+ +
+
+ +
+
+ +
+

Legend

+
+
+ Badness indicator (red = higher badness) +
+
+
+ Tight line (< 65% natural space) +
+
+
+ Decent line (65% - 100% natural space) +
+
+
+ Loose line (100% - 150% natural space) +
+
+
+ Very loose line (> 150% natural space, possible river) +
+
+ +
+

About Knuth-Plass Line Breaking

+

+ The Knuth-Plass algorithm finds the optimal line breaks by treating text layout as a + dynamic programming problem. It evaluates all possible break points and chooses the + sequence that minimizes total "badness" — a measure of how far each line deviates + from the ideal inter-word spacing. +

+

+ Badness formula: (slack / lineWidth)³ × 1000 — cubic + penalty that heavily disfavors very tight or very loose lines. +

+

+ Penalties: River gaps (+5000 when spaces align vertically), + tight lines (+3000), and hyphenation (+50) all increase badness. +

+

+ Fitness classification: Lines are categorized as tight (≤65% stretch), + decent, loose (100-150%), or very loose (>150%, indicating potential rivers). +

+
+
+ + + + + + diff --git a/pages/demos/optimal-line-breaking.model.ts b/pages/demos/optimal-line-breaking.model.ts new file mode 100644 index 00000000..a20a44bf --- /dev/null +++ b/pages/demos/optimal-line-breaking.model.ts @@ -0,0 +1,328 @@ +import { + prepareWithSegments, + type PreparedTextWithSegments, +} from '../../src/layout.ts' + +export type DemoControls = { + colWidth: number + showMetrics: boolean + showBadness: boolean + showFitness: boolean +} + +export type DemoResources = { + preparedParagraphs: PreparedTextWithSegments[] + normalSpaceWidth: number + hyphenWidth: number +} + +type FitnessClass = 'tight' | 'decent' | 'loose' | 'very-loose' + +type BreakKind = 'start' | 'space' | 'soft-hyphen' | 'end' + +interface BreakCandidate { + segIndex: number + kind: BreakKind +} + +interface LineStats { + wordWidth: number + spaceCount: number + naturalWidth: number + trailingMarker: 'none' | 'soft-hyphen' + justifiedSpace: number + fitness: FitnessClass + badness: number +} + +interface BreakAnalysis { + candidates: BreakCandidate[] + lineStats: LineStats[] + totalBadness: number + breakIndices: number[] +} + +const HUGE_BADNESS = 1e9 +const SOFT_HYPHEN = '\u00AD' +const RIVER_THRESHOLD = 1.5 +const INFEASIBLE_SPACE_RATIO = 0.4 +const TIGHT_SPACE_RATIO = 0.65 +const LOOSE_SPACE_RATIO = 1.5 +const HYPHEN_PENALTY = 50 + +export type LineSegment = + | { kind: 'text'; text: string; width: number } + | { kind: 'space'; width: number } + +export type MeasuredLine = { + segments: LineSegment[] + wordWidth: number + spaceCount: number + naturalWidth: number + ending: 'paragraph-end' | 'wrap' + trailingMarker: 'none' | 'soft-hyphen' + badness: number + fitness: FitnessClass + justifiedSpace: number + breakKind: BreakKind +} + +function isSpaceText(text: string): boolean { + return text.trim().length === 0 +} + +function identifyBreakCandidates(segments: string[]): BreakCandidate[] { + const candidates: BreakCandidate[] = [{ segIndex: 0, kind: 'start' }] + const n = segments.length + + for (let i = 0; i < n; i++) { + const text = segments[i]! + if (text === SOFT_HYPHEN) { + if (i + 1 < n) { + candidates.push({ segIndex: i + 1, kind: 'soft-hyphen' }) + } + continue + } + if (isSpaceText(text) && i + 1 < n) { + candidates.push({ segIndex: i + 1, kind: 'space' }) + } + } + + candidates.push({ segIndex: n, kind: 'end' }) + return candidates +} + +function computeLineStats( + segments: string[], + widths: number[], + candidates: BreakCandidate[], + fromIdx: number, + toIdx: number, + normalSpaceWidth: number +): LineStats { + const from = candidates[fromIdx]!.segIndex + const to = candidates[toIdx]!.segIndex + const trailingMarker: 'none' | 'soft-hyphen' = + candidates[toIdx]!.kind === 'soft-hyphen' ? 'soft-hyphen' : 'none' + const isLastLine = candidates[toIdx]!.kind === 'end' + + let wordWidth = 0 + let spaceCount = 0 + + for (let i = from; i < to; i++) { + const text = segments[i]! + if (text === SOFT_HYPHEN) continue + if (isSpaceText(text)) { + spaceCount++ + continue + } + wordWidth += widths[i]! + } + + if (to > from && isSpaceText(segments[to - 1]!)) { + spaceCount-- + } + + const naturalWidth = wordWidth + spaceCount * normalSpaceWidth + + let justifiedSpace = normalSpaceWidth + let fitness: FitnessClass = 'decent' + let badness = 0 + + if (isLastLine) { + badness = wordWidth > candidates[toIdx]!.segIndex ? HUGE_BADNESS : 0 + } else if (spaceCount <= 0) { + const slack = naturalWidth - wordWidth + badness = slack < 0 ? HUGE_BADNESS : slack * slack * 10 + } else { + justifiedSpace = (naturalWidth - wordWidth + spaceCount * normalSpaceWidth - wordWidth) / spaceCount + + if (justifiedSpace < normalSpaceWidth * INFEASIBLE_SPACE_RATIO) { + badness = HUGE_BADNESS + } else { + const ratio = (justifiedSpace - normalSpaceWidth) / normalSpaceWidth + const absRatio = Math.abs(ratio) + badness = absRatio * absRatio * absRatio * 1000 + + const riverExcess = justifiedSpace / normalSpaceWidth - RIVER_THRESHOLD + if (riverExcess > 0) badness += 5000 + riverExcess * riverExcess * 10000 + + const tightThreshold = normalSpaceWidth * TIGHT_SPACE_RATIO + if (justifiedSpace < tightThreshold) { + badness += 3000 + (tightThreshold - justifiedSpace) * (tightThreshold - justifiedSpace) * 10000 + } + + if (trailingMarker === 'soft-hyphen') badness += HYPHEN_PENALTY + } + + const ratio = justifiedSpace / normalSpaceWidth + if (ratio < TIGHT_SPACE_RATIO) fitness = 'tight' + else if (ratio > LOOSE_SPACE_RATIO) fitness = 'very-loose' + else if (ratio > 1.0) fitness = 'loose' + } + + return { wordWidth, spaceCount, naturalWidth, trailingMarker, justifiedSpace, fitness, badness } +} + +export function analyzeParagraphOptimal( + prepared: PreparedTextWithSegments, + maxWidth: number +): BreakAnalysis { + const segments = prepared.segments as unknown as string[] + const widths = prepared.widths as unknown as number[] + const n = segments.length + + if (n === 0) return { candidates: [], lineStats: [], totalBadness: 0, breakIndices: [] } + + const normalSpaceWidth = (() => { + for (let i = 0; i < n; i++) { + if (isSpaceText(segments[i]!)) return widths[i]! + } + return 8 + })() + + const candidates = identifyBreakCandidates(segments) + const m = candidates.length + + const dp: number[] = new Array(m).fill(Infinity) + const previous: number[] = new Array(m).fill(-1) + dp[0] = 0 + + for (let toCandidate = 1; toCandidate < m; toCandidate++) { + for (let fromCandidate = toCandidate - 1; fromCandidate >= 0; fromCandidate--) { + if (dp[fromCandidate] === Infinity) continue + + const lineStats = computeLineStats( + segments, + widths, + candidates, + fromCandidate, + toCandidate, + normalSpaceWidth + ) + + if (lineStats.naturalWidth > maxWidth * 2) break + + const totalBadness = dp[fromCandidate]! + lineStats.badness + if (totalBadness < dp[toCandidate]!) { + dp[toCandidate] = totalBadness + previous[toCandidate] = fromCandidate + } + } + } + + const lineStats: LineStats[] = [] + const breakIndices: number[] = [] + + if (dp[m - 1] !== Infinity) { + let current = m - 1 + while (current > 0) { + if (previous[current] !== -1) { + breakIndices.push(current) + const stats = computeLineStats( + segments, + widths, + candidates, + previous[current]!, + current, + normalSpaceWidth + ) + lineStats.unshift(stats) + } + current = previous[current]! + if (current === -1) break + } + breakIndices.reverse() + } + + return { + candidates, + lineStats, + totalBadness: dp[m - 1] === Infinity ? Infinity : dp[m - 1]!, + breakIndices + } +} + +export function layoutParagraphOptimal( + prepared: PreparedTextWithSegments, + maxWidth: number, + normalSpaceWidth: number +): MeasuredLine[] { + const segments = prepared.segments as unknown as string[] + const widths = prepared.widths as unknown as number[] + const n = segments.length + + if (n === 0) return [] + + const analysis = analyzeParagraphOptimal(prepared, maxWidth) + if (analysis.breakIndices.length === 0) return [] + + const lines: MeasuredLine[] = [] + let fromCandidate = 0 + + for (let i = 0; i < analysis.breakIndices.length; i++) { + const toCandidate = analysis.breakIndices[i]! + const from = analysis.candidates[fromCandidate]!.segIndex + const to = analysis.candidates[toCandidate]!.segIndex + const ending: 'paragraph-end' | 'wrap' = analysis.candidates[toCandidate]!.kind === 'end' ? 'paragraph-end' : 'wrap' + const trailingMarker: 'none' | 'soft-hyphen' = analysis.candidates[toCandidate]!.kind === 'soft-hyphen' ? 'soft-hyphen' : 'none' + + const lineSegments: LineSegment[] = [] + for (let j = from; j < to; j++) { + const text = segments[j]! + if (text === SOFT_HYPHEN) continue + if (isSpaceText(text)) { + lineSegments.push({ kind: 'space', width: widths[j]! }) + } else { + lineSegments.push({ kind: 'text', text, width: widths[j]! }) + } + } + + if (trailingMarker === 'soft-hyphen' && ending === 'wrap') { + lineSegments.push({ kind: 'text', text: '-', width: normalSpaceWidth * 0.4 }) + } + + while (lineSegments.length > 0 && lineSegments[lineSegments.length - 1]!.kind === 'space') { + lineSegments.pop() + } + + const stats = analysis.lineStats[i]! + + lines.push({ + segments: lineSegments, + wordWidth: stats.wordWidth, + spaceCount: stats.spaceCount, + naturalWidth: stats.naturalWidth, + ending, + trailingMarker, + badness: stats.badness, + fitness: stats.fitness, + justifiedSpace: stats.justifiedSpace, + breakKind: analysis.candidates[toCandidate]!.kind + }) + + fromCandidate = toCandidate + } + + return lines +} + +export function createDemoResources(): DemoResources { + const measureCanvas = document.createElement('canvas') + const measureCtx = measureCanvas.getContext('2d') + if (measureCtx === null) throw new Error('2D canvas context is required for the demo') + measureCtx.font = '16px Georgia, "Times New Roman", serif' + + const paragraphs = [ + `The quick brown fox jumps over the lazy dog. This sentence contains every letter of the alphabet at least once, making it a perfect pangram for testing typography.`, + `In the beginning God created the heavens and the earth. Now the earth was formless and empty, darkness was over the surface of the deep, and the Spirit of God was hovering over the waters.`, + `Call me Ishmael. Some years ago—never mind how long precisely—having little or no money in my purse, and nothing particular to interest me on shore, I thought I would sail about a little and see the watery part of the world.`, + `It was the best of times, it was the worst of times, it was the age of wisdom, it was the age of foolishness, it was the epoch of belief, it was the epoch of incredulity.`, + ] + + return { + preparedParagraphs: paragraphs.map(p => prepareWithSegments(p, '16px Georgia, "Times New Roman", serif')), + normalSpaceWidth: measureCtx.measureText(' ').width, + hyphenWidth: measureCtx.measureText('-').width, + } +} diff --git a/pages/demos/optimal-line-breaking.ts b/pages/demos/optimal-line-breaking.ts new file mode 100644 index 00000000..833d9f4a --- /dev/null +++ b/pages/demos/optimal-line-breaking.ts @@ -0,0 +1,135 @@ +import { createDemoResources, layoutParagraphOptimal, type DemoControls } from './optimal-line-breaking.model.ts' +import { renderFrame, renderMetrics, createCtx } from './optimal-line-breaking.ui.ts' + +type State = { + controls: DemoControls + events: { + widthInput: number | null + showMetricsInput: boolean | null + showBadnessInput: boolean | null + showFitnessInput: boolean | null + } +} + +const LINE_HEIGHT = 24 +const PADDING = 12 + +const dom = { + canvas: document.getElementById('canvas') as HTMLCanvasElement, + widthSlider: document.getElementById('widthSlider') as HTMLInputElement, + widthVal: document.getElementById('widthVal') as HTMLSpanElement, + showMetrics: document.getElementById('showMetrics') as HTMLInputElement, + showBadness: document.getElementById('showBadness') as HTMLInputElement, + showFitness: document.getElementById('showFitness') as HTMLInputElement, + paragraphSelect: document.getElementById('paragraphSelect') as HTMLSelectElement, +} + +const ctx = createCtx(dom.canvas) + +const state: State = { + controls: { + colWidth: Number.parseInt(dom.widthSlider.value, 10), + showMetrics: dom.showMetrics.checked, + showBadness: dom.showBadness.checked, + showFitness: dom.showFitness.checked, + }, + events: { + widthInput: null, + showMetricsInput: null, + showBadnessInput: null, + showFitnessInput: null, + }, +} + +let scheduledRaf: number | null = null + +dom.widthSlider.addEventListener('input', () => { + state.events.widthInput = Number.parseInt(dom.widthSlider.value, 10) + scheduleRender() +}) + +dom.showMetrics.addEventListener('input', () => { + state.events.showMetricsInput = dom.showMetrics.checked + scheduleRender() +}) + +dom.showBadness.addEventListener('input', () => { + state.events.showBadnessInput = dom.showBadness.checked + scheduleRender() +}) + +dom.showFitness.addEventListener('input', () => { + state.events.showFitnessInput = dom.showFitness.checked + scheduleRender() +}) + +dom.paragraphSelect.addEventListener('change', scheduleRender) +window.addEventListener('resize', scheduleRender) + +await document.fonts.ready + +const resources = createDemoResources() +render() + +function scheduleRender(): void { + if (scheduledRaf !== null) return + scheduledRaf = requestAnimationFrame(render) +} + +function render(): void { + scheduledRaf = null + + let colWidth = state.controls.colWidth + if (state.events.widthInput !== null) colWidth = state.events.widthInput + + let showMetrics = state.controls.showMetrics + if (state.events.showMetricsInput !== null) showMetrics = state.events.showMetricsInput + + let showBadness = state.controls.showBadness + if (state.events.showBadnessInput !== null) showBadness = state.events.showBadnessInput + + let showFitness = state.controls.showFitness + if (state.events.showFitnessInput !== null) showFitness = state.events.showFitnessInput + + const paragraphIdx = Number.parseInt(dom.paragraphSelect.value, 10) + const prepared = resources.preparedParagraphs[paragraphIdx]! + + const lines = layoutParagraphOptimal(prepared, colWidth - PADDING * 2, resources.normalSpaceWidth) + + const canvasWidth = colWidth + const canvasHeight = lines.length * LINE_HEIGHT + PADDING * 2 + (showMetrics ? 200 : 0) + + dom.canvas.width = canvasWidth + dom.canvas.height = canvasHeight + dom.canvas.style.width = canvasWidth + 'px' + dom.canvas.style.height = canvasHeight + 'px' + + ctx.clearRect(0, 0, canvasWidth, canvasHeight) + + ctx.save() + ctx.translate(PADDING, PADDING) + + const endY = renderFrame( + ctx, + lines, + resources.normalSpaceWidth, + LINE_HEIGHT - 4, + LINE_HEIGHT, + showBadness, + showFitness + ) + + ctx.restore() + + if (showMetrics) { + ctx.save() + ctx.translate(PADDING, endY + PADDING) + renderMetrics(ctx, lines, resources.normalSpaceWidth, 0, 0) + ctx.restore() + } + + state.controls = { colWidth, showMetrics, showBadness, showFitness } + state.events = { widthInput: null, showMetricsInput: null, showBadnessInput: null, showFitnessInput: null } + + dom.widthVal.textContent = colWidth + 'px' +} diff --git a/pages/demos/optimal-line-breaking.ui.ts b/pages/demos/optimal-line-breaking.ui.ts new file mode 100644 index 00000000..fee8d54a --- /dev/null +++ b/pages/demos/optimal-line-breaking.ui.ts @@ -0,0 +1,124 @@ +type Ctx = CanvasRenderingContext2D + +export function renderFrame( + ctx: Ctx, + lines: import('./optimal-line-breaking.model.ts').MeasuredLine[], + normalSpaceWidth: number, + y: number, + lineHeight: number, + showBadness: boolean, + showFitness: boolean +): number { + ctx.font = '15px/1.6 Georgia, "Times New Roman", serif' + ctx.fillStyle = '#2a2520' + ctx.textBaseline = 'alphabetic' + + let maxWidth = 0 + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]! + const isLastLine = line.ending === 'paragraph-end' + + let x = 0 + const spaceStretch = line.spaceCount > 0 + ? (line.justifiedSpace - normalSpaceWidth) / normalSpaceWidth + : 0 + + for (let j = 0; j < line.segments.length; j++) { + const seg = line.segments[j]! + if (seg.kind === 'text') { + ctx.fillText(seg.text, x, y) + x += seg.width + } else { + let spaceWidth = normalSpaceWidth + if (!isLastLine && spaceStretch !== 0) { + spaceWidth = line.justifiedSpace + } + x += spaceWidth + } + } + + const lineWidth = x + + if (showBadness && line.badness > 0 && !isLastLine) { + const intensity = Math.min(1, line.badness / 10000) + ctx.fillStyle = `rgba(220, ${Math.round(80 - intensity * 80)}, ${Math.round(80 - intensity * 60)}, 0.4)` + ctx.fillRect(0, y + 2, lineWidth, 3) + } + + if (showFitness && !isLastLine) { + const fitnessColors: Record = { + tight: 'rgba(180, 60, 60, 0.5)', + decent: 'rgba(60, 180, 100, 0.5)', + loose: 'rgba(200, 160, 60, 0.5)', + 'very-loose': 'rgba(200, 80, 60, 0.5)' + } + ctx.fillStyle = fitnessColors[line.fitness] || 'transparent' + ctx.fillRect(0, y + lineHeight - 4, lineWidth, 3) + } + + maxWidth = Math.max(maxWidth, lineWidth) + y += lineHeight + } + + return y +} + +export function renderMetrics( + ctx: Ctx, + lines: import('./optimal-line-breaking.model.ts').MeasuredLine[], + normalSpaceWidth: number, + x: number, + y: number +): void { + ctx.font = '11px/1.5 "Helvetica Neue", Helvetica, Arial, sans-serif' + ctx.fillStyle = '#6a6055' + ctx.textBaseline = 'top' + + let totalBadness = 0 + let tightCount = 0 + let decentCount = 0 + let looseCount = 0 + let veryLooseCount = 0 + let riverCount = 0 + + for (const line of lines) { + if (line.ending === 'paragraph-end') continue + totalBadness += line.badness + + if (line.fitness === 'tight') tightCount++ + else if (line.fitness === 'decent') decentCount++ + else if (line.fitness === 'loose') looseCount++ + else if (line.fitness === 'very-loose') veryLooseCount++ + + if (line.justifiedSpace / normalSpaceWidth > 1.5) riverCount++ + } + + const metrics = [ + { label: 'Lines', value: lines.length.toString() }, + { label: 'Total badness', value: Math.round(totalBadness).toLocaleString() }, + { label: 'Avg badness', value: (totalBadness / lines.length).toFixed(1) }, + { label: 'Tight lines', value: tightCount.toString(), color: '#b44' }, + { label: 'Decent lines', value: decentCount.toString(), color: '#2a8a4a' }, + { label: 'Loose lines', value: looseCount.toString(), color: '#b87020' }, + { label: 'Very loose', value: veryLooseCount.toString(), color: '#c44' }, + { label: 'Rivers', value: riverCount.toString(), color: riverCount > 0 ? '#c44' : undefined }, + ] + + for (const metric of metrics) { + ctx.fillStyle = '#8a7f70' + ctx.fillText(metric.label + ':', x, y) + ctx.fillStyle = metric.color || '#5a4f40' + ctx.font = '600 11px/1.5 "Helvetica Neue", Helvetica, Arial, sans-serif' + ctx.fillText(metric.value, x + 90, y) + ctx.font = '11px/1.5 "Helvetica Neue", Helvetica, Arial, sans-serif' + ctx.fillStyle = '#6a6055' + y += 16 + } +} + +export function createCtx(canvas: HTMLCanvasElement): Ctx { + const ctx = canvas.getContext('2d') + if (ctx === null) throw new Error('2D canvas context required') + return ctx +}