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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 beansint
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,96 @@
1
+ # textpour
2
+
3
+ A render-agnostic **text-geometry kernel** on top of
4
+ [`@chenglou/pretext`](https://github.com/chenglou/pretext).
5
+
6
+ - **Shape-flow**: pour text into arbitrary 2D regions — circles, polygons, holes, boolean
7
+ combinations, SVG paths, glyph outlines, and raster alpha masks — by routing Pretext's
8
+ line-breaking through per-row spans. (CSS `shape-inside` never shipped; this does it.)
9
+ - **Typographic quality** (the moat): justification (`align: 'justify'`), soft-hyphenation,
10
+ balanced lines, auto-fit (binary-search font size), and conservative band sampling so glyphs
11
+ never poke outside tight curves.
12
+ - **Cursor ↔ point mapping**: map pixel positions to exact grapheme positions and back, for
13
+ caret/hit-testing in custom-rendered text.
14
+ - **Pluggable paint**: the kernel computes geometry; `Renderer`s paint it. Canvas2D works today;
15
+ an HTML-in-Canvas adapter (for high-fidelity, accessible, 3D-surface paint) is stubbed for later.
16
+
17
+ The design bet is the **plan/paint split**: Pretext plans cheaply every frame (no DOM reflow); an
18
+ expensive high-fidelity backend paints only when the plan changes.
19
+
20
+ ## Demo
21
+
22
+ Text poured into a circle and a donut (multi-span), reflowing live as the region changes — the same
23
+ prepared pass reused on every frame:
24
+
25
+ ![textpour demo: text flowing into a circle and a donut, reflowing live](assets/textpour-demo.gif)
26
+
27
+ ## Why not just Pretext?
28
+
29
+ Pretext is the line-breaking and measurement engine — a very good one. It breaks lines at a width
30
+ *you give it*, measures without DOM reflow, and its fuller API even does Knuth–Plass justification,
31
+ syllable hyphenation, and "shrinkwrap" (`walkLineRanges`). It can already flow text past a floated
32
+ image, because that's still **one rectangular width that varies by `y`**.
33
+
34
+ What Pretext deliberately does **not** model is **2D geometry**. Its layout call is "one line, one
35
+ width." textpour adds exactly that missing layer:
36
+
37
+ - **Arbitrary regions, not a scalar width.** A `Region` turns any 2D shape — circles, ellipses,
38
+ polygons, boolean unions/intersections/**holes**, SVG paths, glyph outlines, raster alpha masks —
39
+ into the per-row spans Pretext consumes. Pretext has no notion of a shape, a hole, or an outline.
40
+ - **Multiple disjoint spans per line — the "cursor trick."** Pouring a single row across the left
41
+ *and* right of a hole (a donut), or into the prongs of a concave shape, in reading order on one
42
+ baseline. Pretext's one-line-one-width API can't natively continue a row across a gap; textpour
43
+ threads one cursor through every span on the row.
44
+ - **Auto-fit to a region** — binary-search the font size that exactly fills a shape. Pretext keys
45
+ layout on `(text, font)`; it doesn't size-to-fit a 2D area.
46
+ - **A render-agnostic plan/paint kernel** — geometry computed once, painted by pluggable
47
+ `Renderer`s (Canvas2D today, HTML-in-Canvas later).
48
+ - **Cursor ↔ point mapping** for hit-testing/caret in custom-rendered text.
49
+
50
+ Honest overlap: textpour targets the **published `@chenglou/pretext@0.0.1`**, whose API is just
51
+ `prepareWithSegments` + `layoutNextLine`. So textpour's own justification, soft-hyphenation, and
52
+ balanced-lines are pragmatic implementations over that minimal surface — when Pretext ships its
53
+ richer API (Knuth–Plass justify, real hyphenation, `walkLineRanges`), textpour will defer to those
54
+ and keep only the geometry it uniquely contributes.
55
+
56
+ In one line: **Pretext breaks the lines; textpour decides the shape those lines fill.**
57
+
58
+ ## Quickstart
59
+
60
+ ```bash
61
+ npm install
62
+ npm test # builds, runs the pure-logic test suite (56 specs)
63
+ npm run build # emits dist/
64
+ # demo (needs a browser + http):
65
+ npx http-server . # or any static server
66
+ # open /demo/index.html
67
+ ```
68
+
69
+ ## Status
70
+
71
+ **Phase 0** (kernel scaffold) and **Phase 1** (shape-flow quality — justification, soft-hyphenation,
72
+ balanced lines, auto-fit, region-from-outline, conservative band sampling) are complete and tested
73
+ (56 specs). See **ROADMAP.md** for what's next — the HTML-in-Canvas renderer (Phase 2), then the
74
+ flagship "shaped CSS text on a 3D surface" demo (Phase 3).
75
+
76
+ ## Docs
77
+
78
+ - **EXPLAINER.md** — plain-words overview, ELI5, and the origin story (start here for the why).
79
+ - **CLAUDE.md** — how to work in this repo (conventions, commands, guardrails).
80
+ - **SPEC.md** — the full design, API reference, and the relationship to Pretext + HTML-in-Canvas.
81
+ - **ROADMAP.md** — phased tasks with acceptance + kill criteria.
82
+
83
+ ## Example
84
+
85
+ ```ts
86
+ import { shapeFlow, circle, subtract, Canvas2DRenderer, PretextLineSource } from 'textpour';
87
+
88
+ const source = new PretextLineSource(longText, '17px Georgia'); // one prepare pass
89
+ const region = subtract(circle(220, 220, 160), circle(220, 220, 67)); // a donut
90
+ const result = shapeFlow(source, region, { lineHeight: 24, ascent: 18 });
91
+
92
+ new Canvas2DRenderer('17px Georgia', { color: '#1a1a1a' }).render(result, ctx);
93
+ // result.overflow / result.endCursor drive auto-fit and multi-region pagination
94
+ ```
95
+
96
+ MIT.
@@ -0,0 +1,59 @@
1
+ /**
2
+ * auto-fit.ts — binary-search the largest font size at which text fits a region without overflow.
3
+ *
4
+ * Performance note: because Pretext's prepare is keyed on (text, font), each size trial requires
5
+ * a fresh prepareWithSegments call. Under PretextLineSource this makes each trial moderately
6
+ * expensive (O(text length) Unicode segmentation). Callers should:
7
+ * - Keep maxIterations low (default 24 is already generous for 0.5 px tolerance over a
8
+ * 6–96 px range — fewer than 10 iterations converge in practice).
9
+ * - Cache results when the text and region don't change between frames.
10
+ * - When the range API ships (measureLineStats / layoutNextLineRange), a non-materializing stats
11
+ * call from inside the source can shortcut the full shapeFlow pass; slot it in here behind the
12
+ * makeSource seam without changing this module.
13
+ */
14
+ import type { Region } from './region.js';
15
+ import type { LineSource } from './line-source.js';
16
+ import type { FlowOptions, FlowResult } from './types.js';
17
+ export interface AutoFitOptions extends FlowOptions {
18
+ /** Smallest font size to try (px). Default: 6. */
19
+ minSizePx?: number;
20
+ /** Largest font size to try (px). Default: 96. */
21
+ maxSizePx?: number;
22
+ /**
23
+ * Stop when hi − lo < tolerance (px). Default: 0.5.
24
+ * The returned sizePx is accurate to within this margin.
25
+ */
26
+ tolerance?: number;
27
+ /** Safety cap on iterations. Default: 24. */
28
+ maxIterations?: number;
29
+ /**
30
+ * When set, lineHeight for each trial = sizePx * lineHeightRatio instead of the fixed
31
+ * opts.lineHeight. Required for the overflow predicate to be monotonic in size (bigger
32
+ * font → taller rows → fewer rows fit → more likely overflow). Without this, a fixed
33
+ * lineHeight means more text fits as charWidth grows but row count is constant, which
34
+ * can break monotonicity.
35
+ */
36
+ lineHeightRatio?: number;
37
+ /**
38
+ * When set, ascent for each trial = sizePx * ascentRatio instead of opts.ascent.
39
+ * Defaults to lineHeightRatio * 0.8 when lineHeightRatio is set and ascentRatio is omitted.
40
+ */
41
+ ascentRatio?: number;
42
+ }
43
+ export interface AutoFitResult<C> {
44
+ /** The winning font size (px). May be minSizePx if even the minimum overflows. */
45
+ sizePx: number;
46
+ /** The FlowResult at the winning size. overflow===true only when even minSizePx overflowed. */
47
+ result: FlowResult<C>;
48
+ }
49
+ /**
50
+ * Binary-search the largest sizePx in [minSizePx, maxSizePx] such that shapeFlow does not
51
+ * overflow the region. Returns the best-effort smallest size when all sizes overflow.
52
+ *
53
+ * @param makeSource Factory called once per trial — returns a LineSource calibrated for sizePx.
54
+ * Under Pretext this is a full prepareWithSegments; keep maxIterations small.
55
+ * @param region The target region (unchanged across trials).
56
+ * @param opts Flow options plus auto-fit knobs. lineHeight/ascent are overridden per
57
+ * iteration when lineHeightRatio / ascentRatio are set.
58
+ */
59
+ export declare function autoFit<C>(makeSource: (sizePx: number) => LineSource<C>, region: Region, opts: AutoFitOptions): AutoFitResult<C>;
@@ -0,0 +1,74 @@
1
+ /**
2
+ * auto-fit.ts — binary-search the largest font size at which text fits a region without overflow.
3
+ *
4
+ * Performance note: because Pretext's prepare is keyed on (text, font), each size trial requires
5
+ * a fresh prepareWithSegments call. Under PretextLineSource this makes each trial moderately
6
+ * expensive (O(text length) Unicode segmentation). Callers should:
7
+ * - Keep maxIterations low (default 24 is already generous for 0.5 px tolerance over a
8
+ * 6–96 px range — fewer than 10 iterations converge in practice).
9
+ * - Cache results when the text and region don't change between frames.
10
+ * - When the range API ships (measureLineStats / layoutNextLineRange), a non-materializing stats
11
+ * call from inside the source can shortcut the full shapeFlow pass; slot it in here behind the
12
+ * makeSource seam without changing this module.
13
+ */
14
+ import { shapeFlow } from './flow.js';
15
+ /**
16
+ * Binary-search the largest sizePx in [minSizePx, maxSizePx] such that shapeFlow does not
17
+ * overflow the region. Returns the best-effort smallest size when all sizes overflow.
18
+ *
19
+ * @param makeSource Factory called once per trial — returns a LineSource calibrated for sizePx.
20
+ * Under Pretext this is a full prepareWithSegments; keep maxIterations small.
21
+ * @param region The target region (unchanged across trials).
22
+ * @param opts Flow options plus auto-fit knobs. lineHeight/ascent are overridden per
23
+ * iteration when lineHeightRatio / ascentRatio are set.
24
+ */
25
+ export function autoFit(makeSource, region, opts) {
26
+ const minSizePx = opts.minSizePx ?? 6;
27
+ const maxSizePx = opts.maxSizePx ?? 96;
28
+ const tolerance = opts.tolerance ?? 0.5;
29
+ const maxIterations = opts.maxIterations ?? 24;
30
+ /** Build per-iteration FlowOptions with scaled lineHeight/ascent. */
31
+ function iterOpts(sizePx) {
32
+ const lineHeight = opts.lineHeightRatio != null
33
+ ? sizePx * opts.lineHeightRatio
34
+ : opts.lineHeight;
35
+ const ascent = opts.ascentRatio != null
36
+ ? sizePx * opts.ascentRatio
37
+ : opts.lineHeightRatio != null
38
+ ? sizePx * opts.lineHeightRatio * 0.8
39
+ : opts.ascent;
40
+ // Spread the rest of FlowOptions, then override lineHeight/ascent.
41
+ const { minSizePx: _a, maxSizePx: _b, tolerance: _c, maxIterations: _d, lineHeightRatio: _e, ascentRatio: _f, ...rest } = opts;
42
+ return { ...rest, lineHeight, ...(ascent !== undefined ? { ascent } : {}) };
43
+ }
44
+ /** Flow once at sizePx. Each call is one full prepare under Pretext — so we reuse results. */
45
+ function flowAt(sizePx) {
46
+ return shapeFlow(makeSource(sizePx), region, iterOpts(sizePx));
47
+ }
48
+ // Fast paths: check the extremes first (saves iterations in the common case).
49
+ const maxRes = flowAt(maxSizePx);
50
+ if (!maxRes.overflow)
51
+ return { sizePx: maxSizePx, result: maxRes };
52
+ const minRes = flowAt(minSizePx);
53
+ if (minRes.overflow)
54
+ return { sizePx: minSizePx, result: minRes };
55
+ // Invariant: lo fits (overflow===false), hi overflows. Track lo's result to avoid re-flowing it.
56
+ let lo = minSizePx;
57
+ let hi = maxSizePx;
58
+ let loResult = minRes;
59
+ let iterations = 0;
60
+ while (hi - lo >= tolerance && iterations < maxIterations) {
61
+ const mid = (lo + hi) / 2;
62
+ const midRes = flowAt(mid);
63
+ if (!midRes.overflow) {
64
+ lo = mid;
65
+ loResult = midRes;
66
+ }
67
+ else {
68
+ hi = mid;
69
+ }
70
+ iterations++;
71
+ }
72
+ // lo is the largest size that fits; reuse its already-computed result.
73
+ return { sizePx: lo, result: loResult };
74
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Balanced-line width search.
3
+ *
4
+ * Finds the narrowest line width that keeps the same number of lines as the unconstrained layout,
5
+ * reducing the ragged edge by avoiding a near-empty final line.
6
+ *
7
+ * NOTE: this is meaningful for rectangular / near-rectangular regions. For highly variable-width
8
+ * shapes (circles, stars, concave polygons) uniform narrowing is only a hint, not a guarantee —
9
+ * the per-row span width still varies independently of the global maxWidth cap, so balance is best
10
+ * paired with `multiSpan: 'widest'` or `'first'` where the dominant span drives the width.
11
+ *
12
+ * // FUTURE: when Pretext's measureLineStats/walkLineRanges range API ships, use a non-materializing
13
+ * // stats walk instead of full shapeFlow calls per binary-search step.
14
+ */
15
+ import type { Region } from './region.js';
16
+ import type { LineSource } from './line-source.js';
17
+ import type { FlowOptions, FlowResult } from './types.js';
18
+ /**
19
+ * Find the narrowest line width `w` such that flowing `source` through a region whose spans are
20
+ * capped to `w` produces the same number of lines (and the same `overflow` flag) as the full
21
+ * unconstrained layout.
22
+ *
23
+ * The result is determined by binary search (~20 iterations, resolution 0.5 px). Use it to pour
24
+ * text with a more even ragged edge — avoiding a last line that is nearly empty.
25
+ *
26
+ * **Caveat (rectangular / near-rectangular regions):** For shapes with highly variable per-row
27
+ * widths (circles, stars, concave polygons) the uniform width cap is only a hint. The binary
28
+ * search still converges and preserves the line count, but the visual balance improvement is
29
+ * region-specific. Best paired with `multiSpan: 'widest'` or `'first'` for such shapes.
30
+ *
31
+ * // FUTURE: when Pretext's measureLineStats/walkLineRanges range API ships, use a non-materializing
32
+ * // stats walk instead of full shapeFlow calls per binary-search step.
33
+ */
34
+ export declare function balanceWidth(source: LineSource<unknown>, region: Region, options: FlowOptions): number;
35
+ /**
36
+ * Compute a balanced layout: finds the narrowest width that preserves the line count of the
37
+ * unconstrained layout, then flows through the capped region.
38
+ *
39
+ * This is equivalent to:
40
+ * `shapeFlow(source, narrowedRegion(region, balanceWidth(source, region, options)), options)`
41
+ * but keeps `NarrowedRegion` private to this module.
42
+ *
43
+ * **Caveat:** see `balanceWidth` — most effective for rectangular / near-rectangular regions.
44
+ *
45
+ * // FUTURE: when Pretext's measureLineStats/walkLineRanges range API ships, use a non-materializing
46
+ * // stats walk instead of full shapeFlow calls per binary-search step.
47
+ */
48
+ export declare function balancedFlow<C>(source: LineSource<C>, region: Region, options: FlowOptions): FlowResult<C>;
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Balanced-line width search.
3
+ *
4
+ * Finds the narrowest line width that keeps the same number of lines as the unconstrained layout,
5
+ * reducing the ragged edge by avoiding a near-empty final line.
6
+ *
7
+ * NOTE: this is meaningful for rectangular / near-rectangular regions. For highly variable-width
8
+ * shapes (circles, stars, concave polygons) uniform narrowing is only a hint, not a guarantee —
9
+ * the per-row span width still varies independently of the global maxWidth cap, so balance is best
10
+ * paired with `multiSpan: 'widest'` or `'first'` where the dominant span drives the width.
11
+ *
12
+ * // FUTURE: when Pretext's measureLineStats/walkLineRanges range API ships, use a non-materializing
13
+ * // stats walk instead of full shapeFlow calls per binary-search step.
14
+ */
15
+ import { shapeFlow } from './flow.js';
16
+ // ---------------------------------------------------------------------------
17
+ // NarrowedRegion — caps each span's width at a global maximum.
18
+ // Not exported: it is an implementation detail of the binary search.
19
+ // ---------------------------------------------------------------------------
20
+ class NarrowedRegion {
21
+ inner;
22
+ maxWidth;
23
+ constructor(inner, maxWidth) {
24
+ this.inner = inner;
25
+ this.maxWidth = maxWidth;
26
+ }
27
+ spansAt(y) {
28
+ return this.inner.spansAt(y).map(([x0, x1]) => [x0, Math.min(x1, x0 + this.maxWidth)]);
29
+ }
30
+ bounds() {
31
+ return this.inner.bounds();
32
+ }
33
+ }
34
+ // ---------------------------------------------------------------------------
35
+ // balanceWidth — public utility
36
+ // ---------------------------------------------------------------------------
37
+ /**
38
+ * Find the narrowest line width `w` such that flowing `source` through a region whose spans are
39
+ * capped to `w` produces the same number of lines (and the same `overflow` flag) as the full
40
+ * unconstrained layout.
41
+ *
42
+ * The result is determined by binary search (~20 iterations, resolution 0.5 px). Use it to pour
43
+ * text with a more even ragged edge — avoiding a last line that is nearly empty.
44
+ *
45
+ * **Caveat (rectangular / near-rectangular regions):** For shapes with highly variable per-row
46
+ * widths (circles, stars, concave polygons) the uniform width cap is only a hint. The binary
47
+ * search still converges and preserves the line count, but the visual balance improvement is
48
+ * region-specific. Best paired with `multiSpan: 'widest'` or `'first'` for such shapes.
49
+ *
50
+ * // FUTURE: when Pretext's measureLineStats/walkLineRanges range API ships, use a non-materializing
51
+ * // stats walk instead of full shapeFlow calls per binary-search step.
52
+ */
53
+ export function balanceWidth(source, region, options) {
54
+ const bounds = region.bounds();
55
+ const fullWidth = bounds.maxX - bounds.minX;
56
+ const base = shapeFlow(source, region, options);
57
+ const baseCount = base.lines.length;
58
+ // One line (or no lines): nothing to balance.
59
+ if (baseCount <= 1)
60
+ return fullWidth;
61
+ // Binary search for the minimum width that preserves both line count AND overflow flag.
62
+ let lo = 1;
63
+ let hi = fullWidth;
64
+ let best = fullWidth; // fallback: full width always qualifies
65
+ const iterations = 20;
66
+ for (let k = 0; k < iterations && hi - lo > 0.5; k++) {
67
+ const mid = (lo + hi) / 2;
68
+ const candidate = shapeFlow(source, new NarrowedRegion(region, mid), options);
69
+ if (candidate.lines.length === baseCount && candidate.overflow === base.overflow) {
70
+ best = mid;
71
+ hi = mid; // try even narrower
72
+ }
73
+ else {
74
+ lo = mid; // too narrow — needs more width
75
+ }
76
+ }
77
+ return best;
78
+ }
79
+ // ---------------------------------------------------------------------------
80
+ // balancedFlow — convenience combinator (keeps NarrowedRegion private here)
81
+ // ---------------------------------------------------------------------------
82
+ /**
83
+ * Compute a balanced layout: finds the narrowest width that preserves the line count of the
84
+ * unconstrained layout, then flows through the capped region.
85
+ *
86
+ * This is equivalent to:
87
+ * `shapeFlow(source, narrowedRegion(region, balanceWidth(source, region, options)), options)`
88
+ * but keeps `NarrowedRegion` private to this module.
89
+ *
90
+ * **Caveat:** see `balanceWidth` — most effective for rectangular / near-rectangular regions.
91
+ *
92
+ * // FUTURE: when Pretext's measureLineStats/walkLineRanges range API ships, use a non-materializing
93
+ * // stats walk instead of full shapeFlow calls per binary-search step.
94
+ */
95
+ export function balancedFlow(source, region, options) {
96
+ const w = balanceWidth(source, region, options);
97
+ return shapeFlow(source, new NarrowedRegion(region, w), options);
98
+ }
@@ -0,0 +1,34 @@
1
+ import type { Region } from './region.js';
2
+ import type { LineSource } from './line-source.js';
3
+ import type { FlowOptions, FlowResult } from './types.js';
4
+ /**
5
+ * Pour text from `source` into `region`, line by line.
6
+ *
7
+ * For each row band [y, y+lineHeight) we sample the region's inside-spans at the row center,
8
+ * then feed each span's width to the source as a maxWidth. With multiSpan='fill' a single row can
9
+ * consume several disjoint spans (the cursor trick): we keep advancing the same cursor across
10
+ * spans WITHOUT advancing y, which is how concave shapes and holes get filled.
11
+ *
12
+ * Pure function of (source, region, options) — the source is stateless w.r.t. the cursor, so this
13
+ * is safe to call repeatedly (e.g. on every animation frame for a moving region).
14
+ */
15
+ export declare function shapeFlow<C>(source: LineSource<C>, region: Region, options: FlowOptions): FlowResult<C>;
16
+ /**
17
+ * Holds a source so the (expensive) prepare pass is done once, and re-flows cheaply when only the
18
+ * region or options change. This is the reactive-region perf story: build once, reflow() per frame.
19
+ */
20
+ export declare class ShapeFlow<C> {
21
+ private source;
22
+ private options;
23
+ constructor(source: LineSource<C>, options: FlowOptions);
24
+ flow(region: Region): FlowResult<C>;
25
+ reflow(region: Region, optionsPatch?: Partial<FlowOptions>): FlowResult<C>;
26
+ /**
27
+ * Like `reflow`, but first binary-searches for the narrowest width that preserves the
28
+ * unconstrained line count, then flows through the capped region. This reduces the ragged edge
29
+ * by avoiding a near-empty last line.
30
+ *
31
+ * Delegates to `balancedFlow` from balance.ts so `NarrowedRegion` stays private there.
32
+ */
33
+ rebalance(region: Region, optionsPatch?: Partial<FlowOptions>): FlowResult<C>;
34
+ }
@@ -0,0 +1,172 @@
1
+ import { intersectSpans } from './region.js';
2
+ import { balancedFlow } from './balance.js';
3
+ function widestSpan(spans) {
4
+ let best = spans[0];
5
+ let bestW = best[1] - best[0];
6
+ for (let i = 1; i < spans.length; i++) {
7
+ const w = spans[i][1] - spans[i][0];
8
+ if (w > bestW) {
9
+ best = spans[i];
10
+ bestW = w;
11
+ }
12
+ }
13
+ return best;
14
+ }
15
+ /**
16
+ * Conservative band sampling: intersect the region's spans across `steps` sample points evenly
17
+ * spread within [y, y+lineHeight). The result is the x-ranges inside the shape across the WHOLE
18
+ * band, so a line never pokes outside a tight curve. Returns [] as soon as any sample is empty.
19
+ */
20
+ function bandSpans(region, y, lineHeight, steps) {
21
+ const step = lineHeight / steps;
22
+ let acc = null;
23
+ for (let k = 0; k < steps; k++) {
24
+ const sampleY = y + step * (k + 0.5);
25
+ const s = region.spansAt(sampleY);
26
+ acc = acc === null ? s : intersectSpans(acc, s);
27
+ if (acc.length === 0)
28
+ return [];
29
+ }
30
+ return acc ?? [];
31
+ }
32
+ /**
33
+ * Pour text from `source` into `region`, line by line.
34
+ *
35
+ * For each row band [y, y+lineHeight) we sample the region's inside-spans at the row center,
36
+ * then feed each span's width to the source as a maxWidth. With multiSpan='fill' a single row can
37
+ * consume several disjoint spans (the cursor trick): we keep advancing the same cursor across
38
+ * spans WITHOUT advancing y, which is how concave shapes and holes get filled.
39
+ *
40
+ * Pure function of (source, region, options) — the source is stateless w.r.t. the cursor, so this
41
+ * is safe to call repeatedly (e.g. on every animation frame for a moving region).
42
+ */
43
+ export function shapeFlow(source, region, options) {
44
+ const lineHeight = options.lineHeight;
45
+ const ascent = options.ascent ?? lineHeight * 0.8;
46
+ const multiSpan = options.multiSpan ?? 'fill';
47
+ const align = options.align ?? 'left';
48
+ const minSpanWidth = options.minSpanWidth ?? 1;
49
+ const conservative = options.conservativeBandSampling ?? false;
50
+ const bandSteps = Math.max(1, Math.floor(options.bandSamplingSteps ?? 3));
51
+ const bounds = region.bounds();
52
+ const startY = options.startY ?? bounds.minY;
53
+ const maxY = bounds.maxY;
54
+ const eps = 1e-6;
55
+ const lines = [];
56
+ let cursor = source.start();
57
+ let y = startY;
58
+ let rowIndex = 0;
59
+ let exhausted = false;
60
+ let lastContentBottom = startY;
61
+ while (!exhausted && y + lineHeight <= maxY + eps) {
62
+ const rawSpans = conservative
63
+ ? bandSpans(region, y, lineHeight, bandSteps)
64
+ : region.spansAt(y + lineHeight / 2);
65
+ let spans = rawSpans.filter((s) => s[1] - s[0] >= minSpanWidth);
66
+ if (spans.length > 0) {
67
+ if (multiSpan === 'widest')
68
+ spans = [widestSpan(spans)];
69
+ else if (multiSpan === 'first')
70
+ spans = [spans[0]];
71
+ let spanIndex = 0;
72
+ let placedThisRow = false;
73
+ for (const span of spans) {
74
+ const x0 = span[0];
75
+ const x1 = span[1];
76
+ const width = x1 - x0;
77
+ const line = source.nextLine(cursor, width);
78
+ if (line === null) {
79
+ exhausted = true;
80
+ break;
81
+ }
82
+ if (line.text.length === 0)
83
+ continue; // span too small to make progress; skip it
84
+ let x = x0;
85
+ if (align === 'right')
86
+ x = x1 - line.width;
87
+ else if (align === 'center')
88
+ x = x0 + (width - line.width) / 2;
89
+ // 'justify' is left-anchored: x stays x0 (same as 'left').
90
+ // Compute justified word positions when applicable.
91
+ // Last-line detection: probe one more line from this line's end. This materializes an
92
+ // extra line under Pretext 0.0.1; when the range API ships (measureLineStats / walkLineRanges),
93
+ // replace this with a non-materializing stats call to avoid the extra work.
94
+ let justifiedWords;
95
+ if (align === 'justify' && line.words !== undefined && line.words.length > 1) {
96
+ const probeForLast = source.nextLine(line.end, width);
97
+ const isLastLine = probeForLast === null || probeForLast.text.length === 0;
98
+ if (!isLastLine) {
99
+ // Distribute span width across words: word k's absolute x = x0 + sumWidths[0..k-1] + k*gap.
100
+ const spanWidth = x1 - x0;
101
+ const sumW = line.words.reduce((acc, w) => acc + w.width, 0);
102
+ const gap = (spanWidth - sumW) / (line.words.length - 1);
103
+ // Single left-to-right pass: each word sits after the prior words plus k justified gaps.
104
+ let priorWidths = 0;
105
+ justifiedWords = line.words.map((w, k) => {
106
+ const seg = { text: w.text, x: x0 + priorWidths + k * gap, width: w.width };
107
+ priorWidths += w.width;
108
+ return seg;
109
+ });
110
+ }
111
+ }
112
+ lines.push({
113
+ text: line.text,
114
+ x,
115
+ y,
116
+ baseline: y + ascent,
117
+ width: line.width,
118
+ rowIndex,
119
+ spanIndex,
120
+ start: line.start,
121
+ end: line.end,
122
+ softHyphenated: line.softHyphenated,
123
+ words: justifiedWords,
124
+ });
125
+ cursor = line.end;
126
+ spanIndex++;
127
+ placedThisRow = true;
128
+ }
129
+ if (placedThisRow)
130
+ lastContentBottom = y + lineHeight;
131
+ }
132
+ y += lineHeight;
133
+ rowIndex++;
134
+ }
135
+ // Overflow = text remained when we ran out of vertical room. Probing does not mutate the source.
136
+ let overflow = false;
137
+ if (!exhausted) {
138
+ const probeWidth = Math.max(1, bounds.maxX - bounds.minX);
139
+ const probe = source.nextLine(cursor, probeWidth);
140
+ overflow = probe !== null && probe.text.length > 0;
141
+ }
142
+ return { lines, overflow, endCursor: cursor, height: lastContentBottom - startY };
143
+ }
144
+ /**
145
+ * Holds a source so the (expensive) prepare pass is done once, and re-flows cheaply when only the
146
+ * region or options change. This is the reactive-region perf story: build once, reflow() per frame.
147
+ */
148
+ export class ShapeFlow {
149
+ source;
150
+ options;
151
+ constructor(source, options) {
152
+ this.source = source;
153
+ this.options = options;
154
+ }
155
+ flow(region) {
156
+ return shapeFlow(this.source, region, this.options);
157
+ }
158
+ reflow(region, optionsPatch) {
159
+ return shapeFlow(this.source, region, { ...this.options, ...optionsPatch });
160
+ }
161
+ /**
162
+ * Like `reflow`, but first binary-searches for the narrowest width that preserves the
163
+ * unconstrained line count, then flows through the capped region. This reduces the ragged edge
164
+ * by avoiding a near-empty last line.
165
+ *
166
+ * Delegates to `balancedFlow` from balance.ts so `NarrowedRegion` stays private there.
167
+ */
168
+ rebalance(region, optionsPatch) {
169
+ const merged = { ...this.options, ...optionsPatch };
170
+ return balancedFlow(this.source, region, merged);
171
+ }
172
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Glyph-outline region — the opentype.js seam.
3
+ *
4
+ * This module keeps `opentype.js` (and any font-loading library) entirely out of the
5
+ * `textpour` package. The caller is responsible for loading a font and converting a
6
+ * glyph to SVG path data; this module then wraps the path into a geometry `Region`.
7
+ *
8
+ * Typical caller-side usage with opentype.js:
9
+ * ```ts
10
+ * import opentype from 'opentype.js';
11
+ * import { glyphToRegion } from 'textpour';
12
+ *
13
+ * const font = await opentype.load('MyFont.otf');
14
+ * const glyph = font.charToGlyph('A');
15
+ * const pathData = glyph.getPath(x, y, fontSize).toPathData();
16
+ * const region = glyphToRegion(pathData);
17
+ * ```
18
+ *
19
+ * Note: opentype.js renders glyph paths with Y-axis flipped relative to CSS (glyph
20
+ * coordinates increase upward). Callers should pass a negative `y` origin or apply a
21
+ * scaling transform via `getPath(x, y, fontSize)` to position the glyph in CSS px
22
+ * coordinates before calling `toPathData()`.
23
+ */
24
+ import type { Region } from './region.js';
25
+ /**
26
+ * An array of [x, y] points representing one contour of a glyph.
27
+ * Exported as a convenience type for callers who pre-process glyph outlines before
28
+ * passing path data to `glyphToRegion`.
29
+ */
30
+ export type GlyphContour = Array<[number, number]>;
31
+ /**
32
+ * Convert SVG path data from a glyph outline into a `Region`.
33
+ *
34
+ * Pass `glyph.getPath(x, y, fontSize).toPathData()` from opentype.js here.
35
+ * The kernel remains dependency-free — opentype.js stays on the caller's side.
36
+ *
37
+ * @param pathData SVG path `d` attribute string (M/L/C/Q/Z commands).
38
+ * @param opts Optional flattening options (default `steps` = 24 per curve segment).
39
+ */
40
+ export declare function glyphToRegion(pathData: string, opts?: {
41
+ steps?: number;
42
+ }): Region;