@jasy/pdf 1.0.0-alpha.7 → 1.0.0-alpha.9

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 (140) hide show
  1. package/README.md +11 -10
  2. package/dist/api/content.d.ts +5 -4
  3. package/dist/api/content.js +13 -7
  4. package/dist/api/descriptor.js +23 -0
  5. package/dist/api/dimension.d.ts +56 -0
  6. package/dist/api/dimension.js +27 -0
  7. package/dist/api/font-url.d.ts +28 -0
  8. package/dist/api/font-url.js +134 -0
  9. package/dist/api/forms.d.ts +347 -0
  10. package/dist/api/forms.js +189 -0
  11. package/dist/api/gradient.d.ts +53 -0
  12. package/dist/api/gradient.js +97 -0
  13. package/dist/api/index.d.ts +3 -0
  14. package/dist/api/index.js +3 -0
  15. package/dist/api/insets.d.ts +19 -12
  16. package/dist/api/insets.js +18 -7
  17. package/dist/api/layout.d.ts +20 -6
  18. package/dist/api/layout.js +31 -6
  19. package/dist/api/page-builder.d.ts +8 -0
  20. package/dist/api/page-builder.js +12 -0
  21. package/dist/api/structure.d.ts +11 -0
  22. package/dist/api/structure.js +17 -4
  23. package/dist/api/text.d.ts +42 -6
  24. package/dist/api/text.js +29 -5
  25. package/dist/crypto/legacy-handler.d.ts +43 -0
  26. package/dist/crypto/legacy-handler.js +145 -0
  27. package/dist/crypto/security-handler.d.ts +16 -0
  28. package/dist/crypto/security-handler.js +26 -0
  29. package/dist/edit/acroform-reader.d.ts +70 -0
  30. package/dist/edit/acroform-reader.js +126 -0
  31. package/dist/edit/appearance.d.ts +47 -0
  32. package/dist/edit/appearance.js +171 -0
  33. package/dist/edit/document.d.ts +145 -0
  34. package/dist/edit/document.js +642 -0
  35. package/dist/edit/fill.d.ts +65 -0
  36. package/dist/edit/fill.js +424 -0
  37. package/dist/edit/flatten.d.ts +54 -0
  38. package/dist/edit/flatten.js +462 -0
  39. package/dist/edit/index.d.ts +20 -0
  40. package/dist/edit/index.js +18 -0
  41. package/dist/edit/lexer.d.ts +30 -0
  42. package/dist/edit/lexer.js +285 -0
  43. package/dist/edit/objects.d.ts +68 -0
  44. package/dist/edit/objects.js +83 -0
  45. package/dist/edit/writer.d.ts +60 -0
  46. package/dist/edit/writer.js +283 -0
  47. package/dist/elements/container-element.d.ts +21 -1
  48. package/dist/elements/container-element.js +25 -10
  49. package/dist/elements/forms/checkbox-element.d.ts +51 -0
  50. package/dist/elements/forms/checkbox-element.js +48 -0
  51. package/dist/elements/forms/choice-element.d.ts +53 -0
  52. package/dist/elements/forms/choice-element.js +59 -0
  53. package/dist/elements/forms/push-button-element.d.ts +56 -0
  54. package/dist/elements/forms/push-button-element.js +49 -0
  55. package/dist/elements/forms/radio-element.d.ts +52 -0
  56. package/dist/elements/forms/radio-element.js +49 -0
  57. package/dist/elements/forms/signature-element.d.ts +54 -0
  58. package/dist/elements/forms/signature-element.js +47 -0
  59. package/dist/elements/forms/text-field-element.d.ts +61 -0
  60. package/dist/elements/forms/text-field-element.js +54 -0
  61. package/dist/elements/image-element.d.ts +18 -4
  62. package/dist/elements/image-element.js +27 -12
  63. package/dist/elements/index.d.ts +6 -0
  64. package/dist/elements/index.js +6 -0
  65. package/dist/elements/layout/expanded-element.d.ts +3 -1
  66. package/dist/elements/layout/expanded-element.js +12 -3
  67. package/dist/elements/layout/padding-element.d.ts +4 -2
  68. package/dist/elements/layout/padding-element.js +4 -2
  69. package/dist/elements/layout/page-builder-element.js +2 -0
  70. package/dist/elements/pdf-element.d.ts +59 -1
  71. package/dist/elements/pdf-element.js +62 -1
  72. package/dist/elements/rectangle-element.d.ts +20 -6
  73. package/dist/elements/rectangle-element.js +25 -12
  74. package/dist/elements/row-element.d.ts +24 -1
  75. package/dist/elements/row-element.js +25 -10
  76. package/dist/elements/text-element.d.ts +60 -1
  77. package/dist/elements/text-element.js +132 -33
  78. package/dist/forms/acroform.d.ts +63 -0
  79. package/dist/forms/acroform.js +427 -0
  80. package/dist/forms/appearance.d.ts +58 -0
  81. package/dist/forms/appearance.js +204 -0
  82. package/dist/forms/field.d.ts +164 -0
  83. package/dist/forms/field.js +1 -0
  84. package/dist/forms/pdf.d.ts +19 -0
  85. package/dist/forms/pdf.js +28 -0
  86. package/dist/ir/display-list.d.ts +40 -7
  87. package/dist/ir/display-list.js +8 -1
  88. package/dist/layout/alignment.d.ts +10 -0
  89. package/dist/layout/alignment.js +6 -0
  90. package/dist/layout/box-constraints.d.ts +53 -0
  91. package/dist/layout/box-constraints.js +72 -0
  92. package/dist/layout/insets.d.ts +28 -0
  93. package/dist/layout/insets.js +20 -0
  94. package/dist/renderer/form-field-renderer.d.ts +20 -0
  95. package/dist/renderer/form-field-renderer.js +12 -0
  96. package/dist/renderer/image-renderer.js +2 -1
  97. package/dist/renderer/page-renderer.js +8 -2
  98. package/dist/renderer/pdf-backend.d.ts +6 -0
  99. package/dist/renderer/pdf-backend.js +86 -31
  100. package/dist/renderer/pdf-document-renderer.js +8 -1
  101. package/dist/renderer/pdf-renderer.js +19 -1
  102. package/dist/renderer/rectangle-renderer.js +8 -2
  103. package/dist/renderer/text-renderer.d.ts +2 -2
  104. package/dist/renderer/text-renderer.js +150 -42
  105. package/dist/text/advance.js +5 -2
  106. package/dist/text/arabic.d.ts +20 -0
  107. package/dist/text/arabic.js +101 -0
  108. package/dist/text/bidi.d.ts +33 -0
  109. package/dist/text/bidi.js +94 -0
  110. package/dist/text/font-fallback.d.ts +25 -0
  111. package/dist/text/font-fallback.js +38 -0
  112. package/dist/text/line-breaker.d.ts +35 -1
  113. package/dist/text/line-breaker.js +76 -16
  114. package/dist/text/orphans-widows.d.ts +27 -0
  115. package/dist/text/orphans-widows.js +53 -0
  116. package/dist/text/shape.d.ts +29 -0
  117. package/dist/text/shape.js +67 -0
  118. package/dist/text/text-style.d.ts +25 -0
  119. package/dist/text/text-style.js +44 -1
  120. package/dist/utils/dest-registry.d.ts +5 -2
  121. package/dist/utils/dest-registry.js +5 -5
  122. package/dist/utils/flex-layout.d.ts +24 -4
  123. package/dist/utils/flex-layout.js +169 -7
  124. package/dist/utils/font-metrics.d.ts +5 -0
  125. package/dist/utils/gsub.d.ts +36 -0
  126. package/dist/utils/gsub.js +178 -0
  127. package/dist/utils/inflate.d.ts +16 -0
  128. package/dist/utils/inflate.js +52 -0
  129. package/dist/utils/outline.js +1 -2
  130. package/dist/utils/pdf-object-manager.d.ts +39 -0
  131. package/dist/utils/pdf-object-manager.js +166 -15
  132. package/dist/utils/struct-tree.js +2 -3
  133. package/dist/utils/ttf-parser.d.ts +6 -0
  134. package/dist/utils/ttf-parser.js +19 -0
  135. package/dist/utils/utf8-to-windows1252-encoder.d.ts +6 -0
  136. package/dist/utils/utf8-to-windows1252-encoder.js +10 -0
  137. package/dist/utils/woff.d.ts +10 -0
  138. package/dist/utils/woff.js +116 -0
  139. package/dist/validators/element-validator.js +14 -3
  140. package/package.json +10 -2
@@ -62,6 +62,16 @@ export class BoxConstraints {
62
62
  deflate(horizontal, vertical) {
63
63
  return new BoxConstraints(Math.max(0, this.minWidth - horizontal), Math.max(0, this.maxWidth - horizontal), Math.max(0, this.minHeight - vertical), Math.max(0, this.maxHeight - vertical));
64
64
  }
65
+ /**
66
+ * Tightens this box by an element's own `min`/`max` on either axis. A max never widens the offered
67
+ * room, and a min may push past it - `minHeight: 500` in a 300pt region means 500, and pagination
68
+ * deals with the overflow, the same answer CSS gives.
69
+ */
70
+ narrow(minWidth, maxWidth, minHeight, maxHeight) {
71
+ const lo = (own, current) => Math.max(current, own ?? 0);
72
+ const hi = (own, current) => own === undefined ? current : Math.min(current, own);
73
+ return new BoxConstraints(lo(minWidth, this.minWidth), Math.max(hi(maxWidth, this.maxWidth), lo(minWidth, this.minWidth)), lo(minHeight, this.minHeight), Math.max(hi(maxHeight, this.maxHeight), lo(minHeight, this.minHeight)));
74
+ }
65
75
  /** Returns the constraints clamped to lie within `parent` (Flutter's enforce). */
66
76
  enforce(parent) {
67
77
  return new BoxConstraints(Math.max(parent.minWidth, Math.min(this.minWidth, parent.maxWidth)), Math.max(parent.minWidth, Math.min(this.maxWidth, parent.maxWidth)), Math.max(parent.minHeight, Math.min(this.minHeight, parent.maxHeight)), Math.max(parent.minHeight, Math.min(this.maxHeight, parent.maxHeight)));
@@ -81,3 +91,65 @@ export function resolveExtent(fixed, factor, boundedMax, hasBounded) {
81
91
  return boundedMax * factor;
82
92
  return undefined;
83
93
  }
94
+ /** Split `SizingParams` into the per-axis form `resolveSize` takes. */
95
+ export function extentSpecs(p) {
96
+ return {
97
+ width: {
98
+ fixed: p.width,
99
+ factor: p.widthFactor,
100
+ min: p.minWidth,
101
+ minFactor: p.minWidthFactor,
102
+ max: p.maxWidth,
103
+ maxFactor: p.maxWidthFactor,
104
+ },
105
+ height: {
106
+ fixed: p.height,
107
+ factor: p.heightFactor,
108
+ min: p.minHeight,
109
+ minFactor: p.minHeightFactor,
110
+ max: p.maxHeight,
111
+ maxFactor: p.maxHeightFactor,
112
+ },
113
+ };
114
+ }
115
+ /**
116
+ * Resolve both axes of a sized element: relative sizing, then `aspectRatio`, then `min`/`max`.
117
+ *
118
+ * The order follows CSS: a ratio fills in the axis you left open, and min/max clamp afterwards - so an
119
+ * explicit bound wins over the ratio, exactly as `min-height` beats `aspect-ratio` in a browser.
120
+ *
121
+ * `min`/`max` also come back as NARROWED constraints, because an axis with no explicit size still has
122
+ * to obey them. `maxWidth` alone means "fill, but no wider than this", and the caller's own fill and
123
+ * shrink-wrap paths get that for free by using the returned constraints instead of the ones passed in.
124
+ * A `width` of `undefined` keeps its usual meaning: fill or shrink-wrap.
125
+ */
126
+ export function resolveSize(width, height, aspectRatio, constraints) {
127
+ const bounds = constraints.narrow(resolveExtent(width.min, width.minFactor, constraints.maxWidth, constraints.hasBoundedWidth), resolveExtent(width.max, width.maxFactor, constraints.maxWidth, constraints.hasBoundedWidth), resolveExtent(height.min, height.minFactor, constraints.maxHeight, constraints.hasBoundedHeight), resolveExtent(height.max, height.maxFactor, constraints.maxHeight, constraints.hasBoundedHeight));
128
+ // Against the OFFERED box, not the narrowed one: `width: "50%"` with `maxWidth: 100` in a 400pt
129
+ // region is 200 capped to 100, not 50% of 100. The clamp below applies the bound.
130
+ let w = resolveExtent(width.fixed, width.factor, constraints.maxWidth, constraints.hasBoundedWidth);
131
+ let h = resolveExtent(height.fixed, height.factor, constraints.maxHeight, constraints.hasBoundedHeight);
132
+ if (aspectRatio !== undefined && aspectRatio > 0) {
133
+ if (w !== undefined && h === undefined)
134
+ h = w / aspectRatio;
135
+ else if (h !== undefined && w === undefined)
136
+ w = h * aspectRatio;
137
+ else if (w === undefined && h === undefined) {
138
+ // Neither axis pinned: take the offered width and let the ratio give the height (CSS block
139
+ // behaviour). Falls back to the height when only that axis is bounded.
140
+ if (bounds.hasBoundedWidth) {
141
+ w = bounds.maxWidth;
142
+ h = w / aspectRatio;
143
+ }
144
+ else if (bounds.hasBoundedHeight) {
145
+ h = bounds.maxHeight;
146
+ w = h * aspectRatio;
147
+ }
148
+ }
149
+ }
150
+ return {
151
+ width: w === undefined ? undefined : bounds.constrainWidth(w),
152
+ height: h === undefined ? undefined : bounds.constrainHeight(h),
153
+ constraints: bounds,
154
+ };
155
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Resolved and unresolved edge insets, on the LAYOUT side of the seam.
3
+ *
4
+ * The public `Insets` input (points, percentages, four shapes) lives in `api/insets.ts` and normalizes
5
+ * into the specs below - the same split `dimension.ts` and `box-constraints.ts` already use for sizes,
6
+ * so an element never sees a percentage string and the api layer never sees layout geometry.
7
+ */
8
+ /** One side: an absolute point size, or a fraction of the available width. */
9
+ export interface EdgeSpec {
10
+ points?: number;
11
+ factor?: number;
12
+ }
13
+ /**
14
+ * `[top, right, bottom, left]`, each still unresolved.
15
+ *
16
+ * A plain number is accepted as shorthand for `{ points: n }`: the engine elements are exported for
17
+ * power users and used to take four numbers, so requiring `{ points: 5 }` would break them for a
18
+ * feature they are not using.
19
+ */
20
+ export type EdgeSpecs = [EdgeInput, EdgeInput, EdgeInput, EdgeInput];
21
+ /** A side as given: points for short, or a spec when it may be a percentage. */
22
+ export type EdgeInput = number | EdgeSpec;
23
+ /**
24
+ * Turn the specs into points. `available` is the WIDTH the element was offered - a percentage resolves
25
+ * against the width on ALL FOUR sides, top and bottom included, which is the CSS rule (and Yoga's, so
26
+ * react-pdf agrees). An unbounded width leaves a percentage at 0, the no-op a percentage size gives.
27
+ */
28
+ export declare function resolveEdges(specs: EdgeSpecs, available: number): [number, number, number, number];
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Resolved and unresolved edge insets, on the LAYOUT side of the seam.
3
+ *
4
+ * The public `Insets` input (points, percentages, four shapes) lives in `api/insets.ts` and normalizes
5
+ * into the specs below - the same split `dimension.ts` and `box-constraints.ts` already use for sizes,
6
+ * so an element never sees a percentage string and the api layer never sees layout geometry.
7
+ */
8
+ /**
9
+ * Turn the specs into points. `available` is the WIDTH the element was offered - a percentage resolves
10
+ * against the width on ALL FOUR sides, top and bottom included, which is the CSS rule (and Yoga's, so
11
+ * react-pdf agrees). An unbounded width leaves a percentage at 0, the no-op a percentage size gives.
12
+ */
13
+ export function resolveEdges(specs, available) {
14
+ const one = (e) => {
15
+ if (typeof e === "number")
16
+ return e;
17
+ return (e.points ?? (e.factor !== undefined && Number.isFinite(available) ? available * e.factor : 0));
18
+ };
19
+ return [one(specs[0]), one(specs[1]), one(specs[2]), one(specs[3])];
20
+ }
@@ -0,0 +1,20 @@
1
+ import { PDFObjectManager } from "../utils/pdf-object-manager.js";
2
+ import { TextFieldElement } from "../elements/forms/text-field-element.js";
3
+ import { CheckboxElement } from "../elements/forms/checkbox-element.js";
4
+ import { RadioElement } from "../elements/forms/radio-element.js";
5
+ import { ChoiceElement } from "../elements/forms/choice-element.js";
6
+ import { PushButtonElement } from "../elements/forms/push-button-element.js";
7
+ import { SignatureFieldElement } from "../elements/forms/signature-element.js";
8
+ import { IRNode } from "../ir/display-list.js";
9
+ /** Any form-field element exposes its box + shared spec + style through `getProps()`. */
10
+ type FormFieldElement = TextFieldElement | CheckboxElement | RadioElement | ChoiceElement | PushButtonElement | SignatureFieldElement;
11
+ /**
12
+ * Renders a form-field element to a single `formfield` IR node - the field's box + its shared
13
+ * `FormFieldSpec` + style. It draws nothing; the PageRenderer peels it into a Widget /Annot and the
14
+ * /AcroForm (via forms/acroform.ts). One renderer serves every field element (the spec's `kind`
15
+ * discriminates downstream).
16
+ */
17
+ export declare class FormFieldRenderer {
18
+ static render(element: FormFieldElement, _objectManager: PDFObjectManager): Promise<IRNode[]>;
19
+ }
20
+ export {};
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Renders a form-field element to a single `formfield` IR node - the field's box + its shared
3
+ * `FormFieldSpec` + style. It draws nothing; the PageRenderer peels it into a Widget /Annot and the
4
+ * /AcroForm (via forms/acroform.ts). One renderer serves every field element (the spec's `kind`
5
+ * discriminates downstream).
6
+ */
7
+ export class FormFieldRenderer {
8
+ static async render(element, _objectManager) {
9
+ const { x, y, width, height, spec, style } = element.getProps();
10
+ return [{ type: "formfield", x, y, width, height, field: spec, style }];
11
+ }
12
+ }
@@ -1,6 +1,7 @@
1
1
  import { BoxFit } from "../elements/image-element.js";
2
2
  import { bytesFromLatin1 } from "../utils/bytes.js";
3
3
  import { applyContainFit, applyCoverFit, applyFillFit, applyFitNone, decodePngToRgbFlate, } from "../utils/image-helper.js";
4
+ import { isRounded } from "../ir/display-list.js";
4
5
  export class ImageRenderer {
5
6
  static async render(imageElement, objectManager) {
6
7
  // Load the image and convert it in a binary string
@@ -56,7 +57,7 @@ export class ImageRenderer {
56
57
  }
57
58
  // A radius rounds the image BOX (the element frame), so it clips to that frame too -
58
59
  // independent of the cover/contain overflow clip.
59
- const wantsClip = mustCreateOverflowContainer || (radius ?? 0) > 0;
60
+ const wantsClip = mustCreateOverflowContainer || isRounded(radius);
60
61
  // The fitted geometry becomes a display-list primitive; the backend registers
61
62
  // the XObject and emits the placement (+ clip, rounded when a radius is set).
62
63
  const node = {
@@ -74,14 +74,20 @@ export class PageRenderer {
74
74
  const num = (n) => Number(n.toFixed(2));
75
75
  const annotRefs = [];
76
76
  for (const node of flipped) {
77
+ // A form field: build its Widget annotation + register it as an /AcroForm field (the logic lives in
78
+ // forms/acroform.ts), then reference it here in the page /Annots - the same side channel as a link.
79
+ if (node.type === "formfield") {
80
+ annotRefs.push(`${objectManager.acroform.addField(node, objectManager)} 0 R`);
81
+ continue;
82
+ }
77
83
  if (node.type !== "link")
78
84
  continue;
79
85
  const rect = `[${num(node.x)} ${num(node.y)} ${num(node.x + node.width)} ${num(node.y + node.height)}]`;
80
86
  // Either an external URL (/URI) or an internal named destination (/GoTo, resolved via /Names /Dests).
81
87
  // The Link IR guarantees exactly one is set; prefer `dest` when present.
82
88
  const action = node.dest !== undefined
83
- ? `/A << /S /GoTo /D (${PdfBackend.escapePdfString(node.dest)}) >>`
84
- : `/A << /S /URI /URI (${PdfBackend.escapePdfString(node.href ?? "")}) >>`;
89
+ ? `/A << /S /GoTo /D ${objectManager.pdfString(node.dest)} >>`
90
+ : `/A << /S /URI /URI ${objectManager.pdfString(node.href ?? "")} >>`;
85
91
  const annot = objectManager.addObject(`<< /Type /Annot /Subtype /Link /Rect ${rect} /Border [0 0 0] ${action} >>`);
86
92
  annotRefs.push(`${annot} 0 R`);
87
93
  }
@@ -58,6 +58,12 @@ export declare class PdfBackend {
58
58
  * (kappa ≈ 0.5523). Returns m/l/c/h ops WITHOUT the paint operator.
59
59
  */
60
60
  private static roundedRectPath;
61
+ /**
62
+ * Clamp the four radii into the box. Each is capped at half the box, and two radii sharing an edge
63
+ * are scaled down together when they would overlap - the CSS rule, and what keeps the path from
64
+ * folding back on itself.
65
+ */
66
+ private static clampRadii;
61
67
  /**
62
68
  * Serialize a single display-list node to its content-stream operators.
63
69
  * `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,
@@ -65,6 +71,9 @@ export class PdfBackend {
65
71
  case "link":
66
72
  // A link's clickable rect flips around its bottom edge, exactly like a rect.
67
73
  return { ...node, y: pageHeight - node.y - node.height };
74
+ case "formfield":
75
+ // A form field's box flips around its bottom edge, exactly like a link rect.
76
+ return { ...node, y: pageHeight - node.y - node.height };
68
77
  case "outline":
69
78
  // An outline anchor is a single point (the target's top); flip it like a text baseline.
70
79
  return { ...node, y: pageHeight - node.y };
@@ -137,6 +146,7 @@ export class PdfBackend {
137
146
  node.type === "link" ||
138
147
  node.type === "outline" ||
139
148
  node.type === "anchor" ||
149
+ node.type === "formfield" ||
140
150
  ops === "")
141
151
  return ops;
142
152
  const { open, close } = struct.mark(node.tag);
@@ -189,26 +199,45 @@ export class PdfBackend {
189
199
  * (kappa ≈ 0.5523). Returns m/l/c/h ops WITHOUT the paint operator.
190
200
  */
191
201
  static roundedRectPath(x, y, w, h, radius) {
192
- const r = Math.min(radius, w / 2, h / 2);
193
- const c = r * 0.5523; // control-point offset that approximates a quarter circle
202
+ // Per corner, in PDF coordinates (y up). With all four equal this emits exactly the operators the
203
+ // single-radius version did - the byte-identity guard for every document that never asks for one.
204
+ const r = PdfBackend.clampRadii(radius, w, h);
205
+ const k = 0.5523; // control-point offset that approximates a quarter circle
194
206
  const f = (n) => n.toFixed(3);
195
- const xr = x + r;
196
- const xwr = x + w - r;
197
207
  const xw = x + w;
198
- const yr = y + r;
199
- const yhr = y + h - r;
200
208
  const yh = y + h;
201
- return (`${f(xr)} ${f(y)} m\n` +
202
- `${f(xwr)} ${f(y)} l\n` +
203
- `${f(xwr + c)} ${f(y)} ${f(xw)} ${f(yr - c)} ${f(xw)} ${f(yr)} c\n` +
204
- `${f(xw)} ${f(yhr)} l\n` +
205
- `${f(xw)} ${f(yhr + c)} ${f(xwr + c)} ${f(yh)} ${f(xwr)} ${f(yh)} c\n` +
206
- `${f(xr)} ${f(yh)} l\n` +
207
- `${f(xr - c)} ${f(yh)} ${f(x)} ${f(yhr + c)} ${f(x)} ${f(yhr)} c\n` +
208
- `${f(x)} ${f(yr)} l\n` +
209
- `${f(x)} ${f(yr - c)} ${f(xr - c)} ${f(y)} ${f(xr)} ${f(y)} c\n` +
209
+ return (`${f(x + r.bl)} ${f(y)} m\n` +
210
+ `${f(xw - r.br)} ${f(y)} l\n` +
211
+ `${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` +
212
+ `${f(xw)} ${f(yh - r.tr)} l\n` +
213
+ `${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` +
214
+ `${f(x + r.tl)} ${f(yh)} l\n` +
215
+ `${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` +
216
+ `${f(x)} ${f(y + r.bl)} l\n` +
217
+ `${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` +
210
218
  `h`);
211
219
  }
220
+ /**
221
+ * Clamp the four radii into the box. Each is capped at half the box, and two radii sharing an edge
222
+ * are scaled down together when they would overlap - the CSS rule, and what keeps the path from
223
+ * folding back on itself.
224
+ */
225
+ static clampRadii(radius, w, h) {
226
+ const n = typeof radius === "number" ? radius : 0;
227
+ const cap = Math.max(0, Math.min(w, h) / 2);
228
+ const at = (v) => Math.max(0, Math.min(v ?? n, cap));
229
+ const r = typeof radius === "number"
230
+ ? { tl: at(radius), tr: at(radius), br: at(radius), bl: at(radius) }
231
+ : { tl: at(radius.tl), tr: at(radius.tr), br: at(radius.br), bl: at(radius.bl) };
232
+ // Per-edge overlap: scale the whole set by the tightest edge, so the corners stay proportional.
233
+ // An edge whose two corners are both 0 constrains NOTHING - it must not contribute a ratio of 0,
234
+ // which would scale every other corner away with it.
235
+ const ratio = (extent, a, b) => a + b > 0 ? extent / (a + b) : Infinity;
236
+ 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));
237
+ if (scale >= 1)
238
+ return r;
239
+ return { tl: r.tl * scale, tr: r.tr * scale, br: r.br * scale, bl: r.bl * scale };
240
+ }
212
241
  /**
213
242
  * Serialize a single display-list node to its content-stream operators.
214
243
  * `om` is used only by primitives that allocate PDF resources (images, fonts).
@@ -233,22 +262,37 @@ export class PdfBackend {
233
262
  const doStroke = !!node.stroke && (node.strokeWidth ?? 0) > 0;
234
263
  if (!node.fill && !doStroke)
235
264
  return "";
236
- let ops = "";
237
- if (doStroke) {
238
- ops += `${node.strokeWidth} w\n${node.stroke.toPDFColorString()} RG\n`;
239
- }
240
- if (node.fill)
241
- ops += `${node.fill.toPDFColorString()} rg\n`;
242
- const paint = node.fill ? (doStroke ? "B" : "f") : "S";
243
265
  // Rounded corners emit a Bézier path; sharp corners keep the plain `re`
244
266
  // (byte-identical when no radius is set).
245
- const path = (node.radius ?? 0) > 0
267
+ const path = isRounded(node.radius)
246
268
  ? PdfBackend.roundedRectPath(node.x, node.y, node.width, node.height, node.radius)
247
269
  : `${node.x} ${node.y} ${node.width} ${node.height} re`;
270
+ // A gradient cannot be a fill COLOUR - PDF paints one with `sh`, which floods the current
271
+ // clip. So the box becomes the clip, the shading is painted inside it, and a border is
272
+ // stroked afterwards on its own. Solid fills keep the plain operators, byte for byte.
273
+ const gradient = node.fill !== undefined && !(node.fill instanceof Color) ? node.fill : undefined;
274
+ if (gradient) {
275
+ const shading = om.registerShading(gradient);
276
+ let out = `q\n${path} W n\n/${shading} sh\nQ\n`;
277
+ if (doStroke) {
278
+ const gsStroke = PdfBackend.alphaPrefix(om, 1, node.stroke.getAlpha());
279
+ const stroke = `${node.strokeWidth} w\n${node.stroke.toPDFColorString()} RG\n` + `${path} S\n`;
280
+ out += gsStroke ? `q\n${gsStroke}${stroke}Q\n` : stroke;
281
+ }
282
+ return out;
283
+ }
284
+ const fill = node.fill;
285
+ let ops = "";
286
+ if (doStroke) {
287
+ ops += `${node.strokeWidth} w\n${node.stroke.toPDFColorString()} RG\n`;
288
+ }
289
+ if (fill)
290
+ ops += `${fill.toPDFColorString()} rg\n`;
291
+ const paint = fill ? (doStroke ? "B" : "f") : "S";
248
292
  const body = ops + `${path} ${paint}\n`;
249
293
  // Transparency needs an isolating q/Q so the state does not leak; opaque rects
250
294
  // keep their bare operators (byte-identical).
251
- const gs = PdfBackend.alphaPrefix(om, node.fill?.getAlpha() ?? 1, doStroke ? node.stroke.getAlpha() : 1);
295
+ const gs = PdfBackend.alphaPrefix(om, fill?.getAlpha() ?? 1, doStroke ? node.stroke.getAlpha() : 1);
252
296
  return gs ? `q\n${gs}${body}Q\n` : body;
253
297
  }
254
298
  case "text": {
@@ -266,15 +310,22 @@ export class PdfBackend {
266
310
  const encode = (t) => isCustom
267
311
  ? `<${om.encodeCustomText(node.fontFamily, t, node.fontStyle)}>`
268
312
  : `(${PdfBackend.escapePdfString(t)})`;
313
+ // A shaped run brings its own glyphs, already in drawing order - they cannot be derived from
314
+ // the text here.
315
+ const shapedHex = node.glyphs && isCustom
316
+ ? om.registerGlyphs(node.fontFamily, node.glyphs, node.fontStyle)
317
+ : undefined;
269
318
  // Kerning (if the document has it on): a `TJ` array with the font's per-pair adjustments
270
319
  // between glyph chunks, measured into the layout by the SAME numbers (runAdvance). A plain
271
320
  // `Tj` when the run has no kerning, byte-identical.
272
321
  const kerns = om.kerningEnabled
273
322
  ? om.getKernPairs(node.text, node.fontFamily, node.fontStyle)
274
323
  : [];
275
- const showOp = kerns.some((k) => k !== 0)
276
- ? `${PdfBackend.kernedArray(node.text, kerns, encode)} TJ`
277
- : `${encode(node.text)} Tj`;
324
+ const showOp = shapedHex
325
+ ? `<${shapedHex}> Tj`
326
+ : kerns.some((k) => k !== 0)
327
+ ? `${PdfBackend.kernedArray(node.text, kerns, encode)} TJ`
328
+ : `${encode(node.text)} Tj`;
278
329
  // letterSpacing is the `Tc` operator: extra advance after EVERY glyph (an embedded
279
330
  // Identity-H font too - that is `Tw`, word spacing, which only touches single-byte code 32).
280
331
  const spacing = node.letterSpacing ? `${node.letterSpacing.toFixed(3)} Tc\n` : "";
@@ -299,7 +350,7 @@ export class PdfBackend {
299
350
  // Clip to the frame (re … W n); rounded when a radius is set. The rectangular
300
351
  // path is byte-identical to before.
301
352
  const c = node.clip;
302
- const clipPath = (node.radius ?? 0) > 0
353
+ const clipPath = isRounded(node.radius)
303
354
  ? PdfBackend.roundedRectPath(c.x, c.y, c.width, c.height, node.radius)
304
355
  : `${c.x} ${c.y} ${c.width} ${c.height} re `;
305
356
  return `q\n${clipPath}\nW n \n` + draw + `Q\n`;
@@ -307,7 +358,7 @@ export class PdfBackend {
307
358
  case "clip-push": {
308
359
  // Save the graphics state and intersect the clip with this (rounded) rect. Everything
309
360
  // drawn until the matching clip-pop is cropped to it.
310
- const path = (node.radius ?? 0) > 0
361
+ const path = isRounded(node.radius)
311
362
  ? PdfBackend.roundedRectPath(node.x, node.y, node.width, node.height, node.radius)
312
363
  : `${node.x} ${node.y} ${node.width} ${node.height} re`;
313
364
  return `q\n${path}\nW n\n`;
@@ -357,6 +408,10 @@ export class PdfBackend {
357
408
  case "anchor":
358
409
  // A named destination draws nothing - it becomes a /Names /Dests entry (built in PageRenderer/PDFRenderer).
359
410
  return "";
411
+ case "formfield":
412
+ // A form field draws nothing in the content stream - it becomes a Widget /Annot + /AcroForm field
413
+ // (built in PageRenderer via forms/acroform.ts).
414
+ return "";
360
415
  default: {
361
416
  // Exhaustiveness guard: if a new IRNode variant is added, this fails to compile.
362
417
  const unknown = node;
@@ -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, {
@@ -31,6 +31,13 @@ import { AnchorRenderer } from "./anchor-renderer.js";
31
31
  import { PageBreakRenderer } from "./page-break-renderer.js";
32
32
  import { KeepTogetherElement } from "../elements/layout/keep-together-element.js";
33
33
  import { KeepTogetherRenderer } from "./keep-together-renderer.js";
34
+ import { TextFieldElement } from "../elements/forms/text-field-element.js";
35
+ import { CheckboxElement } from "../elements/forms/checkbox-element.js";
36
+ import { RadioElement } from "../elements/forms/radio-element.js";
37
+ import { ChoiceElement } from "../elements/forms/choice-element.js";
38
+ import { PushButtonElement } from "../elements/forms/push-button-element.js";
39
+ import { SignatureFieldElement } from "../elements/forms/signature-element.js";
40
+ import { FormFieldRenderer } from "./form-field-renderer.js";
34
41
  import { PageBuilderElement } from "../elements/layout/page-builder-element.js";
35
42
  import { PageBuilderRenderer } from "./page-builder-renderer.js";
36
43
  import { RotatedElement } from "../elements/layout/rotated-element.js";
@@ -51,6 +58,12 @@ export class PDFRenderer {
51
58
  RendererRegistry.register(PaddingElement, PaddingRenderer.render);
52
59
  RendererRegistry.register(PageBreakElement, PageBreakRenderer.render);
53
60
  RendererRegistry.register(KeepTogetherElement, KeepTogetherRenderer.render);
61
+ RendererRegistry.register(TextFieldElement, FormFieldRenderer.render);
62
+ RendererRegistry.register(CheckboxElement, FormFieldRenderer.render);
63
+ RendererRegistry.register(RadioElement, FormFieldRenderer.render);
64
+ RendererRegistry.register(ChoiceElement, FormFieldRenderer.render);
65
+ RendererRegistry.register(PushButtonElement, FormFieldRenderer.render);
66
+ RendererRegistry.register(SignatureFieldElement, FormFieldRenderer.render);
54
67
  RendererRegistry.register(DefaultTextStyleElement, DefaultTextStyleRenderer.render);
55
68
  RendererRegistry.register(ImageElement, ImageRenderer.render);
56
69
  RendererRegistry.register(LineElement, LineRenderer.render);
@@ -124,8 +137,13 @@ export class PDFRenderer {
124
137
  const outlineCatalog = objectManager.outline.finalize(objectManager);
125
138
  if (outlineCatalog)
126
139
  catalogParts.push(outlineCatalog);
140
+ // Interactive form fields: emit the catalog /AcroForm dict collected as pages rendered. Returns ""
141
+ // (no-op) when no field was placed, so a plain document's catalog is unchanged.
142
+ const acroformCatalog = objectManager.acroform.finalize(objectManager);
143
+ if (acroformCatalog)
144
+ catalogParts.push(acroformCatalog);
127
145
  // Named destinations (internal-link targets): a /Dests entry in the shared /Names dict.
128
- const destsNames = objectManager.dests.finalize();
146
+ const destsNames = objectManager.dests.finalize(objectManager);
129
147
  if (destsNames)
130
148
  namesParts.push(destsNames);
131
149
  // Emit the single /Names dict if either embedded files or destinations contributed to it.
@@ -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);
@@ -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;