@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
@@ -3,42 +3,30 @@ import { HorizontalAlignment } from "../elements/pdf-element.js";
3
3
  import { VERTICAL_TEXT_SHIFT } from "../elements/text-element.js";
4
4
  import { isEmojiCodePoint } from "../text/emoji-codepoints.js";
5
5
  import { emojiImageNode } from "./emoji-image.js";
6
- import { singleLineWidth, MAX_SPACE_SHRINK, wrapStringIntoLines, breakSegmentsIntoLines, } from "../text/line-breaker.js";
7
- import { lineBoxForSegmentLine, lineBoxForString } from "../text/line-metrics.js";
6
+ import { MAX_SPACE_SHRINK, breakSegmentsIntoLines, } from "../text/line-breaker.js";
7
+ import { lineBoxForSegmentLine } from "../text/line-metrics.js";
8
8
  import { runAdvance } from "../text/advance.js";
9
- import { visualRuns, visualRunsOf } from "../text/bidi.js";
9
+ import { visualRunsOf } from "../text/bidi.js";
10
10
  import { skipInkSegments, strikethroughStroke, underlineStroke, } from "../text/text-decoration.js";
11
11
  import { quadToCubic } from "../vector/path.js";
12
+ /** What a link says when read aloud (its `/Contents`): its pieces, a space where a line ended. */
13
+ function spokenText(pieces) {
14
+ let out = "";
15
+ pieces.forEach((piece, i) => {
16
+ if (i > 0 && piece.y !== pieces[i - 1].y)
17
+ out += " ";
18
+ out += piece.text;
19
+ });
20
+ return out.replace(/\s+/g, " ").trim();
21
+ }
12
22
  export class TextRenderer {
13
- // Measuring only needs metrics, not the full object manager. (The render pass below
14
- // still receives the manager because it also registers fonts/images.)
15
- static calculateTextHeight(content, fontSize, fontFamily, fontStyle, objectManager, maxWidth, maxLines, overflow, lineHeight, letterSpacing = 0, lineOptions = {}) {
16
- // Plain string: every wrapped line gets the same box.
17
- if (typeof content === "string") {
18
- const lines = wrapStringIntoLines(content, fontFamily, fontSize, fontStyle, maxWidth, objectManager, maxLines, overflow, letterSpacing, lineOptions);
19
- const box = lineBoxForString(objectManager, fontFamily, fontStyle, fontSize, lineHeight);
20
- return lines.length * box.height;
21
- }
22
- // Segments: each line's box comes from the fonts actually on it. `splitting` has to travel with
23
- // them for the same reason `letterSpacing` does: it changes where a line ENDS, so leaving it out
24
- // measures a paragraph at one line count and draws it at another.
25
- const defaults = {
26
- fontFamily,
27
- fontSize,
28
- fontStyle,
29
- letterSpacing,
30
- splitting: lineOptions.splitting,
31
- ligatures: lineOptions.ligatures,
32
- };
33
- const lines = breakSegmentsIntoLines(content, defaults, maxWidth, objectManager, maxLines, overflow);
34
- return lines.reduce((total, line) => total + lineBoxForSegmentLine(line, defaults, objectManager, lineHeight).height, 0);
35
- }
36
- static async render(textElement, objectManager) {
37
- const { x, y, width, fontSize, color, content, fontFamily, fontStyle, textAlignment, maxLines, overflow, lineHeight, underline, strikethrough, skipInk, letterSpacing, direction, wordSpacing, textIndent, breakWord, hyphenate, ligatures, role, } = textElement.getProps();
38
- // Component -> display list. Wrapping and positioning stay here; the backend
39
- // turns each run into BT/Tf/Td/Tj/ET. The wrapping algorithm is unchanged from
40
- // the original renderer - unifying it into the engine is Phase 3.
41
- const { runs, links, decorations } = TextRenderer._buildRuns(content, fontSize, fontFamily, fontStyle, objectManager, width ?? Number.NaN, textAlignment, color, x, y, maxLines, overflow, lineHeight, underline, strikethrough, skipInk, letterSpacing, direction, wordSpacing, textIndent, breakWord, hyphenate, ligatures);
23
+ static async render(textElement, objectManager, scope) {
24
+ const { x, y, width, fontSize, color, spans, fontFamily, fontStyle, textAlignment, maxLines, overflow, lineHeight, underline, strikethrough, skipInk, letterSpacing, direction, wordSpacing, textIndent, ligatures, role, breaking, } = textElement.getProps();
25
+ // Component -> display list: the lines come from the one breaker, positioning stays here; the
26
+ // backend turns each run into BT/Tf/Td/Tj/ET.
27
+ const { runs, links, decorations, linkGroups } = TextRenderer._buildRuns(spans, breaking, fontSize, fontFamily, fontStyle, objectManager, width ?? Number.NaN, textAlignment, color,
28
+ // Every run, decoration and link rect derives from this one absolute origin.
29
+ scope.origin.x + (x ?? 0), scope.origin.y + (y ?? 0), maxLines, overflow, lineHeight, underline, strikethrough, skipInk, letterSpacing, direction, wordSpacing, textIndent, ligatures);
42
30
  // Accessible tagging: this whole text block is one structure element (a paragraph P, or a heading
43
31
  // from `role`); each run's text lands under it. Emoji sub-nodes produced below stay untagged (drawn
44
32
  // as artifacts). Keyed by structId so a block split across pages stays one element.
@@ -47,6 +35,25 @@ export class TextRenderer {
47
35
  const key = objectManager.struct.openElement(textElement.structId, pdfRole);
48
36
  for (const run of runs)
49
37
  run.tag = { role: pdfRole, key };
38
+ // An inline link is a /Link element inside the paragraph: it holds its own text and its
39
+ // annotations, which describe themselves by that text (PDF/UA 7.18.5). One cut by a page break
40
+ // stays ONE element: the next piece of the paragraph continues the link its last one ended in.
41
+ const struct = objectManager.struct;
42
+ const carried = struct.takeCarriedLink(textElement.structId);
43
+ linkGroups.forEach((group, i) => {
44
+ const continues = i === 0 && group.runs[0] === runs[0] && carried?.target === group.target;
45
+ const linkKey = continues ? carried.key : struct.addElement("Link", key);
46
+ for (const run of group.runs)
47
+ run.tag = { role: "Link", key: linkKey };
48
+ const first = group.links[0];
49
+ const alt = spokenText(group.pieces) || (first.href ?? first.dest ?? "");
50
+ for (const link of group.links)
51
+ Object.assign(link, { structKey: linkKey, alt });
52
+ const last = group.runs[group.runs.length - 1];
53
+ if (i === linkGroups.length - 1 && last === runs[runs.length - 1]) {
54
+ struct.carryLink(textElement.structId, group.target, linkKey);
55
+ }
56
+ });
50
57
  }
51
58
  // Color fonts (COLR/CPAL): expand each run's emoji into filled vector layers (or fetched images
52
59
  // for an image emoji source); plain runs and monochrome fonts pass straight through unchanged.
@@ -224,11 +231,17 @@ export class TextRenderer {
224
231
  // Lay the content out into absolutely-positioned text runs. Glyph positions match
225
232
  // the previous hand-written operators exactly (verified by pixel-identical render);
226
233
  // only the output form changed from PDF strings to `TextRun`s.
227
- static _buildRuns(content, fontSize, fontFamily, fontStyle, objectManager, maxWidth, textAlignment, color, initialX, yPosition, maxLines, overflow, lineHeight, underline = false, strikethrough = false, skipInk = false, letterSpacing = 0, direction = "ltr", wordSpacing = 0, textIndent = 0, breakWord = false, hyphenate, ligatures = true) {
234
+ static _buildRuns(content,
235
+ // How the text is broken - the element's own settings, the ones it was measured with.
236
+ breaking, fontSize, fontFamily, fontStyle, objectManager, maxWidth, textAlignment, color, initialX, yPosition, maxLines, overflow, lineHeight, underline = false, strikethrough = false, skipInk = false, letterSpacing = 0, direction = "ltr", wordSpacing = 0, textIndent = 0, ligatures = true) {
228
237
  const runs = [];
229
238
  // /Link annotation rects for any `href` spans, collected as we place segments. Empty for the
230
239
  // common (no-href) case, so plain text keeps producing exactly the runs it did before.
231
240
  const links = [];
241
+ // The same links grouped as the reader meets them: consecutive runs of one target are ONE link,
242
+ // across a line break too, with the text they cover (for the structure tree).
243
+ const linkGroups = [];
244
+ let openLink;
232
245
  // Underline / strikethrough strokes, one per decorated run (so a wrapped paragraph gets one
233
246
  // per line, and a decorated span only spans its own glyphs).
234
247
  const decorations = [];
@@ -266,34 +279,41 @@ export class TextRenderer {
266
279
  // Negative slack is real now: the breaker keeps a word when the line can be SQUEEZED to hold
267
280
  // it, so justification has to deliver that squeeze. The floor is the same limit the breaker
268
281
  // used, which is what stops a line it never promised from being crushed.
269
- const spaceWidth = runAdvance(objectManager, " ", { fontFamily, fontSize, fontStyle }, letterSpacing);
282
+ // A space as the breaker counts it: word-spacing included.
283
+ const spaceWidth = runAdvance(objectManager, " ", { fontFamily, fontSize, fontStyle }, letterSpacing) +
284
+ wordSpacing;
270
285
  const extra = Math.max(slack / gaps, -spaceWidth * MAX_SPACE_SHRINK);
271
286
  // The last line is never STRETCHED - the rule print and CSS follow. It may still be SQUEEZED:
272
287
  // the breaker was allowed to pack it that way, and drawing it loose would push it out of its box.
273
288
  return isLast ? Math.min(0, extra) : extra;
274
289
  };
275
290
  /**
276
- * Push one drawn piece and return the advance it took. When the line is justified the pen jumps an
277
- * extra `extra` at every space, which means one run per word - the only way that works for an
278
- * embedded font, whose spaces `Tw` cannot reach.
291
+ * Push one drawn piece and return the advance it took. When the spaces get more room (word-spacing,
292
+ * justification) the pen jumps an extra `extra` at every space, which means one run per word - the
293
+ * only way that works for an embedded font, whose spaces `Tw` cannot reach.
279
294
  *
280
- * A SHAPED piece is never split: its glyph list is in drawn order with ligatures merged, so no
281
- * slice of it is correct. Such a piece keeps its natural spacing.
295
+ * A SHAPED piece is split there too, each word shaped on its own (`shape`): no slice of its glyph
296
+ * list is correct, but neither a ligature nor Arabic joining crosses a space - and a word plus a
297
+ * space is what the breaker measured.
282
298
  */
283
- const emitPiece = (piece, x, extra, style, runFont, spacing) => {
299
+ const emitPiece = (piece, shape, rtl, x, extra, style, runFont, spacing) => {
284
300
  const push = (at, text, glyphs) => {
285
301
  runs.push({ type: "text", x: at, text, ...(glyphs ? { glyphs } : {}), ...style });
286
302
  };
287
- if (extra === 0 || piece.glyphs || !piece.text.includes(" ")) {
303
+ if (extra === 0 || !piece.text.includes(" ")) {
288
304
  push(x, piece.text, piece.glyphs);
289
305
  return runAdvance(objectManager, piece.text, runFont, spacing);
290
306
  }
291
307
  const spaceWidth = runAdvance(objectManager, " ", runFont, spacing);
308
+ // A shaped piece holds its LOGICAL text: drawn right to left, its words come in reverse.
292
309
  const words = piece.text.split(" ");
310
+ if (piece.glyphs && rtl)
311
+ words.reverse();
293
312
  let pen = x;
294
313
  words.forEach((word, i) => {
295
314
  if (word !== "") {
296
- push(pen, word);
315
+ const drawn = piece.glyphs ? shape(word) : { text: word };
316
+ push(pen, drawn.text, drawn.glyphs);
297
317
  pen += runAdvance(objectManager, word, runFont, spacing);
298
318
  }
299
319
  if (i < words.length - 1)
@@ -361,62 +381,25 @@ export class TextRenderer {
361
381
  const ordered = part.rtl ? [...shaped].reverse() : shaped;
362
382
  return { text: part.logical, glyphs: ordered.map((g) => g.glyph) };
363
383
  };
364
- // The advance the viewer will use: plain glyph widths (a `Tj` never kerns) plus one
365
- // `letterSpacing` per code point (the `Tc` operator), from the one shared `advance.ts` primitive.
366
- const font = { fontFamily, fontSize, fontStyle, ligatures };
367
- // --- Plain string: one run per wrapped line. ---
368
- if (typeof content === "string") {
369
- const lines = wrapStringIntoLines(content, fontFamily, fontSize, fontStyle, maxWidth, objectManager, maxLines, overflow, letterSpacing,
370
- // The SAME knobs the measure pass used (`TextElement.lineOptions`), or a line would be
371
- // measured one width and drawn another.
372
- {
373
- wordSpacing,
374
- indent: textIndent,
375
- shrink: align === HorizontalAlignment.justify ? MAX_SPACE_SHRINK : 0,
376
- splitting: { breakWord, hyphenate },
377
- ligatures,
378
- });
379
- // yPosition is the top of the text box (top-left). The line box seats its own baseline; lines
380
- // then stack by that box's height. Same numbers as calculateTextHeight, by construction.
381
- const box = lineBoxForString(objectManager, fontFamily, fontStyle, fontSize, lineHeight);
382
- lines.forEach((line, index) => {
383
- const lineWidth = singleLineWidth(line, font, objectManager, letterSpacing, wordSpacing);
384
- // CSS `text-indent`: the FIRST line starts in, and had that much less room to fill.
385
- const indent = index === 0 ? textIndent : 0;
386
- let x = initialX + indent + alignmentOffset(lineWidth, indent);
387
- const baseline = yPosition + box.baseline + box.height * index;
388
- // Latin-only text in an `ltr` paragraph comes back as one unchanged piece.
389
- // Word-spacing is a per-gap advance just like the justification slack, so it rides the same
390
- // mechanism: `Tw` would reach only the single byte 32 and miss every embedded font.
391
- const extra = wordSpacing + justifyExtra(line, lineWidth, index === lines.length - 1, indent);
392
- for (const part of visualRuns(line, direction)) {
393
- const piece = shapedPiece(part, fontFamily, fontStyle);
394
- const partWidth = emitPiece(piece, x, extra, {
395
- y: baseline,
396
- fontFamily,
397
- fontStyle,
398
- fontSize,
399
- color,
400
- ...(letterSpacing ? { letterSpacing } : {}),
401
- }, font, letterSpacing);
402
- decorate(piece.text, x, partWidth, baseline, fontFamily, fontStyle, fontSize, color, underline, strikethrough, letterSpacing);
403
- x += partWidth;
404
- }
405
- });
406
- return { runs, links, decorations };
407
- }
408
- // --- Segments: break into lines (shared breaker), then emit one run per segment.
384
+ // Break into lines (the one breaker), then emit one run per segment.
409
385
  // Segment 0 starts at the aligned line origin; each following segment is offset by
410
386
  // the previous segment's kerning-free advance. Each line drops by its OWN leading
411
387
  // (tallest font on that line), so mixed-font lines space correctly and the drawn
412
388
  // height matches the measured height.
413
- const pushLine = (line, lineY, isLast) => {
414
- let x = initialX + alignmentOffset(line.width);
415
- const extra = justifyExtra(line.segments.map((seg) => seg.content).join(""), line.width, isLast);
389
+ const pushLine = (line, lineY, isLast, indent) => {
390
+ // CSS `text-indent`: the FIRST line starts in, with that much less room.
391
+ let x = initialX + indent + alignmentOffset(line.width, indent);
392
+ // Word-spacing is a per-gap advance just like the justification slack, so it rides the same
393
+ // mechanism: `Tw` would reach only the single byte 32 and miss every embedded font.
394
+ const extra = wordSpacing +
395
+ justifyExtra(line.segments.map((seg) => seg.content).join(""), line.width, isLast, indent);
416
396
  // Across span boundaries, so a Hebrew span is ordered against its neighbours rather than inside
417
397
  // itself. Each run keeps the segment it came from, and with it that span's style.
418
398
  const ordered = visualRunsOf(line.segments.map((segment) => ({ text: segment.content, source: segment })), direction);
419
399
  ordered.forEach((part) => {
400
+ // A blank line keeps its height (its box is sized from the piece), but there is nothing to draw.
401
+ if (part.text === "")
402
+ return;
420
403
  const segment = part.source;
421
404
  const family = segment.fontFamily || fontFamily;
422
405
  const size = segment.fontSize || fontSize;
@@ -429,21 +412,24 @@ export class TextRenderer {
429
412
  // ride along, since both are placed from the same y.
430
413
  const shift = VERTICAL_TEXT_SHIFT[segment.verticalAlign ?? "baseline"] * size;
431
414
  const runY = lineY - shift;
432
- const drawn = emitPiece(piece, x, extra, {
415
+ const firstRun = runs.length;
416
+ const drawn = emitPiece(piece, (word) => shapedPiece({ text: word, logical: word, rtl: part.rtl }, family, style), part.rtl, x, extra, {
433
417
  y: runY,
434
418
  fontFamily: family,
435
419
  fontStyle: style,
436
420
  fontSize: size,
437
421
  color: runColor,
438
422
  ...(segLetterSpacing ? { letterSpacing: segLetterSpacing } : {}),
439
- }, { fontFamily: family, fontSize: size, fontStyle: style }, segLetterSpacing);
423
+ },
424
+ // `ligatures` is part of the font: the advances must be the glyphs that are drawn.
425
+ { fontFamily: family, fontSize: size, fontStyle: style, ligatures }, segLetterSpacing);
440
426
  decorate(runText, x, drawn, runY, family, style, size, runColor, segment.underline ?? underline, segment.strikethrough ?? strikethrough, segLetterSpacing);
441
427
  // An href/to span becomes a /Link annotation over exactly this run's glyph box: from its own
442
428
  // ascent above the baseline down to its own descender. A span wrapped across lines yields
443
429
  // one rect per line.
444
430
  if (segment.href !== undefined || segment.dest !== undefined) {
445
431
  const v = objectManager.getFontVerticals(family, style);
446
- links.push({
432
+ const link = {
447
433
  type: "link",
448
434
  x,
449
435
  y: runY - v.ascent * size,
@@ -451,34 +437,38 @@ export class TextRenderer {
451
437
  height: (v.ascent + v.descent) * size,
452
438
  href: segment.href,
453
439
  dest: segment.dest,
454
- });
440
+ };
441
+ links.push(link);
442
+ const target = segment.href !== undefined ? `href:${segment.href}` : `dest:${segment.dest}`;
443
+ if (openLink?.target !== target) {
444
+ openLink = { target, group: { target, links: [], runs: [], pieces: [] } };
445
+ linkGroups.push(openLink.group);
446
+ }
447
+ openLink.group.links.push(link);
448
+ openLink.group.runs.push(...runs.slice(firstRun));
449
+ openLink.group.pieces.push({ text: runText, y: runY });
450
+ }
451
+ else if (runText.trim() !== "") {
452
+ openLink = undefined;
455
453
  }
456
454
  x += drawn;
457
455
  });
458
456
  };
459
457
  // yPosition is the top of the text box (top-left). Each line seats its own baseline inside its
460
458
  // own box (tallest ascent / deepest descent ON THAT LINE), then the next line starts below it.
461
- // The seam flips the whole thing to PDF space. `letterSpacing` MUST be in the defaults so a span
462
- // that does not override it wraps and aligns with the element's spacing - the same defaults the
463
- // measure path (calculateTextHeight) uses, or segmented spaced text mis-wraps (measured != drawn).
464
- const defaults = {
465
- fontFamily,
466
- fontSize,
467
- fontStyle,
468
- letterSpacing,
469
- splitting: { breakWord, hyphenate },
470
- ligatures,
471
- };
459
+ // The seam flips the whole thing to PDF space. The lines are broken with the element's own
460
+ // settings - the ones `textHeight` measured with - so measured equals drawn by construction.
461
+ const defaults = breaking;
472
462
  let top = yPosition;
473
- // Materialised, because justification has to know which line is the LAST one of the paragraph.
474
- const segmentLines = [
475
- ...breakSegmentsIntoLines(content, defaults, maxWidth, objectManager, maxLines, overflow),
476
- ];
463
+ const segmentLines = breakSegmentsIntoLines(content, defaults, maxWidth, objectManager, maxLines, overflow);
477
464
  segmentLines.forEach((line, index) => {
478
465
  const box = lineBoxForSegmentLine(line, defaults, objectManager, lineHeight);
479
- pushLine(line, top + box.baseline, index === segmentLines.length - 1);
466
+ // Only a line that WRAPPED is stretched: the end of a paragraph and the line before a `\n` keep
467
+ // their natural spacing (CSS). The last line of a piece continued on the next page wrapped.
468
+ const ends = line.end === "hard" || line.end === "end";
469
+ pushLine(line, top + box.baseline, ends, index === 0 ? textIndent : 0);
480
470
  top += box.height;
481
471
  });
482
- return { runs, links, decorations };
472
+ return { runs, links, decorations, linkGroups };
483
473
  }
484
474
  }
@@ -135,6 +135,11 @@ export function resolveSvgGradient(def, defs, commands) {
135
135
  ];
136
136
  const scale = Math.abs(matrix[0]);
137
137
  const extend = SPREAD[attributes["spreadMethod"] ?? "pad"] ?? "pad";
138
+ if (extend !== "pad") {
139
+ // A PDF shading only clamps its end colours; repeating or mirroring would need a tiling pattern.
140
+ // Drawn as `pad` it is a different picture - so it is named.
141
+ throw new SvgUnsupportedError(`spreadMethod="${extend}" on a gradient`, "Only pad (the default) is drawn. Expand the repeats into stops in your editor.");
142
+ }
138
143
  if (def.kind === "linear") {
139
144
  // Both anchors go through the transform, so a rotation or a shear lands correctly by itself.
140
145
  const [x0, y0] = point(px(num(attributes["x1"], 0)), py(num(attributes["y1"], 0)));
package/dist/svg/index.js CHANGED
@@ -112,9 +112,14 @@ function rootMapping(root, target) {
112
112
  * it draws something the file did not describe: a drop-shadow logo as a hard silhouette. `clip-path`
113
113
  * used to be listed here; it is resolved now, which is the honest version of the same rule.
114
114
  */
115
+ const MARKER_HINT = "Markers (arrowheads, dots on a line) are not drawn yet; expand them into shapes in your editor.";
115
116
  const REFUSED_ATTRIBUTES = {
116
117
  mask: "Masks are pixel operations; flatten the artwork in your editor instead.",
117
118
  filter: "Filters are pixel operations; rasterise that part to a PNG instead.",
119
+ marker: MARKER_HINT,
120
+ "marker-start": MARKER_HINT,
121
+ "marker-mid": MARKER_HINT,
122
+ "marker-end": MARKER_HINT,
118
123
  };
119
124
  function refuseAttributes(tagName, read) {
120
125
  for (const [name, hint] of Object.entries(REFUSED_ATTRIBUTES)) {
@@ -210,6 +215,10 @@ function walk(node, parentStyle, rules, clips, gradients, out, depth = 0) {
210
215
  // Everything below reads through the cascade, not the raw attributes: `filter="url(#f)"` and
211
216
  // `style="filter:url(#f)"` are the same instruction, and a `<style>` rule can set either.
212
217
  const read = cascadedReader(attributes, css);
218
+ // `display: none` takes the element out with everything inside it - nothing of it is drawn, so
219
+ // nothing of it can be unsupported either.
220
+ if (read("display")?.trim() === "none")
221
+ return;
213
222
  refuseAttributes(tagName, read);
214
223
  const style = resolveStyle(parentStyle, attributes, css);
215
224
  const transform = attributes["transform"]
@@ -248,7 +257,7 @@ function walk(node, parentStyle, rules, clips, gradients, out, depth = 0) {
248
257
  }
249
258
  // A shape with neither fill nor stroke, or with no geometry, paints nothing - and an empty Path
250
259
  // node would only cost bytes.
251
- if (commands.length > 0 && (style.fill || style.stroke)) {
260
+ if (commands.length > 0 && style.visible && (style.fill || style.stroke)) {
252
261
  // A gradient is resolved HERE too, and for the same reason: in its default units its anchors
253
262
  // are fractions of the SHAPE's bounding box, which only exists at this point.
254
263
  const shapeAlpha = style.fillOpacity * style.opacity;
@@ -30,6 +30,8 @@ export interface SvgStyle {
30
30
  fillOpacity: number;
31
31
  strokeOpacity: number;
32
32
  opacity: number;
33
+ /** `visibility` (inherits): a hidden shape paints nothing, but a child may set `visible` again. */
34
+ visible: boolean;
33
35
  }
34
36
  /** The colour a shape actually paints with: its raw colour, times the opacities that reached it. */
35
37
  export declare function paintAlpha(color: Color, opacity: number): Color;
package/dist/svg/style.js CHANGED
@@ -18,6 +18,7 @@ export const ROOT_STYLE = {
18
18
  fillOpacity: 1,
19
19
  strokeOpacity: 1,
20
20
  opacity: 1,
21
+ visible: true,
21
22
  };
22
23
  /** Splits a `style=""` bag into declarations. It wins over both other sources, as in CSS. */
23
24
  function styleBag(value) {
@@ -140,5 +141,15 @@ export function resolveStyle(parent, attributes, css = {}) {
140
141
  fillOpacity: ratio(read("fill-opacity"), parent.fillOpacity),
141
142
  strokeOpacity: ratio(read("stroke-opacity"), parent.strokeOpacity),
142
143
  opacity,
144
+ visible: visibility(read("visibility"), parent.visible),
143
145
  };
144
146
  }
147
+ /** `hidden` and `collapse` hide, `visible` shows, anything else (`inherit`, unset) keeps the parent's. */
148
+ function visibility(value, parent) {
149
+ const v = value?.trim();
150
+ if (v === "hidden" || v === "collapse")
151
+ return false;
152
+ if (v === "visible")
153
+ return true;
154
+ return parent;
155
+ }
@@ -32,6 +32,10 @@ export interface RunFont {
32
32
  /** Number of Unicode code points (not UTF-16 units): an astral char is ONE, so it takes one
33
33
  * letter-spacing, not two. Matches how `getStringWidth` iterates. */
34
34
  export declare function codePointCount(text: string): number;
35
+ /** U+00AD: "a line may break here, with a hyphen". Invisible wherever it does not. */
36
+ export declare const SOFT_HYPHEN = "\u00AD";
37
+ /** `text` as it is drawn: without its soft hyphens. */
38
+ export declare function withoutSoftHyphens(text: string): string;
35
39
  /** The advance of `text` in points: the plain glyph widths, plus kerning (if the document has it on),
36
40
  * plus one `letterSpacing` per code point. */
37
41
  export declare function runAdvance(metrics: FontMetrics, text: string, font: RunFont, letterSpacing?: number): number;
@@ -96,6 +96,11 @@ function baseAdvance(metrics, text, font) {
96
96
  }
97
97
  /** The measurement itself, once a miss has found the map it belongs in. */
98
98
  function measureAndStore(metrics, text, font, byText) {
99
+ // A soft hyphen is never drawn where it stands (a break there draws a real hyphen instead), so it has
100
+ // no width - taken out BEFORE measuring, so the kerning pair across it is the one drawn. Here, on a
101
+ // cache miss, and not on every call: the stored width is the drawn one, keyed by the source text.
102
+ const key = text;
103
+ text = withoutSoftHyphens(text);
99
104
  let advance = metrics.getStringWidth(text, font.fontFamily, font.fontSize, font.fontStyle, font.ligatures);
100
105
  if (metrics.kerningEnabled) {
101
106
  let units = 0;
@@ -103,17 +108,25 @@ function measureAndStore(metrics, text, font, byText) {
103
108
  units += k;
104
109
  advance += (units / 1000) * font.fontSize; // kern units are em/1000
105
110
  }
106
- byText.set(text, advance);
111
+ byText.set(key, advance);
107
112
  return advance;
108
113
  }
114
+ /** U+00AD: "a line may break here, with a hyphen". Invisible wherever it does not. */
115
+ export const SOFT_HYPHEN = "\u00AD";
116
+ /** `text` as it is drawn: without its soft hyphens. */
117
+ export function withoutSoftHyphens(text) {
118
+ return text.includes(SOFT_HYPHEN) ? text.split(SOFT_HYPHEN).join("") : text;
119
+ }
109
120
  /** The advance of `text` in points: the plain glyph widths, plus kerning (if the document has it on),
110
121
  * plus one `letterSpacing` per code point. */
111
122
  export function runAdvance(metrics, text, font, letterSpacing = 0) {
112
123
  let advance = baseAdvance(metrics, text, font);
113
124
  if (letterSpacing !== 0) {
114
- // Per DRAWN glyph, which is what `Tc` applies to; only a ligature makes that differ.
115
- const glyphs = metrics.shapedGlyphCount?.(text, font.fontFamily, font.fontStyle, font.ligatures) ??
116
- codePointCount(text);
125
+ // Per DRAWN glyph, which is what `Tc` applies to; only a ligature makes that differ. A soft hyphen is
126
+ // not drawn.
127
+ const drawn = withoutSoftHyphens(text);
128
+ const glyphs = metrics.shapedGlyphCount?.(drawn, font.fontFamily, font.fontStyle, font.ligatures) ??
129
+ codePointCount(drawn);
117
130
  advance += glyphs * letterSpacing;
118
131
  }
119
132
  return advance;
@@ -29,5 +29,3 @@ export declare function visualRunsOf<T>(pieces: {
29
29
  text: string;
30
30
  source: T;
31
31
  }[], base?: Direction): AttributedRun<T>[];
32
- /** The same for one plain string. An empty string still yields one run, or a blank line would vanish. */
33
- export declare const visualRuns: (text: string, base?: Direction) => VisualRun[];
package/dist/text/bidi.js CHANGED
@@ -82,13 +82,3 @@ export function visualRunsOf(pieces, base = "ltr") {
82
82
  flush();
83
83
  return runs;
84
84
  }
85
- /** The same for one plain string. An empty string still yields one run, or a blank line would vanish. */
86
- export const visualRuns = (text, base = "ltr") => {
87
- if (text === "")
88
- return [{ text, logical: text, rtl: false }];
89
- return visualRunsOf([{ text, source: null }], base).map(({ text: t, logical, rtl }) => ({
90
- text: t,
91
- logical,
92
- rtl,
93
- }));
94
- };
@@ -10,6 +10,6 @@ export interface CoverageResult {
10
10
  * Replaces or removes what `fontFamily` cannot draw. Returns the input unchanged (and no allocation
11
11
  * beyond the scan) when everything is drawable, which is the overwhelmingly common case.
12
12
  */
13
- export declare function coverText(text: string, fontFamily: string, fontStyle: FontStyle, metrics: FontMetrics): CoverageResult;
13
+ export declare function coverText(text: string, fontFamily: string, fontStyle: FontStyle, metrics: FontMetrics, emoji?: boolean): CoverageResult;
14
14
  /** `U+2713` - how a dropped code point is named to the caller. */
15
15
  export declare function formatCodePoint(cp: number): string;
@@ -39,19 +39,23 @@ const EQUIVALENTS = {
39
39
  * Replaces or removes what `fontFamily` cannot draw. Returns the input unchanged (and no allocation
40
40
  * beyond the scan) when everything is drawable, which is the overwhelmingly common case.
41
41
  */
42
- export function coverText(text, fontFamily, fontStyle, metrics) {
42
+ export function coverText(text, fontFamily, fontStyle, metrics,
43
+ // Whether colour emoji count as drawable: in `Text` they are drawn from the emoji source; a form
44
+ // field's appearance has only its own font.
45
+ emoji = true) {
43
46
  let out = null; // built only once something has to change
44
47
  const dropped = [];
45
48
  let index = 0;
46
49
  for (const char of text) {
47
50
  const cp = char.codePointAt(0);
48
- // A space and a line break are handled by the breaker, never drawn as a glyph.
51
+ // A space, a line break and a soft hyphen are handled by the breaker, never drawn as a glyph.
49
52
  const drawable = cp === 0x20 ||
50
53
  cp === 0x0a ||
54
+ cp === 0xad ||
51
55
  metrics.hasGlyph(cp, fontFamily, fontStyle) ||
52
56
  // Colour emoji is drawn from a fallback font or an image, NOT from the text font - it is missing
53
57
  // there by design, and removing it would delete the very thing `Document({ emoji })` exists for.
54
- metrics.rendersAsEmoji?.(cp, fontFamily, fontStyle) === true;
58
+ (emoji && metrics.rendersAsEmoji?.(cp, fontFamily, fontStyle) === true);
55
59
  if (!drawable) {
56
60
  if (out === null)
57
61
  out = text.slice(0, index);
@@ -0,0 +1,17 @@
1
+ export declare const NO_BREAKS: readonly number[];
2
+ /** Whether a word holds anything it may break after: a hyphen or a soft hyphen. */
3
+ export declare const mayBreak: (word: string) => boolean;
4
+ /**
5
+ * Where a word may break, as offsets into it - after a hyphen (U+002D) as UAX #14 has it: never before
6
+ * it, never before a digit ("CODE-2", "2026-10-16", "-5" stay whole), never after one that starts the
7
+ * word; and after a soft hyphen (U+00AD), the break its author marked.
8
+ */
9
+ export declare function hyphenBreaks(word: string): number[];
10
+ /**
11
+ * What the line ending at break `k` draws of `word`: everything before it, and at a soft hyphen a REAL
12
+ * hyphen in its place - the only time a soft hyphen shows. The breaker draws this; measuring it measures
13
+ * what is drawn.
14
+ */
15
+ export declare function headAt(word: string, k: number): string;
16
+ /** A word cut at its break opportunities: the pieces no line can split, as they are drawn. */
17
+ export declare function hyphenPieces(word: string): string[];
@@ -0,0 +1,54 @@
1
+ import { SOFT_HYPHEN } from "./advance.js";
2
+ import { HYPHEN } from "./word-splitting.js";
3
+ // Where a word MAY break inside itself - pure text rules, no font and no line. Filling lines is
4
+ // `line-breaker.ts`; this only answers which offsets are allowed.
5
+ export const NO_BREAKS = [];
6
+ /** Whether a word holds anything it may break after: a hyphen or a soft hyphen. */
7
+ export const mayBreak = (word) => word.includes("-") || word.includes(SOFT_HYPHEN);
8
+ /**
9
+ * Where a word may break, as offsets into it - after a hyphen (U+002D) as UAX #14 has it: never before
10
+ * it, never before a digit ("CODE-2", "2026-10-16", "-5" stay whole), never after one that starts the
11
+ * word; and after a soft hyphen (U+00AD), the break its author marked.
12
+ */
13
+ export function hyphenBreaks(word) {
14
+ const out = [];
15
+ for (let i = 1; i < word.length - 1; i++) {
16
+ const next = word[i + 1];
17
+ if (NO_BREAK_BEFORE.has(next))
18
+ continue;
19
+ if (word[i] === SOFT_HYPHEN) {
20
+ out.push(i + 1);
21
+ continue;
22
+ }
23
+ if (word[i] !== "-")
24
+ continue;
25
+ if (next === "-" || (next >= "0" && next <= "9"))
26
+ continue;
27
+ out.push(i + 1);
28
+ }
29
+ return out;
30
+ }
31
+ // UAX #14 never breaks before closing punctuation, a slash or a quote (LB13, LB19): "Lohn-, Gehalts-",
32
+ // "(well-known)", "Ein-/Ausgang" stay together.
33
+ const NO_BREAK_BEFORE = new Set(")]}!?,.:;/\"'«»‘’“”");
34
+ /**
35
+ * What the line ending at break `k` draws of `word`: everything before it, and at a soft hyphen a REAL
36
+ * hyphen in its place - the only time a soft hyphen shows. The breaker draws this; measuring it measures
37
+ * what is drawn.
38
+ */
39
+ export function headAt(word, k) {
40
+ if (word[k - 1] !== SOFT_HYPHEN)
41
+ return word.slice(0, k);
42
+ // A soft hyphen right after a real one adds nothing: "ab-" + "cd", not "ab--".
43
+ return word[k - 2] === "-" ? word.slice(0, k - 1) : word.slice(0, k - 1) + HYPHEN;
44
+ }
45
+ /** A word cut at its break opportunities: the pieces no line can split, as they are drawn. */
46
+ export function hyphenPieces(word) {
47
+ const breaks = mayBreak(word) ? hyphenBreaks(word) : NO_BREAKS;
48
+ if (breaks.length === 0)
49
+ return [word];
50
+ return [0, ...breaks].map((from, k) => {
51
+ const to = breaks[k];
52
+ return to === undefined ? word.slice(from) : headAt(word, to).slice(from);
53
+ });
54
+ }