ntk 2.2.0 → 3.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.
Files changed (47) hide show
  1. package/README.md +156 -151
  2. package/lib/app.js +64 -21
  3. package/lib/cleanup.js +14 -0
  4. package/lib/drawable.js +12 -18
  5. package/lib/events_map.js +92 -98
  6. package/lib/fontconfig.js +123 -17
  7. package/lib/glyphset.js +40 -21
  8. package/lib/image.js +138 -0
  9. package/lib/index.js +129 -57
  10. package/lib/path.js +734 -0
  11. package/lib/picture.js +84 -41
  12. package/lib/pixmap.js +50 -39
  13. package/lib/rasterize.js +178 -0
  14. package/lib/renderingcontext_2d.js +1078 -695
  15. package/lib/renderingcontext_opengl.js +53 -46
  16. package/lib/renderingcontext_x11.js +59 -67
  17. package/lib/text/font.js +196 -0
  18. package/lib/text/fontmanager.js +233 -0
  19. package/lib/text/fontsource.js +179 -0
  20. package/lib/text/glyphs.js +456 -0
  21. package/lib/text/layout.js +356 -0
  22. package/lib/text/shape.js +141 -0
  23. package/lib/trapezoid.js +131 -0
  24. package/lib/widgets/css.js +676 -0
  25. package/lib/widgets/highlight.js +97 -0
  26. package/lib/widgets/htmlview.js +879 -0
  27. package/lib/widgets/markdown.js +148 -0
  28. package/lib/widgets/markdownview.js +487 -0
  29. package/lib/widgets/mermaid.js +720 -0
  30. package/lib/widgets/svgview.js +528 -0
  31. package/lib/widgets/tex.js +681 -0
  32. package/lib/window.js +691 -308
  33. package/package.json +60 -56
  34. package/examples/example-await.js +0 -23
  35. package/examples/example-co.js +0 -13
  36. package/examples/example.js +0 -14
  37. package/examples/fill-styles.js +0 -39
  38. package/examples/glclock.js +0 -100
  39. package/examples/glxpixmap.js +0 -12
  40. package/examples/gradient.js +0 -41
  41. package/examples/package.json +0 -17
  42. package/examples/simpletex.js +0 -72
  43. package/examples/teapot.js +0 -104
  44. package/examples/teapot.json +0 -6
  45. package/examples/text-rendering.js +0 -44
  46. package/lib/fontface.js +0 -103
  47. package/lib/widgets/webview.js +0 -33
@@ -0,0 +1,356 @@
1
+ import LineBreaker from 'linebreak';
2
+
3
+ import { drawGlyphRuns } from './glyphs.js';
4
+ import { embeddingLevels, reorderRuns } from './shape.js';
5
+
6
+ const WS = new Set([0x20, 0x09, 0xa0]);
7
+ const HARD_BREAKS = /[\n\r\u2028\u2029]+$/;
8
+ const TRAILING_WS = /[ \t\u00a0]+$/;
9
+
10
+ function isWsGlyph(g) {
11
+ return g.codePoints.length > 0 && g.codePoints.every((cp) => WS.has(cp));
12
+ }
13
+
14
+ /**
15
+ * Multi-line text layout: breaks (possibly styled) text into lines for a
16
+ * target container width, with full shaping — kerning, ligatures, complex
17
+ * scripts, bidi and font fallback all apply.
18
+ *
19
+ * Line-break opportunities follow UAX#14 (via the `linebreak` package);
20
+ * shaping happens per inter-break segment (so results are cached and reused
21
+ * across relayouts), and bidi reordering is applied per line (UAX#9 L2).
22
+ *
23
+ * Content is a plain string or an array of spans
24
+ * `{ text, family?, size?, weight?, style?, features?, language?, color? }`;
25
+ * span fields override the base style. Options:
26
+ *
27
+ * - `maxWidth` — target container width (default: unlimited)
28
+ * - `align` — 'left' | 'right' | 'center' | 'start' | 'end'
29
+ * - `lineHeight` — multiplier over natural font line height (default 1)
30
+ * - `direction` — 'ltr' | 'rtl' | 'auto' base paragraph direction
31
+ *
32
+ * The result is inspectable before/without drawing: `width`, `height`, and
33
+ * `lines[] = { x, y, baseline, width, ascent, descent, runs }` with
34
+ * `runs[] = { x, width, run, span }` in visual order.
35
+ */
36
+ export class TextLayout {
37
+ constructor(fonts, content, style = {}, options = {}) {
38
+ this.fonts = fonts;
39
+ this.options = options;
40
+ const maxWidth = options.maxWidth ?? Infinity;
41
+
42
+ // ---- normalize spans, resolve fonts eagerly ----
43
+ // unknown span fields ride along untouched (widgets attach markers like
44
+ // MarkdownView's _deco/_href and read them back from line runs)
45
+ const spans = (typeof content === 'string' ? [{ text: content }] : content).map((s) => {
46
+ const merged = {
47
+ ...s,
48
+ text: String(s.text ?? ''),
49
+ family: s.family ?? style.family ?? 'sans-serif',
50
+ size: s.size ?? style.size ?? 16,
51
+ weight: s.weight ?? style.weight,
52
+ style: s.style ?? style.style,
53
+ features: s.features ?? style.features,
54
+ language: s.language ?? style.language,
55
+ color: s.color ?? style.color ?? null
56
+ };
57
+ merged.font = s.font ?? style.font ?? fonts.match(merged.family, merged);
58
+ return merged;
59
+ });
60
+
61
+ const text = spans.map((s) => s.text).join('');
62
+ const emb = embeddingLevels(text, options.direction);
63
+ const levels = emb.levels;
64
+ this.baseLevel = emb.paragraphs.length ? emb.paragraphs[0].level & 1 : 0;
65
+
66
+ // span boundaries, for binary search by char position
67
+ const spanStarts = [];
68
+ {
69
+ let pos = 0;
70
+ for (const s of spans) {
71
+ spanStarts.push(pos);
72
+ pos += s.text.length;
73
+ }
74
+ }
75
+ const spanAt = (pos) => {
76
+ let lo = 0;
77
+ let hi = spanStarts.length - 1;
78
+ while (lo < hi) {
79
+ const mid = (lo + hi + 1) >> 1;
80
+ if (spanStarts[mid] <= pos) lo = mid;
81
+ else hi = mid - 1;
82
+ }
83
+ return {
84
+ span: spans[lo],
85
+ end: lo + 1 < spanStarts.length ? spanStarts[lo + 1] : text.length
86
+ };
87
+ };
88
+
89
+ // ---- tokenize at UAX#14 break opportunities ----
90
+ const tokens = [];
91
+ {
92
+ const breaker = new LineBreaker(text);
93
+ let prev = 0;
94
+ let bk;
95
+ while ((bk = breaker.nextBreak())) {
96
+ tokens.push(this._makeToken(text, prev, bk.position, levels, spanAt, bk.required));
97
+ prev = bk.position;
98
+ }
99
+ if (prev < text.length || tokens.length === 0) {
100
+ tokens.push(this._makeToken(text, prev, text.length, levels, spanAt, false));
101
+ }
102
+ }
103
+
104
+ // ---- greedy line fill ----
105
+ const lineTokens = [];
106
+ {
107
+ let cur = [];
108
+ let curWidth = 0;
109
+ const flush = () => {
110
+ lineTokens.push(cur);
111
+ cur = [];
112
+ curWidth = 0;
113
+ };
114
+ for (let token of tokens) {
115
+ while (curWidth + token.width - token.wsWidth > maxWidth) {
116
+ if (cur.length > 0) {
117
+ flush();
118
+ } else {
119
+ // single token wider than the container: force-break it
120
+ const [head, rest] = this._forceBreak(token, maxWidth);
121
+ if (!head) break; // not even one cluster fits: let it overflow
122
+ cur.push(head);
123
+ flush();
124
+ token = rest;
125
+ }
126
+ }
127
+ cur.push(token);
128
+ curWidth += token.width;
129
+ if (token.required) flush();
130
+ }
131
+ if (cur.length) lineTokens.push(cur);
132
+ }
133
+
134
+ // ---- assemble lines: strip trailing ws, bidi-reorder, position ----
135
+ const baseSpan = spans[0];
136
+ const lineHeightMul = options.lineHeight ?? 1;
137
+ this.lines = [];
138
+ let y = 0;
139
+ let layoutWidth = 0;
140
+
141
+ for (const toks of lineTokens) {
142
+ // entries carry .level so reorderRuns can order them (UAX#9 L2)
143
+ let entries = [];
144
+ for (const token of toks) {
145
+ for (const frag of token.fragments) {
146
+ for (const run of frag.shaped.runs) {
147
+ entries.push({ run, span: frag.span, level: run.level });
148
+ }
149
+ }
150
+ }
151
+ stripTrailingWhitespace(entries);
152
+ entries = reorderRuns(entries);
153
+
154
+ let ascent = 0;
155
+ let descent = 0;
156
+ let natural = 0;
157
+ const runs = [];
158
+ let x = 0;
159
+ for (const e of entries) {
160
+ runs.push({ x, width: e.run.width, run: e.run, span: e.span });
161
+ x += e.run.width;
162
+ const m = e.run.font.metrics(e.run.size);
163
+ if (m.ascent > ascent) ascent = m.ascent;
164
+ if (m.descent > descent) descent = m.descent;
165
+ if (m.lineHeight > natural) natural = m.lineHeight;
166
+ }
167
+ if (runs.length === 0) {
168
+ // empty line (blank paragraph): base style metrics
169
+ const m = baseSpan.font.metrics(baseSpan.size);
170
+ ascent = m.ascent;
171
+ descent = m.descent;
172
+ natural = m.lineHeight;
173
+ }
174
+ if (x > layoutWidth) layoutWidth = x;
175
+ this.lines.push({ x: 0, y, baseline: y + ascent, width: x, ascent, descent, runs });
176
+ y += natural * lineHeightMul;
177
+ }
178
+
179
+ this.width = layoutWidth;
180
+ this.height = y;
181
+
182
+ // ---- horizontal alignment ----
183
+ const container = Number.isFinite(maxWidth) ? maxWidth : this.width;
184
+ let align = options.align ?? 'start';
185
+ if (align === 'start') align = this.baseLevel ? 'right' : 'left';
186
+ if (align === 'end') align = this.baseLevel ? 'left' : 'right';
187
+ if (align !== 'left') {
188
+ for (const line of this.lines) {
189
+ const free = container - line.width;
190
+ line.x = align === 'right' ? free : free / 2;
191
+ }
192
+ }
193
+ }
194
+
195
+ _makeToken(text, start, end, levels, spanAt, required) {
196
+ const fragments = [];
197
+ let width = 0;
198
+ let pos = start;
199
+ while (pos < end) {
200
+ const { span, end: spanEnd } = spanAt(pos);
201
+ const fragEnd = Math.min(end, spanEnd);
202
+ // hard-break controls only terminate the line; never shape them
203
+ const fragText = text.slice(pos, fragEnd).replace(HARD_BREAKS, '');
204
+ if (fragText.length > 0) {
205
+ const fragLevels = normalizedLevels(levels, pos, pos + fragText.length);
206
+ const shaped = this.fonts._shapeCached(fragText, span, fragLevels);
207
+ fragments.push({ text: fragText, span, shaped });
208
+ width += shaped.width;
209
+ }
210
+ pos = fragEnd;
211
+ }
212
+ // trailing whitespace does not count against the container width
213
+ let wsWidth = 0;
214
+ const last = fragments[fragments.length - 1];
215
+ if (last) {
216
+ const m = TRAILING_WS.exec(last.text);
217
+ if (m) {
218
+ const spaceGlyph = last.span.font.fk.glyphForCodePoint(0x20);
219
+ wsWidth = m[0].length * spaceGlyph.advanceWidth * last.span.font.scale(last.span.size);
220
+ }
221
+ }
222
+ return { fragments, width, wsWidth, required };
223
+ }
224
+
225
+ // split an over-wide token at the widest cluster boundary that fits
226
+ _forceBreak(token, maxWidth) {
227
+ const headFrags = [];
228
+ let used = 0;
229
+ for (let i = 0; i < token.fragments.length; i++) {
230
+ const frag = token.fragments[i];
231
+ if (used + frag.shaped.width <= maxWidth) {
232
+ headFrags.push(frag);
233
+ used += frag.shaped.width;
234
+ continue;
235
+ }
236
+ // binary search the longest codepoint prefix of this fragment that fits
237
+ const cps = Array.from(frag.text);
238
+ let lo = 0;
239
+ let hi = cps.length - 1;
240
+ let best = null;
241
+ while (lo <= hi) {
242
+ const mid = (lo + hi) >> 1;
243
+ const prefix = cps.slice(0, mid + 1).join('');
244
+ const shaped = this.fonts._shapeCached(prefix, frag.span, '0');
245
+ if (used + shaped.width <= maxWidth) {
246
+ best = { len: prefix.length, shaped, text: prefix };
247
+ lo = mid + 1;
248
+ } else {
249
+ hi = mid - 1;
250
+ }
251
+ }
252
+ if (best) headFrags.push({ text: best.text, span: frag.span, shaped: best.shaped });
253
+
254
+ const restFrags = [];
255
+ const restText = frag.text.slice(best ? best.len : 0);
256
+ if (restText) {
257
+ restFrags.push({
258
+ text: restText,
259
+ span: frag.span,
260
+ shaped: this.fonts._shapeCached(restText, frag.span, '0')
261
+ });
262
+ }
263
+ restFrags.push(...token.fragments.slice(i + 1));
264
+ const sum = (frags) => frags.reduce((w, f) => w + f.shaped.width, 0);
265
+ const head = headFrags.length
266
+ ? { fragments: headFrags, width: sum(headFrags), wsWidth: 0, required: false }
267
+ : null;
268
+ const rest = {
269
+ fragments: restFrags,
270
+ width: sum(restFrags),
271
+ wsWidth: token.wsWidth,
272
+ required: token.required
273
+ };
274
+ return [head, rest];
275
+ }
276
+ // everything fit after all (float rounding): no split needed
277
+ return [
278
+ { ...token, required: false },
279
+ { fragments: [], width: 0, wsWidth: 0, required: token.required }
280
+ ];
281
+ }
282
+
283
+ /**
284
+ * Draw onto a 2d context at (x, y) = top-left of the layout box.
285
+ * Span `color`s override the context fillStyle; consecutive same-color
286
+ * runs are batched into single CompositeGlyphs requests.
287
+ */
288
+ draw(ctx, x = 0, y = 0) {
289
+ const app = ctx.window.app;
290
+ const Render = app.display.Render;
291
+ for (const line of this.lines) {
292
+ let batch = [];
293
+ let batchColor;
294
+ const flush = () => {
295
+ if (batch.length === 0) return;
296
+ const src = batchColor ? ctx._stylePicture(batchColor) : ctx._backgroundPicture;
297
+ drawGlyphRuns(app, Render.PictOp.Over, src.id, ctx.picture.id, batch);
298
+ batch = [];
299
+ };
300
+ for (const r of line.runs) {
301
+ const color = r.span.color;
302
+ if (batch.length && color !== batchColor) flush();
303
+ batchColor = color;
304
+ batch.push({ run: r.run, x: x + line.x + r.x, y: y + line.baseline });
305
+ }
306
+ flush();
307
+ }
308
+ ctx._markDirty();
309
+ return this;
310
+ }
311
+ }
312
+
313
+ // Drop trailing whitespace glyphs from the logical end of a line.
314
+ // Shaped runs are shared via the shaping cache — clone instead of mutating.
315
+ function stripTrailingWhitespace(entries) {
316
+ for (let i = entries.length - 1; i >= 0; i--) {
317
+ const run = entries[i].run;
318
+ // rtl runs store glyphs in visual order: their logical end is index 0
319
+ const fromFront = run.direction === 'rtl';
320
+ let count = 0;
321
+ let wsWidth = 0;
322
+ while (count < run.glyphs.length) {
323
+ const g = run.glyphs[fromFront ? count : run.glyphs.length - 1 - count];
324
+ if (!isWsGlyph(g)) break;
325
+ count++;
326
+ wsWidth += g.ax;
327
+ }
328
+ if (count === 0) return;
329
+ if (count === run.glyphs.length) {
330
+ entries.splice(i, 1);
331
+ continue;
332
+ }
333
+ entries[i] = {
334
+ ...entries[i],
335
+ run: {
336
+ ...run,
337
+ glyphs: fromFront ? run.glyphs.slice(count) : run.glyphs.slice(0, -count),
338
+ width: run.width - wsWidth
339
+ }
340
+ };
341
+ return;
342
+ }
343
+ }
344
+
345
+ // compact levels key for the shaping cache: single char when uniform
346
+ function normalizedLevels(levels, start, end) {
347
+ let uniform = true;
348
+ for (let i = start + 1; i < end; i++) {
349
+ if (levels[i] !== levels[start]) {
350
+ uniform = false;
351
+ break;
352
+ }
353
+ }
354
+ if (uniform) return String(levels[start] ?? 0);
355
+ return Array.prototype.slice.call(levels, start, end).join(',');
356
+ }
@@ -0,0 +1,141 @@
1
+ import bidiFactory from 'bidi-js';
2
+
3
+ const bidi = bidiFactory();
4
+
5
+ // characters that never force a font switch: whitespace and default-ignorable
6
+ // format controls stay with the surrounding font
7
+ function neverSwitches(cp) {
8
+ return (
9
+ cp === 0x20 ||
10
+ cp === 0x09 ||
11
+ cp === 0x0a ||
12
+ cp === 0x0d ||
13
+ cp === 0xa0 ||
14
+ (cp >= 0x200b && cp <= 0x200f) || // zw space/nonjoiner/joiner, lrm/rlm
15
+ (cp >= 0x202a && cp <= 0x202e) || // bidi embedding controls
16
+ (cp >= 0x2066 && cp <= 0x2069) || // bidi isolate controls
17
+ (cp >= 0xfe00 && cp <= 0xfe0f) || // variation selectors
18
+ cp === 0xfeff
19
+ );
20
+ }
21
+
22
+ /**
23
+ * UAX#9 embedding levels for a string. `direction` is 'ltr', 'rtl' or 'auto'
24
+ * (first-strong detection). Returns bidi-js's result object.
25
+ */
26
+ export function embeddingLevels(text, direction = 'auto') {
27
+ const explicit = direction === 'ltr' || direction === 'rtl' ? direction : null;
28
+ return bidi.getEmbeddingLevels(text, explicit);
29
+ }
30
+
31
+ /**
32
+ * Shape a string through the full pipeline:
33
+ *
34
+ * 1. bidi resolution (UAX#9, bidi-js) — unless precomputed `levels` are
35
+ * passed in (TextLayout resolves whole paragraphs at once)
36
+ * 2. itemization: split into maximal runs of same bidi level and same font,
37
+ * switching to a fallback font for codepoints the primary font lacks
38
+ * 3. OpenType shaping per run (fontkit): kerning, ligatures, contextual
39
+ * forms; RTL runs come back in visual glyph order
40
+ *
41
+ * Runs are returned in **logical order**, each tagged with its bidi `level`;
42
+ * use `reorderRuns()` to get visual order for drawing.
43
+ *
44
+ * @param {FontManager} fonts
45
+ * @param {string} text
46
+ * @param {object} style { font | family/weight/style, size, features,
47
+ * language, direction: 'ltr'|'rtl'|'auto' }
48
+ * @param {ArrayLike<number>} [levels] precomputed embedding levels for `text`
49
+ * @returns {{text, width, baseLevel, runs: Array<{font, size, direction,
50
+ * level, width, glyphs, text, start, end}>}}
51
+ */
52
+ export function shapeText(fonts, text, style, levels) {
53
+ const size = style.size ?? 16;
54
+ const family = style.family ?? 'sans-serif';
55
+ const baseFont = style.font ?? fonts.match(family, style);
56
+
57
+ let baseLevel = 0;
58
+ if (!levels) {
59
+ const emb = embeddingLevels(text, style.direction);
60
+ levels = emb.levels;
61
+ baseLevel = emb.paragraphs.length ? emb.paragraphs[0].level : 0;
62
+ } else {
63
+ baseLevel = levels.length ? levels[0] & ~1 : 0;
64
+ }
65
+
66
+ // itemize: walk codepoints, splitting where bidi level or font changes
67
+ const items = []; // { font, level, start, end }
68
+ let cur = null;
69
+ let curFont = baseFont;
70
+ let i = 0;
71
+ for (const ch of text) {
72
+ const cp = ch.codePointAt(0);
73
+ const level = levels[i] ?? baseLevel;
74
+ let font = curFont;
75
+ if (!neverSwitches(cp)) {
76
+ // the primary font always wins when it covers the char, so fallback
77
+ // runs stay as short as possible
78
+ if (baseFont.hasGlyph(cp)) font = baseFont;
79
+ else if (!font.hasGlyph(cp)) font = fonts.fallbackFor(cp, family, style) ?? baseFont;
80
+ }
81
+ if (cur && cur.font === font && cur.level === level) {
82
+ cur.end = i + ch.length;
83
+ } else {
84
+ cur = { font, level, start: i, end: i + ch.length };
85
+ items.push(cur);
86
+ }
87
+ curFont = font;
88
+ i += ch.length;
89
+ }
90
+
91
+ const runs = [];
92
+ let width = 0;
93
+ for (const item of items) {
94
+ const direction = item.level & 1 ? 'rtl' : 'ltr';
95
+ const sub = text.slice(item.start, item.end);
96
+ const shaped = item.font.shape(sub, size, {
97
+ direction,
98
+ features: style.features,
99
+ language: style.language
100
+ });
101
+ shaped.level = item.level;
102
+ shaped.text = sub;
103
+ shaped.start = item.start;
104
+ shaped.end = item.end;
105
+ width += shaped.width;
106
+ runs.push(shaped);
107
+ }
108
+
109
+ return { text, width, baseLevel, runs };
110
+ }
111
+
112
+ /**
113
+ * Reorder logical runs into visual (left-to-right drawing) order —
114
+ * UAX#9 rule L2 applied at run granularity.
115
+ */
116
+ export function reorderRuns(runs) {
117
+ if (runs.length < 2) return runs.slice();
118
+ let max = 0;
119
+ let minOdd = Infinity;
120
+ for (const r of runs) {
121
+ if (r.level > max) max = r.level;
122
+ if (r.level & 1 && r.level < minOdd) minOdd = r.level;
123
+ }
124
+ const out = runs.slice();
125
+ for (let level = max; level >= minOdd; level--) {
126
+ let start = -1;
127
+ for (let j = 0; j <= out.length; j++) {
128
+ if (j < out.length && out[j].level >= level) {
129
+ if (start === -1) start = j;
130
+ } else if (start !== -1) {
131
+ for (let a = start, b = j - 1; a < b; a++, b--) {
132
+ const tmp = out[a];
133
+ out[a] = out[b];
134
+ out[b] = tmp;
135
+ }
136
+ start = -1;
137
+ }
138
+ }
139
+ }
140
+ return out;
141
+ }
@@ -0,0 +1,131 @@
1
+ // Trapezoidal decomposition of flattened closed polygons (non-zero winding
2
+ // by default, even-odd optionally), producing XRender AddTraps data.
3
+ //
4
+ // A horizontal slab sweep: slab boundaries are the sorted unique vertex y
5
+ // coordinates, so edges never cross inside a slab (self-intersecting input —
6
+ // e.g. overlapping composite-glyph contours — degrades gracefully to a
7
+ // near-correct fill instead of failing). Spans where the winding number is
8
+ // non-zero become trapezoids; a span whose left/right edges continue through
9
+ // consecutive slabs is merged into a single tall trapezoid, which is what
10
+ // keeps the output at roughly one trapezoid per outline edge.
11
+ //
12
+ // Output is the flat number list node-x11's Render.AddTraps expects:
13
+ // 6 values per trapezoid — top spanfix (left x, right x, y) then bottom
14
+ // spanfix (left x, right x, y), y-down, top.y < bottom.y.
15
+
16
+ /**
17
+ * @param {Array<Array<number>>} polys closed polygons as flat [x0,y0,x1,y1,…]
18
+ * @param {number} [dx] translation applied to every x
19
+ * @param {number} [dy] translation applied to every y
20
+ * @param {Array<number>} [out] existing array to append trapezoid data to
21
+ * @param {'nonzero'|'evenodd'} [rule] fill rule (default non-zero winding)
22
+ * @returns {Array<number>} `out`
23
+ */
24
+ export function trapezoidize(polys, dx = 0, dy = 0, out = [], rule = 'nonzero') {
25
+ const evenodd = rule === 'evenodd';
26
+ // collect non-horizontal edges, normalized to y0 < y1 with winding dir
27
+ const edges = [];
28
+ const ys = [];
29
+ for (const poly of polys) {
30
+ const n = poly.length / 2;
31
+ for (let i = 0; i < n; ++i) {
32
+ const j = (i + 1) % n;
33
+ const ax = poly[i * 2] + dx;
34
+ const ay = poly[i * 2 + 1] + dy;
35
+ const bx = poly[j * 2] + dx;
36
+ const by = poly[j * 2 + 1] + dy;
37
+ if (ay === by) continue;
38
+ edges.push(
39
+ ay < by
40
+ ? { x0: ax, y0: ay, x1: bx, y1: by, dir: 1, xa: 0, xb: 0 }
41
+ : { x0: bx, y0: by, x1: ax, y1: ay, dir: -1, xa: 0, xb: 0 }
42
+ );
43
+ ys.push(ay, by);
44
+ }
45
+ }
46
+ if (edges.length === 0) return out;
47
+
48
+ ys.sort((a, b) => a - b);
49
+ edges.sort((a, b) => a.y0 - b.y0);
50
+
51
+ const active = [];
52
+ let nextEdge = 0;
53
+ // spans open from the previous slab, for vertical merging:
54
+ // { l: leftEdge, r: rightEdge, at: index of the trap in `out` }
55
+ let prevSpans = [];
56
+
57
+ for (let yi = 0; yi < ys.length - 1; ++yi) {
58
+ const ya = ys[yi];
59
+ const yb = ys[yi + 1];
60
+ if (yb === ya) continue;
61
+
62
+ // update the active edge list for this slab
63
+ for (let i = active.length - 1; i >= 0; --i) {
64
+ if (active[i].y1 <= ya) active.splice(i, 1);
65
+ }
66
+ while (nextEdge < edges.length && edges[nextEdge].y0 <= ya) {
67
+ if (edges[nextEdge].y1 > ya) active.push(edges[nextEdge]);
68
+ nextEdge++;
69
+ }
70
+ if (active.length === 0) {
71
+ prevSpans = [];
72
+ continue;
73
+ }
74
+
75
+ // x at slab top/bottom for each active edge; sort left-to-right
76
+ for (const e of active) {
77
+ const inv = (e.x1 - e.x0) / (e.y1 - e.y0);
78
+ e.xa = e.x0 + (ya - e.y0) * inv;
79
+ e.xb = e.x0 + (yb - e.y0) * inv;
80
+ }
81
+ active.sort((a, b) => a.xa + a.xb - (b.xa + b.xb));
82
+
83
+ // emit a span per maximal interior interval; interiority is decided by
84
+ // the fill rule (non-zero winding, or crossing parity for even-odd)
85
+ const spans = [];
86
+ let winding = 0;
87
+ let crossings = 0;
88
+ let left = null;
89
+ for (const e of active) {
90
+ const wasInside = evenodd ? (crossings & 1) === 1 : winding !== 0;
91
+ winding += e.dir;
92
+ crossings++;
93
+ const isInside = evenodd ? (crossings & 1) === 1 : winding !== 0;
94
+ if (!wasInside && isInside) {
95
+ left = e;
96
+ } else if (wasInside && !isInside && left) {
97
+ // merge with a span from the previous slab bounded by the same edges
98
+ let merged = false;
99
+ for (const p of prevSpans) {
100
+ if (p.l === left && p.r === e && out[p.at + 5] === ya) {
101
+ out[p.at + 3] = left.xb;
102
+ out[p.at + 4] = e.xb;
103
+ out[p.at + 5] = yb;
104
+ spans.push(p);
105
+ merged = true;
106
+ break;
107
+ }
108
+ }
109
+ if (!merged && (e.xa > left.xa || e.xb > left.xb)) {
110
+ spans.push({ l: left, r: e, at: out.length });
111
+ out.push(left.xa, e.xa, ya, left.xb, e.xb, yb);
112
+ }
113
+ left = null;
114
+ }
115
+ }
116
+ prevSpans = spans;
117
+ }
118
+ return out;
119
+ }
120
+
121
+ /** area covered by a trapezoid list (useful for tests/diagnostics) */
122
+ export function trapArea(traps) {
123
+ let area = 0;
124
+ for (let i = 0; i < traps.length; i += 6) {
125
+ const [tl, tr, ty, bl, br, by] = [
126
+ traps[i], traps[i + 1], traps[i + 2], traps[i + 3], traps[i + 4], traps[i + 5]
127
+ ];
128
+ area += ((tr - tl + (br - bl)) / 2) * (by - ty);
129
+ }
130
+ return area;
131
+ }