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.
Files changed (45) hide show
  1. package/README.md +15 -9
  2. package/dist/agent/vendor/pretext/NOTICE +2 -2
  3. package/dist/agent/vendor/pretext/SOURCE.json +62 -78
  4. package/dist/agent/vendor/pretext/analysis.js +24 -6
  5. package/dist/agent/vendor/pretext/gecko-line-breaks.js +1 -1
  6. package/dist/agent/vendor/pretext/generated/engine-break-data.js +1 -1
  7. package/dist/agent/vendor/pretext/generated/webkit-generic-families.js +1 -1
  8. package/dist/agent/vendor/pretext/graphemes.js +1 -1
  9. package/dist/agent/vendor/pretext/han-kerning.js +1 -1
  10. package/dist/agent/vendor/pretext/layout.js +14 -11
  11. package/dist/agent/vendor/pretext/line-break.js +170 -195
  12. package/dist/agent/vendor/pretext/line-breaks.js +1 -7
  13. package/dist/agent/vendor/pretext/line-text.js +2 -2
  14. package/dist/agent/vendor/pretext/measurement.js +454 -113
  15. package/dist/agent/vendor/pretext/prepare.js +164 -244
  16. package/dist/agent/vendor/pretext/rich-inline.js +57 -37
  17. package/dist/agent/will.mjs +73 -0
  18. package/dist/kit/ledger/data.mjs +57 -4
  19. package/dist/kit/ledger/format.mjs +114 -47
  20. package/dist/kit/ledger/hash.mjs +43 -2
  21. package/dist/kit/ledger/history.mjs +6 -0
  22. package/dist/kit/ledger/inverse-source.mjs +15 -2
  23. package/dist/kit/ledger/journal-records.mjs +32 -9
  24. package/dist/kit/ledger/merge.mjs +3 -17
  25. package/dist/kit/ledger/text-merge.mjs +28 -2
  26. package/dist/kit/rapier-ledger.mjs +2 -2
  27. package/dist/kit/render-markdown.mjs +170 -12
  28. package/dist/kit/render-print.mjs +3 -3
  29. package/dist/kit/render-sanitize.mjs +66 -23
  30. package/dist/kit/render.mjs +10 -10
  31. package/dist/kit/wiki.mjs +34 -0
  32. package/dist/kit/will.d.mts +3 -0
  33. package/dist/layout/hyphenation.mjs +6 -4
  34. package/dist/layout/line-plan.mjs +96 -0
  35. package/dist/layout/model.mjs +2 -1
  36. package/dist/layout/paragraph-fit.mjs +4 -4
  37. package/dist/layout/prepared-paragraph.mjs +19 -2
  38. package/dist/layout/typography.mjs +57 -63
  39. package/dist/spec/document-fonts.mjs +1 -0
  40. package/dist/spec/document-settings.mjs +1 -0
  41. package/dist/spec/html-blocks.mjs +10 -7
  42. package/dist/spec/md-assets.mjs +52 -12
  43. package/package.json +1 -1
  44. package/style.css +33 -23
  45. package/dist/agent/vendor/pretext/entry-geometry.js +0 -183
@@ -1,6 +1,7 @@
1
- /*! Pretext Unreleased (bac1774c0) | MIT; BSD/Unicode/ICU notices in LICENSE | Copyright (c) 2026 Pretext contributors */
1
+ /*! Pretext 1.0.0 + main (0d102a5cd) | MIT; BSD/Unicode/ICU notices in LICENSE | Copyright (c) 2026 Pretext contributors */
2
2
  import {
3
3
  CONTROL,
4
+ DROPPED,
4
5
  HARD_BREAK,
5
6
  KIND_BITS,
6
7
  OBJECT,
@@ -17,7 +18,6 @@ import {
17
18
  } from './analysis.js'
18
19
 
19
20
  import { getEngineProfile } from './measurement.js'
20
- import { getFreshLineEnd, getSegmentEntryWidth, } from './entry-geometry.js'
21
21
 
22
22
  const BREAK_AFTER_KINDS = 1 << SPACE | 1 << ZERO_WIDTH_BREAK | 1 << SOFT_HYPHEN | 1 << PRESERVED_SPACE | 1 << TAB
23
23
 
@@ -25,8 +25,8 @@ const BREAK_AFTER_KINDS = 1 << SPACE | 1 << ZERO_WIDTH_BREAK | 1 << SOFT_HYPHEN
25
25
 
26
26
 
27
27
 
28
-
29
-
28
+
29
+
30
30
 
31
31
 
32
32
 
@@ -35,10 +35,6 @@ const BREAK_AFTER_KINDS = 1 << SPACE | 1 << ZERO_WIDTH_BREAK | 1 << SOFT_HYPHEN
35
35
 
36
36
 
37
37
 
38
-
39
-
40
-
41
-
42
38
 
43
39
 
44
40
 
@@ -97,7 +93,12 @@ const BREAK_AFTER_KINDS = 1 << SPACE | 1 << ZERO_WIDTH_BREAK | 1 << SOFT_HYPHEN
97
93
  // it (setEmptyObjectFacts in src/rich-inline.ts): the width of the white space that ends the
98
94
  // text before it as its last text frame's own, 0 where that text ends in other white space
99
95
  // and -1 where it ends in none, and 1 where a line with a break before the object goes back
100
- // to it, as what follows the object would stick out too.
96
+ // to it, as what follows the object would stick out too;
97
+ // - `hangEnds`: where the engine takes the hang out of each text frame's own width (EngineProfile,
98
+ // hangsSpacesPerTextFrame), 1 at a zero-width space that follows preserved spaces inside its own
99
+ // item: it ends the run of spaces that hang, which count in full once it follows them in their
100
+ // frame (BreakAndMeasureText, gfxTextRun.cpp:1152-1160), where one that starts an item is in the
101
+ // frame after theirs and the run goes on past it.
101
102
 
102
103
 
103
104
 
@@ -109,6 +110,7 @@ const BREAK_AFTER_KINDS = 1 << SPACE | 1 << ZERO_WIDTH_BREAK | 1 << SOFT_HYPHEN
109
110
 
110
111
 
111
112
 
113
+
112
114
 
113
115
 
114
116
 
@@ -129,19 +131,29 @@ function breaksAfterKind(kind ) {
129
131
  // paints none.
130
132
  export function isDiscretionaryLineEnd(
131
133
  segmentFlags ,
134
+ segmentCount ,
132
135
  endSegmentIndex ,
133
136
  endGraphemeIndex ,
134
137
  ) {
135
- return endGraphemeIndex === 0 && endSegmentIndex > 0 && endSegmentIndex < segmentFlags.length &&
138
+ return endGraphemeIndex === 0 && endSegmentIndex > 0 && endSegmentIndex < segmentCount &&
136
139
  (segmentFlags[endSegmentIndex - 1] & KIND_BITS) === SOFT_HYPHEN
137
140
  }
138
141
 
139
- // At a paragraph or hard-break start, ZWSP is real source: it establishes the
140
- // line and offers a break after it. UAX #14 forbids an ordinary break before
141
- // ZWSP. After a forced overflow break browsers can still give ZWSP its own line;
142
- // that start is consumed here.
143
- function consumesAtLineStart(kind , atChunkStart ) {
144
- return kind === SPACE || kind === SOFT_HYPHEN || (kind === ZERO_WIDTH_BREAK && !atChunkStart)
142
+ // What a line's start leaves out, by a segment's flags: a collapsible space, a soft hyphen, and a
143
+ // soft hyphen that is a zero-width break (DROPPED). A zero-width space stays, at a chunk's start
144
+ // and after a wrap alike: every engine lays it out as text of no width, never as white space.
145
+ // Blink trails only U+0020 and U+0009 past a line's end and skips only a collapsible space at its
146
+ // start (IsBreakableSpace, line_breaker.cc:186-188; HandleTrailingSpaces, :2426-2534; HandleText,
147
+ // :1336-1353, Chromium 153); WebKit's white-space items are spaces, tabs and line feeds, and a
148
+ // text run of any length is content (InlineItemsBuilder.cpp:54-73; Line::Run::isContentful,
149
+ // InlineLine.h:129); Gecko trims and skips the characters of IsTrimmableSpace alone
150
+ // (nsTextFrame.cpp:904-942, 10935-10951). So the line a zero-width space starts is a line, a line
151
+ // tall, though nothing else is on it: where nothing follows it, where a hard break does, and
152
+ // where the word after it doesn't fit beside it and goes down whole, as from any line that has a
153
+ // break (walkPreparedComplexLines has where a line ends before one).
154
+ function consumesAtLineStart(flags ) {
155
+ const kind = flags & KIND_BITS
156
+ return kind === SPACE || kind === SOFT_HYPHEN || (flags & DROPPED) !== 0
145
157
  }
146
158
 
147
159
  // A tab's advance from `position` on the line: to the next stop, or to the one after where
@@ -186,20 +198,6 @@ export function getItemTabAdvance(prepared , items
186
198
  return getTabAdvance(lineWidth - endEdge, stopAdvance, minimumAdvance, appUnits) + prepared.widths[index]
187
199
  }
188
200
 
189
- // Where a line that holds only an overflowing grapheme ends: after that grapheme and
190
- // the graphemes after it that can't start a line, up to `endGraphemeIndex`.
191
- function getOverflowingFirstGraphemeEnd(
192
- prepared ,
193
- segmentIndex ,
194
- graphemeIndex ,
195
- endGraphemeIndex ,
196
- ) {
197
- const prohibitions = prepared.lineStartProhibitions?.[segmentIndex] ?? null
198
- let end = graphemeIndex + 1
199
- while (prohibitions !== null && end < endGraphemeIndex && prohibitions[end] === 1) end++
200
- return end
201
- }
202
-
203
201
  function getTerminalLetterSpacing(
204
202
  prepared ,
205
203
  openingEdges ,
@@ -214,10 +212,11 @@ function getTerminalLetterSpacing(
214
212
 
215
213
  if (endGraphemeIndex > 0) return (segmentFlags[endSegmentIndex] & SPACED) !== 0 ? letterSpacing : 0
216
214
 
217
- if (isDiscretionaryLineEnd(segmentFlags, endSegmentIndex, endGraphemeIndex)) return 0
215
+ const segmentCount = prepared.widths.length
216
+ if (isDiscretionaryLineEnd(segmentFlags, segmentCount, endSegmentIndex, endGraphemeIndex)) return 0
218
217
  // A run of preserved spaces and tabs that hangs where the line wraps already
219
218
  // charged the gap after the glyph before it. Gecko doesn't hang tabs.
220
- if (endSegmentIndex > startSegmentIndex && endSegmentIndex < segmentFlags.length && (segmentFlags[endSegmentIndex] & KIND_BITS) !== HARD_BREAK &&
219
+ if (endSegmentIndex > startSegmentIndex && endSegmentIndex < segmentCount && (segmentFlags[endSegmentIndex] & KIND_BITS) !== HARD_BREAK &&
221
220
  (1 << (segmentFlags[endSegmentIndex - 1] & KIND_BITS) & hangingKinds) !== 0) return 0
222
221
 
223
222
  for (let i = endSegmentIndex - 1; i >= startSegmentIndex; i--) {
@@ -257,15 +256,15 @@ export function normalizePreparedLineStart(
257
256
  cursor ,
258
257
  ) {
259
258
  const { segmentFlags } = prepared
260
- const segmentCount = segmentFlags.length
259
+ const segmentCount = prepared.widths.length
261
260
  let segmentIndex = cursor.segmentIndex
262
261
  if (segmentIndex >= segmentCount) return false
263
262
  if (cursor.graphemeIndex > 0) return true
264
263
 
265
264
  let atChunkStart = segmentIndex === 0 || (segmentFlags[segmentIndex - 1] & KIND_BITS) === HARD_BREAK
266
265
  while (true) {
267
- const kind = segmentFlags[segmentIndex] & KIND_BITS
268
- if (kind === HARD_BREAK) {
266
+ const flags = segmentFlags[segmentIndex]
267
+ if ((flags & KIND_BITS) === HARD_BREAK) {
269
268
  if (atChunkStart) {
270
269
  cursor.segmentIndex = segmentIndex
271
270
  cursor.graphemeIndex = 0
@@ -275,7 +274,7 @@ export function normalizePreparedLineStart(
275
274
  cursor.segmentIndex = segmentIndex
276
275
  cursor.graphemeIndex = 0
277
276
  atChunkStart = true
278
- } else if (consumesAtLineStart(kind, atChunkStart)) {
277
+ } else if (consumesAtLineStart(flags)) {
279
278
  if (++segmentIndex >= segmentCount) return false
280
279
  } else {
281
280
  cursor.segmentIndex = segmentIndex
@@ -303,11 +302,10 @@ export function walkPreparedLinesRaw(
303
302
  // stepPreparedLineGeometryFromStart(), walked chat 6 to 32% slower in Chrome and
304
303
  // Firefox (RESEARCH.md, Keeping Work Bounded).
305
304
  const { segmentFlags } = prepared
306
- const segmentCount = segmentFlags.length
305
+ const segmentCount = prepared.widths.length
307
306
  while (true) {
308
307
  let startSegmentIndex = cursor.segmentIndex
309
- const atTextStart = startSegmentIndex === 0
310
- while (startSegmentIndex < segmentCount && consumesAtLineStart(segmentFlags[startSegmentIndex] & KIND_BITS, atTextStart)) {
308
+ while (startSegmentIndex < segmentCount && consumesAtLineStart(segmentFlags[startSegmentIndex] )) {
311
309
  startSegmentIndex++
312
310
  }
313
311
  if (startSegmentIndex >= segmentCount) return stats.lineCount
@@ -322,16 +320,16 @@ export function walkPreparedLinesRaw(
322
320
 
323
321
  // layout()'s count: the simple stepper's lines as one numeric loop, with no
324
322
  // cursor and no per-line call. Every segment boundary of a fast-path handle is
325
- // a break, so an overflowing space or ZWSP ends its line and any other segment
326
- // starts the next one. The full walker costs three to five times as much per
327
- // segment, so one walker for all text was rejected (RESEARCH.md, Decisions Log,
328
- // 2026-09-24).
323
+ // a break, so an overflowing space ends its line, which it hangs on, and any other
324
+ // segment starts the next one, a zero-width space too (stepPreparedSimpleLineGeometry).
325
+ // The full walker costs three to five times as much per segment, so one walker for
326
+ // all text was rejected (RESEARCH.md, Decisions Log, 2026-09-24).
329
327
  export function countPreparedLines(prepared , maxWidth ) {
330
328
  // The loop takes no overflow trims, which the stepper takes for a line's first segment.
331
329
  if (!prepared.simpleLineWalkFastPath || prepared.overflowLineEndTrims !== null) {
332
330
  return prepared.simpleLineCountFastPath ? countSteppedLines(prepared, maxWidth) : walkPreparedLinesRaw(prepared, maxWidth)
333
331
  }
334
- const { widths, segmentFlags, breakableFitAdvances, entryGeometry, lineStartProhibitions, breakableLineStartExtras, lineStartExtras, lineEndTrims } = prepared
332
+ const { widths, segmentFlags, breakableFitAdvances, breakableLineStartExtras, lineStartExtras, lineEndTrims } = prepared
335
333
  const fitLimit = Math.max(0, maxWidth) + getEngineProfile().lineFitEpsilon
336
334
  const segmentCount = widths.length
337
335
  let count = 0
@@ -341,7 +339,6 @@ export function countPreparedLines(prepared , maxWidth )
341
339
  let lineW = 0
342
340
  let hasContent = false
343
341
 
344
- // A ZWSP at the start of the text starts the first line.
345
342
  for (let i = 0; i < segmentCount; i++) {
346
343
  const kind = segmentFlags[i] & KIND_BITS
347
344
  const w = widths[i]
@@ -356,8 +353,8 @@ export function countPreparedLines(prepared , maxWidth )
356
353
  count++
357
354
  lineW = 0
358
355
  hasContent = false
359
- if (kind !== TEXT) continue
360
- } else if (kind === SPACE || (kind === ZERO_WIDTH_BREAK && i > 0)) {
356
+ if (kind === SPACE) continue
357
+ } else if (kind === SPACE) {
361
358
  continue
362
359
  }
363
360
 
@@ -368,37 +365,12 @@ export function countPreparedLines(prepared , maxWidth )
368
365
  hasContent = true
369
366
  continue
370
367
  }
371
- // An overflowing breakable segment fills lines grapheme by grapheme. A line
372
- // holding only an overflowing grapheme keeps the graphemes after it that
373
- // can't start a line. A line that starts inside it where it has fresh-line
374
- // geometry takes the tail or its fresh prefixes.
375
- const prohibitions = lineStartProhibitions?.[i] ?? null
376
- const entry = entryGeometry === null ? null : entryGeometry[i]
368
+ // An overflowing breakable segment fills lines grapheme by grapheme.
377
369
  const startExtras = breakableLineStartExtras?.[i] ?? null
378
370
  let g = 0
379
371
  while (g < advances.length) {
380
- if (g > 0 && entry !== null && entry.entries[g] !== null) {
381
- const end = getFreshLineEnd(entry, g, advances.length, fitLimit)
382
- if (end > advances.length) {
383
- lineW += getSegmentEntryWidth(entry, g, advances.length)
384
- hasContent = true
385
- break
386
- }
387
- count++
388
- g = end
389
- continue
390
- }
391
372
  if (startExtras !== null) lineW += startExtras[g]
392
373
  lineW += advances[g++]
393
- if (prohibitions !== null && lineW > fitLimit) {
394
- const kept = g
395
- while (g < advances.length && prohibitions[g] === 1) lineW += advances[g++]
396
- if (g > kept) {
397
- count++
398
- lineW = 0
399
- continue
400
- }
401
- }
402
374
  while (g < advances.length && lineW + advances[g] <= fitLimit) lineW += advances[g++]
403
375
  if (g < advances.length) {
404
376
  count++
@@ -421,13 +393,14 @@ export function countPreparedLines(prepared , maxWidth )
421
393
  // its in the last bits (RESEARCH.md, Keeping Work Bounded).
422
394
  function countSteppedLines(prepared , maxWidth ) {
423
395
  const { segmentFlags } = prepared
396
+ const segmentCount = prepared.widths.length
424
397
  const cursor = { segmentIndex: 0, graphemeIndex: 0 }
425
398
  let count = 0
426
399
  while (normalizePreparedLineStart(prepared, cursor)) {
427
400
  const startSegmentIndex = cursor.segmentIndex
428
401
  const startGraphemeIndex = cursor.graphemeIndex
429
402
  stepPreparedSimpleLineGeometry(prepared, cursor, maxWidth)
430
- if (cursor.graphemeIndex === 0 && cursor.segmentIndex < segmentFlags.length && (segmentFlags[cursor.segmentIndex] & UNBROKEN) !== 0) {
403
+ if (cursor.graphemeIndex === 0 && cursor.segmentIndex < segmentCount && (segmentFlags[cursor.segmentIndex] & UNBROKEN) !== 0) {
431
404
  cursor.segmentIndex = startSegmentIndex
432
405
  cursor.graphemeIndex = startGraphemeIndex
433
406
  walkPreparedComplexLines(prepared, cursor, maxWidth, undefined, null, true)
@@ -450,30 +423,34 @@ function countSteppedLines(prepared , maxWidth )
450
423
  // spaces and tabs (nsLineBreaker.cpp:323, :586) and doesn't hang a tab, so where the white space
451
424
  // runs into a tab, Firefox moves all of it to the next line with the tab. The walker asks once
452
425
  // per run, so a long run costs one pass.
453
- function staysAfterObject(segmentFlags , start , hangingKinds ) {
426
+ function staysAfterObject(segmentFlags , segmentCount , start , hangingKinds ) {
454
427
  let after = start + 1
455
- while (after < segmentFlags.length && (1 << (segmentFlags[after] & KIND_BITS) & hangingKinds) !== 0) after++
456
- return after === segmentFlags.length || (segmentFlags[after] & KIND_BITS) !== TAB
428
+ while (after < segmentCount && (1 << (segmentFlags[after] & KIND_BITS) & hangingKinds) !== 0) after++
429
+ return after === segmentCount || (segmentFlags[after] & KIND_BITS) !== TAB
457
430
  }
458
431
 
459
432
  // Whether a line that would end at the break before `breakSegmentIndex`, painting
460
433
  // `breakWidth`, returns to an earlier opportunity: where it ends at a selected
461
434
  // discretionary hyphen that doesn't fit. A return needs an overflow that isolated
462
435
  // widths can show: the soft hyphens on the line may measure narrower joined than apart
463
- // by less than the overflow.
436
+ // by less than the overflow. Those are the soft hyphens the line passes, whose text the
437
+ // engine shapes whole, and with `endKeepsJoin` the one the line ends at too
438
+ // (EngineProfile, unfitHyphenRetreat).
464
439
  function returnsFromUnfitHyphen(
465
440
  prepared ,
466
441
  lineStartSegmentIndex ,
467
442
  breakSegmentIndex ,
468
443
  breakWidth ,
469
444
  fitLimit ,
445
+ endKeepsJoin ,
470
446
  ) {
471
447
  const { discretionaryHyphenContexts, segmentFlags } = prepared
472
448
  const softHyphenIndex = breakSegmentIndex - 1
473
449
  if (softHyphenIndex < lineStartSegmentIndex || (segmentFlags[softHyphenIndex] & KIND_BITS) !== SOFT_HYPHEN || breakWidth <= fitLimit) return false
474
450
  const overflow = breakWidth - fitLimit
475
451
  let narrowing = 0
476
- if (discretionaryHyphenContexts !== null) for (let i = lineStartSegmentIndex; i <= softHyphenIndex; i++) narrowing += discretionaryHyphenContexts[i]
452
+ const last = endKeepsJoin ? softHyphenIndex : softHyphenIndex - 1
453
+ if (discretionaryHyphenContexts !== null) for (let i = lineStartSegmentIndex; i <= last; i++) narrowing += discretionaryHyphenContexts[i]
477
454
  return narrowing < overflow
478
455
  }
479
456
 
@@ -497,7 +474,6 @@ function walkPreparedComplexLines(
497
474
  widths,
498
475
  segmentFlags,
499
476
  breakableFitAdvances,
500
- entryGeometry,
501
477
  discretionaryHyphenWidth,
502
478
  letterSpacing,
503
479
  lineStartExtras,
@@ -506,7 +482,7 @@ function walkPreparedComplexLines(
506
482
  tabStopAdvance,
507
483
  minimumTabAdvance,
508
484
  } = prepared
509
- const segmentCount = segmentFlags.length
485
+ const segmentCount = widths.length
510
486
  const items = prepared.items
511
487
  const hyphenWidths = items === undefined ? null : items.hyphenWidths
512
488
  const hyphenRooms = items === undefined ? null : items.hyphenRooms
@@ -524,8 +500,14 @@ function walkPreparedComplexLines(
524
500
  // In Gecko a text frame's width leaves out its trailing spaces that overflow the line and
525
501
  // keeps those that fit, whatever follows the frame (EngineProfile, hangsSpacesPerTextFrame),
526
502
  // so in a rich-inline paragraph the run of preserved spaces that hang goes on, with what
527
- // overflows, past a segment that takes no room: an object of width 0, a soft hyphen or a
528
- // zero-width break, which the Gecko scan makes of a soft hyphen after white space.
503
+ // overflows, past a segment that takes no room: an object of width 0, a soft hyphen, the
504
+ // zero-width break the Gecko scan makes of a soft hyphen after white space (DROPPED), and a
505
+ // zero-width space that starts an item, where the content before the run fits. The frame
506
+ // after the spaces' own then starts at the line's end, where a zero-width space fits; where
507
+ // that content is past the line's end, as a box wider than the line, the zero-width space
508
+ // starts the next line, as a break comes before it after white space
509
+ // (nsLineBreaker.cpp:321-330), and so does one inside the spaces' own item, which ends their
510
+ // run (ParagraphSegmentData, hangEnds).
529
511
  const hangGoesOnPastEmpty = items !== undefined && engineProfile.hangsSpacesPerTextFrame
530
512
  const testsHyphenBeforeAtomic = engineProfile.testsHyphenBeforeAtomic
531
513
  const zeroWidthGlueTakesLine = engineProfile.zeroWidthGlueTakesLine
@@ -546,6 +528,9 @@ function walkPreparedComplexLines(
546
528
  // the soft hyphen the line reaches before any of its opportunities has fit, since one
547
529
  // without a hyphen fits where the text before it did.
548
530
  const keepsFirstBreak = retreatsFromUnfitHyphen && engineProfile.unfitHyphenRetreat === 'full-width-or-first'
531
+ // Gecko's line keeps what the letters on the two sides of the soft hyphen it ends at
532
+ // measure narrower joined. Blink shapes the line's end again, without it.
533
+ const hyphenEndKeepsJoin = engineProfile.unfitHyphenRetreat === 'full-width'
549
534
 
550
535
  let lastLineWidth = null
551
536
  while (true) {
@@ -679,11 +664,11 @@ function walkPreparedComplexLines(
679
664
  }
680
665
 
681
666
  // A line that ends after a whole segment charges its advance and the letter
682
- // spacing gap after it. Spaces and zero-width breaks hang, and zero-width
683
- // text owns no gap, though NEL does. Text that takes no letter spacing, such
684
- // as zero-width glue, fits like the line that still ends with the gap before it.
667
+ // spacing gap after it. Spaces hang, and zero-width text owns no gap, though NEL
668
+ // does. Text that takes no letter spacing, such as zero-width glue or a zero-width
669
+ // space, fits like the line that still ends with the gap before it.
685
670
  let fitAdvance = 0
686
- if (letterSpacing !== 0 && !spaced && !breakAfter && kind !== CONTROL) {
671
+ if (letterSpacing !== 0 && !spaced && (!breakAfter || (kind === ZERO_WIDTH_BREAK && (flags & DROPPED) === 0)) && kind !== CONTROL) {
687
672
  fitAdvance = gap + w
688
673
  } else if (breakAfter ? kind === TAB : w !== 0 || kind === CONTROL) {
689
674
  const contribution = w + (spaced ? letterSpacing : 0)
@@ -694,13 +679,14 @@ function walkPreparedComplexLines(
694
679
  // text 2.8%, and Firefox 156 read within 1.5% either way (RESEARCH.md, Keeping Work
695
680
  // Bounded, Work Done Only Where A Rule Applies; Decisions Log, 2026-10-08).
696
681
  let hangs = (1 << kind & hangingKinds) !== 0
697
- if (hangGoesOnPastEmpty && kind === ZERO_WIDTH_BREAK && hangEndSegmentIndex === i) hangs = true
682
+ if (hangGoesOnPastEmpty && kind === ZERO_WIDTH_BREAK && hangEndSegmentIndex === i &&
683
+ ((flags & DROPPED) !== 0 || (hangStartWidth - hangEdgesWidth <= fitLimit && (items.hangEnds === null || items.hangEnds[i] === 0)))) hangs = true
698
684
  if (hangs) {
699
685
  if (hangEndSegmentIndex !== i) {
700
686
  hangStartWidth = lineW - lineEndTrimmed + leadingSpacing
701
687
  hangStays = false
702
688
  if (items !== undefined && i > lineStartSegmentIndex && (segmentFlags[i - 1] & KIND_BITS) === OBJECT) {
703
- hangStays = staysAfterObject(segmentFlags, i, hangingKinds)
689
+ hangStays = staysAfterObject(segmentFlags, segmentCount, i, hangingKinds)
704
690
  // A run that goes down with its tab leaves from the break after the object.
705
691
  if (!hangStays) {
706
692
  pendingBreakSegmentIndex = i
@@ -794,8 +780,9 @@ function walkPreparedComplexLines(
794
780
  }
795
781
  // No engine lets content make a line narrower (findWholeLine in src/rich-inline.ts),
796
782
  // so a segment of less than no advance, as a letter under a letter spacing more
797
- // negative than it is wide, doesn't bring a line that overflows back.
798
- const narrowsOverflow = fitAdvance < 0 && lineW - lineEndTrimmed > fitLimit
783
+ // negative than it is wide, doesn't bring a line that overflows back. A zero-width
784
+ // space has no advance: what it fits with is the gap of the letter before it.
785
+ const narrowsOverflow = fitAdvance < 0 && kind !== ZERO_WIDTH_BREAK && lineW - lineEndTrimmed > fitLimit
799
786
  overflows = (overflows || narrowsOverflow) && !(hangs && hangStays) && !placesEmptyObject
800
787
  if (hangGoesOnPastEmpty && !overflows && hangEndSegmentIndex === i && kind === OBJECT && w === 0) {
801
788
  hangEndSegmentIndex = i + 1
@@ -804,23 +791,63 @@ function walkPreparedComplexLines(
804
791
  }
805
792
  if (overflows) {
806
793
  // A break segment hangs with the gap before it, after the content before
807
- // it, which fits without its line-end trim. A collapsible space or ZWSP
808
- // hangs even after overflowing content that started the line, as the
809
- // simple stepper does; a preserved space there starts the next line. A
810
- // tab that doesn't hang goes to the next line, as text does. A ZWSP also
811
- // hangs right after an object, as after the start edge of its item's opening,
812
- // which the line took by the part it fits; after a chip wider than the line
813
- // that is Firefox's line, where Chrome and Safari give a ZWSP that ends the
814
- // paragraph a line of its own (ENGINE_FOLLOWUPS.md, Rich-inline item edges).
794
+ // it, which fits without its line-end trim. A collapsible space hangs even
795
+ // after overflowing content that started the line, as the simple stepper
796
+ // does, and so does the zero-width break Firefox drops (DROPPED), there and
797
+ // after the start edge of its item's opening; a preserved space there starts
798
+ // the next line. A tab that doesn't hang goes to the next line, as text does.
799
+ //
800
+ // A zero-width space is content of no width, so it stays where the content
801
+ // before it fits, with the white space between them, which then ends no line,
802
+ // and with the gap after its last letter. Else the line ends before it and
803
+ // it starts the next (consumesAtLineStart). Blink sends text that follows
804
+ // content past the line's end to HandleOverflow, which ends the line at a
805
+ // break before that text (HandleText, line_breaker.cc:1355-1372; :4079-4305),
806
+ // and a line that trails spaces ends at the first character that is neither
807
+ // U+0020 nor U+0009 (HandleTrailingSpaces, :2447-2456, 2518-2523). WebKit fits
808
+ // it against what is left of the line, which isn't clamped at 0 (availableWidth,
809
+ // InlineLineBuilder.cpp:1172-1183, 1466), and keeps overflowing content only
810
+ // where it trims, hangs or is span tags alone (processOverflowingContent,
811
+ // InlineContentBreaker.cpp:160-199, 275-295). Gecko ends a line at its last
812
+ // break whose content fits, else at its first (BreakAndMeasureText,
813
+ // gfxTextRun.cpp:1091-1109), and a break comes before a zero-width space after
814
+ // white space (nsLineBreaker.cpp:321-330) and, by overflow-wrap, before any
815
+ // cluster on a line with no break yet (gfxTextRun.cpp:1068-1074). The break
816
+ // before it is the one the engine has there: after the white space, after an
817
+ // object, or, after a grapheme wider than its line, the one between graphemes
818
+ // that a line with no other break takes. A line-end trim doesn't keep it: a
819
+ // mark is halted and a U+3000 run hangs where the line ends after them, and
820
+ // with a zero-width space after them on the line they count whole (`字」` and
821
+ // one in 40px Hiragino Sans at 70px are two lines in Chrome 155, the second
822
+ // empty). Two places keep it past the line's end: after the start edge
823
+ // of its own item's opening, which the line took by the part it fits, as the
824
+ // engines keep a span's start with its first text; and right after an object
825
+ // in Gecko (EngineProfile, zeroWidthSpaceStaysAfterAtomic). The zero-width
826
+ // spaces after one that stays stay with it, as no break comes between two
827
+ // (UAX #14 LB7). Premise: widths measured apart. Blink kerns the letters on
828
+ // the two sides of a zero-width space, so a letter wider than its line by
829
+ // less than that kerning fits there, and Chrome keeps the zero-width space
830
+ // on its line (ENGINE_FOLLOWUPS.md, Line edges).
831
+ //
832
+ // Only a break segment can stay, so all of this is under that one test, and
833
+ // the profile's field is read where a zero-width space overflows right after
834
+ // an object: a line that ends at a segment that is no break pays nothing for
835
+ // the rule, and a walk reads nothing for it at its start (RESEARCH.md, Keeping
836
+ // Work Bounded, Work Done Only Where A Rule Applies).
815
837
  const contentW = lineW - lineEndTrimmed
816
- if (breakAfter && (hangs || kind !== TAB) && (contentW <= fitLimit ||
817
- (pendingBreakSegmentIndex < 0 && (kind === SPACE || kind === ZERO_WIDTH_BREAK)) ||
818
- (kind === ZERO_WIDTH_BREAK && (segmentFlags[i - 1] & KIND_BITS) === OBJECT))) {
819
- endWidth = hangs ? hangStartWidth : contentW
820
- lineW += advance
821
- endSegmentIndex = i + 1
822
- endGraphemeIndex = 0
823
- break decided
838
+ if (breakAfter) {
839
+ const afterObject = kind === ZERO_WIDTH_BREAK && (segmentFlags[i - 1] & KIND_BITS) === OBJECT
840
+ const stays = kind === ZERO_WIDTH_BREAK && (flags & DROPPED) === 0
841
+ ? afterObject && (engineProfile.zeroWidthSpaceStaysAfterAtomic || (openingEdges !== null && openingEdges[i - 1] !== 0))
842
+ : (hangs || kind !== TAB) && (contentW <= fitLimit || (pendingBreakSegmentIndex < 0 && (kind === SPACE || kind === ZERO_WIDTH_BREAK)) || afterObject)
843
+ if (stays) {
844
+ endWidth = hangs ? hangStartWidth : contentW
845
+ lineW += advance
846
+ endSegmentIndex = i + 1
847
+ if (kind === ZERO_WIDTH_BREAK) while (endSegmentIndex < segmentCount && (segmentFlags[endSegmentIndex] & KIND_BITS) === ZERO_WIDTH_BREAK) endSegmentIndex++
848
+ endGraphemeIndex = 0
849
+ break decided
850
+ }
824
851
  }
825
852
  // Gecko's line returns from a tab that doesn't fit to its latest break (below), as
826
853
  // BreakAndMeasureText keeps the last break whose line fits (gfxTextRun.cpp:1086-1101).
@@ -978,65 +1005,23 @@ function walkPreparedComplexLines(
978
1005
  // A grapheme fits with the letter spacing after it.
979
1006
  const fitAdvances = breakableFitAdvances[i]
980
1007
  const fitCount = fitAdvances.length
981
- const entry = entryGeometry === null ? null : entryGeometry[i]
982
- // Entry geometry describes whole segment tails on a fresh line.
983
- const freshWhole = hasContent ? null : getSegmentEntryWidth(entry, fillStart, fitCount)
984
- if (freshWhole !== null) {
985
- // This branch reads a width for each question: in getFreshLineEnd(), the entry's
986
- // admissionFit for whether the whole tail fits and the fresh prefixes for where
987
- // the line ends where it doesn't; then the tail's fresh width, freshWhole, for the
988
- // line that goes on. The line takes its first grapheme even where that doesn't
989
- // fit, so it advances.
990
- const end = getFreshLineEnd(entry , fillStart, fitCount, fitLimit)
991
- hasContent = true
992
- if (end <= fitCount) {
1008
+ for (let g = fillStart; g < fitCount; g++) {
1009
+ const baseGw = fitAdvances[g]
1010
+ if (!hasContent) {
1011
+ hasContent = true
993
1012
  lineEndSegmentIndex = i
994
- lineEndGraphemeIndex = end
995
- lineW = getSegmentEntryWidth(entry, fillStart, end) - letterSpacing
996
- // Where the fresh prefixes reach the segment's end though the whole tail wasn't
997
- // admitted, the line ends after the segment and takes nothing after it. Only
998
- // an admitted tail lets the line go on with the text after it (below).
999
- if (end === fitCount) {
1000
- endSegmentIndex = i + 1
1001
- endGraphemeIndex = 0
1002
- endWidth = lineW - lineEndTrimmed
1003
- }
1004
- break decided
1005
- }
1006
- lineEndSegmentIndex = i + 1
1007
- lineEndGraphemeIndex = 0
1008
- lineW = freshWhole - letterSpacing
1009
- } else {
1010
- for (let g = fillStart; g < fitCount; g++) {
1011
- const baseGw = fitAdvances[g]
1012
- if (!hasContent) {
1013
- hasContent = true
1014
- lineEndSegmentIndex = i
1015
- lineEndGraphemeIndex = g + 1
1016
- const startExtras = prepared.breakableLineStartExtras?.[i] ?? null
1017
- lineW = startExtras === null ? baseGw : baseGw + startExtras[g]
1018
- // A line that starts inside a padded rich-inline item, or fills its first
1019
- // segment, pays the item's extraWidth (ParagraphSegmentData).
1020
- if (g > 0 ? insideExtras !== null : fillExtras !== null) lineW += g > 0 ? insideExtras [i] : fillExtras [i]
1021
- // A line that holds only this grapheme, overflowing, keeps the graphemes after
1022
- // it that can't start a line, and ends.
1023
- const end = lineW + letterSpacing > fitLimit
1024
- ? getOverflowingFirstGraphemeEnd(prepared, i, g, fitCount)
1025
- : g + 1
1026
- if (end > g + 1) {
1027
- for (let k = g + 1; k < end; k++) lineW += fitAdvances[k] + letterSpacing
1028
- endSegmentIndex = end === fitCount ? i + 1 : i
1029
- endGraphemeIndex = end === fitCount ? 0 : end
1030
- endWidth = lineW - lineEndTrimmed
1031
- break decided
1032
- }
1033
- } else {
1034
- const candidatePaintWidth = lineW + (baseGw + (g > fillStart ? letterSpacing : fillSpacing))
1035
- if (candidatePaintWidth + letterSpacing > fitLimit) break decided
1036
- lineW = candidatePaintWidth
1037
- lineEndSegmentIndex = i
1038
- lineEndGraphemeIndex = g + 1
1039
- }
1013
+ lineEndGraphemeIndex = g + 1
1014
+ const startExtras = prepared.breakableLineStartExtras?.[i] ?? null
1015
+ lineW = startExtras === null ? baseGw : baseGw + startExtras[g]
1016
+ // A line that starts inside a padded rich-inline item, or fills its first
1017
+ // segment, pays the item's extraWidth (ParagraphSegmentData).
1018
+ if (g > 0 ? insideExtras !== null : fillExtras !== null) lineW += g > 0 ? insideExtras [i] : fillExtras [i]
1019
+ } else {
1020
+ const candidatePaintWidth = lineW + (baseGw + (g > fillStart ? letterSpacing : fillSpacing))
1021
+ if (candidatePaintWidth + letterSpacing > fitLimit) break decided
1022
+ lineW = candidatePaintWidth
1023
+ lineEndSegmentIndex = i
1024
+ lineEndGraphemeIndex = g + 1
1040
1025
  }
1041
1026
  }
1042
1027
  if (hasContent && lineEndSegmentIndex === i && lineEndGraphemeIndex === fitCount) {
@@ -1063,7 +1048,7 @@ function walkPreparedComplexLines(
1063
1048
  fitBreakSegmentIndex >= 0 &&
1064
1049
  pendingBreakSegmentIndex === lineEndSegmentIndex &&
1065
1050
  lineEndGraphemeIndex === 0 &&
1066
- returnsFromUnfitHyphen(prepared, lineStartSegmentIndex, lineEndSegmentIndex, pendingBreakWidth, fitLimit)
1051
+ returnsFromUnfitHyphen(prepared, lineStartSegmentIndex, lineEndSegmentIndex, pendingBreakWidth, fitLimit, hyphenEndKeepsJoin)
1067
1052
  ) {
1068
1053
  endSegmentIndex = fitBreakSegmentIndex
1069
1054
  endGraphemeIndex = 0
@@ -1110,9 +1095,10 @@ function stepPreparedSimpleLineGeometry(
1110
1095
  cursor ,
1111
1096
  maxWidth ,
1112
1097
  ) {
1113
- const { widths, segmentFlags, breakableFitAdvances, entryGeometry, lineStartExtras, lineEndTrims, overflowLineEndTrims } = prepared
1098
+ const { widths, segmentFlags, breakableFitAdvances, lineStartExtras, lineEndTrims, overflowLineEndTrims } = prepared
1099
+ const engineProfile = getEngineProfile()
1114
1100
  // A negative width lays out as 0, as in the complex walker.
1115
- const fitLimit = Math.max(0, maxWidth) + getEngineProfile().lineFitEpsilon
1101
+ const fitLimit = Math.max(0, maxWidth) + engineProfile.lineFitEpsilon
1116
1102
  const start = cursor.segmentIndex
1117
1103
 
1118
1104
  // The first segment of the line, or the rest of one a line ended inside. One that
@@ -1124,23 +1110,11 @@ function stepPreparedSimpleLineGeometry(
1124
1110
  const startTrim = overflowLineEndTrims !== null && startW - overflowLineEndTrims[start] <= fitLimit
1125
1111
  ? overflowLineEndTrims[start]
1126
1112
  : startEndTrim
1127
- // A line that starts inside a segment where it has fresh-line geometry takes the
1128
- // tail, and goes on, or its fresh prefixes.
1129
- const entry = cursor.graphemeIndex > 0 && entryGeometry !== null ? entryGeometry[start] : null
1130
1113
  let lineW
1131
1114
  // The line-end trim of the last segment, where only that trim let it fit. Every
1132
1115
  // later segment overflows, so the line ends after it and paints that much less.
1133
1116
  let endTrimmed = 0
1134
- if (entry !== null && entry.entries[cursor.graphemeIndex] !== null) {
1135
- const segmentEnd = startAdvances .length
1136
- const end = getFreshLineEnd(entry, cursor.graphemeIndex, segmentEnd, fitLimit)
1137
- lineW = getSegmentEntryWidth(entry, cursor.graphemeIndex, Math.min(end, segmentEnd))
1138
- if (end <= segmentEnd) {
1139
- cursor.segmentIndex = end === segmentEnd ? start + 1 : start
1140
- cursor.graphemeIndex = end === segmentEnd ? 0 : end
1141
- return lineW
1142
- }
1143
- } else if (cursor.graphemeIndex > 0 || (startW - startTrim > fitLimit && startAdvances !== null)) {
1117
+ if (cursor.graphemeIndex > 0 || (startW - startTrim > fitLimit && startAdvances !== null)) {
1144
1118
  const fitAdvances = startAdvances
1145
1119
  const startExtras = prepared.breakableLineStartExtras?.[start] ?? null
1146
1120
  let g = cursor.graphemeIndex + 1
@@ -1149,15 +1123,6 @@ function stepPreparedSimpleLineGeometry(
1149
1123
  // the item's extraWidth (ParagraphSegmentData).
1150
1124
  const items = prepared.items
1151
1125
  if (items !== undefined && (g > 1 ? items.insideExtras !== null : items.fillExtras !== null)) lineW += g > 1 ? items.insideExtras [start] : items.fillExtras [start]
1152
- // A line that holds only an overflowing grapheme keeps the graphemes after it
1153
- // that can't start a line, and ends.
1154
- const overflowEnd = lineW > fitLimit ? getOverflowingFirstGraphemeEnd(prepared, start, g - 1, fitAdvances.length) : g
1155
- if (overflowEnd > g) {
1156
- for (; g < overflowEnd; g++) lineW += fitAdvances[g]
1157
- cursor.segmentIndex = g === fitAdvances.length ? start + 1 : start
1158
- cursor.graphemeIndex = g === fitAdvances.length ? 0 : g
1159
- return lineW
1160
- }
1161
1126
  for (; g < fitAdvances.length; g++) {
1162
1127
  if (lineW + fitAdvances[g] > fitLimit) {
1163
1128
  cursor.graphemeIndex = g
@@ -1171,16 +1136,26 @@ function stepPreparedSimpleLineGeometry(
1171
1136
  }
1172
1137
 
1173
1138
  // Every boundary of a fast-path handle is a break, so the line takes whole
1174
- // segments until one overflows. An overflowing space or ZWSP hangs and ends the
1175
- // line; before other text, the line leaves out a space or ZWSP it ends with.
1139
+ // segments until one overflows. An overflowing space hangs and ends the line. A
1140
+ // zero-width space, which is content of no width, overflows where the content
1141
+ // before it does, and then starts the next line, but right after an object where
1142
+ // the engine keeps it there (walkPreparedComplexLines has the rule). Before
1143
+ // other text, the line leaves out a space it ends with.
1176
1144
  for (let i = start + 1; i < widths.length; i++) {
1177
1145
  const w = widths[i]
1178
1146
  const endTrim = lineEndTrims === null ? 0 : lineEndTrims[i]
1179
1147
  if (lineW + w - endTrim > fitLimit) {
1180
- const hangs = breaksAfterKind(segmentFlags[i] & KIND_BITS)
1181
- cursor.segmentIndex = hangs ? i + 1 : i
1148
+ const kind = segmentFlags[i] & KIND_BITS
1149
+ let end = i
1150
+ if (kind === SPACE) {
1151
+ end = i + 1
1152
+ } else if (kind === ZERO_WIDTH_BREAK && engineProfile.zeroWidthSpaceStaysAfterAtomic && (segmentFlags[i - 1] & KIND_BITS) === OBJECT) {
1153
+ end = i + 1
1154
+ while (end < widths.length && (segmentFlags[end] & KIND_BITS) === ZERO_WIDTH_BREAK) end++
1155
+ }
1156
+ cursor.segmentIndex = end
1182
1157
  cursor.graphemeIndex = 0
1183
- return !hangs && breaksAfterKind(segmentFlags[i - 1] & KIND_BITS) ? lineW - widths[i - 1] : lineW - endTrimmed
1158
+ return end === i && breaksAfterKind(segmentFlags[i - 1] & KIND_BITS) ? lineW - widths[i - 1] : lineW - endTrimmed
1184
1159
  }
1185
1160
  lineW += w
1186
1161
  endTrimmed = lineW > fitLimit ? endTrim : 0