rapier-markdown-kit 1.1.102 → 1.1.104

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/LICENSE +2 -2
  2. package/README.md +213 -53
  3. package/dist/agent/vendor/hyphen-en-us/LICENSE +14 -0
  4. package/dist/agent/vendor/hyphen-en-us/SOURCE.json +33 -0
  5. package/dist/agent/vendor/hyphen-en-us/hyph-en-us.hyp.txt +14 -0
  6. package/dist/agent/vendor/hyphen-en-us/hyph-en-us.pat.txt +4938 -0
  7. package/dist/agent/vendor/hyphen-en-us/patterns.js +25 -0
  8. package/dist/agent/vendor/pretext/LICENSE +208 -0
  9. package/dist/agent/vendor/pretext/NOTICE +26 -13
  10. package/dist/agent/vendor/pretext/SOURCE.json +213 -45
  11. package/dist/agent/vendor/pretext/analysis.js +675 -1224
  12. package/dist/agent/vendor/pretext/entry-geometry.js +183 -0
  13. package/dist/agent/vendor/pretext/gecko-line-breaks.js +688 -0
  14. package/dist/agent/vendor/pretext/generated/engine-break-data.js +57 -0
  15. package/dist/agent/vendor/pretext/generated/webkit-generic-families.js +80 -0
  16. package/dist/agent/vendor/pretext/graphemes.js +98 -0
  17. package/dist/agent/vendor/pretext/han-kerning.js +253 -0
  18. package/dist/agent/vendor/pretext/layout.js +352 -290
  19. package/dist/agent/vendor/pretext/line-break.js +1154 -588
  20. package/dist/agent/vendor/pretext/line-breaks.js +892 -0
  21. package/dist/agent/vendor/pretext/line-text.js +81 -39
  22. package/dist/agent/vendor/pretext/measurement.js +1105 -176
  23. package/dist/agent/vendor/pretext/prepare.js +807 -0
  24. package/dist/agent/vendor/pretext/rich-inline.js +1242 -273
  25. package/dist/agent/will.mjs +2 -0
  26. package/dist/kit/assets.mjs +5 -1
  27. package/dist/kit/conformance/run.mjs +4 -4
  28. package/dist/kit/export-fonts.mjs +26 -11
  29. package/dist/kit/index.mjs +1 -0
  30. package/dist/kit/ledger/carried.mjs +9 -7
  31. package/dist/kit/ledger/data.mjs +73 -13
  32. package/dist/kit/ledger/format.mjs +166 -53
  33. package/dist/kit/ledger/hash.mjs +75 -18
  34. package/dist/kit/ledger/history.mjs +143 -115
  35. package/dist/kit/ledger/inverse-source.mjs +158 -19
  36. package/dist/kit/ledger/journal-records.mjs +292 -67
  37. package/dist/kit/model.mjs +1 -1
  38. package/dist/kit/pdf-glyphs.mjs +4 -4
  39. package/dist/kit/render-markdown.mjs +259 -122
  40. package/dist/kit/render-sanitize.mjs +24 -4
  41. package/dist/kit/render-work.mjs +99 -20
  42. package/dist/kit/render.mjs +101 -65
  43. package/dist/kit/typography.mjs +2 -0
  44. package/dist/layout/hyphenation.mjs +127 -0
  45. package/dist/layout/line-plan.mjs +88 -96
  46. package/dist/layout/model.mjs +61 -80
  47. package/dist/layout/paragraph-fit.mjs +191 -0
  48. package/dist/layout/preparation.mjs +373 -0
  49. package/dist/layout/prepared-paragraph.mjs +223 -0
  50. package/dist/layout/typography.mjs +345 -111
  51. package/dist/packages/rapier-diff/index.mjs +288 -196
  52. package/dist/spec/document-fonts.mjs +13 -4
  53. package/dist/spec/md-assets.mjs +149 -30
  54. package/dist/spec/md-layout.mjs +5 -2
  55. package/dist/spec/md-marks.mjs +218 -3
  56. package/dist/spec/md-source.mjs +122 -0
  57. package/package.json +2 -1
  58. package/style.css +9 -0
  59. package/dist/agent/vendor/pretext/bidi.js +0 -151
  60. package/dist/agent/vendor/pretext/generated/bidi-data.js +0 -3831
@@ -1,189 +1,1118 @@
1
- /*! Pretext 0.0.9 | MIT | Copyright (c) 2026 Pretext contributors */
2
- import { getSharedGraphemeSegmenter } from './analysis.js';
3
- let measureContext = null;
4
- const segmentMetricCaches = new Map();
5
- let cachedEngineProfile = null;
6
- const MAX_PREFIX_FIT_GRAPHEMES = 96;
7
- const emojiPresentationRe = /\p{Emoji_Presentation}/u;
8
- const maybeEmojiRe = /[\p{Emoji_Presentation}\p{Extended_Pictographic}\p{Regional_Indicator}\uFE0F\u20E3]/u;
9
- const emojiCorrectionCache = new Map();
10
- export function getMeasureContext() {
11
- if (measureContext !== null) return measureContext;
12
- if (typeof OffscreenCanvas !== 'undefined') {
13
- measureContext = new OffscreenCanvas(1, 1).getContext('2d');
14
- return measureContext;
1
+ /*! Pretext Unreleased (bac1774c0) | MIT; BSD/Unicode/ICU notices in LICENSE | Copyright (c) 2026 Pretext contributors */
2
+ import { findGraphemeEnds, } from './graphemes.js'
3
+ import { canWebKitLineStartWith, DEFAULT_IGNORABLE, getBlinkDefaultLocale, hasProperty, MARK } from './line-breaks.js'
4
+ import { webkitGenericFamilies, webkitGenericFamilyNames, webkitScriptLanguages, webkitScriptSubtags } from './generated/webkit-generic-families.js'
5
+
6
+
7
+
8
+ // What preparation knows of a segment in a font. Each is created with all its fields,
9
+ // so their reads see one shape.
10
+
11
+
12
+
13
+
14
+
15
+
16
+
17
+ // A text segment's kerning with a U+0020 beside it, in the Chromium profile (getSpaceKerning).
18
+
19
+
20
+
21
+
22
+
23
+ // Where a segment breaks under overflow-wrap: break-word in one fit mode (getSegmentFit),
24
+ // replaced whole when another mode is asked for.
25
+
26
+
27
+
28
+
29
+
30
+
31
+
32
+
33
+
34
+
35
+
36
+
37
+
38
+
39
+
40
+
41
+
42
+
43
+
44
+
45
+
46
+
47
+
48
+
49
+
50
+
51
+
52
+
53
+
54
+
55
+
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
+ // The measurement context and what preparation measured through it. Canvas resolves
343
+ // fonts under the context's language, the page's unless the context has a `lang` to
344
+ // give it preparation's. Chrome keeps a resolved font while its font string is
345
+ // unchanged, so the context, and every width measured through it, belong to the
346
+ // language it was created under: all of it is replaced when that language changes.
347
+
348
+
349
+
350
+
351
+
352
+
353
+
354
+
355
+
356
+
357
+
358
+
359
+
360
+
361
+
362
+
363
+ let measureState = null
364
+ // What preparation keeps per font. It all goes together, when the caches clear or the
365
+ // language changes.
366
+
367
+
368
+
369
+
370
+
371
+
372
+
373
+
374
+
375
+
376
+
377
+
378
+
379
+
380
+
381
+
382
+
383
+
384
+
385
+
386
+
387
+
388
+ let cachedEngineProfile = null
389
+
390
+ // Prefix fits, which preparation picks by engine, width and letter spacing
391
+ // (measureAnalysis in src/prepare.ts), measure every growing prefix of a
392
+ // segment. That suits word-sized runs, but a giant segment would prepare in time
393
+ // that grows with the square of its length. Past this size, the cheaper
394
+ // pair-context model keeps preparation linear.
395
+ const MAX_PREFIX_FIT_GRAPHEMES = 96
396
+
397
+ // The Canvas letterSpacing a context measures letter-spaced text under: not 0, so the
398
+ // engine shapes the text as its page does under letter spacing, and too small to add any
399
+ // width, so one measurement serves every spacing and preparation adds the spacing itself.
400
+ // Blink adds spacing in units of 1/65536 px (ShapeResultSpacing::SetSpacing,
401
+ // shape_result_spacing.cc:14-33) and Gecko's Canvas in whole app units, 1/60 px
402
+ // (CanvasRenderingContext2D.cpp:4771-4774), and both read 0 here: 16px Roboto `difficult`
403
+ // measures 54.2031px in Chrome 154 and 54.2px in Firefox 156, their pages' widths with
404
+ // `font-variant-ligatures: none` (2026-09-30).
405
+ const LETTER_SPACED_SHAPING = '0.000001px'
406
+
407
+ // Graphemes the emoji font may draw a glyph in, which Canvas widths then tell
408
+ // (countEmojiGlyphs): those holding an emoji-presentation character or a pictograph,
409
+ // or an emoji character followed by U+FE0F, such as a keycap base like `1`. U+FE0F
410
+ // after a letter or a space changes nothing.
411
+ const emojiGraphemeRe = /\p{Emoji_Presentation}|\p{Extended_Pictographic}|\p{Emoji}\uFE0F/u
412
+ const maybeEmojiRe = /[\p{Emoji_Presentation}\p{Extended_Pictographic}\p{Regional_Indicator}\uFE0F\u20E3]/u
413
+
414
+ const genericKeywords = ['serif', 'sans-serif', 'cursive', 'fantasy', 'monospace']
415
+ // A quoted family name, or an unquoted generic keyword with what precedes it.
416
+ const familyListItemRe = /("[^"]*"|'[^']*')|(^|,)(\s*)(serif|sans-serif|cursive|fantasy|monospace)(?=\s*(?:,|$))/gi
417
+ // A font string's size, with its line height, after which its family list starts.
418
+ const fontSizeRe = /\dpx(?:\s*\/\s*\S+)?\s+/
419
+ // One family of a family list, with the white space around it.
420
+ const familyRe = /(?:"[^"]*"|'[^']*'|[^,])+/g
421
+
422
+ // The families WebKit's page gives the generic keywords under a language, or null
423
+ // where they resolve as in Canvas. The page asks Core Text wherever WebKit's script
424
+ // for the language isn't Common: localeToScriptCode tries the language, then its last
425
+ // subtag as a script, then the language less that subtag (LocaleToScriptMapping.cpp:
426
+ // 360-377). A plain Han language becomes the first preferred language starting with
427
+ // zh-, else zh-hans (FontDescription.cpp:74-113); a page can't read those languages,
428
+ // so Pretext takes zh-hans. Core Text's answers are OS data, generated with macOS's
429
+ // and iOS's families (scripts/generate-webkit-generic-families.ts): where they differ,
430
+ // the context takes macOS's if it has it. Listing both instead would send characters
431
+ // macOS's family lacks to iOS's, where the page falls back by language.
432
+ function getWebKitGenericFamilies(language , ctx ) {
433
+ let tag = language.toLowerCase().replaceAll('_', '-')
434
+ let han = null
435
+ for (let at = tag; han === null;) {
436
+ if (webkitScriptLanguages.includes(` ${at} `)) han = at === 'zh'
437
+ else {
438
+ const cut = at.lastIndexOf('-')
439
+ if (cut < 0 || at.endsWith('-zyyy')) return null
440
+ if (webkitScriptSubtags.includes(` ${at.slice(cut + 1)} `)) han = at.endsWith('-hani')
441
+ at = at.slice(0, cut)
15
442
  }
16
- if (typeof document !== 'undefined') {
17
- measureContext = document.createElement('canvas').getContext('2d');
18
- return measureContext;
443
+ }
444
+ if (han) tag = 'zh-hans'
445
+ let row = webkitGenericFamilies[tag]
446
+ while (row === undefined) {
447
+ tag = tag.slice(0, Math.max(0, tag.lastIndexOf('-')))
448
+ row = webkitGenericFamilies[tag]
449
+ }
450
+ const families = []
451
+ for (let i = 0; i < row.length; i++) {
452
+ // Keywords with the same entry share its family, and the context is asked once.
453
+ const first = row.indexOf(row[i] )
454
+ if (first < i) {
455
+ families.push(families[first] )
456
+ continue
19
457
  }
20
- throw new Error('Text measurement requires OffscreenCanvas or a DOM canvas context.');
458
+ const name = webkitGenericFamilyNames[row[i] ]
459
+ const pair = name.indexOf('|')
460
+ const family = pair < 0 ? name : hasFamily(ctx, name.slice(0, pair)) ? name.slice(0, pair) : name.slice(pair + 1)
461
+ families.push(family === '' ? '' : `"${family}"`)
462
+ }
463
+ return families
21
464
  }
22
- export function getSegmentMetricCache(font) {
23
- let cache = segmentMetricCaches.get(font);
24
- if (!cache) {
25
- cache = new Map();
26
- segmentMetricCaches.set(font, cache);
27
- }
28
- return cache;
29
- }
30
- export function getSegmentMetrics(seg, cache) {
31
- let metrics = cache.get(seg);
32
- if (metrics === undefined) {
33
- const ctx = getMeasureContext();
34
- metrics = {
35
- width: ctx.measureText(seg).width
36
- };
37
- cache.set(seg, metrics);
38
- }
39
- return metrics;
40
- }
41
- export function getEngineProfile() {
42
- if (cachedEngineProfile !== null) return cachedEngineProfile;
43
- if (typeof navigator === 'undefined') {
44
- cachedEngineProfile = {
45
- geckoAsciiLineBreaks: false,
46
- lineFitEpsilon: 0.005,
47
- carryCJKAfterClosingQuote: false,
48
- breakKeepAllAfterPunctuation: true,
49
- preferPrefixWidthsForBreakableRuns: false
50
- };
51
- return cachedEngineProfile;
52
- }
53
- const ua = navigator.userAgent;
54
- const vendor = navigator.vendor;
55
- const isSafari = vendor === 'Apple Computer, Inc.' && ua.includes('Safari/') && !ua.includes('Chrome/') && !ua.includes('Chromium/') && !ua.includes('CriOS/') && !ua.includes('FxiOS/') && !ua.includes('EdgiOS/');
56
- const isChromium = ua.includes('Chrome/') || ua.includes('Chromium/') || ua.includes('CriOS/') || ua.includes('Edg/');
57
- const isGecko = ua.includes('Firefox/') && !ua.includes('FxiOS/');
58
- cachedEngineProfile = {
59
- geckoAsciiLineBreaks: isGecko,
60
- lineFitEpsilon: isSafari ? 1 / 64 : 0.005,
61
- carryCJKAfterClosingQuote: isChromium,
62
- breakKeepAllAfterPunctuation: !isSafari,
63
- preferPrefixWidthsForBreakableRuns: isSafari
64
- };
65
- return cachedEngineProfile;
66
- }
67
- export function parseFontSize(font) {
68
- const m = font.match(/(?:^|\D)(\d+(?:\.\d+)?)\s*px/);
69
- return m ? parseFloat(m[1]) : 16;
70
- }
71
- function isEmojiGrapheme(g) {
72
- return emojiPresentationRe.test(g) || g.includes('\uFE0F');
73
- }
74
- export function textMayContainEmoji(text) {
75
- return maybeEmojiRe.test(text);
76
- }
77
- function getEmojiCorrection(font) {
78
- let correction = emojiCorrectionCache.get(font);
79
- if (correction !== undefined) return correction;
80
- const fontSize = parseFontSize(font);
81
- const ctx = getMeasureContext();
82
- ctx.font = font;
83
- const canvasW = ctx.measureText('\u{1F600}').width;
84
- correction = 0;
85
- if (canvasW > fontSize + 0.5 && typeof document !== 'undefined' && document.body !== null) {
86
- const span = document.createElement('span');
87
- span.style.font = font;
88
- span.style.display = 'inline-block';
89
- span.style.visibility = 'hidden';
90
- span.style.position = 'absolute';
91
- span.textContent = '\u{1F600}';
92
- document.body.appendChild(span);
93
- const domW = span.getBoundingClientRect().width;
94
- document.body.removeChild(span);
95
- if (canvasW - domW > 0.5) {
96
- correction = canvasW - domW;
97
- }
98
- }
99
- emojiCorrectionCache.set(font, correction);
100
- return correction;
101
- }
102
- function countEmojiGraphemes(text) {
103
- let count = 0;
104
- const graphemeSegmenter = getSharedGraphemeSegmenter();
105
- for (const g of graphemeSegmenter.segment(text)){
106
- if (isEmojiGrapheme(g.segment)) count++;
107
- }
108
- return count;
465
+
466
+ // Text each of macOS's families in the table draws some of: Latin, Hangul, Han,
467
+ // Devanagari, Khmer, Kannada, Lao, Malayalam, Myanmar, Oriya, Sinhala and Tibetan.
468
+ const familyProbeText = 'Hamburg 한中 नम សួ ನಮ ສະ നമ မင ନମ ආය བཀ'
469
+
470
+ // Whether the context has a family: the probe text measures differently with it first.
471
+ function hasFamily(ctx , family ) {
472
+ for (const fallback of ['monospace', 'serif']) {
473
+ ctx.font = `16px ${fallback}`
474
+ const width = ctx.measureText(familyProbeText).width
475
+ ctx.font = `16px "${family}", ${fallback}`
476
+ if (ctx.measureText(familyProbeText).width !== width) return true
477
+ }
478
+ return false
109
479
  }
110
- function getEmojiCount(seg, metrics) {
111
- if (metrics.emojiCount === undefined) {
112
- metrics.emojiCount = countEmojiGraphemes(seg);
480
+
481
+ // The Canvas font that measures what the page draws: each unquoted generic keyword
482
+ // in the family list, after the size, becomes the page's families for it.
483
+ function getCanvasFont(font , families ) {
484
+ const size = fontSizeRe.exec(font)
485
+ if (size === null) return font
486
+ const start = size.index + size[0].length
487
+ return font.slice(0, start) + font.slice(start).replace(familyListItemRe, (item , quoted , separator , space , keyword ) => {
488
+ const named = quoted === undefined ? families[genericKeywords.indexOf(keyword.toLowerCase())] : ''
489
+ return named === '' ? item : separator + space + named
490
+ })
491
+ }
492
+
493
+ // The string a chosen soft hyphen paints in the font where the engine takes it from the
494
+ // primary font (hyphenFromPrimaryFont): U+2010 where that font has a glyph for it, else `-`.
495
+ // Where both measure the same in the font, either does. Else Canvas tells which family draws
496
+ // a character from two lists: the engines draw each character with the first listed font
497
+ // that has its glyph (WebKit's glyphDataForVariant, FontCascadeFonts.cpp:426-439), so a family
498
+ // draws it where `family, monospace` and `family, serif` measure it alike and the two generic
499
+ // families alone don't. The primary font is the first listed family's that gives a font,
500
+ // whether or not it has a glyph for a space (WebKit's primaryFont, FontCascadeFonts.h:225-254;
501
+ // Blink's DeterminePrimarySimpleFontDataCore, font_fallback_list.cc:88-143). Canvas can't tell
502
+ // a family that gives no font from one that lacks the character, so the premise is that the
503
+ // primary font draws a space, and it is taken as the first family's that does. `-` where the
504
+ // generic families measure alike, which tells nothing, or the font string has no size in px.
505
+ // What the premise gets wrong (ENGINE_FOLLOWUPS.md, Line edges): a first family whose font
506
+ // has no space, an icon font, is skipped where the engines take it; a family split into faces
507
+ // by unicode-range is asked whole, where the engines ask only the face that holds the space;
508
+ // and where no listed family gives a font the answer is `-`, where the engines ask their
509
+ // last-resort font.
510
+ export function getHyphenText(measurement ) {
511
+ if (measurement.hyphenText !== null) return measurement.hyphenText
512
+ const font = measurement.canvasFont
513
+ const size = fontSizeRe.exec(font)
514
+ let hyphenText = '-'
515
+ if (size !== null && getSegmentMetrics('\u2010', measurement).width !== getSegmentMetrics('-', measurement).width) {
516
+ const context = getContext(measurement)
517
+ const start = size.index + size[0].length
518
+ const prefix = font.slice(0, start)
519
+ const families = font.slice(start).match(familyRe) ?? []
520
+ const monospace = prefix + 'monospace'
521
+ const serif = prefix + 'serif'
522
+ if (measureIn(context, monospace, ' ') !== measureIn(context, serif, ' ') && measureIn(context, monospace, '\u2010') !== measureIn(context, serif, '\u2010')) {
523
+ for (let i = 0; i < families.length; i++) {
524
+ const beforeMonospace = `${prefix}${families[i]}, monospace`
525
+ const beforeSerif = `${prefix}${families[i]}, serif`
526
+ if (measureIn(context, beforeMonospace, ' ') !== measureIn(context, beforeSerif, ' ')) continue
527
+ if (measureIn(context, beforeMonospace, '\u2010') === measureIn(context, beforeSerif, '\u2010')) hyphenText = '\u2010'
528
+ break
529
+ }
113
530
  }
114
- return metrics.emojiCount;
531
+ context.font = font
532
+ }
533
+ measurement.hyphenText = hyphenText
534
+ return hyphenText
115
535
  }
116
- export function getCorrectedSegmentWidth(seg, metrics, emojiCorrection) {
117
- if (emojiCorrection === 0) return metrics.width;
118
- return metrics.width - getEmojiCount(seg, metrics) * emojiCorrection;
536
+
537
+ function measureIn(context , font , text ) {
538
+ context.font = font
539
+ return context.measureText(text).width
119
540
  }
120
- export function getSegmentBreakableFitAdvances(seg, metrics, cache, emojiCorrection, mode) {
121
- if (metrics.breakableFitAdvances !== undefined && metrics.breakableFitMode === mode) {
122
- return metrics.breakableFitAdvances;
123
- }
124
- metrics.breakableFitMode = mode;
125
- const graphemeSegmenter = getSharedGraphemeSegmenter();
126
- const graphemes = [];
127
- for (const gs of graphemeSegmenter.segment(seg)){
128
- graphemes.push(gs.segment);
541
+
542
+ // The language setLocale() gave, which preparation reads in place of the page's,
543
+ // or undefined.
544
+ let localeLanguage
545
+
546
+ export function setLocaleLanguage(locale ) {
547
+ localeLanguage = locale
548
+ }
549
+
550
+ // The language one preparation breaks and measures under, read once: setLocale()'s, or
551
+ // else the page's, or null without a document. The Chromium profile lays out a page
552
+ // without one under Blink's default locale (laysOutUnderDefaultLocale).
553
+ export function getPreparationLanguage(profile ) {
554
+ let language = localeLanguage ?? null
555
+ if (localeLanguage === undefined && typeof document !== 'undefined') {
556
+ const lang = (document.documentElement )?.lang
557
+ if (typeof lang === 'string') language = lang
558
+ }
559
+ return language === '' && profile.laysOutUnderDefaultLocale ? getBlinkDefaultLocale() : language
560
+ }
561
+
562
+ // The most app units a Gecko length holds (nscoord_MAX, nsCoord.h:28).
563
+ const MAX_APP_UNITS = (1 << 30) - 1
564
+
565
+ // A text's letter spacing in CSS px as the engine lays it out, 0 by default. CSS and
566
+ // Canvas ignore a non-finite one, which Pretext refuses rather than guess at. Gecko's
567
+ // whole app units are the float32 times 60, rounded half away from zero and clamped
568
+ // (DefaultLengthToAppUnits, ServoStyleConstsInlines.h:584-595), and a spacing that
569
+ // rounds to none keeps the text's ligatures (nsLayoutUtils.cpp:6896-6904).
570
+ export function readLetterSpacing(letterSpacing , profile ) {
571
+ const value = letterSpacing ?? 0
572
+ if (!Number.isFinite(value)) throw new RangeError(`letterSpacing must be a finite number of CSS px, not ${value}`)
573
+ if (!profile.letterSpacingInAppUnits) return value
574
+ const units = Math.fround(Math.fround(value) * 60)
575
+ return Math.min(Math.round(Math.abs(units)), MAX_APP_UNITS) * Math.sign(units) / 60
576
+ }
577
+
578
+ // A zero per segment, where per-segment widths start, pushed in a loop: Array.from over
579
+ // `{ length }` reads every index off the object and calls its map function for each.
580
+ export function zeros(count ) {
581
+ const out = []
582
+ for (let i = 0; i < count; i++) out.push(0)
583
+ return out
584
+ }
585
+
586
+ // A text's width as Canvas spaces it under a letter spacing, or null where the context
587
+ // can't take the spacing. Under none, that is the text's width in the font, which the
588
+ // font's segment cache asks once. Under a spacing it is asked each time, borrowing the
589
+ // font's context for the synchronous call, never enters a segment cache, and
590
+ // letterSpacing is restored even when assignment or measurement fails.
591
+ export function measureWithLetterSpacing(text , letterSpacing , emojiCorrection , measurement ) {
592
+ if (!measurement.state.takesLetterSpacing) return null
593
+ if (letterSpacing === 0) return getTextWidth(text, measurement, emojiCorrection)
594
+ // Counted before the spacing is set: the count measures stretches into the font's segment cache.
595
+ const corrected = emojiCorrection === 0 ? 0 : countEmojiGlyphs(text, measurement) * emojiCorrection
596
+ const context = getContext(measurement)
597
+ const previous = context.letterSpacing
598
+ try {
599
+ context.letterSpacing = `${letterSpacing}px`
600
+ if (Number.parseFloat(context.letterSpacing) !== letterSpacing) return null
601
+ const width = context.measureText(text).width - corrected
602
+ return Number.isFinite(width) ? width : null
603
+ } finally {
604
+ context.letterSpacing = previous
605
+ }
606
+ }
607
+
608
+ // The lookup internalizes seg, and a string V8 hashes for the first time there gets a one-byte
609
+ // copy where its units fit, so Canvas is handed a Latin-1 segment one-byte, which Chrome measures
610
+ // as Latin (RESEARCH.md, Keeping Work Bounded, String Storage). A segment cut from a longer text
611
+ // is a new string, so the lookup is its first hash. A text that is one segment is the caller's
612
+ // own string, since a slice of a whole string is that string, and so is a rich item that is one
613
+ // segment (prepareRichInline() in src/rich-inline.ts): a two-byte one that something hashed
614
+ // before without internalizing it, as a RegExp made from it does, stays two-byte.
615
+ export function getSegmentMetrics(seg , measurement ) {
616
+ return measurement.metrics.get(seg) ?? addMetrics(measurement.metrics, seg, seg, measurement)
617
+ }
618
+
619
+ // Metrics of seg measured together with one following U+0020.
620
+ export function getFollowingSpaceMetrics(seg , measurement ) {
621
+ return measurement.followingSpaceMetrics.get(seg) ?? addMetrics(measurement.followingSpaceMetrics, seg, seg + ' ', measurement)
622
+ }
623
+
624
+ function addMetrics(cache , seg , text , measurement ) {
625
+ const metrics = { width: getContext(measurement).measureText(text).width, emojiCount: -1, fit: null, spaceKerning: null }
626
+ cache.set(seg, metrics)
627
+ return metrics
628
+ }
629
+
630
+ // The kerning of every segment that takes none.
631
+ export const noSpaceKerning = { after: 0, before: 0 }
632
+
633
+ // Whether a character takes no kerning with a space, and Canvas isn't asked.
634
+ function takesNoSpaceKerning(code ) {
635
+ // A combining mark or half of a surrogate pair is part of a longer cluster.
636
+ return hasProperty(code, MARK) || (code & 0xf800) === 0xd800 ||
637
+ // Canvas shapes each ideograph and kana as a word of its own, so it shows no kerning beside
638
+ // one (NextWordEndIndex, plain_text_node.cc:92-153, over kIsCjkIdeographOrSymbolRanges,
639
+ // character_property_data.h:40-80, of which these are the letters).
640
+ (code >= 0x3041 && code <= 0x3096) || (code >= 0x30a1 && code <= 0x30fa) ||
641
+ (code >= 0x3400 && code <= 0x9fff) || (code >= 0xf900 && code <= 0xfaff) ||
642
+ // Premise: no font kerns a Hangul syllable with the space (RESEARCH.md, Kerning At Line Edges).
643
+ (code >= 0xac00 && code <= 0xd7a3)
644
+ }
645
+
646
+ // What is kept of the font's kerning with the space glyph, or null for a font that has none:
647
+ // one in which U+2028 before, between and after the printable ASCII characters is as wide with
648
+ // kerning off. `fontKerning = 'none'` turns the `kern` feature off
649
+ // (FontFeatureRange::FromFontDescription, font_features.cc:39-47), and with it HarfBuzz's
650
+ // kerning from GPOS, `kern` and `kerx` (hb-ot-shape.cc:127-131, hb-ot-kern-table.hh:67). Asked
651
+ // once per font. Premise: a font that kerns no printable ASCII character with the space kerns
652
+ // nothing with it (RESEARCH.md, Kerning At Line Edges, has the fonts that do).
653
+ export function getFontSpaceKerning(measurement ) {
654
+ if (measurement.spaceKerning === undefined) {
655
+ const context = getContext(measurement)
656
+ let probe = '\u2028'
657
+ for (let code = 0x21; code <= 0x7e; code++) probe += String.fromCharCode(code) + '\u2028'
658
+ const kerned = context.measureText(probe).width
659
+ context.fontKerning = 'none'
660
+ const unkerned = context.measureText(probe).width
661
+ context.fontKerning = 'auto'
662
+ // Where U+2028 alone doesn't measure as the space, it doesn't stand for it.
663
+ measurement.spaceKerning = kerned !== unkerned && context.measureText('\u2028').width === getSegmentMetrics(' ', measurement).width ? new Map() : null
664
+ }
665
+ return measurement.spaceKerning
666
+ }
667
+
668
+ // A character's kerning with a space glyph after it, or before it: the two in one string, with
669
+ // U+2028 for the space, less each alone. Asked of Canvas once per font and side.
670
+ function getCharacterSpaceKerning(code , spaceFirst , measurement , kernings ) {
671
+ const key = code << 1 | (spaceFirst ? 1 : 0)
672
+ let kerning = kernings.get(key)
673
+ if (kerning === undefined) {
674
+ kerning = 0
675
+ if (!takesNoSpaceKerning(code)) {
676
+ const character = String.fromCharCode(code)
677
+ const pairWidth = getContext(measurement).measureText(spaceFirst ? '\u2028' + character : character + '\u2028').width
678
+ kerning = pairWidth - getSegmentMetrics(character, measurement).width - getSegmentMetrics(' ', measurement).width
679
+ // Blink keeps a run's width as a float32 (shape_result.cc:1539-1576), so up to the pair's
680
+ // width / 2^22 is rounding, not kerning (RESEARCH.md, Kerning At Line Edges).
681
+ if (Math.abs(kerning) <= pairWidth / 0x400000) kerning = 0
129
682
  }
130
- if (graphemes.length <= 1) {
131
- metrics.breakableFitAdvances = null;
132
- return metrics.breakableFitAdvances;
683
+ kernings.set(key, kerning)
684
+ }
685
+ return kerning
686
+ }
687
+
688
+ // The kerning Blink's layout gives a text segment's edges with a U+0020 beside them
689
+ // (EngineProfile.kernsSpacesInScriptRun), read from Canvas with U+2028 for the space: Blink
690
+ // draws U+2028 with the space glyph (HarfBuzzGetGlyph, harfbuzz_face.cc:103-113) and its Canvas
691
+ // doesn't cut there. Premise: the segment's last and first character stand for the word, past
692
+ // default ignorables, which HarfBuzz's lookups pass over, and a first character with a combining
693
+ // mark after it takes none (RESEARCH.md, Kerning At Line Edges, has the gaps).
694
+ export function getSpaceKerning(seg , metrics , measurement , kernings ) {
695
+ let first = 0
696
+ let last = seg.length - 1
697
+ while (first < last && hasProperty(seg.charCodeAt(first), DEFAULT_IGNORABLE)) first++
698
+ while (last > first && hasProperty(seg.charCodeAt(last), DEFAULT_IGNORABLE)) last--
699
+ const before = first < last && hasProperty(seg.charCodeAt(first + 1), MARK) ? 0 : getCharacterSpaceKerning(seg.charCodeAt(first), true, measurement, kernings)
700
+ const after = getCharacterSpaceKerning(seg.charCodeAt(last), false, measurement, kernings)
701
+ return metrics.spaceKerning = after === 0 && before === 0 ? noSpaceKerning : { after, before }
702
+ }
703
+
704
+ // The font's space, which holds no emoji. Every text asks for it, so it is kept beside the font's
705
+ // Map, which still answers the first one.
706
+ export function getSpaceWidth(measurement ) {
707
+ return measurement.spaceWidth ??= getSegmentMetrics(' ', measurement).width
708
+ }
709
+
710
+ // A text's width in the font, less the emoji correction.
711
+ export function getTextWidth(text , measurement , emojiCorrection ) {
712
+ return getCorrectedSegmentWidth(text, getSegmentMetrics(text, measurement), measurement, emojiCorrection)
713
+ }
714
+
715
+
716
+
717
+ // Engine profiles describe the layout engine, not the browser brand. Chrome,
718
+ // Firefox and Edge on iOS lay out with WebKit whatever their brand token (CriOS/,
719
+ // FxiOS/, EdgiOS/) or desktop-mode user agent, and an app's web view may name no
720
+ // browser at all. The user agent decides alone, so a page and its workers agree.
721
+ // navigator.vendor is not read: workers don't have it, and jsdom reports WebKit's
722
+ // beside Chromium's frozen AppleWebKit/537.36 token. WebKit froze 605.1.15, so
723
+ // 537.36 names Blink only beside Chrome/ or Chromium/, which Samsung's TV web
724
+ // views omit, and any other AppleWebKit/ version names WebKit.
725
+ export function getLayoutEngine(userAgent ) {
726
+ if (userAgent.includes('Firefox/')) return 'gecko'
727
+ if (userAgent.includes('AppleWebKit/537.36')) {
728
+ return userAgent.includes('Chrome/') || userAgent.includes('Chromium/') ? 'blink' : null
729
+ }
730
+ return userAgent.includes('AppleWebKit/') ? 'webkit' : null
731
+ }
732
+
733
+ // Apart from buildEngineProfile(), so that what the line walkers call for every line stays a read
734
+ // of the cached profile, however much the profile holds. V8 inlines a function only while its
735
+ // bytecode takes at most 460 bytes (max_inlined_bytecode_size), minified or not. With the builder
736
+ // inside, this took 454 bytes with 23 fields and 463 with a 24th, which Chrome 154's V8 no longer
737
+ // inlined into the line counter and the simple stepper, and its plain line APIs ran
738
+ // 11-18% slower. Apart, it takes 21 (Node 23, V8 12.9). Check with node --print-bytecode and
739
+ // --trace-turbo-inlining (RESEARCH.md, JavaScript Engines).
740
+ export function getEngineProfile() {
741
+ return cachedEngineProfile ??= buildEngineProfile()
742
+ }
743
+
744
+ function buildEngineProfile() {
745
+ const ua = typeof navigator === 'undefined' ? '' : navigator.userAgent
746
+ // Engines Pretext doesn't recognize take Blink's profile (RESEARCH.md, Decisions Log,
747
+ // 2026-09-26).
748
+ const engine = getLayoutEngine(ua) ?? 'blink'
749
+ // Fresh-entry observations are verified only for desktop Blink and Gecko.
750
+ const isDesktop = /Windows NT|Macintosh|X11/.test(ua) && !/Android|Mobile|iPhone|iPad|iPod/.test(ua)
751
+
752
+ return {
753
+ entryFitBasis: isDesktop && engine === 'blink' ? 'fresh' : isDesktop && engine === 'gecko' ? 'original' : 'disabled',
754
+ lineBreakScan: engine,
755
+ graphemeTable: engine === 'webkit' ? 'apple/char' : engine === 'gecko' ? 'gecko/char' : 'chromium/char',
756
+ lineFitEpsilon: engine === 'webkit' ? 1 / 64 : 0.005,
757
+ cutWordFit: engine === 'blink' ? 'reshaped-lines' : 'segment-prefixes',
758
+ prefixFitMinWidth: engine === 'webkit' ? 0 : 80,
759
+ cutWordKeepsLigatures: engine === 'gecko',
760
+ measureTextWithFollowingSpace: engine === 'webkit',
761
+ kernsSpacesInScriptRun: engine === 'blink',
762
+ letterSpaceDiscretionaryHyphen: engine !== 'blink',
763
+ letterSpacingInAppUnits: engine === 'gecko',
764
+ canvasLetterSpacingDropsLigatures: engine !== 'webkit',
765
+ unspacedCursive: engine === 'blink' ? 'run' : engine === 'gecko' ? 'cluster' : 'none',
766
+ shapesMarksAcrossSoftHyphen: engine === 'blink',
767
+ unfitHyphenRetreat: engine === 'blink' ? 'reduced-width' : engine === 'gecko' ? 'full-width' : 'full-width-or-first',
768
+ hyphenFromPrimaryFont: engine !== 'gecko',
769
+ letterSpaceTabStops: engine !== 'webkit',
770
+ letterSpaceTabs: engine === 'webkit',
771
+ tabMinimumCharacter: engine === 'gecko' ? '0' : ' ',
772
+ tabsInAppUnits: engine === 'gecko',
773
+ hangTabs: engine !== 'gecko',
774
+ zeroWidthGlueTakesLine: engine !== 'gecko',
775
+ keepsLineStartPunctuation: engine === 'webkit',
776
+ hidesControlCharacters: engine === 'gecko',
777
+ hanKerning: engine === 'blink',
778
+ hangsIdeographicSpace: engine !== 'webkit',
779
+ laysOutUnderDefaultLocale: engine === 'blink',
780
+ namesGenericFamiliesByLanguage: engine === 'webkit',
781
+ hardBreakItemRetreat: engine === 'blink' ? 'item' : engine === 'webkit' ? 'fit' : 'last-grapheme',
782
+ paddedOpeningFit: engine === 'blink' ? 'start' : engine === 'webkit' ? 'placed' : 'both',
783
+ emptyAtomicAlwaysFits: engine === 'gecko',
784
+ hangsSpacesPerTextFrame: engine === 'gecko',
785
+ testsHyphenBeforeAtomic: engine !== 'webkit',
786
+ transformsSegmentBreaksAcrossItems: engine === 'blink',
787
+ }
788
+ }
789
+
790
+ export function parseFontSize(font ) {
791
+ // A failed size can restart at the next digit run, not at every digit in it.
792
+ const m = font.match(/(?:^|\D)(\d+(?:\.\d+)?)\s*px/)
793
+ return m ? parseFloat(m[1] ) : 16
794
+ }
795
+
796
+ export function textMayContainEmoji(text ) {
797
+ return maybeEmojiRe.test(text)
798
+ }
799
+
800
+ export function getEmojiCorrection(font , measurement ) {
801
+ let correction = measurement.emojiCorrection
802
+ if (correction !== null) return correction
803
+
804
+ const fontSize = parseFontSize(font)
805
+ const canvasW = measurement.emojiWidth = getContext(measurement).measureText('\u{1F600}').width
806
+ correction = 0
807
+ // document.body is null until the parser reaches <body>, which lib.dom's type leaves out.
808
+ if (
809
+ canvasW > fontSize + 0.5 &&
810
+ typeof document !== 'undefined' &&
811
+ (document.body ) !== null
812
+ ) {
813
+ const span = document.createElement('span')
814
+ span.style.font = font
815
+ span.style.display = 'inline-block'
816
+ span.style.visibility = 'hidden'
817
+ span.style.position = 'absolute'
818
+ span.textContent = '\u{1F600}'
819
+ document.body.appendChild(span)
820
+ const domW = span.getBoundingClientRect().width
821
+ document.body.removeChild(span)
822
+ if (canvasW - domW > 0.5) {
823
+ correction = canvasW - domW
133
824
  }
134
- if (mode === 'sum-graphemes') {
135
- const advances = [];
136
- for (const grapheme of graphemes){
137
- const graphemeMetrics = getSegmentMetrics(grapheme, cache);
138
- advances.push(getCorrectedSegmentWidth(grapheme, graphemeMetrics, emojiCorrection));
139
- }
140
- metrics.breakableFitAdvances = advances;
141
- return metrics.breakableFitAdvances;
825
+ }
826
+ measurement.emojiCorrection = correction
827
+ return correction
828
+ }
829
+
830
+ // Canvas reports a width as a 32-bit float (CanvasRenderingContext2D.cpp:5277 in Firefox,
831
+ // text_metrics.cc:179 in Chrome), so a few equal advances measure that many times one
832
+ // only within that float's rounding, 2^-24 of the width each time one is rounded: 2e-6 px
833
+ // over three emoji of a bold font in Firefox 156. Sixteen roundings are allowed for, a
834
+ // window far finer than the steps a font's advances come in (RESEARCH.md, Content
835
+ // Language And Fonts).
836
+ const CANVAS_WIDTH_ROUNDING = 2 ** -20
837
+
838
+ // What a word's width may differ by from its graphemes' widths added up and still be their sum:
839
+ // 2^-17 of the width, which covers the float32 roundings of a word of 96 graphemes, each 2^-24
840
+ // of the width at most, and is a tenth of the least kerning of a 2,048-unit font in a 100px word
841
+ // at 16px (RESEARCH.md, Break Opportunities From Engine Data).
842
+ const WORD_SUM_ROUNDING = 2 ** -17
843
+
844
+ // How many glyphs of the emoji font draw a text: the emoji font gives every glyph one
845
+ // advance, the probe's, so text it draws measures a whole number of them, and none
846
+ // where it measures anything else.
847
+ function getEmojiGlyphs(text , measurement ) {
848
+ const width = getSegmentMetrics(text, measurement).width
849
+ const glyphs = Math.round(width / measurement.emojiWidth)
850
+ return Math.abs(width - glyphs * measurement.emojiWidth) <= width * CANVAS_WIDTH_ROUNDING ? glyphs : 0
851
+ }
852
+
853
+ // The characters the emoji font shapes together inside a grapheme: emoji and pictographs,
854
+ // the characters that join or modify them (ZWJ, skin tones, tags, U+20E3) and the
855
+ // variation selectors. Every emoji character has one of the two properties. A grapheme
856
+ // of emojiGraphemeRe holds one.
857
+ const emojiStretchRe = /[\p{Extended_Pictographic}\p{Emoji_Component}\uFE0E]+/gu
858
+
859
+ // The glyphs of the emoji font in a text: what the correction is subtracted for, once
860
+ // each. Font fallback decides which font draws an emoji character, and Canvas shows what
861
+ // it decided, at one cached Canvas call per distinct stretch of a font. For a character
862
+ // with no selector, the named font's own glyph comes before the emoji font's
863
+ // (CheckCandidate, gfxTextRun.cpp:3350-3359; FontFallbackIterator::Next,
864
+ // font_fallback_iterator.cc:166-178), and U+FE0E asks for a text font
865
+ // (gfxTextRun.cpp:3270-3273; SymbolsIterator::Consume, symbols_iterator.cc:67-71). So a
866
+ // stretch with a glyph of another font measures as the page draws it: Menlo's own
867
+ // U+26A1, Inter's U+2B1C, Hiragino Sans's U+26AA, or U+231A before U+FE0E. A pictograph
868
+ // whose presentation is text by default takes the correction with no U+FE0F where only
869
+ // the emoji font has it, as U+1F336 in Arial. Two or more glyphs are a sequence the
870
+ // emoji font has no glyph for, drawn as its parts.
871
+ //
872
+ // Each stretch of emoji characters is asked whole, and apart from the rest of its
873
+ // grapheme, since each font shapes its own characters together: Firefox matches a font
874
+ // character by character (gfxFontGroup::FindFontForChar, gfxTextRun.cpp:3178-3194), and
875
+ // Chrome ends a run where emoji give way to text before it shapes
876
+ // (RunSegmenter::Consume, run_segmenter.cc:44-73, over SymbolsIterator::Consume,
877
+ // symbols_iterator.cc:34-79). So an emoji, a sequence or a flag before a combining mark
878
+ // of another script is still one glyph. A stretch that two fonts draw takes no
879
+ // correction, as a text font's pictograph joined by a ZWJ to an emoji in Firefox, and a
880
+ // stretch asked apart can be another font's than inside its grapheme, as a skin tone
881
+ // after a letter in Chrome.
882
+ //
883
+ // Those and the other gaps are in ENGINE_FOLLOWUPS.md, Emoji correction: another font's
884
+ // glyph exactly as wide as an emoji takes the correction, as does Firefox's box for a
885
+ // missing glyph at 13px, and an emoji font whose advances vary would take none.
886
+ function countEmojiGlyphs(text , measurement ) {
887
+ const ends = new Int32Array(text.length)
888
+ const graphemeCount = findGraphemeEnds(getEngineProfile().graphemeTable, text, 0, text.length, ends)
889
+ let count = 0
890
+ for (let i = 0, start = 0; i < graphemeCount; start = ends[i++] ) {
891
+ const grapheme = text.slice(start, ends[i])
892
+ if (!emojiGraphemeRe.test(grapheme)) continue
893
+ const stretches = grapheme.match(emojiStretchRe)
894
+ for (let s = 0; s < stretches.length; s++) count += getEmojiGlyphs(stretches[s] , measurement)
895
+ }
896
+ return count
897
+ }
898
+
899
+ export function getCorrectedSegmentWidth(seg , metrics , measurement , emojiCorrection ) {
900
+ if (emojiCorrection === 0) return metrics.width
901
+ if (metrics.emojiCount < 0) metrics.emojiCount = countEmojiGlyphs(seg, measurement)
902
+ return metrics.width - metrics.emojiCount * emojiCorrection
903
+ }
904
+
905
+ export function getSegmentFit(
906
+ seg ,
907
+ metrics ,
908
+ measurement ,
909
+ emojiCorrection ,
910
+ mode ,
911
+ // When metrics measured seg together with one following U+0020, the width of
912
+ // that space alone. The last grapheme then keeps its kerning with the space.
913
+ followingSpaceWidth = null,
914
+ // Whether to find the segment's WebKit line-start prohibitions.
915
+ withLineStartProhibitions = false,
916
+ ) {
917
+ if (metrics.fit !== null && metrics.fit.mode === mode) return metrics.fit
918
+ const profile = getEngineProfile()
919
+ const ends = new Int32Array(seg.length)
920
+ const count = findGraphemeEnds(profile.graphemeTable, seg, 0, seg.length, ends)
921
+ if (count <= 1) return metrics.fit = { mode, advances: null, lineStartExtras: null, lineStartProhibitions: null, entryGeometry: null }
922
+ let prohibitions = null
923
+ if (withLineStartProhibitions) {
924
+ for (let i = 1; i < count; i++) if (!canWebKitLineStartWith(seg.charCodeAt(ends[i - 1] ))) (prohibitions ??= new Uint8Array(count))[i] = 1
925
+ }
926
+ let advances
927
+ let lineStartExtras = null
928
+ if (mode === 'reshaped-lines') {
929
+ // Blink shapes a line of a cut word again where the cut falls between letters shaped
930
+ // together (EngineProfile's cutWordFit). Premise: a line holds the letters whose width,
931
+ // shaped alone, fits, so the letters after the line's first take the advances they have
932
+ // in the word, and the first its width alone. Premise, taken for speed: a word as wide as
933
+ // its graphemes measured alone has nothing shaped across them, and they are its advances,
934
+ // with nothing more measured. Else each grapheme is measured after the one before it,
935
+ // which every word of the font shares, and where those don't add up to the word either,
936
+ // as in a ligature of three letters or the joined forms of Arabic, the word's prefixes
937
+ // are, up to MAX_PREFIX_FIT_GRAPHEMES (RESEARCH.md, Break Opportunities From Engine Data).
938
+ const width = getCorrectedSegmentWidth(seg, metrics, measurement, emojiCorrection) - (followingSpaceWidth ?? 0)
939
+ const alone = measureFitAdvances(seg, ends, count, 'sum-graphemes', metrics, measurement, emojiCorrection, followingSpaceWidth)
940
+ advances = alone
941
+ if (!addUpTo(alone, width)) {
942
+ advances = measureFitAdvances(seg, ends, count, 'pair-context', metrics, measurement, emojiCorrection, followingSpaceWidth)
943
+ if (count <= MAX_PREFIX_FIT_GRAPHEMES && !addUpTo(advances, width)) {
944
+ advances = measureFitAdvances(seg, ends, count, 'segment-prefixes', metrics, measurement, emojiCorrection, followingSpaceWidth)
945
+ }
946
+ lineStartExtras = [0]
947
+ for (let i = 1; i < count; i++) lineStartExtras.push(alone[i] - advances[i] )
142
948
  }
143
- if (mode === 'pair-context' || graphemes.length > MAX_PREFIX_FIT_GRAPHEMES) {
144
- const advances = [];
145
- let previousGrapheme = null;
146
- let previousWidth = 0;
147
- for (const grapheme of graphemes){
148
- const graphemeMetrics = getSegmentMetrics(grapheme, cache);
149
- const currentWidth = getCorrectedSegmentWidth(grapheme, graphemeMetrics, emojiCorrection);
150
- if (previousGrapheme === null) {
151
- advances.push(currentWidth);
152
- } else {
153
- const pair = previousGrapheme + grapheme;
154
- const pairMetrics = getSegmentMetrics(pair, cache);
155
- advances.push(getCorrectedSegmentWidth(pair, pairMetrics, emojiCorrection) - previousWidth);
156
- }
157
- previousGrapheme = grapheme;
158
- previousWidth = currentWidth;
159
- }
160
- metrics.breakableFitAdvances = advances;
161
- return metrics.breakableFitAdvances;
949
+ } else {
950
+ // Past MAX_PREFIX_FIT_GRAPHEMES, prefixes give way to pairs.
951
+ const prefixes = mode === 'segment-prefixes' && count <= MAX_PREFIX_FIT_GRAPHEMES
952
+ advances = measureFitAdvances(seg, ends, count, mode === 'segment-prefixes' && !prefixes ? 'pair-context' : mode, metrics, measurement, emojiCorrection, followingSpaceWidth)
953
+ if (prefixes && profile.cutWordKeepsLigatures) countLigaturesOnFirstLetter(seg, ends, advances, measurement, emojiCorrection)
954
+ }
955
+ return metrics.fit = { mode, advances, lineStartExtras, lineStartProhibitions: prohibitions, entryGeometry: null }
956
+ }
957
+
958
+ // Moves the advance of each later letter of a ligature onto the ligature's first letter, in a
959
+ // segment's prefix advances, as Gecko counts it where it cuts a word (EngineProfile's
960
+ // cutWordKeepsLigatures). A prefix that ends inside a ligature has the first letter's glyph
961
+ // alone, so the next letter's advance after that prefix isn't its advance alone, in app units,
962
+ // 1/60 px, which Gecko's Canvas reports whole, and there Canvas is asked whether the two are a
963
+ // ligature (isLigature). Where a line is narrower than the ligature it starts with, Gecko cuts
964
+ // inside it and gives each of its letters an equal share (ComputeLigatureData,
965
+ // gfxTextRun.cpp:238-322); here the first letter keeps the whole advance (ENGINE_FOLLOWUPS.md,
966
+ // Emergency breaks inside a word).
967
+ function countLigaturesOnFirstLetter(seg , ends , advances , measurement , emojiCorrection ) {
968
+ // Text measured under letter spacing has no optional ligature, and a context that can't
969
+ // turn them off can't be asked.
970
+ if (!measurement.state.shapesLetterSpaced || measurement.letterSpacing !== '0px') return
971
+ // The grapheme the ligature that holds the grapheme before this one starts with, or that grapheme.
972
+ let first = 0
973
+ for (let i = 1; i < advances.length; i++) {
974
+ const start = ends[i - 1]
975
+ const alone = getTextWidth(seg.slice(start, ends[i]), measurement, emojiCorrection)
976
+ if (Math.round((advances[i] - alone) * 60) !== 0 && isLigature(seg.slice(i === 1 ? 0 : ends[i - 2] , ends[i]), measurement)) {
977
+ advances[first] = advances[first] + advances[i]
978
+ advances[i] = 0
979
+ } else {
980
+ first = i
162
981
  }
163
- const advances = [];
164
- let prefix = '';
165
- let prefixWidth = 0;
166
- for (const grapheme of graphemes){
167
- prefix += grapheme;
168
- const prefixMetrics = getSegmentMetrics(prefix, cache);
169
- const nextPrefixWidth = getCorrectedSegmentWidth(prefix, prefixMetrics, emojiCorrection);
170
- advances.push(nextPrefixWidth - prefixWidth);
171
- prefixWidth = nextPrefixWidth;
982
+ }
983
+ }
984
+
985
+ // Whether two neighbouring graphemes are one of the font's optional ligatures: the pair measures
986
+ // otherwise without them, as the context shapes text under a letter spacing
987
+ // (LETTER_SPACED_SHAPING). Asked of Canvas once per pair and font. A kerned pair, letters that
988
+ // join, a mark on its base and a ligature the font requires measure the same, and aren't one.
989
+ function isLigature(pair , measurement ) {
990
+ let ligature = measurement.ligaturePairs.get(pair)
991
+ if (ligature === undefined) {
992
+ const context = getContext(measurement)
993
+ const width = getSegmentMetrics(pair, measurement).width
994
+ context.letterSpacing = LETTER_SPACED_SHAPING
995
+ ligature = context.measureText(pair).width !== width
996
+ context.letterSpacing = '0px'
997
+ measurement.ligaturePairs.set(pair, ligature)
998
+ }
999
+ return ligature
1000
+ }
1001
+
1002
+ // Whether a segment's advances add up to its width, within the rounding of Canvas's widths.
1003
+ function addUpTo(advances , width ) {
1004
+ let sum = 0
1005
+ for (let i = 0; i < advances.length; i++) sum += advances[i]
1006
+ return Math.abs(sum - width) <= width * WORD_SUM_ROUNDING
1007
+ }
1008
+
1009
+ // A segment's advances per grapheme: the differences of its prefixes' widths, or each
1010
+ // grapheme's width alone or, as a pair, after the one before it.
1011
+ function measureFitAdvances(
1012
+ seg ,
1013
+ ends ,
1014
+ count ,
1015
+ from ,
1016
+ metrics ,
1017
+ measurement ,
1018
+ emojiCorrection ,
1019
+ followingSpaceWidth ,
1020
+ ) {
1021
+ const prefixes = from === 'segment-prefixes'
1022
+ const pairs = from === 'pair-context'
1023
+ const advances = []
1024
+ let previousStart = 0
1025
+ let previousWidth = 0
1026
+ for (let i = 0, start = 0; i < count; start = ends[i++] ) {
1027
+ const end = ends[i]
1028
+ if (prefixes) {
1029
+ // The whole segment is the last prefix; with a following space it was
1030
+ // measured together with that space.
1031
+ const width = followingSpaceWidth !== null && i === count - 1
1032
+ ? getCorrectedSegmentWidth(seg, metrics, measurement, emojiCorrection) - followingSpaceWidth
1033
+ : getTextWidth(seg.slice(0, end), measurement, emojiCorrection)
1034
+ advances.push(width - previousWidth)
1035
+ previousWidth = width
1036
+ continue
172
1037
  }
173
- metrics.breakableFitAdvances = advances;
174
- return metrics.breakableFitAdvances;
175
- }
176
- export function getFontMeasurementState(font, needsEmojiCorrection) {
177
- const ctx = getMeasureContext();
178
- ctx.font = font;
179
- const cache = getSegmentMetricCache(font);
180
- const emojiCorrection = needsEmojiCorrection ? getEmojiCorrection(font) : 0;
181
- return {
182
- cache,
183
- emojiCorrection
184
- };
185
- }
186
- export function clearMeasurementCaches() {
187
- segmentMetricCaches.clear();
188
- emojiCorrectionCache.clear();
1038
+ const width = getTextWidth(seg.slice(start, end), measurement, emojiCorrection)
1039
+ advances.push(pairs && i > 0 ? getTextWidth(seg.slice(previousStart, end), measurement, emojiCorrection) - previousWidth : width)
1040
+ previousStart = start
1041
+ previousWidth = width
1042
+ }
1043
+ // Advances that do not end in the whole segment's width take the kerning as a
1044
+ // difference, which needs the segment measured alone too.
1045
+ if (followingSpaceWidth !== null && !prefixes) {
1046
+ advances[count - 1] = advances[count - 1] + metrics.width - getSegmentMetrics(seg, measurement).width - followingSpaceWidth
1047
+ }
1048
+ return advances
1049
+ }
1050
+
1051
+ // What preparation measures a font's text through. Text under letter spacing has a
1052
+ // measurement of its own where the context shapes it as the page does: its widths,
1053
+ // prefixes and line-edge facts all come from that shaping. The context isn't touched
1054
+ // here: text that is all in the font's caches measures nothing (getContext).
1055
+ export function getFontMeasurement(font , language , letterSpaced ) {
1056
+ // Preparation starts here, with the language it resolved. After that language
1057
+ // changes, start again with a new context and empty caches; clearing the caches
1058
+ // alone would re-measure with fonts resolved under the old language.
1059
+ if (measureState === null || measureState.language !== language) measureState = createMeasureState(language)
1060
+ const state = measureState
1061
+ const shaped = letterSpaced && state.shapesLetterSpaced
1062
+ const fonts = shaped ? state.letterSpacedFonts : state.fonts
1063
+ let measurement = fonts.get(font)
1064
+ if (measurement === undefined) {
1065
+ const canvasFont = state.genericFamilies === null ? font : getCanvasFont(font, state.genericFamilies)
1066
+ measurement = { state, canvasFont, letterSpacing: shaped ? LETTER_SPACED_SHAPING : '0px', metrics: new Map(), followingSpaceMetrics: new Map(), spaceKerning: undefined, emojiCorrection: null, emojiWidth: 0, hyphenText: null, spaceWidth: null, hanKerning: undefined, ligaturePairs: new Map() }
1067
+ fonts.set(font, measurement)
1068
+ }
1069
+ // The first measurement after a lookup sets the font again, the same string too: Firefox's
1070
+ // context takes a face added to document.fonts only when its font is assigned.
1071
+ state.font = ''
1072
+ return measurement
1073
+ }
1074
+
1075
+ // The font's context, set to the font and its shaping. Every measurement in a font takes
1076
+ // its context from here as it measures, so none reads a width under another font's
1077
+ // setting, whatever was looked up or prepared in between, and text that is all cached,
1078
+ // which measures nothing, sets nothing. What changes the context after that puts it back
1079
+ // before it returns (getHyphenText, measureWithLetterSpacing, getFontSpaceKerning,
1080
+ // isLigature).
1081
+ export function getContext(measurement ) {
1082
+ const state = measurement.state
1083
+ if (state.font !== measurement.canvasFont) state.context.font = state.font = measurement.canvasFont
1084
+ if (state.letterSpacing !== measurement.letterSpacing) state.context.letterSpacing = state.letterSpacing = measurement.letterSpacing
1085
+ return state.context
1086
+ }
1087
+
1088
+ function createMeasureState(language ) {
1089
+ let context
1090
+ if (typeof OffscreenCanvas !== 'undefined') {
1091
+ context = new OffscreenCanvas(1, 1).getContext('2d')
1092
+ } else if (typeof document !== 'undefined') {
1093
+ context = document.createElement('canvas').getContext('2d')
1094
+ } else {
1095
+ throw new Error('Text measurement requires OffscreenCanvas or a DOM canvas context.')
1096
+ }
1097
+ // A context's `lang` follows the page's, and preparation's can be setLocale()'s or
1098
+ // Blink's default locale instead.
1099
+ if (language !== null && 'lang' in context) context.lang = language
1100
+ const profile = getEngineProfile()
1101
+ const takesLetterSpacing = typeof context.letterSpacing === 'string'
1102
+ return {
1103
+ language,
1104
+ context,
1105
+ font: '',
1106
+ letterSpacing: '0px',
1107
+ genericFamilies: language !== null && profile.namesGenericFamiliesByLanguage ? getWebKitGenericFamilies(language, context) : null,
1108
+ takesLetterSpacing,
1109
+ shapesLetterSpaced: takesLetterSpacing && profile.canvasLetterSpacingDropsLigatures,
1110
+ fonts: new Map(),
1111
+ letterSpacedFonts: new Map(),
1112
+ }
1113
+ }
1114
+
1115
+ export function clearMeasurementCaches() {
1116
+ measureState?.fonts.clear()
1117
+ measureState?.letterSpacedFonts.clear()
189
1118
  }