@stll/folio-core 0.8.0 → 0.9.0

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 (152) hide show
  1. package/README.md +39 -0
  2. package/dist/ai-edits/apply.js +18 -4
  3. package/dist/ai-edits/headless.d.ts +31 -4
  4. package/dist/ai-edits/headless.js +148 -35
  5. package/dist/ai-edits/index.d.ts +3 -3
  6. package/dist/ai-edits/index.js +2 -1
  7. package/dist/ai-edits/snapshot.js +40 -3
  8. package/dist/ai-edits/types.d.ts +2 -1
  9. package/dist/compat/eigenpal.d.ts +6 -4
  10. package/dist/compat/eigenpal.js +5 -3
  11. package/dist/controller/latestRequestGate.d.ts +13 -0
  12. package/dist/controller/latestRequestGate.js +16 -0
  13. package/dist/controller/layoutPipeline.js +12 -1
  14. package/dist/document-operations.d.ts +10 -3
  15. package/dist/document-operations.js +29 -14
  16. package/dist/docx/blockContentParser.js +2 -2
  17. package/dist/docx/commentParser.js +1 -1
  18. package/dist/docx/compatibility.d.ts +3 -7
  19. package/dist/docx/compatibility.js +0 -11
  20. package/dist/docx/encryption/agileDecryption.js +1 -1
  21. package/dist/docx/encryption/encryptionInfo.js +1 -1
  22. package/dist/docx/encryption/openEncryptedDocx.js +1 -1
  23. package/dist/docx/fontTableParser.d.ts +6 -0
  24. package/dist/docx/fontTableParser.js +72 -0
  25. package/dist/docx/groupDrawingParser.js +7 -2
  26. package/dist/docx/headerFooterParser.js +1 -1
  27. package/dist/docx/normalizeBaseDirection.js +1 -1
  28. package/dist/docx/numberingParser.js +1 -0
  29. package/dist/docx/paragraphParser.js +12 -1
  30. package/dist/docx/parser.d.ts +1 -1
  31. package/dist/docx/parser.js +12 -9
  32. package/dist/docx/rezip.js +70 -5
  33. package/dist/docx/runConsolidator.js +4 -0
  34. package/dist/docx/runParser.js +19 -1
  35. package/dist/docx/selectiveSave.js +3 -3
  36. package/dist/docx/serializer/fontTableSerializer.d.ts +6 -0
  37. package/dist/docx/serializer/fontTableSerializer.js +26 -0
  38. package/dist/docx/serializer/headerFooterSerializer.js +1 -1
  39. package/dist/docx/serializer/paragraphSerializer.js +4 -0
  40. package/dist/docx/serializer/runSerializer.js +7 -0
  41. package/dist/docx/serializer/settingsSerializer.d.ts +6 -0
  42. package/dist/docx/serializer/settingsSerializer.js +16 -0
  43. package/dist/docx/serializer/stylesSerializer.d.ts +6 -0
  44. package/dist/docx/serializer/stylesSerializer.js +56 -0
  45. package/dist/docx/serializer/tableSerializer.js +25 -26
  46. package/dist/docx/serializer/themeSerializer.d.ts +6 -0
  47. package/dist/docx/serializer/themeSerializer.js +36 -0
  48. package/dist/docx/settingsParser.js +46 -0
  49. package/dist/docx/styleParser.js +26 -1
  50. package/dist/docx/tableParser.js +11 -1
  51. package/dist/docx/vmlImageParser.js +1 -0
  52. package/dist/i18n/messages/catalogs.gen.d.ts +5916 -5916
  53. package/dist/i18n/messages/catalogs.gen.js +5910 -5910
  54. package/dist/index.d.ts +6 -4
  55. package/dist/index.js +5 -3
  56. package/dist/layout-bridge/convert/footnoteLayout.d.ts +3 -0
  57. package/dist/layout-bridge/convert/footnoteLayout.js +3 -1
  58. package/dist/layout-bridge/convert/headerFooterLayout.d.ts +3 -0
  59. package/dist/layout-bridge/convert/headerFooterLayout.js +6 -1
  60. package/dist/layout-bridge/convert/toFlowBlocks.d.ts +14 -2
  61. package/dist/layout-bridge/convert/toFlowBlocks.js +51 -14
  62. package/dist/layout-bridge/engine/measuring/index.d.ts +2 -1
  63. package/dist/layout-bridge/engine/measuring/index.js +2 -1
  64. package/dist/layout-engine/index.js +36 -28
  65. package/dist/layout-engine/measure/cache.js +11 -3
  66. package/dist/layout-engine/measure/font-metrics.worker.js +1 -0
  67. package/dist/layout-engine/measure/index.d.ts +2 -1
  68. package/dist/layout-engine/measure/index.js +2 -1
  69. package/dist/layout-engine/measure/lineBreakProvider.d.ts +33 -0
  70. package/dist/layout-engine/measure/lineBreakProvider.js +252 -0
  71. package/dist/layout-engine/measure/lineBreaks.d.ts +7 -2
  72. package/dist/layout-engine/measure/lineBreaks.js +11 -18
  73. package/dist/layout-engine/measure/listMarkerWidth.d.ts +1 -0
  74. package/dist/layout-engine/measure/listMarkerWidth.js +12 -6
  75. package/dist/layout-engine/measure/measureBlocks.d.ts +1 -2
  76. package/dist/layout-engine/measure/measureBlocks.js +6 -36
  77. package/dist/layout-engine/measure/measureContainer.js +32 -9
  78. package/dist/layout-engine/measure/measureHelpers.js +5 -2
  79. package/dist/layout-engine/measure/measureParagraph.js +335 -72
  80. package/dist/layout-engine/measure/measureTypes.d.ts +2 -1
  81. package/dist/layout-engine/measure/measureWorker.d.ts +2 -2
  82. package/dist/layout-engine/measure/measureWorker.js +8 -19
  83. package/dist/layout-engine/measure/measureWorkerProtocol.d.ts +3 -14
  84. package/dist/layout-engine/measure/tableCellFloating.d.ts +10 -3
  85. package/dist/layout-engine/measure/tableCellFloating.js +37 -29
  86. package/dist/layout-engine/measure/tableCellFlow.d.ts +27 -0
  87. package/dist/layout-engine/measure/tableCellFlow.js +73 -0
  88. package/dist/layout-engine/measure/tableCellGrid.js +1 -1
  89. package/dist/layout-engine/measure/textMeasurementPolicy.d.ts +18 -0
  90. package/dist/layout-engine/measure/textMeasurementPolicy.js +24 -0
  91. package/dist/layout-engine/renderedBreakReconciliation.d.ts +6 -1
  92. package/dist/layout-engine/renderedBreakReconciliation.js +34 -11
  93. package/dist/layout-engine/tableRowBreak.d.ts +5 -2
  94. package/dist/layout-engine/tableRowBreak.js +62 -29
  95. package/dist/layout-engine/types.d.ts +40 -7
  96. package/dist/layout-painter/anchoredImagePosition.d.ts +23 -0
  97. package/dist/layout-painter/anchoredImagePosition.js +95 -0
  98. package/dist/layout-painter/borderStroke.d.ts +23 -0
  99. package/dist/layout-painter/borderStroke.js +39 -0
  100. package/dist/layout-painter/renderImage.d.ts +5 -4
  101. package/dist/layout-painter/renderImage.js +18 -4
  102. package/dist/layout-painter/renderPage.d.ts +2 -27
  103. package/dist/layout-painter/renderPage.js +13 -99
  104. package/dist/layout-painter/renderParagraph.js +41 -23
  105. package/dist/layout-painter/renderTable.d.ts +2 -0
  106. package/dist/layout-painter/renderTable.js +223 -43
  107. package/dist/managers/DocumentLoaderManager.d.ts +1 -1
  108. package/dist/managers/DocumentLoaderManager.js +2 -2
  109. package/dist/prosemirror/attrs/index.d.ts +4 -2
  110. package/dist/prosemirror/attrs/index.js +21 -2
  111. package/dist/prosemirror/commands/propertyChangeScope.js +1 -1
  112. package/dist/prosemirror/conversion/fromProseDoc.js +33 -18
  113. package/dist/prosemirror/conversion/toProseDoc.js +50 -18
  114. package/dist/prosemirror/extensions/StarterKit.js +2 -0
  115. package/dist/prosemirror/extensions/core/ParagraphExtension.js +7 -2
  116. package/dist/prosemirror/extensions/features/AutoBidiDetectionExtension.js +1 -1
  117. package/dist/prosemirror/extensions/features/ListExtension.js +1 -0
  118. package/dist/prosemirror/extensions/marks/FootnoteRefExtension.js +1 -0
  119. package/dist/prosemirror/extensions/marks/HighlightExtension.js +1 -1
  120. package/dist/prosemirror/extensions/marks/LanguageExtension.d.ts +7 -0
  121. package/dist/prosemirror/extensions/marks/LanguageExtension.js +30 -0
  122. package/dist/prosemirror/extensions/marks/RunShadingExtension.js +1 -1
  123. package/dist/prosemirror/extensions/marks/TextColorExtension.js +1 -1
  124. package/dist/prosemirror/extensions/marks/TrackedChangeExtensions.js +1 -1
  125. package/dist/prosemirror/extensions/marks/markUtils.js +12 -0
  126. package/dist/prosemirror/extensions/nodes/ImageExtension.js +2 -1
  127. package/dist/prosemirror/extensions/nodes/TableExtension.js +4 -2
  128. package/dist/prosemirror/schema/index.d.ts +2 -2
  129. package/dist/prosemirror/schema/marks.d.ts +6 -1
  130. package/dist/prosemirror/schema/nodes.d.ts +13 -11
  131. package/dist/prosemirror/styles/resolvedStyleAttrs.js +1 -0
  132. package/dist/prosemirror/utils/tabCalculator.d.ts +3 -1
  133. package/dist/prosemirror/utils/tabCalculator.js +12 -10
  134. package/dist/prosemirror/validation.js +4 -1
  135. package/dist/redline.js +13 -3
  136. package/dist/server.d.ts +6 -3
  137. package/dist/server.js +5 -2
  138. package/dist/style-sets/extract.d.ts +35 -0
  139. package/dist/style-sets/extract.js +123 -0
  140. package/dist/style-sets/stellaStyle.d.ts +13 -0
  141. package/dist/style-sets/stellaStyle.js +516 -0
  142. package/dist/style-sets/types.d.ts +31 -0
  143. package/dist/style-sets/types.js +5 -0
  144. package/dist/utils/createDocument.d.ts +12 -2
  145. package/dist/utils/createDocument.js +42 -31
  146. package/dist/utils/fontResolver.js +6 -6
  147. package/dist/utils/formatToStyle.js +1 -1
  148. package/dist/utils/paragraphFormattingMerge.d.ts +14 -3
  149. package/dist/utils/paragraphFormattingMerge.js +14 -4
  150. package/package.json +5 -1
  151. package/dist/docx/capabilities.d.ts +0 -41
  152. package/dist/docx/capabilities.js +0 -322
@@ -1,6 +1,8 @@
1
- import { getFontMetrics, measureRun, measureTextWidth } from "./measureProvider.js";
2
1
  import { CJK_FALLBACK_FONT_FAMILY, isCjkFont } from "../../utils/fontResolver.js";
2
+ import { getFontMetrics, measureRun, measureTextWidth } from "./measureProvider.js";
3
+ import { countCompressibleSpaces } from "./textMeasurementPolicy.js";
3
4
  import { buildRunFontStyle, ptToPx } from "./measureHelpers.js";
5
+ import "./lineBreakProvider.js";
4
6
  import { calculateTabWidth, pixelsToTwips } from "../../prosemirror/utils/tabCalculator.js";
5
7
  import { inlineImageBoundingBox } from "../../utils/rotationBoundingBox.js";
6
8
  import { hasCjk } from "../../utils/scriptSegments.js";
@@ -9,7 +11,7 @@ import { isFloatingImageRun } from "../types.js";
9
11
  import { clampFloatingWrapMargins } from "./clampFloatingWrapMargins.js";
10
12
  import { getFloatingAvailableWidth, getFloatingMargins } from "./floatingZones.js";
11
13
  import { getListMarkerInlineWidth } from "./listMarkerWidth.js";
12
- import { findWordBreaks, isBreakChar } from "./lineBreaks.js";
14
+ import { findGraphemeBreaks, findHyphenationBreaks, findWordBreaks, isBreakChar, isHangingPunctuation } from "./lineBreaks.js";
13
15
  //#region src/layout-engine/measure/measureParagraph.ts
14
16
  /**
15
17
  * Paragraph measurement module
@@ -22,9 +24,11 @@ const DEFAULT_FONT_FAMILY = "Calibri";
22
24
  const DEFAULT_LINE_HEIGHT_MULTIPLIER = 1;
23
25
  const WIDTH_TOLERANCE = .5;
24
26
  const JUSTIFY_SHRINK_TOLERANCE_RATIO = .016;
27
+ const JUSTIFY_SPACE_CONTRACTION_RATIO = .1;
28
+ const JUSTIFY_LIST_MARKER_SPACE_CONTRACTION_RATIO = .195;
29
+ const JUSTIFY_LIST_CONTINUATION_SPACE_CONTRACTION_RATIO = .23;
25
30
  const JUSTIFY_INSET_LIST_SHRINK_TOLERANCE_RATIO = .015;
26
31
  const JUSTIFY_LITERAL_TAB_CONTINUATION_SHRINK_TOLERANCE_RATIO = .017;
27
- const JUSTIFY_PROSE_SHRINK_TOLERANCE_RATIO = .02;
28
32
  const JUSTIFY_HANGING_TAB_SHRINK_TOLERANCE_RATIO = .021;
29
33
  const DEFAULT_LIST_HANGING_INDENT_PX = 24;
30
34
  const ALL_CAPS_RATIO_THRESHOLD = .8;
@@ -32,18 +36,20 @@ const ALL_CAPS_RATIO_THRESHOLD = .8;
32
36
  * Find the longest prefix of `text` that fits within `maxWidth` pixels.
33
37
  * Returns the number of characters that fit (at least 1 if `forceMin` is true).
34
38
  */
35
- function findMaxFittingLength(text, style, maxWidth, forceMin = false) {
36
- let lo = 1;
37
- let hi = text.length;
39
+ function findMaxFittingLength(text, style, maxWidth, forceMin = false, policy) {
40
+ const boundaries = findGraphemeBreaks(text, policy);
41
+ let lo = 0;
42
+ let hi = boundaries.length - 1;
38
43
  let best = 0;
39
44
  while (lo <= hi) {
40
45
  const mid = Math.floor((lo + hi) / 2);
41
- if (measureTextWidth(text.slice(0, mid), style) <= maxWidth) {
42
- best = mid;
46
+ const boundary = boundaries[mid];
47
+ if (measureTextWidth(text.slice(0, boundary), style) <= maxWidth) {
48
+ best = boundary;
43
49
  lo = mid + 1;
44
50
  } else hi = mid - 1;
45
51
  }
46
- return forceMin && best === 0 ? 1 : best;
52
+ return forceMin && best === 0 ? boundaries.at(0) ?? 0 : best;
47
53
  }
48
54
  /**
49
55
  * Extract FontStyle from a run that carries RunFormatting (text, tab, or
@@ -54,6 +60,31 @@ function findMaxFittingLength(text, style, maxWidth, forceMin = false) {
54
60
  function runToFontStyle(run) {
55
61
  return buildRunFontStyle(run, DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE);
56
62
  }
63
+ function languageMatches(ruleLanguage, locale) {
64
+ if (!ruleLanguage) return true;
65
+ if (!locale) return false;
66
+ const rule = ruleLanguage.toLowerCase();
67
+ const active = locale.toLowerCase();
68
+ return active === rule || active.startsWith(`${rule}-`) || rule.startsWith(`${active}-`);
69
+ }
70
+ function lineBreakPolicy(block, run) {
71
+ const language = run.language;
72
+ let locale = language?.val;
73
+ if (hasCjk(run.text)) locale = language?.eastAsia ?? language?.val;
74
+ else if (run.rtl) locale = language?.bidi ?? language?.val;
75
+ const rules = block.attrs?.lineBreakRules;
76
+ const before = rules?.noLineBreaksBefore;
77
+ const after = rules?.noLineBreaksAfter;
78
+ return {
79
+ ...locale ? { locale } : {},
80
+ ...block.attrs?.kinsoku !== void 0 ? { kinsoku: block.attrs.kinsoku } : {},
81
+ ...before && languageMatches(before.language, locale) ? { noLineBreaksBefore: before.characters } : {},
82
+ ...after && languageMatches(after.language, locale) ? { noLineBreaksAfter: after.characters } : {},
83
+ ...rules?.useLegacyEthiopicAmharicRules ? { useLegacyEthiopicAmharicRules: true } : {},
84
+ ...block.attrs?.automaticHyphenation?.doNotHyphenateCaps !== void 0 ? { doNotHyphenateCaps: block.attrs.automaticHyphenation.doNotHyphenateCaps } : {},
85
+ ...run.allCaps === true ? { renderedAllCaps: true } : {}
86
+ };
87
+ }
57
88
  /**
58
89
  * Line-HEIGHT style for a text run. Word derives a CJK line's height from an
59
90
  * East-Asian face, not the run's ascii font: real documents routinely put CJK
@@ -331,34 +362,63 @@ function uppercaseLetterRatio(text) {
331
362
  }
332
363
  return letters === 0 ? 0 : uppercase / letters;
333
364
  }
334
- function fixedSpaceAdjustedShrinkTolerance({ tolerance, regularSpaceCount, nonBreakingSpaceCount }) {
335
- const totalSpaces = regularSpaceCount + nonBreakingSpaceCount;
336
- if (totalSpaces === 0) return tolerance;
337
- return Math.max(JUSTIFY_SHRINK_TOLERANCE_RATIO, tolerance * (regularSpaceCount / totalSpaces));
338
- }
339
- function justifyShrinkToleranceRatio(block, isFirstLine, regularSpaceCount, nonBreakingSpaceCount) {
365
+ function resolveJustifyFitStrategy(block, isFirstLine, profile) {
366
+ if (isShallowFullHangingListContinuation(block, isFirstLine)) return { type: "rounding" };
340
367
  if (block.attrs?.listMarker !== void 0) {
341
368
  const hanging = block.attrs.indent?.hanging ?? 0;
342
- if (isFirstLine) return hanging <= DEFAULT_LIST_HANGING_INDENT_PX ? JUSTIFY_SHRINK_TOLERANCE_RATIO : JUSTIFY_HANGING_TAB_SHRINK_TOLERANCE_RATIO;
369
+ if (isFirstLine) return hanging <= DEFAULT_LIST_HANGING_INDENT_PX ? {
370
+ type: "space",
371
+ ratio: JUSTIFY_LIST_MARKER_SPACE_CONTRACTION_RATIO
372
+ } : {
373
+ type: "width",
374
+ ratio: JUSTIFY_HANGING_TAB_SHRINK_TOLERANCE_RATIO
375
+ };
343
376
  const left = block.attrs.indent?.left ?? 0;
344
- if (hanging > 0 && left > hanging) return JUSTIFY_INSET_LIST_SHRINK_TOLERANCE_RATIO;
345
- return fixedSpaceAdjustedShrinkTolerance({
346
- tolerance: JUSTIFY_PROSE_SHRINK_TOLERANCE_RATIO,
347
- regularSpaceCount,
348
- nonBreakingSpaceCount
349
- });
377
+ if (hanging > 0 && left > hanging) return {
378
+ type: "width",
379
+ ratio: JUSTIFY_INSET_LIST_SHRINK_TOLERANCE_RATIO
380
+ };
381
+ return {
382
+ type: "space",
383
+ ratio: JUSTIFY_LIST_CONTINUATION_SPACE_CONTRACTION_RATIO
384
+ };
350
385
  }
351
386
  const hasTabStops = (block.attrs?.tabs?.length ?? 0) > 0;
352
- const hasTabRuns = block.runs.some(isTabRun);
353
- if (isFirstLine && hasTabRuns && (block.attrs?.indent?.hanging ?? 0) > 0) return JUSTIFY_SHRINK_TOLERANCE_RATIO;
354
- if (!isFirstLine && hasTabRuns && !hasTabStops) return JUSTIFY_LITERAL_TAB_CONTINUATION_SHRINK_TOLERANCE_RATIO;
355
- if (hasTabRuns && (isFirstLine || hasTabStops)) return (block.attrs?.indent?.firstLine ?? 0) === 0 ? JUSTIFY_HANGING_TAB_SHRINK_TOLERANCE_RATIO : JUSTIFY_SHRINK_TOLERANCE_RATIO;
356
- if (uppercaseLetterRatio(block.runs.map((run) => isTextRun(run) ? run.text ?? "" : "").join("")) > ALL_CAPS_RATIO_THRESHOLD) return JUSTIFY_SHRINK_TOLERANCE_RATIO;
357
- return fixedSpaceAdjustedShrinkTolerance({
358
- tolerance: JUSTIFY_PROSE_SHRINK_TOLERANCE_RATIO,
359
- regularSpaceCount,
360
- nonBreakingSpaceCount
361
- });
387
+ if (isFirstLine && profile.hasTabRuns && (block.attrs?.indent?.hanging ?? 0) > 0) return {
388
+ type: "width",
389
+ ratio: JUSTIFY_SHRINK_TOLERANCE_RATIO
390
+ };
391
+ if (!isFirstLine && profile.hasTabRuns && !hasTabStops) return {
392
+ type: "width",
393
+ ratio: JUSTIFY_LITERAL_TAB_CONTINUATION_SHRINK_TOLERANCE_RATIO
394
+ };
395
+ if (profile.hasTabRuns && (isFirstLine || hasTabStops)) return {
396
+ type: "width",
397
+ ratio: (block.attrs?.indent?.firstLine ?? 0) === 0 ? JUSTIFY_HANGING_TAB_SHRINK_TOLERANCE_RATIO : JUSTIFY_SHRINK_TOLERANCE_RATIO
398
+ };
399
+ if (profile.uppercaseRatio > ALL_CAPS_RATIO_THRESHOLD) return {
400
+ type: "width",
401
+ ratio: JUSTIFY_SHRINK_TOLERANCE_RATIO
402
+ };
403
+ return {
404
+ type: "space",
405
+ ratio: JUSTIFY_SPACE_CONTRACTION_RATIO
406
+ };
407
+ }
408
+ function compressibleSpaceWidth(text, style) {
409
+ const count = countCompressibleSpaces(text);
410
+ return count === 0 ? 0 : count * measureTextWidth(" ", style);
411
+ }
412
+ function justifyFitTolerance(line, strategy, candidateSpaceWidth) {
413
+ if (strategy.type === "rounding") return WIDTH_TOLERANCE;
414
+ if (strategy.type === "width") return Math.max(WIDTH_TOLERANCE, line.availableWidth * strategy.ratio);
415
+ return Math.max(WIDTH_TOLERANCE, (line.regularSpaceWidth + candidateSpaceWidth) * strategy.ratio);
416
+ }
417
+ function isShallowFullHangingListContinuation(block, isFirstLine) {
418
+ if (isFirstLine || block.attrs?.listMarker === void 0) return false;
419
+ const hanging = block.attrs.indent?.hanging ?? 0;
420
+ const left = block.attrs.indent?.left ?? 0;
421
+ return hanging > 0 && hanging < DEFAULT_LIST_HANGING_INDENT_PX && Math.abs(left - hanging) <= WIDTH_TOLERANCE;
362
422
  }
363
423
  function trimTrailingSpacesAndTabs(text) {
364
424
  let end = text.length;
@@ -369,12 +429,20 @@ function trimTrailingSpacesAndTabs(text) {
369
429
  }
370
430
  return text.slice(0, end);
371
431
  }
432
+ function trailingHangingPunctuationWidth(text, style, policy, enabled) {
433
+ if (!enabled || text.length === 0) return 0;
434
+ const lastStart = findGraphemeBreaks(text, policy).at(-2) ?? 0;
435
+ const lastGrapheme = text.slice(lastStart);
436
+ if (!isHangingPunctuation(lastGrapheme, policy)) return 0;
437
+ return measureTextWidth(lastGrapheme, style);
438
+ }
372
439
  /**
373
440
  * Width of the unbreakable text glued to the end of each run.
374
441
  * A run boundary is not itself a wrap opportunity: adjacent note markers and
375
442
  * format-only word splits must wrap as one cluster.
376
443
  */
377
- function computeTrailingGlueWidths(runs) {
444
+ function computeTrailingGlueWidths(block) {
445
+ const runs = block.runs;
378
446
  const widths = Array.from({ length: runs.length }, () => 0);
379
447
  for (let index = runs.length - 1; index >= 0; index--) {
380
448
  const nextRun = runs[index + 1];
@@ -384,19 +452,112 @@ function computeTrailingGlueWidths(runs) {
384
452
  widths[index] = widths[index + 1] ?? 0;
385
453
  continue;
386
454
  }
387
- if (isSpaceOrTab(text[0])) continue;
455
+ if (isSpaceOrTab(text.at(0))) continue;
388
456
  const style = runToFontStyle(nextRun);
389
- const breaks = findWordBreaks(text);
390
- if (breaks.length === 0) {
391
- widths[index] = measureTextWidth(text, style) + (widths[index + 1] ?? 0);
392
- continue;
457
+ const firstBreak = findWordBreaks(text, lineBreakPolicy(block, nextRun)).at(0);
458
+ widths[index] = measureTextWidth(firstBreak === void 0 ? text : trimTrailingSpacesAndTabs(text.slice(0, firstBreak)), style) + (firstBreak === void 0 ? widths[index + 1] ?? 0 : 0);
459
+ }
460
+ return widths;
461
+ }
462
+ function computeProtectedCrossRunGlueWidths(block) {
463
+ const widths = Array.from({ length: block.runs.length }, () => 0);
464
+ for (let index = 0; index < block.runs.length; index++) {
465
+ const run = block.runs[index];
466
+ if (!run || !isTextRun(run)) continue;
467
+ const trailing = /(\S+)(\s*)$/u.exec(run.text ?? "");
468
+ const token = trailing?.[1];
469
+ const policy = lineBreakPolicy(block, run);
470
+ if (!token || token.length !== 1 || !policy.locale?.toLocaleLowerCase().startsWith("cs")) continue;
471
+ let separator = trailing[2] ?? "";
472
+ let glueWidth = separator.length > 0 ? measureTextWidth(separator, runToFontStyle(run)) : 0;
473
+ let followingWord = "";
474
+ let unseparatedFollowingText = false;
475
+ for (let nextIndex = index + 1; nextIndex < block.runs.length; nextIndex++) {
476
+ const nextRun = block.runs[nextIndex];
477
+ if (!nextRun || !isTextRun(nextRun)) break;
478
+ const text = nextRun.text ?? "";
479
+ let consumed = "";
480
+ for (const char of text) {
481
+ const isWhitespace = /\s/u.test(char);
482
+ if (followingWord.length > 0 && isWhitespace) break;
483
+ if (separator.length === 0 && !isWhitespace) {
484
+ unseparatedFollowingText = true;
485
+ break;
486
+ }
487
+ consumed += char;
488
+ if (isWhitespace) separator += char;
489
+ else followingWord += char;
490
+ }
491
+ if (consumed.length > 0) glueWidth += measureTextWidth(consumed, runToFontStyle(nextRun));
492
+ if (unseparatedFollowingText) break;
493
+ if (followingWord.length > 0 && consumed.length < text.length) break;
393
494
  }
394
- const firstBreak = breaks[0];
395
- if (firstBreak === void 0) continue;
396
- widths[index] = measureTextWidth(trimTrailingSpacesAndTabs(text.slice(0, firstBreak)), style);
495
+ if (unseparatedFollowingText || separator.length === 0 || followingWord.length === 0) continue;
496
+ const boundary = token.length + separator.length;
497
+ if (!findWordBreaks(token + separator + followingWord, policy).includes(boundary)) widths[index] = glueWidth;
397
498
  }
398
499
  return widths;
399
500
  }
501
+ /** Collect one lexical word continued through adjacent formatting runs. */
502
+ function collectCrossRunWord({ block, startRunIndex, startChar }) {
503
+ const segments = [];
504
+ let text = "";
505
+ let width = 0;
506
+ for (let runIndex = startRunIndex; runIndex < block.runs.length; runIndex++) {
507
+ const run = block.runs[runIndex];
508
+ if (!run || !isTextRun(run)) break;
509
+ const start = runIndex === startRunIndex ? startChar : 0;
510
+ const remainder = run.text.slice(start);
511
+ if (remainder.length === 0) continue;
512
+ if (runIndex !== startRunIndex && isSpaceOrTab(remainder[0])) break;
513
+ const remainingBudget = 256 - text.length;
514
+ const boundedRemainder = remainder.slice(0, remainingBudget + 1);
515
+ const firstBreak = findWordBreaks(boundedRemainder, lineBreakPolicy(block, run)).at(0);
516
+ const segmentText = trimTrailingSpacesAndTabs(firstBreak === void 0 ? boundedRemainder : boundedRemainder.slice(0, firstBreak));
517
+ if (segmentText.length === 0) break;
518
+ if (segmentText.length > remainingBudget) return;
519
+ const style = runToFontStyle(run);
520
+ segments.push({
521
+ runIndex,
522
+ startChar: start,
523
+ text: segmentText,
524
+ run,
525
+ style
526
+ });
527
+ text += segmentText;
528
+ width += measureTextWidth(segmentText, style);
529
+ if (firstBreak !== void 0) break;
530
+ }
531
+ if (segments.length < 2) return;
532
+ const firstRun = segments[0]?.run;
533
+ if (!firstRun) return;
534
+ return {
535
+ text,
536
+ width,
537
+ breaks: findHyphenationBreaks(text, lineBreakPolicy(block, firstRun)),
538
+ segments
539
+ };
540
+ }
541
+ function measureCrossRunPrefix(word, breakOffset) {
542
+ let width = 0;
543
+ let remaining = breakOffset;
544
+ const measuredSegments = [];
545
+ for (const segment of word.segments) {
546
+ const length = Math.min(remaining, segment.text.length);
547
+ if (length > 0) {
548
+ width += measureTextWidth(segment.text.slice(0, length), segment.style);
549
+ measuredSegments.push(segment);
550
+ }
551
+ if (remaining <= segment.text.length) return {
552
+ width,
553
+ endRunIndex: segment.runIndex,
554
+ endChar: segment.startChar + remaining,
555
+ hyphenStyle: segment.style,
556
+ segments: measuredSegments
557
+ };
558
+ remaining -= segment.text.length;
559
+ }
560
+ }
400
561
  /**
401
562
  * Minimum horizontal room a line must offer before we treat it as usable for
402
563
  * body text. Below this threshold the line is bumped past obstructing floats
@@ -446,6 +607,12 @@ function measureParagraph(block, maxWidth, options) {
446
607
  const attrs = block.attrs;
447
608
  const spacing = attrs?.spacing;
448
609
  const isJustifiedParagraph = attrs?.alignment === "justify";
610
+ const justificationProfile = {
611
+ hasTabRuns: isJustifiedParagraph && runs.some(isTabRun),
612
+ uppercaseRatio: isJustifiedParagraph ? uppercaseLetterRatio(runs.map((run) => isTextRun(run) ? run.text ?? "" : "").join("")) : 0
613
+ };
614
+ const firstLineJustifyFitStrategy = resolveJustifyFitStrategy(block, true, justificationProfile);
615
+ const continuationJustifyFitStrategy = resolveJustifyFitStrategy(block, false, justificationProfile);
449
616
  const floatingZones = options?.floatingZones;
450
617
  const paragraphYOffset = options?.paragraphYOffset ?? 0;
451
618
  const indent = attrs?.indent;
@@ -476,6 +643,7 @@ function measureParagraph(block, maxWidth, options) {
476
643
  const firstLineFloatingMargins = getFloatingMargins(cumulativeHeight, estimatedFirstLineHeight, floatingZones, paragraphYOffset);
477
644
  const firstLineWidth = Math.max(1, getFloatingAvailableWidth(firstLineFloatingMargins, baseFirstLineWidth));
478
645
  const lines = [];
646
+ let consecutiveHyphenatedLines = 0;
479
647
  if (runs.length === 0) {
480
648
  if (attrs?.suppressEmptyParagraphHeight) {
481
649
  lines.push({
@@ -534,7 +702,8 @@ function measureParagraph(block, maxWidth, options) {
534
702
  totalHeight
535
703
  };
536
704
  }
537
- const trailingGlueWidths = computeTrailingGlueWidths(runs);
705
+ const trailingGlueWidths = computeTrailingGlueWidths(block);
706
+ const protectedCrossRunGlueWidths = computeProtectedCrossRunGlueWidths(block);
538
707
  let currentLine = {
539
708
  fromRun: 0,
540
709
  fromChar: 0,
@@ -542,11 +711,11 @@ function measureParagraph(block, maxWidth, options) {
542
711
  toChar: 0,
543
712
  width: 0,
544
713
  trailingWhitespaceWidth: 0,
545
- regularSpaceCount: 0,
546
- nonBreakingSpaceCount: 0,
714
+ regularSpaceWidth: 0,
547
715
  maxFontSize: DEFAULT_FONT_SIZE,
548
716
  maxFontMetrics: null,
549
717
  maxImageHeightPx: 0,
718
+ maxExactImageHeightPx: 0,
550
719
  maxMathHeightPx: 0,
551
720
  availableWidth: firstLineWidth,
552
721
  leftOffset: firstLineFloatingMargins.leftMargin,
@@ -566,9 +735,17 @@ function measureParagraph(block, maxWidth, options) {
566
735
  if (inlineObjectHeight > finalTypography.lineHeight) {
567
736
  const objectHeight = inlineObjectHeight;
568
737
  const buffer = finalTypography.descent;
569
- if (line.fromRun === line.toRun) {
570
- finalTypography.lineHeight = objectHeight + buffer * 2;
571
- finalTypography.ascent = objectHeight + buffer;
738
+ const soleRun = line.fromRun === line.toRun ? runs[line.fromRun] : void 0;
739
+ if (line.maxExactImageHeightPx >= objectHeight) {
740
+ finalTypography.lineHeight = objectHeight;
741
+ finalTypography.ascent = objectHeight;
742
+ finalTypography.descent = 0;
743
+ return finalTypography;
744
+ }
745
+ if (soleRun && isImageRun(soleRun)) {
746
+ finalTypography.lineHeight = objectHeight;
747
+ finalTypography.ascent = objectHeight;
748
+ finalTypography.descent = 0;
572
749
  } else {
573
750
  finalTypography.lineHeight = objectHeight + buffer;
574
751
  finalTypography.ascent = objectHeight;
@@ -598,7 +775,8 @@ function measureParagraph(block, maxWidth, options) {
598
775
  toRun: currentLine.toRun,
599
776
  toChar: currentLine.toChar,
600
777
  width: Math.max(0, currentLine.width - currentLine.trailingWhitespaceWidth),
601
- ...finalTypography
778
+ ...finalTypography,
779
+ ...currentLine.discretionaryHyphen ? { discretionaryHyphen: currentLine.discretionaryHyphen } : {}
602
780
  };
603
781
  if (currentLine.leftOffset > 0) line.leftOffset = currentLine.leftOffset;
604
782
  if (currentLine.rightOffset > 0) line.rightOffset = currentLine.rightOffset;
@@ -607,6 +785,7 @@ function measureParagraph(block, maxWidth, options) {
607
785
  pendingFloatSkip = 0;
608
786
  }
609
787
  lines.push(line);
788
+ consecutiveHyphenatedLines = currentLine.discretionaryHyphen ? consecutiveHyphenatedLines + 1 : 0;
610
789
  cumulativeHeight += finalTypography.lineHeight;
611
790
  };
612
791
  /**
@@ -625,11 +804,11 @@ function measureParagraph(block, maxWidth, options) {
625
804
  toChar: charIndex,
626
805
  width: 0,
627
806
  trailingWhitespaceWidth: 0,
628
- regularSpaceCount: 0,
629
- nonBreakingSpaceCount: 0,
807
+ regularSpaceWidth: 0,
630
808
  maxFontSize: DEFAULT_FONT_SIZE,
631
809
  maxFontMetrics: null,
632
810
  maxImageHeightPx: 0,
811
+ maxExactImageHeightPx: 0,
633
812
  maxMathHeightPx: 0,
634
813
  availableWidth: adjustedWidth,
635
814
  leftOffset: floatingMargins.leftMargin,
@@ -651,7 +830,9 @@ function measureParagraph(block, maxWidth, options) {
651
830
  const metrics = getFontMetrics(style);
652
831
  if (metrics.singleLineRatio > currentLine.maxFontMetrics.singleLineRatio) currentLine.maxFontMetrics = metrics;
653
832
  };
833
+ let crossRunResume;
654
834
  for (let runIndex = 0; runIndex < runs.length; runIndex++) {
835
+ if (crossRunResume && runIndex < crossRunResume.runIndex) continue;
655
836
  const run = runs[runIndex];
656
837
  if (isLineBreakRun(run)) {
657
838
  currentLine.toRun = runIndex;
@@ -713,6 +894,7 @@ function measureParagraph(block, maxWidth, options) {
713
894
  if (currentLine.width > 0 && currentLine.width + imageWidth > currentLine.availableWidth + WIDTH_TOLERANCE) startNewLine(runIndex, 0);
714
895
  const imageFootprintPx = imageHeight + (run.distTop ?? 0) + (run.distBottom ?? 0);
715
896
  if (imageFootprintPx > currentLine.maxImageHeightPx) currentLine.maxImageHeightPx = imageFootprintPx;
897
+ if (run.exactLineHeight === true && imageFootprintPx > currentLine.maxExactImageHeightPx) currentLine.maxExactImageHeightPx = imageFootprintPx;
716
898
  currentLine.width += imageWidth;
717
899
  currentLine.trailingWhitespaceWidth = 0;
718
900
  currentLine.toRun = runIndex;
@@ -724,11 +906,14 @@ function measureParagraph(block, maxWidth, options) {
724
906
  const style = buildRunFontStyle(run, DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE);
725
907
  updateMaxFont(style);
726
908
  const fieldWidth = measureTextWidth(fallback, style);
727
- if (currentLine.width > 0 && currentLine.width + fieldWidth > currentLine.availableWidth + WIDTH_TOLERANCE) {
909
+ const fieldSpaceWidth = compressibleSpaceWidth(fallback, style);
910
+ const fieldTolerance = isJustifiedParagraph ? justifyFitTolerance(currentLine, lines.length === 0 ? firstLineJustifyFitStrategy : continuationJustifyFitStrategy, fieldSpaceWidth) : WIDTH_TOLERANCE;
911
+ if (currentLine.width > 0 && currentLine.width + fieldWidth > currentLine.availableWidth + fieldTolerance) {
728
912
  startNewLine(runIndex, 0);
729
913
  updateMaxFont(style);
730
914
  }
731
915
  currentLine.width += fieldWidth;
916
+ currentLine.regularSpaceWidth += fieldSpaceWidth;
732
917
  currentLine.trailingWhitespaceWidth = 0;
733
918
  currentLine.toRun = runIndex;
734
919
  currentLine.toChar = 1;
@@ -759,6 +944,7 @@ function measureParagraph(block, maxWidth, options) {
759
944
  const textRun = run;
760
945
  const text = textRun.text;
761
946
  const style = runToFontStyle(textRun);
947
+ const breakPolicy = lineBreakPolicy(block, textRun);
762
948
  const lineHeightStyle = cjkLineHeightStyle(textRun, style);
763
949
  updateMaxFont(lineHeightStyle);
764
950
  if (!text || text.length === 0) {
@@ -766,8 +952,10 @@ function measureParagraph(block, maxWidth, options) {
766
952
  currentLine.toChar = 0;
767
953
  continue;
768
954
  }
769
- const wordBreaks = findWordBreaks(text);
770
- let charIndex = 0;
955
+ const wordBreaks = findWordBreaks(text, breakPolicy);
956
+ let charIndex = crossRunResume?.runIndex === runIndex ? crossRunResume.charIndex : 0;
957
+ if (crossRunResume?.runIndex === runIndex) crossRunResume = void 0;
958
+ let activeHyphenationWord;
771
959
  while (charIndex < text.length) {
772
960
  let nextBreak = text.length;
773
961
  for (const breakPoint of wordBreaks) if (breakPoint > charIndex) {
@@ -778,29 +966,103 @@ function measureParagraph(block, maxWidth, options) {
778
966
  const measuredWord = trimTrailingSpacesAndTabs(word);
779
967
  const wordWidth = measureTextWidth(measuredWord, style);
780
968
  const fullWordWidth = measureTextWidth(word, style);
781
- const regularSpaces = measuredWord.split(" ").length - 1;
782
- const nonBreakingSpaces = measuredWord.split("\xA0").length - 1;
783
- const widthTolerance = isJustifiedParagraph ? Math.max(WIDTH_TOLERANCE, currentLine.availableWidth * justifyShrinkToleranceRatio(block, lines.length === 0, currentLine.regularSpaceCount + regularSpaces, currentLine.nonBreakingSpaceCount + nonBreakingSpaces)) : WIDTH_TOLERANCE;
784
- if (wordWidth > currentLine.availableWidth + widthTolerance) {
969
+ const hangingPunctuationWidth = trailingHangingPunctuationWidth(measuredWord, style, breakPolicy, block.attrs?.overflowPunctuation);
970
+ const isFirstLine = lines.length === 0;
971
+ const regularSpaceWidth = compressibleSpaceWidth(measuredWord, style);
972
+ const widthTolerance = isJustifiedParagraph ? justifyFitTolerance(currentLine, isFirstLine ? firstLineJustifyFitStrategy : continuationJustifyFitStrategy, regularSpaceWidth) : WIDTH_TOLERANCE;
973
+ const automaticHyphenation = block.attrs?.automaticHyphenation;
974
+ const consecutiveLineLimit = automaticHyphenation?.consecutiveLineLimit ?? 0;
975
+ const mayHyphenateLine = automaticHyphenation?.enabled === true && block.attrs?.suppressAutoHyphens !== true && (consecutiveLineLimit === 0 || consecutiveHyphenatedLines < consecutiveLineLimit);
976
+ const isRunTail = nextBreak === text.length;
977
+ const crossRunWord = mayHyphenateLine && isRunTail && word.length > 0 && !isBreakChar(word.at(-1)) ? collectCrossRunWord({
978
+ block,
979
+ startRunIndex: runIndex,
980
+ startChar: charIndex
981
+ }) : void 0;
982
+ if (mayHyphenateLine && crossRunWord === void 0 && measuredWord.length > 0 && (activeHyphenationWord === void 0 || charIndex < activeHyphenationWord.start || charIndex >= activeHyphenationWord.end)) {
983
+ const fullWordEnd = charIndex + measuredWord.length;
984
+ const fullWordText = text.slice(charIndex, fullWordEnd);
985
+ activeHyphenationWord = {
986
+ start: charIndex,
987
+ end: fullWordEnd,
988
+ text: fullWordText,
989
+ breaks: findHyphenationBreaks(fullWordText, breakPolicy)
990
+ };
991
+ }
992
+ const overflowsCurrentLine = wordWidth > 0 && currentLine.width + wordWidth > currentLine.availableWidth + widthTolerance;
993
+ if (mayHyphenateLine && overflowsCurrentLine && activeHyphenationWord) {
994
+ const consumed = charIndex - activeHyphenationWord.start;
995
+ const spaceLeft = currentLine.availableWidth - currentLine.width + widthTolerance;
996
+ const hyphenWidth = measureTextWidth("-", style);
997
+ let fittingBreak;
998
+ for (const breakOffset of activeHyphenationWord.breaks) {
999
+ if (breakOffset <= consumed || breakOffset >= activeHyphenationWord.text.length) continue;
1000
+ if (measureTextWidth(activeHyphenationWord.text.slice(consumed, breakOffset), style) + hyphenWidth <= spaceLeft) fittingBreak = breakOffset;
1001
+ }
1002
+ if (fittingBreak !== void 0) {
1003
+ const absoluteBreak = activeHyphenationWord.start + fittingBreak;
1004
+ const prefix = text.slice(charIndex, absoluteBreak);
1005
+ currentLine.width += measureTextWidth(prefix, style) + hyphenWidth;
1006
+ currentLine.trailingWhitespaceWidth = 0;
1007
+ currentLine.toRun = runIndex;
1008
+ currentLine.toChar = absoluteBreak;
1009
+ currentLine.discretionaryHyphen = { runIndex };
1010
+ startNewLine(runIndex, absoluteBreak);
1011
+ updateMaxFont(lineHeightStyle);
1012
+ charIndex = absoluteBreak;
1013
+ continue;
1014
+ }
1015
+ }
1016
+ if (crossRunWord && currentLine.width + crossRunWord.width > currentLine.availableWidth + widthTolerance) {
1017
+ const spaceLeft = currentLine.availableWidth - currentLine.width + widthTolerance;
1018
+ let fittingPrefix;
1019
+ for (let breakIndex = crossRunWord.breaks.length - 1; breakIndex >= 0; breakIndex -= 1) {
1020
+ const breakOffset = crossRunWord.breaks[breakIndex];
1021
+ if (breakOffset === void 0) continue;
1022
+ if (breakOffset <= 0 || breakOffset >= crossRunWord.text.length) continue;
1023
+ const prefix = measureCrossRunPrefix(crossRunWord, breakOffset);
1024
+ if (prefix && prefix.width + measureTextWidth("-", prefix.hyphenStyle) <= spaceLeft) {
1025
+ fittingPrefix = prefix;
1026
+ break;
1027
+ }
1028
+ }
1029
+ if (fittingPrefix) {
1030
+ for (const segment of fittingPrefix.segments) updateMaxFont(cjkLineHeightStyle(segment.run, segment.style));
1031
+ currentLine.width += fittingPrefix.width + measureTextWidth("-", fittingPrefix.hyphenStyle);
1032
+ currentLine.trailingWhitespaceWidth = 0;
1033
+ currentLine.toRun = fittingPrefix.endRunIndex;
1034
+ currentLine.toChar = fittingPrefix.endChar;
1035
+ currentLine.discretionaryHyphen = { runIndex: fittingPrefix.endRunIndex };
1036
+ startNewLine(fittingPrefix.endRunIndex, fittingPrefix.endChar);
1037
+ if (fittingPrefix.endRunIndex === runIndex) {
1038
+ updateMaxFont(lineHeightStyle);
1039
+ charIndex = fittingPrefix.endChar;
1040
+ continue;
1041
+ }
1042
+ crossRunResume = {
1043
+ runIndex: fittingPrefix.endRunIndex,
1044
+ charIndex: fittingPrefix.endChar
1045
+ };
1046
+ charIndex = text.length;
1047
+ continue;
1048
+ }
1049
+ }
1050
+ if (wordWidth - hangingPunctuationWidth > currentLine.availableWidth + widthTolerance) {
785
1051
  let chunkStart = 0;
786
1052
  while (chunkStart < measuredWord.length) {
787
1053
  const spaceLeft = currentLine.availableWidth - currentLine.width + WIDTH_TOLERANCE;
788
- let bestEnd = findMaxFittingLength(measuredWord.slice(chunkStart), style, spaceLeft);
1054
+ let bestEnd = findMaxFittingLength(measuredWord.slice(chunkStart), style, spaceLeft, currentLine.width === 0, breakPolicy);
789
1055
  if (bestEnd === 0) {
790
- if (currentLine.width > 0) {
791
- startNewLine(runIndex, charIndex + chunkStart);
792
- updateMaxFont(lineHeightStyle);
793
- continue;
794
- }
795
- bestEnd = 1;
1056
+ startNewLine(runIndex, charIndex + chunkStart);
1057
+ updateMaxFont(lineHeightStyle);
1058
+ continue;
796
1059
  }
797
1060
  const chunkEnd = chunkStart + bestEnd;
798
1061
  const chunk = measuredWord.slice(chunkStart, chunkEnd);
799
1062
  const chunkWidth = measureTextWidth(chunk, style);
800
1063
  currentLine.width += chunkWidth;
801
1064
  currentLine.trailingWhitespaceWidth = chunkWidth - measureTextWidth(trimTrailingSpacesAndTabs(chunk), style);
802
- currentLine.regularSpaceCount += chunk.split(" ").length - 1;
803
- currentLine.nonBreakingSpaceCount += chunk.split("\xA0").length - 1;
1065
+ currentLine.regularSpaceWidth += compressibleSpaceWidth(chunk, style);
804
1066
  currentLine.toRun = runIndex;
805
1067
  currentLine.toChar = charIndex + chunkEnd;
806
1068
  chunkStart = chunkEnd;
@@ -812,23 +1074,24 @@ function measureParagraph(block, maxWidth, options) {
812
1074
  const trailingWhitespaceWidth = fullWordWidth - wordWidth;
813
1075
  currentLine.width += trailingWhitespaceWidth;
814
1076
  currentLine.trailingWhitespaceWidth = trailingWhitespaceWidth;
815
- currentLine.regularSpaceCount += word.split(" ").length - 1;
816
- currentLine.nonBreakingSpaceCount += word.split("\xA0").length - 1;
1077
+ currentLine.regularSpaceWidth += compressibleSpaceWidth(word, style);
817
1078
  currentLine.toChar = nextBreak;
818
1079
  charIndex = nextBreak;
819
1080
  continue;
820
1081
  }
821
- const rawGlueWidth = nextBreak === text.length && word.length > 0 && !isBreakChar(word[word.length - 1]) ? trailingGlueWidths[runIndex] ?? 0 : 0;
1082
+ const wordTail = word.at(-1);
1083
+ const protectedGlueWidth = protectedCrossRunGlueWidths[runIndex] ?? 0;
1084
+ const rawGlueWidth = isRunTail && wordTail !== void 0 && (!isBreakChar(wordTail) || protectedGlueWidth > 0) ? Math.max(trailingGlueWidths[runIndex] ?? 0, protectedGlueWidth) : 0;
822
1085
  const glueWidth = rawGlueWidth > 0 && wordWidth + rawGlueWidth <= getPostWrapAvailableWidth() + widthTolerance ? rawGlueWidth : 0;
823
- if (wordWidth > 0 && currentLine.width > 0 && currentLine.width + wordWidth + glueWidth > currentLine.availableWidth + widthTolerance) {
1086
+ const hangingAllowance = glueWidth === 0 ? hangingPunctuationWidth : 0;
1087
+ if (wordWidth > 0 && currentLine.width > 0 && currentLine.width + wordWidth + glueWidth > currentLine.availableWidth + widthTolerance + hangingAllowance) {
824
1088
  startNewLine(runIndex, charIndex);
825
1089
  updateMaxFont(lineHeightStyle);
826
1090
  }
827
1091
  currentLine.width += fullWordWidth;
828
1092
  const wordTrailingWhitespaceWidth = fullWordWidth - wordWidth;
829
1093
  currentLine.trailingWhitespaceWidth = wordWidth === 0 ? currentLine.trailingWhitespaceWidth + wordTrailingWhitespaceWidth : wordTrailingWhitespaceWidth;
830
- currentLine.regularSpaceCount += word.split(" ").length - 1;
831
- currentLine.nonBreakingSpaceCount += word.split("\xA0").length - 1;
1094
+ currentLine.regularSpaceWidth += compressibleSpaceWidth(word, style);
832
1095
  currentLine.toRun = runIndex;
833
1096
  currentLine.toChar = nextBreak;
834
1097
  charIndex = nextBreak;
@@ -27,7 +27,8 @@ type FontStyle = {
27
27
  letterSpacing?: number;
28
28
  textTransform?: "uppercase";
29
29
  fontVariant?: "small-caps";
30
- horizontalScale?: number;
30
+ horizontalScale?: number; /** Enable pair kerning for runs whose authored threshold is met. */
31
+ kerning?: boolean;
31
32
  };
32
33
  /**
33
34
  * Typography metrics for a font
@@ -1,4 +1,4 @@
1
- import { MeasureWorkerRequest, MeasureWorkerResponse } from "./measureWorkerProtocol.js";
1
+ import { MeasureRequestEntry, MeasureWorkerRequest, MeasureWorkerResponse } from "./measureWorkerProtocol.js";
2
2
 
3
3
  //#region src/layout-engine/measure/measureWorker.d.ts
4
4
  /**
@@ -32,7 +32,7 @@ declare function __setMeasureWorkerTransport(factory: TransportFactory | null):
32
32
  * `OffscreenCanvas`/`Worker`, or when the proxy has been disabled by
33
33
  * a previous error.
34
34
  */
35
- declare function prefetchMeasurement(text: string, font: string, letterSpacing: number, horizontalScale: number, fontCacheKey: string, fontFingerprintWidth: number): void;
35
+ declare function prefetchMeasurement(request: MeasureRequestEntry): void;
36
36
  declare function canPrefetchMeasurement(): boolean;
37
37
  /**
38
38
  * Test-only: synchronously drain the pending queue. The caller is