@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
@@ -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
@@ -58,6 +69,12 @@ export declare class PdfBackend {
58
69
  * (kappa ≈ 0.5523). Returns m/l/c/h ops WITHOUT the paint operator.
59
70
  */
60
71
  private static roundedRectPath;
72
+ /**
73
+ * Clamp the four radii into the box. Each is capped at half the box, and two radii sharing an edge
74
+ * are scaled down together when they would overlap - the CSS rule, and what keeps the path from
75
+ * folding back on itself.
76
+ */
77
+ private static clampRadii;
61
78
  /**
62
79
  * Serialize a single display-list node to its content-stream operators.
63
80
  * `om` is used only by primitives that allocate PDF resources (images, fonts).
@@ -1,3 +1,4 @@
1
+ import { isRounded, } from "../ir/display-list.js";
1
2
  import { Color } from "../common/color.js";
2
3
  /**
3
4
  * PDF backend - turns display-list primitives into content-stream operators.
@@ -18,8 +19,13 @@ export class PdfBackend {
18
19
  static flipY(nodes, pageHeight) {
19
20
  return nodes.map((node) => {
20
21
  switch (node.type) {
21
- case "rect":
22
- return { ...node, y: pageHeight - node.y - node.height };
22
+ case "rect": {
23
+ // A gradient fill carries its own page-space anchors; flip them with the box (see "path").
24
+ const fill = node.fill === undefined || node.fill instanceof Color
25
+ ? node.fill
26
+ : { ...node.fill, y0: pageHeight - node.fill.y0, y1: pageHeight - node.fill.y1 };
27
+ return { ...node, y: pageHeight - node.y - node.height, fill };
28
+ }
23
29
  case "line":
24
30
  return {
25
31
  ...node,
@@ -40,6 +46,19 @@ export class PdfBackend {
40
46
  }
41
47
  return flipped;
42
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
+ };
43
62
  case "clip-push":
44
63
  // Flip the clip rect around its bottom edge, like a rect.
45
64
  return { ...node, y: pageHeight - node.y - node.height };
@@ -57,7 +76,7 @@ export class PdfBackend {
57
76
  });
58
77
  // A gradient fill carries its own page-space anchor points; flip their y too (radii and
59
78
  // x stay). A solid Color fill has no geometry.
60
- const fill = node.fill instanceof Color
79
+ const fill = node.fill === undefined || node.fill instanceof Color
61
80
  ? node.fill
62
81
  : { ...node.fill, y0: pageHeight - node.fill.y0, y1: pageHeight - node.fill.y1 };
63
82
  return { ...node, commands, fill };
@@ -134,6 +153,7 @@ export class PdfBackend {
134
153
  // catalog entries instead of content-stream operators.
135
154
  if (!struct ||
136
155
  node.type === "clip-push" ||
156
+ node.type === "clip-path-push" ||
137
157
  node.type === "clip-pop" ||
138
158
  node.type === "transform-push" ||
139
159
  node.type === "transform-pop" ||
@@ -158,25 +178,72 @@ export class PdfBackend {
158
178
  }
159
179
  /**
160
180
  * Builds the `[ ... ]` operand of a `TJ` from a run and its per-gap kern adjustments (em/1000, the
161
- * sign the font declares - negative tightens). A `TJ` number moves the pen LEFT, so it is the
162
- * NEGATED kern. Consecutive un-kerned glyphs stay in one encoded chunk, so `[(T) 40 (otal)]` rather
163
- * 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`.
164
186
  */
165
- static kernedArray(text, kerns, encode) {
166
- 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
+ }
167
195
  let out = "";
168
- let chunk = chars[0] ?? "";
196
+ let chunk = units.length > 0 ? [units[0]] : [];
169
197
  for (let i = 0; i < kerns.length; i++) {
170
198
  if (kerns[i] === 0) {
171
- chunk += chars[i + 1];
199
+ chunk.push(units[i + 1]);
172
200
  }
173
201
  else {
174
202
  out += encode(chunk) + ` ${-kerns[i]} `;
175
- chunk = chars[i + 1];
203
+ chunk = [units[i + 1]];
176
204
  }
177
205
  }
178
206
  return `[${out}${encode(chunk)}]`;
179
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
+ }
180
247
  /**
181
248
  * Returns the `/GSx gs` operator that selects a transparency state, or `""` when both
182
249
  * alphas are fully opaque. Opaque draws emit nothing here, so output stays
@@ -193,26 +260,45 @@ export class PdfBackend {
193
260
  * (kappa ≈ 0.5523). Returns m/l/c/h ops WITHOUT the paint operator.
194
261
  */
195
262
  static roundedRectPath(x, y, w, h, radius) {
196
- const r = Math.min(radius, w / 2, h / 2);
197
- const c = r * 0.5523; // control-point offset that approximates a quarter circle
263
+ // Per corner, in PDF coordinates (y up). With all four equal this emits exactly the operators the
264
+ // single-radius version did - the byte-identity guard for every document that never asks for one.
265
+ const r = PdfBackend.clampRadii(radius, w, h);
266
+ const k = 0.5523; // control-point offset that approximates a quarter circle
198
267
  const f = (n) => n.toFixed(3);
199
- const xr = x + r;
200
- const xwr = x + w - r;
201
268
  const xw = x + w;
202
- const yr = y + r;
203
- const yhr = y + h - r;
204
269
  const yh = y + h;
205
- return (`${f(xr)} ${f(y)} m\n` +
206
- `${f(xwr)} ${f(y)} l\n` +
207
- `${f(xwr + c)} ${f(y)} ${f(xw)} ${f(yr - c)} ${f(xw)} ${f(yr)} c\n` +
208
- `${f(xw)} ${f(yhr)} l\n` +
209
- `${f(xw)} ${f(yhr + c)} ${f(xwr + c)} ${f(yh)} ${f(xwr)} ${f(yh)} c\n` +
210
- `${f(xr)} ${f(yh)} l\n` +
211
- `${f(xr - c)} ${f(yh)} ${f(x)} ${f(yhr + c)} ${f(x)} ${f(yhr)} c\n` +
212
- `${f(x)} ${f(yr)} l\n` +
213
- `${f(x)} ${f(yr - c)} ${f(xr - c)} ${f(y)} ${f(xr)} ${f(y)} c\n` +
270
+ return (`${f(x + r.bl)} ${f(y)} m\n` +
271
+ `${f(xw - r.br)} ${f(y)} l\n` +
272
+ `${f(xw - r.br + r.br * k)} ${f(y)} ${f(xw)} ${f(y + r.br - r.br * k)} ${f(xw)} ${f(y + r.br)} c\n` +
273
+ `${f(xw)} ${f(yh - r.tr)} l\n` +
274
+ `${f(xw)} ${f(yh - r.tr + r.tr * k)} ${f(xw - r.tr + r.tr * k)} ${f(yh)} ${f(xw - r.tr)} ${f(yh)} c\n` +
275
+ `${f(x + r.tl)} ${f(yh)} l\n` +
276
+ `${f(x + r.tl - r.tl * k)} ${f(yh)} ${f(x)} ${f(yh - r.tl + r.tl * k)} ${f(x)} ${f(yh - r.tl)} c\n` +
277
+ `${f(x)} ${f(y + r.bl)} l\n` +
278
+ `${f(x)} ${f(y + r.bl - r.bl * k)} ${f(x + r.bl - r.bl * k)} ${f(y)} ${f(x + r.bl)} ${f(y)} c\n` +
214
279
  `h`);
215
280
  }
281
+ /**
282
+ * Clamp the four radii into the box. Each is capped at half the box, and two radii sharing an edge
283
+ * are scaled down together when they would overlap - the CSS rule, and what keeps the path from
284
+ * folding back on itself.
285
+ */
286
+ static clampRadii(radius, w, h) {
287
+ const n = typeof radius === "number" ? radius : 0;
288
+ const cap = Math.max(0, Math.min(w, h) / 2);
289
+ const at = (v) => Math.max(0, Math.min(v ?? n, cap));
290
+ const r = typeof radius === "number"
291
+ ? { tl: at(radius), tr: at(radius), br: at(radius), bl: at(radius) }
292
+ : { tl: at(radius.tl), tr: at(radius.tr), br: at(radius.br), bl: at(radius.bl) };
293
+ // Per-edge overlap: scale the whole set by the tightest edge, so the corners stay proportional.
294
+ // An edge whose two corners are both 0 constrains NOTHING - it must not contribute a ratio of 0,
295
+ // which would scale every other corner away with it.
296
+ const ratio = (extent, a, b) => a + b > 0 ? extent / (a + b) : Infinity;
297
+ const scale = Math.min(1, ratio(w, r.tl, r.tr), ratio(w, r.bl, r.br), ratio(h, r.tl, r.bl), ratio(h, r.tr, r.br));
298
+ if (scale >= 1)
299
+ return r;
300
+ return { tl: r.tl * scale, tr: r.tr * scale, br: r.br * scale, bl: r.bl * scale };
301
+ }
216
302
  /**
217
303
  * Serialize a single display-list node to its content-stream operators.
218
304
  * `om` is used only by primitives that allocate PDF resources (images, fonts).
@@ -237,22 +323,38 @@ export class PdfBackend {
237
323
  const doStroke = !!node.stroke && (node.strokeWidth ?? 0) > 0;
238
324
  if (!node.fill && !doStroke)
239
325
  return "";
240
- let ops = "";
241
- if (doStroke) {
242
- ops += `${node.strokeWidth} w\n${node.stroke.toPDFColorString()} RG\n`;
243
- }
244
- if (node.fill)
245
- ops += `${node.fill.toPDFColorString()} rg\n`;
246
- const paint = node.fill ? (doStroke ? "B" : "f") : "S";
247
326
  // Rounded corners emit a Bézier path; sharp corners keep the plain `re`
248
327
  // (byte-identical when no radius is set).
249
- const path = (node.radius ?? 0) > 0
328
+ const path = isRounded(node.radius)
250
329
  ? PdfBackend.roundedRectPath(node.x, node.y, node.width, node.height, node.radius)
251
330
  : `${node.x} ${node.y} ${node.width} ${node.height} re`;
331
+ // A gradient cannot be a fill COLOUR - PDF paints one with `sh`, which floods the current
332
+ // clip. So the box becomes the clip, the shading is painted inside it, and a border is
333
+ // stroked afterwards on its own. Solid fills keep the plain operators, byte for byte.
334
+ const gradient = node.fill !== undefined && !(node.fill instanceof Color) ? node.fill : undefined;
335
+ if (gradient) {
336
+ const shading = om.registerShading(gradient);
337
+ const gsFill = PdfBackend.alphaPrefix(om, gradient.alpha ?? 1, 1);
338
+ let out = `q\n${gsFill}${path} W n\n/${shading} sh\nQ\n`;
339
+ if (doStroke) {
340
+ const gsStroke = PdfBackend.alphaPrefix(om, 1, node.stroke.getAlpha());
341
+ const stroke = `${node.strokeWidth} w\n${node.stroke.toPDFColorString()} RG\n` + `${path} S\n`;
342
+ out += gsStroke ? `q\n${gsStroke}${stroke}Q\n` : stroke;
343
+ }
344
+ return out;
345
+ }
346
+ const fill = node.fill;
347
+ let ops = "";
348
+ if (doStroke) {
349
+ ops += `${node.strokeWidth} w\n${node.stroke.toPDFColorString()} RG\n`;
350
+ }
351
+ if (fill)
352
+ ops += `${fill.toPDFColorString()} rg\n`;
353
+ const paint = fill ? (doStroke ? "B" : "f") : "S";
252
354
  const body = ops + `${path} ${paint}\n`;
253
355
  // Transparency needs an isolating q/Q so the state does not leak; opaque rects
254
356
  // keep their bare operators (byte-identical).
255
- const gs = PdfBackend.alphaPrefix(om, node.fill?.getAlpha() ?? 1, doStroke ? node.stroke.getAlpha() : 1);
357
+ const gs = PdfBackend.alphaPrefix(om, fill?.getAlpha() ?? 1, doStroke ? node.stroke.getAlpha() : 1);
256
358
  return gs ? `q\n${gs}${body}Q\n` : body;
257
359
  }
258
360
  case "text": {
@@ -268,17 +370,35 @@ export class PdfBackend {
268
370
  ? om.getCustomFontResource(node.fontFamily, node.fontStyle)
269
371
  : om.registerFont(node.fontFamily, node.fontStyle);
270
372
  const encode = (t) => isCustom
271
- ? `<${om.encodeCustomText(node.fontFamily, t, node.fontStyle)}>`
373
+ ? `<${om.encodeCustomText(node.fontFamily, t, node.fontStyle, false)}>`
272
374
  : `(${PdfBackend.escapePdfString(t)})`;
375
+ // A shaped run brings its own glyphs, already in drawing order - they cannot be derived from
376
+ // the text here.
377
+ const shapedHex = node.glyphs && isCustom
378
+ ? om.registerGlyphs(node.fontFamily, node.glyphs, node.fontStyle)
379
+ : undefined;
273
380
  // Kerning (if the document has it on): a `TJ` array with the font's per-pair adjustments
274
381
  // between glyph chunks, measured into the layout by the SAME numbers (runAdvance). A plain
275
382
  // `Tj` when the run has no kerning, byte-identical.
276
- const kerns = om.kerningEnabled
277
- ? om.getKernPairs(node.text, node.fontFamily, node.fontStyle)
278
- : [];
279
- const showOp = kerns.some((k) => k !== 0)
280
- ? `${PdfBackend.kernedArray(node.text, kerns, encode)} TJ`
281
- : `${encode(node.text)} 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`
399
+ : kerns.some((k) => k !== 0)
400
+ ? `${PdfBackend.kernedArray([...node.text], kerns, (chunk) => encode(chunk.join("")))} TJ`
401
+ : `${encode(node.text)} Tj`;
282
402
  // letterSpacing is the `Tc` operator: extra advance after EVERY glyph (an embedded
283
403
  // Identity-H font too - that is `Tw`, word spacing, which only touches single-byte code 32).
284
404
  const spacing = node.letterSpacing ? `${node.letterSpacing.toFixed(3)} Tc\n` : "";
@@ -303,7 +423,7 @@ export class PdfBackend {
303
423
  // Clip to the frame (re … W n); rounded when a radius is set. The rectangular
304
424
  // path is byte-identical to before.
305
425
  const c = node.clip;
306
- const clipPath = (node.radius ?? 0) > 0
426
+ const clipPath = isRounded(node.radius)
307
427
  ? PdfBackend.roundedRectPath(c.x, c.y, c.width, c.height, node.radius)
308
428
  : `${c.x} ${c.y} ${c.width} ${c.height} re `;
309
429
  return `q\n${clipPath}\nW n \n` + draw + `Q\n`;
@@ -311,38 +431,55 @@ export class PdfBackend {
311
431
  case "clip-push": {
312
432
  // Save the graphics state and intersect the clip with this (rounded) rect. Everything
313
433
  // drawn until the matching clip-pop is cropped to it.
314
- const path = (node.radius ?? 0) > 0
434
+ const path = isRounded(node.radius)
315
435
  ? PdfBackend.roundedRectPath(node.x, node.y, node.width, node.height, node.radius)
316
436
  : `${node.x} ${node.y} ${node.width} ${node.height} re`;
317
437
  return `q\n${path}\nW n\n`;
318
438
  }
319
439
  case "path": {
320
- // A filled vector path (one color-glyph layer). Emit the subpath ops, then either a solid
321
- // nonzero fill (a Color) or, for a gradient, clip to the path and paint a shading inside it.
322
- const f = (n) => n.toFixed(3);
323
- let path = "";
324
- for (const c of node.commands) {
325
- if (c.op === "m")
326
- path += `${f(c.x)} ${f(c.y)} m\n`;
327
- else if (c.op === "l")
328
- path += `${f(c.x)} ${f(c.y)} l\n`;
329
- else if (c.op === "c")
330
- path += `${f(c.x1)} ${f(c.y1)} ${f(c.x2)} ${f(c.y2)} ${f(c.x)} ${f(c.y)} c\n`;
331
- else
332
- path += `h\n`;
333
- }
334
- 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)) {
335
447
  // Gradient: clip to the path (W n keeps it as the clip without painting it), then paint
336
448
  // the registered shading across that clip. The q/Q isolates the clip. Note: per-stop alpha
337
449
  // is not represented (a DeviceRGB shading has no alpha channel - that would need a soft-mask
338
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.
339
453
  const shading = om.registerShading(node.fill);
340
- 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`;
341
473
  }
342
- const body = `${node.fill.toPDFColorString()} rg\n${path}f\n`;
474
+ const body = `${node.fill.toPDFColorString()} rg\n${path}f${star}\n`;
343
475
  const gs = PdfBackend.alphaPrefix(om, node.fill.getAlpha(), 1);
344
476
  return gs ? `q\n${gs}${body}Q\n` : body;
345
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
+ }
346
483
  case "clip-pop":
347
484
  return `Q\n`;
348
485
  case "transform-push": {
@@ -14,8 +14,13 @@ export class PDFDocumentRenderer {
14
14
  // pure (`{ fitted, remainder }`), so the full list of physical pages can be settled up front. Only
15
15
  // then is the total page count known, which is what page numbers in a header/footer need.
16
16
  const physicalPages = [];
17
+ // Where each sheet sits WITHIN the logical page it came from. Pass A already knows: one logical
18
+ // `Page` element produces one run of sheets, so the run's length is that section's total.
19
+ const section = [];
17
20
  for (const page of document.getProps().children) {
18
- physicalPages.push(...PDFDocumentRenderer.paginateLogicalPage(page, ctx));
21
+ const sheets = PDFDocumentRenderer.paginateLogicalPage(page, ctx);
22
+ sheets.forEach((_, i) => section.push({ number: i + 1, total: sheets.length }));
23
+ physicalPages.push(...sheets);
19
24
  }
20
25
  // Pass B - draw them, in order, so the emitted page objects keep their previous numbering. Each page
21
26
  // is laid out again with its own PageInfo, which is how a `PageBuilder` anywhere in the tree (header,
@@ -25,6 +30,8 @@ export class PDFDocumentRenderer {
25
30
  const pageInfo = {
26
31
  pageNumber: index + 1,
27
32
  pageCount: physicalPages.length,
33
+ subPageNumber: section[index].number,
34
+ subPageTotalPages: section[index].total,
28
35
  pageSize: resolvePageSize(PDFDocumentRenderer.configOf(physical, ctx)),
29
36
  };
30
37
  pageNumbers.push(await PDFDocumentRenderer.renderPhysicalPage(physical, objectManager, {
@@ -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
  }
@@ -1,14 +1,20 @@
1
1
  import { RendererRegistry } from "../utils/renderer-registry.js";
2
+ import { isGradientInput, resolveGradient } from "../api/gradient.js";
2
3
  export class RectangleRenderer {
3
4
  static async render(rectangleElement, objectManager) {
4
5
  const { x, y, width, height, children, color, backgroundColor, borderWidth, radius, sideBorders, overflow, } = rectangleElement.getProps();
5
6
  const h = height;
6
7
  const nodes = [];
8
+ // A gradient is written box-relative (an angle and stops); this is the first place the box is
9
+ // known, so it is resolved into the absolute page coordinates the IR wants.
10
+ const fill = backgroundColor !== undefined && isGradientInput(backgroundColor)
11
+ ? resolveGradient(backgroundColor, x, y, width, h)
12
+ : backgroundColor;
7
13
  if (sideBorders) {
8
14
  // Per-side borders: a fill-only box (no stroke), then a line per present side
9
15
  // (sharp corners). This is what lets cells draw grid lines.
10
16
  if (backgroundColor) {
11
- nodes.push({ type: "rect", x, y, width, height: h, strokeWidth: 0, fill: backgroundColor });
17
+ nodes.push({ type: "rect", x, y, width, height: h, strokeWidth: 0, fill });
12
18
  }
13
19
  nodes.push(...RectangleRenderer.sideLines(x, y, width, h, borderWidth, sideBorders));
14
20
  }
@@ -23,7 +29,7 @@ export class RectangleRenderer {
23
29
  height: h,
24
30
  stroke: color,
25
31
  strokeWidth: borderWidth,
26
- ...(backgroundColor ? { fill: backgroundColor } : {}),
32
+ ...(fill ? { fill } : {}),
27
33
  ...(radius ? { radius } : {}),
28
34
  };
29
35
  nodes.push(node);
@@ -38,9 +44,7 @@ export class RectangleRenderer {
38
44
  if (children)
39
45
  for (const child of children) {
40
46
  const renderer = RendererRegistry.getRenderer(child);
41
- if (renderer) {
42
- nodes.push(...(await renderer(child, objectManager)));
43
- }
47
+ nodes.push(...(await renderer(child, objectManager)));
44
48
  }
45
49
  if (clip)
46
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
+ }
@@ -2,9 +2,9 @@ import { TextElement, TextSegment } from "../elements/text-element.js";
2
2
  import { FontStyle, PDFObjectManager } from "../utils/pdf-object-manager.js";
3
3
  import type { FontMetrics } from "../utils/font-metrics.js";
4
4
  import { IRNode } from "../ir/display-list.js";
5
- import { TextOverflow } from "../text/line-breaker.js";
5
+ import { type LineOptions, TextOverflow } from "../text/line-breaker.js";
6
6
  export declare class TextRenderer {
7
- static calculateTextHeight(content: string | TextSegment[], fontSize: number, fontFamily: string, fontStyle: FontStyle, objectManager: FontMetrics, maxWidth: number, maxLines?: number, overflow?: TextOverflow, lineHeight?: number, letterSpacing?: number): number;
7
+ static calculateTextHeight(content: string | TextSegment[], fontSize: number, fontFamily: string, fontStyle: FontStyle, objectManager: FontMetrics, maxWidth: number, maxLines?: number, overflow?: TextOverflow, lineHeight?: number, letterSpacing?: number, lineOptions?: LineOptions): number;
8
8
  static render(textElement: TextElement, objectManager: PDFObjectManager): Promise<IRNode[]>;
9
9
  private static _expandColorGlyphs;
10
10
  private static _glyphToPath;