rapier-markdown-kit 1.2.33 → 1.2.42
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -9
- package/dist/agent/vendor/pretext/NOTICE +2 -2
- package/dist/agent/vendor/pretext/SOURCE.json +62 -78
- package/dist/agent/vendor/pretext/analysis.js +24 -6
- package/dist/agent/vendor/pretext/gecko-line-breaks.js +1 -1
- package/dist/agent/vendor/pretext/generated/engine-break-data.js +1 -1
- package/dist/agent/vendor/pretext/generated/webkit-generic-families.js +1 -1
- package/dist/agent/vendor/pretext/graphemes.js +1 -1
- package/dist/agent/vendor/pretext/han-kerning.js +1 -1
- package/dist/agent/vendor/pretext/layout.js +14 -11
- package/dist/agent/vendor/pretext/line-break.js +170 -195
- package/dist/agent/vendor/pretext/line-breaks.js +1 -7
- package/dist/agent/vendor/pretext/line-text.js +2 -2
- package/dist/agent/vendor/pretext/measurement.js +454 -113
- package/dist/agent/vendor/pretext/prepare.js +164 -244
- package/dist/agent/vendor/pretext/rich-inline.js +57 -37
- package/dist/agent/will.mjs +73 -0
- package/dist/kit/ledger/data.mjs +57 -4
- package/dist/kit/ledger/format.mjs +114 -47
- package/dist/kit/ledger/hash.mjs +43 -2
- package/dist/kit/ledger/history.mjs +6 -0
- package/dist/kit/ledger/inverse-source.mjs +15 -2
- package/dist/kit/ledger/journal-records.mjs +32 -9
- package/dist/kit/ledger/merge.mjs +3 -17
- package/dist/kit/ledger/text-merge.mjs +28 -2
- package/dist/kit/rapier-ledger.mjs +2 -2
- package/dist/kit/render-markdown.mjs +170 -12
- package/dist/kit/render-print.mjs +3 -3
- package/dist/kit/render-sanitize.mjs +66 -23
- package/dist/kit/render.mjs +10 -10
- package/dist/kit/wiki.mjs +34 -0
- package/dist/kit/will.d.mts +3 -0
- package/dist/layout/hyphenation.mjs +6 -4
- package/dist/layout/line-plan.mjs +96 -0
- package/dist/layout/model.mjs +2 -1
- package/dist/layout/paragraph-fit.mjs +4 -4
- package/dist/layout/prepared-paragraph.mjs +19 -2
- package/dist/layout/typography.mjs +57 -63
- package/dist/spec/document-fonts.mjs +1 -0
- package/dist/spec/document-settings.mjs +1 -0
- package/dist/spec/html-blocks.mjs +10 -7
- package/dist/spec/md-assets.mjs +52 -12
- package/package.json +1 -1
- package/style.css +33 -23
- package/dist/agent/vendor/pretext/entry-geometry.js +0 -183
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
/*! Pretext
|
|
1
|
+
/*! Pretext 1.0.0 + main (0d102a5cd) | MIT; BSD/Unicode/ICU notices in LICENSE | Copyright (c) 2026 Pretext contributors */
|
|
2
2
|
import { findGraphemeEnds, } from './graphemes.js'
|
|
3
|
-
import {
|
|
3
|
+
import { DEFAULT_IGNORABLE, getBlinkDefaultLocale, hasProperty, MARK } from './line-breaks.js'
|
|
4
4
|
import { webkitGenericFamilies, webkitGenericFamilyNames, webkitScriptLanguages, webkitScriptSubtags } from './generated/webkit-generic-families.js'
|
|
5
|
-
|
|
6
5
|
|
|
7
6
|
|
|
8
7
|
// What preparation knows of a segment in a font. Each is created with all its fields,
|
|
@@ -12,6 +11,9 @@ import { webkitGenericFamilies, webkitGenericFamilyNames, webkitScriptLanguages,
|
|
|
12
11
|
|
|
13
12
|
|
|
14
13
|
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
15
17
|
|
|
16
18
|
|
|
17
19
|
// A text segment's kerning with a U+0020 beside it, in the Chromium profile (getSpaceKerning).
|
|
@@ -28,23 +30,9 @@ import { webkitGenericFamilies, webkitGenericFamilyNames, webkitScriptLanguages,
|
|
|
28
30
|
|
|
29
31
|
|
|
30
32
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
33
|
|
|
41
34
|
|
|
42
35
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
36
|
|
|
49
37
|
|
|
50
38
|
|
|
@@ -102,13 +90,16 @@ import { webkitGenericFamilies, webkitGenericFamilyNames, webkitScriptLanguages,
|
|
|
102
90
|
|
|
103
91
|
|
|
104
92
|
|
|
105
|
-
|
|
93
|
+
|
|
94
|
+
|
|
106
95
|
|
|
107
96
|
|
|
108
97
|
|
|
109
98
|
|
|
99
|
+
|
|
100
|
+
|
|
110
101
|
|
|
111
|
-
|
|
102
|
+
|
|
112
103
|
|
|
113
104
|
|
|
114
105
|
|
|
@@ -135,11 +126,23 @@ import { webkitGenericFamilies, webkitGenericFamilyNames, webkitScriptLanguages,
|
|
|
135
126
|
|
|
136
127
|
|
|
137
128
|
|
|
138
|
-
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
|
|
139
135
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
|
|
143
146
|
|
|
144
147
|
|
|
145
148
|
|
|
@@ -167,6 +170,24 @@ import { webkitGenericFamilies, webkitGenericFamilyNames, webkitScriptLanguages,
|
|
|
167
170
|
|
|
168
171
|
|
|
169
172
|
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
|
|
170
191
|
|
|
171
192
|
|
|
172
193
|
|
|
@@ -213,12 +234,6 @@ import { webkitGenericFamilies, webkitGenericFamilyNames, webkitScriptLanguages,
|
|
|
213
234
|
|
|
214
235
|
|
|
215
236
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
237
|
|
|
223
238
|
|
|
224
239
|
|
|
@@ -234,7 +249,7 @@ import { webkitGenericFamilies, webkitGenericFamilyNames, webkitScriptLanguages,
|
|
|
234
249
|
|
|
235
250
|
|
|
236
251
|
|
|
237
|
-
|
|
252
|
+
|
|
238
253
|
|
|
239
254
|
|
|
240
255
|
|
|
@@ -242,6 +257,14 @@ import { webkitGenericFamilies, webkitGenericFamilyNames, webkitScriptLanguages,
|
|
|
242
257
|
|
|
243
258
|
|
|
244
259
|
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
|
|
245
268
|
|
|
246
269
|
|
|
247
270
|
|
|
@@ -307,6 +330,20 @@ import { webkitGenericFamilies, webkitGenericFamilyNames, webkitScriptLanguages,
|
|
|
307
330
|
|
|
308
331
|
|
|
309
332
|
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
|
|
310
347
|
|
|
311
348
|
|
|
312
349
|
|
|
@@ -349,6 +386,11 @@ import { webkitGenericFamilies, webkitGenericFamilyNames, webkitScriptLanguages,
|
|
|
349
386
|
|
|
350
387
|
|
|
351
388
|
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
|
|
352
394
|
|
|
353
395
|
|
|
354
396
|
|
|
@@ -359,6 +401,7 @@ import { webkitGenericFamilies, webkitGenericFamilyNames, webkitScriptLanguages,
|
|
|
359
401
|
|
|
360
402
|
|
|
361
403
|
|
|
404
|
+
|
|
362
405
|
|
|
363
406
|
let measureState = null
|
|
364
407
|
// What preparation keeps per font. It all goes together, when the caches clear or the
|
|
@@ -378,12 +421,19 @@ let measureState = null
|
|
|
378
421
|
|
|
379
422
|
|
|
380
423
|
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
|
|
381
430
|
|
|
382
431
|
|
|
383
432
|
|
|
384
433
|
|
|
385
434
|
|
|
386
435
|
|
|
436
|
+
|
|
387
437
|
|
|
388
438
|
let cachedEngineProfile = null
|
|
389
439
|
|
|
@@ -404,6 +454,19 @@ const MAX_PREFIX_FIT_GRAPHEMES = 96
|
|
|
404
454
|
// `font-variant-ligatures: none` (2026-09-30).
|
|
405
455
|
const LETTER_SPACED_SHAPING = '0.000001px'
|
|
406
456
|
|
|
457
|
+
// The most segments the fonts' caches hold between them when a preparation starts
|
|
458
|
+
// (getPreparationLanguage): past it they are all dropped, as clearCache() drops them, before
|
|
459
|
+
// that preparation measures anything. A handle keeps its own widths, so nothing prepared
|
|
460
|
+
// changes; text prepared afterwards is measured again. Prose adds segments ever more slowly,
|
|
461
|
+
// but ids, numbers and URLs never repeat, so without a bound the caches grow for as long as the
|
|
462
|
+
// page lives. The bound is a safety net well above what a page uses, not a tuned size: the
|
|
463
|
+
// 11,290 plain texts of the harness's real-usage sample, 883,084 units, fill in one font 71,294
|
|
464
|
+
// segments in the Chromium profile, 90,023 in Gecko's and 115,367 in WebKit's, 241-363 B each in
|
|
465
|
+
// JavaScriptCore, and a bound under a page's own count has its text measured again and again:
|
|
466
|
+
// at 65,536, preparing those texts a second time asks Canvas 81,286 to 196,534 times, where
|
|
467
|
+
// under no bound it asks nothing (the harness's stand-in Canvas, 2026-10-12).
|
|
468
|
+
const MAX_CACHED_SEGMENTS = 1 << 18
|
|
469
|
+
|
|
407
470
|
// Graphemes the emoji font may draw a glyph in, which Canvas widths then tell
|
|
408
471
|
// (countEmojiGlyphs): those holding an emoji-presentation character or a pictograph,
|
|
409
472
|
// or an emoji character followed by U+FE0F, such as a keycap base like `1`. U+FE0F
|
|
@@ -551,6 +614,11 @@ export function setLocaleLanguage(locale ) {
|
|
|
551
614
|
// else the page's, or null without a document. The Chromium profile lays out a page
|
|
552
615
|
// without one under Blink's default locale (laysOutUnderDefaultLocale).
|
|
553
616
|
export function getPreparationLanguage(profile ) {
|
|
617
|
+
// A preparation starts with this read, so the fonts' caches are bounded here, between two
|
|
618
|
+
// preparations (MAX_CACHED_SEGMENTS), and not where a font is looked up: a rich-inline
|
|
619
|
+
// paragraph looks one up for each of its items, and caches dropped between two of them would
|
|
620
|
+
// have one paragraph measured partly from the caches and partly anew.
|
|
621
|
+
if (measureState !== null && measureState.segments > MAX_CACHED_SEGMENTS) clearMeasurementCaches()
|
|
554
622
|
let language = localeLanguage ?? null
|
|
555
623
|
if (localeLanguage === undefined && typeof document !== 'undefined') {
|
|
556
624
|
const lang = (document.documentElement )?.lang
|
|
@@ -583,26 +651,62 @@ export function zeros(count ) {
|
|
|
583
651
|
return out
|
|
584
652
|
}
|
|
585
653
|
|
|
586
|
-
//
|
|
587
|
-
//
|
|
588
|
-
//
|
|
589
|
-
//
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
654
|
+
// The Canvas letterSpacing a text is measured under to count the glyphs it spaces: far
|
|
655
|
+
// wider than any text asked about, so the text's width over it rounds to the count with
|
|
656
|
+
// nothing taken off, and a power of two, which float32 widths add whole. A text 2^19 px
|
|
657
|
+
// wide under no spacing would count a glyph more.
|
|
658
|
+
const GLYPH_COUNT_SPACING = 2 ** 20
|
|
659
|
+
|
|
660
|
+
// The spacings of a segment whose graphemes each take one, and of any segment where Canvas
|
|
661
|
+
// can't be asked.
|
|
662
|
+
export const noExtraSpacings = []
|
|
663
|
+
|
|
664
|
+
// Per grapheme of a text segment, how many letter spacings it takes more than one, where
|
|
665
|
+
// the engine spaces glyphs (EngineProfile's letterSpacesGlyphs). Canvas spaces by the
|
|
666
|
+
// engine's rule, so a prefix of the segment measured under a letter spacing holds that
|
|
667
|
+
// spacing once for each of its glyphs with an advance, and a grapheme takes what its
|
|
668
|
+
// prefix has more than the prefix before it: WebKit fits a cut word by its prefixes too
|
|
669
|
+
// (TextUtil::breakWord). A grapheme can take none, as the alef of lam-alef, or fewer,
|
|
670
|
+
// where it joins the glyphs before it into one. Past MAX_PREFIX_FIT_GRAPHEMES each
|
|
671
|
+
// grapheme is asked alone, which keeps a giant segment linear and misses a glyph two of
|
|
672
|
+
// its graphemes share. A text's count is kept for the font (FontMeasurement's
|
|
673
|
+
// glyphCounts), so words that start alike ask about a prefix once: one more Canvas call
|
|
674
|
+
// for each prefix new to the font, which WebKit shapes each time, since it keeps no width
|
|
675
|
+
// measured under a letter spacing (TextMeasurementCache.h:165-167). Under a context that
|
|
676
|
+
// takes no letterSpacing every grapheme takes one.
|
|
677
|
+
export function getExtraSpacings(seg , measurement ) {
|
|
678
|
+
if (!measurement.state.takesLetterSpacing) return noExtraSpacings
|
|
679
|
+
const ends = new Int32Array(seg.length)
|
|
680
|
+
const count = findGraphemeEnds(getEngineProfile().graphemeTable, seg, 0, seg.length, ends)
|
|
681
|
+
const prefixes = count <= MAX_PREFIX_FIT_GRAPHEMES
|
|
682
|
+
const counts = measurement.glyphCounts
|
|
683
|
+
// The font's context once Canvas is asked, under the counting spacing from then on, so
|
|
684
|
+
// it changes its spacing once, and not at all for a segment whose prefixes are counted.
|
|
685
|
+
let context = null
|
|
686
|
+
let extras = noExtraSpacings
|
|
598
687
|
try {
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
688
|
+
let before = 0
|
|
689
|
+
for (let i = 0, start = 0; i < count; start = ends[i++] ) {
|
|
690
|
+
const text = seg.slice(prefixes ? 0 : start, ends[i])
|
|
691
|
+
let glyphs = counts.get(text)
|
|
692
|
+
if (glyphs === undefined) {
|
|
693
|
+
if (context === null) {
|
|
694
|
+
context = getContext(measurement)
|
|
695
|
+
context.letterSpacing = `${GLYPH_COUNT_SPACING}px`
|
|
696
|
+
}
|
|
697
|
+
glyphs = Math.round(context.measureText(text).width / GLYPH_COUNT_SPACING)
|
|
698
|
+
counts.set(text, glyphs)
|
|
699
|
+
}
|
|
700
|
+
const extra = glyphs - (prefixes ? before : 0) - 1
|
|
701
|
+
before = glyphs
|
|
702
|
+
if (extra === 0 && extras.length === 0) continue
|
|
703
|
+
if (extras.length === 0) extras = zeros(count)
|
|
704
|
+
extras[i] = extra
|
|
705
|
+
}
|
|
603
706
|
} finally {
|
|
604
|
-
context.letterSpacing =
|
|
707
|
+
if (context !== null) context.letterSpacing = measurement.letterSpacing
|
|
605
708
|
}
|
|
709
|
+
return extras
|
|
606
710
|
}
|
|
607
711
|
|
|
608
712
|
// The lookup internalizes seg, and a string V8 hashes for the first time there gets a one-byte
|
|
@@ -622,23 +726,37 @@ export function getFollowingSpaceMetrics(seg , measurement
|
|
|
622
726
|
}
|
|
623
727
|
|
|
624
728
|
function addMetrics(cache , seg , text , measurement ) {
|
|
625
|
-
const metrics = { width: getContext(measurement).measureText(text).width, emojiCount: -1, fit: null, spaceKerning: null }
|
|
729
|
+
const metrics = { width: getContext(measurement).measureText(text).width, emojiCount: -1, fit: null, spaceKerning: null, extraSpacings: null }
|
|
626
730
|
cache.set(seg, metrics)
|
|
731
|
+
measurement.state.segments++
|
|
732
|
+
if (!measurement.measuredKana && kanaRe.test(seg)) measurement.measuredKana = true
|
|
627
733
|
return metrics
|
|
628
734
|
}
|
|
629
735
|
|
|
736
|
+
// A character of the Hiragana and Katakana blocks, U+3041 to U+30FF, two of them in a row, and
|
|
737
|
+
// how many the blocks hold.
|
|
738
|
+
const kanaRe = /[\u3041-\u30FF]/
|
|
739
|
+
export const kanaPairRe = /[\u3041-\u30FF]{2}/
|
|
740
|
+
const KANA_COUNT = 0x30ff - 0x3041 + 1
|
|
741
|
+
|
|
630
742
|
// The kerning of every segment that takes none.
|
|
631
743
|
export const noSpaceKerning = { after: 0, before: 0 }
|
|
632
744
|
|
|
745
|
+
// Whether a character is a kana or an ideograph, which Canvas shapes as a word of its own: it
|
|
746
|
+
// cuts a string before one that follows another, and keeps with a letter the marks and CJK
|
|
747
|
+
// punctuation after it (NextWordEndIndex, plain_text_node.cc:92-153, over
|
|
748
|
+
// kIsCjkIdeographOrSymbolRanges, character_property_data.h:40-80, of which these are the
|
|
749
|
+
// letters).
|
|
750
|
+
function isCanvasWordLetter(code ) {
|
|
751
|
+
return (code >= 0x3041 && code <= 0x3096) || (code >= 0x30a1 && code <= 0x30fa) ||
|
|
752
|
+
(code >= 0x3400 && code <= 0x9fff) || (code >= 0xf900 && code <= 0xfaff)
|
|
753
|
+
}
|
|
754
|
+
|
|
633
755
|
// Whether a character takes no kerning with a space, and Canvas isn't asked.
|
|
634
756
|
function takesNoSpaceKerning(code ) {
|
|
635
|
-
// A combining mark or half of a surrogate pair is part of a longer cluster.
|
|
636
|
-
|
|
637
|
-
|
|
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) ||
|
|
757
|
+
// A combining mark or half of a surrogate pair is part of a longer cluster. Canvas shows no
|
|
758
|
+
// kerning beside a letter it shapes as a word of its own.
|
|
759
|
+
return hasProperty(code, MARK) || (code & 0xf800) === 0xd800 || isCanvasWordLetter(code) ||
|
|
642
760
|
// Premise: no font kerns a Hangul syllable with the space (RESEARCH.md, Kerning At Line Edges).
|
|
643
761
|
(code >= 0xac00 && code <= 0xd7a3)
|
|
644
762
|
}
|
|
@@ -686,7 +804,7 @@ function getCharacterSpaceKerning(code , spaceFirst , measurement
|
|
|
686
804
|
}
|
|
687
805
|
|
|
688
806
|
// The kerning Blink's layout gives a text segment's edges with a U+0020 beside them
|
|
689
|
-
// (EngineProfile.
|
|
807
|
+
// (EngineProfile.kernsAcrossCanvasWords), read from Canvas with U+2028 for the space: Blink
|
|
690
808
|
// draws U+2028 with the space glyph (HarfBuzzGetGlyph, harfbuzz_face.cc:103-113) and its Canvas
|
|
691
809
|
// doesn't cut there. Premise: the segment's last and first character stand for the word, past
|
|
692
810
|
// default ignorables, which HarfBuzz's lookups pass over, and a first character with a combining
|
|
@@ -707,6 +825,138 @@ export function getSpaceWidth(measurement ) {
|
|
|
707
825
|
return measurement.spaceWidth ??= getSegmentMetrics(' ', measurement).width
|
|
708
826
|
}
|
|
709
827
|
|
|
828
|
+
// Twelve kana in a row, nine of whose eleven pairs or more kern in each Japanese face measured
|
|
829
|
+
// that kerns kana at all (getFontKanaKerning).
|
|
830
|
+
const KANA_PROBE = '\u30D7\u30C0\u30B0\u30BF\u30CE\u30E0\u30D6\u30A4\u30E1\u3048\u305A\u307A'
|
|
831
|
+
|
|
832
|
+
// The font's context for kana: Canvas shapes a string whole, as Blink's layout shapes a run,
|
|
833
|
+
// under text-rendering: optimizeLegibility where the lookups of the first font with a space
|
|
834
|
+
// cover the space glyph (FontFallbackList::ComputeCanShapeWordByWord, font_fallback_list.cc:264-277),
|
|
835
|
+
// as those of the Japanese fonts of macOS do. It is a context of its own: the first measures
|
|
836
|
+
// everything else as it did, and a kana alone measures the same on both. It is set to the
|
|
837
|
+
// list the font's kana are asked in (getFontKanaKerning), and isn't asked whether it takes the
|
|
838
|
+
// list (contextTakesFont): the font's own list is one the first context, made the same way,
|
|
839
|
+
// took, and where a shorter one is no font string, as a list cut inside a calc() size, the
|
|
840
|
+
// context keeps the list before it and reads what it read under that one, so the search goes
|
|
841
|
+
// on to the next list or ends on the font's own.
|
|
842
|
+
function getWholeRunContext(measurement ) {
|
|
843
|
+
const state = measurement.state
|
|
844
|
+
if (state.wholeRunContext === null) {
|
|
845
|
+
state.wholeRunContext = createContext(state.language)
|
|
846
|
+
state.wholeRunContext.textRendering = 'optimizeLegibility'
|
|
847
|
+
}
|
|
848
|
+
if (state.wholeRunFont !== measurement.kanaFont) state.wholeRunContext.font = state.wholeRunFont = measurement.kanaFont
|
|
849
|
+
return state.wholeRunContext
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// Whether two measurements of one string are of the same glyphs at the same places: as wide,
|
|
853
|
+
// with the same ink edges, to float32 rounding (getKanaKerning has the bound).
|
|
854
|
+
function sameInk(a , b ) {
|
|
855
|
+
const bound = a.width / 0x400000
|
|
856
|
+
return Math.abs(a.width - b.width) <= bound && Math.abs(a.actualBoundingBoxLeft - b.actualBoundingBoxLeft) <= bound &&
|
|
857
|
+
Math.abs(a.actualBoundingBoxRight - b.actualBoundingBoxRight) <= bound
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// What is kept of the font's kerning between kana, or null for a font that shows none: one in
|
|
861
|
+
// which KANA_PROBE, shaped whole, is as wide with kerning off (getFontSpaceKerning has what
|
|
862
|
+
// `fontKerning` turns off). Canvas shapes a string whole by the list's first font alone, where
|
|
863
|
+
// Blink's layout shapes the kana of a run together whatever font leads the list
|
|
864
|
+
// (InlineNode::ShapeText, inline_node.cc:1551-1718), so a list that shows no kerning is asked
|
|
865
|
+
// again without its first family, then without its first two, and so on: once the first
|
|
866
|
+
// font's lookups cover the space glyph, as the kana's own font's do, Canvas shapes the probe
|
|
867
|
+
// whole. A shorter list stands for the font only where the same font draws its kana, which
|
|
868
|
+
// two answers tell. It draws the probe with the same glyphs, as wide and with the same ink
|
|
869
|
+
// edges with kerning off: the first that doesn't ends the search, since a family left out
|
|
870
|
+
// drew the kana. And the kerning Canvas does show under the font's own list, a kana's with a
|
|
871
|
+
// U+3002 it keeps in the kana's word (NextWordEndIndex, plain_text_node.cc:129-153), is the
|
|
872
|
+
// shorter list's and isn't nothing: Hiragino Sans GB draws Hiragino Sans's kana and kerns
|
|
873
|
+
// none. A font that shows no kerning under any list stays as wide as Canvas measures its kana
|
|
874
|
+
// apart. Asked once per font, with two measurements for each list tried and three more for
|
|
875
|
+
// one that shows kerning. Premises: a font that kerns kana kerns one of the probe's pairs
|
|
876
|
+
// (RESEARCH.md, Kerning At Line Edges, has the fonts measured), and one of its hiragana with
|
|
877
|
+
// U+3002 (ENGINE_FOLLOWUPS.md, Kerning between kana).
|
|
878
|
+
export function getFontKanaKerning(measurement ) {
|
|
879
|
+
if (measurement.kanaKerning === undefined) {
|
|
880
|
+
measurement.kanaKerning = null
|
|
881
|
+
const font = measurement.canvasFont
|
|
882
|
+
const size = fontSizeRe.exec(font)
|
|
883
|
+
const start = size === null ? 0 : size.index + size[0].length
|
|
884
|
+
const families = size === null ? null : font.slice(start).match(familyRe)
|
|
885
|
+
const lists = families === null ? 1 : families.length
|
|
886
|
+
let own = null
|
|
887
|
+
for (let i = 0; i < lists; i++) {
|
|
888
|
+
const list = i === 0 ? font : font.slice(0, start) + families .slice(i).join(',')
|
|
889
|
+
measurement.kanaFont = list
|
|
890
|
+
const context = getWholeRunContext(measurement)
|
|
891
|
+
const kerned = context.measureText(KANA_PROBE).width
|
|
892
|
+
context.fontKerning = 'none'
|
|
893
|
+
const unkerned = context.measureText(KANA_PROBE)
|
|
894
|
+
context.fontKerning = 'auto'
|
|
895
|
+
if (own === null) own = unkerned
|
|
896
|
+
else if (!sameInk(own, unkerned)) break
|
|
897
|
+
if (kerned === unkerned.width) continue
|
|
898
|
+
if (i > 0) {
|
|
899
|
+
// Each hiragana letter before a U+3002: Canvas shapes each pair as one word under any list.
|
|
900
|
+
let pairs = ''
|
|
901
|
+
for (let code = 0x3041; code <= 0x3096; code++) pairs += String.fromCharCode(code, 0x3002)
|
|
902
|
+
const shorter = context.measureText(pairs).width
|
|
903
|
+
measurement.kanaFont = font
|
|
904
|
+
const ownContext = getWholeRunContext(measurement)
|
|
905
|
+
const ownKerned = ownContext.measureText(pairs).width
|
|
906
|
+
ownContext.fontKerning = 'none'
|
|
907
|
+
const ownUnkerned = ownContext.measureText(pairs).width
|
|
908
|
+
ownContext.fontKerning = 'auto'
|
|
909
|
+
if (ownKerned === ownUnkerned || Math.abs(shorter - ownKerned) > ownKerned / 0x400000) break
|
|
910
|
+
measurement.kanaFont = list
|
|
911
|
+
}
|
|
912
|
+
measurement.kanaKerning = new Float64Array(KANA_COUNT * KANA_COUNT).fill(NaN)
|
|
913
|
+
break
|
|
914
|
+
}
|
|
915
|
+
if (measurement.kanaKerning === null) measurement.kanaFont = font
|
|
916
|
+
}
|
|
917
|
+
return measurement.kanaKerning
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
// The kerning Blink's layout gives text[at - 1] and text[at] that no measured width holds, or
|
|
921
|
+
// 0: that of two characters of the kana blocks that were measured apart. `start` is where the
|
|
922
|
+
// segment that holds text[at] starts. Two segments are measured apart, so a segment's first
|
|
923
|
+
// character takes its kerning with the one before it. Inside a segment Canvas cuts before a
|
|
924
|
+
// kana letter where the word before it holds a letter already. A mark such as U+30FC stays in
|
|
925
|
+
// the word before it, and a word that marks or CJK punctuation began takes the first letter
|
|
926
|
+
// after them (NextWordEndIndex's has_any_script, plain_text_node.cc:129-153), so Canvas has
|
|
927
|
+
// measured those pairs together; a character below the CJK ranges is in a word of other
|
|
928
|
+
// scripts, which ends before the letter. Each pair is asked of Canvas once per font and kept
|
|
929
|
+
// in the font's table by its two characters, where a pair not asked about yet is NaN.
|
|
930
|
+
export function getKanaKerning(text , start , at , measurement , kernings ) {
|
|
931
|
+
const second = text.charCodeAt(at)
|
|
932
|
+
if (second < 0x3041 || second > 0x30ff) return 0
|
|
933
|
+
const first = text.charCodeAt(at - 1)
|
|
934
|
+
if (first < 0x3041 || first > 0x30ff) return 0
|
|
935
|
+
if (at > start) {
|
|
936
|
+
if (!isCanvasWordLetter(second)) return 0
|
|
937
|
+
// What Canvas's word held before the marks that end it.
|
|
938
|
+
let k = at - 1
|
|
939
|
+
let code = first
|
|
940
|
+
while (code >= 0x3041 && code <= 0x30ff && !isCanvasWordLetter(code)) {
|
|
941
|
+
if (k === start) return 0
|
|
942
|
+
code = text.charCodeAt(--k)
|
|
943
|
+
}
|
|
944
|
+
if (code >= 0x2e80 && !isCanvasWordLetter(code)) return 0
|
|
945
|
+
}
|
|
946
|
+
const index = (first - 0x3041) * KANA_COUNT + second - 0x3041
|
|
947
|
+
const kerning = kernings[index]
|
|
948
|
+
return Number.isNaN(kerning) ? kernings[index] = measureKanaKerning(text.slice(at - 1, at + 1), measurement) : kerning
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
// The kerning of two kana in a row: the two in one string on the context that shapes a string
|
|
952
|
+
// whole, less each alone.
|
|
953
|
+
function measureKanaKerning(pair , measurement ) {
|
|
954
|
+
const pairWidth = getWholeRunContext(measurement).measureText(pair).width
|
|
955
|
+
const kerning = pairWidth - getSegmentMetrics(pair[0] , measurement).width - getSegmentMetrics(pair[1] , measurement).width
|
|
956
|
+
// As for a character and a space, up to the pair's width / 2^22 is rounding.
|
|
957
|
+
return Math.abs(kerning) <= pairWidth / 0x400000 ? 0 : kerning
|
|
958
|
+
}
|
|
959
|
+
|
|
710
960
|
// A text's width in the font, less the emoji correction.
|
|
711
961
|
export function getTextWidth(text , measurement , emojiCorrection ) {
|
|
712
962
|
return getCorrectedSegmentWidth(text, getSegmentMetrics(text, measurement), measurement, emojiCorrection)
|
|
@@ -742,15 +992,14 @@ export function getEngineProfile() {
|
|
|
742
992
|
}
|
|
743
993
|
|
|
744
994
|
function buildEngineProfile() {
|
|
745
|
-
|
|
995
|
+
// A navigator can lack a userAgent, as React Native's does, where lib.dom's type says it is a
|
|
996
|
+
// string: such a runtime names no engine, like one with no navigator.
|
|
997
|
+
const ua = typeof navigator === 'undefined' ? '' : (navigator ).userAgent ?? ''
|
|
746
998
|
// Engines Pretext doesn't recognize take Blink's profile (RESEARCH.md, Decisions Log,
|
|
747
999
|
// 2026-09-26).
|
|
748
1000
|
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
1001
|
|
|
752
1002
|
return {
|
|
753
|
-
entryFitBasis: isDesktop && engine === 'blink' ? 'fresh' : isDesktop && engine === 'gecko' ? 'original' : 'disabled',
|
|
754
1003
|
lineBreakScan: engine,
|
|
755
1004
|
graphemeTable: engine === 'webkit' ? 'apple/char' : engine === 'gecko' ? 'gecko/char' : 'chromium/char',
|
|
756
1005
|
lineFitEpsilon: engine === 'webkit' ? 1 / 64 : 0.005,
|
|
@@ -758,12 +1007,12 @@ function buildEngineProfile() {
|
|
|
758
1007
|
prefixFitMinWidth: engine === 'webkit' ? 0 : 80,
|
|
759
1008
|
cutWordKeepsLigatures: engine === 'gecko',
|
|
760
1009
|
measureTextWithFollowingSpace: engine === 'webkit',
|
|
761
|
-
|
|
1010
|
+
kernsAcrossCanvasWords: engine === 'blink',
|
|
762
1011
|
letterSpaceDiscretionaryHyphen: engine !== 'blink',
|
|
763
1012
|
letterSpacingInAppUnits: engine === 'gecko',
|
|
764
1013
|
canvasLetterSpacingDropsLigatures: engine !== 'webkit',
|
|
765
1014
|
unspacedCursive: engine === 'blink' ? 'run' : engine === 'gecko' ? 'cluster' : 'none',
|
|
766
|
-
|
|
1015
|
+
letterSpacesGlyphs: engine === 'webkit',
|
|
767
1016
|
unfitHyphenRetreat: engine === 'blink' ? 'reduced-width' : engine === 'gecko' ? 'full-width' : 'full-width-or-first',
|
|
768
1017
|
hyphenFromPrimaryFont: engine !== 'gecko',
|
|
769
1018
|
letterSpaceTabStops: engine !== 'webkit',
|
|
@@ -772,15 +1021,15 @@ function buildEngineProfile() {
|
|
|
772
1021
|
tabsInAppUnits: engine === 'gecko',
|
|
773
1022
|
hangTabs: engine !== 'gecko',
|
|
774
1023
|
zeroWidthGlueTakesLine: engine !== 'gecko',
|
|
775
|
-
keepsLineStartPunctuation: engine === 'webkit',
|
|
776
1024
|
hidesControlCharacters: engine === 'gecko',
|
|
777
1025
|
hanKerning: engine === 'blink',
|
|
778
1026
|
hangsIdeographicSpace: engine !== 'webkit',
|
|
779
1027
|
laysOutUnderDefaultLocale: engine === 'blink',
|
|
780
|
-
namesGenericFamiliesByLanguage: engine === 'webkit',
|
|
1028
|
+
namesGenericFamiliesByLanguage: engine === 'webkit' && !/^(?:Linux|Win32|Haiku|$)/.test(navigator.platform),
|
|
781
1029
|
hardBreakItemRetreat: engine === 'blink' ? 'item' : engine === 'webkit' ? 'fit' : 'last-grapheme',
|
|
782
1030
|
paddedOpeningFit: engine === 'blink' ? 'start' : engine === 'webkit' ? 'placed' : 'both',
|
|
783
1031
|
emptyAtomicAlwaysFits: engine === 'gecko',
|
|
1032
|
+
zeroWidthSpaceStaysAfterAtomic: engine === 'gecko',
|
|
784
1033
|
hangsSpacesPerTextFrame: engine === 'gecko',
|
|
785
1034
|
testsHyphenBeforeAtomic: engine !== 'webkit',
|
|
786
1035
|
transformsSegmentBreaksAcrossItems: engine === 'blink',
|
|
@@ -802,31 +1051,71 @@ export function getEmojiCorrection(font , measurement )
|
|
|
802
1051
|
if (correction !== null) return correction
|
|
803
1052
|
|
|
804
1053
|
const fontSize = parseFontSize(font)
|
|
1054
|
+
// Canvas's width is there already when an earlier text asked and the page had no box to read.
|
|
1055
|
+
const askedBefore = measurement.emojiWidth !== 0
|
|
805
1056
|
const canvasW = measurement.emojiWidth = getContext(measurement).measureText('\u{1F600}').width
|
|
806
1057
|
correction = 0
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
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)
|
|
1058
|
+
if (canvasW > fontSize + 0.5 && typeof document !== 'undefined') {
|
|
1059
|
+
const domW = readEmojiWidth(font)
|
|
1060
|
+
// A document with no box for the span, as in an iframe that is display: none, has no
|
|
1061
|
+
// width to read. The text goes uncorrected, as in a worker, and nothing is kept: the next
|
|
1062
|
+
// text that may hold an emoji asks again.
|
|
1063
|
+
if (Number.isNaN(domW)) return 0
|
|
822
1064
|
if (canvasW - domW > 0.5) {
|
|
823
1065
|
correction = canvasW - domW
|
|
1066
|
+
// Advances cut from a segment before this reading hold emoji uncorrected.
|
|
1067
|
+
if (askedBefore) {
|
|
1068
|
+
for (const metrics of measurement.metrics.values()) metrics.fit = null
|
|
1069
|
+
for (const metrics of measurement.followingSpaceMetrics.values()) metrics.fit = null
|
|
1070
|
+
}
|
|
824
1071
|
}
|
|
825
1072
|
}
|
|
826
1073
|
measurement.emojiCorrection = correction
|
|
827
1074
|
return correction
|
|
828
1075
|
}
|
|
829
1076
|
|
|
1077
|
+
// The page's width of one emoji in the font, or NaN where the page has no box for it. The
|
|
1078
|
+
// span takes two things from the page, the font asked for and the page's language, which
|
|
1079
|
+
// picks fonts as it does for Canvas (-webkit-locale is where Blink and WebKit keep it, and
|
|
1080
|
+
// `all` resets it; Gecko's -x-lang is internal and `all` leaves it). Everything else is at
|
|
1081
|
+
// its initial value, and important, so that nothing the page declares reaches the span,
|
|
1082
|
+
// inherited from <body> or matched by a rule: a letter-spacing, a text-indent or a padding
|
|
1083
|
+
// would be read as part of the emoji, and a vertical writing-mode gives a line's height.
|
|
1084
|
+
function readEmojiWidth(font ) {
|
|
1085
|
+
// The span goes in <body>, beside the page's text, and in <html> where that gives it no
|
|
1086
|
+
// box: document.body is null until the parser reaches <body>, and a <body> that is
|
|
1087
|
+
// display: none, as a page hidden until it is ready, has no boxes. A document whose root
|
|
1088
|
+
// isn't <html>, as an SVG file's, has neither a body nor a head, and lays out no HTML.
|
|
1089
|
+
// lib.dom's types leave the nulls out.
|
|
1090
|
+
const body = document.body
|
|
1091
|
+
if (body === null && (document.head ) === null) return NaN
|
|
1092
|
+
const span = document.createElement('span')
|
|
1093
|
+
span.style.cssText = 'all:initial!important;-webkit-locale:inherit!important;display:inline-block!important;visibility:hidden!important;position:absolute!important'
|
|
1094
|
+
span.style.setProperty('font', font, 'important')
|
|
1095
|
+
span.textContent = '\u{1F600}'
|
|
1096
|
+
let width = body === null ? NaN : readWidthIn(body, span)
|
|
1097
|
+
if (Number.isNaN(width)) width = readWidthIn(document.documentElement, span)
|
|
1098
|
+
return width
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
// The span's width under a parent. Its resolved width is its box's used width in the span's
|
|
1102
|
+
// own pixels, and `auto`, so NaN, where it has no box (nsComputedDOMStyle::DoGetWidth,
|
|
1103
|
+
// nsComputedDOMStyle.cpp:1803-1814; StyleExtractorCustom.h:570-576 in WebKit; Blink hands the
|
|
1104
|
+
// property the element's LayoutObject, css_computed_style_declaration.cc:381-383), but it is
|
|
1105
|
+
// written with six significant digits. Its rectangle has every digit, but is the box as
|
|
1106
|
+
// drawn: a transform or a zoom above the span scales it, and it is 0 with no box, which read
|
|
1107
|
+
// as an emoji 0px wide. So the rectangle's width is taken where it is the resolved one
|
|
1108
|
+
// within those digits, and the resolved one where something scales the span. The rectangle
|
|
1109
|
+
// is asked first: before the parser reaches <body>, Firefox makes the document's first boxes
|
|
1110
|
+
// for a rectangle and not for a resolved width, which is `auto` until then.
|
|
1111
|
+
function readWidthIn(parent , span ) {
|
|
1112
|
+
parent.appendChild(span)
|
|
1113
|
+
const drawn = span.getBoundingClientRect().width
|
|
1114
|
+
const width = parseFloat(getComputedStyle(span).width)
|
|
1115
|
+
parent.removeChild(span)
|
|
1116
|
+
return Math.abs(drawn - width) <= width * 1e-5 ? drawn : width
|
|
1117
|
+
}
|
|
1118
|
+
|
|
830
1119
|
// Canvas reports a width as a 32-bit float (CanvasRenderingContext2D.cpp:5277 in Firefox,
|
|
831
1120
|
// text_metrics.cc:179 in Chrome), so a few equal advances measure that many times one
|
|
832
1121
|
// only within that float's rounding, 2^-24 of the width each time one is rounded: 2e-6 px
|
|
@@ -911,20 +1200,16 @@ export function getSegmentFit(
|
|
|
911
1200
|
// When metrics measured seg together with one following U+0020, the width of
|
|
912
1201
|
// that space alone. The last grapheme then keeps its kerning with the space.
|
|
913
1202
|
followingSpaceWidth = null,
|
|
914
|
-
// Whether to find the segment's WebKit line-start prohibitions.
|
|
915
|
-
withLineStartProhibitions = false,
|
|
916
1203
|
) {
|
|
917
1204
|
if (metrics.fit !== null && metrics.fit.mode === mode) return metrics.fit
|
|
918
1205
|
const profile = getEngineProfile()
|
|
919
1206
|
const ends = new Int32Array(seg.length)
|
|
920
1207
|
const count = findGraphemeEnds(profile.graphemeTable, seg, 0, seg.length, ends)
|
|
921
|
-
if (count <= 1) return metrics.fit = { mode, advances: null, lineStartExtras: 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
|
-
}
|
|
1208
|
+
if (count <= 1) return metrics.fit = { mode, advances: null, lineStartExtras: null }
|
|
926
1209
|
let advances
|
|
927
1210
|
let lineStartExtras = null
|
|
1211
|
+
// Whether each advance is of a grapheme measured after the one before it alone.
|
|
1212
|
+
let pairs = false
|
|
928
1213
|
if (mode === 'reshaped-lines') {
|
|
929
1214
|
// Blink shapes a line of a cut word again where the cut falls between letters shaped
|
|
930
1215
|
// together (EngineProfile's cutWordFit). Premise: a line holds the letters whose width,
|
|
@@ -940,19 +1225,42 @@ export function getSegmentFit(
|
|
|
940
1225
|
advances = alone
|
|
941
1226
|
if (!addUpTo(alone, width)) {
|
|
942
1227
|
advances = measureFitAdvances(seg, ends, count, 'pair-context', metrics, measurement, emojiCorrection, followingSpaceWidth)
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
}
|
|
1228
|
+
pairs = count > MAX_PREFIX_FIT_GRAPHEMES || addUpTo(advances, width)
|
|
1229
|
+
if (!pairs) advances = measureFitAdvances(seg, ends, count, 'segment-prefixes', metrics, measurement, emojiCorrection, followingSpaceWidth)
|
|
946
1230
|
lineStartExtras = [0]
|
|
947
1231
|
for (let i = 1; i < count; i++) lineStartExtras.push(alone[i] - advances[i] )
|
|
948
1232
|
}
|
|
949
1233
|
} else {
|
|
950
1234
|
// Past MAX_PREFIX_FIT_GRAPHEMES, prefixes give way to pairs.
|
|
951
1235
|
const prefixes = mode === 'segment-prefixes' && count <= MAX_PREFIX_FIT_GRAPHEMES
|
|
952
|
-
|
|
1236
|
+
pairs = mode !== 'sum-graphemes' && !prefixes
|
|
1237
|
+
advances = measureFitAdvances(seg, ends, count, pairs ? 'pair-context' : mode, metrics, measurement, emojiCorrection, followingSpaceWidth)
|
|
953
1238
|
if (prefixes && profile.cutWordKeepsLigatures) countLigaturesOnFirstLetter(seg, ends, advances, measurement, emojiCorrection)
|
|
954
1239
|
}
|
|
955
|
-
|
|
1240
|
+
// Two kana that Canvas shapes apart inside the segment, as in a keep-all group, kern in
|
|
1241
|
+
// Blink's layout as two segments do, and a line cut between them is shaped again without
|
|
1242
|
+
// the kerning (addKanaKerning in prepare.ts has the rule): one that tightens the pair goes
|
|
1243
|
+
// on the second kana's advance, which gives it back where a line starts with it, and one
|
|
1244
|
+
// that widens it on the first's. Canvas cuts only before a kana letter, which starts a
|
|
1245
|
+
// grapheme. An advance measured after the grapheme before it alone is of a string that
|
|
1246
|
+
// starts at that grapheme, and Canvas's words are read from there: a mark that starts
|
|
1247
|
+
// it is in one word with the letter after it, whose advance has their kerning already.
|
|
1248
|
+
if (profile.kernsAcrossCanvasWords && measurement.kanaKerning !== null && kanaPairRe.test(seg)) {
|
|
1249
|
+
const kernings = getFontKanaKerning(measurement)
|
|
1250
|
+
if (kernings !== null) {
|
|
1251
|
+
for (let g = 1; g < count; g++) {
|
|
1252
|
+
const kerning = getKanaKerning(seg, pairs && g > 1 ? ends[g - 2] : 0, ends[g - 1] , measurement, kernings)
|
|
1253
|
+
if (kerning > 0) {
|
|
1254
|
+
advances[g - 1] = advances[g - 1] + kerning
|
|
1255
|
+
} else if (kerning < 0) {
|
|
1256
|
+
advances[g] = advances[g] + kerning
|
|
1257
|
+
lineStartExtras ??= zeros(count)
|
|
1258
|
+
lineStartExtras[g] = lineStartExtras[g] - kerning
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
return metrics.fit = { mode, advances, lineStartExtras }
|
|
956
1264
|
}
|
|
957
1265
|
|
|
958
1266
|
// Moves the advance of each later letter of a ligature onto the ligature's first letter, in a
|
|
@@ -1053,31 +1361,57 @@ function measureFitAdvances(
|
|
|
1053
1361
|
// prefixes and line-edge facts all come from that shaping. The context isn't touched
|
|
1054
1362
|
// here: text that is all in the font's caches measures nothing (getContext).
|
|
1055
1363
|
export function getFontMeasurement(font , language , letterSpaced ) {
|
|
1056
|
-
//
|
|
1057
|
-
// changes, start again with a new
|
|
1058
|
-
// alone would re-measure with fonts resolved
|
|
1364
|
+
// Measuring starts here, for a text and for each item of a rich-inline paragraph, with the
|
|
1365
|
+
// language the preparation resolved. After that language changes, start again with a new
|
|
1366
|
+
// context and empty caches; clearing the caches alone would re-measure with fonts resolved
|
|
1367
|
+
// under the old language.
|
|
1059
1368
|
if (measureState === null || measureState.language !== language) measureState = createMeasureState(language)
|
|
1060
1369
|
const state = measureState
|
|
1061
1370
|
const shaped = letterSpaced && state.shapesLetterSpaced
|
|
1062
1371
|
const fonts = shaped ? state.letterSpacedFonts : state.fonts
|
|
1372
|
+
// The first measurement after a lookup sets the font again, the same string too: Firefox's
|
|
1373
|
+
// context takes a face added to document.fonts only when its font is assigned, and the
|
|
1374
|
+
// question below leaves the context on a font of its own, whether it throws or not.
|
|
1375
|
+
state.font = ''
|
|
1063
1376
|
let measurement = fonts.get(font)
|
|
1064
1377
|
if (measurement === undefined) {
|
|
1065
1378
|
const canvasFont = state.genericFamilies === null ? font : getCanvasFont(font, state.genericFamilies)
|
|
1066
|
-
|
|
1379
|
+
if (!contextTakesFont(state.context, canvasFont)) throw new RangeError(`font must be a font string Canvas takes, a size in px then a family, such as 16px Inter, not "${font}"`)
|
|
1380
|
+
measurement = { state, canvasFont, letterSpacing: shaped ? LETTER_SPACED_SHAPING : '0px', metrics: new Map(), followingSpaceMetrics: new Map(), spaceKerning: undefined, measuredKana: false, kanaKerning: undefined, kanaFont: canvasFont, emojiCorrection: null, emojiWidth: 0, hyphenText: null, spaceWidth: null, hanKerning: undefined, ligaturePairs: new Map(), glyphCounts: new Map() }
|
|
1067
1381
|
fonts.set(font, measurement)
|
|
1068
1382
|
}
|
|
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
1383
|
return measurement
|
|
1073
1384
|
}
|
|
1074
1385
|
|
|
1386
|
+
// Whether a Canvas context takes a font string. A context ignores a string that isn't a CSS font
|
|
1387
|
+
// shorthand and keeps the font it had (the HTML standard's font setter): a family before its
|
|
1388
|
+
// size, a string with no family, `var(--font)`, or an empty string, which
|
|
1389
|
+
// getComputedStyle(element).font is where the element sets a longhand the shorthand can't hold.
|
|
1390
|
+
// Firefox's OffscreenCanvas also ignores a size relative to a font, such as 1.2em or 120%, which
|
|
1391
|
+
// it has no element to resolve against (SetFontInternalDisconnected,
|
|
1392
|
+
// CanvasRenderingContext2D.cpp:4478-4490), and Firefox's and Safari's a system font such as
|
|
1393
|
+
// `caption` (Firefox 157.0.1, Safari 27.0, 2026-10-11). Every width would then be measured in
|
|
1394
|
+
// whatever font the context held last, so Pretext refuses such a string rather than guess at it,
|
|
1395
|
+
// as it refuses a letter spacing that isn't finite (readLetterSpacing). The context is set to a
|
|
1396
|
+
// font of the question's, then to the caller's, and still reads the first only where the
|
|
1397
|
+
// caller's was ignored; a caller's font that reads back as the first is asked again after
|
|
1398
|
+
// another. A context that doesn't read the question's font back as written refuses nothing.
|
|
1399
|
+
// Only the context's `font` is written and read, no element and no style, and nothing is
|
|
1400
|
+
// measured. Asked once for each font measurement, never for a text.
|
|
1401
|
+
function contextTakesFont(context , font ) {
|
|
1402
|
+
context.font = '1px serif'
|
|
1403
|
+
context.font = font
|
|
1404
|
+
if (context.font !== '1px serif') return true
|
|
1405
|
+
context.font = '2px serif'
|
|
1406
|
+
context.font = font
|
|
1407
|
+
return context.font !== '2px serif'
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1075
1410
|
// The font's context, set to the font and its shaping. Every measurement in a font takes
|
|
1076
1411
|
// its context from here as it measures, so none reads a width under another font's
|
|
1077
1412
|
// setting, whatever was looked up or prepared in between, and text that is all cached,
|
|
1078
1413
|
// which measures nothing, sets nothing. What changes the context after that puts it back
|
|
1079
|
-
// before it returns (getHyphenText,
|
|
1080
|
-
// isLigature).
|
|
1414
|
+
// before it returns (getHyphenText, getExtraSpacings, getFontSpaceKerning, isLigature).
|
|
1081
1415
|
export function getContext(measurement ) {
|
|
1082
1416
|
const state = measurement.state
|
|
1083
1417
|
if (state.font !== measurement.canvasFont) state.context.font = state.font = measurement.canvasFont
|
|
@@ -1085,18 +1419,20 @@ export function getContext(measurement )
|
|
|
1085
1419
|
return state.context
|
|
1086
1420
|
}
|
|
1087
1421
|
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
}
|
|
1097
|
-
// A context's `lang` follows the page's, and preparation's can be setLocale()'s or
|
|
1098
|
-
// Blink's default locale instead.
|
|
1422
|
+
// A context's `lang` follows the page's, and preparation's can be setLocale()'s or Blink's
|
|
1423
|
+
// default locale instead.
|
|
1424
|
+
function createContext(language ) {
|
|
1425
|
+
// jsdom and happy-dom have a document whose canvas gives no 2D context: getContext() returns null there.
|
|
1426
|
+
let context = null
|
|
1427
|
+
if (typeof OffscreenCanvas !== 'undefined') context = new OffscreenCanvas(1, 1).getContext('2d')
|
|
1428
|
+
else if (typeof document !== 'undefined') context = document.createElement('canvas').getContext('2d')
|
|
1429
|
+
if (context === null) throw new Error('Text measurement requires OffscreenCanvas or a DOM canvas context.')
|
|
1099
1430
|
if (language !== null && 'lang' in context) context.lang = language
|
|
1431
|
+
return context
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
function createMeasureState(language ) {
|
|
1435
|
+
const context = createContext(language)
|
|
1100
1436
|
const profile = getEngineProfile()
|
|
1101
1437
|
const takesLetterSpacing = typeof context.letterSpacing === 'string'
|
|
1102
1438
|
return {
|
|
@@ -1104,15 +1440,20 @@ function createMeasureState(language ) {
|
|
|
1104
1440
|
context,
|
|
1105
1441
|
font: '',
|
|
1106
1442
|
letterSpacing: '0px',
|
|
1443
|
+
wholeRunContext: null,
|
|
1444
|
+
wholeRunFont: '',
|
|
1107
1445
|
genericFamilies: language !== null && profile.namesGenericFamiliesByLanguage ? getWebKitGenericFamilies(language, context) : null,
|
|
1108
1446
|
takesLetterSpacing,
|
|
1109
1447
|
shapesLetterSpaced: takesLetterSpacing && profile.canvasLetterSpacingDropsLigatures,
|
|
1110
1448
|
fonts: new Map(),
|
|
1111
1449
|
letterSpacedFonts: new Map(),
|
|
1450
|
+
segments: 0,
|
|
1112
1451
|
}
|
|
1113
1452
|
}
|
|
1114
1453
|
|
|
1115
1454
|
export function clearMeasurementCaches() {
|
|
1116
|
-
measureState
|
|
1117
|
-
measureState
|
|
1455
|
+
if (measureState === null) return
|
|
1456
|
+
measureState.fonts.clear()
|
|
1457
|
+
measureState.letterSpacedFonts.clear()
|
|
1458
|
+
measureState.segments = 0
|
|
1118
1459
|
}
|