@jasy/pdf 1.0.0-alpha.6 → 1.0.0-alpha.8

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 (116) hide show
  1. package/README.md +7 -7
  2. package/dist/api/descriptor.js +26 -1
  3. package/dist/api/forms.d.ts +347 -0
  4. package/dist/api/forms.js +189 -0
  5. package/dist/api/index.d.ts +1 -0
  6. package/dist/api/index.js +1 -0
  7. package/dist/api/layout.d.ts +47 -14
  8. package/dist/api/layout.js +47 -11
  9. package/dist/api/structure.d.ts +11 -0
  10. package/dist/api/structure.js +2 -0
  11. package/dist/api/text.d.ts +18 -2
  12. package/dist/api/text.js +15 -0
  13. package/dist/crypto/legacy-handler.d.ts +43 -0
  14. package/dist/crypto/legacy-handler.js +145 -0
  15. package/dist/crypto/security-handler.d.ts +16 -0
  16. package/dist/crypto/security-handler.js +26 -0
  17. package/dist/edit/acroform-reader.d.ts +70 -0
  18. package/dist/edit/acroform-reader.js +126 -0
  19. package/dist/edit/appearance.d.ts +47 -0
  20. package/dist/edit/appearance.js +171 -0
  21. package/dist/edit/document.d.ts +135 -0
  22. package/dist/edit/document.js +649 -0
  23. package/dist/edit/fill.d.ts +48 -0
  24. package/dist/edit/fill.js +395 -0
  25. package/dist/edit/flatten.d.ts +42 -0
  26. package/dist/edit/flatten.js +437 -0
  27. package/dist/edit/index.d.ts +18 -0
  28. package/dist/edit/index.js +16 -0
  29. package/dist/edit/lexer.d.ts +30 -0
  30. package/dist/edit/lexer.js +285 -0
  31. package/dist/edit/objects.d.ts +63 -0
  32. package/dist/edit/objects.js +56 -0
  33. package/dist/edit/writer.d.ts +53 -0
  34. package/dist/edit/writer.js +261 -0
  35. package/dist/elements/container-element.d.ts +10 -1
  36. package/dist/elements/container-element.js +26 -8
  37. package/dist/elements/forms/checkbox-element.d.ts +51 -0
  38. package/dist/elements/forms/checkbox-element.js +48 -0
  39. package/dist/elements/forms/choice-element.d.ts +53 -0
  40. package/dist/elements/forms/choice-element.js +59 -0
  41. package/dist/elements/forms/push-button-element.d.ts +56 -0
  42. package/dist/elements/forms/push-button-element.js +49 -0
  43. package/dist/elements/forms/radio-element.d.ts +52 -0
  44. package/dist/elements/forms/radio-element.js +49 -0
  45. package/dist/elements/forms/signature-element.d.ts +54 -0
  46. package/dist/elements/forms/signature-element.js +47 -0
  47. package/dist/elements/forms/text-field-element.d.ts +61 -0
  48. package/dist/elements/forms/text-field-element.js +54 -0
  49. package/dist/elements/index.d.ts +8 -0
  50. package/dist/elements/index.js +8 -0
  51. package/dist/elements/layout/keep-together-element.d.ts +42 -0
  52. package/dist/elements/layout/keep-together-element.js +85 -0
  53. package/dist/elements/layout/padding-element.d.ts +3 -0
  54. package/dist/elements/layout/padding-element.js +12 -0
  55. package/dist/elements/layout/page-break-element.d.ts +17 -0
  56. package/dist/elements/layout/page-break-element.js +24 -0
  57. package/dist/elements/layout/positioned-element.d.ts +10 -7
  58. package/dist/elements/layout/positioned-element.js +21 -9
  59. package/dist/elements/page-element.d.ts +11 -1
  60. package/dist/elements/page-element.js +41 -16
  61. package/dist/elements/pdf-element.d.ts +28 -0
  62. package/dist/elements/pdf-element.js +32 -0
  63. package/dist/elements/rectangle-element.d.ts +10 -1
  64. package/dist/elements/rectangle-element.js +27 -6
  65. package/dist/elements/row-element.d.ts +9 -1
  66. package/dist/elements/row-element.js +9 -1
  67. package/dist/elements/text-element.d.ts +30 -4
  68. package/dist/elements/text-element.js +40 -17
  69. package/dist/forms/acroform.d.ts +63 -0
  70. package/dist/forms/acroform.js +427 -0
  71. package/dist/forms/appearance.d.ts +58 -0
  72. package/dist/forms/appearance.js +204 -0
  73. package/dist/forms/field.d.ts +164 -0
  74. package/dist/forms/field.js +1 -0
  75. package/dist/forms/pdf.d.ts +19 -0
  76. package/dist/forms/pdf.js +28 -0
  77. package/dist/ir/display-list.d.ts +18 -1
  78. package/dist/layout/fragmentation.d.ts +12 -0
  79. package/dist/layout/fragmentation.js +45 -8
  80. package/dist/renderer/form-field-renderer.d.ts +20 -0
  81. package/dist/renderer/form-field-renderer.js +12 -0
  82. package/dist/renderer/keep-together-renderer.d.ts +10 -0
  83. package/dist/renderer/keep-together-renderer.js +12 -0
  84. package/dist/renderer/page-break-renderer.d.ts +14 -0
  85. package/dist/renderer/page-break-renderer.js +15 -0
  86. package/dist/renderer/page-renderer.js +8 -2
  87. package/dist/renderer/pdf-backend.d.ts +7 -0
  88. package/dist/renderer/pdf-backend.js +48 -6
  89. package/dist/renderer/pdf-document-class.js +3 -0
  90. package/dist/renderer/pdf-document-renderer.js +26 -7
  91. package/dist/renderer/pdf-renderer.js +25 -1
  92. package/dist/renderer/text-renderer.d.ts +1 -1
  93. package/dist/renderer/text-renderer.js +107 -59
  94. package/dist/text/advance.d.ts +34 -0
  95. package/dist/text/advance.js +42 -0
  96. package/dist/text/line-breaker.d.ts +9 -8
  97. package/dist/text/line-breaker.js +28 -21
  98. package/dist/text/line-metrics.d.ts +49 -0
  99. package/dist/text/line-metrics.js +57 -0
  100. package/dist/text/text-decoration.d.ts +50 -0
  101. package/dist/text/text-decoration.js +62 -0
  102. package/dist/text/text-style.d.ts +13 -3
  103. package/dist/text/text-style.js +10 -3
  104. package/dist/utils/afm-parser.d.ts +34 -0
  105. package/dist/utils/afm-parser.js +67 -0
  106. package/dist/utils/dest-registry.d.ts +5 -2
  107. package/dist/utils/dest-registry.js +5 -5
  108. package/dist/utils/font-metrics.d.ts +14 -0
  109. package/dist/utils/outline.js +1 -2
  110. package/dist/utils/pdf-object-manager.d.ts +45 -1
  111. package/dist/utils/pdf-object-manager.js +193 -26
  112. package/dist/utils/struct-tree.js +2 -3
  113. package/dist/utils/ttf-parser.d.ts +53 -0
  114. package/dist/utils/ttf-parser.js +368 -0
  115. package/dist/validators/element-validator.js +7 -3
  116. package/package.json +9 -2
@@ -0,0 +1,14 @@
1
+ import { PDFObjectManager } from "../utils/pdf-object-manager.js";
2
+ import { PageBreakElement } from "../elements/layout/page-break-element.js";
3
+ import { IRNode } from "../ir/display-list.js";
4
+ /**
5
+ * A `PageBreak` draws nothing - the pagination packer consumes it at the cut, so in a vertical flow it
6
+ * never reaches this pass. Reaching it therefore means the break was ineffective: it sat somewhere that
7
+ * does not paginate, like inside a horizontal `Row` (a row is one line, you cannot split it across
8
+ * pages - CSS and react-pdf ignore a forced break there too). We degrade to drawing nothing, but warn
9
+ * once so the mistake is not silent. This one choke-point catches every ineffective break; no element
10
+ * needs a special case.
11
+ */
12
+ export declare class PageBreakRenderer {
13
+ static render(_element: PageBreakElement, _objectManager: PDFObjectManager): Promise<IRNode[]>;
14
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * A `PageBreak` draws nothing - the pagination packer consumes it at the cut, so in a vertical flow it
3
+ * never reaches this pass. Reaching it therefore means the break was ineffective: it sat somewhere that
4
+ * does not paginate, like inside a horizontal `Row` (a row is one line, you cannot split it across
5
+ * pages - CSS and react-pdf ignore a forced break there too). We degrade to drawing nothing, but warn
6
+ * once so the mistake is not silent. This one choke-point catches every ineffective break; no element
7
+ * needs a special case.
8
+ */
9
+ export class PageBreakRenderer {
10
+ static async render(_element, _objectManager) {
11
+ console.warn("PageBreak had no effect: it is not in a paginating flow (e.g. it is inside a Row, which is a " +
12
+ "single horizontal line). Put the break as a sibling in a Column to break after that content.");
13
+ return [];
14
+ }
15
+ }
@@ -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
  }
@@ -39,6 +39,13 @@ export declare class PdfBackend {
39
39
  * text closes the string early and the remaining characters leak as raw operators.
40
40
  */
41
41
  static escapePdfString(text: string): string;
42
+ /**
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`.
47
+ */
48
+ static kernedArray(text: string, kerns: number[], encode: (chunk: string) => string): string;
42
49
  /**
43
50
  * Returns the `/GSx gs` operator that selects a transparency state, or `""` when both
44
51
  * alphas are fully opaque. Opaque draws emit nothing here, so output stays
@@ -65,6 +65,9 @@ export class PdfBackend {
65
65
  case "link":
66
66
  // A link's clickable rect flips around its bottom edge, exactly like a rect.
67
67
  return { ...node, y: pageHeight - node.y - node.height };
68
+ case "formfield":
69
+ // A form field's box flips around its bottom edge, exactly like a link rect.
70
+ return { ...node, y: pageHeight - node.y - node.height };
68
71
  case "outline":
69
72
  // An outline anchor is a single point (the target's top); flip it like a text baseline.
70
73
  return { ...node, y: pageHeight - node.y };
@@ -137,6 +140,7 @@ export class PdfBackend {
137
140
  node.type === "link" ||
138
141
  node.type === "outline" ||
139
142
  node.type === "anchor" ||
143
+ node.type === "formfield" ||
140
144
  ops === "")
141
145
  return ops;
142
146
  const { open, close } = struct.mark(node.tag);
@@ -152,6 +156,27 @@ export class PdfBackend {
152
156
  static escapePdfString(text) {
153
157
  return text.replace(/\\/g, "\\\\").replace(/\(/g, "\\(").replace(/\)/g, "\\)");
154
158
  }
159
+ /**
160
+ * 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`.
164
+ */
165
+ static kernedArray(text, kerns, encode) {
166
+ const chars = [...text];
167
+ let out = "";
168
+ let chunk = chars[0] ?? "";
169
+ for (let i = 0; i < kerns.length; i++) {
170
+ if (kerns[i] === 0) {
171
+ chunk += chars[i + 1];
172
+ }
173
+ else {
174
+ out += encode(chunk) + ` ${-kerns[i]} `;
175
+ chunk = chars[i + 1];
176
+ }
177
+ }
178
+ return `[${out}${encode(chunk)}]`;
179
+ }
155
180
  /**
156
181
  * Returns the `/GSx gs` operator that selects a transparency state, or `""` when both
157
182
  * alphas are fully opaque. Opaque draws emit nothing here, so output stays
@@ -242,18 +267,31 @@ export class PdfBackend {
242
267
  const font = isCustom
243
268
  ? om.getCustomFontResource(node.fontFamily, node.fontStyle)
244
269
  : om.registerFont(node.fontFamily, node.fontStyle);
245
- const textOp = isCustom
246
- ? `<${om.encodeCustomText(node.fontFamily, node.text, node.fontStyle)}>`
247
- : `(${PdfBackend.escapePdfString(node.text)})`;
270
+ const encode = (t) => isCustom
271
+ ? `<${om.encodeCustomText(node.fontFamily, t, node.fontStyle)}>`
272
+ : `(${PdfBackend.escapePdfString(t)})`;
273
+ // Kerning (if the document has it on): a `TJ` array with the font's per-pair adjustments
274
+ // between glyph chunks, measured into the layout by the SAME numbers (runAdvance). A plain
275
+ // `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`;
282
+ // letterSpacing is the `Tc` operator: extra advance after EVERY glyph (an embedded
283
+ // Identity-H font too - that is `Tw`, word spacing, which only touches single-byte code 32).
284
+ const spacing = node.letterSpacing ? `${node.letterSpacing.toFixed(3)} Tc\n` : "";
248
285
  const block = `BT\n` +
249
286
  `${node.color.toPDFColorString()} rg ` +
250
287
  `/F${font.fontIndex} ${node.fontSize} Tf ` +
251
288
  `${node.x.toFixed(3)} ${node.y.toFixed(3)} Td ` +
252
- `${textOp} Tj\n` +
289
+ `${showOp}\n` +
253
290
  `ET\n`;
254
- // Transparent text gets an isolating q/Q + gs; opaque text is byte-identical.
255
291
  const gs = PdfBackend.alphaPrefix(om, node.color.getAlpha(), 1);
256
- return gs ? `q\n${gs}${block}Q\n` : block;
292
+ // `Tc` is graphics state and would leak into the next run, so isolate it in q/Q - the same
293
+ // reason transparent text is isolated. A run with neither stays byte-identical.
294
+ return gs || spacing ? `q\n${gs}${spacing}${block}Q\n` : block;
257
295
  }
258
296
  case "image": {
259
297
  // The backend owns PDF resource creation: register the XObject (using the
@@ -323,6 +361,10 @@ export class PdfBackend {
323
361
  case "anchor":
324
362
  // A named destination draws nothing - it becomes a /Names /Dests entry (built in PageRenderer/PDFRenderer).
325
363
  return "";
364
+ case "formfield":
365
+ // A form field draws nothing in the content stream - it becomes a Widget /Annot + /AcroForm field
366
+ // (built in PageRenderer via forms/acroform.ts).
367
+ return "";
326
368
  default: {
327
369
  // Exhaustiveness guard: if a new IRNode variant is added, this fails to compile.
328
370
  const unknown = node;
@@ -88,6 +88,9 @@ export class PDFDocument {
88
88
  }
89
89
  if (config)
90
90
  this._objectManager.changePDFConfig(config);
91
+ // Kerning is on by default at the document level (the `renderToBytes` path sets the same). A
92
+ // bare `PDFObjectManager` still defaults OFF, so unit tests keep predictable un-kerned metrics.
93
+ this._objectManager.setKerning(true);
91
94
  }
92
95
  get objectManager() {
93
96
  return this._objectManager;
@@ -1,6 +1,6 @@
1
- import { layoutPageBands, PageElement, resolvePageSize, } from "../elements/page-element.js";
1
+ import { layoutPageBands, pageFrame, PageElement, resolvePageSize, } from "../elements/page-element.js";
2
2
  import { BoxConstraints } from "../layout/box-constraints.js";
3
- import { isFragmentable } from "../layout/fragmentation.js";
3
+ import { isFragmentable, reportOverflow } from "../layout/fragmentation.js";
4
4
  import { PageRenderer } from "./page-renderer.js";
5
5
  export class PDFDocumentRenderer {
6
6
  static async render(document, objectManager, ctx) {
@@ -66,13 +66,19 @@ export class PDFDocumentRenderer {
66
66
  }
67
67
  // Header/footer repeat on every physical page, so the body only ever flows into the
68
68
  // band between them. Resolve that band once (config is already merged by pass 1).
69
+ // The frame is a throwaway: this pass only wants the band HEIGHTS, and an out-of-flow child
70
+ // contributes none. It exists because a `Positioned` in a band demands one; pass 2 builds the
71
+ // real frame (in `PageElement`) and drains it.
69
72
  const pageCtx = {
70
73
  metrics: ctx.metrics,
71
74
  pageConfig: config,
72
75
  textStyle: ctx.textStyle,
73
76
  onOverflow: ctx.onOverflow,
77
+ frame: pageFrame(config),
74
78
  };
75
79
  const { bodyWidth: width, bodyHeight: height } = layoutPageBands(config, header, footer, pageCtx);
80
+ // A `keepTogether` group compares its height against a FULL fresh page (this band), so thread it in.
81
+ pageCtx.pageBodyHeight = height;
76
82
  const pages = [];
77
83
  let region = children[0];
78
84
  let isFirstRegion = true;
@@ -84,17 +90,30 @@ export class PDFDocumentRenderer {
84
90
  }
85
91
  // pageCtx, not the document ctx: a descendant that reads `pageConfig` while being measured (a
86
92
  // `PageBuilder` sizing its provisional build) must see THIS page's geometry, not the document default.
87
- const { fitted, remainder } = region.fragment(height, width, pageCtx);
93
+ const { fitted, remainder, forceBreak } = region.fragment(height, width, pageCtx);
88
94
  // Everything fits on one page: keep the ORIGINAL page so output is unchanged. Measuring inside
89
95
  // fragment() left its children at the measuring origin; pass B lays the page out again, which
90
- // restores their real positions (layout is deterministic).
91
- if (isFirstRegion && remainder === null) {
96
+ // restores their real positions (layout is deterministic). A consumed trailing `PageBreak`
97
+ // (forceBreak with no remainder) is the exception: render the packed `fitted` instead, which no
98
+ // longer holds the break marker, so the original with the break does not leak into the render pass.
99
+ if (isFirstRegion && remainder === null && !forceBreak) {
92
100
  pages.push({ kind: "whole", page });
93
101
  break;
94
102
  }
95
- if (fitted) {
96
- pages.push({ kind: "fragment", config, content: fitted, header, footer });
103
+ // TERMINATION GUARANTEE. Every page here has the full body height, so `fitted === null` means
104
+ // nothing fit even on a whole page - the region did not get smaller. Advancing to the
105
+ // (identical) remainder would loop forever. So we stop: place the region whole (clipped to the
106
+ // page) and surface it per the overflow policy. A step that shrinks nothing ends the loop, for
107
+ // ANY region - an oversized unbreakable block, a future keep-together group, or an engine bug.
108
+ if (fitted === null) {
109
+ // Measure the region's true height for the message (rare path, so the extra pass is free);
110
+ // pass B lays it out again, so this mutation does not leak.
111
+ const needed = region.calculateLayout(BoxConstraints.loose(width, Infinity), { x: 0, y: 0 }, pageCtx).height;
112
+ reportOverflow(region, needed, height, pageCtx.onOverflow ?? "ignore");
113
+ pages.push({ kind: "fragment", config, content: region, header, footer });
114
+ break;
97
115
  }
116
+ pages.push({ kind: "fragment", config, content: fitted, header, footer });
98
117
  region = remainder;
99
118
  isFirstRegion = false;
100
119
  }
@@ -26,7 +26,18 @@ import { LinkRenderer } from "./link-renderer.js";
26
26
  import { BookmarkElement } from "../elements/layout/bookmark-element.js";
27
27
  import { BookmarkRenderer } from "./bookmark-renderer.js";
28
28
  import { AnchorElement } from "../elements/layout/anchor-element.js";
29
+ import { PageBreakElement } from "../elements/layout/page-break-element.js";
29
30
  import { AnchorRenderer } from "./anchor-renderer.js";
31
+ import { PageBreakRenderer } from "./page-break-renderer.js";
32
+ import { KeepTogetherElement } from "../elements/layout/keep-together-element.js";
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";
30
41
  import { PageBuilderElement } from "../elements/layout/page-builder-element.js";
31
42
  import { PageBuilderRenderer } from "./page-builder-renderer.js";
32
43
  import { RotatedElement } from "../elements/layout/rotated-element.js";
@@ -45,6 +56,14 @@ export class PDFRenderer {
45
56
  RendererRegistry.register(RectangleElement, RectangleRenderer.render);
46
57
  RendererRegistry.register(ExpandedElement, ExpandedRenderer.render);
47
58
  RendererRegistry.register(PaddingElement, PaddingRenderer.render);
59
+ RendererRegistry.register(PageBreakElement, PageBreakRenderer.render);
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);
48
67
  RendererRegistry.register(DefaultTextStyleElement, DefaultTextStyleRenderer.render);
49
68
  RendererRegistry.register(ImageElement, ImageRenderer.render);
50
69
  RendererRegistry.register(LineElement, LineRenderer.render);
@@ -118,8 +137,13 @@ export class PDFRenderer {
118
137
  const outlineCatalog = objectManager.outline.finalize(objectManager);
119
138
  if (outlineCatalog)
120
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);
121
145
  // Named destinations (internal-link targets): a /Dests entry in the shared /Names dict.
122
- const destsNames = objectManager.dests.finalize();
146
+ const destsNames = objectManager.dests.finalize(objectManager);
123
147
  if (destsNames)
124
148
  namesParts.push(destsNames);
125
149
  // Emit the single /Names dict if either embedded files or destinations contributed to it.
@@ -4,7 +4,7 @@ import type { FontMetrics } from "../utils/font-metrics.js";
4
4
  import { IRNode } from "../ir/display-list.js";
5
5
  import { 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): 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): number;
8
8
  static render(textElement: TextElement, objectManager: PDFObjectManager): Promise<IRNode[]>;
9
9
  private static _expandColorGlyphs;
10
10
  private static _glyphToPath;
@@ -3,28 +3,30 @@ import { HorizontalAlignment } from "../elements/pdf-element.js";
3
3
  import { isEmojiCodePoint } from "../text/emoji-codepoints.js";
4
4
  import { emojiImageNode } from "./emoji-image.js";
5
5
  import { wrapStringIntoLines, breakSegmentsIntoLines, } from "../text/line-breaker.js";
6
- // Distance from the top of a line down to its baseline, as a fraction of the font
7
- // size. ~0.683 is the standard-14 ascent ratio used to seat the first baseline.
8
- const BASELINE_RATIO = 683 / 1000;
6
+ import { lineBoxForSegmentLine, lineBoxForString } from "../text/line-metrics.js";
7
+ import { runAdvance } from "../text/advance.js";
8
+ import { skipInkSegments, strikethroughStroke, underlineStroke, } from "../text/text-decoration.js";
9
9
  export class TextRenderer {
10
10
  // Measuring only needs metrics, not the full object manager. (The render pass below
11
11
  // still receives the manager because it also registers fonts/images.)
12
- static calculateTextHeight(content, fontSize, fontFamily, fontStyle, objectManager, maxWidth, maxLines, overflow, lineHeight = 1) {
13
- // Plain string: one line box (fontSize * lineHeight) per wrapped line.
12
+ static calculateTextHeight(content, fontSize, fontFamily, fontStyle, objectManager, maxWidth, maxLines, overflow, lineHeight, letterSpacing = 0) {
13
+ // Plain string: every wrapped line gets the same box.
14
14
  if (typeof content === "string") {
15
- const lines = wrapStringIntoLines(content, fontFamily, fontSize, fontStyle, maxWidth, objectManager, maxLines, overflow);
16
- return lines.length * fontSize * lineHeight;
15
+ const lines = wrapStringIntoLines(content, fontFamily, fontSize, fontStyle, maxWidth, objectManager, maxLines, overflow, letterSpacing);
16
+ const box = lineBoxForString(objectManager, fontFamily, fontStyle, fontSize, lineHeight);
17
+ return lines.length * box.height;
17
18
  }
18
- // Segments: each line contributes its own (tallest-on-line) leading, scaled by lineHeight.
19
- const lines = breakSegmentsIntoLines(content, { fontFamily, fontSize, fontStyle }, maxWidth, objectManager, maxLines, overflow);
20
- return lines.reduce((total, line) => total + line.maxFontSize * lineHeight, 0);
19
+ // Segments: each line's box comes from the fonts actually on it.
20
+ const defaults = { fontFamily, fontSize, fontStyle, letterSpacing };
21
+ const lines = breakSegmentsIntoLines(content, defaults, maxWidth, objectManager, maxLines, overflow);
22
+ return lines.reduce((total, line) => total + lineBoxForSegmentLine(line, defaults, objectManager, lineHeight).height, 0);
21
23
  }
22
24
  static async render(textElement, objectManager) {
23
- const { x, y, width, fontSize, color, content, fontFamily, fontStyle, textAlignment, maxLines, overflow, lineHeight, role, } = textElement.getProps();
25
+ const { x, y, width, fontSize, color, content, fontFamily, fontStyle, textAlignment, maxLines, overflow, lineHeight, underline, strikethrough, skipInk, letterSpacing, role, } = textElement.getProps();
24
26
  // Component -> display list. Wrapping and positioning stay here; the backend
25
27
  // turns each run into BT/Tf/Td/Tj/ET. The wrapping algorithm is unchanged from
26
28
  // the original renderer - unifying it into the engine is Phase 3.
27
- const { runs, links } = TextRenderer._buildRuns(content, fontSize, fontFamily, fontStyle, objectManager, width ?? Number.NaN, textAlignment, color, x, y, maxLines, overflow, lineHeight);
29
+ const { runs, links, decorations } = TextRenderer._buildRuns(content, fontSize, fontFamily, fontStyle, objectManager, width ?? Number.NaN, textAlignment, color, x, y, maxLines, overflow, lineHeight, underline, strikethrough, skipInk, letterSpacing);
28
30
  // Accessible tagging: this whole text block is one structure element (a paragraph P, or a heading
29
31
  // from `role`); each run's text lands under it. Emoji sub-nodes produced below stay untagged (drawn
30
32
  // as artifacts). Keyed by structId so a block split across pages stays one element.
@@ -37,9 +39,11 @@ export class TextRenderer {
37
39
  // Color fonts (COLR/CPAL): expand each run's emoji into filled vector layers (or fetched images
38
40
  // for an image emoji source); plain runs and monochrome fonts pass straight through unchanged.
39
41
  const drawn = (await Promise.all(runs.map((run) => TextRenderer._expandColorGlyphs(run, objectManager)))).flat();
42
+ // Underline / strikethrough go on TOP of the glyphs (a strikethrough must), and stay untagged,
43
+ // so the structure tree treats them as artifacts rather than as text.
40
44
  // Inline hyperlinks (href spans) ride along as /Link annotations - they draw nothing, so order
41
45
  // vs the drawn runs does not matter; the page renderer peels them off into /Annots.
42
- return links.length > 0 ? [...drawn, ...links] : drawn;
46
+ return [...drawn, ...decorations, ...links];
43
47
  }
44
48
  // Splits a text run into normal text sub-runs + filled `Path` layers for any COLR color glyphs,
45
49
  // walking code point by code point and advancing the pen by each glyph's width. Returns `[run]`
@@ -82,8 +86,10 @@ export class TextRenderer {
82
86
  if (l)
83
87
  [source, layers] = [fallback, l];
84
88
  }
85
- // The advance matches measuring (getCharWidth applies the same emoji fallback).
86
- const advance = om.getCharWidth(ch, run.fontSize, undefined, run.fontFamily, run.fontStyle);
89
+ // The advance matches measuring (getCharWidth applies the same emoji fallback), plus the run's
90
+ // letter-spacing so a spaced run's emoji sit where `Tc` advances them.
91
+ const advance = om.getCharWidth(ch, run.fontSize, undefined, run.fontFamily, run.fontStyle) +
92
+ (run.letterSpacing ?? 0);
87
93
  if (source && layers) {
88
94
  flushPending();
89
95
  const scale = run.fontSize / source.unitsPerEm;
@@ -209,11 +215,14 @@ export class TextRenderer {
209
215
  // Lay the content out into absolutely-positioned text runs. Glyph positions match
210
216
  // the previous hand-written operators exactly (verified by pixel-identical render);
211
217
  // only the output form changed from PDF strings to `TextRun`s.
212
- static _buildRuns(content, fontSize, fontFamily, fontStyle, objectManager, maxWidth, textAlignment, color, initialX, yPosition, maxLines, overflow, lineHeight = 1) {
218
+ static _buildRuns(content, fontSize, fontFamily, fontStyle, objectManager, maxWidth, textAlignment, color, initialX, yPosition, maxLines, overflow, lineHeight, underline = false, strikethrough = false, skipInk = false, letterSpacing = 0) {
213
219
  const runs = [];
214
220
  // /Link annotation rects for any `href` spans, collected as we place segments. Empty for the
215
221
  // common (no-href) case, so plain text keeps producing exactly the runs it did before.
216
222
  const links = [];
223
+ // Underline / strikethrough strokes, one per decorated run (so a wrapped paragraph gets one
224
+ // per line, and a decorated span only spans its own glyphs).
225
+ const decorations = [];
217
226
  // Horizontal offset of a line of the given width under the current alignment.
218
227
  const alignmentOffset = (lineWidth) => {
219
228
  if (textAlignment === HorizontalAlignment.center)
@@ -222,39 +231,78 @@ export class TextRenderer {
222
231
  return maxWidth - lineWidth;
223
232
  return 0;
224
233
  };
225
- // Advance width WITHOUT kerning. This is how Tj moves the text cursor, so
226
- // segments flowing after each other land here. (Standard-14 fonts carry no
227
- // /Widths array; the viewer advances by the AFM widths - same source as below.)
228
- const advanceNoKerning = (text, family, size, style) => {
229
- let width = 0;
230
- for (const ch of text) {
231
- width += objectManager.getCharWidth(ch, size, undefined, family, style);
234
+ // The decoration strokes for one drawn run, at the geometry ITS OWN font declares. A mixed-size
235
+ // line therefore gets a thicker line under the bigger span, which is what a browser does too.
236
+ const decorate = (text, runX, runWidth, baselineY, family, style, size, runColor, wantsUnderline, wantsStrikethrough, runLetterSpacing) => {
237
+ if ((!wantsUnderline && !wantsStrikethrough) || runWidth <= 0)
238
+ return;
239
+ const metrics = objectManager.getFontDecoration(family, style);
240
+ const push = (from, to, stroke) => {
241
+ decorations.push({
242
+ type: "line",
243
+ x1: runX + from,
244
+ y1: stroke.y,
245
+ x2: runX + to,
246
+ y2: stroke.y,
247
+ stroke: runColor,
248
+ strokeWidth: stroke.thickness,
249
+ });
250
+ };
251
+ if (wantsUnderline) {
252
+ const stroke = underlineStroke(metrics, size, baselineY);
253
+ for (const [from, to] of underlineSegments(text, runWidth, baselineY, size, family, style, stroke, runLetterSpacing)) {
254
+ push(from, to, stroke);
255
+ }
256
+ }
257
+ // A strikethrough crosses the letters by definition, so it is never interrupted.
258
+ if (wantsStrikethrough) {
259
+ const stroke = strikethroughStroke(metrics, size, baselineY);
260
+ push(0, runWidth, stroke);
232
261
  }
233
- return width;
234
262
  };
263
+ // The x-ranges the underline actually draws. Without `skipInk` that is the whole run; with it,
264
+ // the descenders are cut out of it (which needs the real outlines, i.e. an embedded font).
265
+ const underlineSegments = (text, runWidth, baselineY, size, family, style, stroke, runLetterSpacing) => {
266
+ if (!skipInk)
267
+ return [[0, runWidth]];
268
+ if (!objectManager.isCustomFont(family, style)) {
269
+ throw new Error(`skipInk needs an embedded font, but "${family}" is a standard-14 font whose glyph outlines ` +
270
+ `live in the PDF viewer, not in the document - we cannot see where its ink is. Register a ` +
271
+ `font (the document's \`fonts\` option) or drop \`skipInk\`.`);
272
+ }
273
+ // The band the stroke covers, in points from the baseline, y UP (so both values are negative).
274
+ const below = stroke.y - baselineY;
275
+ const half = stroke.thickness / 2;
276
+ const inkSpans = objectManager.getInkSpansInBand(text, size, family, style, -below + half, -below - half, runLetterSpacing);
277
+ return skipInkSegments(runWidth, inkSpans, stroke.thickness);
278
+ };
279
+ // The advance the viewer will use: plain glyph widths (a `Tj` never kerns) plus one
280
+ // `letterSpacing` per code point (the `Tc` operator), from the one shared `advance.ts` primitive.
281
+ const font = { fontFamily, fontSize, fontStyle };
235
282
  // --- Plain string: one run per wrapped line. ---
236
283
  if (typeof content === "string") {
237
- const lines = wrapStringIntoLines(content, fontFamily, fontSize, fontStyle, maxWidth, objectManager, maxLines, overflow);
238
- // yPosition is the top of the text box (top-left); seat line 0's baseline below it, then step
239
- // DOWN by one line box (fontSize * lineHeight) per line. The lineHeight EXTRA leading is split
240
- // half above / half below (CSS/Flutter "half-leading"), so the text sits centered in its line
241
- // box instead of clinging to the top. At lineHeight 1 the half-leading is 0 -> byte-identical.
242
- const halfLeading = (fontSize * (lineHeight - 1)) / 2;
243
- const baseline = yPosition + halfLeading + fontSize * BASELINE_RATIO;
284
+ const lines = wrapStringIntoLines(content, fontFamily, fontSize, fontStyle, maxWidth, objectManager, maxLines, overflow, letterSpacing);
285
+ // yPosition is the top of the text box (top-left). The line box seats its own baseline; lines
286
+ // then stack by that box's height. Same numbers as calculateTextHeight, by construction.
287
+ const box = lineBoxForString(objectManager, fontFamily, fontStyle, fontSize, lineHeight);
244
288
  lines.forEach((line, index) => {
245
- const lineWidth = objectManager.getStringWidth(line, fontFamily, fontSize, fontStyle);
289
+ const lineWidth = runAdvance(objectManager, line, font, letterSpacing);
290
+ const x = initialX + alignmentOffset(lineWidth);
291
+ const baseline = yPosition + box.baseline + box.height * index;
246
292
  runs.push({
247
293
  type: "text",
248
- x: initialX + alignmentOffset(lineWidth),
249
- y: baseline + fontSize * lineHeight * index,
294
+ x,
295
+ y: baseline,
250
296
  text: line,
251
297
  fontFamily,
252
298
  fontStyle,
253
299
  fontSize,
254
300
  color,
301
+ ...(letterSpacing ? { letterSpacing } : {}),
255
302
  });
303
+ decorate(line, x, lineWidth, baseline, fontFamily, fontStyle, fontSize, color, underline, strikethrough, letterSpacing);
256
304
  });
257
- return { runs, links };
305
+ return { runs, links, decorations };
258
306
  }
259
307
  // --- Segments: break into lines (shared breaker), then emit one run per segment.
260
308
  // Segment 0 starts at the aligned line origin; each following segment is offset by
@@ -267,7 +315,9 @@ export class TextRenderer {
267
315
  const family = segment.fontFamily || fontFamily;
268
316
  const size = segment.fontSize || fontSize;
269
317
  const style = segment.fontStyle || fontStyle;
270
- const advance = advanceNoKerning(segment.content, family, size, style);
318
+ const segLetterSpacing = segment.letterSpacing ?? letterSpacing;
319
+ const advance = runAdvance(objectManager, segment.content, { fontFamily: family, fontSize: size, fontStyle: style }, segLetterSpacing);
320
+ const runColor = segment.fontColor || color;
271
321
  runs.push({
272
322
  type: "text",
273
323
  x,
@@ -276,18 +326,21 @@ export class TextRenderer {
276
326
  fontFamily: family,
277
327
  fontStyle: style,
278
328
  fontSize: size,
279
- color: segment.fontColor || color,
329
+ color: runColor,
330
+ ...(segLetterSpacing ? { letterSpacing: segLetterSpacing } : {}),
280
331
  });
281
- // An href/to span becomes a /Link annotation over exactly this run's glyph box. `lineY` is the
282
- // baseline; the box spans from the ascent (BASELINE_RATIO above it) down by the full size, so
283
- // ascent + descent are covered. A span wrapped across lines yields one rect per line.
332
+ decorate(segment.content, x, advance, lineY, family, style, size, runColor, segment.underline ?? underline, segment.strikethrough ?? strikethrough, segLetterSpacing);
333
+ // An href/to span becomes a /Link annotation over exactly this run's glyph box: from its own
334
+ // ascent above the baseline down to its own descender. A span wrapped across lines yields
335
+ // one rect per line.
284
336
  if (segment.href !== undefined || segment.dest !== undefined) {
337
+ const v = objectManager.getFontVerticals(family, style);
285
338
  links.push({
286
339
  type: "link",
287
340
  x,
288
- y: lineY - size * BASELINE_RATIO,
341
+ y: lineY - v.ascent * size,
289
342
  width: advance,
290
- height: size,
343
+ height: (v.ascent + v.descent) * size,
291
344
  href: segment.href,
292
345
  dest: segment.dest,
293
346
  });
@@ -295,23 +348,18 @@ export class TextRenderer {
295
348
  x += advance;
296
349
  });
297
350
  };
298
- // The overall tallest font seats the first baseline; each line then steps DOWN by
299
- // its own leading. yPosition is the top of the text box (top-left); the seam flips
300
- // the whole thing to PDF space.
301
- let overallMaxFont = fontSize;
302
- for (const segment of content) {
303
- const size = segment.fontSize || fontSize;
304
- if (size > overallMaxFont)
305
- overallMaxFont = size;
306
- }
307
- let lineY = yPosition + overallMaxFont * BASELINE_RATIO;
308
- for (const line of breakSegmentsIntoLines(content, { fontFamily, fontSize, fontStyle }, maxWidth, objectManager, maxLines, overflow)) {
309
- // Half-leading: shift this line's baseline down by half its own extra leading, so the line
310
- // sits centered in its box (CSS/Flutter). At lineHeight 1 the shift is 0 -> byte-identical.
311
- const halfLeading = (line.maxFontSize * (lineHeight - 1)) / 2;
312
- pushLine(line, lineY + halfLeading);
313
- lineY += line.maxFontSize * lineHeight;
351
+ // yPosition is the top of the text box (top-left). Each line seats its own baseline inside its
352
+ // own box (tallest ascent / deepest descent ON THAT LINE), then the next line starts below it.
353
+ // The seam flips the whole thing to PDF space. `letterSpacing` MUST be in the defaults so a span
354
+ // that does not override it wraps and aligns with the element's spacing - the same defaults the
355
+ // measure path (calculateTextHeight) uses, or segmented spaced text mis-wraps (measured != drawn).
356
+ const defaults = { fontFamily, fontSize, fontStyle, letterSpacing };
357
+ let top = yPosition;
358
+ for (const line of breakSegmentsIntoLines(content, defaults, maxWidth, objectManager, maxLines, overflow)) {
359
+ const box = lineBoxForSegmentLine(line, defaults, objectManager, lineHeight);
360
+ pushLine(line, top + box.baseline);
361
+ top += box.height;
314
362
  }
315
- return { runs, links };
363
+ return { runs, links, decorations };
316
364
  }
317
365
  }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * How wide a run of text is: the horizontal counterpart to `line-metrics.ts` (how tall a line is)
3
+ * and `line-breaker.ts` (where a line breaks). The ONE canonical answer to "how far does the pen
4
+ * move", so measuring and drawing can never disagree about it.
5
+ *
6
+ * An advance has three parts, each with its own PDF mechanism:
7
+ *
8
+ * advance = sum(glyph widths) + sum(kerning) + n * letterSpacing
9
+ * /Widths TJ array Tc
10
+ *
11
+ * `letterSpacing` is added after EVERY code point, the last one included - exactly what the `Tc`
12
+ * operator does, and exactly what CSS `letter-spacing` does (which is why right-aligned spaced text
13
+ * looks a hair offset: a spacing hangs off the final glyph). Measuring `(n - 1)` spacings while `Tc`
14
+ * applies `n` would make every line draw one spacing wider than it was wrapped - text out of its box.
15
+ *
16
+ * Kerning is added ONLY when the document has it on (`metrics.kerningEnabled`), and then the backend
17
+ * emits a `TJ` with the same per-pair adjustments - so a measured advance always equals the drawn
18
+ * one. With kerning off, `getStringWidth` is the whole advance (its plain glyph sum), byte-identical
19
+ * to before. Folding kerning into the measurement while the output ignored it was the 2026-07-10 bug.
20
+ */
21
+ import type { FontStyle } from "../utils/pdf-object-manager.js";
22
+ import type { FontMetrics } from "../utils/font-metrics.js";
23
+ /** The font a run is set in. */
24
+ export interface RunFont {
25
+ fontFamily: string;
26
+ fontSize: number;
27
+ fontStyle: FontStyle;
28
+ }
29
+ /** Number of Unicode code points (not UTF-16 units): an astral char is ONE, so it takes one
30
+ * letter-spacing, not two. Matches how `getStringWidth` iterates. */
31
+ export declare function codePointCount(text: string): number;
32
+ /** The advance of `text` in points: the plain glyph widths, plus kerning (if the document has it on),
33
+ * plus one `letterSpacing` per code point. */
34
+ export declare function runAdvance(metrics: FontMetrics, text: string, font: RunFont, letterSpacing?: number): number;