postext 0.1.11 → 0.1.13

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.
@@ -0,0 +1,653 @@
1
+ /**
2
+ * Knuth-Plass optimal paragraph line-breaking algorithm.
3
+ *
4
+ * Replaces the greedy first-fit line breaker with a dynamic-programming
5
+ * approach that minimizes total demerits across all lines of a paragraph,
6
+ * producing more even word-spacing and fewer "loose" lines.
7
+ */
8
+ import { createBoundingBox } from './vdt';
9
+ // ---------------------------------------------------------------------------
10
+ // Constants
11
+ // ---------------------------------------------------------------------------
12
+ const KP_INFINITY = 10000;
13
+ const HYPHEN_PENALTY = 50;
14
+ const DEFAULT_CONSECUTIVE_HYPHEN_DEMERIT = 3000;
15
+ const DEFAULT_FITNESS_CLASS_DEMERIT = 100;
16
+ const BADNESS_CAP = 10000;
17
+ const MAX_STRETCH = Number.MAX_SAFE_INTEGER;
18
+ // ---------------------------------------------------------------------------
19
+ // Helpers
20
+ // ---------------------------------------------------------------------------
21
+ function classifyFitness(r) {
22
+ if (r < -0.5)
23
+ return 0; // tight
24
+ if (r < 0.5)
25
+ return 1; // normal
26
+ if (r < 1.0)
27
+ return 2; // loose
28
+ return 3; // very loose
29
+ }
30
+ function computeBadness(r) {
31
+ const absR = Math.abs(r);
32
+ const b = Math.round(100 * absR * absR * absR);
33
+ return Math.min(b, BADNESS_CAP);
34
+ }
35
+ // ---------------------------------------------------------------------------
36
+ // Core DP: compute optimal breakpoints
37
+ // ---------------------------------------------------------------------------
38
+ export function computeBreakpoints(items, options) {
39
+ const { lineWidth, consecutiveHyphenDemerit = DEFAULT_CONSECUTIVE_HYPHEN_DEMERIT, fitnessClassDemerit = DEFAULT_FITNESS_CLASS_DEMERIT, } = options;
40
+ // Running totals up to current item
41
+ let sumWidth = 0;
42
+ let sumStretch = 0;
43
+ let sumShrink = 0;
44
+ // Prefix sums: sumWidthAt[i] = sum of widths of items 0..i-1
45
+ // We build these incrementally and store for the range computations.
46
+ const sumWidthAt = [0];
47
+ const sumStretchAt = [0];
48
+ const sumShrinkAt = [0];
49
+ for (const item of items) {
50
+ if (item.type === 'box') {
51
+ sumWidth += item.width;
52
+ }
53
+ else if (item.type === 'glue') {
54
+ sumWidth += item.width;
55
+ sumStretch += item.stretch;
56
+ sumShrink += item.shrink;
57
+ }
58
+ // Penalties don't contribute to running width/stretch/shrink
59
+ sumWidthAt.push(sumWidth);
60
+ sumStretchAt.push(sumStretch);
61
+ sumShrinkAt.push(sumShrink);
62
+ }
63
+ // Seed active node list with a "start of paragraph" sentinel
64
+ let activeNodes = [{
65
+ position: -1,
66
+ line: 0,
67
+ fitnessClass: 1,
68
+ totalWidth: 0,
69
+ totalStretch: 0,
70
+ totalShrink: 0,
71
+ totalDemerits: 0,
72
+ previous: null,
73
+ }];
74
+ for (let i = 0; i < items.length; i++) {
75
+ const item = items[i];
76
+ // Determine if this position is a legal breakpoint
77
+ let isLegalBreak = false;
78
+ if (item.type === 'glue') {
79
+ // Glue breaks if preceded by a box
80
+ if (i > 0 && items[i - 1].type === 'box') {
81
+ isLegalBreak = true;
82
+ }
83
+ }
84
+ else if (item.type === 'penalty') {
85
+ if (item.penalty < KP_INFINITY) {
86
+ isLegalBreak = true;
87
+ }
88
+ }
89
+ if (!isLegalBreak)
90
+ continue;
91
+ // Width contribution of breaking at this item
92
+ const breakWidth = item.type === 'penalty' ? item.width : 0;
93
+ const isFlagged = item.type === 'penalty' && item.flagged;
94
+ // For glue breakpoints, the line content goes up to (but excluding) this glue.
95
+ // For penalty breakpoints, the line content goes up to this penalty.
96
+ // The content width from after active node a to break at i:
97
+ // If glue: sumWidthAt[i] - a.totalWidth (excludes glue itself)
98
+ // If penalty: sumWidthAt[i] - a.totalWidth (sumWidthAt doesn't include penalty width)
99
+ // Plus breakWidth for penalties.
100
+ // Compute the content width from the items between a.position and i.
101
+ // After the break at active node a.position:
102
+ // - If a.position was a glue break, the next line starts at the item AFTER the glue
103
+ // - If a.position was a penalty break, the next line starts at the item AFTER the penalty
104
+ // - For the sentinel (-1), the line starts at item 0
105
+ // Total width from start to just before item i (excluding this glue/penalty's own width):
106
+ // For a glue at i: line content = sumWidthAt[i] (which includes all box+glue widths before i)
107
+ // but we need to subtract the width of glues/boxes after the previous break.
108
+ // Actually sumWidthAt[i] already accounts for that.
109
+ // We subtract a.totalWidth which is sumWidthAt at the point right after a's break.
110
+ const newNodes = [];
111
+ const toDeactivate = new Set();
112
+ for (let ai = 0; ai < activeNodes.length; ai++) {
113
+ const a = activeNodes[ai];
114
+ // Line content width: width of boxes and glues from after a's break to this break.
115
+ // For glue break: content is items from (a.position+1) to (i-1) inclusive + breakWidth
116
+ // = sumWidthAt[i] - a.totalWidth
117
+ // For penalty break: content is items from (a.position+1) to (i-1) inclusive + penalty.width
118
+ // = sumWidthAt[i] - a.totalWidth + breakWidth
119
+ // Note: sumWidthAt[i] does NOT include item i if it's a penalty.
120
+ // sumWidthAt[i] includes item i's width only for box/glue through the push.
121
+ // Wait — re-examine: sumWidthAt[i] is the prefix sum BEFORE item i is processed.
122
+ // Actually, sumWidthAt has length items.length + 1. sumWidthAt[0] = 0, sumWidthAt[k+1] includes item k.
123
+ // So sumWidthAt[i] includes items 0..i-1, and sumWidthAt[i+1] includes items 0..i.
124
+ // For a glue break at position i:
125
+ // The line goes from after a's break to just before this glue.
126
+ // Content width = sumWidthAt[i] - a.totalWidth
127
+ // (sumWidthAt[i] includes items 0..i-1, which covers everything up to but not including the glue)
128
+ //
129
+ // For a penalty break at position i:
130
+ // The line includes everything up to but not including the penalty, plus breakWidth
131
+ // Content width = sumWidthAt[i] - a.totalWidth + breakWidth
132
+ const contentWidth = sumWidthAt[i] - a.totalWidth + breakWidth;
133
+ const available = lineWidth(a.line);
134
+ const slack = available - contentWidth;
135
+ // Stretch/shrink from the items on this line
136
+ const lineStretch = sumStretchAt[i] - a.totalStretch;
137
+ const lineShrink = sumShrinkAt[i] - a.totalShrink;
138
+ let r;
139
+ if (slack >= 0) {
140
+ r = lineStretch > 0 ? slack / lineStretch : KP_INFINITY;
141
+ }
142
+ else {
143
+ r = lineShrink > 0 ? slack / lineShrink : -KP_INFINITY;
144
+ }
145
+ // Deactivate nodes that are too far behind (line too wide, can't shrink enough)
146
+ if (r < -1) {
147
+ toDeactivate.add(ai);
148
+ continue;
149
+ }
150
+ // Feasibility: don't allow extreme stretching
151
+ // We allow r up to a generous threshold; the demerits will naturally penalize loose lines
152
+ if (r > KP_INFINITY) {
153
+ // Infeasible — too few items to fill the line
154
+ continue;
155
+ }
156
+ const badness = computeBadness(r);
157
+ const pen = item.type === 'penalty' ? item.penalty : 0;
158
+ let d;
159
+ if (pen >= 0) {
160
+ d = (1 + badness + pen) * (1 + badness + pen);
161
+ }
162
+ else if (pen > -KP_INFINITY) {
163
+ d = (1 + badness) * (1 + badness) - pen * pen;
164
+ }
165
+ else {
166
+ d = (1 + badness) * (1 + badness);
167
+ }
168
+ // Consecutive hyphen demerit
169
+ const prevIsFlagged = a.position >= 0 &&
170
+ items[a.position].type === 'penalty' &&
171
+ items[a.position].flagged;
172
+ if (isFlagged && prevIsFlagged) {
173
+ d += consecutiveHyphenDemerit;
174
+ }
175
+ // Fitness class demerit
176
+ const fc = classifyFitness(r);
177
+ if (Math.abs(fc - a.fitnessClass) > 1) {
178
+ d += fitnessClassDemerit;
179
+ }
180
+ const totalDemerits = a.totalDemerits + d;
181
+ // The total width/stretch/shrink after this break:
182
+ // After a glue break: the glue is consumed. Next line starts at item i+1.
183
+ // totalWidth = sumWidthAt[i+1] (includes the glue's width — but we DON'T want it on the next line)
184
+ // Actually, for the "totalWidth" tracked in active nodes, it represents the cumulative
185
+ // width at the START of the next line. After a glue break at i, the next line starts at i+1.
186
+ // So totalWidth should be sumWidthAt[i+1], which includes the glue.
187
+ // When computing content width for the next line: sumWidthAt[j] - totalWidth
188
+ // This correctly excludes the consumed glue.
189
+ //
190
+ // After a penalty break: the penalty is not consumed as content on the next line.
191
+ // totalWidth = sumWidthAt[i+1] (which for penalties is same as sumWidthAt[i] since penalties
192
+ // don't add to sumWidth). Wait — let me re-check: penalties don't contribute to sumWidth,
193
+ // so sumWidthAt[i+1] == sumWidthAt[i] for a penalty at position i.
194
+ // But the next line starts at i+1. So totalWidth = sumWidthAt[i+1].
195
+ const newTotalWidth = sumWidthAt[i + 1];
196
+ const newTotalStretch = sumStretchAt[i + 1];
197
+ const newTotalShrink = sumShrinkAt[i + 1];
198
+ newNodes.push({
199
+ position: i,
200
+ line: a.line + 1,
201
+ fitnessClass: fc,
202
+ totalWidth: newTotalWidth,
203
+ totalStretch: newTotalStretch,
204
+ totalShrink: newTotalShrink,
205
+ totalDemerits: totalDemerits,
206
+ previous: a,
207
+ });
208
+ }
209
+ // Remove deactivated nodes
210
+ if (toDeactivate.size > 0) {
211
+ activeNodes = activeNodes.filter((_, idx) => !toDeactivate.has(idx));
212
+ }
213
+ // Deduplicate new nodes by (line, fitnessClass): keep best demerits.
214
+ // Different breakpoint positions must NOT be merged — only nodes at the
215
+ // same position (which all newNodes share) can be compared.
216
+ const deduped = [];
217
+ for (const node of newNodes) {
218
+ const key = node.line * 4 + node.fitnessClass;
219
+ let found = false;
220
+ for (let di = 0; di < deduped.length; di++) {
221
+ const existing = deduped[di];
222
+ if (existing.line * 4 + existing.fitnessClass === key) {
223
+ if (node.totalDemerits < existing.totalDemerits) {
224
+ deduped[di] = node;
225
+ }
226
+ found = true;
227
+ break;
228
+ }
229
+ }
230
+ if (!found) {
231
+ deduped.push(node);
232
+ }
233
+ }
234
+ // Append all deduplicated nodes to the active set
235
+ for (const node of deduped) {
236
+ activeNodes.push(node);
237
+ }
238
+ // Forced breaks: when penalty = -INFINITY, remove all active nodes that
239
+ // are NOT at this position. The break is mandatory — no line can skip it.
240
+ if (item.type === 'penalty' && item.penalty <= -KP_INFINITY) {
241
+ activeNodes = activeNodes.filter((a) => a.position === i);
242
+ }
243
+ // Emergency fallback: if active set is empty, force a break here
244
+ if (activeNodes.length === 0) {
245
+ activeNodes.push({
246
+ position: i,
247
+ line: 1, // We lost track — start fresh
248
+ fitnessClass: 1,
249
+ totalWidth: sumWidthAt[i + 1],
250
+ totalStretch: sumStretchAt[i + 1],
251
+ totalShrink: sumShrinkAt[i + 1],
252
+ totalDemerits: 0,
253
+ previous: null,
254
+ });
255
+ }
256
+ }
257
+ // Find best final node: only consider nodes at the last breakpoint position
258
+ // (the forced penalty at the end of the paragraph).
259
+ const lastBreakPosition = items.length - 1;
260
+ let best = null;
261
+ for (const a of activeNodes) {
262
+ if (a.position !== lastBreakPosition)
263
+ continue;
264
+ if (best === null || a.totalDemerits < best.totalDemerits) {
265
+ best = a;
266
+ }
267
+ }
268
+ // Fallback: if no node reached the final position, take any node with the
269
+ // highest position (nearest to the end).
270
+ if (!best) {
271
+ for (const a of activeNodes) {
272
+ if (a.position < 0)
273
+ continue;
274
+ if (best === null || a.position > best.position ||
275
+ (a.position === best.position && a.totalDemerits < best.totalDemerits)) {
276
+ best = a;
277
+ }
278
+ }
279
+ }
280
+ if (!best)
281
+ return [];
282
+ // Traceback
283
+ const breaks = [];
284
+ let node = best;
285
+ while (node !== null && node.position >= 0) {
286
+ breaks.push(node.position);
287
+ node = node.previous;
288
+ }
289
+ breaks.reverse();
290
+ return breaks;
291
+ }
292
+ // ---------------------------------------------------------------------------
293
+ // Adapter: pretext segments → KPItems
294
+ // ---------------------------------------------------------------------------
295
+ const SOFT_HYPHEN = '\u00AD';
296
+ export function pretextSegmentsToItems(prepared, normalSpaceWidth, maxStretchRatio, minShrinkRatio) {
297
+ const items = [];
298
+ const segments = prepared.segments;
299
+ const widths = prepared.widths;
300
+ const kinds = prepared.kinds;
301
+ const discretionaryHyphenWidth = prepared.discretionaryHyphenWidth;
302
+ const stretchPerSpace = normalSpaceWidth * (maxStretchRatio - 1);
303
+ const shrinkPerSpace = normalSpaceWidth * (1 - minShrinkRatio);
304
+ for (let i = 0; i < segments.length; i++) {
305
+ const kind = kinds[i];
306
+ const w = widths[i];
307
+ switch (kind) {
308
+ case 'text':
309
+ items.push({ type: 'box', width: w, sourceIndex: i });
310
+ break;
311
+ case 'space':
312
+ case 'glue':
313
+ items.push({
314
+ type: 'glue',
315
+ width: w,
316
+ stretch: stretchPerSpace,
317
+ shrink: shrinkPerSpace,
318
+ sourceIndex: i,
319
+ });
320
+ break;
321
+ case 'soft-hyphen':
322
+ items.push({
323
+ type: 'penalty',
324
+ width: discretionaryHyphenWidth,
325
+ penalty: HYPHEN_PENALTY,
326
+ flagged: true,
327
+ sourceIndex: i,
328
+ });
329
+ break;
330
+ case 'hard-break':
331
+ items.push({
332
+ type: 'penalty',
333
+ width: 0,
334
+ penalty: -KP_INFINITY,
335
+ flagged: false,
336
+ sourceIndex: i,
337
+ });
338
+ break;
339
+ case 'zero-width-break':
340
+ items.push({
341
+ type: 'penalty',
342
+ width: 0,
343
+ penalty: 0,
344
+ flagged: false,
345
+ sourceIndex: i,
346
+ });
347
+ break;
348
+ case 'preserved-space':
349
+ case 'tab':
350
+ items.push({ type: 'box', width: w, sourceIndex: i });
351
+ break;
352
+ default:
353
+ items.push({ type: 'box', width: w, sourceIndex: i });
354
+ break;
355
+ }
356
+ }
357
+ // Final-line glue (infinite stretch) + forced break
358
+ items.push({
359
+ type: 'glue',
360
+ width: 0,
361
+ stretch: MAX_STRETCH,
362
+ shrink: 0,
363
+ sourceIndex: -1,
364
+ });
365
+ items.push({
366
+ type: 'penalty',
367
+ width: 0,
368
+ penalty: -KP_INFINITY,
369
+ flagged: false,
370
+ sourceIndex: -1,
371
+ });
372
+ return items;
373
+ }
374
+ export function richTokensToItems(tokens, normalSpaceWidth, maxStretchRatio, minShrinkRatio) {
375
+ const items = [];
376
+ const stretchPerSpace = normalSpaceWidth * (maxStretchRatio - 1);
377
+ const shrinkPerSpace = normalSpaceWidth * (1 - minShrinkRatio);
378
+ for (let t = 0; t < tokens.length; t++) {
379
+ const token = tokens[t];
380
+ const meta = {
381
+ bold: token.bold,
382
+ italic: token.italic,
383
+ originalTokenIndex: t,
384
+ };
385
+ if (token.kind === 'space') {
386
+ items.push({
387
+ type: 'glue',
388
+ width: token.width,
389
+ stretch: stretchPerSpace,
390
+ shrink: shrinkPerSpace,
391
+ sourceIndex: t,
392
+ meta: { ...meta },
393
+ });
394
+ continue;
395
+ }
396
+ // Text token — may have soft-hyphen break points
397
+ if (token.breakPoints && token.breakPoints.length > 0) {
398
+ const hyphenW = token.hyphenWidth ?? 0;
399
+ let prevCharIndex = 0;
400
+ let prevWidth = 0;
401
+ for (let bp = 0; bp < token.breakPoints.length; bp++) {
402
+ const breakPoint = token.breakPoints[bp];
403
+ const fragmentWidth = breakPoint.widthBefore - prevWidth;
404
+ // Box for the fragment before this break point
405
+ items.push({
406
+ type: 'box',
407
+ width: fragmentWidth,
408
+ sourceIndex: t,
409
+ meta: { ...meta, subStart: prevCharIndex, subEnd: breakPoint.charIndex },
410
+ });
411
+ // Penalty at the soft hyphen
412
+ items.push({
413
+ type: 'penalty',
414
+ width: hyphenW,
415
+ penalty: HYPHEN_PENALTY,
416
+ flagged: true,
417
+ sourceIndex: t,
418
+ meta: { ...meta },
419
+ });
420
+ prevCharIndex = breakPoint.charIndex;
421
+ prevWidth = breakPoint.widthBefore;
422
+ }
423
+ // Final fragment after last break point
424
+ items.push({
425
+ type: 'box',
426
+ width: token.width - prevWidth,
427
+ sourceIndex: t,
428
+ meta: { ...meta, subStart: prevCharIndex, subEnd: token.text.length },
429
+ });
430
+ }
431
+ else {
432
+ // Simple text token without break points
433
+ items.push({
434
+ type: 'box',
435
+ width: token.width,
436
+ sourceIndex: t,
437
+ meta: { ...meta, subStart: 0, subEnd: token.text.length },
438
+ });
439
+ }
440
+ }
441
+ // Final-line glue + forced break
442
+ items.push({
443
+ type: 'glue',
444
+ width: 0,
445
+ stretch: MAX_STRETCH,
446
+ shrink: 0,
447
+ sourceIndex: -1,
448
+ });
449
+ items.push({
450
+ type: 'penalty',
451
+ width: 0,
452
+ penalty: -KP_INFINITY,
453
+ flagged: false,
454
+ sourceIndex: -1,
455
+ });
456
+ return items;
457
+ }
458
+ // ---------------------------------------------------------------------------
459
+ // Reconstruction: breakpoints → VDTLine[] (pretext path)
460
+ // ---------------------------------------------------------------------------
461
+ function cleanSoftHyphens(text) {
462
+ return text.replace(/\u00AD/g, '');
463
+ }
464
+ export function reconstructPretextLines(items, breaks, prepared, lineHeightPx, lineWidthFn, lineIndentFn, normalSpaceWidth, textAlign) {
465
+ const segments = prepared.segments;
466
+ const widths = prepared.widths;
467
+ const discretionaryHyphenWidth = prepared.discretionaryHyphenWidth;
468
+ const lines = [];
469
+ let lineStart = 0; // item index where current line content starts
470
+ for (let li = 0; li < breaks.length; li++) {
471
+ const breakAt = breaks[li];
472
+ const isLastLine = li === breaks.length - 1;
473
+ const lineIndent = lineIndentFn(li);
474
+ const lineMaxWidth = lineWidthFn(li);
475
+ // Determine if this break is at a penalty (hyphenation)
476
+ const breakItem = items[breakAt];
477
+ const hyphenated = breakItem.type === 'penalty' && breakItem.flagged;
478
+ // Collect segments for this line: items from lineStart to breakAt
479
+ // For glue breaks: line content is items lineStart..breakAt-1 (exclude the breaking glue)
480
+ // For penalty breaks: line content is items lineStart..breakAt-1 (exclude the penalty itself)
481
+ const contentEnd = breakItem.type === 'glue' ? breakAt : breakAt;
482
+ const lineSegments = [];
483
+ const textParts = [];
484
+ for (let j = lineStart; j < contentEnd; j++) {
485
+ const it = items[j];
486
+ if (it.sourceIndex < 0)
487
+ continue; // skip synthetic items
488
+ if (it.type === 'box') {
489
+ const seg = segments[it.sourceIndex];
490
+ const w = widths[it.sourceIndex];
491
+ if (seg === SOFT_HYPHEN)
492
+ continue;
493
+ const cleanText = cleanSoftHyphens(seg);
494
+ lineSegments.push({ kind: 'text', text: cleanText, width: w });
495
+ textParts.push(cleanText);
496
+ }
497
+ else if (it.type === 'glue') {
498
+ const seg = segments[it.sourceIndex];
499
+ const w = widths[it.sourceIndex];
500
+ lineSegments.push({ kind: 'space', text: seg, width: w });
501
+ textParts.push(seg);
502
+ }
503
+ // Penalties within the line are skipped (they're just potential break points)
504
+ }
505
+ // Trim trailing spaces
506
+ while (lineSegments.length > 0 && lineSegments[lineSegments.length - 1].kind === 'space') {
507
+ lineSegments.pop();
508
+ textParts.pop();
509
+ }
510
+ // If hyphenated, append '-' to the last text segment
511
+ if (hyphenated && lineSegments.length > 0) {
512
+ const lastIdx = lineSegments.length - 1;
513
+ const last = lineSegments[lastIdx];
514
+ if (last.kind === 'text') {
515
+ lineSegments[lastIdx] = {
516
+ kind: 'text',
517
+ text: last.text + '-',
518
+ width: last.width + discretionaryHyphenWidth,
519
+ };
520
+ textParts[textParts.length - 1] = last.text + '-';
521
+ }
522
+ }
523
+ const lineText = textParts.join('');
524
+ const contentWidth = lineSegments.reduce((s, seg) => s + seg.width, 0);
525
+ // Compute justifiedSpaceRatio
526
+ let justifiedSpaceRatio;
527
+ if (textAlign === 'justify' && !isLastLine && normalSpaceWidth > 0) {
528
+ let wordWidth = 0;
529
+ let spaceCount = 0;
530
+ for (const seg of lineSegments) {
531
+ if (seg.kind === 'space')
532
+ spaceCount++;
533
+ else
534
+ wordWidth += seg.width;
535
+ }
536
+ if (spaceCount > 0) {
537
+ const justifiedSpaceWidth = (lineMaxWidth - wordWidth) / spaceCount;
538
+ justifiedSpaceRatio = justifiedSpaceWidth / normalSpaceWidth;
539
+ }
540
+ }
541
+ lines.push({
542
+ text: lineText,
543
+ bbox: createBoundingBox(lineIndent, li * lineHeightPx, contentWidth, lineHeightPx),
544
+ baseline: li * lineHeightPx + lineHeightPx * 0.8,
545
+ hyphenated,
546
+ segments: lineSegments,
547
+ isLastLine,
548
+ ...(justifiedSpaceRatio !== undefined ? { justifiedSpaceRatio } : {}),
549
+ });
550
+ // Next line starts after the break
551
+ lineStart = breakAt + 1;
552
+ }
553
+ return lines;
554
+ }
555
+ // ---------------------------------------------------------------------------
556
+ // Reconstruction: breakpoints → VDTLine[] (rich text path)
557
+ // ---------------------------------------------------------------------------
558
+ export function reconstructRichLines(items, breaks, tokens, lineHeightPx, lineWidthFn, lineIndentFn, normalSpaceWidth, textAlign) {
559
+ const lines = [];
560
+ let lineStart = 0;
561
+ for (let li = 0; li < breaks.length; li++) {
562
+ const breakAt = breaks[li];
563
+ const isLastLine = li === breaks.length - 1;
564
+ const lineIndent = lineIndentFn(li);
565
+ const lineMaxWidth = lineWidthFn(li);
566
+ const breakItem = items[breakAt];
567
+ const hyphenated = breakItem.type === 'penalty' && breakItem.flagged;
568
+ const lineSegments = [];
569
+ const textParts = [];
570
+ for (let j = lineStart; j < breakAt; j++) {
571
+ const it = items[j];
572
+ if (it.sourceIndex < 0)
573
+ continue;
574
+ const meta = it.meta;
575
+ if (it.type === 'box' && meta) {
576
+ const token = tokens[meta.originalTokenIndex];
577
+ const subText = meta.subStart !== undefined && meta.subEnd !== undefined
578
+ ? token.text.slice(meta.subStart, meta.subEnd)
579
+ : token.text;
580
+ const cleanText = cleanSoftHyphens(subText);
581
+ lineSegments.push({
582
+ kind: 'text',
583
+ text: cleanText,
584
+ width: it.width,
585
+ bold: meta.bold || undefined,
586
+ italic: meta.italic || undefined,
587
+ });
588
+ textParts.push(cleanText);
589
+ }
590
+ else if (it.type === 'glue' && meta) {
591
+ const token = tokens[meta.originalTokenIndex];
592
+ lineSegments.push({
593
+ kind: 'space',
594
+ text: token.text,
595
+ width: it.width,
596
+ });
597
+ textParts.push(token.text);
598
+ }
599
+ }
600
+ // Trim trailing spaces
601
+ while (lineSegments.length > 0 && lineSegments[lineSegments.length - 1].kind === 'space') {
602
+ lineSegments.pop();
603
+ textParts.pop();
604
+ }
605
+ // If hyphenated, append '-' to the last text segment
606
+ if (hyphenated && lineSegments.length > 0) {
607
+ const breakMeta = breakItem.meta;
608
+ const hyphenW = breakMeta
609
+ ? (tokens[breakMeta.originalTokenIndex]?.hyphenWidth ?? 0)
610
+ : 0;
611
+ const lastIdx = lineSegments.length - 1;
612
+ const last = lineSegments[lastIdx];
613
+ if (last.kind === 'text') {
614
+ lineSegments[lastIdx] = {
615
+ ...last,
616
+ text: last.text + '-',
617
+ width: last.width + hyphenW,
618
+ };
619
+ textParts[textParts.length - 1] = last.text + '-';
620
+ }
621
+ }
622
+ const lineText = textParts.join('');
623
+ const contentWidth = lineSegments.reduce((s, seg) => s + seg.width, 0);
624
+ // Compute justifiedSpaceRatio
625
+ let justifiedSpaceRatio;
626
+ if (textAlign === 'justify' && !isLastLine && normalSpaceWidth > 0) {
627
+ let wordWidth = 0;
628
+ let spaceCount = 0;
629
+ for (const seg of lineSegments) {
630
+ if (seg.kind === 'space')
631
+ spaceCount++;
632
+ else
633
+ wordWidth += seg.width;
634
+ }
635
+ if (spaceCount > 0) {
636
+ const justifiedSpaceWidth = (lineMaxWidth - wordWidth) / spaceCount;
637
+ justifiedSpaceRatio = justifiedSpaceWidth / normalSpaceWidth;
638
+ }
639
+ }
640
+ lines.push({
641
+ text: lineText,
642
+ bbox: createBoundingBox(lineIndent, li * lineHeightPx, contentWidth, lineHeightPx),
643
+ baseline: li * lineHeightPx + lineHeightPx * 0.8,
644
+ hyphenated,
645
+ segments: lineSegments,
646
+ isLastLine,
647
+ ...(justifiedSpaceRatio !== undefined ? { justifiedSpaceRatio } : {}),
648
+ });
649
+ lineStart = breakAt + 1;
650
+ }
651
+ return lines;
652
+ }
653
+ //# sourceMappingURL=knuthPlass.js.map