@jasy/pdf 1.0.0-alpha.9 → 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 (110) hide show
  1. package/README.md +27 -9
  2. package/dist/api/color.js +69 -0
  3. package/dist/api/content.d.ts +40 -1
  4. package/dist/api/content.js +83 -0
  5. package/dist/api/descriptor.js +6 -1
  6. package/dist/api/dimension.d.ts +3 -2
  7. package/dist/api/font-url.js +3 -2
  8. package/dist/api/structure.d.ts +7 -0
  9. package/dist/api/structure.js +38 -3
  10. package/dist/api/text.d.ts +23 -2
  11. package/dist/api/text.js +9 -0
  12. package/dist/canvas/painter.d.ts +102 -0
  13. package/dist/canvas/painter.js +183 -0
  14. package/dist/elements/canvas-element.d.ts +38 -0
  15. package/dist/elements/canvas-element.js +47 -0
  16. package/dist/elements/container-element.d.ts +2 -0
  17. package/dist/elements/container-element.js +6 -0
  18. package/dist/elements/index.d.ts +2 -0
  19. package/dist/elements/index.js +2 -0
  20. package/dist/elements/layout/expanded-element.d.ts +2 -0
  21. package/dist/elements/layout/expanded-element.js +4 -0
  22. package/dist/elements/layout/keep-together-element.d.ts +2 -0
  23. package/dist/elements/layout/keep-together-element.js +4 -0
  24. package/dist/elements/layout/padding-element.d.ts +5 -0
  25. package/dist/elements/layout/padding-element.js +8 -0
  26. package/dist/elements/layout/struct-group.d.ts +2 -0
  27. package/dist/elements/layout/struct-group.js +4 -0
  28. package/dist/elements/pdf-element.d.ts +19 -3
  29. package/dist/elements/pdf-element.js +22 -4
  30. package/dist/elements/rectangle-element.d.ts +2 -0
  31. package/dist/elements/rectangle-element.js +6 -0
  32. package/dist/elements/row-element.d.ts +2 -0
  33. package/dist/elements/row-element.js +6 -0
  34. package/dist/elements/svg-element.d.ts +41 -0
  35. package/dist/elements/svg-element.js +93 -0
  36. package/dist/elements/text-element.d.ts +33 -1
  37. package/dist/elements/text-element.js +80 -4
  38. package/dist/ir/display-list.d.ts +44 -5
  39. package/dist/layout/min-intrinsic.d.ts +29 -0
  40. package/dist/layout/min-intrinsic.js +37 -0
  41. package/dist/renderer/anchor-renderer.js +1 -1
  42. package/dist/renderer/bookmark-renderer.js +1 -1
  43. package/dist/renderer/canvas-renderer.d.ts +14 -0
  44. package/dist/renderer/canvas-renderer.js +36 -0
  45. package/dist/renderer/container-renderer.js +1 -3
  46. package/dist/renderer/default-text-style-renderer.js +1 -1
  47. package/dist/renderer/deferred-renderer.js +1 -1
  48. package/dist/renderer/expanded-renderer.js +1 -1
  49. package/dist/renderer/index.d.ts +2 -0
  50. package/dist/renderer/index.js +2 -0
  51. package/dist/renderer/keep-together-renderer.js +1 -1
  52. package/dist/renderer/link-renderer.js +1 -1
  53. package/dist/renderer/padding-renderer.js +1 -1
  54. package/dist/renderer/page-builder-renderer.js +1 -1
  55. package/dist/renderer/page-renderer.js +1 -3
  56. package/dist/renderer/pdf-backend.d.ts +15 -4
  57. package/dist/renderer/pdf-backend.js +125 -35
  58. package/dist/renderer/pdf-renderer.js +5 -1
  59. package/dist/renderer/positioned-renderer.js +1 -1
  60. package/dist/renderer/rectangle-renderer.js +1 -3
  61. package/dist/renderer/repeating-header-renderer.js +1 -2
  62. package/dist/renderer/rotated-renderer.js +1 -1
  63. package/dist/renderer/row-renderer.js +1 -3
  64. package/dist/renderer/struct-group-renderer.js +1 -1
  65. package/dist/renderer/svg-renderer.d.ts +11 -0
  66. package/dist/renderer/svg-renderer.js +27 -0
  67. package/dist/renderer/text-renderer.js +30 -19
  68. package/dist/svg/css.d.ts +38 -0
  69. package/dist/svg/css.js +115 -0
  70. package/dist/svg/errors.d.ts +24 -0
  71. package/dist/svg/errors.js +30 -0
  72. package/dist/svg/gradients.d.ts +34 -0
  73. package/dist/svg/gradients.js +156 -0
  74. package/dist/svg/index.d.ts +22 -0
  75. package/dist/svg/index.js +331 -0
  76. package/dist/svg/shapes.d.ts +6 -0
  77. package/dist/svg/shapes.js +177 -0
  78. package/dist/svg/style.d.ts +61 -0
  79. package/dist/svg/style.js +144 -0
  80. package/dist/svg/transform.d.ts +13 -0
  81. package/dist/svg/transform.js +68 -0
  82. package/dist/svg/units.d.ts +11 -0
  83. package/dist/svg/units.js +73 -0
  84. package/dist/svg/xml.d.ts +24 -0
  85. package/dist/svg/xml.js +158 -0
  86. package/dist/text/advance.d.ts +3 -0
  87. package/dist/text/advance.js +4 -3
  88. package/dist/text/glyph-coverage.d.ts +15 -0
  89. package/dist/text/glyph-coverage.js +77 -0
  90. package/dist/text/line-breaker.d.ts +8 -0
  91. package/dist/text/line-breaker.js +146 -70
  92. package/dist/text/shape.d.ts +3 -3
  93. package/dist/text/shape.js +34 -10
  94. package/dist/text/text-style.d.ts +13 -0
  95. package/dist/text/text-style.js +6 -0
  96. package/dist/text/whitespace.d.ts +4 -0
  97. package/dist/text/whitespace.js +43 -0
  98. package/dist/text/word-splitting.d.ts +42 -0
  99. package/dist/text/word-splitting.js +72 -0
  100. package/dist/utils/flex-layout.js +67 -18
  101. package/dist/utils/font-metrics.d.ts +9 -3
  102. package/dist/utils/pdf-object-manager.d.ts +25 -5
  103. package/dist/utils/pdf-object-manager.js +82 -18
  104. package/dist/utils/renderer-registry.d.ts +13 -1
  105. package/dist/utils/renderer-registry.js +22 -1
  106. package/dist/utils/woff2.d.ts +6 -0
  107. package/dist/utils/woff2.js +651 -0
  108. package/dist/vector/path.d.ts +33 -0
  109. package/dist/vector/path.js +81 -0
  110. package/package.json +5 -2
@@ -41,11 +41,22 @@ export declare class PdfBackend {
41
41
  static escapePdfString(text: string): string;
42
42
  /**
43
43
  * Builds the `[ ... ]` operand of a `TJ` from a run and its per-gap kern adjustments (em/1000, the
44
- * sign the font declares - negative tightens). A `TJ` number moves the pen LEFT, so it is the
45
- * NEGATED kern. Consecutive un-kerned glyphs stay in one encoded chunk, so `[(T) 40 (otal)]` rather
46
- * than one chunk per glyph. `kerns[i]` is the adjustment AFTER code point `i`.
44
+ * sign the font declares). Generic over the UNIT so the same chunking serves both paths: characters
45
+ * for a WinAnsi run, glyph ids for a shaped one - a shaped run cannot be expressed as text.
46
+ * A `TJ` number moves the pen LEFT, so it is the NEGATED kern. Consecutive un-kerned glyphs stay in
47
+ * one encoded chunk, so `[(T) 40 (otal)]` rather than one chunk per glyph. `kerns[i]` is the
48
+ * adjustment AFTER code point `i`.
47
49
  */
48
- static kernedArray(text: string, kerns: number[], encode: (chunk: string) => string): string;
50
+ static kernedArray<T>(units: readonly T[], kerns: number[], encode: (chunk: T[]) => string): string;
51
+ /** The path-construction operators for a command list. Shared by a filled path and by a clip. */
52
+ private static pathOps;
53
+ /**
54
+ * The graphics-state operators for a stroke, in the order a content stream wants them. Only what
55
+ * differs from the PDF default is emitted, EXCEPT the miter limit: SVG's initial value is 4 and
56
+ * PDF's is 10, so a mitered join has to say so or a sharp corner grows a spike the source never had.
57
+ * The caller isolates these in a q/Q - `d` in particular would otherwise dash every later stroke.
58
+ */
59
+ private static strokeState;
49
60
  /**
50
61
  * Returns the `/GSx gs` operator that selects a transparency state, or `""` when both
51
62
  * alphas are fully opaque. Opaque draws emit nothing here, so output stays
@@ -1,4 +1,4 @@
1
- import { isRounded } from "../ir/display-list.js";
1
+ import { isRounded, } from "../ir/display-list.js";
2
2
  import { Color } from "../common/color.js";
3
3
  /**
4
4
  * PDF backend - turns display-list primitives into content-stream operators.
@@ -46,6 +46,19 @@ export class PdfBackend {
46
46
  }
47
47
  return flipped;
48
48
  }
49
+ case "clip-path-push":
50
+ // Its commands live in the same space as a Path's, so they flip the same way.
51
+ return {
52
+ ...node,
53
+ commands: node.commands.map((c) => {
54
+ if (c.op === "z")
55
+ return c;
56
+ if (c.op === "c") {
57
+ return { ...c, y1: pageHeight - c.y1, y2: pageHeight - c.y2, y: pageHeight - c.y };
58
+ }
59
+ return { ...c, y: pageHeight - c.y };
60
+ }),
61
+ };
49
62
  case "clip-push":
50
63
  // Flip the clip rect around its bottom edge, like a rect.
51
64
  return { ...node, y: pageHeight - node.y - node.height };
@@ -63,7 +76,7 @@ export class PdfBackend {
63
76
  });
64
77
  // A gradient fill carries its own page-space anchor points; flip their y too (radii and
65
78
  // x stay). A solid Color fill has no geometry.
66
- const fill = node.fill instanceof Color
79
+ const fill = node.fill === undefined || node.fill instanceof Color
67
80
  ? node.fill
68
81
  : { ...node.fill, y0: pageHeight - node.fill.y0, y1: pageHeight - node.fill.y1 };
69
82
  return { ...node, commands, fill };
@@ -140,6 +153,7 @@ export class PdfBackend {
140
153
  // catalog entries instead of content-stream operators.
141
154
  if (!struct ||
142
155
  node.type === "clip-push" ||
156
+ node.type === "clip-path-push" ||
143
157
  node.type === "clip-pop" ||
144
158
  node.type === "transform-push" ||
145
159
  node.type === "transform-pop" ||
@@ -164,25 +178,72 @@ export class PdfBackend {
164
178
  }
165
179
  /**
166
180
  * Builds the `[ ... ]` operand of a `TJ` from a run and its per-gap kern adjustments (em/1000, the
167
- * sign the font declares - negative tightens). A `TJ` number moves the pen LEFT, so it is the
168
- * NEGATED kern. Consecutive un-kerned glyphs stay in one encoded chunk, so `[(T) 40 (otal)]` rather
169
- * than one chunk per glyph. `kerns[i]` is the adjustment AFTER code point `i`.
181
+ * sign the font declares). Generic over the UNIT so the same chunking serves both paths: characters
182
+ * for a WinAnsi run, glyph ids for a shaped one - a shaped run cannot be expressed as text.
183
+ * A `TJ` number moves the pen LEFT, so it is the NEGATED kern. Consecutive un-kerned glyphs stay in
184
+ * one encoded chunk, so `[(T) 40 (otal)]` rather than one chunk per glyph. `kerns[i]` is the
185
+ * adjustment AFTER code point `i`.
170
186
  */
171
- static kernedArray(text, kerns, encode) {
172
- const chars = [...text];
187
+ static kernedArray(units, kerns, encode) {
188
+ const gaps = Math.max(0, units.length - 1);
189
+ if (kerns.length !== gaps) {
190
+ // The loop walks the GAPS, so a short list would drop the units past it - silently, and only the
191
+ // last glyph, which is exactly the kind of thing nobody notices until a word reads wrong. An
192
+ // empty run has no gaps at all, so any adjustment for one is a disagreement too.
193
+ throw new Error(`@jasy/pdf: ${kerns.length} kern adjustments for ${units.length} units - expected ${gaps}.`);
194
+ }
173
195
  let out = "";
174
- let chunk = chars[0] ?? "";
196
+ let chunk = units.length > 0 ? [units[0]] : [];
175
197
  for (let i = 0; i < kerns.length; i++) {
176
198
  if (kerns[i] === 0) {
177
- chunk += chars[i + 1];
199
+ chunk.push(units[i + 1]);
178
200
  }
179
201
  else {
180
202
  out += encode(chunk) + ` ${-kerns[i]} `;
181
- chunk = chars[i + 1];
203
+ chunk = [units[i + 1]];
182
204
  }
183
205
  }
184
206
  return `[${out}${encode(chunk)}]`;
185
207
  }
208
+ /** The path-construction operators for a command list. Shared by a filled path and by a clip. */
209
+ static pathOps(commands) {
210
+ const f = (n) => n.toFixed(3);
211
+ let out = "";
212
+ for (const c of commands) {
213
+ if (c.op === "m")
214
+ out += `${f(c.x)} ${f(c.y)} m\n`;
215
+ else if (c.op === "l")
216
+ out += `${f(c.x)} ${f(c.y)} l\n`;
217
+ else if (c.op === "c")
218
+ out += `${f(c.x1)} ${f(c.y1)} ${f(c.x2)} ${f(c.y2)} ${f(c.x)} ${f(c.y)} c\n`;
219
+ else
220
+ out += `h\n`;
221
+ }
222
+ return out;
223
+ }
224
+ /**
225
+ * The graphics-state operators for a stroke, in the order a content stream wants them. Only what
226
+ * differs from the PDF default is emitted, EXCEPT the miter limit: SVG's initial value is 4 and
227
+ * PDF's is 10, so a mitered join has to say so or a sharp corner grows a spike the source never had.
228
+ * The caller isolates these in a q/Q - `d` in particular would otherwise dash every later stroke.
229
+ */
230
+ static strokeState(stroke) {
231
+ const CAPS = { butt: 0, round: 1, square: 2 };
232
+ const JOINS = { miter: 0, round: 1, bevel: 2 };
233
+ let ops = `${stroke.width} w\n${stroke.color.toPDFColorString()} RG\n`;
234
+ if (stroke.cap && stroke.cap !== "butt")
235
+ ops += `${CAPS[stroke.cap]} J\n`;
236
+ const join = stroke.join ?? "miter";
237
+ if (join !== "miter")
238
+ ops += `${JOINS[join]} j\n`;
239
+ else
240
+ ops += `${stroke.miterLimit ?? 4} M\n`;
241
+ // A dash array of all zeros means "solid" in SVG but is an error in PDF, so it is dropped.
242
+ if (stroke.dash && stroke.dash.length > 0 && stroke.dash.some((n) => n > 0)) {
243
+ ops += `[${stroke.dash.join(" ")}] ${stroke.dashOffset ?? 0} d\n`;
244
+ }
245
+ return ops;
246
+ }
186
247
  /**
187
248
  * Returns the `/GSx gs` operator that selects a transparency state, or `""` when both
188
249
  * alphas are fully opaque. Opaque draws emit nothing here, so output stays
@@ -273,7 +334,8 @@ export class PdfBackend {
273
334
  const gradient = node.fill !== undefined && !(node.fill instanceof Color) ? node.fill : undefined;
274
335
  if (gradient) {
275
336
  const shading = om.registerShading(gradient);
276
- let out = `q\n${path} W n\n/${shading} sh\nQ\n`;
337
+ const gsFill = PdfBackend.alphaPrefix(om, gradient.alpha ?? 1, 1);
338
+ let out = `q\n${gsFill}${path} W n\n/${shading} sh\nQ\n`;
277
339
  if (doStroke) {
278
340
  const gsStroke = PdfBackend.alphaPrefix(om, 1, node.stroke.getAlpha());
279
341
  const stroke = `${node.strokeWidth} w\n${node.stroke.toPDFColorString()} RG\n` + `${path} S\n`;
@@ -308,7 +370,7 @@ export class PdfBackend {
308
370
  ? om.getCustomFontResource(node.fontFamily, node.fontStyle)
309
371
  : om.registerFont(node.fontFamily, node.fontStyle);
310
372
  const encode = (t) => isCustom
311
- ? `<${om.encodeCustomText(node.fontFamily, t, node.fontStyle)}>`
373
+ ? `<${om.encodeCustomText(node.fontFamily, t, node.fontStyle, false)}>`
312
374
  : `(${PdfBackend.escapePdfString(t)})`;
313
375
  // A shaped run brings its own glyphs, already in drawing order - they cannot be derived from
314
376
  // the text here.
@@ -318,13 +380,24 @@ export class PdfBackend {
318
380
  // Kerning (if the document has it on): a `TJ` array with the font's per-pair adjustments
319
381
  // between glyph chunks, measured into the layout by the SAME numbers (runAdvance). A plain
320
382
  // `Tj` when the run has no kerning, byte-identical.
321
- const kerns = om.kerningEnabled
322
- ? om.getKernPairs(node.text, node.fontFamily, node.fontStyle)
323
- : [];
324
- const showOp = shapedHex
325
- ? `<${shapedHex}> Tj`
383
+ // A shaped run brings its glyphs along, so kern those directly rather than re-deriving them
384
+ // from the text - which would also have to guess the run's ligature setting.
385
+ const kerns = !om.kerningEnabled
386
+ ? []
387
+ : node.glyphs && isCustom
388
+ ? om.getGlyphKernPairs(node.glyphs, node.fontFamily, node.fontStyle)
389
+ : // No glyph list means the run was NOT shaped, whatever the document asks for - so take the
390
+ // character path explicitly. Letting it default would shape here, and a pair count that
391
+ // does not match the character count silently drops the last one.
392
+ om.getKernPairs(node.text, node.fontFamily, node.fontStyle, false);
393
+ // A shaped run kerns too, over its SHAPED glyphs - `getKernPairs` returns one adjustment per
394
+ // adjacent drawn pair either way, so both paths use the same numbers `runAdvance` measured.
395
+ const showOp = node.glyphs && isCustom
396
+ ? kerns.some((k) => k !== 0)
397
+ ? `${PdfBackend.kernedArray(node.glyphs, kerns, (chunk) => `<${om.registerGlyphs(node.fontFamily, chunk, node.fontStyle)}>`)} TJ`
398
+ : `<${shapedHex}> Tj`
326
399
  : kerns.some((k) => k !== 0)
327
- ? `${PdfBackend.kernedArray(node.text, kerns, encode)} TJ`
400
+ ? `${PdfBackend.kernedArray([...node.text], kerns, (chunk) => encode(chunk.join("")))} TJ`
328
401
  : `${encode(node.text)} Tj`;
329
402
  // letterSpacing is the `Tc` operator: extra advance after EVERY glyph (an embedded
330
403
  // Identity-H font too - that is `Tw`, word spacing, which only touches single-byte code 32).
@@ -364,32 +437,49 @@ export class PdfBackend {
364
437
  return `q\n${path}\nW n\n`;
365
438
  }
366
439
  case "path": {
367
- // A filled vector path (one color-glyph layer). Emit the subpath ops, then either a solid
368
- // nonzero fill (a Color) or, for a gradient, clip to the path and paint a shading inside it.
369
- const f = (n) => n.toFixed(3);
370
- let path = "";
371
- for (const c of node.commands) {
372
- if (c.op === "m")
373
- path += `${f(c.x)} ${f(c.y)} m\n`;
374
- else if (c.op === "l")
375
- path += `${f(c.x)} ${f(c.y)} l\n`;
376
- else if (c.op === "c")
377
- path += `${f(c.x1)} ${f(c.y1)} ${f(c.x2)} ${f(c.y2)} ${f(c.x)} ${f(c.y)} c\n`;
378
- else
379
- path += `h\n`;
380
- }
381
- if (!(node.fill instanceof Color)) {
440
+ // A vector path: a color-glyph layer (fill only) or an SVG shape (fill and/or stroke). Emit
441
+ // the subpath ops once, then pick the painting operator from what the node actually carries.
442
+ const path = PdfBackend.pathOps(node.commands);
443
+ // The even-odd variants of the fill and clip operators are the same letter plus a star.
444
+ const star = node.fillRule === "evenodd" ? "*" : "";
445
+ const stroke = node.stroke;
446
+ if (node.fill !== undefined && !(node.fill instanceof Color)) {
382
447
  // Gradient: clip to the path (W n keeps it as the clip without painting it), then paint
383
448
  // the registered shading across that clip. The q/Q isolates the clip. Note: per-stop alpha
384
449
  // is not represented (a DeviceRGB shading has no alpha channel - that would need a soft-mask
385
450
  // group); COLR gradient stops are opaque in real fonts, so they render correctly.
451
+ // A stroke cannot share that q/Q: the clip would cut the outline in half, because a stroke
452
+ // straddles the edge. So it is drawn afterwards, over the same path.
386
453
  const shading = om.registerShading(node.fill);
387
- return `q\n${path}W n\n/${shading} sh\nQ\n`;
454
+ const gsFill = PdfBackend.alphaPrefix(om, node.fill.alpha ?? 1, 1);
455
+ const filled = `q\n${gsFill}${path}W${star} n\n/${shading} sh\nQ\n`;
456
+ if (!stroke)
457
+ return filled;
458
+ const gsStroke = PdfBackend.alphaPrefix(om, 1, stroke.color.getAlpha());
459
+ return `${filled}q\n${gsStroke}${PdfBackend.strokeState(stroke)}${path}S\nQ\n`;
460
+ }
461
+ // No fill and no stroke: a shape that paints nothing. Emitting the path without an operator
462
+ // would leave it pending in the content stream and swallow whatever is drawn next.
463
+ if (node.fill === undefined && !stroke)
464
+ return "";
465
+ if (stroke) {
466
+ // A stroked path always gets its own q/Q. Unlike a rect it can set `J`, `j`, `M` and `d`,
467
+ // and those would otherwise stay on for every later stroke on the page - a dash pattern
468
+ // leaking into the next table rule. Existing IR carries no stroke, so nothing moves.
469
+ const paint = node.fill !== undefined ? `B${star}` : "S";
470
+ const color = node.fill !== undefined ? `${node.fill.toPDFColorString()} rg\n` : "";
471
+ const gs = PdfBackend.alphaPrefix(om, node.fill !== undefined ? node.fill.getAlpha() : 1, stroke.color.getAlpha());
472
+ return `q\n${gs}${color}${PdfBackend.strokeState(stroke)}${path}${paint}\nQ\n`;
388
473
  }
389
- const body = `${node.fill.toPDFColorString()} rg\n${path}f\n`;
474
+ const body = `${node.fill.toPDFColorString()} rg\n${path}f${star}\n`;
390
475
  const gs = PdfBackend.alphaPrefix(om, node.fill.getAlpha(), 1);
391
476
  return gs ? `q\n${gs}${body}Q\n` : body;
392
477
  }
478
+ case "clip-path-push": {
479
+ // `W n` sets the clip from the current path without painting it; the q is closed by clip-pop.
480
+ const star = node.fillRule === "evenodd" ? "*" : "";
481
+ return `q\n${PdfBackend.pathOps(node.commands)}W${star} n\n`;
482
+ }
393
483
  case "clip-pop":
394
484
  return `Q\n`;
395
485
  case "transform-push": {
@@ -1,6 +1,6 @@
1
1
  import { PDFDocumentRenderer } from "./pdf-document-renderer.js";
2
2
  import { RendererRegistry } from "../utils/renderer-registry.js";
3
- import { DefaultTextStyleElement, ExpandedElement, ImageElement, LineElement, PaddingElement, TextElement, } from "../elements/index.js";
3
+ import { DefaultTextStyleElement, ExpandedElement, ImageElement, LineElement, CanvasElement, PaddingElement, SvgElement, TextElement, } from "../elements/index.js";
4
4
  import { TextRenderer } from "./text-renderer.js";
5
5
  import { ContainerElement } from "../elements/container-element.js";
6
6
  import { RectangleElement } from "../elements/rectangle-element.js";
@@ -13,6 +13,8 @@ import { PaddingRenderer } from "./padding-renderer.js";
13
13
  import { DefaultTextStyleRenderer } from "./default-text-style-renderer.js";
14
14
  import { ImageRenderer } from "./image-renderer.js";
15
15
  import { LineRenderer } from "./line-renderer.js";
16
+ import { SvgRenderer } from "./svg-renderer.js";
17
+ import { CanvasRenderer } from "./canvas-renderer.js";
16
18
  import { RepeatingHeaderElement } from "../elements/layout/repeating-header-element.js";
17
19
  import { RepeatingHeaderRenderer } from "./repeating-header-renderer.js";
18
20
  import { DeferredElement } from "../elements/layout/deferred-element.js";
@@ -67,6 +69,8 @@ export class PDFRenderer {
67
69
  RendererRegistry.register(DefaultTextStyleElement, DefaultTextStyleRenderer.render);
68
70
  RendererRegistry.register(ImageElement, ImageRenderer.render);
69
71
  RendererRegistry.register(LineElement, LineRenderer.render);
72
+ RendererRegistry.register(SvgElement, SvgRenderer.render);
73
+ RendererRegistry.register(CanvasElement, CanvasRenderer.render);
70
74
  RendererRegistry.register(RepeatingHeaderElement, RepeatingHeaderRenderer.render);
71
75
  RendererRegistry.register(DeferredElement, DeferredRenderer.render);
72
76
  RendererRegistry.register(PositionedElement, PositionedRenderer.render);
@@ -5,6 +5,6 @@ export class PositionedRenderer {
5
5
  // after the frame's flow content, so it paints on top.
6
6
  const { child } = positionedElement.getProps();
7
7
  const renderer = RendererRegistry.getRenderer(child);
8
- return renderer ? renderer(child, objectManager) : [];
8
+ return renderer(child, objectManager);
9
9
  }
10
10
  }
@@ -44,9 +44,7 @@ export class RectangleRenderer {
44
44
  if (children)
45
45
  for (const child of children) {
46
46
  const renderer = RendererRegistry.getRenderer(child);
47
- if (renderer) {
48
- nodes.push(...(await renderer(child, objectManager)));
49
- }
47
+ nodes.push(...(await renderer(child, objectManager)));
50
48
  }
51
49
  if (clip)
52
50
  nodes.push({ type: "clip-pop" });
@@ -5,8 +5,7 @@ export class RepeatingHeaderRenderer {
5
5
  const nodes = [];
6
6
  for (const child of [header, body]) {
7
7
  const renderer = RendererRegistry.getRenderer(child);
8
- if (renderer)
9
- nodes.push(...(await renderer(child, objectManager)));
8
+ nodes.push(...(await renderer(child, objectManager)));
10
9
  }
11
10
  return nodes;
12
11
  }
@@ -22,7 +22,7 @@ export class RotatedRenderer {
22
22
  cy - sin * cx - cos * cy,
23
23
  ];
24
24
  const renderer = RendererRegistry.getRenderer(child);
25
- const childNodes = renderer ? await renderer(child, objectManager) : [];
25
+ const childNodes = await renderer(child, objectManager);
26
26
  // Wrap the child's drawing between a push (apply the matrix) and a pop (restore).
27
27
  return [{ type: "transform-push", matrix }, ...childNodes, { type: "transform-pop" }];
28
28
  }
@@ -6,9 +6,7 @@ export class RowRenderer {
6
6
  if (children)
7
7
  for (const child of children) {
8
8
  const renderer = RendererRegistry.getRenderer(child);
9
- if (renderer) {
10
- nodes.push(...(await renderer(child, objectManager)));
11
- }
9
+ nodes.push(...(await renderer(child, objectManager)));
12
10
  }
13
11
  return nodes;
14
12
  }
@@ -8,7 +8,7 @@ export class StructGroupRenderer {
8
8
  const { role, child } = group.getProps();
9
9
  const renderChild = async () => {
10
10
  const renderer = RendererRegistry.getRenderer(child);
11
- return renderer ? await renderer(child, objectManager) : [];
11
+ return renderer(child, objectManager);
12
12
  };
13
13
  if (!objectManager.struct.enabled)
14
14
  return renderChild();
@@ -0,0 +1,11 @@
1
+ import { PDFObjectManager } from "../utils/pdf-object-manager.js";
2
+ import { SvgElement } from "../elements/svg-element.js";
3
+ import type { IRNode } from "../ir/display-list.js";
4
+ /**
5
+ * Turns the drawing into display-list nodes inside the box layout gave it. All the work is in
6
+ * `svg/`; this is only the seam, which is why an SVG needs no new IR primitive - the vector layer
7
+ * that colour emoji already draw through carries it.
8
+ */
9
+ export declare class SvgRenderer {
10
+ static render(element: SvgElement, objectManager: PDFObjectManager): Promise<IRNode[]>;
11
+ }
@@ -0,0 +1,27 @@
1
+ import { svgToIr } from "../svg/index.js";
2
+ /**
3
+ * Turns the drawing into display-list nodes inside the box layout gave it. All the work is in
4
+ * `svg/`; this is only the seam, which is why an SVG needs no new IR primitive - the vector layer
5
+ * that colour emoji already draw through carries it.
6
+ */
7
+ export class SvgRenderer {
8
+ static async render(element, objectManager) {
9
+ const { x, y, width, height, source, alt } = element.getProps();
10
+ const nodes = svgToIr(source, {
11
+ x: x ?? 0,
12
+ y: y ?? 0,
13
+ width: width ?? 0,
14
+ height: height ?? 0,
15
+ });
16
+ // Accessible tagging: a drawing WITH alt text is one Figure - every path of it belongs to the
17
+ // same struct element, which is what `openElement` keyed on `structId` gives us. Without alt it
18
+ // stays untagged and the backend treats it as decoration (an Artifact).
19
+ if (!alt || !objectManager.struct.enabled)
20
+ return nodes;
21
+ const tag = {
22
+ role: "Figure",
23
+ key: objectManager.struct.openElement(element.structId, "Figure", { alt }),
24
+ };
25
+ return nodes.map((node) => (node.type === "path" ? { ...node, tag } : node));
26
+ }
27
+ }
@@ -8,6 +8,7 @@ import { lineBoxForSegmentLine, lineBoxForString } from "../text/line-metrics.js
8
8
  import { runAdvance } from "../text/advance.js";
9
9
  import { visualRuns, visualRunsOf } from "../text/bidi.js";
10
10
  import { skipInkSegments, strikethroughStroke, underlineStroke, } from "../text/text-decoration.js";
11
+ import { quadToCubic } from "../vector/path.js";
11
12
  export class TextRenderer {
12
13
  // Measuring only needs metrics, not the full object manager. (The render pass below
13
14
  // still receives the manager because it also registers fonts/images.)
@@ -18,17 +19,26 @@ export class TextRenderer {
18
19
  const box = lineBoxForString(objectManager, fontFamily, fontStyle, fontSize, lineHeight);
19
20
  return lines.length * box.height;
20
21
  }
21
- // Segments: each line's box comes from the fonts actually on it.
22
- 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
+ };
23
33
  const lines = breakSegmentsIntoLines(content, defaults, maxWidth, objectManager, maxLines, overflow);
24
34
  return lines.reduce((total, line) => total + lineBoxForSegmentLine(line, defaults, objectManager, lineHeight).height, 0);
25
35
  }
26
36
  static async render(textElement, objectManager) {
27
- const { x, y, width, fontSize, color, content, fontFamily, fontStyle, textAlignment, maxLines, overflow, lineHeight, underline, strikethrough, skipInk, letterSpacing, direction, wordSpacing, textIndent, 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();
28
38
  // Component -> display list. Wrapping and positioning stay here; the backend
29
39
  // turns each run into BT/Tf/Td/Tj/ET. The wrapping algorithm is unchanged from
30
40
  // the original renderer - unifying it into the engine is Phase 3.
31
- 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);
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);
32
42
  // Accessible tagging: this whole text block is one structure element (a paragraph P, or a heading
33
43
  // from `role`); each run's text lands under it. Emoji sub-nodes produced below stay untagged (drawn
34
44
  // as artifacts). Keyed by structId so a block split across pages stays one element.
@@ -161,19 +171,9 @@ export class TextRenderer {
161
171
  out.push({ op: "l", x: curX, y: curY });
162
172
  }
163
173
  else if (cmd.type === "Q") {
164
- const ctrlX = mapX(cmd.cx, cmd.cy);
165
- const ctrlY = mapY(cmd.cx, cmd.cy);
166
174
  const endX = mapX(cmd.x, cmd.y);
167
175
  const endY = mapY(cmd.x, cmd.y);
168
- out.push({
169
- op: "c",
170
- x1: curX + (2 / 3) * (ctrlX - curX),
171
- y1: curY + (2 / 3) * (ctrlY - curY),
172
- x2: endX + (2 / 3) * (ctrlX - endX),
173
- y2: endY + (2 / 3) * (ctrlY - endY),
174
- x: endX,
175
- y: endY,
176
- });
176
+ out.push(quadToCubic(curX, curY, mapX(cmd.cx, cmd.cy), mapY(cmd.cx, cmd.cy), endX, endY));
177
177
  curX = endX;
178
178
  curY = endY;
179
179
  }
@@ -224,7 +224,7 @@ export class TextRenderer {
224
224
  // Lay the content out into absolutely-positioned text runs. Glyph positions match
225
225
  // the previous hand-written operators exactly (verified by pixel-identical render);
226
226
  // 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) {
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) {
228
228
  const runs = [];
229
229
  // /Link annotation rects for any `href` spans, collected as we place segments. Empty for the
230
230
  // common (no-href) case, so plain text keeps producing exactly the runs it did before.
@@ -353,7 +353,9 @@ export class TextRenderer {
353
353
  * text, no glyph list, byte-identical output.
354
354
  */
355
355
  const shapedPiece = (part, family, style) => {
356
- const shaped = objectManager.shapeText(part.logical, 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 : []);
357
359
  if (!shaped)
358
360
  return { text: part.text };
359
361
  const ordered = part.rtl ? [...shaped].reverse() : shaped;
@@ -361,7 +363,7 @@ export class TextRenderer {
361
363
  };
362
364
  // The advance the viewer will use: plain glyph widths (a `Tj` never kerns) plus one
363
365
  // `letterSpacing` per code point (the `Tc` operator), from the one shared `advance.ts` primitive.
364
- const font = { fontFamily, fontSize, fontStyle };
366
+ const font = { fontFamily, fontSize, fontStyle, ligatures };
365
367
  // --- Plain string: one run per wrapped line. ---
366
368
  if (typeof content === "string") {
367
369
  const lines = wrapStringIntoLines(content, fontFamily, fontSize, fontStyle, maxWidth, objectManager, maxLines, overflow, letterSpacing,
@@ -371,6 +373,8 @@ export class TextRenderer {
371
373
  wordSpacing,
372
374
  indent: textIndent,
373
375
  shrink: align === HorizontalAlignment.justify ? MAX_SPACE_SHRINK : 0,
376
+ splitting: { breakWord, hyphenate },
377
+ ligatures,
374
378
  });
375
379
  // yPosition is the top of the text box (top-left). The line box seats its own baseline; lines
376
380
  // then stack by that box's height. Same numbers as calculateTextHeight, by construction.
@@ -457,7 +461,14 @@ export class TextRenderer {
457
461
  // The seam flips the whole thing to PDF space. `letterSpacing` MUST be in the defaults so a span
458
462
  // that does not override it wraps and aligns with the element's spacing - the same defaults the
459
463
  // measure path (calculateTextHeight) uses, or segmented spaced text mis-wraps (measured != drawn).
460
- const defaults = { fontFamily, fontSize, fontStyle, letterSpacing };
464
+ const defaults = {
465
+ fontFamily,
466
+ fontSize,
467
+ fontStyle,
468
+ letterSpacing,
469
+ splitting: { breakWord, hyphenate },
470
+ ligatures,
471
+ };
461
472
  let top = yPosition;
462
473
  // Materialised, because justification has to know which line is the LAST one of the paragraph.
463
474
  const segmentLines = [
@@ -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>;