textpour 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,22 @@
1
+ /** Measures the rendered width (px) of a string in a fixed font. */
2
+ export interface TextMeasurer {
3
+ measure(text: string): number;
4
+ }
5
+ /**
6
+ * Cumulative x-offset at each grapheme boundary of a line.
7
+ * Returns number[] of length (graphemeCount + 1); result[0] === 0 and
8
+ * result[k] === width of the first k graphemes. Cumulative prefixes are measured
9
+ * (not summed per-grapheme) so kerning/shaping between graphemes is captured.
10
+ */
11
+ export declare function buildPrefixWidths(lineText: string, measurer: TextMeasurer, segmenter?: Intl.Segmenter): number[];
12
+ /** Map an x offset (relative to line start) to the nearest grapheme boundary index. */
13
+ export declare function xToGraphemeIndex(prefix: number[], x: number): number;
14
+ /** Map a grapheme boundary index to its x offset (relative to line start). */
15
+ export declare function graphemeIndexToX(prefix: number[], index: number): number;
16
+ /** Build a measurer backed by a Canvas 2D context (browser or node-canvas). */
17
+ export declare function canvasMeasurer(ctx: {
18
+ font: string;
19
+ measureText(s: string): {
20
+ width: number;
21
+ };
22
+ }, font: string): TextMeasurer;
@@ -0,0 +1,61 @@
1
+ // Per-line cursor<->point mapping. This is the data structure that powers hit-testing
2
+ // and caret placement (project D) and justification (project B). Build it once per line at
3
+ // layout time, then map point->index and index->point in O(log n).
4
+ //
5
+ // SCOPE: this is correct for left-to-right text. Bidi (visual vs logical order) is NOT handled
6
+ // here yet — see ROADMAP. For RTL/mixed lines you must consult Pretext's segLevels.
7
+ function segmentGraphemes(text, segmenter) {
8
+ const seg = segmenter ?? new Intl.Segmenter(undefined, { granularity: 'grapheme' });
9
+ const out = [];
10
+ for (const part of seg.segment(text))
11
+ out.push(part.segment);
12
+ return out;
13
+ }
14
+ /**
15
+ * Cumulative x-offset at each grapheme boundary of a line.
16
+ * Returns number[] of length (graphemeCount + 1); result[0] === 0 and
17
+ * result[k] === width of the first k graphemes. Cumulative prefixes are measured
18
+ * (not summed per-grapheme) so kerning/shaping between graphemes is captured.
19
+ */
20
+ export function buildPrefixWidths(lineText, measurer, segmenter) {
21
+ const graphemes = segmentGraphemes(lineText, segmenter);
22
+ const prefix = [0];
23
+ let cum = '';
24
+ for (const g of graphemes) {
25
+ cum += g;
26
+ prefix.push(measurer.measure(cum));
27
+ }
28
+ return prefix;
29
+ }
30
+ /** Map an x offset (relative to line start) to the nearest grapheme boundary index. */
31
+ export function xToGraphemeIndex(prefix, x) {
32
+ const last = prefix.length - 1;
33
+ if (x <= 0)
34
+ return 0;
35
+ if (x >= prefix[last])
36
+ return last;
37
+ // Smallest index whose prefix >= x.
38
+ let lo = 0;
39
+ let hi = last;
40
+ while (lo < hi) {
41
+ const mid = (lo + hi) >> 1;
42
+ if (prefix[mid] < x)
43
+ lo = mid + 1;
44
+ else
45
+ hi = mid;
46
+ }
47
+ const hiIdx = lo;
48
+ const loIdx = lo - 1;
49
+ // Snap to the nearer of the two surrounding boundaries.
50
+ return x - prefix[loIdx] <= prefix[hiIdx] - x ? loIdx : hiIdx;
51
+ }
52
+ /** Map a grapheme boundary index to its x offset (relative to line start). */
53
+ export function graphemeIndexToX(prefix, index) {
54
+ const i = Math.max(0, Math.min(index, prefix.length - 1));
55
+ return prefix[i];
56
+ }
57
+ /** Build a measurer backed by a Canvas 2D context (browser or node-canvas). */
58
+ export function canvasMeasurer(ctx, font) {
59
+ ctx.font = font;
60
+ return { measure: (t) => ctx.measureText(t).width };
61
+ }
@@ -0,0 +1,24 @@
1
+ import { type LayoutCursor } from '@chenglou/pretext';
2
+ import type { Line, LineSource } from './line-source.js';
3
+ import type { TextMeasurer } from './prefix-widths.js';
4
+ export declare class PretextLineSource implements LineSource<LayoutCursor> {
5
+ private measurer?;
6
+ private prepared;
7
+ /**
8
+ * @param text the paragraph to lay out
9
+ * @param font a Canvas 2D font shorthand, e.g. '16px Inter'. Must match the CSS used to render.
10
+ * @param measurer optional — when provided, `nextLine` populates `Line.words` for justification.
11
+ * Pass `canvasMeasurer(ctx, font)` from `prefix-widths.ts`. Without a measurer, `words` is
12
+ * undefined and `align:'justify'` silently falls back to left alignment.
13
+ *
14
+ * Soft hyphens (U+00AD) are honored natively by Pretext: unchosen soft hyphens are invisible,
15
+ * and when a soft hyphen wins the break, Pretext's materialized `line.text` already ends with a
16
+ * visible '-'. The @chenglou/pretext@0.0.1 API exposes no flag to distinguish a soft-hyphen break
17
+ * from a real hyphen, so `softHyphenated` is left undefined here (a real '-' is indistinguishable
18
+ * from a soft-hyphen break via the 0.0.1 API). To pre-insert soft hyphens into your text before
19
+ * constructing this source, use the `insertSoftHyphens()` helper exported from this package.
20
+ */
21
+ constructor(text: string, font: string, measurer?: TextMeasurer | undefined);
22
+ start(): LayoutCursor;
23
+ nextLine(cursor: LayoutCursor, maxWidth: number): Line<LayoutCursor> | null;
24
+ }
@@ -0,0 +1,66 @@
1
+ // The real adapter onto @chenglou/pretext. Kept in a separate module so that
2
+ // importing the orchestrator/tests does NOT pull Pretext (which needs Canvas 2D + Intl.Segmenter
3
+ // at runtime). Only browser code / the demo imports this.
4
+ //
5
+ // VERSION NOTE: built against the published @chenglou/pretext@0.0.1, which exposes
6
+ // prepareWithSegments(text, font) // no options arg yet
7
+ // layoutNextLine(prepared, cursor, maxWidth) // returns a materialized LayoutLine | null
8
+ // The GitHub README is ahead of npm and adds layoutNextLineRange/materializeLineRange/
9
+ // measureLineStats/measureNaturalWidth/rich-inline + a prepare options arg. When those ship,
10
+ // switch nextLine() to the range API to avoid materializing text on rows you only measure.
11
+ import { prepareWithSegments, layoutNextLine, } from '@chenglou/pretext';
12
+ export class PretextLineSource {
13
+ measurer;
14
+ prepared;
15
+ /**
16
+ * @param text the paragraph to lay out
17
+ * @param font a Canvas 2D font shorthand, e.g. '16px Inter'. Must match the CSS used to render.
18
+ * @param measurer optional — when provided, `nextLine` populates `Line.words` for justification.
19
+ * Pass `canvasMeasurer(ctx, font)` from `prefix-widths.ts`. Without a measurer, `words` is
20
+ * undefined and `align:'justify'` silently falls back to left alignment.
21
+ *
22
+ * Soft hyphens (U+00AD) are honored natively by Pretext: unchosen soft hyphens are invisible,
23
+ * and when a soft hyphen wins the break, Pretext's materialized `line.text` already ends with a
24
+ * visible '-'. The @chenglou/pretext@0.0.1 API exposes no flag to distinguish a soft-hyphen break
25
+ * from a real hyphen, so `softHyphenated` is left undefined here (a real '-' is indistinguishable
26
+ * from a soft-hyphen break via the 0.0.1 API). To pre-insert soft hyphens into your text before
27
+ * constructing this source, use the `insertSoftHyphens()` helper exported from this package.
28
+ */
29
+ constructor(text, font, measurer) {
30
+ this.measurer = measurer;
31
+ this.prepared = prepareWithSegments(text, font);
32
+ }
33
+ start() {
34
+ return { segmentIndex: 0, graphemeIndex: 0 };
35
+ }
36
+ nextLine(cursor, maxWidth) {
37
+ const line = layoutNextLine(this.prepared, cursor, maxWidth);
38
+ if (line === null)
39
+ return null;
40
+ // softHyphenated is left undefined: the 0.0.1 API provides no way to detect a soft-hyphen break.
41
+ if (this.measurer === undefined) {
42
+ return { text: line.text, width: line.width, start: line.start, end: line.end };
43
+ }
44
+ // Build per-word segments for justification support.
45
+ // x is the natural offset of each word from the LINE LEFT (cumulative widths + space widths).
46
+ // flow.ts recomputes absolute x when justify is active, so approximate space measure is fine.
47
+ const measurer = this.measurer;
48
+ const spaceWidth = measurer.measure(' ');
49
+ const tokens = line.text.split(' ').filter(t => t.length > 0);
50
+ // Reconstruct natural x offsets by walking the tokens left-to-right.
51
+ let xOff = 0;
52
+ let tokenIdx = 0;
53
+ const wordSegments = tokens.map(token => {
54
+ // Find where this token starts in the displayed text (skip leading spaces/prior tokens).
55
+ const tokenX = xOff;
56
+ const w = measurer.measure(token);
57
+ xOff += w;
58
+ // Account for one space gap between tokens (approximate — ignores multiple consecutive spaces).
59
+ if (tokenIdx < tokens.length - 1)
60
+ xOff += spaceWidth;
61
+ tokenIdx++;
62
+ return { text: token, x: tokenX, width: w };
63
+ });
64
+ return { text: line.text, width: line.width, start: line.start, end: line.end, words: wordSegments };
65
+ }
66
+ }
@@ -0,0 +1,62 @@
1
+ import type { Interval, Bounds } from './types.js';
2
+ /** Sort, drop zero/negative-width, and coalesce overlapping or touching intervals. */
3
+ export declare function normalize(spans: Interval[]): Interval[];
4
+ export declare function unionSpans(a: Interval[], b: Interval[]): Interval[];
5
+ export declare function intersectSpans(a: Interval[], b: Interval[]): Interval[];
6
+ export declare function subtractSpans(a: Interval[], b: Interval[]): Interval[];
7
+ export interface Region {
8
+ /** Sorted, disjoint inside-intervals at horizontal scanline y. Empty if y is outside. */
9
+ spansAt(y: number): Interval[];
10
+ bounds(): Bounds;
11
+ }
12
+ export declare class RectRegion implements Region {
13
+ private x;
14
+ private y;
15
+ private w;
16
+ private h;
17
+ constructor(x: number, y: number, w: number, h: number);
18
+ spansAt(y: number): Interval[];
19
+ bounds(): Bounds;
20
+ }
21
+ export declare class CircleRegion implements Region {
22
+ private cx;
23
+ private cy;
24
+ private r;
25
+ constructor(cx: number, cy: number, r: number);
26
+ spansAt(y: number): Interval[];
27
+ bounds(): Bounds;
28
+ }
29
+ export declare class EllipseRegion implements Region {
30
+ private cx;
31
+ private cy;
32
+ private rx;
33
+ private ry;
34
+ constructor(cx: number, cy: number, rx: number, ry: number);
35
+ spansAt(y: number): Interval[];
36
+ bounds(): Bounds;
37
+ }
38
+ /** Polygon filled with the even-odd rule (supports concavity and holes via winding). */
39
+ export declare class PolygonRegion implements Region {
40
+ private pts;
41
+ private bb;
42
+ constructor(points: Array<readonly [number, number]>);
43
+ spansAt(y: number): Interval[];
44
+ bounds(): Bounds;
45
+ }
46
+ type CompositeOp = 'union' | 'intersect' | 'subtract';
47
+ /** Boolean combination of regions. 'subtract' = children[0] minus the union of the rest. */
48
+ export declare class CompositeRegion implements Region {
49
+ private op;
50
+ private children;
51
+ constructor(op: CompositeOp, children: Region[]);
52
+ spansAt(y: number): Interval[];
53
+ bounds(): Bounds;
54
+ }
55
+ export declare const rect: (x: number, y: number, w: number, h: number) => Region;
56
+ export declare const circle: (cx: number, cy: number, r: number) => Region;
57
+ export declare const ellipse: (cx: number, cy: number, rx: number, ry: number) => Region;
58
+ export declare const polygon: (points: Array<readonly [number, number]>) => Region;
59
+ export declare const union: (...regions: Region[]) => Region;
60
+ export declare const intersect: (...regions: Region[]) => Region;
61
+ export declare const subtract: (base: Region, ...holes: Region[]) => Region;
62
+ export {};
@@ -0,0 +1,229 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Interval-set algebra. All public functions return sorted, disjoint intervals.
3
+ // ---------------------------------------------------------------------------
4
+ /** Sort, drop zero/negative-width, and coalesce overlapping or touching intervals. */
5
+ export function normalize(spans) {
6
+ const valid = spans.filter((s) => s[1] > s[0]);
7
+ if (valid.length === 0)
8
+ return [];
9
+ const sorted = [...valid].sort((a, b) => a[0] - b[0]);
10
+ const out = [];
11
+ let curStart = sorted[0][0];
12
+ let curEnd = sorted[0][1];
13
+ for (let i = 1; i < sorted.length; i++) {
14
+ const s = sorted[i];
15
+ if (s[0] <= curEnd) {
16
+ if (s[1] > curEnd)
17
+ curEnd = s[1];
18
+ }
19
+ else {
20
+ out.push([curStart, curEnd]);
21
+ curStart = s[0];
22
+ curEnd = s[1];
23
+ }
24
+ }
25
+ out.push([curStart, curEnd]);
26
+ return out;
27
+ }
28
+ export function unionSpans(a, b) {
29
+ return normalize([...a, ...b]);
30
+ }
31
+ export function intersectSpans(a, b) {
32
+ const A = normalize(a);
33
+ const B = normalize(b);
34
+ const out = [];
35
+ let i = 0;
36
+ let j = 0;
37
+ while (i < A.length && j < B.length) {
38
+ const lo = Math.max(A[i][0], B[j][0]);
39
+ const hi = Math.min(A[i][1], B[j][1]);
40
+ if (lo < hi)
41
+ out.push([lo, hi]);
42
+ if (A[i][1] < B[j][1])
43
+ i++;
44
+ else
45
+ j++;
46
+ }
47
+ return out;
48
+ }
49
+ export function subtractSpans(a, b) {
50
+ const A = normalize(a);
51
+ const B = normalize(b);
52
+ const out = [];
53
+ for (const [as, ae] of A) {
54
+ let curStart = as;
55
+ for (const [bs, be] of B) {
56
+ if (be <= curStart)
57
+ continue;
58
+ if (bs >= ae)
59
+ break;
60
+ if (bs > curStart)
61
+ out.push([curStart, bs]);
62
+ curStart = Math.max(curStart, be);
63
+ if (curStart >= ae)
64
+ break;
65
+ }
66
+ if (curStart < ae)
67
+ out.push([curStart, ae]);
68
+ }
69
+ return normalize(out);
70
+ }
71
+ export class RectRegion {
72
+ x;
73
+ y;
74
+ w;
75
+ h;
76
+ constructor(x, y, w, h) {
77
+ this.x = x;
78
+ this.y = y;
79
+ this.w = w;
80
+ this.h = h;
81
+ }
82
+ spansAt(y) {
83
+ if (y < this.y || y >= this.y + this.h)
84
+ return [];
85
+ return [[this.x, this.x + this.w]];
86
+ }
87
+ bounds() {
88
+ return { minX: this.x, minY: this.y, maxX: this.x + this.w, maxY: this.y + this.h };
89
+ }
90
+ }
91
+ export class CircleRegion {
92
+ cx;
93
+ cy;
94
+ r;
95
+ constructor(cx, cy, r) {
96
+ this.cx = cx;
97
+ this.cy = cy;
98
+ this.r = r;
99
+ }
100
+ spansAt(y) {
101
+ const dy = y - this.cy;
102
+ if (Math.abs(dy) >= this.r)
103
+ return [];
104
+ const hc = Math.sqrt(this.r * this.r - dy * dy);
105
+ return [[this.cx - hc, this.cx + hc]];
106
+ }
107
+ bounds() {
108
+ return { minX: this.cx - this.r, minY: this.cy - this.r, maxX: this.cx + this.r, maxY: this.cy + this.r };
109
+ }
110
+ }
111
+ export class EllipseRegion {
112
+ cx;
113
+ cy;
114
+ rx;
115
+ ry;
116
+ constructor(cx, cy, rx, ry) {
117
+ this.cx = cx;
118
+ this.cy = cy;
119
+ this.rx = rx;
120
+ this.ry = ry;
121
+ }
122
+ spansAt(y) {
123
+ const dy = (y - this.cy) / this.ry;
124
+ if (Math.abs(dy) >= 1)
125
+ return [];
126
+ const hc = this.rx * Math.sqrt(1 - dy * dy);
127
+ return [[this.cx - hc, this.cx + hc]];
128
+ }
129
+ bounds() {
130
+ return { minX: this.cx - this.rx, minY: this.cy - this.ry, maxX: this.cx + this.rx, maxY: this.cy + this.ry };
131
+ }
132
+ }
133
+ /** Polygon filled with the even-odd rule (supports concavity and holes via winding). */
134
+ export class PolygonRegion {
135
+ pts;
136
+ bb;
137
+ constructor(points) {
138
+ if (points.length < 3)
139
+ throw new Error('PolygonRegion needs at least 3 points');
140
+ this.pts = points;
141
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
142
+ for (const [px, py] of points) {
143
+ if (px < minX)
144
+ minX = px;
145
+ if (px > maxX)
146
+ maxX = px;
147
+ if (py < minY)
148
+ minY = py;
149
+ if (py > maxY)
150
+ maxY = py;
151
+ }
152
+ this.bb = { minX, minY, maxX, maxY };
153
+ }
154
+ spansAt(y) {
155
+ const xs = [];
156
+ const pts = this.pts;
157
+ const n = pts.length;
158
+ for (let i = 0; i < n; i++) {
159
+ const a = pts[i];
160
+ const b = pts[(i + 1) % n];
161
+ const y1 = a[1];
162
+ const y2 = b[1];
163
+ // Half-open crossing test avoids double-counting shared vertices.
164
+ if ((y1 <= y && y < y2) || (y2 <= y && y < y1)) {
165
+ const t = (y - y1) / (y2 - y1);
166
+ xs.push(a[0] + t * (b[0] - a[0]));
167
+ }
168
+ }
169
+ xs.sort((p, q) => p - q);
170
+ const out = [];
171
+ for (let i = 0; i + 1 < xs.length; i += 2)
172
+ out.push([xs[i], xs[i + 1]]);
173
+ return normalize(out);
174
+ }
175
+ bounds() {
176
+ return this.bb;
177
+ }
178
+ }
179
+ /** Boolean combination of regions. 'subtract' = children[0] minus the union of the rest. */
180
+ export class CompositeRegion {
181
+ op;
182
+ children;
183
+ constructor(op, children) {
184
+ this.op = op;
185
+ this.children = children;
186
+ if (children.length === 0)
187
+ throw new Error('CompositeRegion needs at least one child');
188
+ }
189
+ spansAt(y) {
190
+ const c = this.children;
191
+ if (this.op === 'union') {
192
+ return c.reduce((acc, r) => unionSpans(acc, r.spansAt(y)), []);
193
+ }
194
+ if (this.op === 'intersect') {
195
+ let acc = c[0].spansAt(y);
196
+ for (let i = 1; i < c.length; i++)
197
+ acc = intersectSpans(acc, c[i].spansAt(y));
198
+ return acc;
199
+ }
200
+ // subtract
201
+ let holes = [];
202
+ for (let i = 1; i < c.length; i++)
203
+ holes = unionSpans(holes, c[i].spansAt(y));
204
+ return subtractSpans(c[0].spansAt(y), holes);
205
+ }
206
+ bounds() {
207
+ // Over-approximate with the union of child bounds; empty rows simply produce no spans.
208
+ let b = this.children[0].bounds();
209
+ for (let i = 1; i < this.children.length; i++) {
210
+ const o = this.children[i].bounds();
211
+ b = {
212
+ minX: Math.min(b.minX, o.minX),
213
+ minY: Math.min(b.minY, o.minY),
214
+ maxX: Math.max(b.maxX, o.maxX),
215
+ maxY: Math.max(b.maxY, o.maxY),
216
+ };
217
+ }
218
+ // For subtract, the vertical/horizontal extent can only shrink, so children[0] bounds is tighter:
219
+ return this.op === 'subtract' ? this.children[0].bounds() : b;
220
+ }
221
+ }
222
+ // ---- convenience builders ----
223
+ export const rect = (x, y, w, h) => new RectRegion(x, y, w, h);
224
+ export const circle = (cx, cy, r) => new CircleRegion(cx, cy, r);
225
+ export const ellipse = (cx, cy, rx, ry) => new EllipseRegion(cx, cy, rx, ry);
226
+ export const polygon = (points) => new PolygonRegion(points);
227
+ export const union = (...regions) => new CompositeRegion('union', regions);
228
+ export const intersect = (...regions) => new CompositeRegion('intersect', regions);
229
+ export const subtract = (base, ...holes) => new CompositeRegion('subtract', [base, ...holes]);
@@ -0,0 +1,47 @@
1
+ import type { FlowResult } from './types.js';
2
+ /** A paint backend. The kernel produces geometry; renderers turn it into pixels/DOM. */
3
+ export interface Renderer<Target, C = unknown> {
4
+ render(result: FlowResult<C>, target: Target): void;
5
+ }
6
+ /** Minimal slice of CanvasRenderingContext2D we actually use (keeps it testable). */
7
+ export interface Canvas2DLike {
8
+ font: string;
9
+ fillStyle: string | CanvasGradient | CanvasPattern;
10
+ textBaseline: CanvasTextBaseline;
11
+ fillText(text: string, x: number, y: number): void;
12
+ }
13
+ /** Working backend: draws each placed line to a 2D canvas. */
14
+ export declare class Canvas2DRenderer<C = unknown> implements Renderer<Canvas2DLike, C> {
15
+ private font;
16
+ private opts;
17
+ constructor(font: string, opts?: {
18
+ color?: string;
19
+ });
20
+ render(result: FlowResult<C>, ctx: Canvas2DLike): void;
21
+ }
22
+ /**
23
+ * STUB. HTML-in-Canvas is a Chrome origin-trial API (chrome://flags/#canvas-draw-element); no
24
+ * Firefox/Safari intent yet. The seam exists so the shape-flow module can target a high-fidelity,
25
+ * accessible paint path without knowing the backend. Pretext still does ALL line-breaking/geometry
26
+ * (the plan); HTML-in-Canvas only does the paint.
27
+ *
28
+ * Intended implementation (see ROADMAP phase 2):
29
+ * 1. Give the <canvas> the `layoutsubtree` attribute.
30
+ * 2. Keep one real styled child element per PlacedLine (e.g. a <span> with the same CSS font),
31
+ * as a direct child of the canvas, positioned via CSS transform to (line.x, line.baseline).
32
+ * 3. In the canvas `paint` event, for each line call
33
+ * const m = ctx.drawElementImage(span, line.x, line.y);
34
+ * span.style.transform = m.toString(); // keep hit-testing + a11y aligned
35
+ * 4. Repaint only when the FlowResult changes; use canvas.requestPaint() for per-frame needs.
36
+ * Caching matters: the element layout pass is the expensive op (the very reflow Pretext avoids),
37
+ * so plan with Pretext every frame and only drawElementImage when the chosen layout changes.
38
+ */
39
+ export interface HtmlInCanvasTarget {
40
+ canvas: HTMLCanvasElement;
41
+ ctx: CanvasRenderingContext2D;
42
+ }
43
+ export declare class HtmlInCanvasRenderer<C = unknown> implements Renderer<HtmlInCanvasTarget, C> {
44
+ /** Feature-detect the origin-trial API before using this backend. */
45
+ static isSupported(): boolean;
46
+ render(_result: FlowResult<C>, _target: HtmlInCanvasTarget): void;
47
+ }
@@ -0,0 +1,37 @@
1
+ /** Working backend: draws each placed line to a 2D canvas. */
2
+ export class Canvas2DRenderer {
3
+ font;
4
+ opts;
5
+ constructor(font, opts = {}) {
6
+ this.font = font;
7
+ this.opts = opts;
8
+ }
9
+ render(result, ctx) {
10
+ ctx.font = this.font;
11
+ ctx.textBaseline = 'alphabetic';
12
+ if (this.opts.color)
13
+ ctx.fillStyle = this.opts.color;
14
+ for (const line of result.lines) {
15
+ if (line.words !== undefined) {
16
+ // Justified line: draw each word at its individually computed absolute x position.
17
+ for (const word of line.words)
18
+ ctx.fillText(word.text, word.x, line.baseline);
19
+ }
20
+ else {
21
+ ctx.fillText(line.text, line.x, line.baseline);
22
+ }
23
+ }
24
+ }
25
+ }
26
+ export class HtmlInCanvasRenderer {
27
+ /** Feature-detect the origin-trial API before using this backend. */
28
+ static isSupported() {
29
+ return (typeof CanvasRenderingContext2D !== 'undefined' &&
30
+ 'drawElementImage' in CanvasRenderingContext2D.prototype);
31
+ }
32
+ render(_result, _target) {
33
+ throw new Error('HtmlInCanvasRenderer is a stub (ROADMAP phase 2). HTML-in-Canvas is a Chrome origin-trial ' +
34
+ 'API behind chrome://flags/#canvas-draw-element. See renderer.ts comments for the intended ' +
35
+ 'drawElementImage flow. Use Canvas2DRenderer until this is implemented.');
36
+ }
37
+ }
@@ -0,0 +1,85 @@
1
+ /** A horizontal inside-interval [x0, x1] with x0 <= x1. */
2
+ export type Interval = readonly [number, number];
3
+ export interface Bounds {
4
+ minX: number;
5
+ minY: number;
6
+ maxX: number;
7
+ maxY: number;
8
+ }
9
+ /** How to use multiple disjoint spans on a single row. */
10
+ export type MultiSpan = 'fill' | 'widest' | 'first';
11
+ export type Align = 'left' | 'center' | 'right' | 'justify';
12
+ /**
13
+ * One word segment with an absolute x position within the canvas/target coordinate space.
14
+ * Set on `PlacedLine.words` when `align === 'justify'` and the line is not the last line
15
+ * of a paragraph. The renderer draws each word individually at its computed x so that
16
+ * inter-word gaps expand to fill the span exactly.
17
+ */
18
+ export interface WordSegment {
19
+ text: string;
20
+ /** Absolute x of the word's left edge (in canvas coordinates). */
21
+ x: number;
22
+ /** Rendered width of the word (NOT including surrounding spaces). */
23
+ width: number;
24
+ }
25
+ export interface FlowOptions {
26
+ /** CSS line-height in px. Required. */
27
+ lineHeight: number;
28
+ /** Baseline offset within the row box (px). Default: lineHeight * 0.8. */
29
+ ascent?: number;
30
+ /** First row's top y. Default: region.bounds().minY. */
31
+ startY?: number;
32
+ /** Multi-span strategy. Default: 'fill'. */
33
+ multiSpan?: MultiSpan;
34
+ /** Horizontal alignment within each span. Default: 'left'. */
35
+ align?: Align;
36
+ /** Ignore spans narrower than this (px). Default: 1. */
37
+ minSpanWidth?: number;
38
+ /**
39
+ * Sample the FULL row band instead of only its vertical center. When true, the row's spans are the
40
+ * intersection of `region.spansAt` taken at several y within [y, y+lineHeight), so a line never
41
+ * pokes outside a tight curve (the row only claims x-ranges inside the shape across the whole band).
42
+ * More conservative (text stays strictly inside), at the cost of extra `spansAt` calls. Default: false.
43
+ */
44
+ conservativeBandSampling?: boolean;
45
+ /** Number of band samples when `conservativeBandSampling` is on. Default: 3. Clamped to >= 1. */
46
+ bandSamplingSteps?: number;
47
+ }
48
+ /** One laid-out line, positioned. Carries source cursors for hit-testing / continuation. */
49
+ export interface PlacedLine<C> {
50
+ text: string;
51
+ /** Left x of the line after alignment. */
52
+ x: number;
53
+ /** Top y of the row box. */
54
+ y: number;
55
+ /** y + ascent — pass this to Canvas2D fillText with textBaseline 'alphabetic'. */
56
+ baseline: number;
57
+ /** Measured width of the line. */
58
+ width: number;
59
+ /** Row index from startY (0-based; rows with no content still increment it). */
60
+ rowIndex: number;
61
+ /** Which span within the row this line filled (0 for single-span rows). */
62
+ spanIndex: number;
63
+ /** Inclusive start cursor in the source. */
64
+ start: C;
65
+ /** Exclusive end cursor in the source. */
66
+ end: C;
67
+ /** True when a soft hyphen (U+00AD) was chosen as the line-break point and a visible '-' was appended. */
68
+ softHyphenated?: boolean;
69
+ /**
70
+ * Justified word positions with ABSOLUTE x coordinates (set ONLY when align==='justify',
71
+ * words.length > 1, and this is NOT the last line of the paragraph). When present, the renderer
72
+ * draws each word individually to achieve exact span-filling. When absent, the renderer falls
73
+ * back to a single fillText of the whole line string.
74
+ */
75
+ words?: WordSegment[];
76
+ }
77
+ export interface FlowResult<C> {
78
+ lines: PlacedLine<C>[];
79
+ /** True if text remained when vertical room ran out. */
80
+ overflow: boolean;
81
+ /** Where layout stopped — the start of any leftover text (drive pagination / auto-fit with this). */
82
+ endCursor: C;
83
+ /** Vertical extent actually used: bottom of last content row minus startY. */
84
+ height: number;
85
+ }
@@ -0,0 +1,5 @@
1
+ // Core geometry & result types for the kernel.
2
+ // Coordinate model: top-left origin, +y points down, units are CSS px.
3
+ // A text row occupies the vertical band [y, y + lineHeight). Spans are sampled
4
+ // at the row's vertical center. The baseline (where Canvas2D fillText draws) is y + ascent.
5
+ export {};