@jasy/pdf 1.0.0-beta.3 → 1.0.0-beta.5

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 (246) hide show
  1. package/dist/api/color.d.ts +1 -24
  2. package/dist/api/color.js +2 -278
  3. package/dist/api/content.d.ts +10 -5
  4. package/dist/api/content.js +29 -13
  5. package/dist/api/descriptor.js +30 -3
  6. package/dist/api/flow.d.ts +55 -0
  7. package/dist/api/flow.js +71 -0
  8. package/dist/api/font-url.d.ts +1 -2
  9. package/dist/api/font-url.js +8 -74
  10. package/dist/api/forms.d.ts +3 -0
  11. package/dist/api/forms.js +3 -0
  12. package/dist/api/gradient.d.ts +1 -53
  13. package/dist/api/gradient.js +2 -97
  14. package/dist/api/index.d.ts +4 -0
  15. package/dist/api/index.js +3 -0
  16. package/dist/api/layout.d.ts +6 -0
  17. package/dist/api/layout.js +9 -3
  18. package/dist/api/page-builder.d.ts +4 -0
  19. package/dist/api/page-builder.js +4 -0
  20. package/dist/api/structure.d.ts +10 -2
  21. package/dist/api/structure.js +9 -6
  22. package/dist/api/table.d.ts +5 -3
  23. package/dist/api/table.js +101 -22
  24. package/dist/api/text.d.ts +8 -8
  25. package/dist/api/text.js +6 -0
  26. package/dist/canvas/painter.d.ts +2 -2
  27. package/dist/canvas/painter.js +2 -2
  28. package/dist/common/color-input.d.ts +24 -0
  29. package/dist/common/color-input.js +278 -0
  30. package/dist/common/diagnostics.d.ts +61 -0
  31. package/dist/common/diagnostics.js +77 -0
  32. package/dist/common/gradient.d.ts +53 -0
  33. package/dist/common/gradient.js +97 -0
  34. package/dist/edit/acroform-reader.js +7 -7
  35. package/dist/edit/appearance.js +3 -3
  36. package/dist/edit/attachments.d.ts +52 -0
  37. package/dist/edit/attachments.js +178 -0
  38. package/dist/edit/document.d.ts +38 -8
  39. package/dist/edit/document.js +208 -51
  40. package/dist/edit/fill.js +8 -23
  41. package/dist/edit/filters.d.ts +15 -0
  42. package/dist/edit/filters.js +157 -0
  43. package/dist/edit/index.d.ts +4 -1
  44. package/dist/edit/index.js +3 -1
  45. package/dist/edit/objects.d.ts +3 -4
  46. package/dist/edit/objects.js +5 -18
  47. package/dist/elements/canvas-element.d.ts +3 -0
  48. package/dist/elements/canvas-element.js +14 -3
  49. package/dist/elements/container-element.d.ts +8 -1
  50. package/dist/elements/container-element.js +49 -17
  51. package/dist/elements/forms/checkbox-element.d.ts +2 -0
  52. package/dist/elements/forms/checkbox-element.js +12 -3
  53. package/dist/elements/forms/choice-element.d.ts +6 -1
  54. package/dist/elements/forms/choice-element.js +17 -6
  55. package/dist/elements/forms/field-font.d.ts +8 -0
  56. package/dist/elements/forms/field-font.js +5 -0
  57. package/dist/elements/forms/push-button-element.d.ts +6 -1
  58. package/dist/elements/forms/push-button-element.js +16 -4
  59. package/dist/elements/forms/radio-element.d.ts +2 -0
  60. package/dist/elements/forms/radio-element.js +12 -3
  61. package/dist/elements/forms/signature-element.d.ts +6 -1
  62. package/dist/elements/forms/signature-element.js +16 -4
  63. package/dist/elements/forms/text-field-element.d.ts +6 -1
  64. package/dist/elements/forms/text-field-element.js +16 -4
  65. package/dist/elements/image-element.d.ts +70 -21
  66. package/dist/elements/image-element.js +173 -52
  67. package/dist/elements/index.d.ts +3 -0
  68. package/dist/elements/index.js +3 -0
  69. package/dist/elements/layout/anchor-element.d.ts +8 -10
  70. package/dist/elements/layout/anchor-element.js +10 -18
  71. package/dist/elements/layout/bookmark-element.d.ts +9 -11
  72. package/dist/elements/layout/bookmark-element.js +11 -20
  73. package/dist/elements/layout/column-break-element.d.ts +17 -0
  74. package/dist/elements/layout/column-break-element.js +26 -0
  75. package/dist/elements/layout/default-text-style-element.d.ts +11 -13
  76. package/dist/elements/layout/default-text-style-element.js +14 -31
  77. package/dist/elements/layout/deferred-element.d.ts +20 -4
  78. package/dist/elements/layout/deferred-element.js +41 -9
  79. package/dist/elements/layout/expanded-element.d.ts +11 -0
  80. package/dist/elements/layout/expanded-element.js +53 -18
  81. package/dist/elements/layout/flow-element.d.ts +109 -0
  82. package/dist/elements/layout/flow-element.js +317 -0
  83. package/dist/elements/layout/keep-together-element.d.ts +8 -18
  84. package/dist/elements/layout/keep-together-element.js +33 -45
  85. package/dist/elements/layout/link-element.d.ts +8 -13
  86. package/dist/elements/layout/link-element.js +9 -19
  87. package/dist/elements/layout/padding-element.d.ts +18 -1
  88. package/dist/elements/layout/padding-element.js +63 -28
  89. package/dist/elements/layout/page-break-element.d.ts +4 -1
  90. package/dist/elements/layout/page-break-element.js +6 -1
  91. package/dist/elements/layout/page-builder-element.d.ts +5 -0
  92. package/dist/elements/layout/page-builder-element.js +13 -2
  93. package/dist/elements/layout/positioned-element.d.ts +23 -4
  94. package/dist/elements/layout/positioned-element.js +42 -12
  95. package/dist/elements/layout/proxy-element.d.ts +47 -0
  96. package/dist/elements/layout/proxy-element.js +106 -0
  97. package/dist/elements/layout/region-element.d.ts +53 -0
  98. package/dist/elements/layout/region-element.js +108 -0
  99. package/dist/elements/layout/repeating-header-element.d.ts +9 -0
  100. package/dist/elements/layout/repeating-header-element.js +55 -13
  101. package/dist/elements/layout/rotated-box-element.d.ts +6 -0
  102. package/dist/elements/layout/rotated-box-element.js +27 -14
  103. package/dist/elements/layout/rotated-element.d.ts +9 -0
  104. package/dist/elements/layout/rotated-element.js +33 -2
  105. package/dist/elements/layout/struct-group.d.ts +9 -17
  106. package/dist/elements/layout/struct-group.js +10 -35
  107. package/dist/elements/line-element.d.ts +1 -0
  108. package/dist/elements/line-element.js +6 -0
  109. package/dist/elements/page-element.d.ts +10 -3
  110. package/dist/elements/page-element.js +39 -31
  111. package/dist/elements/pdf-document-element.d.ts +3 -0
  112. package/dist/elements/pdf-document-element.js +7 -0
  113. package/dist/elements/pdf-element.d.ts +105 -16
  114. package/dist/elements/pdf-element.js +118 -19
  115. package/dist/elements/rectangle-element.d.ts +22 -2
  116. package/dist/elements/rectangle-element.js +119 -55
  117. package/dist/elements/row-element.d.ts +17 -12
  118. package/dist/elements/row-element.js +89 -9
  119. package/dist/elements/svg-element.d.ts +30 -6
  120. package/dist/elements/svg-element.js +91 -26
  121. package/dist/elements/text-element.d.ts +48 -13
  122. package/dist/elements/text-element.js +161 -116
  123. package/dist/forms/acroform.d.ts +9 -1
  124. package/dist/forms/acroform.js +83 -34
  125. package/dist/forms/appearance.d.ts +17 -6
  126. package/dist/forms/appearance.js +18 -16
  127. package/dist/ir/display-list.d.ts +18 -3
  128. package/dist/layout/box-constraints.d.ts +17 -0
  129. package/dist/layout/box-constraints.js +31 -0
  130. package/dist/layout/collect-images.d.ts +6 -4
  131. package/dist/layout/collect-images.js +14 -8
  132. package/dist/layout/flex-item.d.ts +11 -0
  133. package/dist/layout/flex-item.js +51 -0
  134. package/dist/layout/fragmentation.d.ts +23 -9
  135. package/dist/layout/fragmentation.js +62 -19
  136. package/dist/layout/layout-cache.d.ts +53 -0
  137. package/dist/layout/layout-cache.js +110 -0
  138. package/dist/platform/browser-fs.d.ts +1 -2
  139. package/dist/platform/browser-fs.js +3 -6
  140. package/dist/platform/node-fs.d.ts +0 -1
  141. package/dist/platform/node-fs.js +0 -4
  142. package/dist/renderer/anchor-renderer.d.ts +2 -1
  143. package/dist/renderer/anchor-renderer.js +5 -4
  144. package/dist/renderer/bookmark-renderer.d.ts +2 -1
  145. package/dist/renderer/bookmark-renderer.js +5 -4
  146. package/dist/renderer/canvas-renderer.d.ts +2 -1
  147. package/dist/renderer/canvas-renderer.js +5 -2
  148. package/dist/renderer/column-break-renderer.d.ts +8 -0
  149. package/dist/renderer/column-break-renderer.js +15 -0
  150. package/dist/renderer/container-renderer.d.ts +2 -1
  151. package/dist/renderer/container-renderer.js +6 -5
  152. package/dist/renderer/default-text-style-renderer.d.ts +2 -1
  153. package/dist/renderer/default-text-style-renderer.js +2 -3
  154. package/dist/renderer/deferred-renderer.d.ts +2 -1
  155. package/dist/renderer/deferred-renderer.js +2 -3
  156. package/dist/renderer/emoji-image.js +3 -1
  157. package/dist/renderer/expanded-renderer.d.ts +2 -1
  158. package/dist/renderer/expanded-renderer.js +2 -3
  159. package/dist/renderer/flow-renderer.d.ts +16 -0
  160. package/dist/renderer/flow-renderer.js +21 -0
  161. package/dist/renderer/form-field-renderer.d.ts +2 -1
  162. package/dist/renderer/form-field-renderer.js +16 -3
  163. package/dist/renderer/image-renderer.d.ts +2 -1
  164. package/dist/renderer/image-renderer.js +33 -53
  165. package/dist/renderer/index.d.ts +2 -0
  166. package/dist/renderer/index.js +2 -0
  167. package/dist/renderer/keep-together-renderer.d.ts +2 -1
  168. package/dist/renderer/keep-together-renderer.js +2 -3
  169. package/dist/renderer/line-renderer.d.ts +2 -1
  170. package/dist/renderer/line-renderer.js +7 -2
  171. package/dist/renderer/link-renderer.d.ts +2 -1
  172. package/dist/renderer/link-renderer.js +36 -5
  173. package/dist/renderer/padding-renderer.d.ts +2 -1
  174. package/dist/renderer/padding-renderer.js +5 -5
  175. package/dist/renderer/page-break-renderer.d.ts +6 -5
  176. package/dist/renderer/page-break-renderer.js +14 -7
  177. package/dist/renderer/page-builder-renderer.d.ts +2 -1
  178. package/dist/renderer/page-builder-renderer.js +2 -3
  179. package/dist/renderer/page-renderer.js +30 -10
  180. package/dist/renderer/pdf-backend.d.ts +4 -3
  181. package/dist/renderer/pdf-backend.js +14 -7
  182. package/dist/renderer/pdf-document-renderer.d.ts +1 -8
  183. package/dist/renderer/pdf-document-renderer.js +17 -17
  184. package/dist/renderer/pdf-renderer.js +20 -11
  185. package/dist/renderer/positioned-renderer.d.ts +18 -1
  186. package/dist/renderer/positioned-renderer.js +43 -6
  187. package/dist/renderer/rectangle-renderer.d.ts +2 -1
  188. package/dist/renderer/rectangle-renderer.js +18 -8
  189. package/dist/renderer/repeating-header-renderer.d.ts +2 -1
  190. package/dist/renderer/repeating-header-renderer.js +6 -5
  191. package/dist/renderer/rotated-renderer.d.ts +2 -1
  192. package/dist/renderer/rotated-renderer.js +9 -5
  193. package/dist/renderer/row-renderer.d.ts +2 -1
  194. package/dist/renderer/row-renderer.js +6 -5
  195. package/dist/renderer/struct-group-renderer.d.ts +2 -1
  196. package/dist/renderer/struct-group-renderer.js +2 -3
  197. package/dist/renderer/svg-renderer.d.ts +2 -1
  198. package/dist/renderer/svg-renderer.js +8 -2
  199. package/dist/renderer/text-renderer.d.ts +4 -6
  200. package/dist/renderer/text-renderer.js +114 -127
  201. package/dist/svg/gradients.js +5 -0
  202. package/dist/svg/index.d.ts +1 -1
  203. package/dist/svg/index.js +36 -6
  204. package/dist/svg/style.d.ts +2 -0
  205. package/dist/svg/style.js +12 -1
  206. package/dist/text/advance.d.ts +4 -0
  207. package/dist/text/advance.js +17 -4
  208. package/dist/text/bidi.d.ts +0 -2
  209. package/dist/text/bidi.js +0 -10
  210. package/dist/text/glyph-coverage.d.ts +1 -1
  211. package/dist/text/glyph-coverage.js +7 -3
  212. package/dist/text/hyphen-breaks.d.ts +17 -0
  213. package/dist/text/hyphen-breaks.js +54 -0
  214. package/dist/text/line-breaker.d.ts +55 -29
  215. package/dist/text/line-breaker.js +453 -188
  216. package/dist/text/line-metrics.d.ts +8 -3
  217. package/dist/text/line-metrics.js +11 -2
  218. package/dist/text/pdf-text-string.d.ts +21 -0
  219. package/dist/text/pdf-text-string.js +114 -0
  220. package/dist/text/text-decoration.d.ts +5 -5
  221. package/dist/text/text-decoration.js +5 -5
  222. package/dist/text/word-splitting.d.ts +15 -2
  223. package/dist/text/word-splitting.js +45 -13
  224. package/dist/utils/afm-parser.d.ts +2 -0
  225. package/dist/utils/afm-parser.js +10 -1
  226. package/dist/utils/dest-registry.d.ts +1 -1
  227. package/dist/utils/dest-registry.js +3 -2
  228. package/dist/utils/fetch-bytes.d.ts +19 -0
  229. package/dist/utils/fetch-bytes.js +79 -0
  230. package/dist/utils/flex-layout.d.ts +16 -14
  231. package/dist/utils/flex-layout.js +427 -217
  232. package/dist/utils/font-metrics.d.ts +4 -3
  233. package/dist/utils/image-helper.d.ts +59 -11
  234. package/dist/utils/image-helper.js +209 -81
  235. package/dist/utils/pdf-object-manager.d.ts +17 -6
  236. package/dist/utils/pdf-object-manager.js +70 -20
  237. package/dist/utils/renderer-registry.d.ts +49 -2
  238. package/dist/utils/renderer-registry.js +40 -0
  239. package/dist/utils/struct-tree.d.ts +24 -0
  240. package/dist/utils/struct-tree.js +61 -9
  241. package/dist/utils/ttf-parser.d.ts +4 -0
  242. package/dist/utils/ttf-parser.js +30 -6
  243. package/dist/validators/element-validator.js +2 -1
  244. package/package.json +1 -1
  245. package/dist/elements/layout/sized-container-element.d.ts +0 -14
  246. package/dist/elements/layout/sized-container-element.js +0 -32
@@ -1,5 +1,6 @@
1
- import { runAdvance } from "./advance.js";
2
- import { splitLongWord } from "./word-splitting.js";
1
+ import { runAdvance, SOFT_HYPHEN, withoutSoftHyphens } from "./advance.js";
2
+ import { HYPHEN, splitLongWord } from "./word-splitting.js";
3
+ import { headAt, hyphenBreaks, mayBreak, NO_BREAKS } from "./hyphen-breaks.js";
3
4
  /**
4
5
  * How far a JUSTIFIED line's spaces may be squeezed to keep one more word on it.
5
6
  *
@@ -13,20 +14,57 @@ import { splitLongWord } from "./word-splitting.js";
13
14
  * Only justified text uses it: under any other alignment a squeezed line would simply overflow.
14
15
  */
15
16
  export const MAX_SPACE_SHRINK = 1 / 4;
17
+ /**
18
+ * A span's run style: what it sets itself, else what the paragraph sets. The ONE place a span inherits
19
+ * it, so breaking, drawing and the element's own sizing ask the same question.
20
+ */
21
+ export function runStyleOf(seg, defaults) {
22
+ return {
23
+ font: {
24
+ fontFamily: seg.fontFamily || defaults.fontFamily,
25
+ fontSize: seg.fontSize || defaults.fontSize,
26
+ fontStyle: seg.fontStyle || defaults.fontStyle,
27
+ ligatures: seg.ligatures ?? defaults.ligatures,
28
+ },
29
+ letterSpacing: seg.letterSpacing ?? defaults.letterSpacing ?? 0,
30
+ wordSpacing: seg.wordSpacing ?? defaults.wordSpacing ?? 0,
31
+ splitting: {
32
+ breakWord: seg.breakWord ?? defaults.splitting?.breakWord,
33
+ hyphenate: seg.hyphenate ?? defaults.splitting?.hyphenate,
34
+ },
35
+ };
36
+ }
16
37
  /** Three ASCII dots, NOT the "…" glyph (U+2026): plain dots encode in every font - standard-14
17
38
  * (WinAnsi) and any embedded TTF - whereas U+2026 needs a glyph the font may not carry. */
18
39
  const ELLIPSIS = "...";
40
+ /** The spaces that end a line: they hang past its edge, neither drawn nor in its width. */
41
+ const TRAILING_SPACES = / +$/;
19
42
  /**
20
- * Break a plain string into lines that each fit within `maxWidth`, splitting on
21
- * spaces (greedy: a word stays on the current line unless it would overflow).
22
- *
23
- * Single source of truth for plain-string wrapping: both height measurement and
24
- * rendering call this, so they can never disagree. Depends only on `FontMetrics`,
25
- * not the PDF byte writer - the future fragmentation pass can reuse it.
43
+ * A line's spaces, each as wide as its own span makes it - what justification spreads the slack over,
44
+ * and squeezes by at most `shrink` of their width.
26
45
  */
46
+ export function lineGaps(segments, defaults, metrics) {
47
+ let gaps = 0;
48
+ let width = 0;
49
+ for (const seg of segments) {
50
+ const spaces = [...seg.content].filter((c) => c === " ").length;
51
+ if (spaces === 0)
52
+ continue;
53
+ const run = runStyleOf(seg, defaults);
54
+ gaps += spaces;
55
+ width += spaces * (runAdvance(metrics, " ", run.font, run.letterSpacing) + run.wordSpacing);
56
+ }
57
+ return { gaps, width };
58
+ }
59
+ /** How far `line` runs past `room` once a justified line has been squeezed as far as it may be. */
60
+ function overrunOf(line, room, defaults, metrics) {
61
+ const shrink = defaults.shrink ?? 0;
62
+ const squeeze = shrink > 0 ? lineGaps(line.segments, defaults, metrics).width * shrink : 0;
63
+ return line.width - squeeze - room;
64
+ }
27
65
  /**
28
- * The width of `text` set as ONE line, accumulated in EXACTLY the order `wrapStringIntoLines` builds a
29
- * line: the first word, then `+ (space + word)` each time. The order matters because floating-point
66
+ * The width of `text` set as ONE line, accumulated in EXACTLY the order the breaker builds a line: the
67
+ * first word, then `+ (space + word)` each time. The order matters because floating-point
30
68
  * addition is not associative - a box sized by a differently-grouped sum can be one bit too small for
31
69
  * the very line the breaker then measures, and the text wraps inside a box made to fit it. That is not
32
70
  * hypothetical: it is what split a footer sized to its own content.
@@ -43,218 +81,445 @@ export function singleLineWidth(text, font, metrics, letterSpacing = 0, wordSpac
43
81
  : width + (space + runAdvance(metrics, word, font, letterSpacing)), 0);
44
82
  }
45
83
  export function wrapStringIntoLines(text, fontFamily, fontSize, fontStyle, maxWidth, metrics, maxLines, overflow, letterSpacing = 0, options = {}) {
46
- const { wordSpacing = 0, indent = 0, shrink = 0, splitting = {}, ligatures } = options;
47
- let currentLine = "";
48
- let currentWidth = 0;
49
- let gaps = 0; // spaces on the current line, which is what a justified line can squeeze
50
- const lines = [];
51
- const font = { fontFamily, fontSize, fontStyle, ligatures };
52
- // A `\n` is a HARD break: wrap each paragraph on its own, then cut. An EMPTY paragraph still has to
53
- // produce a line, or a deliberate blank line between two blocks silently disappears.
54
- text.split("\n").forEach((paragraph, paragraphIndex) => {
55
- if (paragraphIndex > 0) {
56
- lines.push(currentLine.trim());
57
- currentLine = "";
58
- currentWidth = 0;
59
- gaps = 0;
60
- }
61
- const words = paragraph.split(" ");
62
- words.forEach((word) => {
63
- // Word and space advances come from the one shared primitive (`advance.ts`), the same one
64
- // `naturalWidth` uses - so a bounded and an unbounded layout of the same text agree bit for bit.
65
- const wordWidth = runAdvance(metrics, word, font, letterSpacing);
66
- const spaceWidth = runAdvance(metrics, " ", font, letterSpacing) + wordSpacing;
67
- // The FIRST line has the indent taken out of its room; every later one gets the full box.
68
- const room = lines.length === 0 ? maxWidth - indent : maxWidth;
69
- // Break before a word that won't fit - counting the SPACE that would join it, which the old test
70
- // forgot. It went unnoticed on the first line, where the very first word added a space too many
71
- // and cancelled the error out; after a break `currentWidth` is the bare word, so every following
72
- // test was short by one space and the line could overrun its box by that much.
73
- //
74
- // A single word wider than maxWidth still sits on its (empty) line and overflows, rather than
75
- // pushing a phantom empty line before it (which would over-count the height by a line).
76
- // A word too wide for the box, split into pieces that fit (hyphenation first, break-word as the
77
- // floor). Every piece but the last closes a line; the last carries on as an ordinary word. Off by
78
- // default, so a document that asks for neither reaches the untouched path below.
79
- // The pieces land on lines that are NOT the first one whenever the current line is closed below,
80
- // so they get the full box - `room` only loses the indent while we are genuinely still filling
81
- // the indented first line.
82
- const splitRoom = lines.length === 0 && currentLine === "" ? room : maxWidth;
83
- const split = splitLongWord(word, splitRoom, font, metrics, letterSpacing, splitting);
84
- if (split) {
85
- if (currentLine !== "")
86
- lines.push(currentLine.trim());
87
- for (const piece of split.slice(0, -1))
88
- lines.push(piece);
89
- currentLine = split[split.length - 1];
90
- currentWidth = runAdvance(metrics, currentLine, font, letterSpacing);
91
- gaps = 0;
92
- return;
93
- }
94
- const candidate = currentWidth + (spaceWidth + wordWidth);
95
- if (currentLine === "") {
96
- currentLine = word;
97
- currentWidth = wordWidth;
98
- gaps = 0;
99
- // How much a JUSTIFIED line may be squeezed to keep one more word: the spaces already on the
100
- // line plus the one that would join it, each giving up at most `shrink` of its width. Zero for
101
- // every other alignment, where a squeezed line would simply overflow instead.
102
- // ONE expression, used for the test AND for the running total. Adding the same three numbers in
103
- // a different order gives a different last bit, and a line that lands exactly on `maxWidth` is
104
- // then broken by that bit alone - which is how a footer sized to its own text wrapped.
105
- }
106
- else if (candidate - (gaps + 1) * spaceWidth * shrink > room) {
107
- lines.push(currentLine.trim());
108
- currentLine = word;
109
- currentWidth = wordWidth;
110
- gaps = 0;
111
- }
112
- else {
113
- currentLine += " " + word;
114
- currentWidth = candidate;
115
- gaps += 1;
116
- }
117
- });
118
- });
119
- if (currentLine)
120
- lines.push(currentLine.trim());
121
- // Open-end by default; cap only when maxLines is set (the others get undefined → untouched).
122
- if (maxLines == null || lines.length <= maxLines)
123
- return lines;
124
- const kept = lines.slice(0, maxLines);
125
- if (overflow === "ellipsis") {
126
- const last = kept.length - 1;
127
- kept[last] = ellipsize(kept[last], fontFamily, fontSize, fontStyle,
128
- // The same room that line was broken against: the first one lost the indent to it.
129
- last === 0 ? maxWidth - indent : maxWidth, metrics, letterSpacing, wordSpacing, ligatures);
130
- }
131
- return kept;
84
+ return breakStringIntoLines(text, fontFamily, fontSize, fontStyle, maxWidth, metrics, maxLines, overflow, letterSpacing, options).map((line) => line.text);
85
+ }
86
+ /** How a line that ends at break `k` of `word` ends: a soft hyphen adds a hyphen the source lacks. */
87
+ const endAt = (word, k) => word[k - 1] === SOFT_HYPHEN && word[k - 2] !== "-" ? "hyphen" : "soft";
88
+ /**
89
+ * `wrapStringIntoLines`, with each line's source offset and ending. The ONE breaking pass both share,
90
+ * so a line's position in the source can never disagree with the line itself.
91
+ */
92
+ export function breakStringIntoLines(text, fontFamily, fontSize, fontStyle, maxWidth, metrics, maxLines, overflow, letterSpacing = 0, options = {}) {
93
+ // A string is one span: same breaker, same lines.
94
+ return breakSegmentsIntoLines([{ content: text }], { fontFamily, fontSize, fontStyle, letterSpacing, ...options }, maxWidth, metrics, maxLines, overflow).map((line) => ({
95
+ text: line.segments.map((segment) => segment.content).join(""),
96
+ start: line.start.offset,
97
+ end: line.end,
98
+ }));
132
99
  }
133
100
  /**
134
- * Break styled segments into lines that fit within `maxWidth`. Same greedy word-splitting as the
135
- * string breaker; each line keeps the segments that landed on it, which is what `line-metrics.ts`
136
- * later derives the line's height from. Single source of truth: both height measurement and
137
- * rendering call this.
101
+ * Break text - spans; a plain string is one - into lines that fit within `maxWidth`, greedily: a word
102
+ * stays on the current line unless it would overflow. Each line keeps the segments that landed on it,
103
+ * which is what `line-metrics.ts` derives the line's height from. The one breaker: measuring,
104
+ * fragmenting and drawing all call this.
138
105
  */
139
106
  export function breakSegmentsIntoLines(segments, defaults, maxWidth, metrics, maxLines, overflow) {
140
107
  const lines = [];
108
+ const { indent = 0, shrink = 0 } = defaults;
141
109
  let width = 0;
110
+ // The spaces on the line, summed - what a justified line can squeeze. Summed, not counted: each space
111
+ // is as wide as its own span makes it.
112
+ let gapWidth = 0;
142
113
  let lineSegments = [];
143
114
  let combined = "";
144
- segments.forEach((segment) => {
145
- const family = segment.fontFamily || defaults.fontFamily;
146
- const size = segment.fontSize || defaults.fontSize;
147
- const style = segment.fontStyle || defaults.fontStyle;
148
- const letterSpacing = segment.letterSpacing ?? defaults.letterSpacing ?? 0;
149
- const font = {
150
- fontFamily: family,
151
- fontSize: size,
152
- fontStyle: style,
153
- ligatures: defaults.ligatures,
154
- };
155
- const spaceWidth = runAdvance(metrics, " ", font, letterSpacing);
156
- // A `\n` inside a span is a HARD break, exactly as in the string path: close the line where it
157
- // sits, whatever else was already on it, and start the next one with this segment's style.
115
+ let lineStart = { segment: 0, offset: 0 }; // where the current line begins
116
+ // Whether the line holds a word yet - an empty one too: a space is text as written, so a leading one
117
+ // and both of a double space count.
118
+ let open = false;
119
+ // Whether anything DRAWN is on the line. Only then does a word that does not fit start a new one:
120
+ // a line of spaces alone is empty, and closing it would push a blank line before the word.
121
+ let inked = false;
122
+ let ink = 0; // the line's width up to its last drawn glyph - the spaces after it hang
123
+ // A space that did not fit was not added (it hangs), so the line's text no longer shows that it ends
124
+ // in a break opportunity - this does.
125
+ let hung = false;
126
+ // An empty piece (what a span leaves after its `\n`) has nothing to draw, and its font must not size
127
+ // the line it lands on - a big span ending in `\n` made the next line as tall as itself. Only a blank
128
+ // line keeps one: it is what gives that line its height.
129
+ const close = (segs, lineWidth, end, glued = false) => {
130
+ let width = lineWidth;
131
+ let ends = end;
132
+ // A span boundary is a break too: a line that ends there right after a soft hyphen shows it as a real
133
+ // hyphen, exactly as a break inside a word does. Only when `glued` - the next piece follows with no
134
+ // space between; a line that breaks at a space ends there, and the soft hyphen stays invisible.
135
+ let lastIndex = segs.length - 1;
136
+ while (lastIndex >= 0 && segs[lastIndex].content === "")
137
+ lastIndex--;
138
+ const last = segs[lastIndex];
139
+ if (glued &&
140
+ last?.content.endsWith(SOFT_HYPHEN) &&
141
+ !withoutSoftHyphens(last.content).endsWith("-")) {
142
+ const run = runStyleOf(last, defaults);
143
+ width += runAdvance(metrics, HYPHEN, run.font, run.letterSpacing);
144
+ segs = segs.map((seg, i) => i === lastIndex ? { ...seg, content: withoutSoftHyphens(seg.content) + HYPHEN } : seg);
145
+ ends = "hyphen";
146
+ }
147
+ // The spaces the line ends in hang: not drawn, and `width` (the ink) already stops before them.
148
+ for (let i = segs.length - 1; i >= 0; i--) {
149
+ const kept = segs[i].content.replace(TRAILING_SPACES, "");
150
+ if (kept !== segs[i].content)
151
+ segs = segs.map((seg, j) => (j === i ? { ...seg, content: kept } : seg));
152
+ if (kept !== "")
153
+ break;
154
+ }
155
+ // Soft hyphens leave the line here - a blank line's piece included.
156
+ const stripped = segs.map((seg) => seg.content.includes(SOFT_HYPHEN)
157
+ ? { ...seg, content: withoutSoftHyphens(seg.content) }
158
+ : seg);
159
+ const drawn = stripped.filter((seg) => seg.content !== "");
160
+ const kept = drawn.length > 0 ? drawn : stripped.slice(0, 1);
161
+ const room = lines.length === 0 ? maxWidth - indent : maxWidth;
162
+ lines.push({
163
+ segments: kept,
164
+ width,
165
+ start: lineStart,
166
+ end: ends,
167
+ overrun: overrunOf({ segments: kept, width }, room, defaults, metrics),
168
+ });
169
+ };
170
+ // Where each piece of the line begins in the source: a word that runs across a span boundary can move
171
+ // to the next line together with its earlier part, which then starts that line in an earlier span.
172
+ const origin = new WeakMap();
173
+ const started = (piece, from) => {
174
+ origin.set(piece, from);
175
+ return piece;
176
+ };
177
+ /** The width and ink of `pieces`, summed exactly as `placeWord` builds a line: word by word, a space
178
+ * joining the words inside a piece, nothing between pieces. */
179
+ const measure = (pieces) => {
180
+ let total = 0;
181
+ let ink = 0;
182
+ let open = false;
183
+ for (const piece of pieces) {
184
+ const { font, letterSpacing: spacing, wordSpacing } = runStyleOf(piece, defaults);
185
+ const space = runAdvance(metrics, " ", font, spacing) + wordSpacing;
186
+ piece.content.split(" ").forEach((word, i) => {
187
+ total = total + ((open && i > 0 ? space : 0) + runAdvance(metrics, word, font, spacing));
188
+ open = true;
189
+ if (word !== "")
190
+ ink = total;
191
+ });
192
+ }
193
+ return { width: total, ink };
194
+ };
195
+ /**
196
+ * The line is full and the next word follows the line's last piece with NO space: it continues a word
197
+ * that began in an earlier span (`span("Rech") + span("nung")`). A span boundary is no break
198
+ * opportunity, so the break belongs at the last one before that word - a space, or a hyphen or soft
199
+ * hyphen ending a span - and the word's earlier part moves down with it. `null`: the line has none
200
+ * before it, and the word stays, as the same text in one string would.
201
+ */
202
+ const gluedTail = (next, afterSpace) => {
203
+ const pieces = lineSegments.slice(0, -1); // the last is the current span's own, still empty
204
+ const moved = [];
205
+ let following = next;
206
+ const inks = (keep) => keep.some((p) => withoutSoftHyphens(p.content).trim() !== "");
207
+ if (afterSpace)
208
+ return inks(pieces) ? { keep: pieces, moved, hyphen: false } : null;
209
+ for (let i = pieces.length - 1; i >= 0; i--) {
210
+ const piece = pieces[i];
211
+ const c = piece.content;
212
+ const soft = c.endsWith(SOFT_HYPHEN);
213
+ if (c.endsWith(" ") || soft || (c.endsWith("-") && !/^\d/.test(following))) {
214
+ const keep = pieces.slice(0, i + 1);
215
+ return inks(keep)
216
+ ? { keep, moved, from: moved[0] && origin.get(moved[0]), hyphen: soft }
217
+ : null;
218
+ }
219
+ const space = c.lastIndexOf(" ");
220
+ if (space >= 0) {
221
+ const from = origin.get(piece);
222
+ const head = started({ ...piece, content: c.slice(0, space + 1) }, from);
223
+ const tail = started({ ...piece, content: c.slice(space + 1) }, { segment: from.segment, offset: from.offset + space + 1 });
224
+ const keep = [...pieces.slice(0, i), head];
225
+ return inks(keep)
226
+ ? { keep, moved: [tail, ...moved], from: origin.get(tail), hyphen: false }
227
+ : null;
228
+ }
229
+ moved.unshift(piece);
230
+ following = c + following;
231
+ }
232
+ return null;
233
+ };
234
+ segments.forEach((segment, segmentIndex) => {
235
+ const { font, letterSpacing, wordSpacing, splitting } = runStyleOf(segment, defaults);
236
+ const family = font.fontFamily;
237
+ const spaceWidth = runAdvance(metrics, " ", font, letterSpacing) + wordSpacing;
238
+ const at = (offset) => ({ segment: segmentIndex, offset });
239
+ // A `\n` is a HARD break: close the line where it sits, whatever else was already on it, and start
240
+ // the next one with this segment's style. An EMPTY paragraph still makes a line, or a deliberate
241
+ // blank line between two blocks silently disappears.
242
+ let paragraphStart = 0;
158
243
  segment.content.split("\n").forEach((paragraph, paragraphIndex) => {
159
244
  if (paragraphIndex > 0) {
160
- lines.push({ segments: lineSegments, width });
245
+ close(lineSegments, ink, "hard");
161
246
  width = 0;
247
+ gapWidth = 0;
248
+ open = false;
249
+ inked = false;
250
+ ink = 0;
251
+ hung = false;
162
252
  lineSegments = [];
253
+ lineStart = at(paragraphStart);
163
254
  }
164
255
  const words = paragraph.split(" ");
256
+ let wordStart = paragraphStart;
165
257
  // Start this segment's piece empty; its content is filled word-by-word below. (Not
166
258
  // the original content - otherwise a segment whose FIRST word overflows would carry
167
259
  // its whole text into the line that just closed.)
168
- lineSegments.push({ ...segment, fontFamily: family, content: "" });
260
+ lineSegments.push(started({ ...segment, fontFamily: family, content: "" }, at(paragraphStart)));
169
261
  combined = "";
170
- words.forEach((word, wordIndex) => {
262
+ /** Close the line before a word whose earlier part (`glued.moved`) moves down with it. */
263
+ const carryOver = (glued, wordAt) => {
264
+ close(glued.keep, measure(glued.keep).ink, "soft", glued.hyphen);
265
+ const carried = measure(glued.moved);
266
+ lineSegments = [
267
+ ...glued.moved,
268
+ started({ ...segment, fontFamily: family, content: "" }, at(wordAt)),
269
+ ];
270
+ combined = "";
271
+ lineStart = glued.from;
272
+ width = carried.width;
273
+ ink = carried.ink;
274
+ gapWidth = 0;
275
+ open = true;
276
+ inked = true;
277
+ };
278
+ /**
279
+ * No regular break before a word glued to the line, and it does not fit: Chrome's `overflow-wrap`
280
+ * emergency break, where a break after a character is allowed by THAT character's own setting.
281
+ * As much of the word as fits stays here if its span may split; else the line ends at the span
282
+ * boundary if the span before it may split; else one character is forced on if this span may
283
+ * split at all. `false`: none applies, and the word overflows on this line as before.
284
+ */
285
+ const emergencyBreak = (word, wordAt, room) => {
286
+ let before;
287
+ for (let i = lineSegments.length - 1; i >= 0 && !before; i--)
288
+ if (lineSegments[i].content !== "")
289
+ before = lineSegments[i];
290
+ const beforeMaySplit = before !== undefined && runStyleOf(before, defaults).splitting.breakWord === true;
291
+ let head = null; // how much of the word's source stays on this line
292
+ if (splitting.breakWord) {
293
+ const split = splitLongWord(word, room - width, font, metrics, letterSpacing, {
294
+ breakWord: true,
295
+ });
296
+ // A glyph forced on past the edge is the last resort, below a break at the span boundary.
297
+ const fits = split && runAdvance(metrics, split[0].text, font, letterSpacing) <= room - width;
298
+ if (fits)
299
+ head = split[0].source;
300
+ }
301
+ if (head === null && beforeMaySplit) {
302
+ close(lineSegments, ink, "split");
303
+ startLine(wordAt);
304
+ placeWord(word, wordAt, false);
305
+ return true;
306
+ }
307
+ if (head === null && splitting.breakWord)
308
+ head = String.fromCodePoint(word.codePointAt(0)).length;
309
+ if (head === null)
310
+ return false;
311
+ combined += word.slice(0, head);
312
+ width += runAdvance(metrics, word.slice(0, head), font, letterSpacing);
313
+ ink = width;
314
+ lineSegments[lineSegments.length - 1].content = combined;
315
+ close(lineSegments, ink, "split");
316
+ startLine(wordAt + head);
317
+ placeWord(word.slice(head), wordAt + head, false);
318
+ return true;
319
+ };
320
+ /** An empty line, starting at source offset `startAt` in this segment. */
321
+ const startLine = (startAt) => {
322
+ lineSegments = [started({ ...segment, fontFamily: family, content: "" }, at(startAt))];
323
+ combined = "";
324
+ width = 0;
325
+ gapWidth = 0;
326
+ open = false;
327
+ inked = false;
328
+ ink = 0;
329
+ lineStart = at(startAt);
330
+ };
331
+ // One word, placed at source offset `wordAt`; `joined`: a space joins it to the word before.
332
+ const placeWord = (word, wordAt, joined) => {
333
+ const afterSpace = hung;
334
+ hung = false;
335
+ // The space before this word hung past the edge, so the line is full: the word starts the next
336
+ // one. It must not slip in behind the space it was written after, however narrow it is.
337
+ if (afterSpace && inked && word !== "") {
338
+ close(lineSegments, ink, "soft");
339
+ startLine(wordAt);
340
+ }
341
+ // Only a word that follows something on this line is joined to it by the space.
342
+ const joins = open && joined;
171
343
  const wordWidth = runAdvance(metrics, word, font, letterSpacing);
172
- // Same treatment as the string path: a word too wide for the box is split into pieces that fit.
173
- // Each finished piece closes the line it sits on, keeping this segment's style.
174
- const split = splitLongWord(word, maxWidth, font, metrics, letterSpacing, defaults.splitting ?? {});
344
+ // CSS `text-indent`: the FIRST line has the indent taken out of its room; the pieces of a split
345
+ // word land on the first line only when it is still empty.
346
+ const room = lines.length === 0 ? maxWidth - indent : maxWidth;
347
+ // How much a JUSTIFIED line may be squeezed to keep one more word: the spaces already on the
348
+ // line plus the one that would join it, each giving up at most `shrink` of its width. Zero for
349
+ // every other alignment, where a squeezed line would simply overflow instead. The drawing
350
+ // spreads the same total evenly over the gaps (`justifyExtra`).
351
+ const squeeze = (gapWidth + (joins ? spaceWidth : 0)) * shrink;
352
+ // A hyphen inside the word is a break opportunity, taken only when the whole word does not fit
353
+ // here - the same test, squeeze included, as below.
354
+ const fitsHere = (w) => !open ? w <= room : width + ((joins ? spaceWidth : 0) + w) - squeeze <= room;
355
+ const breaks = mayBreak(word) ? hyphenBreaks(word) : NO_BREAKS;
356
+ if (breaks.length > 0 && !fitsHere(wordWidth)) {
357
+ let head = 0;
358
+ for (const k of breaks) {
359
+ if (!fitsHere(runAdvance(metrics, headAt(word, k), font, letterSpacing)))
360
+ break;
361
+ head = k;
362
+ }
363
+ if (head > 0) {
364
+ const part = headAt(word, head);
365
+ const joiner = joins ? spaceWidth : 0;
366
+ if (!open)
367
+ lineStart = at(wordAt);
368
+ width += joiner + runAdvance(metrics, part, font, letterSpacing);
369
+ ink = width;
370
+ combined += joins ? " " + part : part;
371
+ if (lineSegments.length === 0)
372
+ lineSegments.push(started({ ...segment, fontFamily: family, content: "" }, at(wordAt)));
373
+ lineSegments[lineSegments.length - 1].content = combined;
374
+ close(lineSegments, ink, endAt(word, head));
375
+ startLine(wordAt + head);
376
+ placeWord(word.slice(head), wordAt + head, false);
377
+ return;
378
+ }
379
+ if (inked) {
380
+ const glued = joined ? undefined : gluedTail(word, afterSpace);
381
+ if (glued && glued.moved.length > 0) {
382
+ carryOver(glued, wordAt);
383
+ placeWord(word, wordAt, false);
384
+ return;
385
+ }
386
+ if (glued !== null) {
387
+ close(lineSegments, ink, "soft", !joined);
388
+ startLine(wordAt);
389
+ placeWord(word, wordAt, false);
390
+ return;
391
+ }
392
+ }
393
+ placeWord(headAt(word, breaks[0]), wordAt, joined);
394
+ close(lineSegments, ink, endAt(word, breaks[0]));
395
+ startLine(wordAt + breaks[0]);
396
+ placeWord(word.slice(breaks[0]), wordAt + breaks[0], false);
397
+ return;
398
+ }
399
+ // A word glued to the line - it continues one from an earlier span - that does not fit here.
400
+ if (!joined && !afterSpace && inked && word !== "" && !fitsHere(wordWidth)) {
401
+ const glued = gluedTail(word, afterSpace);
402
+ if (glued && glued.moved.length > 0) {
403
+ carryOver(glued, wordAt);
404
+ placeWord(word, wordAt, false);
405
+ return;
406
+ }
407
+ if (glued === null && emergencyBreak(word, wordAt, room))
408
+ return;
409
+ }
410
+ const splitRoom = lines.length === 0 && !open ? room : maxWidth;
411
+ // A word too wide for the box is split into pieces that fit (hyphenation first, break-word as
412
+ // the floor). Each finished piece closes the line it sits on, keeping this segment's style. Off
413
+ // by default, so a document that asks for neither never gets here.
414
+ const split = splitLongWord(word, splitRoom, font, metrics, letterSpacing, splitting);
175
415
  if (split) {
176
- // Close whatever is already on the line FIRST. The pieces were measured against the full box,
177
- // not against what is left of this line - appending the first one put a line 80 wide into a
178
- // 50 box, and reported 50, which is exactly why it went unnoticed. The string path has always
179
- // pushed its current line before splitting; this is the same move.
180
- if (width > 0) {
181
- lines.push({ segments: lineSegments, width });
182
- lineSegments = [{ ...segment, fontFamily: family, content: "" }];
416
+ // Close whatever is already on the line FIRST: the pieces were measured against the full box,
417
+ // not against what is left of this line.
418
+ if (inked) {
419
+ close(lineSegments, ink, "soft", !joined);
420
+ lineSegments = [started({ ...segment, fontFamily: family, content: "" }, at(wordAt))];
183
421
  combined = "";
184
422
  }
423
+ // Each piece starts where the source text of the previous one ended.
424
+ let pieceAt = wordAt;
185
425
  for (const piece of split.slice(0, -1)) {
186
- lineSegments[lineSegments.length - 1].content = piece;
426
+ lineSegments[lineSegments.length - 1].content = piece.text;
427
+ lineStart = at(pieceAt);
187
428
  // The width a line REPORTS is what alignment and line-metrics work from: measure it.
188
- lines.push({
189
- segments: lineSegments,
190
- width: runAdvance(metrics, piece, font, letterSpacing),
191
- });
192
- lineSegments = [{ ...segment, fontFamily: family, content: "" }];
429
+ close(lineSegments, runAdvance(metrics, piece.text, font, letterSpacing), piece.hyphen ? "hyphen" : "split");
430
+ pieceAt += piece.source;
431
+ lineSegments = [started({ ...segment, fontFamily: family, content: "" }, at(pieceAt))];
193
432
  }
194
- combined = split[split.length - 1];
433
+ lineStart = at(pieceAt);
434
+ combined = split[split.length - 1].text;
195
435
  width = runAdvance(metrics, combined, font, letterSpacing);
436
+ gapWidth = 0;
437
+ open = true;
438
+ inked = true;
439
+ ink = width;
196
440
  lineSegments[lineSegments.length - 1].content = combined;
197
441
  return;
198
442
  }
199
443
  // A space joins this word to what precedes it only INSIDE a segment; segments butt together
200
444
  // with nothing between them, which is what `span("a") + span("b")` draws.
201
- const joiner = wordIndex > 0 ? spaceWidth : 0;
445
+ const joiner = joins ? spaceWidth : 0;
202
446
  // ONE expression for the test and for the running total, grouped exactly as `singleLineWidth`
203
447
  // groups it - see the note there. A width built any other way can be a bit off the one the box
204
448
  // was sized with, and the text wraps inside a box made to hold it.
205
449
  const candidate = width + (joiner + wordWidth);
206
- // Same guard as the string path: don't open a phantom empty line for an over-wide first word -
207
- // place it (overflowing) on the current empty line instead.
208
- if (candidate > maxWidth && width > 0) {
209
- lines.push({ segments: lineSegments, width });
210
- width = wordWidth;
211
- lineSegments = [];
212
- combined = word;
213
- lineSegments.push({ ...segment, content: combined });
214
- }
215
- else {
216
- combined += wordIndex === 0 ? word : " " + word;
217
- width = candidate;
218
- if (lineSegments.length === 0) {
219
- lineSegments.push({ ...segment, fontFamily: family, content: combined });
450
+ // Break before a word that won't fit - counting the space that would join it. A word wider than
451
+ // the whole box still sits on its (empty) line and overflows, rather than pushing a phantom
452
+ // empty line before it.
453
+ if (candidate - squeeze > room && inked) {
454
+ // A space that does not fit hangs past the edge, as in CSS. Closing the line on it would end
455
+ // the paragraph's last line "soft" (stretched when justified) or push a blank line before `\n`.
456
+ if (word === "") {
457
+ hung = true;
458
+ return;
220
459
  }
221
- lineSegments[lineSegments.length - 1].content = combined;
460
+ const glued = joined ? undefined : gluedTail(word, afterSpace);
461
+ if (glued && glued.moved.length > 0) {
462
+ carryOver(glued, wordAt);
463
+ placeWord(word, wordAt, false);
464
+ return;
465
+ }
466
+ // `null`: nowhere to break before this word - it stays on the line, below.
467
+ if (glued !== null) {
468
+ close(lineSegments, ink, "soft", !joined);
469
+ lineStart = at(wordAt);
470
+ width = wordWidth;
471
+ gapWidth = 0;
472
+ ink = wordWidth;
473
+ inked = true;
474
+ lineSegments = [];
475
+ combined = word;
476
+ lineSegments.push(started({ ...segment, content: combined }, at(wordAt)));
477
+ return;
478
+ }
479
+ }
480
+ combined += joins ? " " + word : word;
481
+ width = candidate;
482
+ if (joins)
483
+ gapWidth += spaceWidth;
484
+ open = true;
485
+ if (word !== "") {
486
+ ink = width;
487
+ inked = true;
222
488
  }
489
+ if (lineSegments.length === 0) {
490
+ lineSegments.push(started({ ...segment, fontFamily: family, content: combined }, at(wordAt)));
491
+ }
492
+ lineSegments[lineSegments.length - 1].content = combined;
493
+ };
494
+ words.forEach((word, wordIndex) => {
495
+ const wordAt = wordStart;
496
+ wordStart += word.length + 1;
497
+ placeWord(word, wordAt, wordIndex > 0);
223
498
  });
499
+ paragraphStart += paragraph.length + 1;
224
500
  });
225
501
  });
226
- // Only if something actually landed on it. The string path ends with `if (currentLine)`, so a text
227
- // ending in `\n` is ONE line there - the two must not disagree about the same text. An empty
228
- // paragraph in the MIDDLE still becomes a line; that one is pushed at the break, not here.
502
+ // Only if something actually landed on it: a text ending in `\n` is ONE line. An empty paragraph in
503
+ // the MIDDLE still becomes a line; that one is pushed at the break, not here.
229
504
  if (lineSegments.some((seg) => seg.content !== "")) {
230
- lines.push({ segments: lineSegments, width });
505
+ close(lineSegments, ink, "end");
231
506
  }
232
507
  // Open-end by default; cap only when maxLines is set.
233
- if (maxLines == null || lines.length <= maxLines)
234
- return lines;
235
- const kept = lines.slice(0, maxLines);
236
- if (overflow === "ellipsis")
237
- ellipsizeSegmentLine(kept[kept.length - 1], defaults, maxWidth, metrics);
238
- return kept;
239
- }
240
- /**
241
- * Inverse of `breakSegmentsIntoLines`: flatten broken lines back into a `TextSegment[]`
242
- * that re-wraps to exactly those lines. The wrap consumed the space at each line break,
243
- * so re-insert one between lines (unless the piece already ends with one) - otherwise the
244
- * last word of a line and the first of the next would fuse ("a b" + "c d" -> "a bc d").
245
- * Used by text fragmentation to rebuild the fitted/remainder halves of a split paragraph.
246
- */
247
- export function segmentLinesToSegments(lines) {
248
- const result = [];
249
- lines.forEach((line, lineIndex) => {
250
- line.segments.forEach((segment) => result.push({ ...segment }));
251
- if (lineIndex < lines.length - 1) {
252
- const last = result[result.length - 1];
253
- if (last && !last.content.endsWith(" "))
254
- last.content += " ";
508
+ let kept = lines;
509
+ if (maxLines != null && lines.length > maxLines) {
510
+ // The text ends at the cap, as far as this element goes: its last line is not a wrapped one.
511
+ kept = lines.slice(0, maxLines);
512
+ kept[kept.length - 1] = { ...kept[kept.length - 1], end: "end" };
513
+ if (overflow === "ellipsis") {
514
+ // The same room that line was broken against: the first one lost the indent to it.
515
+ const room = kept.length === 1 ? maxWidth - indent : maxWidth;
516
+ const last = kept[kept.length - 1];
517
+ ellipsizeLine(last, defaults, room, metrics);
518
+ last.overrun = overrunOf(last, room, defaults, metrics);
255
519
  }
256
- });
257
- return result;
520
+ }
521
+ // A piece of a paragraph split across pages draws only its own lines.
522
+ return defaults.shownLines === undefined ? kept : kept.slice(0, defaults.shownLines);
258
523
  }
259
524
  /** Appends "…" to a single line, dropping trailing words (then characters) until the line plus the
260
525
  * ellipsis fits `maxWidth`. Falls back to a bare "…" if not even one character fits. */
@@ -282,22 +547,22 @@ function ellipsize(line, fontFamily, fontSize, fontStyle, maxWidth, metrics, let
282
547
  }
283
548
  return ELLIPSIS;
284
549
  }
285
- /** Ellipsizes the LAST segment of a truncated segment line in place (within the width left by the
286
- * segments before it) and recomputes the line width. */
287
- function ellipsizeSegmentLine(line, defaults, maxWidth, metrics) {
550
+ /** Ellipsizes the LAST segment of a truncated line in place (within the width left by the segments
551
+ * before it) and recomputes the line width. */
552
+ function ellipsizeLine(line, defaults, maxWidth, metrics) {
288
553
  const segs = line.segments;
289
554
  if (segs.length === 0)
290
555
  return;
291
- const lsOf = (seg) => seg.letterSpacing ?? defaults.letterSpacing ?? 0;
292
- const widthOf = (seg) => runAdvance(metrics, seg.content, {
293
- fontFamily: seg.fontFamily || defaults.fontFamily,
294
- fontSize: seg.fontSize || defaults.fontSize,
295
- fontStyle: seg.fontStyle || defaults.fontStyle,
296
- }, lsOf(seg));
556
+ // Summed word by word, as the breaker sums a line - one owner for a line's width.
557
+ const widthOf = (seg) => {
558
+ const run = runStyleOf(seg, defaults);
559
+ return singleLineWidth(seg.content, run.font, metrics, run.letterSpacing, run.wordSpacing);
560
+ };
297
561
  let prefix = 0;
298
562
  for (let i = 0; i < segs.length - 1; i++)
299
563
  prefix += widthOf(segs[i]);
300
564
  const last = segs[segs.length - 1];
301
- last.content = ellipsize(last.content, last.fontFamily || defaults.fontFamily, last.fontSize || defaults.fontSize, last.fontStyle || defaults.fontStyle, maxWidth - prefix, metrics, lsOf(last));
565
+ const run = runStyleOf(last, defaults);
566
+ last.content = ellipsize(last.content, run.font.fontFamily, run.font.fontSize, run.font.fontStyle, maxWidth - prefix, metrics, run.letterSpacing, run.wordSpacing, run.font.ligatures);
302
567
  line.width = prefix + widthOf(last);
303
568
  }