rapier-markdown-kit 1.1.102 → 1.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +5 -2
- package/README.md +245 -54
- package/dist/agent/vendor/hyphen-en-us/LICENSE +14 -0
- package/dist/agent/vendor/hyphen-en-us/SOURCE.json +33 -0
- package/dist/agent/vendor/hyphen-en-us/hyph-en-us.hyp.txt +14 -0
- package/dist/agent/vendor/hyphen-en-us/hyph-en-us.pat.txt +4938 -0
- package/dist/agent/vendor/hyphen-en-us/patterns.js +25 -0
- package/dist/agent/vendor/pretext/LICENSE +208 -0
- package/dist/agent/vendor/pretext/NOTICE +26 -13
- package/dist/agent/vendor/pretext/SOURCE.json +213 -45
- package/dist/agent/vendor/pretext/analysis.js +675 -1224
- package/dist/agent/vendor/pretext/entry-geometry.js +183 -0
- package/dist/agent/vendor/pretext/gecko-line-breaks.js +688 -0
- package/dist/agent/vendor/pretext/generated/engine-break-data.js +57 -0
- package/dist/agent/vendor/pretext/generated/webkit-generic-families.js +80 -0
- package/dist/agent/vendor/pretext/graphemes.js +98 -0
- package/dist/agent/vendor/pretext/han-kerning.js +253 -0
- package/dist/agent/vendor/pretext/layout.js +352 -290
- package/dist/agent/vendor/pretext/line-break.js +1154 -588
- package/dist/agent/vendor/pretext/line-breaks.js +892 -0
- package/dist/agent/vendor/pretext/line-text.js +81 -39
- package/dist/agent/vendor/pretext/measurement.js +1105 -176
- package/dist/agent/vendor/pretext/prepare.js +807 -0
- package/dist/agent/vendor/pretext/rich-inline.js +1242 -273
- package/dist/agent/will.mjs +2 -0
- package/dist/images/gif.mjs +47 -0
- package/dist/images/playback.mjs +101 -0
- package/dist/kit/assets.mjs +5 -1
- package/dist/kit/conformance/03-wrap-around-silhouette.md +1 -1
- package/dist/kit/conformance/04-wrap-box.md +1 -1
- package/dist/kit/conformance/05-behind.md +1 -1
- package/dist/kit/conformance/06-front.md +1 -1
- package/dist/kit/conformance/07-rotate-raster.md +1 -1
- package/dist/kit/conformance/08-drawing-rotation.md +1 -1
- package/dist/kit/conformance/09-drawing-ring-interior.expected.json +18 -18
- package/dist/kit/conformance/09-drawing-ring-interior.md +1 -1
- package/dist/kit/conformance/10-both-sides.md +1 -1
- package/dist/kit/conformance/11-neighbour-skips-image.md +1 -1
- package/dist/kit/conformance/{12-heading-barrier.expected.json → 12-rule-barrier.expected.json} +1 -1
- package/dist/kit/conformance/{12-heading-barrier.md → 12-rule-barrier.md} +2 -2
- package/dist/kit/conformance/README.md +19 -28
- package/dist/kit/conformance/run.mjs +13 -11
- package/dist/kit/export-fonts.mjs +26 -11
- package/dist/kit/index.mjs +1 -0
- package/dist/kit/ledger/carried.mjs +17 -10
- package/dist/kit/ledger/data.mjs +73 -13
- package/dist/kit/ledger/format.mjs +176 -54
- package/dist/kit/ledger/hash.mjs +91 -18
- package/dist/kit/ledger/history.mjs +156 -116
- package/dist/kit/ledger/inverse-source.mjs +158 -19
- package/dist/kit/ledger/journal-records.mjs +292 -67
- package/dist/kit/ledger/merge.mjs +4 -3
- package/dist/kit/model.mjs +1 -1
- package/dist/kit/pdf-glyphs.mjs +4 -4
- package/dist/kit/pdf-vectors.mjs +4 -1
- package/dist/kit/rapier-ledger.mjs +2 -2
- package/dist/kit/render-ink.mjs +1 -1
- package/dist/kit/render-markdown.mjs +400 -148
- package/dist/kit/render-print.mjs +131 -55
- package/dist/kit/render-sanitize.mjs +30 -7
- package/dist/kit/render-work.mjs +99 -20
- package/dist/kit/render.mjs +133 -140
- package/dist/kit/typography.mjs +2 -0
- package/dist/layout/hyphenation.mjs +127 -0
- package/dist/layout/line-plan.mjs +88 -96
- package/dist/layout/model.mjs +71 -88
- package/dist/layout/paragraph-fit.mjs +191 -0
- package/dist/layout/preparation.mjs +373 -0
- package/dist/layout/prepared-paragraph.mjs +223 -0
- package/dist/layout/typography.mjs +345 -111
- package/dist/packages/rapier-diff/index.mjs +359 -224
- package/dist/spec/document-fonts.mjs +14 -5
- package/dist/spec/document-settings.mjs +11 -164
- package/dist/spec/html-blocks.mjs +618 -79
- package/dist/spec/md-assets.mjs +178 -36
- package/dist/spec/md-layout.mjs +13 -2
- package/dist/spec/md-marks.mjs +220 -127
- package/dist/spec/md-source.mjs +157 -0
- package/package.json +2 -1
- package/style.css +15 -50
- package/dist/agent/vendor/pretext/bidi.js +0 -151
- package/dist/agent/vendor/pretext/generated/bidi-data.js +0 -3831
|
@@ -1,1234 +1,685 @@
|
|
|
1
|
-
/*! Pretext
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
export
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
'
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
'
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
'\
|
|
193
|
-
'\
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
splitIndex = start;
|
|
359
|
-
continue;
|
|
360
|
-
}
|
|
361
|
-
break;
|
|
362
|
-
}
|
|
363
|
-
if (splitIndex <= 0 || splitIndex === text.length) return null;
|
|
364
|
-
return {
|
|
365
|
-
head: text.slice(0, splitIndex),
|
|
366
|
-
tail: text.slice(splitIndex)
|
|
367
|
-
};
|
|
368
|
-
}
|
|
369
|
-
function getRepeatableSingleCharRunChar(text, isWordLike, kind) {
|
|
370
|
-
return kind === 'text' && !isWordLike && text.length === 1 && text !== '-' && text !== '—' ? text : null;
|
|
371
|
-
}
|
|
372
|
-
function hasArabicNoSpacePunctuation(containsArabic, lastCodePoint) {
|
|
373
|
-
return containsArabic && lastCodePoint !== null && arabicNoSpaceTrailingPunctuation.has(lastCodePoint);
|
|
374
|
-
}
|
|
375
|
-
function endsWithMyanmarMedialGlue(segment) {
|
|
376
|
-
const lastCodePoint = getLastCodePoint(segment);
|
|
377
|
-
return lastCodePoint !== null && myanmarMedialGlue.has(lastCodePoint);
|
|
378
|
-
}
|
|
379
|
-
function splitLeadingSpaceAndMarks(segment) {
|
|
380
|
-
if (segment.length < 2 || segment[0] !== ' ') return null;
|
|
381
|
-
const marks = segment.slice(1);
|
|
382
|
-
if (/^\p{M}+$/u.test(marks)) {
|
|
383
|
-
return {
|
|
384
|
-
space: ' ',
|
|
385
|
-
marks
|
|
386
|
-
};
|
|
387
|
-
}
|
|
388
|
-
return null;
|
|
389
|
-
}
|
|
390
|
-
export function endsWithClosingQuote(text) {
|
|
391
|
-
let end = text.length;
|
|
392
|
-
while(end > 0){
|
|
393
|
-
const start = previousCodePointStart(text, end);
|
|
394
|
-
const ch = text.slice(start, end);
|
|
395
|
-
if (closingQuoteChars.has(ch)) return true;
|
|
396
|
-
if (!leftStickyPunctuation.has(ch)) return false;
|
|
397
|
-
end = start;
|
|
398
|
-
}
|
|
399
|
-
return false;
|
|
400
|
-
}
|
|
401
|
-
function classifySegmentBreakChar(ch, whiteSpace) {
|
|
402
|
-
if (whiteSpace === 'pre-wrap') {
|
|
403
|
-
if (ch === ' ') return 'preserved-space';
|
|
404
|
-
if (ch === '\t') return 'tab';
|
|
405
|
-
if (ch === '\n') return 'hard-break';
|
|
406
|
-
}
|
|
407
|
-
if (ch === ' ') return 'space';
|
|
408
|
-
if (ch === '\u00A0' || ch === '\u202F' || ch === '\u2060' || ch === '\uFEFF') {
|
|
409
|
-
return 'glue';
|
|
410
|
-
}
|
|
411
|
-
if (ch === '\u200B') return 'zero-width-break';
|
|
412
|
-
if (ch === '\u00AD') return 'soft-hyphen';
|
|
413
|
-
return 'text';
|
|
414
|
-
}
|
|
415
|
-
const breakCharRe = /[\x20\t\n\xA0\xAD\u200B\u202F\u2060\uFEFF]/;
|
|
416
|
-
function joinTextParts(parts) {
|
|
417
|
-
return parts.length === 1 ? parts[0] : parts.join('');
|
|
418
|
-
}
|
|
419
|
-
function joinReversedPrefixParts(prefixParts, tail) {
|
|
420
|
-
const parts = [];
|
|
421
|
-
for(let i = prefixParts.length - 1; i >= 0; i--){
|
|
422
|
-
parts.push(prefixParts[i]);
|
|
423
|
-
}
|
|
424
|
-
parts.push(tail);
|
|
425
|
-
return joinTextParts(parts);
|
|
426
|
-
}
|
|
427
|
-
function splitSegmentByBreakKind(segment, isWordLike, start, whiteSpace) {
|
|
428
|
-
if (!breakCharRe.test(segment)) {
|
|
429
|
-
return [
|
|
430
|
-
{
|
|
431
|
-
text: segment,
|
|
432
|
-
isWordLike,
|
|
433
|
-
kind: 'text',
|
|
434
|
-
start
|
|
435
|
-
}
|
|
436
|
-
];
|
|
437
|
-
}
|
|
438
|
-
const pieces = [];
|
|
439
|
-
let currentKind = null;
|
|
440
|
-
let currentStart = 0;
|
|
441
|
-
let currentWordLike = false;
|
|
442
|
-
let offset = 0;
|
|
443
|
-
for (const ch of segment){
|
|
444
|
-
const kind = classifySegmentBreakChar(ch, whiteSpace);
|
|
445
|
-
const wordLike = kind === 'text' && isWordLike;
|
|
446
|
-
if (currentKind !== null && kind === currentKind && wordLike === currentWordLike) {
|
|
447
|
-
offset += ch.length;
|
|
448
|
-
continue;
|
|
449
|
-
}
|
|
450
|
-
if (currentKind !== null) {
|
|
451
|
-
pieces.push({
|
|
452
|
-
text: segment.slice(currentStart, offset),
|
|
453
|
-
isWordLike: currentWordLike,
|
|
454
|
-
kind: currentKind,
|
|
455
|
-
start: start + currentStart
|
|
456
|
-
});
|
|
457
|
-
}
|
|
458
|
-
currentKind = kind;
|
|
459
|
-
currentStart = offset;
|
|
460
|
-
currentWordLike = wordLike;
|
|
461
|
-
offset += ch.length;
|
|
462
|
-
}
|
|
463
|
-
if (currentKind !== null) {
|
|
464
|
-
pieces.push({
|
|
465
|
-
text: segment.slice(currentStart),
|
|
466
|
-
isWordLike: currentWordLike,
|
|
467
|
-
kind: currentKind,
|
|
468
|
-
start: start + currentStart
|
|
469
|
-
});
|
|
470
|
-
}
|
|
471
|
-
return pieces;
|
|
472
|
-
}
|
|
473
|
-
function isTextRunBoundary(kind) {
|
|
474
|
-
return kind === 'space' || kind === 'preserved-space' || kind === 'zero-width-break' || kind === 'hard-break';
|
|
475
|
-
}
|
|
476
|
-
const urlSchemeSegmentRe = /^[A-Za-z][A-Za-z0-9+.-]*:$/;
|
|
477
|
-
function isUrlLikeRunStart(segmentation, index) {
|
|
478
|
-
const text = segmentation.texts[index];
|
|
479
|
-
if (text.startsWith('www.')) return true;
|
|
480
|
-
return urlSchemeSegmentRe.test(text) && index + 1 < segmentation.len && segmentation.kinds[index + 1] === 'text' && segmentation.texts[index + 1] === '//';
|
|
481
|
-
}
|
|
482
|
-
function isUrlQueryBoundarySegment(text) {
|
|
483
|
-
return text.includes('?') && (text.includes('://') || text.startsWith('www.'));
|
|
484
|
-
}
|
|
485
|
-
function mergeUrlRuns(segmentation, normalized, profile) {
|
|
486
|
-
const texts = [];
|
|
487
|
-
const isWordLike = [];
|
|
488
|
-
const kinds = [];
|
|
489
|
-
const starts = [];
|
|
490
|
-
for(let i = 0; i < segmentation.len; i++){
|
|
491
|
-
const start = segmentation.starts[i];
|
|
492
|
-
let text = segmentation.texts[i];
|
|
493
|
-
let wordLike = segmentation.isWordLike[i];
|
|
494
|
-
const kind = segmentation.kinds[i];
|
|
495
|
-
let queryStartOverride = -1;
|
|
496
|
-
if (kind === 'text' && isUrlLikeRunStart(segmentation, i)) {
|
|
497
|
-
const urlParts = [
|
|
498
|
-
text
|
|
499
|
-
];
|
|
500
|
-
let j = i + 1;
|
|
501
|
-
while(j < segmentation.len && !isTextRunBoundary(segmentation.kinds[j]) && numericAffixBoundary(normalized, segmentation.starts[j], profile) !== false){
|
|
502
|
-
if (queryStartOverride < 0 && isUrlLikeRunStart(segmentation, j)) {
|
|
503
|
-
queryStartOverride = segmentation.starts[j];
|
|
504
|
-
}
|
|
505
|
-
const nextText = segmentation.texts[j];
|
|
506
|
-
urlParts.push(nextText);
|
|
507
|
-
wordLike = true;
|
|
508
|
-
j++;
|
|
509
|
-
if (nextText.includes('?')) break;
|
|
510
|
-
}
|
|
511
|
-
text = joinTextParts(urlParts);
|
|
512
|
-
i = j - 1;
|
|
513
|
-
}
|
|
514
|
-
texts.push(text);
|
|
515
|
-
isWordLike.push(wordLike);
|
|
516
|
-
kinds.push(kind);
|
|
517
|
-
starts.push(start);
|
|
518
|
-
if (!isUrlQueryBoundarySegment(text)) continue;
|
|
519
|
-
const nextIndex = i + 1;
|
|
520
|
-
if (nextIndex >= segmentation.len || isTextRunBoundary(segmentation.kinds[nextIndex])) {
|
|
521
|
-
continue;
|
|
522
|
-
}
|
|
523
|
-
const queryParts = [];
|
|
524
|
-
const queryStart = queryStartOverride < 0 ? segmentation.starts[nextIndex] : queryStartOverride;
|
|
525
|
-
let j = nextIndex;
|
|
526
|
-
while(j < segmentation.len && !isTextRunBoundary(segmentation.kinds[j]) && numericAffixBoundary(normalized, segmentation.starts[j], profile) !== false){
|
|
527
|
-
queryParts.push(segmentation.texts[j]);
|
|
528
|
-
j++;
|
|
529
|
-
}
|
|
530
|
-
if (queryParts.length > 0) {
|
|
531
|
-
texts.push(joinTextParts(queryParts));
|
|
532
|
-
isWordLike.push(true);
|
|
533
|
-
kinds.push('text');
|
|
534
|
-
starts.push(queryStart);
|
|
535
|
-
i = j - 1;
|
|
536
|
-
}
|
|
537
|
-
}
|
|
538
|
-
return {
|
|
539
|
-
len: texts.length,
|
|
540
|
-
texts,
|
|
541
|
-
isWordLike,
|
|
542
|
-
kinds,
|
|
543
|
-
starts
|
|
544
|
-
};
|
|
545
|
-
}
|
|
546
|
-
const numericJoinerChars = new Set([
|
|
547
|
-
':',
|
|
548
|
-
'-',
|
|
549
|
-
'/',
|
|
550
|
-
'×',
|
|
551
|
-
',',
|
|
552
|
-
'.',
|
|
553
|
-
'+',
|
|
554
|
-
'\u2013',
|
|
555
|
-
'\u2014'
|
|
556
|
-
]);
|
|
557
|
-
const wordInternalSymbolRe = /[\p{P}\p{S}\p{Co}]/u;
|
|
558
|
-
const emojiPresentationRe = /\p{Emoji_Presentation}/u;
|
|
559
|
-
const noSpaceWordBreakAfterChars = new Set([
|
|
560
|
-
'?',
|
|
561
|
-
'\u058A',
|
|
562
|
-
'-',
|
|
563
|
-
'\u2010',
|
|
564
|
-
'\u2012',
|
|
565
|
-
'\u2013',
|
|
566
|
-
'\u2014',
|
|
567
|
-
'\u2026',
|
|
568
|
-
'\u203C',
|
|
569
|
-
'\u203D',
|
|
570
|
-
'\u2049'
|
|
571
|
-
]);
|
|
572
|
-
function isAsciiWordInternalSymbolCode(code) {
|
|
573
|
-
return code >= 0x21 && code <= 0x2F && code !== 0x2D || code >= 0x3A && code <= 0x40 && code !== 0x3F || code >= 0x5B && code <= 0x60 || code >= 0x7B && code <= 0x7E;
|
|
574
|
-
}
|
|
575
|
-
function isNoSpaceWordInternalSymbol(ch) {
|
|
576
|
-
const code = ch.charCodeAt(0);
|
|
577
|
-
if (code < 0x80) return isAsciiWordInternalSymbolCode(code);
|
|
578
|
-
return !noSpaceWordBreakAfterChars.has(ch) && !emojiPresentationRe.test(ch) && wordInternalSymbolRe.test(ch);
|
|
579
|
-
}
|
|
580
|
-
function isNoSpaceWordInternalSymbolSegment(text) {
|
|
581
|
-
let sawSymbol = false;
|
|
582
|
-
for (const ch of text){
|
|
583
|
-
if (combiningMarkRe.test(ch)) continue;
|
|
584
|
-
if (!isNoSpaceWordInternalSymbol(ch)) return false;
|
|
585
|
-
sawSymbol = true;
|
|
586
|
-
}
|
|
587
|
-
return sawSymbol;
|
|
588
|
-
}
|
|
589
|
-
function endsWithNoSpaceWordJoiner(text) {
|
|
590
|
-
for(let end = text.length; end > 0;){
|
|
591
|
-
const start = previousCodePointStart(text, end);
|
|
592
|
-
const ch = text.slice(start, end);
|
|
593
|
-
if (combiningMarkRe.test(ch)) {
|
|
594
|
-
end = start;
|
|
595
|
-
continue;
|
|
596
|
-
}
|
|
597
|
-
return isNoSpaceWordInternalSymbol(ch) || isLineBreakNumericAffix(ch);
|
|
598
|
-
}
|
|
599
|
-
return false;
|
|
600
|
-
}
|
|
601
|
-
const asciiAlphabeticBoundaryRe = /[A-Za-z#&*<=>@^_`~]/;
|
|
602
|
-
function isAsciiBoundary(left, right) {
|
|
603
|
-
return left.charCodeAt(0) < 0x80 && right.charCodeAt(0) < 0x80;
|
|
604
|
-
}
|
|
605
|
-
function numericAffixBoundary(source, boundary, profile) {
|
|
606
|
-
if (!profile.geckoAsciiLineBreaks || boundary <= 0 || boundary >= source.length) return null;
|
|
607
|
-
const left = getLastSignificantCodePoint(source, boundary);
|
|
608
|
-
const right = String.fromCodePoint(source.codePointAt(boundary));
|
|
609
|
-
if (left === null || !isAsciiBoundary(left, right)) return null;
|
|
610
|
-
const leftAffix = isLineBreakNumericAffix(left);
|
|
611
|
-
const rightAffix = isLineBreakNumericAffix(right);
|
|
612
|
-
if (!leftAffix && !rightAffix) return null;
|
|
613
|
-
if (right === ')' || right === ']' || right === '}' || '!,.:;?/'.includes(right)) return true;
|
|
614
|
-
if ('([{'.includes(left) || left === '"' || left === "'" || right === '"' || right === "'") return true;
|
|
615
|
-
if (right === '|' || right === '-') return true;
|
|
616
|
-
if (leftAffix && asciiAlphabeticBoundaryRe.test(right) || asciiAlphabeticBoundaryRe.test(left) && rightAffix) return true;
|
|
617
|
-
if (rightAffix && (')]}'.includes(left) || /[0-9]/.test(left))) return true;
|
|
618
|
-
if (leftAffix && ('([{'.includes(right) || /[0-9]/.test(right))) return true;
|
|
619
|
-
return false;
|
|
620
|
-
}
|
|
621
|
-
function openingPunctuationJoinsPrevious(left, right, profile, leftEnd = left.length) {
|
|
622
|
-
const first = right[0];
|
|
623
|
-
if (first === undefined || !'([{'.includes(first)) return null;
|
|
624
|
-
const last = getLastSignificantCodePoint(left, leftEnd);
|
|
625
|
-
if (last === null) return null;
|
|
626
|
-
if (profile.geckoAsciiLineBreaks && isAsciiBoundary(last, first)) {
|
|
627
|
-
return asciiAlphabeticBoundaryRe.test(last) || decimalDigitRe.test(last) || '"\'([{'.includes(last) || isLineBreakNumericAffix(last);
|
|
628
|
-
}
|
|
629
|
-
if (last.charCodeAt(0) >= 0x80) {
|
|
630
|
-
return !isCJK(last) && /[\p{L}\p{N}]/u.test(last) ? true : null;
|
|
631
|
-
}
|
|
632
|
-
return /[A-Za-z0-9]/.test(last) || "$'(/<@[^_`{".includes(last);
|
|
633
|
-
}
|
|
634
|
-
function canJoinNoSpaceWordBoundary(leftText, leftWordLike, rightText, rightWordLike, profile) {
|
|
635
|
-
if (rightText[0] === '-' && isCJK(leftText)) return true;
|
|
636
|
-
if (isCJK(leftText) || isCJK(rightText)) return false;
|
|
637
|
-
const openingJoin = openingPunctuationJoinsPrevious(leftText, rightText, profile);
|
|
638
|
-
if (openingJoin !== null) return openingJoin;
|
|
639
|
-
const leftSymbol = !leftWordLike && isNoSpaceWordInternalSymbolSegment(leftText);
|
|
640
|
-
const rightSymbol = !rightWordLike && isNoSpaceWordInternalSymbolSegment(rightText);
|
|
641
|
-
const leftAffix = endsWithLineBreakNumericAffix(leftText);
|
|
642
|
-
const leftEndsJoiner = (leftWordLike || leftAffix) && endsWithNoSpaceWordJoiner(leftText);
|
|
643
|
-
if (!leftSymbol && !rightSymbol && !leftEndsJoiner) return false;
|
|
644
|
-
return (leftWordLike || leftSymbol || leftAffix) && (rightWordLike || rightSymbol);
|
|
645
|
-
}
|
|
646
|
-
function segmentContainsDecimalDigit(text) {
|
|
647
|
-
for (const ch of text){
|
|
648
|
-
if (decimalDigitRe.test(ch)) return true;
|
|
649
|
-
}
|
|
650
|
-
return false;
|
|
651
|
-
}
|
|
652
|
-
export function isNumericRunSegment(text) {
|
|
653
|
-
if (text.length === 0) return false;
|
|
654
|
-
for (const ch of text){
|
|
655
|
-
if (decimalDigitRe.test(ch) || numericJoinerChars.has(ch)) continue;
|
|
656
|
-
return false;
|
|
657
|
-
}
|
|
658
|
-
return true;
|
|
659
|
-
}
|
|
660
|
-
function mergeNumericRuns(segmentation, normalized, profile) {
|
|
661
|
-
const texts = [];
|
|
662
|
-
const isWordLike = [];
|
|
663
|
-
const kinds = [];
|
|
664
|
-
const starts = [];
|
|
665
|
-
function pushNumericRun(text, start) {
|
|
666
|
-
if (text.includes('-')) {
|
|
667
|
-
const parts = text.split('-');
|
|
668
|
-
let shouldSplit = parts.length > 1;
|
|
669
|
-
for(let i = 0; i < parts.length; i++){
|
|
670
|
-
const part = parts[i];
|
|
671
|
-
if (!shouldSplit) break;
|
|
672
|
-
if (part.length === 0 || !segmentContainsDecimalDigit(part) || !isNumericRunSegment(part)) {
|
|
673
|
-
shouldSplit = false;
|
|
674
|
-
}
|
|
675
|
-
}
|
|
676
|
-
if (shouldSplit) {
|
|
677
|
-
let offset = 0;
|
|
678
|
-
for(let i = 0; i < parts.length; i++){
|
|
679
|
-
const part = parts[i];
|
|
680
|
-
const splitText = i < parts.length - 1 ? `${part}-` : part;
|
|
681
|
-
texts.push(splitText);
|
|
682
|
-
isWordLike.push(true);
|
|
683
|
-
kinds.push('text');
|
|
684
|
-
starts.push(start + offset);
|
|
685
|
-
offset += splitText.length;
|
|
686
|
-
}
|
|
687
|
-
return;
|
|
688
|
-
}
|
|
689
|
-
}
|
|
690
|
-
texts.push(text);
|
|
691
|
-
isWordLike.push(true);
|
|
692
|
-
kinds.push('text');
|
|
693
|
-
starts.push(start);
|
|
694
|
-
}
|
|
695
|
-
for(let i = 0; i < segmentation.len; i++){
|
|
696
|
-
const text = segmentation.texts[i];
|
|
697
|
-
const kind = segmentation.kinds[i];
|
|
698
|
-
if (kind === 'text' && isNumericRunSegment(text) && segmentContainsDecimalDigit(text)) {
|
|
699
|
-
const mergedParts = [
|
|
700
|
-
text
|
|
701
|
-
];
|
|
702
|
-
let j = i + 1;
|
|
703
|
-
while(j < segmentation.len && segmentation.kinds[j] === 'text' && isNumericRunSegment(segmentation.texts[j]) && numericAffixBoundary(normalized, segmentation.starts[j], profile) !== false){
|
|
704
|
-
mergedParts.push(segmentation.texts[j]);
|
|
705
|
-
j++;
|
|
706
|
-
}
|
|
707
|
-
pushNumericRun(joinTextParts(mergedParts), segmentation.starts[i]);
|
|
708
|
-
i = j - 1;
|
|
709
|
-
continue;
|
|
710
|
-
}
|
|
711
|
-
texts.push(text);
|
|
712
|
-
isWordLike.push(segmentation.isWordLike[i]);
|
|
713
|
-
kinds.push(kind);
|
|
714
|
-
starts.push(segmentation.starts[i]);
|
|
715
|
-
}
|
|
716
|
-
return {
|
|
717
|
-
len: texts.length,
|
|
718
|
-
texts,
|
|
719
|
-
isWordLike,
|
|
720
|
-
kinds,
|
|
721
|
-
starts
|
|
722
|
-
};
|
|
723
|
-
}
|
|
724
|
-
function mergeNoSpaceWordChains(segmentation, normalized, profile) {
|
|
725
|
-
const texts = [];
|
|
726
|
-
const isWordLike = [];
|
|
727
|
-
const kinds = [];
|
|
728
|
-
const starts = [];
|
|
729
|
-
let i = 0;
|
|
730
|
-
while(i < segmentation.len){
|
|
731
|
-
const text = segmentation.texts[i];
|
|
732
|
-
const kind = segmentation.kinds[i];
|
|
733
|
-
const wordLike = segmentation.isWordLike[i];
|
|
734
|
-
if (kind === 'text') {
|
|
735
|
-
const mergedParts = [
|
|
736
|
-
text
|
|
737
|
-
];
|
|
738
|
-
let j = i + 1;
|
|
739
|
-
let mergedWordLike = wordLike;
|
|
740
|
-
while(j < segmentation.len && segmentation.kinds[j] === 'text' && (numericAffixBoundary(normalized, segmentation.starts[j], profile) ?? canJoinNoSpaceWordBoundary(segmentation.texts[j - 1], segmentation.isWordLike[j - 1], segmentation.texts[j], segmentation.isWordLike[j], profile))){
|
|
741
|
-
const nextText = segmentation.texts[j];
|
|
742
|
-
mergedParts.push(nextText);
|
|
743
|
-
mergedWordLike = mergedWordLike || segmentation.isWordLike[j];
|
|
744
|
-
j++;
|
|
745
|
-
}
|
|
746
|
-
if (j > i + 1) {
|
|
747
|
-
texts.push(joinTextParts(mergedParts));
|
|
748
|
-
isWordLike.push(mergedWordLike);
|
|
749
|
-
kinds.push('text');
|
|
750
|
-
starts.push(segmentation.starts[i]);
|
|
751
|
-
i = j;
|
|
752
|
-
continue;
|
|
753
|
-
}
|
|
754
|
-
}
|
|
755
|
-
texts.push(text);
|
|
756
|
-
isWordLike.push(wordLike);
|
|
757
|
-
kinds.push(kind);
|
|
758
|
-
starts.push(segmentation.starts[i]);
|
|
759
|
-
i++;
|
|
760
|
-
}
|
|
761
|
-
return {
|
|
762
|
-
len: texts.length,
|
|
763
|
-
texts,
|
|
764
|
-
isWordLike,
|
|
765
|
-
kinds,
|
|
766
|
-
starts
|
|
767
|
-
};
|
|
768
|
-
}
|
|
769
|
-
function mergeGlueConnectedTextRuns(segmentation) {
|
|
770
|
-
const texts = [];
|
|
771
|
-
const isWordLike = [];
|
|
772
|
-
const kinds = [];
|
|
773
|
-
const starts = [];
|
|
774
|
-
let read = 0;
|
|
775
|
-
while(read < segmentation.len){
|
|
776
|
-
const textParts = [
|
|
777
|
-
segmentation.texts[read]
|
|
778
|
-
];
|
|
779
|
-
let wordLike = segmentation.isWordLike[read];
|
|
780
|
-
let kind = segmentation.kinds[read];
|
|
781
|
-
let start = segmentation.starts[read];
|
|
782
|
-
if (kind === 'glue') {
|
|
783
|
-
const glueParts = [
|
|
784
|
-
textParts[0]
|
|
785
|
-
];
|
|
786
|
-
const glueStart = start;
|
|
787
|
-
read++;
|
|
788
|
-
while(read < segmentation.len && segmentation.kinds[read] === 'glue'){
|
|
789
|
-
glueParts.push(segmentation.texts[read]);
|
|
790
|
-
read++;
|
|
791
|
-
}
|
|
792
|
-
const glueText = joinTextParts(glueParts);
|
|
793
|
-
if (read < segmentation.len && segmentation.kinds[read] === 'text') {
|
|
794
|
-
textParts[0] = glueText;
|
|
795
|
-
textParts.push(segmentation.texts[read]);
|
|
796
|
-
wordLike = segmentation.isWordLike[read];
|
|
797
|
-
kind = 'text';
|
|
798
|
-
start = glueStart;
|
|
799
|
-
read++;
|
|
800
|
-
} else {
|
|
801
|
-
texts.push(glueText);
|
|
802
|
-
isWordLike.push(false);
|
|
803
|
-
kinds.push('glue');
|
|
804
|
-
starts.push(glueStart);
|
|
805
|
-
continue;
|
|
806
|
-
}
|
|
1
|
+
/*! Pretext Unreleased (bac1774c0) | MIT; BSD/Unicode/ICU notices in LICENSE | Copyright (c) 2026 Pretext contributors */
|
|
2
|
+
import { getGeckoLineBreaks, isDiscardable, isEastAsianSegmentBreak, isJapaneseOrChinese, isSpaceCombiningSequenceTail, isSpaceOrTabOrSegmentBreak } from './gecko-line-breaks.js'
|
|
3
|
+
import { isBidiControl, } from './graphemes.js'
|
|
4
|
+
import { BREAK, CLUSTER_START, FORCED_BREAK, ITEM_START, SOFT_HYPHEN_BREAK, getBlinkLineBreaks, getWebKitBreakBetweenItems, getWebKitLineBreaks } from './line-breaks.js'
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
// No `glue` kind: no-break characters are text and the scans decide their breaks
|
|
10
|
+
// (RESEARCH.md, Decisions Log, 2026-09-24).
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
// A segment's flags byte: its kind's code in the low four bits, then what else the
|
|
25
|
+
// walkers read of it.
|
|
26
|
+
export const TEXT = 0
|
|
27
|
+
export const SPACE = 1
|
|
28
|
+
export const ZERO_WIDTH_BREAK = 2
|
|
29
|
+
export const SOFT_HYPHEN = 3
|
|
30
|
+
export const PRESERVED_SPACE = 4
|
|
31
|
+
export const TAB = 5
|
|
32
|
+
export const ZERO_WIDTH_GLUE = 6
|
|
33
|
+
export const CONTROL = 7
|
|
34
|
+
// Ends its chunk: a line's walk stops there, and the next line starts after it.
|
|
35
|
+
export const HARD_BREAK = 8
|
|
36
|
+
// In a rich-inline paragraph's handle (src/rich-inline.ts), an atomic item or a box: one object with
|
|
37
|
+
// a break on both sides and none inside, which a line fits and paints as it does text. The start
|
|
38
|
+
// edge of a padded item that opens with white space, a hard break or a zero-width space is one too,
|
|
39
|
+
// with no break before it where the text has none there. No analysis makes one.
|
|
40
|
+
export const OBJECT = 9
|
|
41
|
+
export const KIND_BITS = 0x0F
|
|
42
|
+
// The segment takes letter spacing after its graphemes. Set by measurement.
|
|
43
|
+
export const SPACED = 0x10
|
|
44
|
+
// The engine's scan gives no break before the segment, so no line ends there.
|
|
45
|
+
export const UNBROKEN = 0x20
|
|
46
|
+
// The scan gives a break before the segment, in text that also has unbroken
|
|
47
|
+
// boundaries, where a line that overflows at one returns to the latest such break.
|
|
48
|
+
export const RETURNABLE = 0x40
|
|
49
|
+
// The engine's clusters don't split the segment, so no emergency break splits it
|
|
50
|
+
// either. Measurement clears it.
|
|
51
|
+
export const ONE_CLUSTER = 0x80
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
// Each kind's name by its code, as prepareWithSegments() gives them.
|
|
55
|
+
export const SEGMENT_KINDS = [
|
|
56
|
+
'text', 'space', 'zero-width-break', 'soft-hyphen', 'preserved-space', 'tab', 'zero-width-glue', 'control', 'hard-break',
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
// `spaceSources` holds, in the WebKit profile where normal white space collapsed, the source
|
|
60
|
+
// unit each normalized unit starts from, such as the TAB or LF a space came from. Null otherwise.
|
|
61
|
+
// `texts` holds each segment's text, `starts` where it starts in `normalized`, and `flags` its
|
|
62
|
+
// flags byte: its kind, UNBROKEN where the engine's scan gives no break before text, zero-width
|
|
63
|
+
// glue or a control, other than at a line start, and where tabs don't hang before a tab or
|
|
64
|
+
// the spaces after one, RETURNABLE at the other segments of text with such a boundary, which
|
|
65
|
+
// `hasUnbroken` tells, and ONE_CLUSTER where the scan has clusters of its own.
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
// The items of a rich-inline paragraph in its text (src/rich-inline.ts): where each starts there,
|
|
82
|
+
// whether it is atomic, one U+FFFC in the text, as Blink and Gecko put an atomic inline in a
|
|
83
|
+
// paragraph's text (inline_node.cc:408-422, nsBidiPresUtils.cpp:1385-1396), and whether the engine
|
|
84
|
+
// transforms segment breaks in each item's text apart (EngineProfile,
|
|
85
|
+
// transformsSegmentBreaksAcrossItems). Every item starts a segment, and an atomic item is a segment
|
|
86
|
+
// with a break on both sides. The analysis leaves in `sourceOffsets` the offset in the text that
|
|
87
|
+
// each unit of its normalized text comes from. `items` holds the items themselves, whose own texts
|
|
88
|
+
// WebKit's scan reads (getWebKitParagraphBreaks).
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
// The runs that collapsing changes: a lone space is already the space its run becomes.
|
|
98
|
+
const collapsibleWhitespaceRunRe = /[ \t\n\r\f]{2,}|[\t\n\r\f]/g
|
|
99
|
+
const needsWhitespaceNormalizationRe = /[\t\n\r\f]| {2,}|^ | $/
|
|
100
|
+
|
|
101
|
+
function isSegmentBreakRunSpace(code , scan ) {
|
|
102
|
+
return code === 0x20 || code === 0x09 || code === 0x0A || (code === 0x0D && scan === 'blink')
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// CSS segment break transformation in normal white space. Blink and Gecko
|
|
106
|
+
// delete a collapsible run containing LF when a ZWSP immediately precedes or
|
|
107
|
+
// follows the run; WebKit turns it into a space. Gecko also deletes it between two
|
|
108
|
+
// East Asian wide characters other than Hangul, and on a `ja` or `zh` page next to
|
|
109
|
+
// East Asian punctuation, reading past default-ignorable characters on both sides
|
|
110
|
+
// (TransformWhiteSpaces, nsTextFrameUtils.cpp:84-209, with the predicates of
|
|
111
|
+
// nsUnicharUtils.cpp:500-527). Each engine collects its own run, and engines Pretext
|
|
112
|
+
// doesn't recognize take Blink's, as they take its scan:
|
|
113
|
+
// - Blink: SPACE, TAB, LF and CR.
|
|
114
|
+
// - Gecko: SPACE, TAB and LF, continuing through the characters Gecko discards
|
|
115
|
+
// (SHY and bidi controls) without ending on one, and leaving out a last SPACE
|
|
116
|
+
// before a combining sequence tail. Text holding a ZWSP is 16-bit in Gecko.
|
|
117
|
+
// Characters outside the run, such as FF, keep the ordinary collapse, or take no room in the
|
|
118
|
+
// Gecko profile (analyzeText).
|
|
119
|
+
export function removeSkippableSegmentBreaks(text , profile , language = null) {
|
|
120
|
+
const scan = profile.lineBreakScan
|
|
121
|
+
if (scan === 'webkit' || !text.includes('\n')) return text
|
|
122
|
+
const eastAsian = scan === 'gecko' && maybeEastAsianRe.test(text)
|
|
123
|
+
if (!eastAsian && !text.includes('\u200B')) return text
|
|
124
|
+
const japaneseOrChinese = eastAsian && isJapaneseOrChinese(language)
|
|
125
|
+
let result = ''
|
|
126
|
+
let copied = 0
|
|
127
|
+
// Only a run containing LF can be removed. Expand each LF to its run once;
|
|
128
|
+
// the next search starts where this run's scan stopped.
|
|
129
|
+
for (let newline = text.indexOf('\n'); newline !== -1;) {
|
|
130
|
+
let start = newline
|
|
131
|
+
for (let index = newline - 1; index >= 0; index--) {
|
|
132
|
+
const code = text.charCodeAt(index)
|
|
133
|
+
if (isSegmentBreakRunSpace(code, scan)) start = index
|
|
134
|
+
else if (!(scan === 'gecko' && isDiscardable(code, false))) break
|
|
135
|
+
}
|
|
136
|
+
let end = newline + 1
|
|
137
|
+
let index = end
|
|
138
|
+
for (; index < text.length; index++) {
|
|
139
|
+
const code = text.charCodeAt(index)
|
|
140
|
+
if (isSegmentBreakRunSpace(code, scan)) end = index + 1
|
|
141
|
+
else if (!(scan === 'gecko' && isDiscardable(code, false))) break
|
|
142
|
+
}
|
|
143
|
+
newline = text.indexOf('\n', index)
|
|
144
|
+
if (scan === 'gecko' && text.charCodeAt(end - 1) === 0x20 && isSpaceCombiningSequenceTail(text, end)) end--
|
|
145
|
+
if (text.charCodeAt(start - 1) !== 0x200B && text.charCodeAt(end) !== 0x200B &&
|
|
146
|
+
!(eastAsian && isEastAsianSegmentBreak(text, start, end, japaneseOrChinese))) continue
|
|
147
|
+
result += text.slice(copied, start)
|
|
148
|
+
for (let member = start; member < end; member++) {
|
|
149
|
+
if (!isSegmentBreakRunSpace(text.charCodeAt(member), scan)) result += text[member]
|
|
150
|
+
}
|
|
151
|
+
copied = end
|
|
152
|
+
}
|
|
153
|
+
return copied === 0 ? text : result + text.slice(copied)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Where the collapsible white space that ends text[from, text.length) starts, which the analysis
|
|
157
|
+
// leaves out and a line end trims, or the text's length where none ends it. Gecko's white-space
|
|
158
|
+
// run reads through the bidi controls in it and after it (TransformText, nsTextFrameUtils.cpp:
|
|
159
|
+
// 319-345), so there it is the first white space after the last character that is neither, and
|
|
160
|
+
// the controls stay.
|
|
161
|
+
function getTrailingCollapsibleStart(text , from , profile ) {
|
|
162
|
+
let start = text.length
|
|
163
|
+
for (let i = text.length - 1; i >= from; i--) {
|
|
164
|
+
const code = text.charCodeAt(i)
|
|
165
|
+
if (isCollapsibleSpaceCode(code)) start = i
|
|
166
|
+
else if (!(profile.lineBreakScan === 'gecko' && isBidiControl(code))) break
|
|
167
|
+
}
|
|
168
|
+
return start
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Every East Asian wide, fullwidth or halfwidth character is at or above U+1100.
|
|
172
|
+
const maybeEastAsianRe = /[\u1100-\uFFFF]/
|
|
173
|
+
|
|
174
|
+
// Normal white space after the segment break transformation: each run of SPACE, TAB, LF,
|
|
175
|
+
// CR and FF becomes one space, or nothing at either end.
|
|
176
|
+
function collapseWhitespaceNormal(text ) {
|
|
177
|
+
if (!needsWhitespaceNormalizationRe.test(text)) return text
|
|
178
|
+
|
|
179
|
+
let normalized = text.replace(collapsibleWhitespaceRunRe, ' ')
|
|
180
|
+
if (normalized.charCodeAt(0) === 0x20) {
|
|
181
|
+
normalized = normalized.slice(1)
|
|
182
|
+
}
|
|
183
|
+
if (normalized.length > 0 && normalized.charCodeAt(normalized.length - 1) === 0x20) {
|
|
184
|
+
normalized = normalized.slice(0, -1)
|
|
185
|
+
}
|
|
186
|
+
return normalized
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function normalizeWhitespacePreWrap(text ) {
|
|
190
|
+
if (!/[\r\f]/.test(text)) return text
|
|
191
|
+
return text
|
|
192
|
+
.replace(/\r\n/g, '\n')
|
|
193
|
+
.replace(/[\r\f]/g, '\n')
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const combiningMarkRe = /\p{M}/u
|
|
197
|
+
|
|
198
|
+
function classifySegmentBreakCode(code , whiteSpace , scan ) {
|
|
199
|
+
if (whiteSpace === 'pre-wrap') {
|
|
200
|
+
if (code === 0x20) return PRESERVED_SPACE
|
|
201
|
+
if (code === 0x09) return TAB
|
|
202
|
+
if (code === 0x0A) return HARD_BREAK
|
|
203
|
+
}
|
|
204
|
+
if (code === 0x20) return SPACE
|
|
205
|
+
if (code === 0x200B) return ZERO_WIDTH_BREAK
|
|
206
|
+
if (code === 0x00AD) return SOFT_HYPHEN
|
|
207
|
+
// NEL (UAX #14 NL) offers a break after itself and no ordinary break before it (LB5, LB6),
|
|
208
|
+
// as the scans find. The WebKit profile gives NEL its own control segment for letter
|
|
209
|
+
// spacing: WebKit's simple text path gives NEL no letter spacing, at either sign, and its
|
|
210
|
+
// complex path spaces it. A NEL control segment takes spacing after text in WebKit's
|
|
211
|
+
// complex ranges, or before such text that starts with a combining mark. Preparation
|
|
212
|
+
// cannot see the page direction, so after complex text whose direction differs from the
|
|
213
|
+
// page's it keeps spacing Safari omits. Blink spaces NEL outside cursive runs, and release
|
|
214
|
+
// Gecko draws NEL with no advance while its Canvas measures a space, so both keep NEL as
|
|
215
|
+
// ordinary text.
|
|
216
|
+
if (code === 0x0085 && scan === 'webkit') return CONTROL
|
|
217
|
+
return TEXT
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function isCollapsibleSpaceCode(code ) {
|
|
221
|
+
return code === 0x20 || code === 0x09 || code === 0x0A || code === 0x0D || code === 0x0C
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// WebKit and Gecko scan a text node's source, where normalization collapsed white space: in
|
|
225
|
+
// normal white space each run of SPACE, TAB, LF, CR and FF became one space, or nothing
|
|
226
|
+
// at either end, and in pre-wrap CRLF became LF. A break before a run's first unit is a
|
|
227
|
+
// break before what the run became. A break before a later unit, as before a CR after a
|
|
228
|
+
// space, follows white space, so it is a break after what the run became. A mark without a
|
|
229
|
+
// break goes with its unit: Gecko's cluster start, which only a unit that stays text reads,
|
|
230
|
+
// and WebKit's forced break after a separator, which keeps its mark at the end too, also
|
|
231
|
+
// where the white space that ends the text starts at a break: at an item's edge in a
|
|
232
|
+
// paragraph, or after a lone CR the analysis took out, whose mark it took (RESEARCH.md,
|
|
233
|
+
// Decisions Log, 2026-10-07).
|
|
234
|
+
// Fills spaceSources, when given, in normal white space.
|
|
235
|
+
function mapSourceLineBreaks(source , normalizedLength , sourceBreaks , whiteSpace , spaceSources ) {
|
|
236
|
+
const breaks = new Uint8Array(normalizedLength + 1)
|
|
237
|
+
let normalizedIndex = 0
|
|
238
|
+
if (whiteSpace === 'pre-wrap') {
|
|
239
|
+
for (let i = 0; i < source.length; i++, normalizedIndex++) {
|
|
240
|
+
breaks[normalizedIndex] = sourceBreaks[i]
|
|
241
|
+
if (source.charCodeAt(i) === 0x0D && source.charCodeAt(i + 1) === 0x0A) {
|
|
242
|
+
i++
|
|
243
|
+
if ((sourceBreaks[i] & BREAK) !== 0) breaks[normalizedIndex] = sourceBreaks[i]
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
breaks[normalizedLength] = sourceBreaks[source.length]
|
|
247
|
+
return breaks
|
|
248
|
+
}
|
|
249
|
+
let i = 0
|
|
250
|
+
while (i < source.length && isCollapsibleSpaceCode(source.charCodeAt(i))) i++
|
|
251
|
+
while (i < source.length) {
|
|
252
|
+
let end = i + 1
|
|
253
|
+
if (isCollapsibleSpaceCode(source.charCodeAt(i))) {
|
|
254
|
+
while (end < source.length && isCollapsibleSpaceCode(source.charCodeAt(end))) end++
|
|
255
|
+
if (end === source.length) break
|
|
256
|
+
}
|
|
257
|
+
if ((sourceBreaks[i] & BREAK) === 0 && breaks[normalizedIndex] === 0) breaks[normalizedIndex] = sourceBreaks[i]
|
|
258
|
+
if (spaceSources !== null) spaceSources[normalizedIndex] = source.charCodeAt(i)
|
|
259
|
+
const start = i
|
|
260
|
+
for (; i < end; i++) {
|
|
261
|
+
if ((sourceBreaks[i] & BREAK) !== 0) breaks[i === start ? normalizedIndex : normalizedIndex + 1] = sourceBreaks[i]
|
|
262
|
+
}
|
|
263
|
+
normalizedIndex++
|
|
264
|
+
}
|
|
265
|
+
breaks[normalizedLength] = (sourceBreaks[i] & BREAK) === 0 ? sourceBreaks[i] : sourceBreaks[i] & FORCED_BREAK
|
|
266
|
+
return breaks
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// A FORCED_BREAK after U+2028 or U+2029 makes the separator a hard break in every white-space
|
|
270
|
+
// mode, and a SOFT_HYPHEN_BREAK makes a soft hyphen a zero-width break: the line can end there
|
|
271
|
+
// without a hyphen. One with only soft hyphens before it on its chunk stays a soft hyphen, since
|
|
272
|
+
// a zero-width break there holds a line and Firefox, which drops soft hyphens from its text runs,
|
|
273
|
+
// gives it none.
|
|
274
|
+
function classifySegmentUnit(normalized , breaks , i , code , whiteSpace , scan ) {
|
|
275
|
+
if ((code === 0x2028 || code === 0x2029) && (breaks[i + 1] & FORCED_BREAK) !== 0) return HARD_BREAK
|
|
276
|
+
if (code === 0x00AD && (breaks[i + 1] & SOFT_HYPHEN_BREAK) !== 0 && followsChunkContent(normalized, i)) return ZERO_WIDTH_BREAK
|
|
277
|
+
return classifySegmentBreakCode(code, whiteSpace, scan)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function followsChunkContent(normalized , i ) {
|
|
281
|
+
let j = i - 1
|
|
282
|
+
while (j >= 0 && normalized.charCodeAt(j) === 0x00AD) j--
|
|
283
|
+
return j >= 0 && normalized.charCodeAt(j) !== 0x0A
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Characters of these kinds share a segment when no break falls between them. Each
|
|
287
|
+
// tab, hard break, ZWSP and NEL control stays its own segment.
|
|
288
|
+
function gathersKind(kind ) {
|
|
289
|
+
return kind === TEXT || kind === SPACE || kind === PRESERVED_SPACE || kind === SOFT_HYPHEN
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// A control character that stays its own text segment, measured alone: the C0 and C1
|
|
293
|
+
// controls that white-space normalization leaves as text, and the line and paragraph
|
|
294
|
+
// separators where they don't end a line.
|
|
295
|
+
function isControlSegmentCode(code ) {
|
|
296
|
+
return code < 0x20 || (code >= 0x7F && code <= 0x9F) || code === 0x2028 || code === 0x2029
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Segments are the text between an engine's break opportunities, split where the
|
|
300
|
+
// break kind changes and where an item of a rich-inline paragraph starts (ITEM_START), and a
|
|
301
|
+
// control stays alone. A ZWSP or soft hyphen that the scan
|
|
302
|
+
// doesn't break after, as at the start of a WebKit scan, before a combining mark or a
|
|
303
|
+
// closing bracket, or under keep-all, is zero-width glue: it stays its own zero-width
|
|
304
|
+
// segment, takes no letter spacing and doesn't end a line.
|
|
305
|
+
// Combining marks right after it, or after a control, stay apart from the text after
|
|
306
|
+
// them, since they shape on the grapheme before it (measureAnalysis). Where the Gecko
|
|
307
|
+
// scan marks cluster starts, a segment is ONE_CLUSTER unless one falls inside it.
|
|
308
|
+
//
|
|
309
|
+
// Firefox leaves soft hyphens and bidi controls out of the text run it breaks and maps a line end
|
|
310
|
+
// past the characters it left out (IsDiscardable, nsTextFrameUtils.cpp:32-49; nsTextFrame.cpp:
|
|
311
|
+
// 11161-11170), so a line ends after a run of them, never before one, and none starts with one
|
|
312
|
+
// after a wrap. Where the Gecko scan's text run left out a bidi control, a run of such characters
|
|
313
|
+
// with a control in it goes with the segment before it up to its last control: the scan finds no
|
|
314
|
+
// break inside the run and gives the one after it after the run. A soft hyphen after the last
|
|
315
|
+
// control keeps its break, and one before it offers none (GetHyphenationBreaks,
|
|
316
|
+
// nsTextFrame.cpp:4436-4442). A run that starts a chunk is a text segment that starts its line,
|
|
317
|
+
// with the text after it: Firefox trims no leading space there, since the content doesn't start
|
|
318
|
+
// with one (nsTextFrame.cpp:10935-10950), and the break the scan gives after the run, after a hard
|
|
319
|
+
// break or collapsed leading white space, can't end a line that holds nothing yet. A chunk of only
|
|
320
|
+
// such a run is no content of its own, so the hard break before it takes it (nsTextFrame.cpp:
|
|
321
|
+
// 11421-11429). After a control or marks that stay alone, the run starts a text segment. The
|
|
322
|
+
// profile's graphemes look past these characters (src/graphemes.ts), so a cluster extender after
|
|
323
|
+
// them joins the cluster before, and the text after them goes on in the segment as without them.
|
|
324
|
+
// A run of them ends where an item of a rich-inline paragraph starts inside it, and an item that
|
|
325
|
+
// starts with one starts a text segment with the text after it, as a chunk does, after the break
|
|
326
|
+
// the scan gives at the run, so its fragments keep the controls it starts with.
|
|
327
|
+
function segmentAtLineBreaks(normalized , spaceSources , breaks , whiteSpace , scan , hangTabs , dropsBidiControl ) {
|
|
328
|
+
const oneCluster = scan === 'gecko' ? ONE_CLUSTER : 0
|
|
329
|
+
const starts = []
|
|
330
|
+
// A plain array, which measurement copies into the prepared handle's bytes: a Uint8Array for
|
|
331
|
+
// each text slowed short texts' preparation (RESEARCH.md, Keeping Work Bounded).
|
|
332
|
+
const flags = []
|
|
333
|
+
let lastAlone = false
|
|
334
|
+
let markRun = false
|
|
335
|
+
// Where the last run of what the text run drops, handled at its start, ends.
|
|
336
|
+
let droppedEnd = -1
|
|
337
|
+
// The last segment's kind, -1 before the first unit, which starts one.
|
|
338
|
+
let lastKind = -1
|
|
339
|
+
for (let i = 0; i < normalized.length; i++) {
|
|
340
|
+
const code = normalized.charCodeAt(i)
|
|
341
|
+
if (dropsBidiControl && i < droppedEnd) continue
|
|
342
|
+
if (dropsBidiControl && isDiscardable(code, false) && (i === 0 || (breaks[i] & ITEM_START) !== 0 || !isDiscardable(normalized.charCodeAt(i - 1), false))) {
|
|
343
|
+
// The run of what the text run drops from here, and where its last bidi control ends.
|
|
344
|
+
let j = i
|
|
345
|
+
let controlEnd = -1
|
|
346
|
+
for (; j < normalized.length && isDiscardable(normalized.charCodeAt(j), false) && (j === i || (breaks[j] & ITEM_START) === 0); j++) if (isBidiControl(normalized.charCodeAt(j))) controlEnd = j + 1
|
|
347
|
+
if (controlEnd > 0) {
|
|
348
|
+
const startsItem = lastKind >= 0 && lastKind !== HARD_BREAK && (breaks[i] & ITEM_START) !== 0
|
|
349
|
+
const chunkStart = lastKind < 0 || lastKind === HARD_BREAK || startsItem
|
|
350
|
+
if (chunkStart) {
|
|
351
|
+
const endsChunk = j === normalized.length || classifySegmentUnit(normalized, breaks, j, normalized.charCodeAt(j), whiteSpace, scan) === HARD_BREAK
|
|
352
|
+
if (!endsChunk) {
|
|
353
|
+
if (startsItem) breaks[i] = breaks[i] | (breaks[j] & BREAK)
|
|
354
|
+
breaks[j] = breaks[j] & ~(BREAK | SOFT_HYPHEN_BREAK)
|
|
355
|
+
}
|
|
356
|
+
droppedEnd = j
|
|
357
|
+
if (endsChunk && lastKind >= 0) continue
|
|
807
358
|
} else {
|
|
808
|
-
|
|
809
|
-
}
|
|
810
|
-
if (kind === 'text') {
|
|
811
|
-
while(read < segmentation.len && segmentation.kinds[read] === 'glue'){
|
|
812
|
-
const glueParts = [];
|
|
813
|
-
while(read < segmentation.len && segmentation.kinds[read] === 'glue'){
|
|
814
|
-
glueParts.push(segmentation.texts[read]);
|
|
815
|
-
read++;
|
|
816
|
-
}
|
|
817
|
-
const glueText = joinTextParts(glueParts);
|
|
818
|
-
if (read < segmentation.len && segmentation.kinds[read] === 'text') {
|
|
819
|
-
textParts.push(glueText, segmentation.texts[read]);
|
|
820
|
-
wordLike = wordLike || segmentation.isWordLike[read];
|
|
821
|
-
read++;
|
|
822
|
-
continue;
|
|
823
|
-
}
|
|
824
|
-
textParts.push(glueText);
|
|
825
|
-
}
|
|
826
|
-
}
|
|
827
|
-
texts.push(joinTextParts(textParts));
|
|
828
|
-
isWordLike.push(wordLike);
|
|
829
|
-
kinds.push(kind);
|
|
830
|
-
starts.push(start);
|
|
831
|
-
}
|
|
832
|
-
return {
|
|
833
|
-
len: texts.length,
|
|
834
|
-
texts,
|
|
835
|
-
isWordLike,
|
|
836
|
-
kinds,
|
|
837
|
-
starts
|
|
838
|
-
};
|
|
839
|
-
}
|
|
840
|
-
function carryTrailingForwardStickyAcrossCJKBoundary(segmentation) {
|
|
841
|
-
const { texts, kinds, starts } = segmentation;
|
|
842
|
-
for(let i = 0; i < texts.length - 1; i++){
|
|
843
|
-
if (kinds[i] !== 'text' || kinds[i + 1] !== 'text') continue;
|
|
844
|
-
if (!isCJK(texts[i]) || !isCJK(texts[i + 1])) continue;
|
|
845
|
-
const split = splitTrailingForwardStickyCluster(texts[i]);
|
|
846
|
-
if (split === null) continue;
|
|
847
|
-
texts[i] = split.head;
|
|
848
|
-
texts[i + 1] = split.tail + texts[i + 1];
|
|
849
|
-
starts[i + 1] = starts[i] + split.head.length;
|
|
850
|
-
}
|
|
851
|
-
}
|
|
852
|
-
function buildMergedSegmentation(normalized, profile, whiteSpace) {
|
|
853
|
-
const wordSegmenter = getSharedWordSegmenter();
|
|
854
|
-
let mergedLen = 0;
|
|
855
|
-
const mergedTexts = [];
|
|
856
|
-
const mergedWordLike = [];
|
|
857
|
-
const mergedKinds = [];
|
|
858
|
-
const mergedStarts = [];
|
|
859
|
-
let hasTail = false;
|
|
860
|
-
let tailStart = 0;
|
|
861
|
-
let tailEnd = 0;
|
|
862
|
-
let tailWordLike = false;
|
|
863
|
-
let tailKind = 'text';
|
|
864
|
-
let tailSingleCharRunChar = null;
|
|
865
|
-
let tailContainsCJK = false;
|
|
866
|
-
let tailContainsArabicScript = false;
|
|
867
|
-
let tailEndsWithClosingQuote = false;
|
|
868
|
-
let tailEndsWithMyanmarMedialGlue = false;
|
|
869
|
-
let tailHasArabicNoSpacePunctuation = false;
|
|
870
|
-
for (const s of wordSegmenter.segment(normalized)){
|
|
871
|
-
for (const piece of splitSegmentByBreakKind(s.segment, s.isWordLike ?? false, s.index, whiteSpace)){
|
|
872
|
-
const isText = piece.kind === 'text';
|
|
873
|
-
const repeatableSingleCharRunChar = getRepeatableSingleCharRunChar(piece.text, piece.isWordLike, piece.kind);
|
|
874
|
-
const pieceContainsCJK = isCJK(piece.text);
|
|
875
|
-
const pieceContainsArabicScript = containsArabicScript(piece.text);
|
|
876
|
-
const pieceLastCodePoint = getLastCodePoint(piece.text);
|
|
877
|
-
const pieceEndsWithClosingQuote = endsWithClosingQuote(piece.text);
|
|
878
|
-
const pieceEndsWithMyanmarMedialGlue = endsWithMyanmarMedialGlue(piece.text);
|
|
879
|
-
const pieceEnd = piece.start + piece.text.length;
|
|
880
|
-
const boundaryJoin = numericAffixBoundary(normalized, piece.start, profile) ?? (tailContainsCJK || pieceContainsCJK ? null : openingPunctuationJoinsPrevious(normalized, piece.text, profile, piece.start));
|
|
881
|
-
let appendToTail = false;
|
|
882
|
-
if (profile.carryCJKAfterClosingQuote && isText && hasTail && tailKind === 'text' && pieceContainsCJK && tailContainsCJK && tailEndsWithClosingQuote) {
|
|
883
|
-
appendToTail = true;
|
|
884
|
-
} else if (isText && hasTail && tailKind === 'text' && isCJKLineStartProhibitedSegment(piece.text) && tailContainsCJK) {
|
|
885
|
-
appendToTail = true;
|
|
886
|
-
} else if (isText && hasTail && tailKind === 'text' && tailEndsWithMyanmarMedialGlue) {
|
|
887
|
-
appendToTail = true;
|
|
888
|
-
} else if (isText && hasTail && tailKind === 'text' && piece.isWordLike && pieceContainsArabicScript && tailHasArabicNoSpacePunctuation) {
|
|
889
|
-
appendToTail = true;
|
|
890
|
-
} else if (repeatableSingleCharRunChar !== null && hasTail && tailKind === 'text' && tailSingleCharRunChar === repeatableSingleCharRunChar && boundaryJoin !== false) {
|
|
891
|
-
tailEnd = pieceEnd;
|
|
892
|
-
continue;
|
|
893
|
-
} else if (isText && !piece.isWordLike && hasTail && tailKind === 'text' && !tailContainsCJK && (isLeftStickyPunctuationSegment(piece.text) || piece.text === '-' && tailWordLike)) {
|
|
894
|
-
appendToTail = true;
|
|
895
|
-
}
|
|
896
|
-
if (isText && hasTail && tailKind === 'text' && boundaryJoin !== null) appendToTail = boundaryJoin;
|
|
897
|
-
if (appendToTail) {
|
|
898
|
-
tailEnd = pieceEnd;
|
|
899
|
-
tailWordLike = tailWordLike || piece.isWordLike;
|
|
900
|
-
tailSingleCharRunChar = null;
|
|
901
|
-
tailContainsCJK = tailContainsCJK || pieceContainsCJK;
|
|
902
|
-
tailContainsArabicScript = tailContainsArabicScript || pieceContainsArabicScript;
|
|
903
|
-
tailEndsWithClosingQuote = pieceEndsWithClosingQuote;
|
|
904
|
-
tailEndsWithMyanmarMedialGlue = pieceEndsWithMyanmarMedialGlue;
|
|
905
|
-
tailHasArabicNoSpacePunctuation = hasArabicNoSpacePunctuation(tailContainsArabicScript, pieceLastCodePoint);
|
|
906
|
-
} else {
|
|
907
|
-
if (hasTail) {
|
|
908
|
-
mergedTexts[mergedLen] = normalized.slice(tailStart, tailEnd);
|
|
909
|
-
mergedWordLike[mergedLen] = tailWordLike;
|
|
910
|
-
mergedKinds[mergedLen] = tailKind;
|
|
911
|
-
mergedStarts[mergedLen] = tailStart;
|
|
912
|
-
mergedLen++;
|
|
913
|
-
}
|
|
914
|
-
hasTail = true;
|
|
915
|
-
tailStart = piece.start;
|
|
916
|
-
tailEnd = pieceEnd;
|
|
917
|
-
tailWordLike = piece.isWordLike;
|
|
918
|
-
tailKind = piece.kind;
|
|
919
|
-
tailSingleCharRunChar = repeatableSingleCharRunChar;
|
|
920
|
-
tailContainsCJK = pieceContainsCJK;
|
|
921
|
-
tailContainsArabicScript = pieceContainsArabicScript;
|
|
922
|
-
tailEndsWithClosingQuote = pieceEndsWithClosingQuote;
|
|
923
|
-
tailEndsWithMyanmarMedialGlue = pieceEndsWithMyanmarMedialGlue;
|
|
924
|
-
tailHasArabicNoSpacePunctuation = hasArabicNoSpacePunctuation(pieceContainsArabicScript, pieceLastCodePoint);
|
|
925
|
-
}
|
|
926
|
-
}
|
|
927
|
-
}
|
|
928
|
-
if (hasTail) {
|
|
929
|
-
mergedTexts[mergedLen] = normalized.slice(tailStart, tailEnd);
|
|
930
|
-
mergedWordLike[mergedLen] = tailWordLike;
|
|
931
|
-
mergedKinds[mergedLen] = tailKind;
|
|
932
|
-
mergedStarts[mergedLen] = tailStart;
|
|
933
|
-
mergedLen++;
|
|
934
|
-
}
|
|
935
|
-
for(let i = 1; i < mergedLen; i++){
|
|
936
|
-
if (mergedKinds[i] === 'text' && !mergedWordLike[i] && isPunctuationGlueCluster(mergedTexts[i]) && mergedKinds[i - 1] === 'text' && !isCJK(mergedTexts[i - 1]) && (numericAffixBoundary(normalized, mergedStarts[i], profile) ?? openingPunctuationJoinsPrevious(normalized, mergedTexts[i], profile, mergedStarts[i])) !== false) {
|
|
937
|
-
mergedTexts[i - 1] += mergedTexts[i];
|
|
938
|
-
mergedWordLike[i - 1] = mergedWordLike[i - 1] || mergedWordLike[i];
|
|
939
|
-
mergedTexts[i] = '';
|
|
359
|
+
droppedEnd = controlEnd
|
|
940
360
|
}
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
const nextText = forwardStickyPrefixParts?.at(-1) ?? (nextLiveIndex >= 0 ? mergedTexts[nextLiveIndex] : null);
|
|
948
|
-
if (mergedKinds[i] === 'text' && !mergedWordLike[i] && nextText !== null && mergedKinds[nextLiveIndex] === 'text' && ((numericAffixBoundary(normalized, mergedStarts[i] + text.length, profile) ?? (isForwardStickyClusterSegment(text) && openingPunctuationJoinsPrevious(text, nextText, profile) !== false)) || text === '-' && startsWithDecimalDigit(nextText))) {
|
|
949
|
-
if (forwardStickyPrefixParts === null) forwardStickyPrefixParts = [];
|
|
950
|
-
forwardStickyPrefixParts.push(text);
|
|
951
|
-
mergedStarts[nextLiveIndex] = mergedStarts[i];
|
|
952
|
-
mergedTexts[i] = '';
|
|
953
|
-
continue;
|
|
954
|
-
}
|
|
955
|
-
if (forwardStickyPrefixParts !== null) {
|
|
956
|
-
mergedTexts[nextLiveIndex] = joinReversedPrefixParts(forwardStickyPrefixParts, mergedTexts[nextLiveIndex]);
|
|
957
|
-
forwardStickyPrefixParts = null;
|
|
958
|
-
}
|
|
959
|
-
nextLiveIndex = i;
|
|
960
|
-
}
|
|
961
|
-
if (forwardStickyPrefixParts !== null) {
|
|
962
|
-
mergedTexts[nextLiveIndex] = joinReversedPrefixParts(forwardStickyPrefixParts, mergedTexts[nextLiveIndex]);
|
|
963
|
-
}
|
|
964
|
-
let compactLen = 0;
|
|
965
|
-
for(let read = 0; read < mergedLen; read++){
|
|
966
|
-
const text = mergedTexts[read];
|
|
967
|
-
if (text.length === 0) continue;
|
|
968
|
-
if (compactLen !== read) {
|
|
969
|
-
mergedTexts[compactLen] = text;
|
|
970
|
-
mergedWordLike[compactLen] = mergedWordLike[read];
|
|
971
|
-
mergedKinds[compactLen] = mergedKinds[read];
|
|
972
|
-
mergedStarts[compactLen] = mergedStarts[read];
|
|
361
|
+
if (chunkStart || lastAlone || markRun) {
|
|
362
|
+
starts.push(i)
|
|
363
|
+
flags.push(TEXT | oneCluster)
|
|
364
|
+
lastKind = TEXT
|
|
365
|
+
lastAlone = false
|
|
366
|
+
markRun = false
|
|
973
367
|
}
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
}
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
const
|
|
1007
|
-
const
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
isWordLike.push(segmentation.isWordLike[index]);
|
|
1014
|
-
kinds.push('text');
|
|
1015
|
-
starts.push(segmentation.starts[index]);
|
|
1016
|
-
}
|
|
1017
|
-
function pushMergedText(start, end) {
|
|
1018
|
-
let wordLike = false;
|
|
1019
|
-
for(let i = start; i < end; i++){
|
|
1020
|
-
wordLike = wordLike || segmentation.isWordLike[i];
|
|
1021
|
-
}
|
|
1022
|
-
const sourceStart = segmentation.starts[start];
|
|
1023
|
-
const sourceEnd = end < segmentation.len ? segmentation.starts[end] : normalized.length;
|
|
1024
|
-
texts.push(normalized.slice(sourceStart, sourceEnd));
|
|
1025
|
-
isWordLike.push(wordLike);
|
|
1026
|
-
kinds.push('text');
|
|
1027
|
-
starts.push(sourceStart);
|
|
1028
|
-
}
|
|
1029
|
-
function flushGroup(end) {
|
|
1030
|
-
if (groupStart < 0) return;
|
|
1031
|
-
if (groupContainsCJK) {
|
|
1032
|
-
if (groupStart + 1 === end) {
|
|
1033
|
-
pushOriginalText(groupStart);
|
|
1034
|
-
} else {
|
|
1035
|
-
pushMergedText(groupStart, end);
|
|
1036
|
-
}
|
|
1037
|
-
} else {
|
|
1038
|
-
for(let i = groupStart; i < end; i++)pushOriginalText(i);
|
|
1039
|
-
}
|
|
1040
|
-
groupStart = -1;
|
|
1041
|
-
groupContainsCJK = false;
|
|
1042
|
-
}
|
|
1043
|
-
for(let i = 0; i < segmentation.len; i++){
|
|
1044
|
-
const text = segmentation.texts[i];
|
|
1045
|
-
const kind = segmentation.kinds[i];
|
|
1046
|
-
if (kind === 'text') {
|
|
1047
|
-
if (groupStart >= 0 && (!canContinueKeepAllTextRun(segmentation.texts[i - 1], profile.breakKeepAllAfterPunctuation) || numericAffixBoundary(normalized, segmentation.starts[i], profile) === false)) {
|
|
1048
|
-
flushGroup(i);
|
|
1049
|
-
}
|
|
1050
|
-
if (groupStart < 0) groupStart = i;
|
|
1051
|
-
groupContainsCJK = groupContainsCJK || isCJK(text);
|
|
1052
|
-
continue;
|
|
1053
|
-
}
|
|
1054
|
-
flushGroup(i);
|
|
1055
|
-
texts.push(text);
|
|
1056
|
-
isWordLike.push(segmentation.isWordLike[i]);
|
|
1057
|
-
kinds.push(kind);
|
|
1058
|
-
starts.push(segmentation.starts[i]);
|
|
1059
|
-
}
|
|
1060
|
-
flushGroup(segmentation.len);
|
|
1061
|
-
return {
|
|
1062
|
-
len: texts.length,
|
|
1063
|
-
texts,
|
|
1064
|
-
isWordLike,
|
|
1065
|
-
kinds,
|
|
1066
|
-
starts
|
|
1067
|
-
};
|
|
1068
|
-
}
|
|
1069
|
-
function isNumericHyphen(text, index) {
|
|
1070
|
-
const next = text.charCodeAt(index + 1);
|
|
1071
|
-
if (next < 0x80) {
|
|
1072
|
-
if (next < 0x30 || next > 0x39) return false;
|
|
368
|
+
continue
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
const kind = classifySegmentUnit(normalized, breaks, i, code, whiteSpace, scan)
|
|
372
|
+
const alone = kind === TEXT && isControlSegmentCode(code)
|
|
373
|
+
const unbroken = (breaks[i] & BREAK) === 0
|
|
374
|
+
if (
|
|
375
|
+
(breaks[i] & (BREAK | ITEM_START)) === 0 && !alone && !lastAlone && !(markRun && !combiningMarkRe.test(normalized[i] )) &&
|
|
376
|
+
kind === lastKind && gathersKind(kind)
|
|
377
|
+
) {
|
|
378
|
+
if ((breaks[i] & CLUSTER_START) !== 0) flags[flags.length - 1] = flags[flags.length - 1] & ~ONE_CLUSTER
|
|
379
|
+
continue
|
|
380
|
+
}
|
|
381
|
+
markRun = unbroken && kind === TEXT && combiningMarkRe.test(normalized[i] ) &&
|
|
382
|
+
(lastAlone || lastKind === ZERO_WIDTH_BREAK || lastKind === SOFT_HYPHEN || lastKind === CONTROL)
|
|
383
|
+
starts.push(i)
|
|
384
|
+
flags.push(kind | oneCluster)
|
|
385
|
+
lastKind = kind
|
|
386
|
+
lastAlone = alone
|
|
387
|
+
}
|
|
388
|
+
// A line ends only where the scan breaks, so the walkers learn where it doesn't:
|
|
389
|
+
// before text, zero-width glue or a control, other than at a line start. A
|
|
390
|
+
// ZWSP or soft hyphen there is zero-width glue. Before a space, tab or hard break
|
|
391
|
+
// the scan has no break either, but the line can still end there, as they hang or
|
|
392
|
+
// end it, so it keeps its kind. A tab that doesn't hang (EngineProfile's hangTabs) ends
|
|
393
|
+
// no line before itself, nor do the spaces after it, which hang only where the run of
|
|
394
|
+
// white space ends the line: Gecko breaks only after the whole run (nsLineBreaker.cpp:
|
|
395
|
+
// 318-330). A soft hyphen before the tab keeps its break (GetHyphenationBreaks,
|
|
396
|
+
// nsTextFrame.cpp:4409-4457).
|
|
397
|
+
let hasUnbroken = false
|
|
398
|
+
const count = flags.length
|
|
399
|
+
for (let j = count - 2; j >= 0; j--) {
|
|
400
|
+
const kind = flags[j] & KIND_BITS
|
|
401
|
+
const next = flags[j + 1] & KIND_BITS
|
|
402
|
+
if ((breaks[starts[j + 1] ] & BREAK) !== 0 || kind === HARD_BREAK) continue
|
|
403
|
+
if (!hangTabs && (next === TAB || (next === PRESERVED_SPACE && kind === TAB))) {
|
|
404
|
+
if (kind === SOFT_HYPHEN) continue
|
|
405
|
+
} else if (next === TEXT || next === ZERO_WIDTH_GLUE || next === CONTROL) {
|
|
406
|
+
if (kind === ZERO_WIDTH_BREAK || kind === SOFT_HYPHEN) flags[j] = flags[j] & ~KIND_BITS | ZERO_WIDTH_GLUE
|
|
1073
407
|
} else {
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
408
|
+
continue
|
|
409
|
+
}
|
|
410
|
+
flags[j + 1] = flags[j + 1] | UNBROKEN
|
|
411
|
+
hasUnbroken = true
|
|
412
|
+
}
|
|
413
|
+
if (hasUnbroken) for (let j = 0; j < count; j++) if ((flags[j] & UNBROKEN) === 0) flags[j] = flags[j] | RETURNABLE
|
|
414
|
+
// Each segment's text, sliced once here for measurement and the neighbours it reads: sliced
|
|
415
|
+
// where measurement reads it, Firefox's rich-inline preparation ran 11-19% slower (RESEARCH.md,
|
|
416
|
+
// Keeping Work Bounded).
|
|
417
|
+
const texts = []
|
|
418
|
+
for (let j = 0; j < count; j++) texts.push(normalized.slice(starts[j], j + 1 < count ? starts[j + 1] : normalized.length))
|
|
419
|
+
return { normalized, spaceSources, texts, starts, flags, hasUnbroken }
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
export function analyzeText(
|
|
423
|
+
text ,
|
|
424
|
+
profile ,
|
|
425
|
+
whiteSpace = 'normal',
|
|
426
|
+
wordBreak = 'normal',
|
|
427
|
+
// Preparation's language (getPreparationLanguage), which picks Chrome's and WebKit's
|
|
428
|
+
// line tables, WebKit's quotation remap and Gecko's rule for newlines next to East
|
|
429
|
+
// Asian punctuation.
|
|
430
|
+
language = null,
|
|
431
|
+
// The items of the rich-inline paragraph whose text this is, else null.
|
|
432
|
+
paragraph = null,
|
|
433
|
+
) {
|
|
434
|
+
const preserve = whiteSpace === 'pre-wrap'
|
|
435
|
+
// Where each of a paragraph's items starts in the source the engine scans.
|
|
436
|
+
const scanStarts = paragraph === null ? null : []
|
|
437
|
+
// The source a text node's engine scans, after the segment break transformation.
|
|
438
|
+
let source = preserve ? text
|
|
439
|
+
: paragraph !== null && paragraph.ownSegmentBreaks ? removeItemsSkippableSegmentBreaks(text, paragraph.starts, profile, language, scanStarts )
|
|
440
|
+
: removeSkippableSegmentBreaks(text, profile, language)
|
|
441
|
+
if (paragraph !== null && scanStarts .length === 0) for (let k = 0; k < paragraph.starts.length; k++) scanStarts .push(paragraph.starts[k] )
|
|
442
|
+
let normalized = preserve ? normalizeWhitespacePreWrap(text) : collapseWhitespaceNormal(source)
|
|
443
|
+
const keepAll = wordBreak === 'keep-all'
|
|
444
|
+
let breaks
|
|
445
|
+
let spaceSources = null
|
|
446
|
+
let dropsBidiControl = false
|
|
447
|
+
if (profile.lineBreakScan === 'blink') {
|
|
448
|
+
breaks = getBlinkLineBreaks(normalized, keepAll, language)
|
|
449
|
+
} else {
|
|
450
|
+
// WebKit and Gecko scan the source. Gecko's scan collapses its white space as Firefox does.
|
|
451
|
+
let sourceBreaks
|
|
452
|
+
if (profile.lineBreakScan === 'webkit') {
|
|
453
|
+
sourceBreaks = paragraph === null ? getWebKitLineBreaks(source, preserve, keepAll, language) : getWebKitParagraphBreaks(source, paragraph, preserve, keepAll, language)
|
|
454
|
+
// A CR is no white space to WebKit (moveToNextNonWhitespacePosition, InlineItemsBuilder.cpp:
|
|
455
|
+
// 55-73) and stays in its text item with no advance and no letter spacing: Core Text's
|
|
456
|
+
// shaping gives its glyph none (CTFontShapeGlyphs, from Font::applyTransforms,
|
|
457
|
+
// FontCoreText.cpp:617-699; the complex path at ComplexTextController.cpp:762-768), and only
|
|
458
|
+
// a character that advances is spaced (WidthIterator.cpp:508-516). Every Canvas measures
|
|
459
|
+
// it as a space, so the source leaves it out, as the Gecko profile's does below, and white
|
|
460
|
+
// space on its two sides is one space, where Safari keeps two. The scan read it, so the
|
|
461
|
+
// unit after it keeps its own break: ICU's, after the CR, where the scan takes it, as before
|
|
462
|
+
// a letter above U+00FF, and none elsewhere, its table having none beside a control
|
|
463
|
+
// (BreakablePositions.h:179-187, 238-251; RESEARCH.md, Engine Facts, Safari (WebKit), CR and
|
|
464
|
+
// FF, has where). That unit also takes the CR's break, the one after white space. The CR of a
|
|
465
|
+
// CRLF stays, to collapse into the line feed's space; in a paragraph that is also a CR that
|
|
466
|
+
// ends an item before a line feed that starts the next, whose space is the line feed's item's
|
|
467
|
+
// (alignToSource). In the installed fonts that take WebKit's fixed-pitch shortcut, Menlo
|
|
468
|
+
// and so the generic monospace among them, text on simplified measuring is as wide as its
|
|
469
|
+
// characters are many, the CR among them (Font::determinePitch, FontCoreText.cpp:753-785;
|
|
470
|
+
// widthForSimpleTextWithFixedPitch, FontCascade.cpp:414-421), which Canvas can't show, so
|
|
471
|
+
// the profile gives those fonts up (RESEARCH.md, Decisions Log, 2026-10-06;
|
|
472
|
+
// ENGINE_FOLLOWUPS.md, White space and controls).
|
|
473
|
+
if (!preserve && source !== normalized && /\r(?!\n)/.test(source)) {
|
|
474
|
+
let count = 0
|
|
475
|
+
for (let i = 0; i < source.length; i++) {
|
|
476
|
+
if (source.charCodeAt(i) !== 0x0D || source.charCodeAt(i + 1) === 0x0A) sourceBreaks[count++] = sourceBreaks[i]
|
|
477
|
+
else sourceBreaks[i + 1] = sourceBreaks[i + 1] | sourceBreaks[i]
|
|
1134
478
|
}
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
479
|
+
sourceBreaks[count] = sourceBreaks[source.length]
|
|
480
|
+
source = source.replace(/\r(?!\n)/g, '')
|
|
481
|
+
normalized = collapseWhitespaceNormal(source)
|
|
482
|
+
}
|
|
483
|
+
} else {
|
|
484
|
+
const gecko = getGeckoLineBreaks(source, preserve, keepAll, profile.graphemeTable, scanStarts)
|
|
485
|
+
dropsBidiControl = gecko.dropsBidiControl
|
|
486
|
+
sourceBreaks = gecko.breaks
|
|
487
|
+
// Gecko's line breaker starts again after an atomic frame (BuildTextRunsScanner::ScanFrame,
|
|
488
|
+
// nsTextFrame.cpp:2248-2251, 2264-2268), so the text after one has no break of the scan's at
|
|
489
|
+
// its start, where the scan finds one after the U+FFFC: a soft hyphen that starts that text
|
|
490
|
+
// hyphenates there, and the break after the atomic item comes before it (markItemStarts).
|
|
491
|
+
if (paragraph !== null) {
|
|
492
|
+
for (let k = 0; k < paragraph.atomic.length; k++) {
|
|
493
|
+
if (!paragraph.atomic[k]) continue
|
|
494
|
+
let after = scanStarts [k] + 1
|
|
495
|
+
while (after < source.length && isDiscardable(source.charCodeAt(after), false)) after++
|
|
496
|
+
sourceBreaks[after] = sourceBreaks[after] & ~SOFT_HYPHEN_BREAK
|
|
1144
497
|
}
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
498
|
+
}
|
|
499
|
+
// Gecko's white-space run reads through the soft hyphens and bidi controls in it, which its
|
|
500
|
+
// text run drops, and keeps its segment break if it holds one, or else its first white space
|
|
501
|
+
// (TransformWhiteSpaces, nsTextFrameUtils.cpp:151-193); the scan takes the text as one text
|
|
502
|
+
// frame (transformText in src/gecko-line-breaks.ts). The white space the scan's text run left
|
|
503
|
+
// out of such a run leaves the source too, and so does white space before only bidi controls
|
|
504
|
+
// at the end, which the line end trims, and a CR or FF, which ends a run and takes no room
|
|
505
|
+
// (the CR of a CRLF stays, to collapse into the line feed's space); white space on its two
|
|
506
|
+
// sides then touches and is one space, where Firefox keeps two.
|
|
507
|
+
// The other units keep their breaks: none is at white space, and the unit after a CR or FF
|
|
508
|
+
// has its own, as a CR is one of nsLineBreaker's breakable spaces, whose run gives the unit
|
|
509
|
+
// after it a break (IsSegmentSpace, nsLineBreaker.h:260-264; nsLineBreaker.cpp:318-327), and
|
|
510
|
+
// an FF is UAX #14's BK, which the word breaker breaks after. Only a combining mark there has
|
|
511
|
+
// none, since a break inside a cluster holds only after a space (SetPotentialLineBreaks,
|
|
512
|
+
// gfxTextRun.cpp:219-226), so the break Firefox has before the CR is lost (ENGINE_FOLLOWUPS.md).
|
|
513
|
+
// Where the white space right after a soft hyphen leaves, the break after it stays that white
|
|
514
|
+
// space's, which draws no hyphen (SOFT_HYPHEN_BREAK): Gecko hyphenates only at a soft hyphen
|
|
515
|
+
// that ends what its text run left out (GetHyphenationBreaks, nsTextFrame.cpp:4436-4443).
|
|
516
|
+
const leftOut = gecko.leftOut
|
|
517
|
+
const trailing = !preserve && (dropsBidiControl || leftOut !== null) ? getTrailingCollapsibleStart(source, 0, profile) : source.length
|
|
518
|
+
if (leftOut !== null || trailing < source.length) {
|
|
519
|
+
// A run that goes on into that trailing white space and keeps its one white space there, as
|
|
520
|
+
// a segment break after a soft hyphen, keeps its first white space instead: the text before
|
|
521
|
+
// the trailing white space holds the run's space, as a rich-inline paragraph's run keeps the
|
|
522
|
+
// white space of the item it starts in (transformText in src/gecko-line-breaks.ts).
|
|
523
|
+
let runSpace = -1
|
|
524
|
+
if (leftOut !== null && trailing < source.length) {
|
|
525
|
+
for (let i = trailing - 1; i >= 0; i--) {
|
|
526
|
+
const code = source.charCodeAt(i)
|
|
527
|
+
if (!isSpaceOrTabOrSegmentBreak(code) && !isDiscardable(code, false)) break
|
|
528
|
+
if (isSpaceOrTabOrSegmentBreak(code) && leftOut[i] !== 1) {
|
|
529
|
+
runSpace = -1
|
|
530
|
+
break
|
|
1172
531
|
}
|
|
1173
|
-
|
|
1174
|
-
|
|
532
|
+
if (isSpaceOrTabOrSegmentBreak(code)) runSpace = i
|
|
533
|
+
}
|
|
1175
534
|
}
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
535
|
+
// Only the units from the first one that leaves move.
|
|
536
|
+
const from = leftOut === null ? trailing : 0
|
|
537
|
+
let kept = ''
|
|
538
|
+
let copied = 0
|
|
539
|
+
let count = from
|
|
540
|
+
let last = from - 1
|
|
541
|
+
for (let i = from; i < source.length; i++) {
|
|
542
|
+
if (i < trailing ? leftOut !== null && leftOut[i] === 1 && i !== runSpace : isCollapsibleSpaceCode(source.charCodeAt(i))) {
|
|
543
|
+
kept += source.slice(copied, i)
|
|
544
|
+
copied = i + 1
|
|
545
|
+
continue
|
|
546
|
+
}
|
|
547
|
+
const breakBefore = sourceBreaks[i]
|
|
548
|
+
sourceBreaks[count++] = last >= 0 && last < i - 1 && (breakBefore & BREAK) !== 0 && source.charCodeAt(last) === 0x00AD ? breakBefore | SOFT_HYPHEN_BREAK : breakBefore
|
|
549
|
+
last = i
|
|
1183
550
|
}
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
551
|
+
sourceBreaks[count] = sourceBreaks[source.length]
|
|
552
|
+
source = kept + source.slice(copied)
|
|
553
|
+
normalized = collapseWhitespaceNormal(source)
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
if (profile.lineBreakScan === 'webkit' && !preserve && source !== normalized) spaceSources = new Uint16Array(normalized.length)
|
|
557
|
+
breaks = source === normalized ? sourceBreaks : mapSourceLineBreaks(source, normalized.length, sourceBreaks, whiteSpace, spaceSources)
|
|
558
|
+
}
|
|
559
|
+
if (paragraph !== null) markItemStarts(text, normalized, breaks, paragraph, profile.lineBreakScan)
|
|
560
|
+
return segmentAtLineBreaks(normalized, spaceSources, breaks, whiteSpace, profile.lineBreakScan, profile.hangTabs, dropsBidiControl)
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// A paragraph's text after each item's own segment break transformation, where the engine
|
|
564
|
+
// transforms segment breaks in each text frame's text apart (ParagraphItems), and in
|
|
565
|
+
// `resultStarts` where each item starts in it.
|
|
566
|
+
function removeItemsSkippableSegmentBreaks(text , starts , profile , language , resultStarts ) {
|
|
567
|
+
if (profile.lineBreakScan === 'webkit' || !text.includes('\n')) return text
|
|
568
|
+
let result = ''
|
|
569
|
+
// Where the text that isn't in `result` yet starts. Only an item that holds a line feed can
|
|
570
|
+
// change, so only those are cut out and transformed, and the text between two that changed is
|
|
571
|
+
// copied in one piece.
|
|
572
|
+
let copied = 0
|
|
573
|
+
let newline = text.indexOf('\n')
|
|
574
|
+
for (let k = 0; k < starts.length; k++) {
|
|
575
|
+
const start = starts[k]
|
|
576
|
+
const end = k + 1 < starts.length ? starts[k + 1] : text.length
|
|
577
|
+
resultStarts.push(result.length + start - copied)
|
|
578
|
+
if (newline < 0 || newline >= end) continue
|
|
579
|
+
const item = text.slice(start, end)
|
|
580
|
+
const transformed = removeSkippableSegmentBreaks(item, profile, language)
|
|
581
|
+
if (transformed.length !== item.length) {
|
|
582
|
+
result += text.slice(copied, start) + transformed
|
|
583
|
+
copied = end
|
|
584
|
+
}
|
|
585
|
+
newline = text.indexOf('\n', end)
|
|
586
|
+
}
|
|
587
|
+
return copied === 0 ? text : result + text.slice(copied)
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// The offset in `source` that each unit of `normalized` comes from. Normalization only removes
|
|
591
|
+
// white space and, in the Gecko profile, a CR or FF, and in the WebKit profile a lone CR, turns a
|
|
592
|
+
// run of white space into one space, which comes from the run's first unit, and turns CR, CRLF
|
|
593
|
+
// and FF into LF, so a greedy walk aligns the two. Under the WebKit and Gecko scans no space
|
|
594
|
+
// comes from a CR, nor under Gecko's from an FF, one right before the white space included:
|
|
595
|
+
// neither is white space to that engine (moveToNextNonWhitespacePosition,
|
|
596
|
+
// InlineItemsBuilder.cpp:55-73; IsSpaceOrTabOrSegmentBreak, nsTextFrameUtils.cpp:51-57), so the
|
|
597
|
+
// run starts after it, and the box that holds the run's first character has its space, in that
|
|
598
|
+
// box's font and letter spacing. WebKit makes a white-space item of its text box, as wide as
|
|
599
|
+
// that box's space (InlineItemsBuilder.cpp:947, 963-987), and takes out only white space that
|
|
600
|
+
// follows other white space, an earlier box's too (Line::appendText, InlineLine.cpp:357-365);
|
|
601
|
+
// Gecko transforms a frame at a time from the white-space state the frame before it left, which
|
|
602
|
+
// a CR or FF clears (nsTextFrameUtils.cpp:286-309, 382-386). So the space after a CR that ends
|
|
603
|
+
// an item is the next item's, as is the line feed of a CRLF split there, and a fragment that
|
|
604
|
+
// ends with a space after a CR ends after that space in its item's text, and one that starts
|
|
605
|
+
// with such a space starts at it. To Blink a CR is white space (Character::IsCollapsibleSpace,
|
|
606
|
+
// platform/text/character.h:150-153), its run's first unit, and an FF is still a space in the
|
|
607
|
+
// Blink and WebKit profiles.
|
|
608
|
+
export function alignToSource(source , normalized , scan ) {
|
|
609
|
+
const offsets = new Int32Array(normalized.length)
|
|
610
|
+
// The units no space comes from under `scan`, or -1.
|
|
611
|
+
const cr = scan === 'blink' ? -1 : 0x0D
|
|
612
|
+
const ff = scan === 'gecko' ? 0x0C : -1
|
|
613
|
+
let i = 0
|
|
614
|
+
for (let j = 0; j < normalized.length; j++) {
|
|
615
|
+
const unit = normalized.charCodeAt(j)
|
|
616
|
+
while (i < source.length) {
|
|
617
|
+
const code = source.charCodeAt(i)
|
|
618
|
+
if (code === unit || (isCollapsibleSpaceCode(code) && (unit === 0x20 ? code !== cr && code !== ff : unit === 0x0A))) break
|
|
619
|
+
i++
|
|
620
|
+
}
|
|
621
|
+
offsets[j] = i++
|
|
622
|
+
// CRLF became one LF.
|
|
623
|
+
if (unit === 0x0A && source.charCodeAt(i - 1) === 0x0D && source.charCodeAt(i) === 0x0A) i++
|
|
624
|
+
}
|
|
625
|
+
return offsets
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// Marks where each item of a paragraph starts in its normalized text (ITEM_START), at the first
|
|
629
|
+
// unit that comes from the item or from one after it, and a break on both sides of an atomic item,
|
|
630
|
+
// under keep-all too: Blink breaks after an atomic inline and before one (CanBreakAfterAtomicInline
|
|
631
|
+
// and CanBreakAfter, line_breaker.cc:1168-1263 in core/layout/inline), WebKit finds a soft wrap
|
|
632
|
+
// opportunity on either side of one (InlineFormattingUtils.cpp:445-449), and Gecko records a break
|
|
633
|
+
// after one and breaks before one that doesn't fit (nsLineLayout.cpp:1057-1068, 1339-1340).
|
|
634
|
+
function markItemStarts(text , normalized , breaks , paragraph , scan ) {
|
|
635
|
+
const offsets = paragraph.sourceOffsets = alignToSource(text, normalized, scan)
|
|
636
|
+
const { starts, atomic } = paragraph
|
|
637
|
+
for (let k = 0, j = 0; k < starts.length; k++) {
|
|
638
|
+
while (j < normalized.length && offsets[j] < starts[k] ) j++
|
|
639
|
+
if (j === normalized.length) break
|
|
640
|
+
breaks[j] = breaks[j] | ITEM_START
|
|
641
|
+
if (atomic[k] && offsets[j] === starts[k]) {
|
|
642
|
+
breaks[j] = breaks[j] | BREAK
|
|
643
|
+
breaks[j + 1] = breaks[j + 1] | BREAK | ITEM_START
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// Where a line may start in a paragraph's source, in WebKit: it finds breaks inside each inline
|
|
649
|
+
// box from that box's own text, and at a boundary between boxes from the scan over the next box's
|
|
650
|
+
// text with the last two characters before it as prior context (TextUtil.cpp:374-396), so a
|
|
651
|
+
// paragraph's breaks are each item's own scan, joined by that check (getWebKitBreakBetweenItems).
|
|
652
|
+
// Collapsible white space on either side of a boundary breaks there, as inside a text, but a CR,
|
|
653
|
+
// which is no white space to WebKit: beside one the check decides. It takes the pair of a CR and
|
|
654
|
+
// a character up to U+00FF from its table, which has no break for it, so at a boundary only a
|
|
655
|
+
// character above U+00FF right after the CR brings ICU's break, where inside a text a letter above
|
|
656
|
+
// U+00FF before the CR can too, before an ASCII letter. The analysis then takes a lone CR out with
|
|
657
|
+
// the breaks found around it (analyzeText). The source is the items' texts joined as they are, an
|
|
658
|
+
// atomic item as one U+FFFC, which is never scanned (removeItemsSkippableSegmentBreaks leaves
|
|
659
|
+
// WebKit's text alone), so a scan takes an item's own text and nothing is cut out of the source
|
|
660
|
+
// before the scans.
|
|
661
|
+
function getWebKitParagraphBreaks(source , paragraph , preserve , keepAll , language ) {
|
|
662
|
+
const { items, starts, atomic } = paragraph
|
|
663
|
+
const breaks = new Uint8Array(source.length + 1)
|
|
664
|
+
let previous = -1
|
|
665
|
+
for (let k = 0; k < starts.length; k++) {
|
|
666
|
+
const start = starts[k]
|
|
667
|
+
const end = k + 1 < starts.length ? starts[k + 1] : source.length
|
|
668
|
+
if (start === end) continue
|
|
669
|
+
// An item of one unit, as a space between two styled words or an atomic item's U+FFFC, has
|
|
670
|
+
// no break inside, and its scan marks its end only after a line or paragraph separator
|
|
671
|
+
// (getWebKitLineBreaks), so only those are scanned.
|
|
672
|
+
const first = source.charCodeAt(start)
|
|
673
|
+
if (end - start > 1 || first === 0x2028 || first === 0x2029) {
|
|
674
|
+
const itemBreaks = getWebKitLineBreaks(items[k] .text , preserve, keepAll, language)
|
|
675
|
+
for (let i = 1; i <= end - start; i++) breaks[start + i] = itemBreaks[i]
|
|
676
|
+
}
|
|
677
|
+
if (previous >= 0 && !atomic[k] && !atomic[previous]) {
|
|
678
|
+
const last = source.charCodeAt(start - 1)
|
|
679
|
+
const collapses = !preserve && ((first !== 0x0D && isCollapsibleSpaceCode(first)) || (last !== 0x0D && isCollapsibleSpaceCode(last)))
|
|
680
|
+
if (collapses || getWebKitBreakBetweenItems(items[previous] .text , items[k] .text , keepAll, language)) breaks[start] = breaks[start] | BREAK
|
|
681
|
+
}
|
|
682
|
+
previous = k
|
|
683
|
+
}
|
|
684
|
+
return breaks
|
|
1234
685
|
}
|