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