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 +21 -0
- package/README.md +96 -0
- package/dist/src/auto-fit.d.ts +59 -0
- package/dist/src/auto-fit.js +74 -0
- package/dist/src/balance.d.ts +48 -0
- package/dist/src/balance.js +98 -0
- package/dist/src/flow.d.ts +34 -0
- package/dist/src/flow.js +172 -0
- package/dist/src/glyph-region.d.ts +42 -0
- package/dist/src/glyph-region.js +36 -0
- package/dist/src/hyphen.d.ts +37 -0
- package/dist/src/hyphen.js +83 -0
- package/dist/src/index.d.ts +19 -0
- package/dist/src/index.js +17 -0
- package/dist/src/line-source.d.ts +46 -0
- package/dist/src/line-source.js +134 -0
- package/dist/src/outline-region.d.ts +30 -0
- package/dist/src/outline-region.js +255 -0
- package/dist/src/prefix-widths.d.ts +22 -0
- package/dist/src/prefix-widths.js +61 -0
- package/dist/src/pretext-source.d.ts +24 -0
- package/dist/src/pretext-source.js +66 -0
- package/dist/src/region.d.ts +62 -0
- package/dist/src/region.js +229 -0
- package/dist/src/renderer.d.ts +47 -0
- package/dist/src/renderer.js +37 -0
- package/dist/src/types.d.ts +85 -0
- package/dist/src/types.js +5 -0
- package/package.json +39 -0
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
|
+

|
|
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
|
+
}
|
package/dist/src/flow.js
ADDED
|
@@ -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;
|