pa_font 0.2.7 → 0.3.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/dist/paFont.js CHANGED
@@ -3,127 +3,52 @@ import { load, parse } from "opentype.js";
3
3
  function toScreenPoint(point, scale) {
4
4
  return [point.x * scale, -point.y * scale];
5
5
  }
6
+ function cloneRawPoint(point) {
7
+ return {
8
+ x: point.x,
9
+ y: point.y
10
+ };
11
+ }
6
12
  function pushUniquePoint(points, point) {
7
13
  if (points.length === 0 || !pointsEqual2D(points[points.length - 1], point)) points.push(point);
8
14
  }
9
- function flattenQuadratic(p0, p1, p2, tolerance, out) {
10
- let sp = 0;
11
- const stack = _quadStack;
12
- stack[sp++] = p0[0];
13
- stack[sp++] = p0[1];
14
- stack[sp++] = p1[0];
15
- stack[sp++] = p1[1];
16
- stack[sp++] = p2[0];
17
- stack[sp++] = p2[1];
18
- stack[sp++] = 0;
19
- while (sp > 0) {
20
- const depth = stack[--sp];
21
- const ax2 = stack[--sp];
22
- const ay2 = stack[--sp];
23
- const ax1 = stack[--sp];
24
- const ay1 = stack[--sp];
25
- const ax0 = stack[--sp];
26
- const ay0 = stack[--sp];
27
- if (depth >= 12 || _quadFlatnessInline(ax0, ay0, ax1, ay1, ax2, ay2) <= tolerance) {
28
- pushUniquePoint(out, [ax2, ay2]);
29
- continue;
30
- }
31
- const m01x = (ax0 + ax1) * .5, m01y = (ay0 + ay1) * .5;
32
- const m12x = (ax1 + ax2) * .5, m12y = (ay1 + ay2) * .5;
33
- const mx = (m01x + m12x) * .5, my = (m01y + m12y) * .5;
34
- const d1 = depth + 1;
35
- stack[sp++] = mx;
36
- stack[sp++] = my;
37
- stack[sp++] = m12x;
38
- stack[sp++] = m12y;
39
- stack[sp++] = ax2;
40
- stack[sp++] = ay2;
41
- stack[sp++] = d1;
42
- stack[sp++] = ax0;
43
- stack[sp++] = ay0;
44
- stack[sp++] = m01x;
45
- stack[sp++] = m01y;
46
- stack[sp++] = mx;
47
- stack[sp++] = my;
48
- stack[sp++] = d1;
49
- }
50
- }
51
- function flattenCubic(p0, p1, p2, p3, tolerance, out) {
52
- let sp = 0;
53
- const stack = _cubicStack;
54
- stack[sp++] = p0[0];
55
- stack[sp++] = p0[1];
56
- stack[sp++] = p1[0];
57
- stack[sp++] = p1[1];
58
- stack[sp++] = p2[0];
59
- stack[sp++] = p2[1];
60
- stack[sp++] = p3[0];
61
- stack[sp++] = p3[1];
62
- stack[sp++] = 0;
63
- while (sp > 0) {
64
- const depth = stack[--sp];
65
- const bx3 = stack[--sp];
66
- const by3 = stack[--sp];
67
- const bx2 = stack[--sp];
68
- const by2 = stack[--sp];
69
- const bx1 = stack[--sp];
70
- const by1 = stack[--sp];
71
- const bx0 = stack[--sp];
72
- const by0 = stack[--sp];
73
- if (depth >= 12 || _cubicFlatnessInline(bx0, by0, bx1, by1, bx2, by2, bx3, by3) <= tolerance) {
74
- pushUniquePoint(out, [bx3, by3]);
75
- continue;
76
- }
77
- const m01x = (bx0 + bx1) * .5, m01y = (by0 + by1) * .5;
78
- const m12x = (bx1 + bx2) * .5, m12y = (by1 + by2) * .5;
79
- const m23x = (bx2 + bx3) * .5, m23y = (by2 + by3) * .5;
80
- const m012x = (m01x + m12x) * .5, m012y = (m01y + m12y) * .5;
81
- const m123x = (m12x + m23x) * .5, m123y = (m12y + m23y) * .5;
82
- const mx = (m012x + m123x) * .5, my = (m012y + m123y) * .5;
83
- const d1 = depth + 1;
84
- stack[sp++] = mx;
85
- stack[sp++] = my;
86
- stack[sp++] = m123x;
87
- stack[sp++] = m123y;
88
- stack[sp++] = m23x;
89
- stack[sp++] = m23y;
90
- stack[sp++] = bx3;
91
- stack[sp++] = by3;
92
- stack[sp++] = d1;
93
- stack[sp++] = bx0;
94
- stack[sp++] = by0;
95
- stack[sp++] = m01x;
96
- stack[sp++] = m01y;
97
- stack[sp++] = m012x;
98
- stack[sp++] = m012y;
99
- stack[sp++] = mx;
100
- stack[sp++] = my;
101
- stack[sp++] = d1;
102
- }
103
- }
104
- var _quadStack = new Float64Array(896);
105
- var _cubicStack = new Float64Array(1152);
106
- function _quadFlatnessInline(x0, y0, x1, y1, x2, y2) {
107
- const dx = x2 - x0, dy = y2 - y0;
108
- const lenSq = dx * dx + dy * dy;
109
- if (lenSq === 0) {
110
- const ex = x1 - x0, ey = y1 - y0;
111
- return Math.sqrt(ex * ex + ey * ey);
112
- }
113
- return Math.abs(dx * (y0 - y1) - (x0 - x1) * dy) / Math.sqrt(lenSq);
114
- }
115
- function _cubicFlatnessInline(x0, y0, x1, y1, x2, y2, x3, y3) {
116
- const dx = x3 - x0, dy = y3 - y0;
117
- const lenSq = dx * dx + dy * dy;
118
- if (lenSq === 0) {
119
- const e1x = x1 - x0, e1y = y1 - y0;
120
- const e2x = x2 - x0, e2y = y2 - y0;
121
- return Math.max(Math.sqrt(e1x * e1x + e1y * e1y), Math.sqrt(e2x * e2x + e2y * e2y));
122
- }
123
- const invLen = 1 / Math.sqrt(lenSq);
124
- const a1 = Math.abs(dx * (y0 - y1) - (x0 - x1) * dy);
125
- const a2 = Math.abs(dx * (y0 - y2) - (x0 - x2) * dy);
126
- return Math.max(a1, a2) * invLen;
15
+ function flattenQuadratic(p0, p1, p2, tolerance, out, depth = 0) {
16
+ if (depth >= 12 || quadFlatness(p0, p1, p2) <= tolerance) {
17
+ pushUniquePoint(out, p2);
18
+ return;
19
+ }
20
+ const p01 = midpoint(p0, p1);
21
+ const p12 = midpoint(p1, p2);
22
+ const p012 = midpoint(p01, p12);
23
+ flattenQuadratic(p0, p01, p012, tolerance, out, depth + 1);
24
+ flattenQuadratic(p012, p12, p2, tolerance, out, depth + 1);
25
+ }
26
+ function flattenCubic(p0, p1, p2, p3, tolerance, out, depth = 0) {
27
+ if (depth >= 12 || cubicFlatness(p0, p1, p2, p3) <= tolerance) {
28
+ pushUniquePoint(out, p3);
29
+ return;
30
+ }
31
+ const p01 = midpoint(p0, p1);
32
+ const p12 = midpoint(p1, p2);
33
+ const p23 = midpoint(p2, p3);
34
+ const p012 = midpoint(p01, p12);
35
+ const p123 = midpoint(p12, p23);
36
+ const p0123 = midpoint(p012, p123);
37
+ flattenCubic(p0, p01, p012, p0123, tolerance, out, depth + 1);
38
+ flattenCubic(p0123, p123, p23, p3, tolerance, out, depth + 1);
39
+ }
40
+ function quadFlatness(p0, p1, p2) {
41
+ return pointToLineDistance(p1, p0, p2);
42
+ }
43
+ function cubicFlatness(p0, p1, p2, p3) {
44
+ return Math.max(pointToLineDistance(p1, p0, p3), pointToLineDistance(p2, p0, p3));
45
+ }
46
+ function pointToLineDistance(point, lineStart, lineEnd) {
47
+ const dx = lineEnd[0] - lineStart[0];
48
+ const dy = lineEnd[1] - lineStart[1];
49
+ const lengthSq = dx * dx + dy * dy;
50
+ if (lengthSq === 0) return distance(point, lineStart);
51
+ return Math.abs(dx * (lineStart[1] - point[1]) - (lineStart[0] - point[0]) * dy) / Math.sqrt(lengthSq);
127
52
  }
128
53
  function midpoint(a, b) {
129
54
  return [(a[0] + b[0]) * .5, (a[1] + b[1]) * .5];
@@ -181,37 +106,28 @@ function distance(a, b) {
181
106
  return Math.sqrt(dx * dx + dy * dy);
182
107
  }
183
108
  function sampleRing(ring, step, callback) {
184
- const len = ring.length;
185
- for (let index = 0; index < len; index++) {
109
+ for (let index = 0; index < ring.length; index += 1) {
186
110
  const a = ring[index];
187
- const b = ring[(index + 1) % len];
188
- const dx = b[0] - a[0], dy = b[1] - a[1];
189
- const segmentLength = Math.sqrt(dx * dx + dy * dy);
111
+ const b = ring[(index + 1) % ring.length];
112
+ const segmentLength = distance(a, b);
190
113
  const divisions = Math.max(1, Math.ceil(segmentLength / step));
191
- if (index === 0) {
192
- callback([a[0], a[1]]);
193
- for (let offset = 1; offset < divisions; offset++) {
194
- const t = offset / divisions;
195
- callback([a[0] + dx * t, a[1] + dy * t]);
196
- }
197
- } else for (let offset = 0; offset < divisions; offset++) {
114
+ for (let offset = 0; offset < divisions; offset += 1) if (index > 0 || offset > 0) {
198
115
  const t = offset / divisions;
199
- callback([a[0] + dx * t, a[1] + dy * t]);
200
- }
116
+ callback([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]);
117
+ } else callback([a[0], a[1]]);
201
118
  }
202
119
  }
203
120
  function ringBounds(ring) {
204
- let minX = ring[0][0];
205
- let minY = ring[0][1];
206
- let maxX = minX;
207
- let maxY = minY;
208
- for (let i = 1, len = ring.length; i < len; i++) {
209
- const x = ring[i][0], y = ring[i][1];
210
- if (x < minX) minX = x;
211
- else if (x > maxX) maxX = x;
212
- if (y < minY) minY = y;
213
- else if (y > maxY) maxY = y;
214
- }
121
+ let minX = Number.POSITIVE_INFINITY;
122
+ let minY = Number.POSITIVE_INFINITY;
123
+ let maxX = Number.NEGATIVE_INFINITY;
124
+ let maxY = Number.NEGATIVE_INFINITY;
125
+ ring.forEach((point) => {
126
+ minX = Math.min(minX, point[0]);
127
+ minY = Math.min(minY, point[1]);
128
+ maxX = Math.max(maxX, point[0]);
129
+ maxY = Math.max(maxY, point[1]);
130
+ });
215
131
  return {
216
132
  x: minX,
217
133
  y: minY,
@@ -220,10 +136,7 @@ function ringBounds(ring) {
220
136
  };
221
137
  }
222
138
  function translateRing(ring, tx, ty) {
223
- const len = ring.length;
224
- const result = new Array(len);
225
- for (let i = 0; i < len; i++) result[i] = [ring[i][0] + tx, ring[i][1] + ty];
226
- return result;
139
+ return ring.map((point) => [point[0] + tx, point[1] + ty]);
227
140
  }
228
141
  function translateRect(rect, tx, ty) {
229
142
  return {
@@ -234,28 +147,20 @@ function translateRect(rect, tx, ty) {
234
147
  };
235
148
  }
236
149
  function copyRing(ring) {
237
- const len = ring.length;
238
- const result = new Array(len);
239
- for (let i = 0; i < len; i++) result[i] = [ring[i][0], ring[i][1]];
240
- return result;
150
+ return ring.map((point) => [point[0], point[1]]);
241
151
  }
242
152
  function combineRects(rects) {
243
- const len = rects.length;
244
- if (len === 0) return null;
245
- const first = rects[0];
246
- let minX = first.x;
247
- let minY = first.y;
248
- let maxX = first.x + first.w;
249
- let maxY = first.y + first.h;
250
- for (let i = 1; i < len; i++) {
251
- const r = rects[i];
252
- const rx = r.x, ry = r.y;
253
- const rx2 = rx + r.w, ry2 = ry + r.h;
254
- if (rx < minX) minX = rx;
255
- if (ry < minY) minY = ry;
256
- if (rx2 > maxX) maxX = rx2;
257
- if (ry2 > maxY) maxY = ry2;
258
- }
153
+ if (rects.length === 0) return null;
154
+ let minX = Number.POSITIVE_INFINITY;
155
+ let minY = Number.POSITIVE_INFINITY;
156
+ let maxX = Number.NEGATIVE_INFINITY;
157
+ let maxY = Number.NEGATIVE_INFINITY;
158
+ rects.forEach((rect) => {
159
+ minX = Math.min(minX, rect.x);
160
+ minY = Math.min(minY, rect.y);
161
+ maxX = Math.max(maxX, rect.x + rect.w);
162
+ maxY = Math.max(maxY, rect.y + rect.h);
163
+ });
259
164
  return {
260
165
  x: minX,
261
166
  y: minY,
@@ -297,64 +202,43 @@ function ringPerimeter(ring) {
297
202
  for (let index = 0; index < ring.length; index += 1) total += distance(ring[index], ring[(index + 1) % ring.length]);
298
203
  return total;
299
204
  }
300
- function buildCumulativeDistances(ring) {
301
- const len = ring.length;
302
- const cumulative = new Float64Array(len + 1);
205
+ function polylineLength(path) {
303
206
  let total = 0;
304
- for (let i = 0; i < len; i++) {
305
- cumulative[i] = total;
306
- const next = (i + 1) % len;
307
- const dx = ring[next][0] - ring[i][0];
308
- const dy = ring[next][1] - ring[i][1];
309
- total += Math.sqrt(dx * dx + dy * dy);
310
- }
311
- cumulative[len] = total;
312
- return cumulative;
313
- }
314
- function buildOpenCumulativeDistances(path) {
315
- const len = path.length;
316
- const cumulative = new Float64Array(len);
317
- let total = 0;
318
- cumulative[0] = 0;
319
- for (let i = 1; i < len; i++) {
320
- const dx = path[i][0] - path[i - 1][0];
321
- const dy = path[i][1] - path[i - 1][1];
322
- total += Math.sqrt(dx * dx + dy * dy);
323
- cumulative[i] = total;
324
- }
325
- return cumulative;
326
- }
327
- function binarySearchSegment(cumulative, d) {
328
- let lo = 0, hi = cumulative.length - 2;
329
- while (lo < hi) {
330
- const mid = lo + hi + 1 >>> 1;
331
- if (cumulative[mid] <= d) lo = mid;
332
- else hi = mid - 1;
333
- }
334
- return lo;
335
- }
336
- function pointAtClosedPathDistanceFast(ring, cumulative, distanceAlong) {
337
- const perimeter = cumulative[ring.length];
207
+ for (let index = 0; index < path.length - 1; index += 1) total += distance(path[index], path[index + 1]);
208
+ return total;
209
+ }
210
+ function pointAtClosedPathDistance(ring, distanceAlong) {
211
+ const perimeter = ringPerimeter(ring);
338
212
  if (perimeter <= 1e-9) return [ring[0][0], ring[0][1]];
339
- const d = mod(distanceAlong, perimeter);
340
- const idx = binarySearchSegment(cumulative, d);
341
- const segLen = cumulative[idx + 1] - cumulative[idx];
342
- if (segLen <= 1e-9) return [ring[idx][0], ring[idx][1]];
343
- const t = (d - cumulative[idx]) / segLen;
344
- const start = ring[idx];
345
- const end = ring[(idx + 1) % ring.length];
346
- return [start[0] + (end[0] - start[0]) * t, start[1] + (end[1] - start[1]) * t];
347
- }
348
- function pointAtOpenPathDistanceFast(path, cumulative, distanceAlong) {
213
+ let remaining = mod(distanceAlong, perimeter);
214
+ for (let index = 0; index < ring.length; index += 1) {
215
+ const start = ring[index];
216
+ const end = ring[(index + 1) % ring.length];
217
+ const segmentLength = distance(start, end);
218
+ if (segmentLength <= 1e-9) continue;
219
+ if (remaining <= segmentLength) {
220
+ const t = remaining / segmentLength;
221
+ return [start[0] + (end[0] - start[0]) * t, start[1] + (end[1] - start[1]) * t];
222
+ }
223
+ remaining -= segmentLength;
224
+ }
225
+ return [ring[ring.length - 1][0], ring[ring.length - 1][1]];
226
+ }
227
+ function pointAtOpenPathDistance(path, distanceAlong) {
349
228
  if (distanceAlong <= 0) return [path[0][0], path[0][1]];
350
- if (distanceAlong >= cumulative[path.length - 1]) return [path[path.length - 1][0], path[path.length - 1][1]];
351
- const idx = binarySearchSegment(cumulative, distanceAlong);
352
- const segLen = cumulative[idx + 1] - cumulative[idx];
353
- if (segLen <= 1e-9) return [path[idx][0], path[idx][1]];
354
- const t = (distanceAlong - cumulative[idx]) / segLen;
355
- const start = path[idx];
356
- const end = path[idx + 1];
357
- return [start[0] + (end[0] - start[0]) * t, start[1] + (end[1] - start[1]) * t];
229
+ let remaining = distanceAlong;
230
+ for (let index = 0; index < path.length - 1; index += 1) {
231
+ const start = path[index];
232
+ const end = path[index + 1];
233
+ const segmentLength = distance(start, end);
234
+ if (segmentLength <= 1e-9) continue;
235
+ if (remaining <= segmentLength) {
236
+ const t = remaining / segmentLength;
237
+ return [start[0] + (end[0] - start[0]) * t, start[1] + (end[1] - start[1]) * t];
238
+ }
239
+ remaining -= segmentLength;
240
+ }
241
+ return [path[path.length - 1][0], path[path.length - 1][1]];
358
242
  }
359
243
  function segmentIntersectsRing(a, b, ring, ignoredEdges, epsilon) {
360
244
  for (let index = 0; index < ring.length; index += 1) {
@@ -436,28 +320,23 @@ function buildGlyphTopology(glyph, fallbackUnitsPerEm) {
436
320
  current = null;
437
321
  return;
438
322
  }
439
- if (!samePoint(current.cursor, current.start)) current.segments.push({
440
- type: "L",
441
- from: {
442
- x: current.cursor.x,
443
- y: current.cursor.y
444
- },
445
- to: {
446
- x: current.start.x,
447
- y: current.start.y
448
- }
449
- });
323
+ if (!samePoint(current.cursor, current.start)) {
324
+ current.segments.push({
325
+ type: "L",
326
+ from: cloneRawPoint(current.cursor),
327
+ to: cloneRawPoint(current.start)
328
+ });
329
+ current.cursor = cloneRawPoint(current.start);
330
+ }
450
331
  contours.push({
451
332
  id: contourId++,
452
- start: current.start,
333
+ start: cloneRawPoint(current.start),
453
334
  segments: current.segments
454
335
  });
455
336
  current = null;
456
337
  };
457
- for (let i = 0, len = commands.length; i < len; i++) {
458
- const cmd = commands[i];
459
- const type = cmd.type;
460
- if (type === "M") {
338
+ commands.forEach((cmd) => {
339
+ if (cmd.type === "M") {
461
340
  finishCurrentContour();
462
341
  current = {
463
342
  start: {
@@ -470,49 +349,47 @@ function buildGlyphTopology(glyph, fallbackUnitsPerEm) {
470
349
  },
471
350
  segments: []
472
351
  };
473
- continue;
352
+ return;
474
353
  }
475
- if (!current) continue;
476
- if (type === "L") {
354
+ if (!current) return;
355
+ if (cmd.type === "L") {
477
356
  const to = {
478
357
  x: cmd.x,
479
358
  y: cmd.y
480
359
  };
481
360
  current.segments.push({
482
361
  type: "L",
483
- from: current.cursor,
362
+ from: cloneRawPoint(current.cursor),
484
363
  to
485
364
  });
486
- current.cursor = to;
487
- continue;
365
+ current.cursor = cloneRawPoint(to);
366
+ return;
488
367
  }
489
- if (type === "Q") {
490
- const from = current.cursor;
368
+ if (cmd.type === "Q") {
491
369
  const to = {
492
370
  x: cmd.x,
493
371
  y: cmd.y
494
372
  };
495
373
  current.segments.push({
496
374
  type: "Q",
497
- from,
375
+ from: cloneRawPoint(current.cursor),
498
376
  c1: {
499
377
  x: cmd.x1,
500
378
  y: cmd.y1
501
379
  },
502
380
  to
503
381
  });
504
- current.cursor = to;
505
- continue;
382
+ current.cursor = cloneRawPoint(to);
383
+ return;
506
384
  }
507
- if (type === "C") {
508
- const from = current.cursor;
385
+ if (cmd.type === "C") {
509
386
  const to = {
510
387
  x: cmd.x,
511
388
  y: cmd.y
512
389
  };
513
390
  current.segments.push({
514
391
  type: "C",
515
- from,
392
+ from: cloneRawPoint(current.cursor),
516
393
  c1: {
517
394
  x: cmd.x1,
518
395
  y: cmd.y1
@@ -523,11 +400,11 @@ function buildGlyphTopology(glyph, fallbackUnitsPerEm) {
523
400
  },
524
401
  to
525
402
  });
526
- current.cursor = to;
527
- continue;
403
+ current.cursor = cloneRawPoint(to);
404
+ return;
528
405
  }
529
- if (type === "Z") finishCurrentContour();
530
- }
406
+ if (cmd.type === "Z") finishCurrentContour();
407
+ });
531
408
  finishCurrentContour();
532
409
  return {
533
410
  glyphIndex: glyph.index,
@@ -551,25 +428,18 @@ function flattenGlyphTopology(topology, options) {
551
428
  }
552
429
  function flattenContour(contour, scale, tolerance) {
553
430
  const ring = [];
554
- let cursor = toScreenPoint(contour.start, scale);
555
- pushUniquePoint(ring, cursor);
556
- const segments = contour.segments;
557
- for (let i = 0, len = segments.length; i < len; i++) {
558
- const segment = segments[i];
559
- const type = segment.type;
560
- if (type === "L") {
561
- cursor = toScreenPoint(segment.to, scale);
562
- pushUniquePoint(ring, cursor);
563
- } else if (type === "Q") {
564
- const to = toScreenPoint(segment.to, scale);
565
- flattenQuadratic(cursor, toScreenPoint(segment.c1, scale), to, tolerance, ring);
566
- cursor = to;
567
- } else if (type === "C") {
568
- const to = toScreenPoint(segment.to, scale);
569
- flattenCubic(cursor, toScreenPoint(segment.c1, scale), toScreenPoint(segment.c2, scale), to, tolerance, ring);
570
- cursor = to;
431
+ pushUniquePoint(ring, toScreenPoint(contour.start, scale));
432
+ contour.segments.forEach((segment) => {
433
+ if (segment.type === "L") {
434
+ pushUniquePoint(ring, toScreenPoint(segment.to, scale));
435
+ return;
571
436
  }
572
- }
437
+ if (segment.type === "Q") {
438
+ flattenQuadratic(toScreenPoint(segment.from, scale), toScreenPoint(segment.c1, scale), toScreenPoint(segment.to, scale), tolerance, ring);
439
+ return;
440
+ }
441
+ if (segment.type === "C") flattenCubic(toScreenPoint(segment.from, scale), toScreenPoint(segment.c1, scale), toScreenPoint(segment.c2, scale), toScreenPoint(segment.to, scale), tolerance, ring);
442
+ });
573
443
  if (ring.length > 1 && pointsEqual2D(ring[0], ring[ring.length - 1])) ring.pop();
574
444
  if (ring.length < 3) return null;
575
445
  const bbox = ringBounds(ring);
@@ -582,45 +452,37 @@ function flattenContour(contour, scale, tolerance) {
582
452
  };
583
453
  }
584
454
  function classifyContours(contours) {
585
- const len = contours.length;
586
- const result = new Array(len);
587
- for (let i = 0; i < len; i++) result[i] = {
588
- ...contours[i],
455
+ const result = contours.map((contour) => ({
456
+ ...contour,
589
457
  parentId: null,
590
458
  depth: 0,
591
459
  role: "outer"
592
- };
593
- const sortedByArea = result.slice().sort((a, b) => Math.abs(b.area) - Math.abs(a.area));
594
- for (let ci = 0; ci < len; ci++) {
595
- const child = result[ci];
596
- const childAbsArea = Math.abs(child.area);
460
+ }));
461
+ result.forEach((child) => {
597
462
  let parent = null;
598
463
  let parentArea = Number.POSITIVE_INFINITY;
599
- for (let si = 0; si < len; si++) {
600
- const candidate = sortedByArea[si];
601
- const candidateAbsArea = Math.abs(candidate.area);
602
- if (candidateAbsArea <= childAbsArea) break;
603
- if (candidate.id === child.id) continue;
604
- if (candidateAbsArea >= parentArea) continue;
605
- if (!rectContainsRect(candidate.bbox, child.bbox)) continue;
606
- if (!pointInRing(child.ring[0], candidate.ring)) continue;
607
- parent = candidate;
608
- parentArea = candidateAbsArea;
609
- }
464
+ result.forEach((candidate) => {
465
+ if (candidate.id === child.id) return;
466
+ if (Math.abs(candidate.area) <= Math.abs(child.area)) return;
467
+ if (!rectContainsRect(candidate.bbox, child.bbox)) return;
468
+ if (!pointInRing(child.ring[0], candidate.ring)) return;
469
+ const candidateArea = Math.abs(candidate.area);
470
+ if (candidateArea < parentArea) {
471
+ parent = candidate;
472
+ parentArea = candidateArea;
473
+ }
474
+ });
610
475
  if (parent) child.parentId = parent.id;
611
- }
476
+ });
612
477
  const byId = new Map(result.map((contour) => [contour.id, contour]));
613
- for (let i = 0; i < len; i++) {
614
- const contour = result[i];
615
- let depth = 0;
616
- let current = contour;
617
- while (current.parentId != null) {
618
- depth++;
619
- current = byId.get(current.parentId);
620
- }
621
- contour.depth = depth;
622
- contour.role = depth % 2 === 0 ? "outer" : "hole";
623
- }
478
+ const resolveDepth = (contour) => {
479
+ if (contour.parentId == null) return 0;
480
+ return resolveDepth(byId.get(contour.parentId)) + 1;
481
+ };
482
+ result.forEach((contour) => {
483
+ contour.depth = resolveDepth(contour);
484
+ contour.role = contour.depth % 2 === 0 ? "outer" : "hole";
485
+ });
624
486
  return result;
625
487
  }
626
488
  function buildParts(contours) {
@@ -635,26 +497,16 @@ function buildParts(contours) {
635
497
  });
636
498
  }
637
499
  function translateGlyphGeometry(geometry, tx, ty, glyphPosition) {
638
- const srcParts = geometry.parts;
639
- const srcContours = geometry.contours;
640
- const partsLen = srcParts.length;
641
- const contoursLen = srcContours.length;
642
- const parts = new Array(partsLen);
643
- const contours = new Array(contoursLen);
644
- for (let i = 0; i < partsLen; i++) {
645
- const part = srcParts[i];
646
- parts[i] = {
500
+ return {
501
+ parts: geometry.parts.map((part, partIndex) => ({
647
502
  outer: translateRing(part.outer, tx, ty),
648
503
  holes: part.holes.map((hole) => translateRing(hole, tx, ty)),
649
504
  bbox: translateRect(part.bbox, tx, ty),
650
505
  area: part.area,
651
506
  glyphPosition,
652
- partIndex: i
653
- };
654
- }
655
- for (let i = 0; i < contoursLen; i++) {
656
- const contour = srcContours[i];
657
- contours[i] = {
507
+ partIndex
508
+ })),
509
+ contours: geometry.contours.map((contour) => ({
658
510
  id: `${glyphPosition}:${contour.id}`,
659
511
  sourceId: contour.id,
660
512
  glyphPosition,
@@ -664,11 +516,7 @@ function translateGlyphGeometry(geometry, tx, ty, glyphPosition) {
664
516
  area: contour.area,
665
517
  bbox: translateRect(contour.bbox, tx, ty),
666
518
  ring: translateRing(contour.ring, tx, ty)
667
- };
668
- }
669
- return {
670
- parts,
671
- contours,
519
+ })),
672
520
  bbox: translateRect(geometry.bbox, tx, ty)
673
521
  };
674
522
  }
@@ -844,31 +692,26 @@ var PAShape = class {
844
692
  function createTextShape(layout, opts, fontInstance) {
845
693
  const parts = [];
846
694
  const contours = [];
847
- const layoutGlyphs = layout.glyphs;
848
- const glyphCount = layoutGlyphs.length;
849
- const glyphs = new Array(glyphCount);
850
- const sourceLayoutGlyphs = Array.isArray(layoutGlyphs) ? layoutGlyphs.slice() : [];
695
+ const glyphs = [];
696
+ const sourceLayoutGlyphs = Array.isArray(layout.glyphs) ? layout.glyphs.slice() : [];
851
697
  const variantOptions = {
852
698
  openWidth: normalizePositive(opts.openWidth, 0),
853
699
  step: normalizePositive(opts.step, 0)
854
700
  };
855
- for (let glyphPosition = 0; glyphPosition < glyphCount; glyphPosition++) {
856
- const item = layoutGlyphs[glyphPosition];
701
+ layout.glyphs.forEach((item, glyphPosition) => {
857
702
  const translated = translateGlyphGeometry(fontInstance._getGlyphGeometryVariant(item.glyph, opts), item.x, item.y, glyphPosition);
858
- const tParts = translated.parts;
859
- const tContours = translated.contours;
860
- for (let j = 0; j < tParts.length; j++) parts.push(tParts[j]);
861
- for (let j = 0; j < tContours.length; j++) contours.push(tContours[j]);
862
- glyphs[glyphPosition] = {
703
+ parts.push(...translated.parts);
704
+ contours.push(...translated.contours);
705
+ glyphs.push({
863
706
  position: glyphPosition,
864
707
  glyphIndex: item.glyph.index,
865
708
  x: item.x,
866
709
  y: item.y,
867
710
  size: item.size,
868
711
  bbox: translated.bbox,
869
- partCount: tParts.length
870
- };
871
- }
712
+ partCount: translated.parts.length
713
+ });
714
+ });
872
715
  const bbox = combineRects(parts.map((part) => part.bbox)) ?? emptyRect();
873
716
  return new PAShape({
874
717
  text: layout.text,
@@ -1102,105 +945,73 @@ function openPartWithSlit(part, width, epsilon) {
1102
945
  let outer = copyRing(part.outer);
1103
946
  const holes = part.holes.map((hole) => copyRing(hole));
1104
947
  const anchors = [];
1105
- const blockers = [];
1106
948
  while (holes.length > 0) {
1107
949
  let selectedHoleIndex = -1;
1108
950
  let selectedBridge = null;
1109
- let selectedDist = Number.POSITIVE_INFINITY;
1110
- for (let hi = 0; hi < holes.length; hi++) {
1111
- blockers.length = 0;
1112
- for (let bi = 0; bi < holes.length; bi++) if (bi !== hi) blockers.push(holes[bi]);
1113
- const candidate = findBestHoleBridge(outer, holes[hi], blockers, epsilon);
1114
- if (candidate && candidate.distance < selectedDist) {
951
+ holes.forEach((hole, holeIndex) => {
952
+ const blockers = holes.filter((_, index) => index !== holeIndex);
953
+ const candidate = findBestHoleBridge(outer, hole, blockers, epsilon);
954
+ if (candidate && (!selectedBridge || candidate.distance < selectedBridge.distance)) {
1115
955
  selectedBridge = candidate;
1116
- selectedHoleIndex = hi;
1117
- selectedDist = candidate.distance;
956
+ selectedHoleIndex = holeIndex;
1118
957
  }
1119
- }
958
+ });
1120
959
  if (!selectedBridge || selectedHoleIndex === -1) break;
1121
960
  const [hole] = holes.splice(selectedHoleIndex, 1);
1122
961
  const merged = mergeHoleIntoOuter(outer, hole, selectedBridge, width, epsilon);
1123
962
  outer = merged.ring;
1124
- const mergedAnchors = merged.anchors;
1125
- for (let i = 0; i < mergedAnchors.length; i++) anchors.push(mergedAnchors[i]);
963
+ anchors.push(...merged.anchors);
1126
964
  }
1127
- let holeArea = 0;
1128
- for (let i = 0; i < holes.length; i++) holeArea += Math.abs(signedArea(holes[i]));
1129
965
  return {
1130
966
  ...part,
1131
967
  outer,
1132
968
  holes,
1133
969
  anchors,
1134
970
  bbox: ringBounds(outer),
1135
- area: Math.abs(signedArea(outer)) - holeArea
971
+ area: Math.abs(signedArea(outer)) - holes.reduce((sum, hole) => sum + Math.abs(signedArea(hole)), 0)
1136
972
  };
1137
973
  }
1138
974
  function resamplePart(part, step) {
1139
975
  const outer = resampleRing(part.outer, step, part.anchors ?? []);
1140
- const srcHoles = part.holes;
1141
- const holes = new Array(srcHoles.length);
1142
- let holeArea = 0;
1143
- for (let i = 0; i < srcHoles.length; i++) {
1144
- holes[i] = resampleRing(srcHoles[i], step);
1145
- holeArea += Math.abs(signedArea(holes[i]));
1146
- }
976
+ const holes = part.holes.map((hole) => resampleRing(hole, step));
1147
977
  return {
1148
978
  ...part,
1149
979
  outer,
1150
980
  holes,
1151
981
  anchors: part.anchors ? part.anchors.map((point) => [point[0], point[1]]) : void 0,
1152
982
  bbox: ringBounds(outer),
1153
- area: Math.abs(signedArea(outer)) - holeArea
983
+ area: Math.abs(signedArea(outer)) - holes.reduce((sum, hole) => sum + Math.abs(signedArea(hole)), 0)
1154
984
  };
1155
985
  }
1156
986
  function findBestHoleBridge(outer, hole, blockers, epsilon) {
1157
987
  let best = null;
1158
- let bestDist = Number.POSITIVE_INFINITY;
1159
- const outerLen = outer.length;
1160
- const holeLen = hole.length;
1161
- for (let oi = 0; oi < outerLen; oi++) {
1162
- const op = outer[oi];
1163
- for (let hi = 0; hi < holeLen; hi++) {
1164
- const hp = hole[hi];
1165
- const dx = op[0] - hp[0], dy = op[1] - hp[1];
1166
- const dist = Math.sqrt(dx * dx + dy * dy);
1167
- if (dist >= bestDist) continue;
1168
- if (!isVisibleBridge(outer, hole, blockers, op, hp, oi, hi, epsilon)) continue;
1169
- best = {
1170
- outerIndex: oi,
1171
- holeIndex: hi,
1172
- distance: dist
988
+ outer.forEach((outerPoint, outerIndex) => {
989
+ hole.forEach((holePoint, holeIndex) => {
990
+ if (!isVisibleBridge(outer, hole, blockers, outerPoint, holePoint, outerIndex, holeIndex, epsilon)) return;
991
+ const candidate = {
992
+ outerIndex,
993
+ holeIndex,
994
+ distance: distance(outerPoint, holePoint)
1173
995
  };
1174
- bestDist = dist;
1175
- }
1176
- }
996
+ if (!best || candidate.distance < best.distance) best = candidate;
997
+ });
998
+ });
1177
999
  if (best) return best;
1178
1000
  return findNearestBridge(outer, hole);
1179
1001
  }
1180
1002
  function findNearestBridge(outer, hole) {
1181
- let bestOi = 0, bestHi = 0;
1182
- let bestDistSq = Number.POSITIVE_INFINITY;
1183
- const outerLen = outer.length;
1184
- const holeLen = hole.length;
1185
- for (let oi = 0; oi < outerLen; oi++) {
1186
- const op = outer[oi];
1187
- const ox = op[0], oy = op[1];
1188
- for (let hi = 0; hi < holeLen; hi++) {
1189
- const hp = hole[hi];
1190
- const dx = ox - hp[0], dy = oy - hp[1];
1191
- const distSq = dx * dx + dy * dy;
1192
- if (distSq < bestDistSq) {
1193
- bestDistSq = distSq;
1194
- bestOi = oi;
1195
- bestHi = hi;
1196
- }
1197
- }
1198
- }
1199
- return {
1200
- outerIndex: bestOi,
1201
- holeIndex: bestHi,
1202
- distance: Math.sqrt(bestDistSq)
1203
- };
1003
+ let best = null;
1004
+ outer.forEach((outerPoint, outerIndex) => {
1005
+ hole.forEach((holePoint, holeIndex) => {
1006
+ const candidate = {
1007
+ outerIndex,
1008
+ holeIndex,
1009
+ distance: distance(outerPoint, holePoint)
1010
+ };
1011
+ if (!best || candidate.distance < best.distance) best = candidate;
1012
+ });
1013
+ });
1014
+ return best;
1204
1015
  }
1205
1016
  function isVisibleBridge(outer, hole, blockers, outerPoint, holePoint, outerIndex, holeIndex, epsilon) {
1206
1017
  if (distance(outerPoint, holePoint) <= epsilon) return false;
@@ -1367,14 +1178,13 @@ function resampleRingWithAnchors(ring, step, anchorIndices) {
1367
1178
  return result;
1368
1179
  }
1369
1180
  function resampleClosedPath(ring, step) {
1370
- const cumulative = buildCumulativeDistances(ring);
1371
- const perimeter = cumulative[ring.length];
1181
+ const perimeter = ringPerimeter(ring);
1372
1182
  if (perimeter <= 1e-9) return copyRing(ring);
1373
1183
  const pointCount = Math.max(3, Math.round(perimeter / step));
1374
1184
  if (pointCount >= ring.length) return copyRing(ring);
1375
1185
  const sampled = [];
1376
- for (let index = 0; index < pointCount; index++) {
1377
- const point = pointAtClosedPathDistanceFast(ring, cumulative, perimeter * index / pointCount);
1186
+ for (let index = 0; index < pointCount; index += 1) {
1187
+ const point = pointAtClosedPathDistance(ring, perimeter * index / pointCount);
1378
1188
  if (sampled.length === 0 || !pointsAlmostEqual2D(sampled[sampled.length - 1], point, 1e-6)) sampled.push(point);
1379
1189
  }
1380
1190
  if (sampled.length < 3 || sampled.length >= ring.length) return copyRing(ring);
@@ -1382,13 +1192,12 @@ function resampleClosedPath(ring, step) {
1382
1192
  }
1383
1193
  function resampleOpenPath(path, step) {
1384
1194
  if (path.length <= 2) return path.map((point) => [point[0], point[1]]);
1385
- const cumulative = buildOpenCumulativeDistances(path);
1386
- const length = cumulative[path.length - 1];
1195
+ const length = polylineLength(path);
1387
1196
  if (length <= step) return [[path[0][0], path[0][1]], [path[path.length - 1][0], path[path.length - 1][1]]];
1388
1197
  const divisionCount = Math.max(1, Math.round(length / step));
1389
1198
  const sampled = [];
1390
- for (let index = 0; index <= divisionCount; index++) {
1391
- const point = pointAtOpenPathDistanceFast(path, cumulative, length * index / divisionCount);
1199
+ for (let index = 0; index <= divisionCount; index += 1) {
1200
+ const point = pointAtOpenPathDistance(path, length * index / divisionCount);
1392
1201
  if (sampled.length === 0 || !pointsAlmostEqual2D(sampled[sampled.length - 1], point, 1e-6)) sampled.push(point);
1393
1202
  }
1394
1203
  return sampled;
@@ -3486,6 +3295,7 @@ function layoutWithLines(prepared, maxWidth, lineHeight) {
3486
3295
  //#endregion
3487
3296
  //#region src/paFont/paragraphLayout.js
3488
3297
  var DEFAULT_LINE_HEIGHT_RATIO = 1.2;
3298
+ var DEFAULT_PARAGRAPH_GAP = .5;
3489
3299
  var HUGE_LAYOUT_WIDTH = 1e9;
3490
3300
  var JUSTIFY_EPSILON = 1e-6;
3491
3301
  var QUOTE_RE = /"/g;
@@ -3499,13 +3309,14 @@ function layoutParagraph(fontInstance, text, options = {}, state = {}) {
3499
3309
  const textValue = String(text ?? "");
3500
3310
  const layoutBox = resolveLayoutBox(normalized, state);
3501
3311
  const retainedPreparedState = resolveRetainedPreparedState(state, normalized);
3502
- const pretextState = shouldAttemptPretextLayout(normalized) ? layoutWithPretext(textValue, normalized, retainedPreparedState, layoutBox) : null;
3503
- const layoutState = pretextState != null && canUsePretextLayout(pretextState, normalized) ? pretextState : layoutWithNative(fontInstance, textValue, normalized, layoutBox);
3312
+ const paragraphs = splitParagraphText(textValue, normalized.whiteSpace);
3313
+ const pretextState = shouldAttemptPretextLayout(normalized) ? layoutParagraphsWithPretext(paragraphs, normalized, retainedPreparedState, layoutBox) : null;
3314
+ const layoutState = pretextState != null && canUsePretextLayout(pretextState, normalized) ? pretextState : layoutParagraphsWithNative(fontInstance, paragraphs, normalized, layoutBox);
3504
3315
  const measureWidth = createLazyTextMeasurer(fontInstance, normalized);
3505
3316
  const lines = positionLines(fontInstance, applyOverflowClamping(layoutState.lines, normalized, layoutBox, measureWidth), normalized, layoutBox, measureWidth);
3506
3317
  const textBBox = combineRects(lines.map((line) => line.bbox)) ?? emptyRect();
3507
3318
  const textWidth = lines.reduce((max, line) => Math.max(max, line.width), 0);
3508
- const textHeight = lines.length * normalized.lineHeight;
3319
+ const textHeight = resolvePositionedTextHeight(lines, layoutBox.contentY);
3509
3320
  const finalLayoutBox = finalizeLayoutBox(layoutBox, normalized, textHeight);
3510
3321
  const cachedPrepared = pretextState?.prepared ?? retainedPreparedState.prepared ?? null;
3511
3322
  const cachedPreparedWhiteSpace = pretextState?.preparedWhiteSpace ?? retainedPreparedState.preparedWhiteSpace ?? null;
@@ -3542,8 +3353,10 @@ function normalizeParagraphOptions(fontInstance, options = {}) {
3542
3353
  ], null));
3543
3354
  const width = normalizeDimension(options.width);
3544
3355
  const height = normalizeDimension(options.height);
3356
+ const gap = normalizeGap(options.gap);
3545
3357
  if (options.width != null && width == null) throw new TypeError("font.paragraph() option \"width\" must be a positive number.");
3546
3358
  if (options.height != null && height == null) throw new TypeError("font.paragraph() option \"height\" must be a positive number.");
3359
+ if (options.gap != null && gap == null) throw new TypeError("font.paragraph() option \"gap\" must be a non-negative number.");
3547
3360
  const font = resolveCanvasFont(fontInstance, textOptions.size, options);
3548
3361
  return {
3549
3362
  ...textOptions,
@@ -3558,6 +3371,7 @@ function normalizeParagraphOptions(fontInstance, options = {}) {
3558
3371
  ], wrapDefaults.overflowWrap)),
3559
3372
  width,
3560
3373
  height,
3374
+ gap: gap ?? DEFAULT_PARAGRAPH_GAP,
3561
3375
  lineHeight: resolveLineHeight(options.lineHeight, textOptions.size),
3562
3376
  align: normalizeEnum(options.align, [
3563
3377
  "left",
@@ -3608,6 +3422,44 @@ function resolveCanvasFont(fontInstance, size, options = {}) {
3608
3422
  function canReusePreparedParagraphState(previousState, previousOptions, nextOptions) {
3609
3423
  return previousState?.prepared != null && previousState.preparedWhiteSpace === resolvePretextWhiteSpace(nextOptions.whiteSpace) && previousOptions?.font === nextOptions.font;
3610
3424
  }
3425
+ function layoutParagraphsWithPretext(paragraphs, options, state, layoutBox) {
3426
+ const retainedPrepared = Array.isArray(state.prepared) ? state.prepared : state.prepared != null ? [state.prepared] : [];
3427
+ const paragraphStates = paragraphs.map((paragraph, paragraphIndex) => layoutWithPretext(paragraph, options, {
3428
+ prepared: retainedPrepared[paragraphIndex] ?? null,
3429
+ preparedWhiteSpace: state.preparedWhiteSpace,
3430
+ font: options.font
3431
+ }, layoutBox));
3432
+ return {
3433
+ lines: annotateParagraphLines(paragraphStates),
3434
+ prepared: paragraphStates.map((paragraphState) => paragraphState.prepared ?? null),
3435
+ preparedWhiteSpace: paragraphStates[0]?.preparedWhiteSpace ?? resolvePretextWhiteSpace(options.whiteSpace),
3436
+ layoutEngine: "pretext",
3437
+ usedOverflowWrapFallbackBreaks: paragraphStates.some((paragraphState) => paragraphState.usedOverflowWrapFallbackBreaks)
3438
+ };
3439
+ }
3440
+ function layoutParagraphsWithNative(fontInstance, paragraphs, options, layoutBox) {
3441
+ return {
3442
+ lines: annotateParagraphLines(paragraphs.map((paragraph) => layoutWithNative(fontInstance, paragraph, options, layoutBox))),
3443
+ prepared: null,
3444
+ preparedWhiteSpace: null,
3445
+ layoutEngine: "native"
3446
+ };
3447
+ }
3448
+ function annotateParagraphLines(paragraphStates) {
3449
+ const lines = [];
3450
+ paragraphStates.forEach((paragraphState, paragraphIndex) => {
3451
+ paragraphState.lines.forEach((line, lineIndex) => {
3452
+ lines.push({
3453
+ ...line,
3454
+ start: null,
3455
+ end: null,
3456
+ paragraphIndex,
3457
+ paragraphEnd: lineIndex === paragraphState.lines.length - 1
3458
+ });
3459
+ });
3460
+ });
3461
+ return lines;
3462
+ }
3611
3463
  function layoutWithPretext(text, options, state, layoutBox) {
3612
3464
  const preparedWhiteSpace = resolvePretextWhiteSpace(options.whiteSpace);
3613
3465
  const prepared = state.prepared != null && state.preparedWhiteSpace === preparedWhiteSpace && state.font === options.font ? state.prepared : prepareWithSegments(text, options.font, { whiteSpace: preparedWhiteSpace });
@@ -3732,7 +3584,7 @@ function applyOverflowClamping(lines, options, layoutBox, measureWidth) {
3732
3584
  const result = lines.map((line) => ({ ...line }));
3733
3585
  let lineLimit = options.maxLines;
3734
3586
  if (options.overflow === "hidden" && layoutBox.clipContentHeight != null) {
3735
- const visibleLineCount = Math.max(0, Math.floor((layoutBox.clipContentHeight + JUSTIFY_EPSILON) / options.lineHeight));
3587
+ const visibleLineCount = countVisibleLinesForHeight(result, options, layoutBox.clipContentHeight);
3736
3588
  lineLimit = lineLimit == null ? visibleLineCount : Math.min(lineLimit, visibleLineCount);
3737
3589
  }
3738
3590
  let clippedByCount = false;
@@ -3744,14 +3596,30 @@ function applyOverflowClamping(lines, options, layoutBox, measureWidth) {
3744
3596
  }
3745
3597
  }
3746
3598
  if (result.length === 0) return result;
3747
- if (options.overflow === "hidden" && options.whiteSpace === "nowrap" && result[0].width > contentWidth + JUSTIFY_EPSILON) result[0] = truncateLineToWidth(result[0], contentWidth, measureWidth, options.textOverflow === "ellipsis" ? options.ellipsis : false);
3748
- else if (clippedByCount && shouldEllipsizeClampedLines(options)) {
3599
+ if (options.overflow === "hidden" && options.whiteSpace === "nowrap") {
3600
+ for (let index = 0; index < result.length; index += 1) if (result[index].width > contentWidth + JUSTIFY_EPSILON) result[index] = truncateLineToWidth(result[index], contentWidth, measureWidth, options.textOverflow === "ellipsis" ? options.ellipsis : false);
3601
+ }
3602
+ if (clippedByCount && shouldEllipsizeClampedLines(options)) {
3749
3603
  const lastIndex = result.length - 1;
3750
3604
  result[lastIndex] = truncateLineToWidth(result[lastIndex], contentWidth, measureWidth, options.ellipsis);
3751
3605
  result[lastIndex].hardBreak = false;
3606
+ result[lastIndex].paragraphEnd = true;
3752
3607
  }
3753
3608
  return result;
3754
3609
  }
3610
+ function countVisibleLinesForHeight(lines, options, maxHeight) {
3611
+ let visibleLineCount = 0;
3612
+ let consumedHeight = 0;
3613
+ const lineBoxHeight = options.lineHeight;
3614
+ const paragraphGap = lineBoxHeight * options.gap;
3615
+ for (let index = 0; index < lines.length; index += 1) {
3616
+ if (consumedHeight + lineBoxHeight > maxHeight + JUSTIFY_EPSILON) break;
3617
+ consumedHeight += lineBoxHeight;
3618
+ visibleLineCount += 1;
3619
+ if (lines[index].paragraphEnd && index < lines.length - 1) consumedHeight += paragraphGap;
3620
+ }
3621
+ return visibleLineCount;
3622
+ }
3755
3623
  function truncateLineToWidth(line, maxWidth, measureWidth, suffix) {
3756
3624
  const suffixText = suffix === false ? "" : suffix;
3757
3625
  if ((suffixText.length > 0 ? measureWidth(suffixText) : 0) > maxWidth + JUSTIFY_EPSILON) return {
@@ -3760,25 +3628,9 @@ function truncateLineToWidth(line, maxWidth, measureWidth, suffix) {
3760
3628
  width: 0,
3761
3629
  hardBreak: false
3762
3630
  };
3763
- const trimmed = trimTrailingWhitespace(line.text);
3764
- if (measureWidth(`${trimmed}${suffixText}`) <= maxWidth + JUSTIFY_EPSILON) {
3765
- const text = `${trimmed}${suffixText}`;
3766
- return {
3767
- ...line,
3768
- text,
3769
- width: measureWidth(text),
3770
- hardBreak: false
3771
- };
3772
- }
3773
- const segmenter = getGraphemeSegmenter();
3774
- const graphemes = Array.from(segmenter.segment(trimmed), (seg) => seg.segment);
3775
- let lo = 0, hi = graphemes.length;
3776
- while (lo < hi) {
3777
- const mid = lo + hi + 1 >>> 1;
3778
- if (measureWidth(graphemes.slice(0, mid).join("") + suffixText) <= maxWidth + JUSTIFY_EPSILON) lo = mid;
3779
- else hi = mid - 1;
3780
- }
3781
- const text = (lo > 0 ? graphemes.slice(0, lo).join("") : "") + suffixText;
3631
+ let nextText = trimTrailingWhitespace(line.text);
3632
+ while (nextText.length > 0 && measureWidth(`${nextText}${suffixText}`) > maxWidth + JUSTIFY_EPSILON) nextText = trimLastGrapheme(nextText);
3633
+ const text = `${nextText}${suffixText}`;
3782
3634
  return {
3783
3635
  ...line,
3784
3636
  text,
@@ -3792,13 +3644,17 @@ function shouldEllipsizeClampedLines(options) {
3792
3644
  function positionLines(fontInstance, lines, options, layoutBox, measureWidth) {
3793
3645
  const ascent = getFontAscender(fontInstance, options.size);
3794
3646
  const lineBoxHeight = options.lineHeight;
3647
+ const paragraphGap = lineBoxHeight * options.gap;
3795
3648
  let cursor = 0;
3649
+ let cursorY = layoutBox.contentY;
3796
3650
  return lines.map((line, index) => {
3797
3651
  const justified = shouldJustifyLine(line, index, lines.length, options);
3798
3652
  const offsetX = justified ? 0 : resolveAlignOffset(options.align, line.width, layoutBox.contentWidth);
3799
3653
  const x = layoutBox.contentX + offsetX;
3800
- const y = layoutBox.contentY + index * lineBoxHeight;
3654
+ const y = cursorY;
3801
3655
  const baseline = y + ascent;
3656
+ const start = line.start ?? cursor;
3657
+ const end = line.end ?? start + line.text.length;
3802
3658
  const fragments = justified ? buildJustifiedFragments(line, layoutBox.contentX, layoutBox.contentWidth, measureWidth) : [{
3803
3659
  text: line.text,
3804
3660
  x,
@@ -3815,8 +3671,8 @@ function positionLines(fontInstance, lines, options, layoutBox, measureWidth) {
3815
3671
  const positioned = {
3816
3672
  index,
3817
3673
  text: line.text,
3818
- start: line.start ?? cursor,
3819
- end: line.end ?? cursor + line.text.length,
3674
+ start,
3675
+ end,
3820
3676
  x,
3821
3677
  y,
3822
3678
  width,
@@ -3826,10 +3682,21 @@ function positionLines(fontInstance, lines, options, layoutBox, measureWidth) {
3826
3682
  hardBreak: line.hardBreak,
3827
3683
  fragments
3828
3684
  };
3829
- cursor = positioned.end + (line.hardBreak ? 1 : 0);
3685
+ cursor = end;
3686
+ if (line.hardBreak) cursor += 1;
3687
+ if (line.paragraphEnd && index < lines.length - 1) {
3688
+ cursor += 2;
3689
+ cursorY += paragraphGap;
3690
+ }
3691
+ cursorY += lineBoxHeight;
3830
3692
  return positioned;
3831
3693
  });
3832
3694
  }
3695
+ function resolvePositionedTextHeight(lines, contentY) {
3696
+ if (lines.length === 0) return 0;
3697
+ const lastLine = lines[lines.length - 1];
3698
+ return lastLine.y + lastLine.height - contentY;
3699
+ }
3833
3700
  function buildJustifiedFragments(line, contentX, contentWidth, measureWidth) {
3834
3701
  const tokens = splitPreservingWhitespace(line.text);
3835
3702
  const expandable = tokens.reduce((count, token, index) => {
@@ -3860,7 +3727,7 @@ function buildJustifiedFragments(line, contentX, contentWidth, measureWidth) {
3860
3727
  return fragments;
3861
3728
  }
3862
3729
  function shouldJustifyLine(line, index, lineCount, options) {
3863
- return options.align === "justify" && index < lineCount - 1 && !line.hardBreak && /\S\s+\S/u.test(line.text);
3730
+ return options.align === "justify" && index < lineCount - 1 && !line.hardBreak && !line.paragraphEnd && /\S\s+\S/u.test(line.text);
3864
3731
  }
3865
3732
  function resolveAlignOffset(align, lineWidth, maxWidth) {
3866
3733
  if (align === "center") return (maxWidth - lineWidth) * .5;
@@ -3970,6 +3837,9 @@ function normalizeMaxLines(value) {
3970
3837
  function normalizeDimension(value) {
3971
3838
  return Number.isFinite(value) && value > 0 ? value : null;
3972
3839
  }
3840
+ function normalizeGap(value) {
3841
+ return Number.isFinite(value) && value >= 0 ? Number(value) : null;
3842
+ }
3973
3843
  function normalizeSpacing(value) {
3974
3844
  if (value == null) return zeroSpacing();
3975
3845
  if (Number.isFinite(value)) {
@@ -4084,6 +3954,27 @@ function resolveRetainedPreparedState(state, options) {
4084
3954
  function isHardBreak(prepared, line) {
4085
3955
  return prepared.kinds?.[line.end.segmentIndex] === "hard-break";
4086
3956
  }
3957
+ function splitParagraphText(text, whiteSpace) {
3958
+ const normalized = String(text ?? "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
3959
+ const paragraphs = [];
3960
+ const lines = normalized.split("\n");
3961
+ let currentLines = [];
3962
+ const pushCurrentParagraph = () => {
3963
+ if (currentLines.length === 0) return;
3964
+ const paragraph = whiteSpace === "pre-wrap" ? currentLines.join("\n") : currentLines.join("\n").replace(/\s+/gu, " ").trim();
3965
+ currentLines = [];
3966
+ if (paragraph.length > 0) paragraphs.push(paragraph);
3967
+ };
3968
+ lines.forEach((line) => {
3969
+ if (/^[\t ]*$/u.test(line)) {
3970
+ pushCurrentParagraph();
3971
+ return;
3972
+ }
3973
+ currentLines.push(line);
3974
+ });
3975
+ pushCurrentParagraph();
3976
+ return paragraphs;
3977
+ }
4087
3978
  function normalizeNativeText(text, whiteSpace) {
4088
3979
  if (whiteSpace === "pre-wrap") return String(text ?? "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
4089
3980
  return String(text ?? "").replace(/\s+/gu, " ").trim();
@@ -4315,6 +4206,12 @@ function getGraphemeSegmenter() {
4315
4206
  function trimTrailingWhitespace(value) {
4316
4207
  return value.replace(/\s+$/u, "");
4317
4208
  }
4209
+ function trimLastGrapheme(value) {
4210
+ const segmenter = getGraphemeSegmenter();
4211
+ const graphemes = Array.from(segmenter.segment(value), (segment) => segment.segment);
4212
+ graphemes.pop();
4213
+ return graphemes.join("");
4214
+ }
4318
4215
  function splitPreservingWhitespace(value) {
4319
4216
  return value.split(/(\s+)/u).filter((token) => token.length > 0);
4320
4217
  }