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

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