postext 0.1.12 → 0.1.14
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/README.md +18 -0
- package/dist/__tests__/knuthPlass.test.d.ts +2 -0
- package/dist/__tests__/knuthPlass.test.d.ts.map +1 -0
- package/dist/__tests__/knuthPlass.test.js +209 -0
- package/dist/__tests__/knuthPlass.test.js.map +1 -0
- package/dist/canvas-backend.d.ts +4 -1
- package/dist/canvas-backend.d.ts.map +1 -1
- package/dist/canvas-backend.js +9 -1
- package/dist/canvas-backend.js.map +1 -1
- package/dist/defaults/bodyText.d.ts +2 -2
- package/dist/defaults/bodyText.d.ts.map +1 -1
- package/dist/defaults/bodyText.js +9 -3
- package/dist/defaults/bodyText.js.map +1 -1
- package/dist/defaults/debug.d.ts.map +1 -1
- package/dist/defaults/debug.js +12 -0
- package/dist/defaults/debug.js.map +1 -1
- package/dist/hyphenate.d.ts +2 -5
- package/dist/hyphenate.d.ts.map +1 -1
- package/dist/hyphenate.js +28 -128
- package/dist/hyphenate.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/knuthPlass.d.ts +76 -0
- package/dist/knuthPlass.d.ts.map +1 -0
- package/dist/knuthPlass.js +653 -0
- package/dist/knuthPlass.js.map +1 -0
- package/dist/measure.d.ts +6 -0
- package/dist/measure.d.ts.map +1 -1
- package/dist/measure.js +63 -4
- package/dist/measure.js.map +1 -1
- package/dist/pipeline/build.d.ts.map +1 -1
- package/dist/pipeline/build.js +3 -0
- package/dist/pipeline/build.js.map +1 -1
- package/dist/types.d.ts +12 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +11 -2
|
@@ -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
|