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

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 (72) hide show
  1. package/dist/api/content.d.ts +6 -3
  2. package/dist/api/content.js +12 -3
  3. package/dist/api/descriptor.js +22 -1
  4. package/dist/api/dimension.d.ts +20 -0
  5. package/dist/api/dimension.js +16 -0
  6. package/dist/api/index.d.ts +2 -0
  7. package/dist/api/index.js +2 -0
  8. package/dist/api/layout.d.ts +65 -3
  9. package/dist/api/layout.js +71 -2
  10. package/dist/api/page-builder.d.ts +32 -0
  11. package/dist/api/page-builder.js +32 -0
  12. package/dist/api/text.d.ts +6 -0
  13. package/dist/api/text.js +31 -4
  14. package/dist/elements/container-element.d.ts +17 -1
  15. package/dist/elements/container-element.js +54 -22
  16. package/dist/elements/image-element.d.ts +19 -1
  17. package/dist/elements/image-element.js +52 -11
  18. package/dist/elements/index.d.ts +6 -0
  19. package/dist/elements/index.js +6 -0
  20. package/dist/elements/layout/anchor-element.d.ts +23 -0
  21. package/dist/elements/layout/anchor-element.js +27 -0
  22. package/dist/elements/layout/bookmark-element.d.ts +27 -0
  23. package/dist/elements/layout/bookmark-element.js +30 -0
  24. package/dist/elements/layout/link-element.d.ts +33 -0
  25. package/dist/elements/layout/link-element.js +39 -0
  26. package/dist/elements/layout/page-builder-element.d.ts +32 -0
  27. package/dist/elements/layout/page-builder-element.js +43 -0
  28. package/dist/elements/layout/rotated-box-element.d.ts +35 -0
  29. package/dist/elements/layout/rotated-box-element.js +54 -0
  30. package/dist/elements/layout/rotated-element.d.ts +32 -0
  31. package/dist/elements/layout/rotated-element.js +41 -0
  32. package/dist/elements/page-element.d.ts +8 -0
  33. package/dist/elements/page-element.js +13 -1
  34. package/dist/elements/pdf-element.d.ts +31 -0
  35. package/dist/elements/pdf-element.js +19 -0
  36. package/dist/elements/rectangle-element.d.ts +11 -1
  37. package/dist/elements/rectangle-element.js +49 -13
  38. package/dist/elements/row-element.d.ts +11 -1
  39. package/dist/elements/row-element.js +37 -8
  40. package/dist/elements/text-element.d.ts +4 -0
  41. package/dist/ir/display-list.d.ts +59 -1
  42. package/dist/layout/box-constraints.d.ts +8 -0
  43. package/dist/layout/box-constraints.js +14 -0
  44. package/dist/layout/collect-images.d.ts +9 -0
  45. package/dist/layout/collect-images.js +34 -0
  46. package/dist/renderer/anchor-renderer.d.ts +6 -0
  47. package/dist/renderer/anchor-renderer.js +11 -0
  48. package/dist/renderer/bookmark-renderer.d.ts +6 -0
  49. package/dist/renderer/bookmark-renderer.js +11 -0
  50. package/dist/renderer/link-renderer.d.ts +6 -0
  51. package/dist/renderer/link-renderer.js +11 -0
  52. package/dist/renderer/page-builder-renderer.d.ts +6 -0
  53. package/dist/renderer/page-builder-renderer.js +11 -0
  54. package/dist/renderer/page-renderer.js +37 -2
  55. package/dist/renderer/pdf-backend.d.ts +10 -0
  56. package/dist/renderer/pdf-backend.js +77 -1
  57. package/dist/renderer/pdf-document-renderer.d.ts +16 -7
  58. package/dist/renderer/pdf-document-renderer.js +65 -33
  59. package/dist/renderer/pdf-renderer.js +47 -2
  60. package/dist/renderer/rotated-renderer.d.ts +7 -0
  61. package/dist/renderer/rotated-renderer.js +29 -0
  62. package/dist/renderer/text-renderer.js +26 -5
  63. package/dist/utils/afm-parser.js +6 -4
  64. package/dist/utils/dest-registry.d.ts +19 -0
  65. package/dist/utils/dest-registry.js +32 -0
  66. package/dist/utils/flex-layout.d.ts +10 -3
  67. package/dist/utils/flex-layout.js +26 -6
  68. package/dist/utils/outline.d.ts +21 -0
  69. package/dist/utils/outline.js +80 -0
  70. package/dist/utils/pdf-object-manager.d.ts +6 -0
  71. package/dist/utils/pdf-object-manager.js +0 -0
  72. package/package.json +1 -1
@@ -0,0 +1,11 @@
1
+ import { RendererRegistry } from "../utils/renderer-registry.js";
2
+ export class BookmarkRenderer {
3
+ static async render(element, objectManager) {
4
+ const { title, level, child, y } = element.getProps();
5
+ const renderer = RendererRegistry.getRenderer(child);
6
+ const childNodes = renderer ? await renderer(child, objectManager) : [];
7
+ // The child draws normally; an Outline IR node carries the title + nesting + scroll target for the
8
+ // page renderer to collect into the /Outlines tree (it produces no content-stream ops itself).
9
+ return [...childNodes, { type: "outline", y, title, level }];
10
+ }
11
+ }
@@ -0,0 +1,6 @@
1
+ import { PDFObjectManager } from "../utils/pdf-object-manager.js";
2
+ import { LinkElement } from "../elements/layout/link-element.js";
3
+ import { IRNode } from "../ir/display-list.js";
4
+ export declare class LinkRenderer {
5
+ static render(element: LinkElement, objectManager: PDFObjectManager): Promise<IRNode[]>;
6
+ }
@@ -0,0 +1,11 @@
1
+ import { RendererRegistry } from "../utils/renderer-registry.js";
2
+ export class LinkRenderer {
3
+ static async render(element, objectManager) {
4
+ const { href, dest, child, x, y, width, height } = element.getProps();
5
+ const renderer = RendererRegistry.getRenderer(child);
6
+ const childNodes = renderer ? await renderer(child, objectManager) : [];
7
+ // The child draws normally; a Link IR node carries the clickable rect + target (external href or
8
+ // internal dest) for the page renderer to emit as a /Link annotation (no content-stream ops itself).
9
+ return [...childNodes, { type: "link", x, y, width, height, href, dest }];
10
+ }
11
+ }
@@ -0,0 +1,6 @@
1
+ import { PDFObjectManager } from "../utils/pdf-object-manager.js";
2
+ import { PageBuilderElement } from "../elements/layout/page-builder-element.js";
3
+ import { IRNode } from "../ir/display-list.js";
4
+ export declare class PageBuilderRenderer {
5
+ static render(element: PageBuilderElement, objectManager: PDFObjectManager): Promise<IRNode[]>;
6
+ }
@@ -0,0 +1,11 @@
1
+ import { RendererRegistry } from "../utils/renderer-registry.js";
2
+ export class PageBuilderRenderer {
3
+ static async render(element, objectManager) {
4
+ // `composed` is whatever the closure built for THIS page during the render pass's layout.
5
+ const { composed } = element.getProps();
6
+ if (!composed)
7
+ return [];
8
+ const renderer = RendererRegistry.getRenderer(composed);
9
+ return renderer ? renderer(composed, objectManager) : [];
10
+ }
11
+ }
@@ -28,7 +28,8 @@ export class PageRenderer {
28
28
  // Accessible tagging: one struct context per page (allocates its StructParents index), threaded into
29
29
  // serialize so each drawable node is wrapped in marked content. Undefined = tagging off (byte-identical).
30
30
  const structCtx = objectManager.struct.enabled ? objectManager.struct.beginPage() : undefined;
31
- const pageContent = PdfBackend.serialize(PdfBackend.flipY(nodes, height), objectManager, structCtx);
31
+ const flipped = PdfBackend.flipY(nodes, height);
32
+ const pageContent = PdfBackend.serialize(flipped, objectManager, structCtx);
32
33
  // Add the page content as a new object (FlateDecode-compressed when enabled). The /Length is
33
34
  // computed inside, with an explicit EOL before `endstream` (PDF/A clause 6.1.7.1).
34
35
  const contentObjectNumber = objectManager.addContentStream(pageContent);
@@ -67,14 +68,48 @@ export class PageRenderer {
67
68
  shadingReferences.push(`/${name} ${objectNumber} 0 R`);
68
69
  });
69
70
  const shadingCode = shadingReferences.length > 0 ? "/Shading <<\n" + shadingReferences.join("\n") + "\n>>\n" : "";
71
+ // Hyperlinks: a `Link` IR node drew nothing into the content stream - here its (already Y-flipped)
72
+ // rect + href become a /Link annotation object. `/Border [0 0 0]` hides the default visible frame;
73
+ // the URL is escaped like any PDF literal string. Refs are collected into the page's /Annots below.
74
+ const num = (n) => Number(n.toFixed(2));
75
+ const annotRefs = [];
76
+ for (const node of flipped) {
77
+ if (node.type !== "link")
78
+ continue;
79
+ const rect = `[${num(node.x)} ${num(node.y)} ${num(node.x + node.width)} ${num(node.y + node.height)}]`;
80
+ // Either an external URL (/URI) or an internal named destination (/GoTo, resolved via /Names /Dests).
81
+ // The Link IR guarantees exactly one is set; prefer `dest` when present.
82
+ const action = node.dest !== undefined
83
+ ? `/A << /S /GoTo /D (${PdfBackend.escapePdfString(node.dest)}) >>`
84
+ : `/A << /S /URI /URI (${PdfBackend.escapePdfString(node.href ?? "")}) >>`;
85
+ const annot = objectManager.addObject(`<< /Type /Annot /Subtype /Link /Rect ${rect} /Border [0 0 0] ${action} >>`);
86
+ annotRefs.push(`${annot} 0 R`);
87
+ }
88
+ const annotsCode = annotRefs.length > 0 ? ` /Annots [${annotRefs.join(" ")}]` : "";
70
89
  // Tagging only: /StructParents links this page's marked content to the ParentTree, /Tabs /S makes the
71
90
  // logical structure order the tab/reading order (a PDF/UA requirement).
72
91
  const structAttrs = structCtx ? ` /StructParents ${structCtx.structParents} /Tabs /S` : "";
73
- const pageObject = `<< /Type /Page /Parent ${parentObjectNumber} 0 R /Contents ${contentObjectNumber} 0 R /Resources <<\n/Font <<\n${fontReferences.join("\n")}\n>>\n${imageCode}${extGStateCode}${shadingCode}>>\n/MediaBox [0 0 ${width} ${height}]${structAttrs} >>`;
92
+ const pageObject = `<< /Type /Page /Parent ${parentObjectNumber} 0 R /Contents ${contentObjectNumber} 0 R /Resources <<\n/Font <<\n${fontReferences.join("\n")}\n>>\n${imageCode}${extGStateCode}${shadingCode}>>\n/MediaBox [0 0 ${width} ${height}]${structAttrs}${annotsCode} >>`;
74
93
  // Add page as new object; register its object number with the struct tree (for /Pg refs), then return it.
75
94
  const pageObjNum = objectManager.addObject(pageObject);
76
95
  if (structCtx)
77
96
  objectManager.struct.setPageObject(structCtx.structParents, pageObjNum);
97
+ // Bookmarks + named destinations: an `Outline`/`Anchor` IR node drew nothing; record it now that the
98
+ // page's object number is known. `node.y` is already Y-flipped to the page-space scroll target.
99
+ // PDFRenderer builds /Outlines and /Names /Dests at the end.
100
+ for (const node of flipped) {
101
+ if (node.type === "outline") {
102
+ objectManager.outline.add({
103
+ title: node.title,
104
+ level: node.level,
105
+ pageObjNum,
106
+ top: node.y,
107
+ });
108
+ }
109
+ else if (node.type === "anchor") {
110
+ objectManager.dests.add(node.name, { pageObjNum, top: node.y });
111
+ }
112
+ }
78
113
  return pageObjNum;
79
114
  }
80
115
  }
@@ -22,6 +22,16 @@ export declare class PdfBackend {
22
22
  * `PageStructContext` is given (accessible tagging on), each drawable node is wrapped in a marked-content
23
23
  * sequence (`/Role <</MCID n>> BDC … EMC`, or `/Artifact` when untagged); graphics-state and empty nodes
24
24
  * pass through untouched, so untagged output stays byte-identical. */
25
+ /**
26
+ * Every number reaching the backend must be finite. A non-finite coordinate is a layout bug, but
27
+ * silence turns it into a corrupt PDF: `Infinity` is written into the operator stream verbatim, and a
28
+ * viewer then discards everything from that point on - including the footer, which is drawn last. That
29
+ * failure is invisible until somebody opens the file, so we refuse instead.
30
+ *
31
+ * Checks the node's numbers, not the serialized text: a document containing the WORD "Infinity" is
32
+ * perfectly legal and must not trip this.
33
+ */
34
+ private static assertFinite;
25
35
  static serialize(nodes: IRNode[], om: PDFObjectManager, struct?: PageStructContext): string;
26
36
  /**
27
37
  * Escapes a string for a PDF literal string `( ... )`. The backslash must be doubled
@@ -62,6 +62,28 @@ export class PdfBackend {
62
62
  : { ...node.fill, y0: pageHeight - node.fill.y0, y1: pageHeight - node.fill.y1 };
63
63
  return { ...node, commands, fill };
64
64
  }
65
+ case "link":
66
+ // A link's clickable rect flips around its bottom edge, exactly like a rect.
67
+ return { ...node, y: pageHeight - node.y - node.height };
68
+ case "outline":
69
+ // An outline anchor is a single point (the target's top); flip it like a text baseline.
70
+ return { ...node, y: pageHeight - node.y };
71
+ case "anchor":
72
+ // A named-destination anchor is a single point (the target's top), flipped like a baseline.
73
+ return { ...node, y: pageHeight - node.y };
74
+ case "transform-push": {
75
+ // The child nodes are flipped from top-left to bottom-left by F = [1,0,0,-1,0,H] (F is its
76
+ // own inverse). For a matrix authored in top-left space to act correctly on them, conjugate
77
+ // it by that flip: M_pdf = F · M · F. Worked out for [a,b,c,d,e,f] with H = pageHeight:
78
+ const [a, b, c, d, e, f] = node.matrix;
79
+ const H = pageHeight;
80
+ return {
81
+ ...node,
82
+ matrix: [a, -b, -c, d, H * c + e, H - H * d - f],
83
+ };
84
+ }
85
+ case "transform-pop":
86
+ return node; // no coordinates to flip
65
87
  default: {
66
88
  const unknown = node;
67
89
  return unknown;
@@ -73,11 +95,49 @@ export class PdfBackend {
73
95
  * `PageStructContext` is given (accessible tagging on), each drawable node is wrapped in a marked-content
74
96
  * sequence (`/Role <</MCID n>> BDC … EMC`, or `/Artifact` when untagged); graphics-state and empty nodes
75
97
  * pass through untouched, so untagged output stays byte-identical. */
98
+ /**
99
+ * Every number reaching the backend must be finite. A non-finite coordinate is a layout bug, but
100
+ * silence turns it into a corrupt PDF: `Infinity` is written into the operator stream verbatim, and a
101
+ * viewer then discards everything from that point on - including the footer, which is drawn last. That
102
+ * failure is invisible until somebody opens the file, so we refuse instead.
103
+ *
104
+ * Checks the node's numbers, not the serialized text: a document containing the WORD "Infinity" is
105
+ * perfectly legal and must not trip this.
106
+ */
107
+ static assertFinite(node) {
108
+ const finite = (value) => {
109
+ if (typeof value === "number")
110
+ return Number.isFinite(value);
111
+ if (Array.isArray(value))
112
+ return value.every(finite);
113
+ if (value !== null && typeof value === "object")
114
+ return Object.values(value).every(finite);
115
+ return true;
116
+ };
117
+ if (!finite(node)) {
118
+ throw new Error(`Layout produced a non-finite number on a "${node.type}" node, refusing to emit a corrupt ` +
119
+ `content stream. This is a bug in an element's calculateLayout (a size resolved to Infinity ` +
120
+ `or NaN), not in your document.`);
121
+ }
122
+ }
76
123
  static serialize(nodes, om, struct) {
77
124
  return nodes
78
125
  .map((node) => {
126
+ PdfBackend.assertFinite(node);
79
127
  const ops = PdfBackend.serializeNode(node, om);
80
- if (!struct || node.type === "clip-push" || node.type === "clip-pop" || ops === "")
128
+ // Two kinds of node skip the marked-content wrapper, both because they carry no tag and never
129
+ // draw: GRAPHICS-STATE nodes (clip / transform push+pop) only save/restore state around the
130
+ // drawables they enclose, and SIDE-CHANNEL nodes (link, outline, anchor) become annotations /
131
+ // catalog entries instead of content-stream operators.
132
+ if (!struct ||
133
+ node.type === "clip-push" ||
134
+ node.type === "clip-pop" ||
135
+ node.type === "transform-push" ||
136
+ node.type === "transform-pop" ||
137
+ node.type === "link" ||
138
+ node.type === "outline" ||
139
+ node.type === "anchor" ||
140
+ ops === "")
81
141
  return ops;
82
142
  const { open, close } = struct.mark(node.tag);
83
143
  return open + ops + close;
@@ -247,6 +307,22 @@ export class PdfBackend {
247
307
  }
248
308
  case "clip-pop":
249
309
  return `Q\n`;
310
+ case "transform-push": {
311
+ // Save the graphics state, then apply the affine; everything until transform-pop paints through it.
312
+ const f = (n) => n.toFixed(3);
313
+ return `q\n${node.matrix.map(f).join(" ")} cm\n`;
314
+ }
315
+ case "transform-pop":
316
+ return `Q\n`;
317
+ case "link":
318
+ // A link draws nothing in the content stream - it becomes a page /Annot (built in PageRenderer).
319
+ return "";
320
+ case "outline":
321
+ // An outline anchor draws nothing - it becomes a /Outlines entry (built in PageRenderer/PDFRenderer).
322
+ return "";
323
+ case "anchor":
324
+ // A named destination draws nothing - it becomes a /Names /Dests entry (built in PageRenderer/PDFRenderer).
325
+ return "";
250
326
  default: {
251
327
  // Exhaustiveness guard: if a new IRNode variant is added, this fails to compile.
252
328
  const unknown = node;
@@ -4,16 +4,25 @@ import { PDFObjectManager } from "../utils/pdf-object-manager.js";
4
4
  export declare class PDFDocumentRenderer {
5
5
  static render(document: PDFDocumentElement, objectManager: PDFObjectManager, ctx: LayoutContext): Promise<number>;
6
6
  /**
7
- * Renders one logical page, paginating it into one or more physical pages.
7
+ * The page geometry a physical page will use. `PageElement.calculateLayout` has already folded the
8
+ * document defaults into every page's config by the time we get here (`PDFRenderer` lays the document
9
+ * out before it calls us, and it is our only caller). Merging again is therefore a no-op today - it is
10
+ * here so this stays correct BY CONSTRUCTION rather than by call order: were that order ever changed,
11
+ * an unmerged config would otherwise make `resolvePageSize` fall back to A4 and quietly report the
12
+ * wrong page size.
13
+ */
14
+ private static configOf;
15
+ /**
16
+ * Splits one logical page into the physical pages it will occupy, without rendering.
8
17
  *
9
- * Slice 0 only auto-paginates the common shape - a page whose single child is a
10
- * fragmentation context (a Container). Anything else renders as a single page on the
11
- * unchanged path, so non-overflowing documents stay byte-identical to pre-Slice-0.
18
+ * Only the common shape auto-paginates - a page whose single child is a fragmentation context
19
+ * (a Container). Anything else stays a single page on the unchanged path, so non-overflowing
20
+ * documents keep producing byte-identical output.
12
21
  */
13
- private static renderLogicalPage;
22
+ private static paginateLogicalPage;
14
23
  /**
15
- * Lays out one fragment on a fresh page of the same geometry and renders it. The
16
- * header/footer are attached to every physical page so they repeat; `PageElement`
24
+ * Lays out one physical page and renders it. A fragment gets a fresh `PageElement` of the same
25
+ * geometry; the header/footer are attached to every physical page so they repeat, and `PageElement`
17
26
  * re-places them and the body in the band between.
18
27
  */
19
28
  private static renderPhysicalPage;
@@ -1,21 +1,36 @@
1
- import { layoutPageBands, PageElement } from "../elements/page-element.js";
1
+ import { layoutPageBands, PageElement, resolvePageSize, } from "../elements/page-element.js";
2
2
  import { BoxConstraints } from "../layout/box-constraints.js";
3
3
  import { isFragmentable } 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) {
7
- const pageNumbers = [];
8
7
  // Add the pages object first... we need its object number (resources). The count is
9
8
  // a placeholder; it is replaced below with the real (post-pagination) page count.
10
9
  const pagesObject = `<< /Type /Pages /Kids [] /Count ${document.getProps().children.length} >>`;
11
10
  const pagesObjectNumber = objectManager.addObject(pagesObject);
12
11
  // Now set the given object number all its childs
13
12
  objectManager.setParentObjectNumber(pagesObjectNumber);
14
- // The page driver: each logical PageElement may produce SEVERAL physical PDF pages
15
- // when its content overflows (Slice 0: whole children reflow to the next page).
16
- for (let page of document.getProps().children) {
17
- const numbers = await PDFDocumentRenderer.renderLogicalPage(page, objectManager, ctx);
18
- pageNumbers.push(...numbers);
13
+ // Pass A - paginate the whole document WITHOUT drawing anything. Fragmentation is synchronous and
14
+ // pure (`{ fitted, remainder }`), so the full list of physical pages can be settled up front. Only
15
+ // then is the total page count known, which is what page numbers in a header/footer need.
16
+ const physicalPages = [];
17
+ for (const page of document.getProps().children) {
18
+ physicalPages.push(...PDFDocumentRenderer.paginateLogicalPage(page, ctx));
19
+ }
20
+ // Pass B - draw them, in order, so the emitted page objects keep their previous numbering. Each page
21
+ // is laid out again with its own PageInfo, which is how a `PageBuilder` anywhere in the tree (header,
22
+ // footer or body) learns its final page number and the now-known document total.
23
+ const pageNumbers = [];
24
+ for (const [index, physical] of physicalPages.entries()) {
25
+ const pageInfo = {
26
+ pageNumber: index + 1,
27
+ pageCount: physicalPages.length,
28
+ pageSize: resolvePageSize(PDFDocumentRenderer.configOf(physical, ctx)),
29
+ };
30
+ pageNumbers.push(await PDFDocumentRenderer.renderPhysicalPage(physical, objectManager, {
31
+ ...ctx,
32
+ pageInfo,
33
+ }));
19
34
  }
20
35
  // We must update the pages object with the real physical page numbers and count.
21
36
  const updatedPagesObject = `<< /Type /Pages /Kids [${pageNumbers
@@ -26,16 +41,28 @@ export class PDFDocumentRenderer {
26
41
  return pagesObjectNumber;
27
42
  }
28
43
  /**
29
- * Renders one logical page, paginating it into one or more physical pages.
44
+ * The page geometry a physical page will use. `PageElement.calculateLayout` has already folded the
45
+ * document defaults into every page's config by the time we get here (`PDFRenderer` lays the document
46
+ * out before it calls us, and it is our only caller). Merging again is therefore a no-op today - it is
47
+ * here so this stays correct BY CONSTRUCTION rather than by call order: were that order ever changed,
48
+ * an unmerged config would otherwise make `resolvePageSize` fall back to A4 and quietly report the
49
+ * wrong page size.
50
+ */
51
+ static configOf(physical, ctx) {
52
+ const config = physical.kind === "whole" ? physical.page.getProps().config : physical.config;
53
+ return { ...ctx.pageConfig, ...config };
54
+ }
55
+ /**
56
+ * Splits one logical page into the physical pages it will occupy, without rendering.
30
57
  *
31
- * Slice 0 only auto-paginates the common shape - a page whose single child is a
32
- * fragmentation context (a Container). Anything else renders as a single page on the
33
- * unchanged path, so non-overflowing documents stay byte-identical to pre-Slice-0.
58
+ * Only the common shape auto-paginates - a page whose single child is a fragmentation context
59
+ * (a Container). Anything else stays a single page on the unchanged path, so non-overflowing
60
+ * documents keep producing byte-identical output.
34
61
  */
35
- static async renderLogicalPage(page, objectManager, ctx) {
62
+ static paginateLogicalPage(page, ctx) {
36
63
  const { children, config, header, footer } = page.getProps();
37
64
  if (children.length !== 1 || !isFragmentable(children[0])) {
38
- return [await PageRenderer.render(page, objectManager)];
65
+ return [{ kind: "whole", page }];
39
66
  }
40
67
  // Header/footer repeat on every physical page, so the body only ever flows into the
41
68
  // band between them. Resolve that band once (config is already merged by pass 1).
@@ -46,45 +73,50 @@ export class PDFDocumentRenderer {
46
73
  onOverflow: ctx.onOverflow,
47
74
  };
48
75
  const { bodyWidth: width, bodyHeight: height } = layoutPageBands(config, header, footer, pageCtx);
49
- const numbers = [];
76
+ const pages = [];
50
77
  let region = children[0];
51
78
  let isFirstRegion = true;
52
79
  while (region) {
53
80
  if (!isFragmentable(region)) {
54
81
  // A non-fragmentable remainder is placed whole on its own page.
55
- numbers.push(await PDFDocumentRenderer.renderPhysicalPage(config, region, header, footer, objectManager, ctx));
82
+ pages.push({ kind: "fragment", config, content: region, header, footer });
56
83
  break;
57
84
  }
58
- const { fitted, remainder } = region.fragment(height, width, ctx);
59
- // Everything fits on one page: render the ORIGINAL page so output is unchanged.
60
- // Measuring inside fragment() laid the children out at the origin to size them, so
61
- // re-run the page layout first to restore their real positions (deterministic).
85
+ // pageCtx, not the document ctx: a descendant that reads `pageConfig` while being measured (a
86
+ // `PageBuilder` sizing its provisional build) must see THIS page's geometry, not the document default.
87
+ const { fitted, remainder } = region.fragment(height, width, pageCtx);
88
+ // Everything fits on one page: keep the ORIGINAL page so output is unchanged. Measuring inside
89
+ // fragment() left its children at the measuring origin; pass B lays the page out again, which
90
+ // restores their real positions (layout is deterministic).
62
91
  if (isFirstRegion && remainder === null) {
63
- page.calculateLayout(new BoxConstraints(), { x: 0, y: 0 }, ctx);
64
- numbers.push(await PageRenderer.render(page, objectManager));
92
+ pages.push({ kind: "whole", page });
65
93
  break;
66
94
  }
67
95
  if (fitted) {
68
- numbers.push(await PDFDocumentRenderer.renderPhysicalPage(config, fitted, header, footer, objectManager, ctx));
96
+ pages.push({ kind: "fragment", config, content: fitted, header, footer });
69
97
  }
70
98
  region = remainder;
71
99
  isFirstRegion = false;
72
100
  }
73
- return numbers;
101
+ return pages;
74
102
  }
75
103
  /**
76
- * Lays out one fragment on a fresh page of the same geometry and renders it. The
77
- * header/footer are attached to every physical page so they repeat; `PageElement`
104
+ * Lays out one physical page and renders it. A fragment gets a fresh `PageElement` of the same
105
+ * geometry; the header/footer are attached to every physical page so they repeat, and `PageElement`
78
106
  * re-places them and the body in the band between.
79
107
  */
80
- static async renderPhysicalPage(config, content, header, footer, objectManager, ctx) {
81
- const physicalPage = new PageElement({
82
- config,
83
- header,
84
- footer,
85
- children: [content],
108
+ static async renderPhysicalPage(physical, objectManager, ctx) {
109
+ if (physical.kind === "whole") {
110
+ physical.page.calculateLayout(new BoxConstraints(), { x: 0, y: 0 }, ctx);
111
+ return PageRenderer.render(physical.page, objectManager);
112
+ }
113
+ const page = new PageElement({
114
+ config: physical.config,
115
+ header: physical.header,
116
+ footer: physical.footer,
117
+ children: [physical.content],
86
118
  });
87
- physicalPage.calculateLayout(new BoxConstraints(), { x: 0, y: 0 }, ctx);
88
- return PageRenderer.render(physicalPage, objectManager);
119
+ page.calculateLayout(new BoxConstraints(), { x: 0, y: 0 }, ctx);
120
+ return PageRenderer.render(page, objectManager);
89
121
  }
90
122
  }
@@ -21,7 +21,20 @@ import { PositionedElement } from "../elements/layout/positioned-element.js";
21
21
  import { PositionedRenderer } from "./positioned-renderer.js";
22
22
  import { StructGroup } from "../elements/layout/struct-group.js";
23
23
  import { StructGroupRenderer } from "./struct-group-renderer.js";
24
+ import { LinkElement } from "../elements/layout/link-element.js";
25
+ import { LinkRenderer } from "./link-renderer.js";
26
+ import { BookmarkElement } from "../elements/layout/bookmark-element.js";
27
+ import { BookmarkRenderer } from "./bookmark-renderer.js";
28
+ import { AnchorElement } from "../elements/layout/anchor-element.js";
29
+ import { AnchorRenderer } from "./anchor-renderer.js";
30
+ import { PageBuilderElement } from "../elements/layout/page-builder-element.js";
31
+ import { PageBuilderRenderer } from "./page-builder-renderer.js";
32
+ import { RotatedElement } from "../elements/layout/rotated-element.js";
33
+ import { RotatedBoxElement } from "../elements/layout/rotated-box-element.js";
34
+ import { RotatedRenderer } from "./rotated-renderer.js";
35
+ import { PdfBackend } from "./pdf-backend.js";
24
36
  import { BoxConstraints } from "../layout/box-constraints.js";
37
+ import { collectImageElements } from "../layout/collect-images.js";
25
38
  import { DEFAULT_TEXT_STYLE, mergeTextStyle } from "../text/text-style.js";
26
39
  export class PDFRenderer {
27
40
  static async render(document, objectManager) {
@@ -39,6 +52,12 @@ export class PDFRenderer {
39
52
  RendererRegistry.register(DeferredElement, DeferredRenderer.render);
40
53
  RendererRegistry.register(PositionedElement, PositionedRenderer.render);
41
54
  RendererRegistry.register(StructGroup, StructGroupRenderer.render);
55
+ RendererRegistry.register(LinkElement, LinkRenderer.render);
56
+ RendererRegistry.register(BookmarkElement, BookmarkRenderer.render);
57
+ RendererRegistry.register(AnchorElement, AnchorRenderer.render);
58
+ RendererRegistry.register(PageBuilderElement, PageBuilderRenderer.render);
59
+ RendererRegistry.register(RotatedElement, RotatedRenderer.render);
60
+ RendererRegistry.register(RotatedBoxElement, RotatedRenderer.render);
42
61
  let pdfContent = "";
43
62
  // Header: version line + the PDF/A binary marker (the object manager owns it so its length
44
63
  // matches the xref offset calculation).
@@ -51,6 +70,11 @@ export class PDFRenderer {
51
70
  textStyle: mergeTextStyle(DEFAULT_TEXT_STYLE, document.getDefaultTextStyle()),
52
71
  onOverflow: objectManager.getOverflowPolicy(),
53
72
  };
73
+ // Pre-layout: resolve every image's intrinsic pixel size (async), so the synchronous layout can
74
+ // give a width-only image a proportional height. init() is idempotent, so the renderer reuses it.
75
+ const images = collectImageElements(document);
76
+ if (images.length > 0)
77
+ await Promise.all(images.map((img) => img.resolveIntrinsicSize()));
54
78
  document.calculateLayout(new BoxConstraints(), { x: 0, y: 0 }, ctx);
55
79
  // Render pages and contents (the driver paginates overflowing pages).
56
80
  await PDFDocumentRenderer.render(document, objectManager, ctx);
@@ -66,11 +90,20 @@ export class PDFRenderer {
66
90
  if (outputIntent) {
67
91
  catalogParts.push(`/OutputIntents [${outputIntent} 0 R]`);
68
92
  }
93
+ // The catalog has exactly one /Names dict; embedded files and named destinations are two entries in
94
+ // it, so collect both and emit the /Names once (below).
95
+ const namesParts = [];
69
96
  const attachments = objectManager.getAttachments();
70
97
  if (attachments.length > 0) {
71
- const names = attachments.map((a) => `(${a.name}) ${a.filespec} 0 R`).join(" ");
98
+ // The name-tree key is a PDF literal string, so it needs the same escaping the /Filespec's /F and
99
+ // /UF get in attachFile(). Unescaped, a ")" or "\" in a file name closes the string early and the
100
+ // rest of it leaks out as raw operators.
101
+ const names = attachments
102
+ .map((a) => `(${PdfBackend.escapePdfString(a.name)}) ${a.filespec} 0 R`)
103
+ .join(" ");
72
104
  const af = attachments.map((a) => `${a.filespec} 0 R`).join(" ");
73
- catalogParts.push(`/AF [${af}]`, `/Names << /EmbeddedFiles << /Names [${names}] >> >>`);
105
+ catalogParts.push(`/AF [${af}]`);
106
+ namesParts.push(`/EmbeddedFiles << /Names [${names}] >>`);
74
107
  }
75
108
  // Accessible tagging: finalize the structure tree (emits StructTreeRoot + StructElems + ParentTree) and
76
109
  // fold /MarkInfo + /StructTreeRoot + /Lang into the catalog. Returns "" (no-op) when tagging is off.
@@ -80,6 +113,18 @@ export class PDFRenderer {
80
113
  // PDF/UA requires the viewer to show the document title (not the file name).
81
114
  catalogParts.push("/ViewerPreferences << /DisplayDocTitle true >>");
82
115
  }
116
+ // Document outline (bookmarks): emit the /Outlines tree collected during page rendering. Returns
117
+ // "" (no-op) when no Bookmark was placed, so a plain document's catalog is unchanged.
118
+ const outlineCatalog = objectManager.outline.finalize(objectManager);
119
+ if (outlineCatalog)
120
+ catalogParts.push(outlineCatalog);
121
+ // Named destinations (internal-link targets): a /Dests entry in the shared /Names dict.
122
+ const destsNames = objectManager.dests.finalize();
123
+ if (destsNames)
124
+ namesParts.push(destsNames);
125
+ // Emit the single /Names dict if either embedded files or destinations contributed to it.
126
+ if (namesParts.length > 0)
127
+ catalogParts.push(`/Names << ${namesParts.join(" ")} >>`);
83
128
  const catalogObject = `<< ${catalogParts.join(" ")} >>`;
84
129
  objectManager.addObject(catalogObject);
85
130
  // Now that the render pass has revealed which glyphs each embedded font uses, fill the reserved
@@ -0,0 +1,7 @@
1
+ import { PDFObjectManager } from "../utils/pdf-object-manager.js";
2
+ import { RotatedElement } from "../elements/layout/rotated-element.js";
3
+ import { RotatedBoxElement } from "../elements/layout/rotated-box-element.js";
4
+ import { IRNode } from "../ir/display-list.js";
5
+ export declare class RotatedRenderer {
6
+ static render(element: RotatedElement | RotatedBoxElement, objectManager: PDFObjectManager): Promise<IRNode[]>;
7
+ }
@@ -0,0 +1,29 @@
1
+ import { RendererRegistry } from "../utils/renderer-registry.js";
2
+ export class RotatedRenderer {
3
+ // Both `Rotated` (paint-only) and `RotatedBox` (layout-aware quarter-turns) expose the same
4
+ // `{ angle, child, x, y, width, height }` and rotate the child around the box center - shared here.
5
+ static async render(element, objectManager) {
6
+ const { angle, child, x, y, width, height } = element.getProps();
7
+ // Rotate around the box CENTER, in the engine's top-left coordinates (the IR->backend seam flips
8
+ // the matrix to PDF space per page). A positive angle reads clockwise on the page.
9
+ const cx = x + width / 2;
10
+ const cy = y + height / 2;
11
+ const theta = (angle * Math.PI) / 180;
12
+ const cos = Math.cos(theta);
13
+ const sin = Math.sin(theta);
14
+ // M = T(cx,cy) · R(theta) · T(-cx,-cy) as a PDF cm [a,b,c,d,e,f]: the linear part is the rotation,
15
+ // the translation keeps the center fixed.
16
+ const matrix = [
17
+ cos,
18
+ sin,
19
+ -sin,
20
+ cos,
21
+ cx - cos * cx + sin * cy,
22
+ cy - sin * cx - cos * cy,
23
+ ];
24
+ const renderer = RendererRegistry.getRenderer(child);
25
+ const childNodes = renderer ? await renderer(child, objectManager) : [];
26
+ // Wrap the child's drawing between a push (apply the matrix) and a pop (restore).
27
+ return [{ type: "transform-push", matrix }, ...childNodes, { type: "transform-pop" }];
28
+ }
29
+ }
@@ -24,7 +24,7 @@ export class TextRenderer {
24
24
  // Component -> display list. Wrapping and positioning stay here; the backend
25
25
  // turns each run into BT/Tf/Td/Tj/ET. The wrapping algorithm is unchanged from
26
26
  // the original renderer - unifying it into the engine is Phase 3.
27
- const runs = TextRenderer._buildRuns(content, fontSize, fontFamily, fontStyle, objectManager, width ?? Number.NaN, textAlignment, color, x, y, maxLines, overflow, lineHeight);
27
+ const { runs, links } = TextRenderer._buildRuns(content, fontSize, fontFamily, fontStyle, objectManager, width ?? Number.NaN, textAlignment, color, x, y, maxLines, overflow, lineHeight);
28
28
  // Accessible tagging: this whole text block is one structure element (a paragraph P, or a heading
29
29
  // from `role`); each run's text lands under it. Emoji sub-nodes produced below stay untagged (drawn
30
30
  // as artifacts). Keyed by structId so a block split across pages stays one element.
@@ -36,7 +36,10 @@ export class TextRenderer {
36
36
  }
37
37
  // Color fonts (COLR/CPAL): expand each run's emoji into filled vector layers (or fetched images
38
38
  // for an image emoji source); plain runs and monochrome fonts pass straight through unchanged.
39
- return (await Promise.all(runs.map((run) => TextRenderer._expandColorGlyphs(run, objectManager)))).flat();
39
+ const drawn = (await Promise.all(runs.map((run) => TextRenderer._expandColorGlyphs(run, objectManager)))).flat();
40
+ // Inline hyperlinks (href spans) ride along as /Link annotations - they draw nothing, so order
41
+ // vs the drawn runs does not matter; the page renderer peels them off into /Annots.
42
+ return links.length > 0 ? [...drawn, ...links] : drawn;
40
43
  }
41
44
  // Splits a text run into normal text sub-runs + filled `Path` layers for any COLR color glyphs,
42
45
  // walking code point by code point and advancing the pen by each glyph's width. Returns `[run]`
@@ -208,6 +211,9 @@ export class TextRenderer {
208
211
  // only the output form changed from PDF strings to `TextRun`s.
209
212
  static _buildRuns(content, fontSize, fontFamily, fontStyle, objectManager, maxWidth, textAlignment, color, initialX, yPosition, maxLines, overflow, lineHeight = 1) {
210
213
  const runs = [];
214
+ // /Link annotation rects for any `href` spans, collected as we place segments. Empty for the
215
+ // common (no-href) case, so plain text keeps producing exactly the runs it did before.
216
+ const links = [];
211
217
  // Horizontal offset of a line of the given width under the current alignment.
212
218
  const alignmentOffset = (lineWidth) => {
213
219
  if (textAlignment === HorizontalAlignment.center)
@@ -248,7 +254,7 @@ export class TextRenderer {
248
254
  color,
249
255
  });
250
256
  });
251
- return runs;
257
+ return { runs, links };
252
258
  }
253
259
  // --- Segments: break into lines (shared breaker), then emit one run per segment.
254
260
  // Segment 0 starts at the aligned line origin; each following segment is offset by
@@ -261,6 +267,7 @@ export class TextRenderer {
261
267
  const family = segment.fontFamily || fontFamily;
262
268
  const size = segment.fontSize || fontSize;
263
269
  const style = segment.fontStyle || fontStyle;
270
+ const advance = advanceNoKerning(segment.content, family, size, style);
264
271
  runs.push({
265
272
  type: "text",
266
273
  x,
@@ -271,7 +278,21 @@ export class TextRenderer {
271
278
  fontSize: size,
272
279
  color: segment.fontColor || color,
273
280
  });
274
- x += advanceNoKerning(segment.content, family, size, style);
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.
284
+ if (segment.href !== undefined || segment.dest !== undefined) {
285
+ links.push({
286
+ type: "link",
287
+ x,
288
+ y: lineY - size * BASELINE_RATIO,
289
+ width: advance,
290
+ height: size,
291
+ href: segment.href,
292
+ dest: segment.dest,
293
+ });
294
+ }
295
+ x += advance;
275
296
  });
276
297
  };
277
298
  // The overall tallest font seats the first baseline; each line then steps DOWN by
@@ -291,6 +312,6 @@ export class TextRenderer {
291
312
  pushLine(line, lineY + halfLeading);
292
313
  lineY += line.maxFontSize * lineHeight;
293
314
  }
294
- return runs;
315
+ return { runs, links };
295
316
  }
296
317
  }