@jasy/pdf 1.0.0-alpha.8 → 1.0.0-beta.1

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 (167) hide show
  1. package/README.md +29 -10
  2. package/dist/api/color.js +69 -0
  3. package/dist/api/content.d.ts +45 -5
  4. package/dist/api/content.js +96 -7
  5. package/dist/api/descriptor.js +6 -1
  6. package/dist/api/dimension.d.ts +57 -0
  7. package/dist/api/dimension.js +27 -0
  8. package/dist/api/font-url.d.ts +28 -0
  9. package/dist/api/font-url.js +135 -0
  10. package/dist/api/gradient.d.ts +53 -0
  11. package/dist/api/gradient.js +97 -0
  12. package/dist/api/index.d.ts +2 -0
  13. package/dist/api/index.js +2 -0
  14. package/dist/api/insets.d.ts +19 -12
  15. package/dist/api/insets.js +18 -7
  16. package/dist/api/layout.d.ts +20 -6
  17. package/dist/api/layout.js +31 -6
  18. package/dist/api/page-builder.d.ts +8 -0
  19. package/dist/api/page-builder.js +12 -0
  20. package/dist/api/structure.d.ts +12 -0
  21. package/dist/api/structure.js +54 -7
  22. package/dist/api/text.d.ts +65 -8
  23. package/dist/api/text.js +38 -5
  24. package/dist/canvas/painter.d.ts +102 -0
  25. package/dist/canvas/painter.js +183 -0
  26. package/dist/edit/document.d.ts +14 -4
  27. package/dist/edit/document.js +15 -22
  28. package/dist/edit/fill.d.ts +19 -2
  29. package/dist/edit/fill.js +63 -34
  30. package/dist/edit/flatten.d.ts +13 -1
  31. package/dist/edit/flatten.js +38 -13
  32. package/dist/edit/index.d.ts +2 -0
  33. package/dist/edit/index.js +3 -1
  34. package/dist/edit/objects.d.ts +5 -0
  35. package/dist/edit/objects.js +27 -0
  36. package/dist/edit/writer.d.ts +7 -0
  37. package/dist/edit/writer.js +23 -1
  38. package/dist/elements/canvas-element.d.ts +38 -0
  39. package/dist/elements/canvas-element.js +47 -0
  40. package/dist/elements/container-element.d.ts +23 -1
  41. package/dist/elements/container-element.js +31 -10
  42. package/dist/elements/image-element.d.ts +18 -4
  43. package/dist/elements/image-element.js +27 -12
  44. package/dist/elements/index.d.ts +2 -0
  45. package/dist/elements/index.js +2 -0
  46. package/dist/elements/layout/expanded-element.d.ts +5 -1
  47. package/dist/elements/layout/expanded-element.js +16 -3
  48. package/dist/elements/layout/keep-together-element.d.ts +2 -0
  49. package/dist/elements/layout/keep-together-element.js +4 -0
  50. package/dist/elements/layout/padding-element.d.ts +9 -2
  51. package/dist/elements/layout/padding-element.js +12 -2
  52. package/dist/elements/layout/page-builder-element.js +2 -0
  53. package/dist/elements/layout/struct-group.d.ts +2 -0
  54. package/dist/elements/layout/struct-group.js +4 -0
  55. package/dist/elements/pdf-element.d.ts +75 -1
  56. package/dist/elements/pdf-element.js +80 -1
  57. package/dist/elements/rectangle-element.d.ts +22 -6
  58. package/dist/elements/rectangle-element.js +31 -12
  59. package/dist/elements/row-element.d.ts +26 -1
  60. package/dist/elements/row-element.js +31 -10
  61. package/dist/elements/svg-element.d.ts +41 -0
  62. package/dist/elements/svg-element.js +93 -0
  63. package/dist/elements/text-element.d.ts +92 -1
  64. package/dist/elements/text-element.js +208 -33
  65. package/dist/ir/display-list.d.ts +67 -11
  66. package/dist/ir/display-list.js +8 -1
  67. package/dist/layout/alignment.d.ts +10 -0
  68. package/dist/layout/alignment.js +6 -0
  69. package/dist/layout/box-constraints.d.ts +53 -0
  70. package/dist/layout/box-constraints.js +72 -0
  71. package/dist/layout/insets.d.ts +28 -0
  72. package/dist/layout/insets.js +20 -0
  73. package/dist/layout/min-intrinsic.d.ts +29 -0
  74. package/dist/layout/min-intrinsic.js +37 -0
  75. package/dist/renderer/anchor-renderer.js +1 -1
  76. package/dist/renderer/bookmark-renderer.js +1 -1
  77. package/dist/renderer/canvas-renderer.d.ts +14 -0
  78. package/dist/renderer/canvas-renderer.js +36 -0
  79. package/dist/renderer/container-renderer.js +1 -3
  80. package/dist/renderer/default-text-style-renderer.js +1 -1
  81. package/dist/renderer/deferred-renderer.js +1 -1
  82. package/dist/renderer/expanded-renderer.js +1 -1
  83. package/dist/renderer/image-renderer.js +2 -1
  84. package/dist/renderer/index.d.ts +2 -0
  85. package/dist/renderer/index.js +2 -0
  86. package/dist/renderer/keep-together-renderer.js +1 -1
  87. package/dist/renderer/link-renderer.js +1 -1
  88. package/dist/renderer/padding-renderer.js +1 -1
  89. package/dist/renderer/page-builder-renderer.js +1 -1
  90. package/dist/renderer/page-renderer.js +1 -3
  91. package/dist/renderer/pdf-backend.d.ts +21 -4
  92. package/dist/renderer/pdf-backend.js +198 -61
  93. package/dist/renderer/pdf-document-renderer.js +8 -1
  94. package/dist/renderer/pdf-renderer.js +5 -1
  95. package/dist/renderer/positioned-renderer.js +1 -1
  96. package/dist/renderer/rectangle-renderer.js +9 -5
  97. package/dist/renderer/repeating-header-renderer.js +1 -2
  98. package/dist/renderer/rotated-renderer.js +1 -1
  99. package/dist/renderer/row-renderer.js +1 -3
  100. package/dist/renderer/struct-group-renderer.js +1 -1
  101. package/dist/renderer/svg-renderer.d.ts +11 -0
  102. package/dist/renderer/svg-renderer.js +27 -0
  103. package/dist/renderer/text-renderer.d.ts +2 -2
  104. package/dist/renderer/text-renderer.js +176 -57
  105. package/dist/svg/css.d.ts +38 -0
  106. package/dist/svg/css.js +115 -0
  107. package/dist/svg/errors.d.ts +24 -0
  108. package/dist/svg/errors.js +30 -0
  109. package/dist/svg/gradients.d.ts +34 -0
  110. package/dist/svg/gradients.js +156 -0
  111. package/dist/svg/index.d.ts +22 -0
  112. package/dist/svg/index.js +331 -0
  113. package/dist/svg/shapes.d.ts +6 -0
  114. package/dist/svg/shapes.js +177 -0
  115. package/dist/svg/style.d.ts +61 -0
  116. package/dist/svg/style.js +144 -0
  117. package/dist/svg/transform.d.ts +13 -0
  118. package/dist/svg/transform.js +68 -0
  119. package/dist/svg/units.d.ts +11 -0
  120. package/dist/svg/units.js +73 -0
  121. package/dist/svg/xml.d.ts +24 -0
  122. package/dist/svg/xml.js +158 -0
  123. package/dist/text/advance.d.ts +3 -0
  124. package/dist/text/advance.js +8 -4
  125. package/dist/text/arabic.d.ts +20 -0
  126. package/dist/text/arabic.js +101 -0
  127. package/dist/text/bidi.d.ts +33 -0
  128. package/dist/text/bidi.js +94 -0
  129. package/dist/text/font-fallback.d.ts +25 -0
  130. package/dist/text/font-fallback.js +38 -0
  131. package/dist/text/glyph-coverage.d.ts +15 -0
  132. package/dist/text/glyph-coverage.js +77 -0
  133. package/dist/text/line-breaker.d.ts +43 -1
  134. package/dist/text/line-breaker.js +181 -45
  135. package/dist/text/orphans-widows.d.ts +27 -0
  136. package/dist/text/orphans-widows.js +53 -0
  137. package/dist/text/shape.d.ts +29 -0
  138. package/dist/text/shape.js +91 -0
  139. package/dist/text/text-style.d.ts +38 -0
  140. package/dist/text/text-style.js +50 -1
  141. package/dist/text/whitespace.d.ts +4 -0
  142. package/dist/text/whitespace.js +43 -0
  143. package/dist/text/word-splitting.d.ts +42 -0
  144. package/dist/text/word-splitting.js +72 -0
  145. package/dist/utils/flex-layout.d.ts +24 -4
  146. package/dist/utils/flex-layout.js +218 -7
  147. package/dist/utils/font-metrics.d.ts +13 -2
  148. package/dist/utils/gsub.d.ts +36 -0
  149. package/dist/utils/gsub.js +178 -0
  150. package/dist/utils/inflate.d.ts +16 -0
  151. package/dist/utils/inflate.js +52 -0
  152. package/dist/utils/pdf-object-manager.d.ts +44 -3
  153. package/dist/utils/pdf-object-manager.js +176 -14
  154. package/dist/utils/renderer-registry.d.ts +13 -1
  155. package/dist/utils/renderer-registry.js +22 -1
  156. package/dist/utils/ttf-parser.d.ts +6 -0
  157. package/dist/utils/ttf-parser.js +19 -0
  158. package/dist/utils/utf8-to-windows1252-encoder.d.ts +6 -0
  159. package/dist/utils/utf8-to-windows1252-encoder.js +10 -0
  160. package/dist/utils/woff.d.ts +10 -0
  161. package/dist/utils/woff.js +116 -0
  162. package/dist/utils/woff2.d.ts +6 -0
  163. package/dist/utils/woff2.js +651 -0
  164. package/dist/validators/element-validator.js +9 -2
  165. package/dist/vector/path.d.ts +33 -0
  166. package/dist/vector/path.js +81 -0
  167. package/package.json +6 -2
@@ -1,32 +1,44 @@
1
1
  import { Color } from "../common/color.js";
2
2
  import { HorizontalAlignment } from "../elements/pdf-element.js";
3
+ import { VERTICAL_TEXT_SHIFT } from "../elements/text-element.js";
3
4
  import { isEmojiCodePoint } from "../text/emoji-codepoints.js";
4
5
  import { emojiImageNode } from "./emoji-image.js";
5
- import { wrapStringIntoLines, breakSegmentsIntoLines, } from "../text/line-breaker.js";
6
+ import { singleLineWidth, MAX_SPACE_SHRINK, wrapStringIntoLines, breakSegmentsIntoLines, } from "../text/line-breaker.js";
6
7
  import { lineBoxForSegmentLine, lineBoxForString } from "../text/line-metrics.js";
7
8
  import { runAdvance } from "../text/advance.js";
9
+ import { visualRuns, visualRunsOf } from "../text/bidi.js";
8
10
  import { skipInkSegments, strikethroughStroke, underlineStroke, } from "../text/text-decoration.js";
11
+ import { quadToCubic } from "../vector/path.js";
9
12
  export class TextRenderer {
10
13
  // Measuring only needs metrics, not the full object manager. (The render pass below
11
14
  // still receives the manager because it also registers fonts/images.)
12
- static calculateTextHeight(content, fontSize, fontFamily, fontStyle, objectManager, maxWidth, maxLines, overflow, lineHeight, letterSpacing = 0) {
15
+ static calculateTextHeight(content, fontSize, fontFamily, fontStyle, objectManager, maxWidth, maxLines, overflow, lineHeight, letterSpacing = 0, lineOptions = {}) {
13
16
  // Plain string: every wrapped line gets the same box.
14
17
  if (typeof content === "string") {
15
- const lines = wrapStringIntoLines(content, fontFamily, fontSize, fontStyle, maxWidth, objectManager, maxLines, overflow, letterSpacing);
18
+ const lines = wrapStringIntoLines(content, fontFamily, fontSize, fontStyle, maxWidth, objectManager, maxLines, overflow, letterSpacing, lineOptions);
16
19
  const box = lineBoxForString(objectManager, fontFamily, fontStyle, fontSize, lineHeight);
17
20
  return lines.length * box.height;
18
21
  }
19
- // Segments: each line's box comes from the fonts actually on it.
20
- const defaults = { fontFamily, fontSize, fontStyle, letterSpacing };
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
+ };
21
33
  const lines = breakSegmentsIntoLines(content, defaults, maxWidth, objectManager, maxLines, overflow);
22
34
  return lines.reduce((total, line) => total + lineBoxForSegmentLine(line, defaults, objectManager, lineHeight).height, 0);
23
35
  }
24
36
  static async render(textElement, objectManager) {
25
- const { x, y, width, fontSize, color, content, fontFamily, fontStyle, textAlignment, maxLines, overflow, lineHeight, underline, strikethrough, skipInk, letterSpacing, role, } = textElement.getProps();
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();
26
38
  // Component -> display list. Wrapping and positioning stay here; the backend
27
39
  // turns each run into BT/Tf/Td/Tj/ET. The wrapping algorithm is unchanged from
28
40
  // the original renderer - unifying it into the engine is Phase 3.
29
- 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);
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);
30
42
  // Accessible tagging: this whole text block is one structure element (a paragraph P, or a heading
31
43
  // from `role`); each run's text lands under it. Emoji sub-nodes produced below stay untagged (drawn
32
44
  // as artifacts). Keyed by structId so a block split across pages stays one element.
@@ -61,6 +73,13 @@ export class TextRenderer {
61
73
  const imageSource = om.getEmojiImageSource();
62
74
  if (!own && !fallback && !imageSource)
63
75
  return [run];
76
+ // A SHAPED run cannot be split here: the sub-runs are cut by code point, while `glyphs` is a
77
+ // drawn-order list in which a ligature is one glyph for several code points - so no sub-run could
78
+ // be given the right slice, and inheriting the whole list would draw every glyph again per piece.
79
+ // Passed through whole instead: an emoji inside Arabic then comes from the text font rather than
80
+ // the emoji source (`todo.md`).
81
+ if (run.glyphs)
82
+ return [run];
64
83
  const nodes = [];
65
84
  let cursorX = run.x; // absolute x of the next glyph
66
85
  let pending = ""; // run of consecutive non-color characters
@@ -152,19 +171,9 @@ export class TextRenderer {
152
171
  out.push({ op: "l", x: curX, y: curY });
153
172
  }
154
173
  else if (cmd.type === "Q") {
155
- const ctrlX = mapX(cmd.cx, cmd.cy);
156
- const ctrlY = mapY(cmd.cx, cmd.cy);
157
174
  const endX = mapX(cmd.x, cmd.y);
158
175
  const endY = mapY(cmd.x, cmd.y);
159
- out.push({
160
- op: "c",
161
- x1: curX + (2 / 3) * (ctrlX - curX),
162
- y1: curY + (2 / 3) * (ctrlY - curY),
163
- x2: endX + (2 / 3) * (ctrlX - endX),
164
- y2: endY + (2 / 3) * (ctrlY - endY),
165
- x: endX,
166
- y: endY,
167
- });
176
+ out.push(quadToCubic(curX, curY, mapX(cmd.cx, cmd.cy), mapY(cmd.cx, cmd.cy), endX, endY));
168
177
  curX = endX;
169
178
  curY = endY;
170
179
  }
@@ -215,7 +224,7 @@ export class TextRenderer {
215
224
  // Lay the content out into absolutely-positioned text runs. Glyph positions match
216
225
  // the previous hand-written operators exactly (verified by pixel-identical render);
217
226
  // only the output form changed from PDF strings to `TextRun`s.
218
- static _buildRuns(content, fontSize, fontFamily, fontStyle, objectManager, maxWidth, textAlignment, color, initialX, yPosition, maxLines, overflow, lineHeight, underline = false, strikethrough = false, skipInk = false, letterSpacing = 0) {
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) {
219
228
  const runs = [];
220
229
  // /Link annotation rects for any `href` spans, collected as we place segments. Empty for the
221
230
  // common (no-href) case, so plain text keeps producing exactly the runs it did before.
@@ -224,13 +233,74 @@ export class TextRenderer {
224
233
  // per line, and a decorated span only spans its own glyphs).
225
234
  const decorations = [];
226
235
  // Horizontal offset of a line of the given width under the current alignment.
227
- const alignmentOffset = (lineWidth) => {
228
- if (textAlignment === HorizontalAlignment.center)
229
- return (maxWidth - lineWidth) / 2;
230
- if (textAlignment === HorizontalAlignment.right)
231
- return maxWidth - lineWidth;
236
+ // `start` is the one alignment that depends on the direction, resolved at the single place
237
+ // alignment is consumed.
238
+ const align = textAlignment === HorizontalAlignment.start
239
+ ? direction === "rtl"
240
+ ? HorizontalAlignment.right
241
+ : HorizontalAlignment.left
242
+ : textAlignment;
243
+ const alignmentOffset = (lineWidth, indent = 0) => {
244
+ const room = maxWidth - indent;
245
+ if (align === HorizontalAlignment.center)
246
+ return (room - lineWidth) / 2;
247
+ if (align === HorizontalAlignment.right)
248
+ return room - lineWidth;
232
249
  return 0;
233
250
  };
251
+ /**
252
+ * Justification: how much extra to put in EACH space of a line so it reaches the full width.
253
+ * The LAST line of a paragraph keeps its natural spacing, as it does in CSS and in print - a
254
+ * justified last line is the classic sign of a broken text engine.
255
+ *
256
+ * Spaces are stretched by moving the pen, not by the `Tw` operator: `Tw` applies only to the
257
+ * single byte 32, so an embedded (Identity-H) font would ignore it entirely.
258
+ */
259
+ const justifyExtra = (line, naturalWidth, isLast, indent = 0) => {
260
+ if (align !== HorizontalAlignment.justify)
261
+ return 0;
262
+ const gaps = [...line].filter((c) => c === " ").length;
263
+ if (gaps === 0)
264
+ return 0;
265
+ const slack = maxWidth - indent - naturalWidth;
266
+ // Negative slack is real now: the breaker keeps a word when the line can be SQUEEZED to hold
267
+ // 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);
271
+ // The last line is never STRETCHED - the rule print and CSS follow. It may still be SQUEEZED:
272
+ // the breaker was allowed to pack it that way, and drawing it loose would push it out of its box.
273
+ return isLast ? Math.min(0, extra) : extra;
274
+ };
275
+ /**
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.
279
+ *
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.
282
+ */
283
+ const emitPiece = (piece, x, extra, style, runFont, spacing) => {
284
+ const push = (at, text, glyphs) => {
285
+ runs.push({ type: "text", x: at, text, ...(glyphs ? { glyphs } : {}), ...style });
286
+ };
287
+ if (extra === 0 || piece.glyphs || !piece.text.includes(" ")) {
288
+ push(x, piece.text, piece.glyphs);
289
+ return runAdvance(objectManager, piece.text, runFont, spacing);
290
+ }
291
+ const spaceWidth = runAdvance(objectManager, " ", runFont, spacing);
292
+ const words = piece.text.split(" ");
293
+ let pen = x;
294
+ words.forEach((word, i) => {
295
+ if (word !== "") {
296
+ push(pen, word);
297
+ pen += runAdvance(objectManager, word, runFont, spacing);
298
+ }
299
+ if (i < words.length - 1)
300
+ pen += spaceWidth + extra;
301
+ });
302
+ return pen - x;
303
+ };
234
304
  // The decoration strokes for one drawn run, at the geometry ITS OWN font declares. A mixed-size
235
305
  // line therefore gets a thicker line under the bigger span, which is what a browser does too.
236
306
  const decorate = (text, runX, runWidth, baselineY, family, style, size, runColor, wantsUnderline, wantsStrikethrough, runLetterSpacing) => {
@@ -276,31 +346,62 @@ export class TextRenderer {
276
346
  const inkSpans = objectManager.getInkSpansInBand(text, size, family, style, -below + half, -below - half, runLetterSpacing);
277
347
  return skipInkSegments(runWidth, inkSpans, stroke.thickness);
278
348
  };
349
+ /**
350
+ * One drawn piece of a line. SHAPING must see the run's LOGICAL text - a letter's form comes from
351
+ * its neighbours, and a reversed run has those swapped - so we shape that and then reverse the
352
+ * GLYPHS for an rtl run. A font that does not shape keeps the old path exactly: draw the visual
353
+ * text, no glyph list, byte-identical output.
354
+ */
355
+ const shapedPiece = (part, family, style) => {
356
+ // The run's OWN setting, not the default: measuring asked with it (`runAdvance` -> RunFont), and
357
+ // 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 : []);
359
+ if (!shaped)
360
+ return { text: part.text };
361
+ const ordered = part.rtl ? [...shaped].reverse() : shaped;
362
+ return { text: part.logical, glyphs: ordered.map((g) => g.glyph) };
363
+ };
279
364
  // The advance the viewer will use: plain glyph widths (a `Tj` never kerns) plus one
280
365
  // `letterSpacing` per code point (the `Tc` operator), from the one shared `advance.ts` primitive.
281
- const font = { fontFamily, fontSize, fontStyle };
366
+ const font = { fontFamily, fontSize, fontStyle, ligatures };
282
367
  // --- Plain string: one run per wrapped line. ---
283
368
  if (typeof content === "string") {
284
- const lines = wrapStringIntoLines(content, fontFamily, fontSize, fontStyle, maxWidth, objectManager, maxLines, overflow, letterSpacing);
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
+ });
285
379
  // yPosition is the top of the text box (top-left). The line box seats its own baseline; lines
286
380
  // then stack by that box's height. Same numbers as calculateTextHeight, by construction.
287
381
  const box = lineBoxForString(objectManager, fontFamily, fontStyle, fontSize, lineHeight);
288
382
  lines.forEach((line, index) => {
289
- const lineWidth = runAdvance(objectManager, line, font, letterSpacing);
290
- const x = initialX + alignmentOffset(lineWidth);
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);
291
387
  const baseline = yPosition + box.baseline + box.height * index;
292
- runs.push({
293
- type: "text",
294
- x,
295
- y: baseline,
296
- text: line,
297
- fontFamily,
298
- fontStyle,
299
- fontSize,
300
- color,
301
- ...(letterSpacing ? { letterSpacing } : {}),
302
- });
303
- decorate(line, x, lineWidth, baseline, fontFamily, fontStyle, fontSize, color, underline, strikethrough, letterSpacing);
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
+ }
304
405
  });
305
406
  return { runs, links, decorations };
306
407
  }
@@ -309,27 +410,34 @@ export class TextRenderer {
309
410
  // the previous segment's kerning-free advance. Each line drops by its OWN leading
310
411
  // (tallest font on that line), so mixed-font lines space correctly and the drawn
311
412
  // height matches the measured height.
312
- const pushLine = (line, lineY) => {
413
+ const pushLine = (line, lineY, isLast) => {
313
414
  let x = initialX + alignmentOffset(line.width);
314
- line.segments.forEach((segment) => {
415
+ const extra = justifyExtra(line.segments.map((seg) => seg.content).join(""), line.width, isLast);
416
+ // Across span boundaries, so a Hebrew span is ordered against its neighbours rather than inside
417
+ // itself. Each run keeps the segment it came from, and with it that span's style.
418
+ const ordered = visualRunsOf(line.segments.map((segment) => ({ text: segment.content, source: segment })), direction);
419
+ ordered.forEach((part) => {
420
+ const segment = part.source;
315
421
  const family = segment.fontFamily || fontFamily;
316
422
  const size = segment.fontSize || fontSize;
317
423
  const style = segment.fontStyle || fontStyle;
318
424
  const segLetterSpacing = segment.letterSpacing ?? letterSpacing;
319
- const advance = runAdvance(objectManager, segment.content, { fontFamily: family, fontSize: size, fontStyle: style }, segLetterSpacing);
425
+ const piece = shapedPiece(part, family, style);
426
+ const runText = piece.text;
320
427
  const runColor = segment.fontColor || color;
321
- runs.push({
322
- type: "text",
323
- x,
324
- y: lineY,
325
- text: segment.content,
428
+ // `vertical-align`: this run alone leaves the line's baseline. Its own decoration and link box
429
+ // ride along, since both are placed from the same y.
430
+ const shift = VERTICAL_TEXT_SHIFT[segment.verticalAlign ?? "baseline"] * size;
431
+ const runY = lineY - shift;
432
+ const drawn = emitPiece(piece, x, extra, {
433
+ y: runY,
326
434
  fontFamily: family,
327
435
  fontStyle: style,
328
436
  fontSize: size,
329
437
  color: runColor,
330
438
  ...(segLetterSpacing ? { letterSpacing: segLetterSpacing } : {}),
331
- });
332
- decorate(segment.content, x, advance, lineY, family, style, size, runColor, segment.underline ?? underline, segment.strikethrough ?? strikethrough, 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);
333
441
  // An href/to span becomes a /Link annotation over exactly this run's glyph box: from its own
334
442
  // ascent above the baseline down to its own descender. A span wrapped across lines yields
335
443
  // one rect per line.
@@ -338,14 +446,14 @@ export class TextRenderer {
338
446
  links.push({
339
447
  type: "link",
340
448
  x,
341
- y: lineY - v.ascent * size,
342
- width: advance,
449
+ y: runY - v.ascent * size,
450
+ width: drawn,
343
451
  height: (v.ascent + v.descent) * size,
344
452
  href: segment.href,
345
453
  dest: segment.dest,
346
454
  });
347
455
  }
348
- x += advance;
456
+ x += drawn;
349
457
  });
350
458
  };
351
459
  // yPosition is the top of the text box (top-left). Each line seats its own baseline inside its
@@ -353,13 +461,24 @@ export class TextRenderer {
353
461
  // The seam flips the whole thing to PDF space. `letterSpacing` MUST be in the defaults so a span
354
462
  // that does not override it wraps and aligns with the element's spacing - the same defaults the
355
463
  // measure path (calculateTextHeight) uses, or segmented spaced text mis-wraps (measured != drawn).
356
- const defaults = { fontFamily, fontSize, fontStyle, letterSpacing };
464
+ const defaults = {
465
+ fontFamily,
466
+ fontSize,
467
+ fontStyle,
468
+ letterSpacing,
469
+ splitting: { breakWord, hyphenate },
470
+ ligatures,
471
+ };
357
472
  let top = yPosition;
358
- for (const line of breakSegmentsIntoLines(content, defaults, maxWidth, objectManager, maxLines, overflow)) {
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
+ ];
477
+ segmentLines.forEach((line, index) => {
359
478
  const box = lineBoxForSegmentLine(line, defaults, objectManager, lineHeight);
360
- pushLine(line, top + box.baseline);
479
+ pushLine(line, top + box.baseline, index === segmentLines.length - 1);
361
480
  top += box.height;
362
- }
481
+ });
363
482
  return { runs, links, decorations };
364
483
  }
365
484
  }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * The `<style>` block - the third way an SVG carries style, beside presentation attributes and the
3
+ * `style=""` bag.
4
+ *
5
+ * It exists here because it is the case that BREAKS in practice: Illustrator's default export option
6
+ * is "Internal CSS", which puts every fill into a class and leaves the shapes with nothing but
7
+ * `class="st0"`. Dropping the block therefore does not lose a detail - it makes the whole logo black.
8
+ * (react-pdf drops it, with a warning; we dropped it silently until this module existed.)
9
+ *
10
+ * Only FLAT selectors are resolved - a tag, `.class`, `#id`, and compounds of those - because that is
11
+ * what exporters emit. A combinator or a pseudo-class is reported rather than quietly mismatched: a
12
+ * selector that silently fails to match is the same black logo again, one level down.
13
+ */
14
+ export interface CssRule {
15
+ tag?: string;
16
+ id?: string;
17
+ classes: string[];
18
+ /** CSS specificity as one number, and the source order that breaks ties. */
19
+ weight: number;
20
+ order: number;
21
+ declarations: Record<string, string>;
22
+ }
23
+ /**
24
+ * Parses every `<style>` block's text into rules, in source order.
25
+ *
26
+ * The brace matching is done by hand rather than with one regex: `@media print { .a { … } }` nests,
27
+ * and a regex that skips to the first balanced-looking block silently applies the INNER rule while
28
+ * never seeing the at-rule that guards it.
29
+ */
30
+ export declare function parseStylesheet(css: string): CssRule[];
31
+ /**
32
+ * The declarations that apply to one element, weakest first - so a caller can just assign them in
33
+ * order and let the last one win, as the cascade does.
34
+ */
35
+ export declare function declarationsFor(rules: readonly CssRule[], tagName: string, attributes: {
36
+ class?: string;
37
+ id?: string;
38
+ }): Record<string, string>;
@@ -0,0 +1,115 @@
1
+ import { SvgUnsupportedError } from "./errors.js";
2
+ const COMMENTS = /\/\*[\s\S]*?\*\//g;
3
+ /** One compound selector, e.g. `rect.st0#a`. Anything structural is refused, not approximated. */
4
+ function parseSelector(selector, order) {
5
+ const text = selector.trim();
6
+ if (/[\s>+~]/.test(text)) {
7
+ throw new SvgUnsupportedError(`the CSS selector "${text}" (it combines elements)`, "Only flat selectors are resolved. Re-export with presentation attributes instead.");
8
+ }
9
+ if (text.includes(":") || text.includes("[")) {
10
+ throw new SvgUnsupportedError(`the CSS selector "${text}" (pseudo-class or attribute selector)`, "Only a tag, .class and #id are resolved. Re-export with presentation attributes instead.");
11
+ }
12
+ const classes = [...text.matchAll(/\.([\w-]+)/g)].map((m) => m[1]);
13
+ const id = /#([\w-]+)/.exec(text)?.[1];
14
+ const tag = /^[\w-]+/.exec(text)?.[0];
15
+ // The usual a-b-c weights, flattened: an id beats any number of classes, a class beats a tag.
16
+ const weight = (id ? 10000 : 0) + classes.length * 100 + (tag ? 1 : 0);
17
+ return { tag, id, classes, weight, order };
18
+ }
19
+ function parseDeclarations(body) {
20
+ const out = {};
21
+ for (const declaration of body.split(";")) {
22
+ const colon = declaration.indexOf(":");
23
+ if (colon === -1)
24
+ continue;
25
+ const property = declaration.slice(0, colon).trim();
26
+ // `!important` would need a second cascade level; exporters do not emit it, so it is refused
27
+ // rather than silently treated as a normal declaration.
28
+ const value = declaration.slice(colon + 1).trim();
29
+ if (!property)
30
+ continue;
31
+ if (/!\s*important$/i.test(value)) {
32
+ throw new SvgUnsupportedError(`"!important" in a <style> block`, "Remove it, or re-export with presentation attributes.");
33
+ }
34
+ out[property] = value;
35
+ }
36
+ return out;
37
+ }
38
+ /**
39
+ * Parses every `<style>` block's text into rules, in source order.
40
+ *
41
+ * The brace matching is done by hand rather than with one regex: `@media print { .a { … } }` nests,
42
+ * and a regex that skips to the first balanced-looking block silently applies the INNER rule while
43
+ * never seeing the at-rule that guards it.
44
+ */
45
+ export function parseStylesheet(css) {
46
+ const text = css.replace(COMMENTS, "");
47
+ const rules = [];
48
+ let order = 0;
49
+ let i = 0;
50
+ while (i < text.length) {
51
+ const open = text.indexOf("{", i);
52
+ if (open === -1)
53
+ break;
54
+ // A statement at-rule (`@import "a";`) ends at its semicolon. Taking everything up to the next
55
+ // brace as one prelude would swallow the rule that follows it.
56
+ const head = text.slice(i, open);
57
+ const prelude = head.slice(head.lastIndexOf(";") + 1).trim();
58
+ let depth = 1;
59
+ let j = open + 1;
60
+ while (j < text.length && depth > 0) {
61
+ if (text[j] === "{")
62
+ depth++;
63
+ else if (text[j] === "}")
64
+ depth--;
65
+ j++;
66
+ }
67
+ if (prelude.startsWith("@")) {
68
+ // An at-rule is DROPPED, not reported - which is what CSS itself does with one it cannot
69
+ // evaluate, and what the corpus demanded: 68 of 10,819 real files carry a `@media
70
+ // (prefers-color-scheme)` or a `@keyframes` that can never apply to a static page, and
71
+ // refusing them meant those files did not render at all.
72
+ // The exception is a query that always holds for print, whose rules DO apply.
73
+ const [at, ...rest] = prelude.split(/\s+/);
74
+ // A media list applies if ANY of its components does - `@media print, screen` is for print too.
75
+ const applies = at === "@media" &&
76
+ rest
77
+ .join(" ")
78
+ .split(",")
79
+ .some((q) => ["print", "all", ""].includes(q.trim()));
80
+ if (applies) {
81
+ // Its body is a nested stylesheet; parse it in place so the rules keep their source order.
82
+ for (const rule of parseStylesheet(text.slice(open + 1, j - 1))) {
83
+ rules.push({ ...rule, order: order++ });
84
+ }
85
+ }
86
+ i = j;
87
+ continue;
88
+ }
89
+ const declarations = parseDeclarations(text.slice(open + 1, j - 1));
90
+ for (const selector of prelude.split(",")) {
91
+ if (selector.trim() === "")
92
+ continue;
93
+ rules.push({ ...parseSelector(selector, order++), declarations });
94
+ }
95
+ i = j;
96
+ }
97
+ return rules;
98
+ }
99
+ /**
100
+ * The declarations that apply to one element, weakest first - so a caller can just assign them in
101
+ * order and let the last one win, as the cascade does.
102
+ */
103
+ export function declarationsFor(rules, tagName, attributes) {
104
+ if (rules.length === 0)
105
+ return {};
106
+ const classes = (attributes.class ?? "").trim().split(/\s+/).filter(Boolean);
107
+ const matched = rules.filter((rule) => (rule.tag === undefined || rule.tag === tagName) &&
108
+ (rule.id === undefined || rule.id === attributes.id) &&
109
+ rule.classes.every((name) => classes.includes(name)));
110
+ matched.sort((a, b) => a.weight - b.weight || a.order - b.order);
111
+ const out = {};
112
+ for (const rule of matched)
113
+ Object.assign(out, rule.declarations);
114
+ return out;
115
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * SVG is a document format, not a graphics format: it carries CSS, text layout, filters, masks and
3
+ * animation. Filters and masks are PIXEL operations, so no vector backend can express them - which is
4
+ * why every complete SVG renderer owns a rasterizer, and why every PDF library ships a subset instead.
5
+ *
6
+ * We ship a subset too. The difference is what happens at its edge: an element we cannot draw is a
7
+ * NAMED ERROR, never a silent skip. Two reasons, and the second is the important one.
8
+ *
9
+ * 1. A silently dropped element leaves a logo that renders and is quietly wrong - the fill missing,
10
+ * the icon half there - and nobody finds out. (react-pdf skips, and its `<style>` handling loses
11
+ * every fill of an Illustrator export made with the default "Internal CSS" option.)
12
+ * 2. It keeps every later stage a MINOR. Supporting something new turns an error into a picture, which
13
+ * can never break a document that worked. Going the other way - from silently skipping to drawing -
14
+ * would change existing output.
15
+ */
16
+ /** An element or attribute value we deliberately do not support yet. */
17
+ export declare class SvgUnsupportedError extends Error {
18
+ readonly feature: string;
19
+ constructor(feature: string, hint: string);
20
+ }
21
+ /** The file is not SVG we can read at all - malformed, or with no root `<svg>`. */
22
+ export declare class SvgParseError extends Error {
23
+ constructor(message: string);
24
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * SVG is a document format, not a graphics format: it carries CSS, text layout, filters, masks and
3
+ * animation. Filters and masks are PIXEL operations, so no vector backend can express them - which is
4
+ * why every complete SVG renderer owns a rasterizer, and why every PDF library ships a subset instead.
5
+ *
6
+ * We ship a subset too. The difference is what happens at its edge: an element we cannot draw is a
7
+ * NAMED ERROR, never a silent skip. Two reasons, and the second is the important one.
8
+ *
9
+ * 1. A silently dropped element leaves a logo that renders and is quietly wrong - the fill missing,
10
+ * the icon half there - and nobody finds out. (react-pdf skips, and its `<style>` handling loses
11
+ * every fill of an Illustrator export made with the default "Internal CSS" option.)
12
+ * 2. It keeps every later stage a MINOR. Supporting something new turns an error into a picture, which
13
+ * can never break a document that worked. Going the other way - from silently skipping to drawing -
14
+ * would change existing output.
15
+ */
16
+ /** An element or attribute value we deliberately do not support yet. */
17
+ export class SvgUnsupportedError extends Error {
18
+ constructor(feature, hint) {
19
+ super(`@jasy/pdf: ${feature} is not supported yet. ${hint}`);
20
+ this.feature = feature;
21
+ this.name = "SvgUnsupportedError";
22
+ }
23
+ }
24
+ /** The file is not SVG we can read at all - malformed, or with no root `<svg>`. */
25
+ export class SvgParseError extends Error {
26
+ constructor(message) {
27
+ super(`@jasy/pdf: ${message}`);
28
+ this.name = "SvgParseError";
29
+ }
30
+ }
@@ -0,0 +1,34 @@
1
+ import type { Gradient, GradientStop, PathCommand } from "../ir/display-list.js";
2
+ import { type Attributes } from "./style.js";
3
+ import type { XmlNode } from "./xml.js";
4
+ /**
5
+ * `<linearGradient>` and `<radialGradient>`.
6
+ *
7
+ * The backend has painted shadings since colour emoji, and `Box({ bg: linearGradient(...) })` uses
8
+ * the same node - so this file only has to turn a `url(#id)` reference into that node. Two things
9
+ * make it more than a lookup:
10
+ *
11
+ * - **`gradientUnits` defaults to `objectBoundingBox`**, i.e. the coordinates are fractions of the
12
+ * SHAPE's bounding box, not of the page. So a gradient cannot be resolved where it is defined,
13
+ * only where it is used - which is why this takes the shape's commands.
14
+ * - **`href` chains.** Illustrator emits one gradient with the stops and a second that references it
15
+ * and only moves the coordinates. Missing that leaves a shape with no stops at all.
16
+ */
17
+ export interface GradientDef {
18
+ kind: "linear" | "radial";
19
+ attributes: Attributes;
20
+ stops: GradientStop[];
21
+ /** The `stop-opacity` of each stop, in the same order. */
22
+ opacities: number[];
23
+ /** The id this definition inherits from (`href` / `xlink:href`), without the `#`. */
24
+ href?: string;
25
+ }
26
+ /** Every gradient definition in the document, by id - they are referenced, not drawn in place. */
27
+ export declare function gradientsOf(node: XmlNode, into?: Map<string, GradientDef>, depth?: number): Map<string, GradientDef>;
28
+ /**
29
+ * Resolves a reference into an IR gradient positioned on `commands`.
30
+ *
31
+ * The anchors are mapped into the shape's own space, then through `gradientTransform` - so they end
32
+ * up in the SAME coordinates as the path, which is what the backend and the Y-flip both assume.
33
+ */
34
+ export declare function resolveSvgGradient(def: GradientDef, defs: ReadonlyMap<string, GradientDef>, commands: readonly PathCommand[]): Gradient;