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
|
@@ -0,0 +1,36 @@
|
|
|
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 { svgPathToRegion } from './outline-region.js';
|
|
25
|
+
/**
|
|
26
|
+
* Convert SVG path data from a glyph outline into a `Region`.
|
|
27
|
+
*
|
|
28
|
+
* Pass `glyph.getPath(x, y, fontSize).toPathData()` from opentype.js here.
|
|
29
|
+
* The kernel remains dependency-free — opentype.js stays on the caller's side.
|
|
30
|
+
*
|
|
31
|
+
* @param pathData SVG path `d` attribute string (M/L/C/Q/Z commands).
|
|
32
|
+
* @param opts Optional flattening options (default `steps` = 24 per curve segment).
|
|
33
|
+
*/
|
|
34
|
+
export function glyphToRegion(pathData, opts) {
|
|
35
|
+
return svgPathToRegion(pathData, opts);
|
|
36
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conservative heuristic soft-hyphen insertion.
|
|
3
|
+
*
|
|
4
|
+
* IMPORTANT: this is a HEURISTIC, NOT a dictionary hyphenator. It does not consult any word list,
|
|
5
|
+
* pronunciation data, or language-specific hyphenation rules. It uses simple vowel→consonant
|
|
6
|
+
* boundary detection for Latin text only. For professional-quality hyphenation, use a dictionary
|
|
7
|
+
* hyphenator (e.g. Hyphenopoly / hypher) and pre-process your text before calling `insertSoftHyphens`.
|
|
8
|
+
*
|
|
9
|
+
* The `locale` option is reserved for future use (it is passed to `Intl.Segmenter` but has no effect
|
|
10
|
+
* on the heuristic itself, which is purely structural for now).
|
|
11
|
+
*
|
|
12
|
+
* INVARIANT: `insertSoftHyphens(t).replaceAll('', '') === t` for all string inputs.
|
|
13
|
+
*/
|
|
14
|
+
export interface InsertSoftHyphensOptions {
|
|
15
|
+
/** Minimum word length (chars) before inserting any soft hyphens. Default: 8. */
|
|
16
|
+
minWordLength?: number;
|
|
17
|
+
/** Never insert within the first N characters of a word. Default: 3. */
|
|
18
|
+
minPrefix?: number;
|
|
19
|
+
/** Never insert within the last N characters of a word. Default: 3. */
|
|
20
|
+
minSuffix?: number;
|
|
21
|
+
/** Reserved for future use — passed to `Intl.Segmenter`. Default: undefined (host locale). */
|
|
22
|
+
locale?: string;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Insert U+00AD (soft hyphen) at heuristic break points in long Latin words.
|
|
26
|
+
*
|
|
27
|
+
* Only Latin alphabetic words (`/^[A-Za-z]+$/`) of at least `minWordLength` characters are
|
|
28
|
+
* processed. Within such words, soft hyphens are placed at vowel→consonant boundaries, never
|
|
29
|
+
* within the first `minPrefix` or last `minSuffix` characters, and never two in a row. All other
|
|
30
|
+
* text (numbers, punctuation, CJK, emoji, short words) passes through unchanged.
|
|
31
|
+
*
|
|
32
|
+
* @param text The input string.
|
|
33
|
+
* @param opts Optional configuration (see {@link InsertSoftHyphensOptions}).
|
|
34
|
+
* @returns A copy of `text` with soft hyphens inserted. Stripping all U+00AD restores
|
|
35
|
+
* the original: `result.replaceAll('', '') === text`.
|
|
36
|
+
*/
|
|
37
|
+
export declare function insertSoftHyphens(text: string, opts?: InsertSoftHyphensOptions): string;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conservative heuristic soft-hyphen insertion.
|
|
3
|
+
*
|
|
4
|
+
* IMPORTANT: this is a HEURISTIC, NOT a dictionary hyphenator. It does not consult any word list,
|
|
5
|
+
* pronunciation data, or language-specific hyphenation rules. It uses simple vowel→consonant
|
|
6
|
+
* boundary detection for Latin text only. For professional-quality hyphenation, use a dictionary
|
|
7
|
+
* hyphenator (e.g. Hyphenopoly / hypher) and pre-process your text before calling `insertSoftHyphens`.
|
|
8
|
+
*
|
|
9
|
+
* The `locale` option is reserved for future use (it is passed to `Intl.Segmenter` but has no effect
|
|
10
|
+
* on the heuristic itself, which is purely structural for now).
|
|
11
|
+
*
|
|
12
|
+
* INVARIANT: `insertSoftHyphens(t).replaceAll('', '') === t` for all string inputs.
|
|
13
|
+
*/
|
|
14
|
+
const SOFT_HYPHEN = '';
|
|
15
|
+
const VOWELS = new Set(['a', 'e', 'i', 'o', 'u', 'A', 'E', 'I', 'O', 'U']);
|
|
16
|
+
const LATIN_WORD = /^[A-Za-z]+$/;
|
|
17
|
+
/**
|
|
18
|
+
* Insert U+00AD (soft hyphen) at heuristic break points in long Latin words.
|
|
19
|
+
*
|
|
20
|
+
* Only Latin alphabetic words (`/^[A-Za-z]+$/`) of at least `minWordLength` characters are
|
|
21
|
+
* processed. Within such words, soft hyphens are placed at vowel→consonant boundaries, never
|
|
22
|
+
* within the first `minPrefix` or last `minSuffix` characters, and never two in a row. All other
|
|
23
|
+
* text (numbers, punctuation, CJK, emoji, short words) passes through unchanged.
|
|
24
|
+
*
|
|
25
|
+
* @param text The input string.
|
|
26
|
+
* @param opts Optional configuration (see {@link InsertSoftHyphensOptions}).
|
|
27
|
+
* @returns A copy of `text` with soft hyphens inserted. Stripping all U+00AD restores
|
|
28
|
+
* the original: `result.replaceAll('', '') === text`.
|
|
29
|
+
*/
|
|
30
|
+
export function insertSoftHyphens(text, opts = {}) {
|
|
31
|
+
const minWordLength = opts.minWordLength ?? 8;
|
|
32
|
+
const minPrefix = opts.minPrefix ?? 3;
|
|
33
|
+
const minSuffix = opts.minSuffix ?? 3;
|
|
34
|
+
const locale = opts.locale;
|
|
35
|
+
const segmenter = new Intl.Segmenter(locale, { granularity: 'word' });
|
|
36
|
+
const segments = segmenter.segment(text);
|
|
37
|
+
let result = '';
|
|
38
|
+
for (const seg of segments) {
|
|
39
|
+
const word = seg.segment;
|
|
40
|
+
if (seg.isWordLike &&
|
|
41
|
+
LATIN_WORD.test(word) &&
|
|
42
|
+
word.length >= minWordLength) {
|
|
43
|
+
result += hyphenateWord(word, minPrefix, minSuffix);
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
result += word;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return result;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Insert soft hyphens into a single Latin word at vowel→consonant boundaries.
|
|
53
|
+
* Never within first `minPrefix` or last `minSuffix` chars; never two in a row.
|
|
54
|
+
*/
|
|
55
|
+
function hyphenateWord(word, minPrefix, minSuffix) {
|
|
56
|
+
const len = word.length;
|
|
57
|
+
// The zone where we may insert: [minPrefix, len - minSuffix)
|
|
58
|
+
// If that range is empty, return unchanged.
|
|
59
|
+
if (minPrefix + minSuffix >= len)
|
|
60
|
+
return word;
|
|
61
|
+
let out = '';
|
|
62
|
+
let lastInsert = -2; // index of last soft-hyphen insertion (prevent consecutive)
|
|
63
|
+
for (let i = 0; i < len; i++) {
|
|
64
|
+
out += word[i];
|
|
65
|
+
// Candidate position is AFTER character i, i.e. between i and i+1.
|
|
66
|
+
// Must be within the allowed zone: i+1 >= minPrefix AND i+1 <= len - minSuffix
|
|
67
|
+
// i.e. i >= minPrefix - 1 AND i < len - minSuffix
|
|
68
|
+
const afterI = i + 1;
|
|
69
|
+
if (afterI >= minPrefix &&
|
|
70
|
+
afterI <= len - minSuffix &&
|
|
71
|
+
i !== lastInsert + 1 // no two in a row
|
|
72
|
+
) {
|
|
73
|
+
const cur = word[i];
|
|
74
|
+
const next = word[i + 1];
|
|
75
|
+
// Vowel→consonant boundary: current is a vowel, next is a consonant.
|
|
76
|
+
if (next !== undefined && VOWELS.has(cur) && !VOWELS.has(next)) {
|
|
77
|
+
out += SOFT_HYPHEN;
|
|
78
|
+
lastInsert = i;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export type { Interval, Bounds, MultiSpan, Align, FlowOptions, PlacedLine, FlowResult, WordSegment, } from './types.js';
|
|
2
|
+
export { normalize, unionSpans, intersectSpans, subtractSpans, RectRegion, CircleRegion, EllipseRegion, PolygonRegion, CompositeRegion, rect, circle, ellipse, polygon, union, intersect, subtract, } from './region.js';
|
|
3
|
+
export type { Region } from './region.js';
|
|
4
|
+
export { MonospaceLineSource } from './line-source.js';
|
|
5
|
+
export type { Line, LineSource, MonoCursor } from './line-source.js';
|
|
6
|
+
export { PretextLineSource } from './pretext-source.js';
|
|
7
|
+
export { buildPrefixWidths, xToGraphemeIndex, graphemeIndexToX, canvasMeasurer, } from './prefix-widths.js';
|
|
8
|
+
export type { TextMeasurer } from './prefix-widths.js';
|
|
9
|
+
export { shapeFlow, ShapeFlow } from './flow.js';
|
|
10
|
+
export { balanceWidth, balancedFlow } from './balance.js';
|
|
11
|
+
export { autoFit } from './auto-fit.js';
|
|
12
|
+
export type { AutoFitOptions, AutoFitResult } from './auto-fit.js';
|
|
13
|
+
export { insertSoftHyphens } from './hyphen.js';
|
|
14
|
+
export type { InsertSoftHyphensOptions } from './hyphen.js';
|
|
15
|
+
export { Canvas2DRenderer, HtmlInCanvasRenderer, } from './renderer.js';
|
|
16
|
+
export type { Renderer, Canvas2DLike, HtmlInCanvasTarget } from './renderer.js';
|
|
17
|
+
export { svgPathToPolygon, svgPathToRegion, maskRegion } from './outline-region.js';
|
|
18
|
+
export { glyphToRegion } from './glyph-region.js';
|
|
19
|
+
export type { GlyphContour } from './glyph-region.js';
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export {
|
|
2
|
+
// interval algebra
|
|
3
|
+
normalize, unionSpans, intersectSpans, subtractSpans,
|
|
4
|
+
// region primitives
|
|
5
|
+
RectRegion, CircleRegion, EllipseRegion, PolygonRegion, CompositeRegion,
|
|
6
|
+
// builders
|
|
7
|
+
rect, circle, ellipse, polygon, union, intersect, subtract, } from './region.js';
|
|
8
|
+
export { MonospaceLineSource } from './line-source.js';
|
|
9
|
+
export { PretextLineSource } from './pretext-source.js';
|
|
10
|
+
export { buildPrefixWidths, xToGraphemeIndex, graphemeIndexToX, canvasMeasurer, } from './prefix-widths.js';
|
|
11
|
+
export { shapeFlow, ShapeFlow } from './flow.js';
|
|
12
|
+
export { balanceWidth, balancedFlow } from './balance.js';
|
|
13
|
+
export { autoFit } from './auto-fit.js';
|
|
14
|
+
export { insertSoftHyphens } from './hyphen.js';
|
|
15
|
+
export { Canvas2DRenderer, HtmlInCanvasRenderer, } from './renderer.js';
|
|
16
|
+
export { svgPathToPolygon, svgPathToRegion, maskRegion } from './outline-region.js';
|
|
17
|
+
export { glyphToRegion } from './glyph-region.js';
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { WordSegment } from './types.js';
|
|
2
|
+
/** One line produced by a source, with cursors back into the source. */
|
|
3
|
+
export interface Line<C> {
|
|
4
|
+
text: string;
|
|
5
|
+
width: number;
|
|
6
|
+
start: C;
|
|
7
|
+
end: C;
|
|
8
|
+
/** True when a soft hyphen (U+00AD) was chosen as the line-break point and a visible '-' was appended. */
|
|
9
|
+
softHyphenated?: boolean;
|
|
10
|
+
/**
|
|
11
|
+
* Per-word segments with x relative to the LINE LEFT (not canvas). Each x is the natural offset
|
|
12
|
+
* of the word from the start of the line; width does NOT include surrounding spaces. Populated by
|
|
13
|
+
* sources that can cheaply compute word offsets (e.g. MonospaceLineSource). When present and
|
|
14
|
+
* align==='justify', flow.ts replaces these relative x values with absolute canvas x positions
|
|
15
|
+
* on PlacedLine.words.
|
|
16
|
+
*/
|
|
17
|
+
words?: WordSegment[];
|
|
18
|
+
}
|
|
19
|
+
export interface LineSource<C> {
|
|
20
|
+
/** The cursor at the very start of the text. */
|
|
21
|
+
start(): C;
|
|
22
|
+
/**
|
|
23
|
+
* Return the next line that fits within `maxWidth` starting from `cursor`,
|
|
24
|
+
* or null when the text is exhausted. Implementations MUST make progress
|
|
25
|
+
* (emit >= 1 grapheme) whenever text remains, even if maxWidth is tiny.
|
|
26
|
+
*/
|
|
27
|
+
nextLine(cursor: C, maxWidth: number): Line<C> | null;
|
|
28
|
+
}
|
|
29
|
+
/** Cursor for the monospace source: an index into the grapheme array. */
|
|
30
|
+
export interface MonoCursor {
|
|
31
|
+
i: number;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Deterministic, dependency-free source for unit tests and offline demos.
|
|
35
|
+
* Fixed advance width per grapheme, word-aware greedy wrapping with hard-break fallback.
|
|
36
|
+
* Supports U+00AD soft hyphens: unchosen soft hyphens are invisible (zero width); when a soft
|
|
37
|
+
* hyphen is chosen as the break point the displayed text gains a trailing '-' and softHyphenated=true.
|
|
38
|
+
* Not for production rendering — it ignores kerning, shaping, bidi, and real fonts.
|
|
39
|
+
*/
|
|
40
|
+
export declare class MonospaceLineSource implements LineSource<MonoCursor> {
|
|
41
|
+
private charWidth;
|
|
42
|
+
private graphemes;
|
|
43
|
+
constructor(text: string, charWidth?: number);
|
|
44
|
+
start(): MonoCursor;
|
|
45
|
+
nextLine(cursor: MonoCursor, maxWidth: number): Line<MonoCursor> | null;
|
|
46
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// The seam that decouples the shape-flow orchestrator from any specific text engine.
|
|
2
|
+
// The orchestrator only knows LineSource, so it is testable in Node with no canvas/Pretext,
|
|
3
|
+
// and the same seam lets an HTML-in-Canvas planner reuse the geometry without changes.
|
|
4
|
+
/**
|
|
5
|
+
* Build a WordSegment array from a displayed text line for a monospace source.
|
|
6
|
+
* Scans for maximal non-space runs; x is the character-offset * charWidth (natural offset from
|
|
7
|
+
* the LINE LEFT), width is the run's character count * charWidth.
|
|
8
|
+
* The trailing '-' from a soft-hyphen break is considered part of its last word.
|
|
9
|
+
* Single-token lines get a one-element array.
|
|
10
|
+
*/
|
|
11
|
+
function monoWords(text, charWidth) {
|
|
12
|
+
const chars = Array.from(text);
|
|
13
|
+
const words = [];
|
|
14
|
+
let i = 0;
|
|
15
|
+
while (i < chars.length) {
|
|
16
|
+
if (chars[i] === ' ') {
|
|
17
|
+
i++;
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
const start = i;
|
|
21
|
+
while (i < chars.length && chars[i] !== ' ')
|
|
22
|
+
i++;
|
|
23
|
+
const run = chars.slice(start, i).join('');
|
|
24
|
+
words.push({ text: run, x: start * charWidth, width: run.length * charWidth });
|
|
25
|
+
}
|
|
26
|
+
return words;
|
|
27
|
+
}
|
|
28
|
+
const SOFT_HYPHEN = '';
|
|
29
|
+
/**
|
|
30
|
+
* Deterministic, dependency-free source for unit tests and offline demos.
|
|
31
|
+
* Fixed advance width per grapheme, word-aware greedy wrapping with hard-break fallback.
|
|
32
|
+
* Supports U+00AD soft hyphens: unchosen soft hyphens are invisible (zero width); when a soft
|
|
33
|
+
* hyphen is chosen as the break point the displayed text gains a trailing '-' and softHyphenated=true.
|
|
34
|
+
* Not for production rendering — it ignores kerning, shaping, bidi, and real fonts.
|
|
35
|
+
*/
|
|
36
|
+
export class MonospaceLineSource {
|
|
37
|
+
charWidth;
|
|
38
|
+
graphemes;
|
|
39
|
+
constructor(text, charWidth = 10) {
|
|
40
|
+
this.charWidth = charWidth;
|
|
41
|
+
this.graphemes = Array.from(text); // code-point granularity is sufficient for tests
|
|
42
|
+
}
|
|
43
|
+
start() {
|
|
44
|
+
return { i: 0 };
|
|
45
|
+
}
|
|
46
|
+
nextLine(cursor, maxWidth) {
|
|
47
|
+
const g = this.graphemes;
|
|
48
|
+
let i = cursor.i;
|
|
49
|
+
if (i >= g.length)
|
|
50
|
+
return null;
|
|
51
|
+
// Collapse leading spaces at the start of a line (browser-like).
|
|
52
|
+
// Leading soft hyphens are NOT collapsed — they are invisible but not whitespace.
|
|
53
|
+
while (i < g.length && g[i] === ' ')
|
|
54
|
+
i++;
|
|
55
|
+
if (i >= g.length)
|
|
56
|
+
return null;
|
|
57
|
+
// Discard any stray leading soft hyphens (invisible, zero-width).
|
|
58
|
+
while (i < g.length && g[i] === SOFT_HYPHEN)
|
|
59
|
+
i++;
|
|
60
|
+
if (i >= g.length)
|
|
61
|
+
return null;
|
|
62
|
+
const maxChars = Math.max(1, Math.floor(maxWidth / this.charWidth));
|
|
63
|
+
const lineStart = i;
|
|
64
|
+
// Walk forward, counting only VISIBLE characters (excluding soft hyphens).
|
|
65
|
+
// Track the latest break opportunity: space index OR soft-hyphen index.
|
|
66
|
+
// breakKind: 'space' means break before lastBreak, 'softhyphen' means break after it (append '-').
|
|
67
|
+
let end = i;
|
|
68
|
+
let visibleCount = 0;
|
|
69
|
+
let lastBreakEnd = -1; // source index AFTER the break (where next line starts)
|
|
70
|
+
let lastBreakKind = null;
|
|
71
|
+
while (end < g.length) {
|
|
72
|
+
const ch = g[end];
|
|
73
|
+
if (ch === SOFT_HYPHEN) {
|
|
74
|
+
// A soft hyphen is a break opportunity. If appending '-' would still fit, record it.
|
|
75
|
+
// The '-' counts as 1 visible char.
|
|
76
|
+
if (visibleCount + 1 <= maxChars) {
|
|
77
|
+
lastBreakEnd = end + 1; // consume the soft hyphen
|
|
78
|
+
lastBreakKind = 'softhyphen';
|
|
79
|
+
}
|
|
80
|
+
end++;
|
|
81
|
+
// Soft hyphen is NOT counted in visibleCount.
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (ch === ' ') {
|
|
85
|
+
// Space is a break opportunity (break BEFORE it, existing behavior).
|
|
86
|
+
lastBreakEnd = end;
|
|
87
|
+
lastBreakKind = 'space';
|
|
88
|
+
}
|
|
89
|
+
if (visibleCount >= maxChars) {
|
|
90
|
+
// Reached the limit — don't consume this character.
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
visibleCount++;
|
|
94
|
+
end++;
|
|
95
|
+
}
|
|
96
|
+
// Determine if we stopped because we ran out of characters (exhausted) or hit the limit.
|
|
97
|
+
const hitLimit = end < g.length && g[end] !== ' ' && g[end] !== SOFT_HYPHEN;
|
|
98
|
+
// If we hit the limit mid-word and have an earlier break, back up to it.
|
|
99
|
+
if (hitLimit && lastBreakEnd > lineStart && lastBreakKind !== null) {
|
|
100
|
+
if (lastBreakKind === 'space') {
|
|
101
|
+
// Break before the space: end = space index, next line starts there.
|
|
102
|
+
const spaceIdx = lastBreakEnd;
|
|
103
|
+
const raw = g.slice(lineStart, spaceIdx).filter(c => c !== SOFT_HYPHEN).join('');
|
|
104
|
+
const text = raw.replace(/\s+$/u, '');
|
|
105
|
+
const width = Array.from(text).length * this.charWidth;
|
|
106
|
+
return { text, width, start: { i: lineStart }, end: { i: spaceIdx }, words: monoWords(text, this.charWidth) };
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
// lastBreakKind === 'softhyphen'
|
|
110
|
+
// Break at/after the soft hyphen: append '-', next line starts after soft hyphen.
|
|
111
|
+
const raw = g.slice(lineStart, lastBreakEnd - 1).filter(c => c !== SOFT_HYPHEN).join('');
|
|
112
|
+
const text = raw + '-';
|
|
113
|
+
const width = Array.from(text).length * this.charWidth;
|
|
114
|
+
return { text, width, softHyphenated: true, start: { i: lineStart }, end: { i: lastBreakEnd }, words: monoWords(text, this.charWidth) };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
// No overflow or no usable break — take everything up to `end`, strip soft hyphens.
|
|
118
|
+
// Guarantee at least 1 visible grapheme progress (skip all-soft-hyphen edge case).
|
|
119
|
+
if (end === lineStart) {
|
|
120
|
+
// All remaining chars from lineStart were soft hyphens with no visible char — force progress.
|
|
121
|
+
end = lineStart + 1;
|
|
122
|
+
}
|
|
123
|
+
const raw = g.slice(lineStart, end).filter(c => c !== SOFT_HYPHEN).join('');
|
|
124
|
+
const text = raw.replace(/\s+$/u, '');
|
|
125
|
+
// If text is empty but end advanced, we need to guarantee progress on the cursor.
|
|
126
|
+
if (text.length === 0) {
|
|
127
|
+
// Skip to end (all soft hyphens / spaces — shouldn't normally happen post the leading collapse).
|
|
128
|
+
const width = 0;
|
|
129
|
+
return { text: '', width, start: { i: lineStart }, end: { i: end } };
|
|
130
|
+
}
|
|
131
|
+
const width = Array.from(text).length * this.charWidth;
|
|
132
|
+
return { text, width, start: { i: lineStart }, end: { i: end }, words: monoWords(text, this.charWidth) };
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Region } from './region.js';
|
|
2
|
+
/**
|
|
3
|
+
* Parse a minimal subset of SVG path data into a flat array of [x, y] points.
|
|
4
|
+
*
|
|
5
|
+
* Supported commands: M/m, L/l, H/h, V/v, C/c, Q/q, Z/z (absolute + relative).
|
|
6
|
+
* Cubic (C) and quadratic (Q) Béziers are flattened with `opts.steps` subdivisions
|
|
7
|
+
* per segment (default 24).
|
|
8
|
+
*
|
|
9
|
+
* Multiple subpaths are concatenated (acceptable for even-odd polygon fill).
|
|
10
|
+
*/
|
|
11
|
+
export declare function svgPathToPolygon(d: string, opts?: {
|
|
12
|
+
steps?: number;
|
|
13
|
+
}): Array<[number, number]>;
|
|
14
|
+
/**
|
|
15
|
+
* Convenience: parse an SVG path string into a `Region` (via `PolygonRegion`, even-odd fill).
|
|
16
|
+
*/
|
|
17
|
+
export declare function svgPathToRegion(d: string, opts?: {
|
|
18
|
+
steps?: number;
|
|
19
|
+
}): Region;
|
|
20
|
+
/**
|
|
21
|
+
* Build a `Region` from a raster alpha mask.
|
|
22
|
+
*
|
|
23
|
+
* @param width Pixel width of the mask grid.
|
|
24
|
+
* @param height Pixel height of the mask grid.
|
|
25
|
+
* @param alpha Row-major array of alpha values (`alpha[row * width + col]`).
|
|
26
|
+
* @param threshold Minimum alpha to count as "inside" (default 128, half-transparent).
|
|
27
|
+
* @param originX X offset of the mask's top-left corner in canvas space (default 0).
|
|
28
|
+
* @param originY Y offset of the mask's top-left corner in canvas space (default 0).
|
|
29
|
+
*/
|
|
30
|
+
export declare function maskRegion(width: number, height: number, alpha: Uint8ClampedArray | number[], threshold?: number, originX?: number, originY?: number): Region;
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { polygon } from './region.js';
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// SVG path tokenizer
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
/**
|
|
6
|
+
* Tokenize an SVG `d` attribute string into commands and numeric arguments.
|
|
7
|
+
* Handles: M/m L/l H/h V/v C/c Q/q Z/z (absolute + relative variants).
|
|
8
|
+
* Numbers may be delimited by whitespace, commas, or sign characters.
|
|
9
|
+
*/
|
|
10
|
+
function* tokenizePath(d) {
|
|
11
|
+
const re = /([MmLlHhVvCcQqZz])|([+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)/g;
|
|
12
|
+
let m;
|
|
13
|
+
while ((m = re.exec(d)) !== null) {
|
|
14
|
+
if (m[1] !== undefined) {
|
|
15
|
+
yield m[1];
|
|
16
|
+
}
|
|
17
|
+
else if (m[2] !== undefined) {
|
|
18
|
+
yield parseFloat(m[2]);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// De Casteljau flattening helpers
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
/** Evaluate a cubic Bézier at parameter t. */
|
|
26
|
+
function cubicBezier(p0, p1, p2, p3, t) {
|
|
27
|
+
const mt = 1 - t;
|
|
28
|
+
const mt2 = mt * mt;
|
|
29
|
+
const t2 = t * t;
|
|
30
|
+
return [
|
|
31
|
+
mt2 * mt * p0[0] + 3 * mt2 * t * p1[0] + 3 * mt * t2 * p2[0] + t2 * t * p3[0],
|
|
32
|
+
mt2 * mt * p0[1] + 3 * mt2 * t * p1[1] + 3 * mt * t2 * p2[1] + t2 * t * p3[1],
|
|
33
|
+
];
|
|
34
|
+
}
|
|
35
|
+
/** Evaluate a quadratic Bézier at parameter t. */
|
|
36
|
+
function quadBezier(p0, p1, p2, t) {
|
|
37
|
+
const mt = 1 - t;
|
|
38
|
+
return [
|
|
39
|
+
mt * mt * p0[0] + 2 * mt * t * p1[0] + t * t * p2[0],
|
|
40
|
+
mt * mt * p0[1] + 2 * mt * t * p1[1] + t * t * p2[1],
|
|
41
|
+
];
|
|
42
|
+
}
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// SVG path flattener
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
/**
|
|
47
|
+
* Parse a minimal subset of SVG path data into a flat array of [x, y] points.
|
|
48
|
+
*
|
|
49
|
+
* Supported commands: M/m, L/l, H/h, V/v, C/c, Q/q, Z/z (absolute + relative).
|
|
50
|
+
* Cubic (C) and quadratic (Q) Béziers are flattened with `opts.steps` subdivisions
|
|
51
|
+
* per segment (default 24).
|
|
52
|
+
*
|
|
53
|
+
* Multiple subpaths are concatenated (acceptable for even-odd polygon fill).
|
|
54
|
+
*/
|
|
55
|
+
export function svgPathToPolygon(d, opts) {
|
|
56
|
+
const steps = opts?.steps ?? 24;
|
|
57
|
+
const pts = [];
|
|
58
|
+
const tokens = [...tokenizePath(d)];
|
|
59
|
+
let i = 0;
|
|
60
|
+
let cx = 0; // current x
|
|
61
|
+
let cy = 0; // current y
|
|
62
|
+
let subpathStartX = 0;
|
|
63
|
+
let subpathStartY = 0;
|
|
64
|
+
let cmd = '';
|
|
65
|
+
function nextNum() {
|
|
66
|
+
while (i < tokens.length && typeof tokens[i] === 'string')
|
|
67
|
+
i++;
|
|
68
|
+
if (i >= tokens.length)
|
|
69
|
+
throw new Error('SVG path: expected number');
|
|
70
|
+
return tokens[i++];
|
|
71
|
+
}
|
|
72
|
+
function addPoint(x, y) {
|
|
73
|
+
pts.push([x, y]);
|
|
74
|
+
}
|
|
75
|
+
while (i < tokens.length) {
|
|
76
|
+
const tok = tokens[i];
|
|
77
|
+
if (typeof tok === 'string') {
|
|
78
|
+
cmd = tok;
|
|
79
|
+
i++;
|
|
80
|
+
}
|
|
81
|
+
// Implicit line-to: after M, subsequent coords are L; after m, they are l.
|
|
82
|
+
const impliedCmd = cmd === 'M' ? 'L' : cmd === 'm' ? 'l' : cmd;
|
|
83
|
+
switch (cmd) {
|
|
84
|
+
case 'M':
|
|
85
|
+
case 'm': {
|
|
86
|
+
const x = nextNum();
|
|
87
|
+
const y = nextNum();
|
|
88
|
+
if (cmd === 'M') {
|
|
89
|
+
cx = x;
|
|
90
|
+
cy = y;
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
cx += x;
|
|
94
|
+
cy += y;
|
|
95
|
+
}
|
|
96
|
+
subpathStartX = cx;
|
|
97
|
+
subpathStartY = cy;
|
|
98
|
+
addPoint(cx, cy);
|
|
99
|
+
// Switch to implied L/l for subsequent coordinate pairs.
|
|
100
|
+
cmd = cmd === 'M' ? 'L' : 'l';
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
case 'L':
|
|
104
|
+
case 'l': {
|
|
105
|
+
const x = nextNum();
|
|
106
|
+
const y = nextNum();
|
|
107
|
+
if (impliedCmd === 'L') {
|
|
108
|
+
cx = x;
|
|
109
|
+
cy = y;
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
cx += x;
|
|
113
|
+
cy += y;
|
|
114
|
+
}
|
|
115
|
+
addPoint(cx, cy);
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
case 'H':
|
|
119
|
+
case 'h': {
|
|
120
|
+
const x = nextNum();
|
|
121
|
+
cx = cmd === 'H' ? x : cx + x;
|
|
122
|
+
addPoint(cx, cy);
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
case 'V':
|
|
126
|
+
case 'v': {
|
|
127
|
+
const y = nextNum();
|
|
128
|
+
cy = cmd === 'V' ? y : cy + y;
|
|
129
|
+
addPoint(cx, cy);
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
case 'C':
|
|
133
|
+
case 'c': {
|
|
134
|
+
const x1 = nextNum();
|
|
135
|
+
const y1 = nextNum();
|
|
136
|
+
const x2 = nextNum();
|
|
137
|
+
const y2 = nextNum();
|
|
138
|
+
const x = nextNum();
|
|
139
|
+
const y = nextNum();
|
|
140
|
+
const p0 = [cx, cy];
|
|
141
|
+
const p1 = cmd === 'C' ? [x1, y1] : [cx + x1, cy + y1];
|
|
142
|
+
const p2 = cmd === 'C' ? [x2, y2] : [cx + x2, cy + y2];
|
|
143
|
+
const p3 = cmd === 'C' ? [x, y] : [cx + x, cy + y];
|
|
144
|
+
for (let s = 1; s <= steps; s++) {
|
|
145
|
+
addPoint(...cubicBezier(p0, p1, p2, p3, s / steps));
|
|
146
|
+
}
|
|
147
|
+
cx = p3[0];
|
|
148
|
+
cy = p3[1];
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
case 'Q':
|
|
152
|
+
case 'q': {
|
|
153
|
+
const x1 = nextNum();
|
|
154
|
+
const y1 = nextNum();
|
|
155
|
+
const x = nextNum();
|
|
156
|
+
const y = nextNum();
|
|
157
|
+
const p0 = [cx, cy];
|
|
158
|
+
const p1 = cmd === 'Q' ? [x1, y1] : [cx + x1, cy + y1];
|
|
159
|
+
const p2 = cmd === 'Q' ? [x, y] : [cx + x, cy + y];
|
|
160
|
+
for (let s = 1; s <= steps; s++) {
|
|
161
|
+
addPoint(...quadBezier(p0, p1, p2, s / steps));
|
|
162
|
+
}
|
|
163
|
+
cx = p2[0];
|
|
164
|
+
cy = p2[1];
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
case 'Z':
|
|
168
|
+
case 'z': {
|
|
169
|
+
// Close path: line back to subpath start (add the close point so the polygon closes).
|
|
170
|
+
addPoint(subpathStartX, subpathStartY);
|
|
171
|
+
cx = subpathStartX;
|
|
172
|
+
cy = subpathStartY;
|
|
173
|
+
// Reset cmd so the next token is always read as a command.
|
|
174
|
+
cmd = '';
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
default:
|
|
178
|
+
// Unknown command — skip to next command token.
|
|
179
|
+
i++;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return pts;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Convenience: parse an SVG path string into a `Region` (via `PolygonRegion`, even-odd fill).
|
|
186
|
+
*/
|
|
187
|
+
export function svgPathToRegion(d, opts) {
|
|
188
|
+
return polygon(svgPathToPolygon(d, opts));
|
|
189
|
+
}
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
// Alpha-mask region
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
class MaskRegion {
|
|
194
|
+
width;
|
|
195
|
+
height;
|
|
196
|
+
alpha;
|
|
197
|
+
threshold;
|
|
198
|
+
originX;
|
|
199
|
+
originY;
|
|
200
|
+
bb;
|
|
201
|
+
constructor(width, height, alpha, threshold, originX, originY) {
|
|
202
|
+
this.width = width;
|
|
203
|
+
this.height = height;
|
|
204
|
+
this.alpha = alpha;
|
|
205
|
+
this.threshold = threshold ?? 128;
|
|
206
|
+
this.originX = originX ?? 0;
|
|
207
|
+
this.originY = originY ?? 0;
|
|
208
|
+
this.bb = {
|
|
209
|
+
minX: this.originX,
|
|
210
|
+
minY: this.originY,
|
|
211
|
+
maxX: this.originX + width,
|
|
212
|
+
maxY: this.originY + height,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
spansAt(y) {
|
|
216
|
+
const row = Math.floor(y - this.originY);
|
|
217
|
+
if (row < 0 || row >= this.height)
|
|
218
|
+
return [];
|
|
219
|
+
const out = [];
|
|
220
|
+
let inRun = false;
|
|
221
|
+
let runStart = 0;
|
|
222
|
+
const { width, alpha, threshold, originX } = this;
|
|
223
|
+
for (let col = 0; col < width; col++) {
|
|
224
|
+
const inside = (alpha[row * width + col] ?? 0) >= threshold;
|
|
225
|
+
if (inside && !inRun) {
|
|
226
|
+
inRun = true;
|
|
227
|
+
runStart = col;
|
|
228
|
+
}
|
|
229
|
+
else if (!inside && inRun) {
|
|
230
|
+
out.push([originX + runStart, originX + col]);
|
|
231
|
+
inRun = false;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
if (inRun) {
|
|
235
|
+
out.push([originX + runStart, originX + width]);
|
|
236
|
+
}
|
|
237
|
+
return out;
|
|
238
|
+
}
|
|
239
|
+
bounds() {
|
|
240
|
+
return this.bb;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Build a `Region` from a raster alpha mask.
|
|
245
|
+
*
|
|
246
|
+
* @param width Pixel width of the mask grid.
|
|
247
|
+
* @param height Pixel height of the mask grid.
|
|
248
|
+
* @param alpha Row-major array of alpha values (`alpha[row * width + col]`).
|
|
249
|
+
* @param threshold Minimum alpha to count as "inside" (default 128, half-transparent).
|
|
250
|
+
* @param originX X offset of the mask's top-left corner in canvas space (default 0).
|
|
251
|
+
* @param originY Y offset of the mask's top-left corner in canvas space (default 0).
|
|
252
|
+
*/
|
|
253
|
+
export function maskRegion(width, height, alpha, threshold, originX, originY) {
|
|
254
|
+
return new MaskRegion(width, height, alpha, threshold, originX, originY);
|
|
255
|
+
}
|