@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
@@ -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, lineGaps, runStyleOf, } 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, color, spans, textAlignment, maxLines, overflow, lineHeight, underline, strikethrough, skipInk, direction, textIndent, 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, 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, direction, textIndent);
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, objectManager, maxWidth, textAlignment, color, initialX, yPosition, maxLines, overflow, lineHeight, underline = false, strikethrough = false, skipInk = false, direction = "ltr", textIndent = 0) {
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 = [];
@@ -256,44 +269,49 @@ export class TextRenderer {
256
269
  * Spaces are stretched by moving the pen, not by the `Tw` operator: `Tw` applies only to the
257
270
  * single byte 32, so an embedded (Identity-H) font would ignore it entirely.
258
271
  */
259
- const justifyExtra = (line, naturalWidth, isLast, indent = 0) => {
272
+ const justifyExtra = (segments, naturalWidth, isLast, indent = 0) => {
260
273
  if (align !== HorizontalAlignment.justify)
261
274
  return 0;
262
- const gaps = [...line].filter((c) => c === " ").length;
275
+ const { gaps, width: gapWidth } = lineGaps(segments, breaking, objectManager);
263
276
  if (gaps === 0)
264
277
  return 0;
265
278
  const slack = maxWidth - indent - naturalWidth;
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
- // used, which is what stops a line it never promised from being crushed.
269
- const spaceWidth = runAdvance(objectManager, " ", { fontFamily, fontSize, fontStyle }, letterSpacing);
270
- const extra = Math.max(slack / gaps, -spaceWidth * MAX_SPACE_SHRINK);
281
+ // used - the gaps' widths, summed - which is what stops a line it never promised from being
282
+ // crushed.
283
+ const extra = Math.max(slack / gaps, (-gapWidth * MAX_SPACE_SHRINK) / gaps);
271
284
  // The last line is never STRETCHED - the rule print and CSS follow. It may still be SQUEEZED:
272
285
  // the breaker was allowed to pack it that way, and drawing it loose would push it out of its box.
273
286
  return isLast ? Math.min(0, extra) : extra;
274
287
  };
275
288
  /**
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.
289
+ * Push one drawn piece and return the advance it took. When the spaces get more room (word-spacing,
290
+ * justification) the pen jumps an extra `extra` at every space, which means one run per word - the
291
+ * only way that works for an embedded font, whose spaces `Tw` cannot reach.
279
292
  *
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.
293
+ * A SHAPED piece is split there too, each word shaped on its own (`shape`): no slice of its glyph
294
+ * list is correct, but neither a ligature nor Arabic joining crosses a space - and a word plus a
295
+ * space is what the breaker measured.
282
296
  */
283
- const emitPiece = (piece, x, extra, style, runFont, spacing) => {
297
+ const emitPiece = (piece, shape, rtl, x, extra, style, runFont, spacing) => {
284
298
  const push = (at, text, glyphs) => {
285
299
  runs.push({ type: "text", x: at, text, ...(glyphs ? { glyphs } : {}), ...style });
286
300
  };
287
- if (extra === 0 || piece.glyphs || !piece.text.includes(" ")) {
301
+ if (extra === 0 || !piece.text.includes(" ")) {
288
302
  push(x, piece.text, piece.glyphs);
289
303
  return runAdvance(objectManager, piece.text, runFont, spacing);
290
304
  }
291
305
  const spaceWidth = runAdvance(objectManager, " ", runFont, spacing);
306
+ // A shaped piece holds its LOGICAL text: drawn right to left, its words come in reverse.
292
307
  const words = piece.text.split(" ");
308
+ if (piece.glyphs && rtl)
309
+ words.reverse();
293
310
  let pen = x;
294
311
  words.forEach((word, i) => {
295
312
  if (word !== "") {
296
- push(pen, word);
313
+ const drawn = piece.glyphs ? shape(word) : { text: word };
314
+ push(pen, drawn.text, drawn.glyphs);
297
315
  pen += runAdvance(objectManager, word, runFont, spacing);
298
316
  }
299
317
  if (i < words.length - 1)
@@ -303,7 +321,7 @@ export class TextRenderer {
303
321
  };
304
322
  // The decoration strokes for one drawn run, at the geometry ITS OWN font declares. A mixed-size
305
323
  // line therefore gets a thicker line under the bigger span, which is what a browser does too.
306
- const decorate = (text, runX, runWidth, baselineY, family, style, size, runColor, wantsUnderline, wantsStrikethrough, runLetterSpacing) => {
324
+ const decorate = (text, runX, runWidth, baselineY, family, style, size, runColor, wantsUnderline, wantsStrikethrough, runLetterSpacing, runSkipInk) => {
307
325
  if ((!wantsUnderline && !wantsStrikethrough) || runWidth <= 0)
308
326
  return;
309
327
  const metrics = objectManager.getFontDecoration(family, style);
@@ -320,7 +338,7 @@ export class TextRenderer {
320
338
  };
321
339
  if (wantsUnderline) {
322
340
  const stroke = underlineStroke(metrics, size, baselineY);
323
- for (const [from, to] of underlineSegments(text, runWidth, baselineY, size, family, style, stroke, runLetterSpacing)) {
341
+ for (const [from, to] of underlineSegments(text, runWidth, baselineY, size, family, style, stroke, runLetterSpacing, runSkipInk)) {
324
342
  push(from, to, stroke);
325
343
  }
326
344
  }
@@ -332,8 +350,8 @@ export class TextRenderer {
332
350
  };
333
351
  // The x-ranges the underline actually draws. Without `skipInk` that is the whole run; with it,
334
352
  // the descenders are cut out of it (which needs the real outlines, i.e. an embedded font).
335
- const underlineSegments = (text, runWidth, baselineY, size, family, style, stroke, runLetterSpacing) => {
336
- if (!skipInk)
353
+ const underlineSegments = (text, runWidth, baselineY, size, family, style, stroke, runLetterSpacing, runSkipInk) => {
354
+ if (!runSkipInk)
337
355
  return [[0, runWidth]];
338
356
  if (!objectManager.isCustomFont(family, style)) {
339
357
  throw new Error(`skipInk needs an embedded font, but "${family}" is a standard-14 font whose glyph outlines ` +
@@ -352,98 +370,63 @@ export class TextRenderer {
352
370
  * GLYPHS for an rtl run. A font that does not shape keeps the old path exactly: draw the visual
353
371
  * text, no glyph list, byte-identical output.
354
372
  */
355
- const shapedPiece = (part, family, style) => {
373
+ const shapedPiece = (part, family, style, runLigatures) => {
356
374
  // The run's OWN setting, not the default: measuring asked with it (`runAdvance` -> RunFont), and
357
375
  // drawing has to ask the same question or the line is measured one width and drawn another.
358
- const shaped = objectManager.shapeText(part.logical, family, style, ligatures ? undefined : []);
376
+ const shaped = objectManager.shapeText(part.logical, family, style, runLigatures ? undefined : []);
359
377
  if (!shaped)
360
378
  return { text: part.text };
361
379
  const ordered = part.rtl ? [...shaped].reverse() : shaped;
362
380
  return { text: part.logical, glyphs: ordered.map((g) => g.glyph) };
363
381
  };
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.
382
+ // Break into lines (the one breaker), then emit one run per segment.
409
383
  // Segment 0 starts at the aligned line origin; each following segment is offset by
410
384
  // the previous segment's kerning-free advance. Each line drops by its OWN leading
411
385
  // (tallest font on that line), so mixed-font lines space correctly and the drawn
412
386
  // 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);
387
+ const pushLine = (line, lineY, isLast, indent) => {
388
+ // CSS `text-indent`: the FIRST line starts in, with that much less room.
389
+ let x = initialX + indent + alignmentOffset(line.width, indent);
390
+ // Word-spacing is a per-gap advance just like the justification slack, so it rides the same
391
+ // mechanism: `Tw` would reach only the single byte 32 and miss every embedded font.
392
+ const justify = justifyExtra(line.segments, line.width, isLast, indent);
416
393
  // Across span boundaries, so a Hebrew span is ordered against its neighbours rather than inside
417
394
  // itself. Each run keeps the segment it came from, and with it that span's style.
418
395
  const ordered = visualRunsOf(line.segments.map((segment) => ({ text: segment.content, source: segment })), direction);
419
396
  ordered.forEach((part) => {
397
+ // A blank line keeps its height (its box is sized from the piece), but there is nothing to draw.
398
+ if (part.text === "")
399
+ return;
420
400
  const segment = part.source;
421
- const family = segment.fontFamily || fontFamily;
422
- const size = segment.fontSize || fontSize;
423
- const style = segment.fontStyle || fontStyle;
424
- const segLetterSpacing = segment.letterSpacing ?? letterSpacing;
425
- const piece = shapedPiece(part, family, style);
401
+ const run = runStyleOf(segment, breaking);
402
+ const { fontFamily: family, fontSize: size, fontStyle: style } = run.font;
403
+ const segLetterSpacing = run.letterSpacing;
404
+ const runLigatures = run.font.ligatures ?? true;
405
+ const piece = shapedPiece(part, family, style, runLigatures);
426
406
  const runText = piece.text;
427
407
  const runColor = segment.fontColor || color;
428
408
  // `vertical-align`: this run alone leaves the line's baseline. Its own decoration and link box
429
409
  // ride along, since both are placed from the same y.
430
410
  const shift = VERTICAL_TEXT_SHIFT[segment.verticalAlign ?? "baseline"] * size;
431
411
  const runY = lineY - shift;
432
- const drawn = emitPiece(piece, x, extra, {
412
+ const firstRun = runs.length;
413
+ const drawn = emitPiece(piece, (word) => shapedPiece({ text: word, logical: word, rtl: part.rtl }, family, style, runLigatures), part.rtl, x, run.wordSpacing + justify, {
433
414
  y: runY,
434
415
  fontFamily: family,
435
416
  fontStyle: style,
436
417
  fontSize: size,
437
418
  color: runColor,
438
419
  ...(segLetterSpacing ? { letterSpacing: segLetterSpacing } : {}),
439
- }, { fontFamily: family, fontSize: size, fontStyle: style }, segLetterSpacing);
440
- decorate(runText, x, drawn, runY, family, style, size, runColor, segment.underline ?? underline, segment.strikethrough ?? strikethrough, segLetterSpacing);
420
+ },
421
+ // `ligatures` is part of the font: the advances must be the glyphs that are drawn.
422
+ run.font, segLetterSpacing);
423
+ decorate(runText, x, drawn, runY, family, style, size, runColor, segment.underline ?? underline, segment.strikethrough ?? strikethrough, segLetterSpacing, segment.skipInk ?? skipInk);
441
424
  // An href/to span becomes a /Link annotation over exactly this run's glyph box: from its own
442
425
  // ascent above the baseline down to its own descender. A span wrapped across lines yields
443
426
  // one rect per line.
444
427
  if (segment.href !== undefined || segment.dest !== undefined) {
445
428
  const v = objectManager.getFontVerticals(family, style);
446
- links.push({
429
+ const link = {
447
430
  type: "link",
448
431
  x,
449
432
  y: runY - v.ascent * size,
@@ -451,34 +434,38 @@ export class TextRenderer {
451
434
  height: (v.ascent + v.descent) * size,
452
435
  href: segment.href,
453
436
  dest: segment.dest,
454
- });
437
+ };
438
+ links.push(link);
439
+ const target = segment.href !== undefined ? `href:${segment.href}` : `dest:${segment.dest}`;
440
+ if (openLink?.target !== target) {
441
+ openLink = { target, group: { target, links: [], runs: [], pieces: [] } };
442
+ linkGroups.push(openLink.group);
443
+ }
444
+ openLink.group.links.push(link);
445
+ openLink.group.runs.push(...runs.slice(firstRun));
446
+ openLink.group.pieces.push({ text: runText, y: runY });
447
+ }
448
+ else if (runText.trim() !== "") {
449
+ openLink = undefined;
455
450
  }
456
451
  x += drawn;
457
452
  });
458
453
  };
459
454
  // yPosition is the top of the text box (top-left). Each line seats its own baseline inside its
460
455
  // 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
- };
456
+ // The seam flips the whole thing to PDF space. The lines are broken with the element's own
457
+ // settings - the ones `textHeight` measured with - so measured equals drawn by construction.
458
+ const defaults = breaking;
472
459
  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
- ];
460
+ const segmentLines = breakSegmentsIntoLines(content, defaults, maxWidth, objectManager, maxLines, overflow);
477
461
  segmentLines.forEach((line, index) => {
478
462
  const box = lineBoxForSegmentLine(line, defaults, objectManager, lineHeight);
479
- pushLine(line, top + box.baseline, index === segmentLines.length - 1);
463
+ // Only a line that WRAPPED is stretched: the end of a paragraph and the line before a `\n` keep
464
+ // their natural spacing (CSS). The last line of a piece continued on the next page wrapped.
465
+ const ends = line.end === "hard" || line.end === "end";
466
+ pushLine(line, top + box.baseline, ends, index === 0 ? textIndent : 0);
480
467
  top += box.height;
481
468
  });
482
- return { runs, links, decorations };
469
+ return { runs, links, decorations, linkGroups };
483
470
  }
484
471
  }
@@ -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)));
@@ -7,7 +7,7 @@ export interface SvgTarget {
7
7
  width: number;
8
8
  height: number;
9
9
  }
10
- /** The intrinsic size of an SVG: its `width`/`height`, else its `viewBox`. */
10
+ /** The intrinsic size of an SVG in points: its `width`/`height`, else its `viewBox`, else 300 x 150 px. */
11
11
  export interface SvgSize {
12
12
  width: number;
13
13
  height: number;
package/dist/svg/index.js CHANGED
@@ -2,6 +2,7 @@ import { parseXml } from "./xml.js";
2
2
  import { SvgParseError, SvgUnsupportedError } from "./errors.js";
3
3
  import { IDENTITY, isIdentity, multiply, parseTransform } from "./transform.js";
4
4
  import { shapeOf } from "./shapes.js";
5
+ import { length } from "./units.js";
5
6
  import { transformCommands } from "../vector/path.js";
6
7
  import { declarationsFor, parseStylesheet } from "./css.js";
7
8
  import { ROOT_STYLE, isPaintRef, cascadedReader, paintAlpha, resolveStyle, } from "./style.js";
@@ -72,6 +73,21 @@ function stylesheetOf(node, out = [], depth = 0) {
72
73
  const elements = (node) => node.type === "element"
73
74
  ? node.children.filter((child) => child.type === "element")
74
75
  : [];
76
+ /** SVG's user unit is the CSS px: 1/96 inch, so 0.75pt - the resolution an image without one gets too. */
77
+ const PT_PER_USER_UNIT = 72 / 96;
78
+ /** The size of a replaced element that declares none (CSS). */
79
+ const DEFAULT_VIEWPORT = { width: 300, height: 150 };
80
+ /**
81
+ * A root `width`/`height` in user units, or `undefined` when it gives none. A percentage sizes against a
82
+ * container, which a drawing on its own does not have - a browser treats it as not given, and so do we.
83
+ */
84
+ function rootLength(value, name) {
85
+ const text = value?.trim() ?? "";
86
+ if (text === "" || text.endsWith("%"))
87
+ return undefined;
88
+ const n = length(text, `the root <svg> ${name}`, NaN);
89
+ return n > 0 ? n : undefined;
90
+ }
75
91
  /**
76
92
  * Reads the root `<svg>` and returns the matrix that maps its user space onto `target`, plus the
77
93
  * intrinsic size. The `viewBox` scales UNIFORMLY and centres - SVG's default `preserveAspectRatio`
@@ -85,11 +101,16 @@ function rootMapping(root, target) {
85
101
  .split(/[\s,]+/)
86
102
  .map(Number);
87
103
  const hasBox = box.length === 4 && box.every((n) => Number.isFinite(n)) && box[2] > 0 && box[3] > 0;
88
- const declaredW = Number(attributes["width"]);
89
- const declaredH = Number(attributes["height"]);
104
+ // The viewport in user units. One side given and a viewBox: the other follows its ratio, as in a
105
+ // browser. Nothing to go on: CSS's 300 x 150 for a replaced element without a size.
106
+ const w = rootLength(attributes["width"], "width");
107
+ const h = rootLength(attributes["height"], "height");
108
+ const ratio = hasBox ? box[2] / box[3] : undefined;
109
+ const userWidth = w ?? (h !== undefined && ratio ? h * ratio : hasBox ? box[2] : DEFAULT_VIEWPORT.width);
110
+ const userHeight = h ?? (w !== undefined && ratio ? w / ratio : hasBox ? box[3] : DEFAULT_VIEWPORT.height);
90
111
  const size = {
91
- width: Number.isFinite(declaredW) && declaredW > 0 ? declaredW : hasBox ? box[2] : target.width,
92
- height: Number.isFinite(declaredH) && declaredH > 0 ? declaredH : hasBox ? box[3] : target.height,
112
+ width: userWidth * PT_PER_USER_UNIT,
113
+ height: userHeight * PT_PER_USER_UNIT,
93
114
  };
94
115
  // We always scale uniformly and centre, which is SVG's default "xMidYMid meet". Any other value
95
116
  // moves or crops the drawing, so it is named rather than quietly ignored.
@@ -97,7 +118,7 @@ function rootMapping(root, target) {
97
118
  if (par !== "" && !/^xMidYMid(\s+meet)?$/.test(par)) {
98
119
  throw new SvgUnsupportedError(`preserveAspectRatio="${par}"`, 'Only the default "xMidYMid meet" is honoured. Size the drawing through width/height instead.');
99
120
  }
100
- const [vx, vy, vw, vh] = hasBox ? box : [0, 0, size.width, size.height];
121
+ const [vx, vy, vw, vh] = hasBox ? box : [0, 0, userWidth, userHeight];
101
122
  const scale = Math.min(target.width / vw, target.height / vh);
102
123
  const dx = target.x + (target.width - vw * scale) / 2;
103
124
  const dy = target.y + (target.height - vh * scale) / 2;
@@ -112,9 +133,14 @@ function rootMapping(root, target) {
112
133
  * it draws something the file did not describe: a drop-shadow logo as a hard silhouette. `clip-path`
113
134
  * used to be listed here; it is resolved now, which is the honest version of the same rule.
114
135
  */
136
+ const MARKER_HINT = "Markers (arrowheads, dots on a line) are not drawn yet; expand them into shapes in your editor.";
115
137
  const REFUSED_ATTRIBUTES = {
116
138
  mask: "Masks are pixel operations; flatten the artwork in your editor instead.",
117
139
  filter: "Filters are pixel operations; rasterise that part to a PNG instead.",
140
+ marker: MARKER_HINT,
141
+ "marker-start": MARKER_HINT,
142
+ "marker-mid": MARKER_HINT,
143
+ "marker-end": MARKER_HINT,
118
144
  };
119
145
  function refuseAttributes(tagName, read) {
120
146
  for (const [name, hint] of Object.entries(REFUSED_ATTRIBUTES)) {
@@ -210,6 +236,10 @@ function walk(node, parentStyle, rules, clips, gradients, out, depth = 0) {
210
236
  // Everything below reads through the cascade, not the raw attributes: `filter="url(#f)"` and
211
237
  // `style="filter:url(#f)"` are the same instruction, and a `<style>` rule can set either.
212
238
  const read = cascadedReader(attributes, css);
239
+ // `display: none` takes the element out with everything inside it - nothing of it is drawn, so
240
+ // nothing of it can be unsupported either.
241
+ if (read("display")?.trim() === "none")
242
+ return;
213
243
  refuseAttributes(tagName, read);
214
244
  const style = resolveStyle(parentStyle, attributes, css);
215
245
  const transform = attributes["transform"]
@@ -248,7 +278,7 @@ function walk(node, parentStyle, rules, clips, gradients, out, depth = 0) {
248
278
  }
249
279
  // A shape with neither fill nor stroke, or with no geometry, paints nothing - and an empty Path
250
280
  // node would only cost bytes.
251
- if (commands.length > 0 && (style.fill || style.stroke)) {
281
+ if (commands.length > 0 && style.visible && (style.fill || style.stroke)) {
252
282
  // A gradient is resolved HERE too, and for the same reason: in its default units its anchors
253
283
  // are fractions of the SHAPE's bounding box, which only exists at this point.
254
284
  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
@@ -1,5 +1,5 @@
1
1
  import { Color } from "../common/color.js";
2
- import { toColor } from "../api/color.js";
2
+ import { toColor } from "../common/color-input.js";
3
3
  import { SvgParseError, SvgUnsupportedError } from "./errors.js";
4
4
  import { length, ratio } from "./units.js";
5
5
  export const isPaintRef = (paint) => paint !== undefined && !(paint instanceof Color);
@@ -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;