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

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 (105) 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 +24 -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 +112 -17
  9. package/dist/api/layout.js +118 -13
  10. package/dist/api/page-builder.d.ts +32 -0
  11. package/dist/api/page-builder.js +32 -0
  12. package/dist/api/structure.d.ts +5 -0
  13. package/dist/api/structure.js +1 -0
  14. package/dist/api/text.d.ts +24 -2
  15. package/dist/api/text.js +46 -4
  16. package/dist/elements/container-element.d.ts +26 -1
  17. package/dist/elements/container-element.js +79 -29
  18. package/dist/elements/image-element.d.ts +19 -1
  19. package/dist/elements/image-element.js +52 -11
  20. package/dist/elements/index.d.ts +8 -0
  21. package/dist/elements/index.js +8 -0
  22. package/dist/elements/layout/anchor-element.d.ts +23 -0
  23. package/dist/elements/layout/anchor-element.js +27 -0
  24. package/dist/elements/layout/bookmark-element.d.ts +27 -0
  25. package/dist/elements/layout/bookmark-element.js +30 -0
  26. package/dist/elements/layout/keep-together-element.d.ts +42 -0
  27. package/dist/elements/layout/keep-together-element.js +85 -0
  28. package/dist/elements/layout/link-element.d.ts +33 -0
  29. package/dist/elements/layout/link-element.js +39 -0
  30. package/dist/elements/layout/padding-element.d.ts +3 -0
  31. package/dist/elements/layout/padding-element.js +12 -0
  32. package/dist/elements/layout/page-break-element.d.ts +17 -0
  33. package/dist/elements/layout/page-break-element.js +24 -0
  34. package/dist/elements/layout/page-builder-element.d.ts +32 -0
  35. package/dist/elements/layout/page-builder-element.js +43 -0
  36. package/dist/elements/layout/positioned-element.d.ts +10 -7
  37. package/dist/elements/layout/positioned-element.js +21 -9
  38. package/dist/elements/layout/rotated-box-element.d.ts +35 -0
  39. package/dist/elements/layout/rotated-box-element.js +54 -0
  40. package/dist/elements/layout/rotated-element.d.ts +32 -0
  41. package/dist/elements/layout/rotated-element.js +41 -0
  42. package/dist/elements/page-element.d.ts +19 -1
  43. package/dist/elements/page-element.js +54 -17
  44. package/dist/elements/pdf-element.d.ts +59 -0
  45. package/dist/elements/pdf-element.js +51 -0
  46. package/dist/elements/rectangle-element.d.ts +20 -1
  47. package/dist/elements/rectangle-element.js +75 -18
  48. package/dist/elements/row-element.d.ts +19 -1
  49. package/dist/elements/row-element.js +45 -8
  50. package/dist/elements/text-element.d.ts +34 -4
  51. package/dist/elements/text-element.js +40 -17
  52. package/dist/ir/display-list.d.ts +60 -1
  53. package/dist/layout/box-constraints.d.ts +8 -0
  54. package/dist/layout/box-constraints.js +14 -0
  55. package/dist/layout/collect-images.d.ts +9 -0
  56. package/dist/layout/collect-images.js +34 -0
  57. package/dist/layout/fragmentation.d.ts +12 -0
  58. package/dist/layout/fragmentation.js +45 -8
  59. package/dist/renderer/anchor-renderer.d.ts +6 -0
  60. package/dist/renderer/anchor-renderer.js +11 -0
  61. package/dist/renderer/bookmark-renderer.d.ts +6 -0
  62. package/dist/renderer/bookmark-renderer.js +11 -0
  63. package/dist/renderer/keep-together-renderer.d.ts +10 -0
  64. package/dist/renderer/keep-together-renderer.js +12 -0
  65. package/dist/renderer/link-renderer.d.ts +6 -0
  66. package/dist/renderer/link-renderer.js +11 -0
  67. package/dist/renderer/page-break-renderer.d.ts +14 -0
  68. package/dist/renderer/page-break-renderer.js +15 -0
  69. package/dist/renderer/page-builder-renderer.d.ts +6 -0
  70. package/dist/renderer/page-builder-renderer.js +11 -0
  71. package/dist/renderer/page-renderer.js +37 -2
  72. package/dist/renderer/pdf-backend.d.ts +17 -0
  73. package/dist/renderer/pdf-backend.js +117 -7
  74. package/dist/renderer/pdf-document-class.js +3 -0
  75. package/dist/renderer/pdf-document-renderer.d.ts +16 -7
  76. package/dist/renderer/pdf-document-renderer.js +87 -36
  77. package/dist/renderer/pdf-renderer.js +53 -2
  78. package/dist/renderer/rotated-renderer.d.ts +7 -0
  79. package/dist/renderer/rotated-renderer.js +29 -0
  80. package/dist/renderer/text-renderer.d.ts +1 -1
  81. package/dist/renderer/text-renderer.js +123 -54
  82. package/dist/text/advance.d.ts +34 -0
  83. package/dist/text/advance.js +42 -0
  84. package/dist/text/line-breaker.d.ts +9 -8
  85. package/dist/text/line-breaker.js +28 -21
  86. package/dist/text/line-metrics.d.ts +49 -0
  87. package/dist/text/line-metrics.js +57 -0
  88. package/dist/text/text-decoration.d.ts +50 -0
  89. package/dist/text/text-decoration.js +62 -0
  90. package/dist/text/text-style.d.ts +13 -3
  91. package/dist/text/text-style.js +10 -3
  92. package/dist/utils/afm-parser.d.ts +34 -0
  93. package/dist/utils/afm-parser.js +73 -4
  94. package/dist/utils/dest-registry.d.ts +19 -0
  95. package/dist/utils/dest-registry.js +32 -0
  96. package/dist/utils/flex-layout.d.ts +10 -3
  97. package/dist/utils/flex-layout.js +26 -6
  98. package/dist/utils/font-metrics.d.ts +14 -0
  99. package/dist/utils/outline.d.ts +21 -0
  100. package/dist/utils/outline.js +80 -0
  101. package/dist/utils/pdf-object-manager.d.ts +33 -1
  102. package/dist/utils/pdf-object-manager.js +0 -0
  103. package/dist/utils/ttf-parser.d.ts +53 -0
  104. package/dist/utils/ttf-parser.js +368 -0
  105. package/package.json +1 -1
@@ -2,6 +2,7 @@ import { ImageElement, CustomImage } from "../elements/image-element.js";
2
2
  import { PDFElement } from "../elements/pdf-element.js";
3
3
  import { ColorInput } from "./color.js";
4
4
  import { Insets } from "./insets.js";
5
+ import { SizeInput } from "./dimension.js";
5
6
  /** A horizontal rule (locked §4). */
6
7
  export interface DividerOptions {
7
8
  /** Line color (default a light gray). */
@@ -22,9 +23,11 @@ export type ImageSource = string | Uint8Array | CustomImage;
22
23
  /** How the image fills its box (locked §4). Mirrors CSS `object-fit`. */
23
24
  export type ImageFit = "none" | "contain" | "cover" | "fill";
24
25
  export interface ImageOptions {
25
- width?: number;
26
- height?: number;
27
- /** Fit within the box (default `none`). */
26
+ /** Size on each axis: points (fixed) or a percentage string like `"50%"` (a fraction of the offered
27
+ * space). Pin exactly ONE axis and the other follows the image's aspect ratio (CSS `height: auto`). */
28
+ width?: SizeInput;
29
+ height?: SizeInput;
30
+ /** Fit within the box (default `none`; `fill` when exactly one axis is pinned so it scales to fit). */
28
31
  fit?: ImageFit;
29
32
  /** Corner radius in points (rounds the image box). */
30
33
  radius?: number;
@@ -3,6 +3,7 @@ import { PaddingElement } from "../elements/layout/padding-element.js";
3
3
  import { ImageElement, CustomLocalImage, CustomBytesImage, BoxFit, } from "../elements/image-element.js";
4
4
  import { toColor } from "./color.js";
5
5
  import { toEdges } from "./insets.js";
6
+ import { toDimension } from "./dimension.js";
6
7
  const DEFAULT_DIVIDER_COLOR = "lightgray";
7
8
  const DEFAULT_DIVIDER_MARGIN = { y: 6 };
8
9
  /**
@@ -35,15 +36,23 @@ const FIT = {
35
36
  * `CustomImage` for non-filesystem sources. Maps to an `ImageElement`.
36
37
  */
37
38
  export function Image(src, opts = {}) {
39
+ const w = opts.width !== undefined ? toDimension(opts.width) : undefined;
40
+ const h = opts.height !== undefined ? toDimension(opts.height) : undefined;
41
+ // Exactly one axis pinned -> the other is derived from the aspect ratio, so scale the image to the
42
+ // resulting box (fit: fill). Both or neither pinned keeps the default fit (none).
43
+ const autoScale = (opts.width !== undefined) !== (opts.height !== undefined);
44
+ const fit = opts.fit ? FIT[opts.fit] : autoScale ? BoxFit.fill : undefined;
38
45
  return new ImageElement({
39
46
  image: typeof src === "string"
40
47
  ? new CustomLocalImage(src)
41
48
  : src instanceof Uint8Array
42
49
  ? new CustomBytesImage(src)
43
50
  : src,
44
- width: opts.width,
45
- height: opts.height,
46
- fit: opts.fit ? FIT[opts.fit] : undefined,
51
+ width: w?.points,
52
+ height: h?.points,
53
+ widthFactor: w?.factor,
54
+ heightFactor: h?.factor,
55
+ fit,
47
56
  radius: opts.radius,
48
57
  alt: opts.alt,
49
58
  });
@@ -1,7 +1,8 @@
1
1
  import { Text, Paragraph, span } from "./text.js";
2
- import { Column, Row, Box, Padding, Spacer, Expanded, Positioned } from "./layout.js";
2
+ import { Column, Row, Box, Padding, Spacer, PageBreak, keepTogether, Expanded, Positioned, Link, Anchor, Bookmark, Rotated, RotatedBox, } from "./layout.js";
3
3
  import { Divider, Image } from "./content.js";
4
4
  import { Page, Document, DefaultTextStyle } from "./structure.js";
5
+ import { PageNumber, PageCount } from "./page-builder.js";
5
6
  import { Table } from "./table.js";
6
7
  // Element children (Column/Row/Box/Page/…) → built recursively. Text/Paragraph children are
7
8
  // strings or `span` descriptors and become content instead.
@@ -50,6 +51,14 @@ function slotElement(node) {
50
51
  const els = (node.children ?? []).map((c) => (typeof c === "string" ? Text(c) : build(c)));
51
52
  return els.length === 1 ? els[0] : Column(els);
52
53
  }
54
+ // The single child a wrapper takes (`Link`, `Anchor`, `Bookmark`, `Rotated`, ...). Several children are
55
+ // stacked in a Column, so `<JasyLink><Text/><Text/></JasyLink>` behaves like the factory would.
56
+ function wrappedChild(children) {
57
+ const els = children.map((c) => (typeof c === "string" ? Text(c) : build(c)));
58
+ if (els.length === 0)
59
+ throw new Error("A wrapper element needs exactly one child");
60
+ return els.length === 1 ? els[0] : Column(els);
61
+ }
53
62
  const REGISTRY = {
54
63
  document: (props, children) => {
55
64
  const doc = Document(props, elementChildren(children));
@@ -76,6 +85,8 @@ const REGISTRY = {
76
85
  padding: (props, children) => Padding(props.insets ?? 0, elementChildren(children)[0]),
77
86
  expanded: (props, children) => Expanded(props, elementChildren(children)[0]),
78
87
  spacer: (props) => Spacer(props?.flex),
88
+ "page-break": () => PageBreak(),
89
+ "keep-together": (_props, children) => keepTogether(elementChildren(children)),
79
90
  divider: (props) => Divider(props),
80
91
  image: (props) => Image(props.src, props),
81
92
  text: (props, children) => Text(textContent(children), props),
@@ -89,6 +100,18 @@ const REGISTRY = {
89
100
  },
90
101
  positioned: (props, children) => Positioned(props, elementChildren(children)[0]),
91
102
  "default-text-style": (props, children) => DefaultTextStyle(props, elementChildren(children)),
103
+ // Navigation. `Link` takes exactly one of `href` (a URL) or `to` (an `Anchor` name) - the factory
104
+ // enforces that, so a template typo surfaces as an error instead of a dead link.
105
+ link: (props, children) => Link(props, wrappedChild(children)),
106
+ anchor: (props, children) => Anchor(props, wrappedChild(children)),
107
+ bookmark: (props, children) => Bookmark(props, wrappedChild(children)),
108
+ // Transforms.
109
+ rotated: (props, children) => Rotated(props, wrappedChild(children)),
110
+ "rotated-box": (props, children) => RotatedBox(props, wrappedChild(children)),
111
+ // Page numbers. `PageBuilder` itself is deliberately NOT exposed: it takes a closure, which a template
112
+ // cannot express. These two cover what the closure exists for.
113
+ "page-number": (props) => PageNumber(props),
114
+ "page-count": (props) => PageCount(props),
92
115
  };
93
116
  /**
94
117
  * Registers a custom element type, so a binding (or a user-defined component) can introduce
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Size input for a Box's `width` / `height` (relative sizing). Either a number of PDF points (a
3
+ * fixed size) or a percentage string like `"50%"` - a fraction of the space the parent offers on
4
+ * that axis. Every form normalizes to a `Dimension` via `toDimension`, so the engine only ever sees
5
+ * points and factors, never a string. A percentage resolves against the parent's offered extent, so
6
+ * it only has meaning inside a bounded region (a fraction of an unbounded axis is a no-op).
7
+ */
8
+ export type SizeInput = number | `${number}%`;
9
+ /** A resolved `SizeInput`: exactly one of an absolute point size or a 0..1 fraction of the parent. */
10
+ export interface Dimension {
11
+ /** Absolute size in PDF points. */
12
+ points?: number;
13
+ /** Fraction of the parent's offered extent on this axis; a `"50%"` input is `0.5`. */
14
+ factor?: number;
15
+ }
16
+ /**
17
+ * Normalizes a `SizeInput` to a `Dimension` (a point size or a fraction). Rejects negatives at this
18
+ * boundary so `resolveExtent` and the layout never see a negative size.
19
+ */
20
+ export declare function toDimension(value: SizeInput): Dimension;
@@ -0,0 +1,16 @@
1
+ const PERCENT = /^(\d+(?:\.\d+)?)%$/; // no leading "-": a size cannot be negative
2
+ /**
3
+ * Normalizes a `SizeInput` to a `Dimension` (a point size or a fraction). Rejects negatives at this
4
+ * boundary so `resolveExtent` and the layout never see a negative size.
5
+ */
6
+ export function toDimension(value) {
7
+ if (typeof value === "number") {
8
+ if (value < 0)
9
+ throw new Error(`Invalid size ${value}: a size cannot be negative.`);
10
+ return { points: value };
11
+ }
12
+ const m = PERCENT.exec(value.trim());
13
+ if (m)
14
+ return { factor: parseFloat(m[1]) / 100 };
15
+ throw new Error(`Invalid size "${value}": use a number of points or a percentage like "50%".`);
16
+ }
@@ -1,8 +1,10 @@
1
1
  export * from "./color.js";
2
2
  export * from "./insets.js";
3
+ export * from "./dimension.js";
3
4
  export * from "./text.js";
4
5
  export * from "./layout.js";
5
6
  export * from "./content.js";
7
+ export * from "./page-builder.js";
6
8
  export * from "./structure.js";
7
9
  export * from "./table.js";
8
10
  export * from "./descriptor.js";
package/dist/api/index.js CHANGED
@@ -3,9 +3,11 @@
3
3
  // core classes stay untouched and remain exported for power users.
4
4
  export * from "./color.js";
5
5
  export * from "./insets.js";
6
+ export * from "./dimension.js";
6
7
  export * from "./text.js";
7
8
  export * from "./layout.js";
8
9
  export * from "./content.js";
10
+ export * from "./page-builder.js";
9
11
  export * from "./structure.js";
10
12
  export * from "./table.js";
11
13
  export * from "./descriptor.js";
@@ -1,13 +1,18 @@
1
- import { ContainerElement } from "../elements/container-element.js";
2
- import { RowElement } from "../elements/row-element.js";
3
- import { RectangleElement } from "../elements/rectangle-element.js";
4
1
  import { ExpandedElement } from "../elements/layout/expanded-element.js";
5
2
  import { PaddingElement } from "../elements/layout/padding-element.js";
3
+ import { PageBreakElement } from "../elements/layout/page-break-element.js";
4
+ import { KeepTogetherElement } from "../elements/layout/keep-together-element.js";
6
5
  import { PositionedElement, PositionedInsets } from "../elements/layout/positioned-element.js";
6
+ import { LinkElement } from "../elements/layout/link-element.js";
7
+ import { BookmarkElement } from "../elements/layout/bookmark-element.js";
8
+ import { AnchorElement } from "../elements/layout/anchor-element.js";
9
+ import { RotatedElement } from "../elements/layout/rotated-element.js";
10
+ import { RotatedBoxElement } from "../elements/layout/rotated-box-element.js";
7
11
  import { PDFElement } from "../elements/pdf-element.js";
8
12
  import { MainAlign, CrossAlign } from "../utils/flex-layout.js";
9
13
  import { ColorInput } from "./color.js";
10
14
  import { Insets } from "./insets.js";
15
+ import { SizeInput } from "./dimension.js";
11
16
  /** Options shared by the `Column` and `Row` stacks (locked §4). */
12
17
  export interface StackOptions {
13
18
  /** Space inserted between children, in points. */
@@ -17,13 +22,33 @@ export interface StackOptions {
17
22
  justify?: MainAlign;
18
23
  /** Position across the axis (CSS `align-items`): start (default) · center · end · stretch. */
19
24
  align?: CrossAlign;
25
+ /** Size on each axis: points (fixed) or a percentage string like `"50%"` (a fraction of the offered
26
+ * space). Omit to fill the offered width / shrink-wrap to the children. A percentage needs a bounded
27
+ * axis (`height: "50%"` only resolves where the parent bounds the height). */
28
+ width?: SizeInput;
29
+ height?: SizeInput;
30
+ /** Start this stack on a fresh page (CSS `break-before: page`, react-pdf's `break`). Ignored at the
31
+ * top of a page. */
32
+ breakBefore?: boolean;
33
+ /** Start everything after this stack on a fresh page (CSS `break-after: page`). */
34
+ breakAfter?: boolean;
35
+ /** Try to keep this stack on one page (CSS `break-inside: avoid`). If it does not fit here but would
36
+ * fit on a fresh page, it moves there whole; if it is taller than a whole page, it splits anyway. */
37
+ keepTogether?: boolean;
20
38
  }
21
39
  /** A vertical stack. `Column(children)` or `Column(opts, children)`. */
22
- export declare function Column(children: PDFElement[]): ContainerElement;
23
- export declare function Column(opts: StackOptions, children: PDFElement[]): ContainerElement;
40
+ export declare function Column(children: PDFElement[]): PDFElement;
41
+ export declare function Column(opts: StackOptions, children: PDFElement[]): PDFElement;
24
42
  /** A horizontal stack. `Row(children)` or `Row(opts, children)`. */
25
- export declare function Row(children: PDFElement[]): RowElement;
26
- export declare function Row(opts: StackOptions, children: PDFElement[]): RowElement;
43
+ export declare function Row(children: PDFElement[]): PDFElement;
44
+ export declare function Row(opts: StackOptions, children: PDFElement[]): PDFElement;
45
+ /**
46
+ * Keep a group of elements on ONE page (CSS `break-inside: avoid`). `keepTogether([...])` stacks the
47
+ * children in a `Column` and refuses to split them across a page break - unless they do not fit on a
48
+ * fresh page at all, in which case they split anyway (so pagination always terminates). Equivalent to
49
+ * the `keepTogether` prop on `Box`/`Column`/`Row`.
50
+ */
51
+ export declare function keepTogether(children: PDFElement[]): KeepTogetherElement;
27
52
  /** A bordered / filled box that wraps its children (locked §4). */
28
53
  export interface BoxOptions {
29
54
  /** Border (stroke) color. A box has a border only when `border` or `borderWidth` is set. */
@@ -41,9 +66,11 @@ export interface BoxOptions {
41
66
  bg?: ColorInput;
42
67
  /** Inner padding between the border and the children. */
43
68
  padding?: Insets;
44
- /** Fixed size; omit to fill the offered width and shrink-wrap the height. */
45
- width?: number;
46
- height?: number;
69
+ /** Size on each axis: a number of points (fixed) or a percentage string like `"50%"` (a fraction
70
+ * of the space the parent offers on that axis). Omit `width` to fill the offered width, omit
71
+ * `height` to shrink-wrap the content. A percentage only resolves in a bounded region. */
72
+ width?: SizeInput;
73
+ height?: SizeInput;
47
74
  /** Corner radius in points. */
48
75
  radius?: number;
49
76
  /** Make this box a positioning frame: `Positioned` children placed inside it resolve their
@@ -52,29 +79,97 @@ export interface BoxOptions {
52
79
  /** `"hidden"` crops children to the box (rounded corners included); `"visible"` (default) lets a
53
80
  * `Positioned` child spill over the edge. */
54
81
  overflow?: "hidden" | "visible";
82
+ /** Start this box on a fresh page (CSS `break-before: page`, react-pdf's `break`). Ignored at the top
83
+ * of a page. */
84
+ breakBefore?: boolean;
85
+ /** Start everything after this box on a fresh page (CSS `break-after: page`). */
86
+ breakAfter?: boolean;
87
+ /** Try to keep this box on one page (CSS `break-inside: avoid`). Moves whole to a fresh page if it
88
+ * would fit there; splits anyway if it is taller than a whole page. */
89
+ keepTogether?: boolean;
55
90
  }
56
91
  /**
57
92
  * A box: maps to a `RectangleElement` (fill + border + radius) whose children are stacked
58
93
  * inside it, optionally inset by `padding` (a `PaddingElement` wrapping a `Column` of the
59
94
  * children). With no `border`/`borderWidth` the box has no outline (`borderWidth` 0).
60
95
  */
61
- export declare function Box(children: PDFElement[]): RectangleElement;
62
- export declare function Box(opts: BoxOptions, children: PDFElement[]): RectangleElement;
96
+ export declare function Box(children: PDFElement[]): PDFElement;
97
+ export declare function Box(opts: BoxOptions, children: PDFElement[]): PDFElement;
63
98
  /** Insets a single child by `padding` (a number / `{x,y}` / `{top,…}` / 4-tuple). */
64
99
  export declare function Padding(padding: Insets, child: PDFElement): PaddingElement;
65
100
  /**
66
- * Places a child OUT OF FLOW, relative to the nearest enclosing `relative` Box. Two ways, pick per
67
- * axis: pin to EDGES - `Positioned({ top, left, right, bottom }, child)`, where a negative value
68
- * pokes into / out of the corner (a badge, a tab, a ribbon) and pinning both sides stretches; or
69
- * ANCHOR + nudge - `Positioned({ h: "center", v: "end", x: -10, y: -8 }, child)`, i.e. centered /
70
- * end-aligned with a pixel offset. An edge wins over an anchor on the same axis.
101
+ * Places a child OUT OF FLOW, relative to the nearest enclosing `relative` Box - or, with none, to
102
+ * the page's content box. Two ways, pick per axis: pin to EDGES - `Positioned({ top, left, right,
103
+ * bottom }, child)`, where a negative value pokes into / out of the corner (a badge, a tab, a
104
+ * ribbon) and pinning both sides stretches; or ANCHOR + nudge - `Positioned({ h: "center", v: "end",
105
+ * x: -10, y: -8 }, child)`, i.e. centered / end-aligned with a pixel offset. An edge wins over an
106
+ * anchor on the same axis.
107
+ *
108
+ * A `Positioned` in a `Page`'s `header` or `footer` repeats on EVERY page and takes no space in the
109
+ * band, which is how you draw a watermark, a draft stamp or a corner ribbon across a document. It
110
+ * anchors to the page, not to the band, so `bottom: 0` is the foot of the page.
71
111
  */
72
112
  export declare function Positioned(opts: PositionedInsets, child: PDFElement): PositionedElement;
113
+ /**
114
+ * Makes any `child` a clickable hyperlink - the child's whole box is the clickable region (an image, a
115
+ * box, a row). Pass `href` for an external URL, or `to` for an internal jump to an `Anchor({ name })`
116
+ * elsewhere in the document (e.g. a clickable table-of-contents row). Exactly one of the two. The link
117
+ * draws nothing itself, so style the child - e.g. `Link({ href }, Text("jasy.dev", { color: "#1450aa" }))`
118
+ * or `Link({ to: "chapter-2" }, Text("Chapter 2 ....... 5"))`. For a link on part of a line, put `href`/
119
+ * `to` on a `span` instead (`Text([span("Visit "), span("jasy.dev", { href })])`), linking just that run.
120
+ */
121
+ export declare function Link(opts: {
122
+ href?: string;
123
+ to?: string;
124
+ }, child: PDFElement): LinkElement;
125
+ /**
126
+ * Marks `child` as a named jump target that an internal `Link({ to: name })` can point at (a table of
127
+ * contents linking to sections, cross-references). Layout-transparent: `child` renders exactly as it
128
+ * would on its own, on whatever page it lands on. `name` must match the link's `to`.
129
+ * `Anchor({ name: "chapter-2" }, Text("Chapter 2", { size: 24 }))`.
130
+ */
131
+ export declare function Anchor(opts: {
132
+ name: string;
133
+ }, child: PDFElement): AnchorElement;
134
+ /**
135
+ * Adds a bookmark to the document outline (the viewer's sidebar) that jumps to `child`. `title` is the
136
+ * label; `level` (1-based, default 1) nests it - a `level: 2` bookmark hangs under the nearest preceding
137
+ * `level: 1`, so you get a collapsible tree (chapters -> sections). It is layout-transparent: `child`
138
+ * renders exactly as it would on its own. `Bookmark({ title: "Chapter 2", level: 1 }, Text("Chapter 2"))`.
139
+ */
140
+ export declare function Bookmark(opts: {
141
+ title: string;
142
+ level?: number;
143
+ }, child: PDFElement): BookmarkElement;
144
+ /**
145
+ * Rotates `child` at PAINT time by `angle` degrees (clockwise), around its center - like CSS
146
+ * `transform: rotate()` / Flutter `Transform.rotate`. The child keeps its normal, unrotated layout box,
147
+ * so siblings do NOT reflow around the spun shape and the drawing may overflow its slot. Pair it with a
148
+ * `relative` Box + `Positioned` for a diagonal watermark or a "PAID" stamp laid over a document.
149
+ */
150
+ export declare function Rotated(opts: {
151
+ angle: number;
152
+ }, child: PDFElement): RotatedElement;
153
+ /**
154
+ * Rotates `child` by whole quarter-turns and, unlike `Rotated`, is LAYOUT-AWARE: a 90 / 270 turn swaps
155
+ * the box's width and height so siblings reflow around it (a tall label becomes a narrow, tall strip
156
+ * that reserves exactly that footprint). `turns` = clockwise 90-degree steps (1 = 90, 2 = 180, 3 = 270).
157
+ * Use it for a vertical label beside a table; use `Rotated` for a free-angle stamp / watermark.
158
+ */
159
+ export declare function RotatedBox(opts: {
160
+ turns: number;
161
+ }, child: PDFElement): RotatedBoxElement;
73
162
  /**
74
163
  * A flexible empty gap that pushes its siblings apart - `Row([a, Spacer(), b])` sends `a`
75
164
  * and `b` to the edges. `flex` weights it against other flex children (default 1).
76
165
  */
77
166
  export declare function Spacer(flex?: number): ExpandedElement;
167
+ /**
168
+ * A forced page break. Put it in a `Column` (or any vertical flow) and everything AFTER it starts on
169
+ * a fresh page, no matter how much space is left: `Column([intro, PageBreak(), chapterTwo])`. It takes
170
+ * no space and draws nothing. Works nested, and a break wins over `keepTogether`.
171
+ */
172
+ export declare function PageBreak(): PageBreakElement;
78
173
  export interface ExpandedOptions {
79
174
  /** Share of the leftover space vs other flex children (default 1). */
80
175
  flex?: number;
@@ -3,32 +3,67 @@ import { RowElement } from "../elements/row-element.js";
3
3
  import { RectangleElement } from "../elements/rectangle-element.js";
4
4
  import { ExpandedElement } from "../elements/layout/expanded-element.js";
5
5
  import { PaddingElement } from "../elements/layout/padding-element.js";
6
+ import { PageBreakElement } from "../elements/layout/page-break-element.js";
7
+ import { KeepTogetherElement } from "../elements/layout/keep-together-element.js";
6
8
  import { PositionedElement } from "../elements/layout/positioned-element.js";
9
+ import { LinkElement } from "../elements/layout/link-element.js";
10
+ import { BookmarkElement } from "../elements/layout/bookmark-element.js";
11
+ import { AnchorElement } from "../elements/layout/anchor-element.js";
12
+ import { RotatedElement } from "../elements/layout/rotated-element.js";
13
+ import { RotatedBoxElement } from "../elements/layout/rotated-box-element.js";
7
14
  import { toColor } from "./color.js";
8
15
  import { toEdges } from "./insets.js";
16
+ import { toDimension } from "./dimension.js";
9
17
  import { splitArgs } from "./args.js";
18
+ /** Splits `StackOptions` width/height into the fixed points + fraction the stack elements expect. */
19
+ function stackSize(opts) {
20
+ const w = opts.width !== undefined ? toDimension(opts.width) : undefined;
21
+ const h = opts.height !== undefined ? toDimension(opts.height) : undefined;
22
+ return { width: w?.points, height: h?.points, widthFactor: w?.factor, heightFactor: h?.factor };
23
+ }
10
24
  // The public default for `cross` is `start` (locked §5) - i.e. don't stretch a child unless
11
25
  // asked. The engine default is `stretch`; the factory sets the friendlier public one.
12
26
  const DEFAULT_CROSS = "start";
13
27
  export function Column(a, b) {
14
28
  const { opts, children } = splitArgs(a, b);
15
- return new ContainerElement({
29
+ return maybeKeepTogether(opts, new ContainerElement({
16
30
  x: 0,
17
31
  y: 0,
18
32
  children,
19
33
  gap: opts.gap,
20
34
  main: opts.justify, // undefined → engine default `start` (matches §5)
21
35
  cross: opts.align ?? DEFAULT_CROSS,
22
- });
36
+ breakBefore: opts.breakBefore,
37
+ breakAfter: opts.breakAfter,
38
+ ...stackSize(opts),
39
+ }));
23
40
  }
24
41
  export function Row(a, b) {
25
42
  const { opts, children } = splitArgs(a, b);
26
- return new RowElement({
43
+ return maybeKeepTogether(opts, new RowElement({
27
44
  children,
28
45
  gap: opts.gap,
29
46
  main: opts.justify,
30
47
  cross: opts.align ?? DEFAULT_CROSS,
31
- });
48
+ breakBefore: opts.breakBefore,
49
+ breakAfter: opts.breakAfter,
50
+ ...stackSize(opts),
51
+ }));
52
+ }
53
+ /** Wraps `element` in a `keepTogether` group when the `keepTogether` option is set; otherwise returns it
54
+ * unchanged (byte-identical). Shared by the `Box`/`Column`/`Row` prop shortcut. */
55
+ function maybeKeepTogether(opts, element) {
56
+ return opts.keepTogether ? new KeepTogetherElement({ child: element }) : element;
57
+ }
58
+ /**
59
+ * Keep a group of elements on ONE page (CSS `break-inside: avoid`). `keepTogether([...])` stacks the
60
+ * children in a `Column` and refuses to split them across a page break - unless they do not fit on a
61
+ * fresh page at all, in which case they split anyway (so pagination always terminates). Equivalent to
62
+ * the `keepTogether` prop on `Box`/`Column`/`Row`.
63
+ */
64
+ export function keepTogether(children) {
65
+ const child = children.length === 1 ? children[0] : Column(children);
66
+ return new KeepTogetherElement({ child });
32
67
  }
33
68
  export function Box(a, b) {
34
69
  const { opts, children } = splitArgs(a, b);
@@ -49,15 +84,20 @@ export function Box(a, b) {
49
84
  return c !== undefined ? toColor(c) : undefined;
50
85
  };
51
86
  const hasBorder = opts.border !== undefined || opts.borderWidth !== undefined || hasPerSide;
52
- return new RectangleElement({
87
+ // A point size fills `width`/`height`; a percentage fills `widthFactor`/`heightFactor`.
88
+ const w = opts.width !== undefined ? toDimension(opts.width) : undefined;
89
+ const h = opts.height !== undefined ? toDimension(opts.height) : undefined;
90
+ return maybeKeepTogether(opts, new RectangleElement({
53
91
  x: 0,
54
92
  y: 0,
55
93
  children: content,
56
94
  color: opts.border !== undefined ? toColor(opts.border) : undefined,
57
95
  backgroundColor: opts.bg !== undefined ? toColor(opts.bg) : undefined,
58
96
  borderWidth: hasBorder ? (opts.borderWidth ?? 1) : 0,
59
- width: opts.width,
60
- height: opts.height,
97
+ width: w?.points,
98
+ height: h?.points,
99
+ widthFactor: w?.factor,
100
+ heightFactor: h?.factor,
61
101
  radius: opts.radius,
62
102
  sideBorders: hasPerSide
63
103
  ? {
@@ -69,22 +109,79 @@ export function Box(a, b) {
69
109
  : undefined,
70
110
  relative: opts.relative,
71
111
  overflow: opts.overflow,
72
- });
112
+ breakBefore: opts.breakBefore,
113
+ breakAfter: opts.breakAfter,
114
+ }));
73
115
  }
74
116
  /** Insets a single child by `padding` (a number / `{x,y}` / `{top,…}` / 4-tuple). */
75
117
  export function Padding(padding, child) {
76
118
  return new PaddingElement({ margin: toEdges(padding), child });
77
119
  }
78
120
  /**
79
- * Places a child OUT OF FLOW, relative to the nearest enclosing `relative` Box. Two ways, pick per
80
- * axis: pin to EDGES - `Positioned({ top, left, right, bottom }, child)`, where a negative value
81
- * pokes into / out of the corner (a badge, a tab, a ribbon) and pinning both sides stretches; or
82
- * ANCHOR + nudge - `Positioned({ h: "center", v: "end", x: -10, y: -8 }, child)`, i.e. centered /
83
- * end-aligned with a pixel offset. An edge wins over an anchor on the same axis.
121
+ * Places a child OUT OF FLOW, relative to the nearest enclosing `relative` Box - or, with none, to
122
+ * the page's content box. Two ways, pick per axis: pin to EDGES - `Positioned({ top, left, right,
123
+ * bottom }, child)`, where a negative value pokes into / out of the corner (a badge, a tab, a
124
+ * ribbon) and pinning both sides stretches; or ANCHOR + nudge - `Positioned({ h: "center", v: "end",
125
+ * x: -10, y: -8 }, child)`, i.e. centered / end-aligned with a pixel offset. An edge wins over an
126
+ * anchor on the same axis.
127
+ *
128
+ * A `Positioned` in a `Page`'s `header` or `footer` repeats on EVERY page and takes no space in the
129
+ * band, which is how you draw a watermark, a draft stamp or a corner ribbon across a document. It
130
+ * anchors to the page, not to the band, so `bottom: 0` is the foot of the page.
84
131
  */
85
132
  export function Positioned(opts, child) {
86
133
  return new PositionedElement({ child, ...opts });
87
134
  }
135
+ /**
136
+ * Makes any `child` a clickable hyperlink - the child's whole box is the clickable region (an image, a
137
+ * box, a row). Pass `href` for an external URL, or `to` for an internal jump to an `Anchor({ name })`
138
+ * elsewhere in the document (e.g. a clickable table-of-contents row). Exactly one of the two. The link
139
+ * draws nothing itself, so style the child - e.g. `Link({ href }, Text("jasy.dev", { color: "#1450aa" }))`
140
+ * or `Link({ to: "chapter-2" }, Text("Chapter 2 ....... 5"))`. For a link on part of a line, put `href`/
141
+ * `to` on a `span` instead (`Text([span("Visit "), span("jasy.dev", { href })])`), linking just that run.
142
+ */
143
+ export function Link(opts, child) {
144
+ if ((opts.href === undefined) === (opts.to === undefined)) {
145
+ throw new Error("Link needs exactly one of `href` (external URL) or `to` (internal Anchor name).");
146
+ }
147
+ return new LinkElement({ href: opts.href, dest: opts.to, child });
148
+ }
149
+ /**
150
+ * Marks `child` as a named jump target that an internal `Link({ to: name })` can point at (a table of
151
+ * contents linking to sections, cross-references). Layout-transparent: `child` renders exactly as it
152
+ * would on its own, on whatever page it lands on. `name` must match the link's `to`.
153
+ * `Anchor({ name: "chapter-2" }, Text("Chapter 2", { size: 24 }))`.
154
+ */
155
+ export function Anchor(opts, child) {
156
+ return new AnchorElement({ name: opts.name, child });
157
+ }
158
+ /**
159
+ * Adds a bookmark to the document outline (the viewer's sidebar) that jumps to `child`. `title` is the
160
+ * label; `level` (1-based, default 1) nests it - a `level: 2` bookmark hangs under the nearest preceding
161
+ * `level: 1`, so you get a collapsible tree (chapters -> sections). It is layout-transparent: `child`
162
+ * renders exactly as it would on its own. `Bookmark({ title: "Chapter 2", level: 1 }, Text("Chapter 2"))`.
163
+ */
164
+ export function Bookmark(opts, child) {
165
+ return new BookmarkElement({ title: opts.title, level: opts.level, child });
166
+ }
167
+ /**
168
+ * Rotates `child` at PAINT time by `angle` degrees (clockwise), around its center - like CSS
169
+ * `transform: rotate()` / Flutter `Transform.rotate`. The child keeps its normal, unrotated layout box,
170
+ * so siblings do NOT reflow around the spun shape and the drawing may overflow its slot. Pair it with a
171
+ * `relative` Box + `Positioned` for a diagonal watermark or a "PAID" stamp laid over a document.
172
+ */
173
+ export function Rotated(opts, child) {
174
+ return new RotatedElement({ angle: opts.angle, child });
175
+ }
176
+ /**
177
+ * Rotates `child` by whole quarter-turns and, unlike `Rotated`, is LAYOUT-AWARE: a 90 / 270 turn swaps
178
+ * the box's width and height so siblings reflow around it (a tall label becomes a narrow, tall strip
179
+ * that reserves exactly that footprint). `turns` = clockwise 90-degree steps (1 = 90, 2 = 180, 3 = 270).
180
+ * Use it for a vertical label beside a table; use `Rotated` for a free-angle stamp / watermark.
181
+ */
182
+ export function RotatedBox(opts, child) {
183
+ return new RotatedBoxElement({ turns: opts.turns, child });
184
+ }
88
185
  /**
89
186
  * A flexible empty gap that pushes its siblings apart - `Row([a, Spacer(), b])` sends `a`
90
187
  * and `b` to the edges. `flex` weights it against other flex children (default 1).
@@ -92,6 +189,14 @@ export function Positioned(opts, child) {
92
189
  export function Spacer(flex = 1) {
93
190
  return new ExpandedElement({ flex, child: Column([]) });
94
191
  }
192
+ /**
193
+ * A forced page break. Put it in a `Column` (or any vertical flow) and everything AFTER it starts on
194
+ * a fresh page, no matter how much space is left: `Column([intro, PageBreak(), chapterTwo])`. It takes
195
+ * no space and draws nothing. Works nested, and a break wins over `keepTogether`.
196
+ */
197
+ export function PageBreak() {
198
+ return new PageBreakElement();
199
+ }
95
200
  export function Expanded(a, b) {
96
201
  const isOptsForm = b !== undefined;
97
202
  const opts = (isOptsForm ? a : {});
@@ -0,0 +1,32 @@
1
+ import { PageBuilderElement } from "../elements/layout/page-builder-element.js";
2
+ import { PageInfo, PDFElement } from "../elements/pdf-element.js";
3
+ import { TextOptions } from "./text.js";
4
+ export type { PageInfo };
5
+ /**
6
+ * Builds content from the page it ends up on - this is how you show `pageNumber` / `pageCount`. Place it
7
+ * anywhere: in a `Page`'s `header`/`footer`, in the body, in a table cell, several times on one page.
8
+ * Because you write the closure, formatting and conditions are yours:
9
+ *
10
+ * ```ts
11
+ * PageBuilder(({ pageNumber, pageCount }) => Text(`Seite ${pageNumber} von ${pageCount}`))
12
+ * PageBuilder(({ pageNumber }) => (pageNumber === 1 ? Logo() : Text("Fortsetzung")))
13
+ * PageBuilder(({ pageNumber, pageCount }) => Text(`${pageNumber - 1} / ${pageCount - 1}`)) // cover page
14
+ * ```
15
+ *
16
+ * `pageNumber` is 1-based and counts PHYSICAL pages (an overflowing `Page` contributes several);
17
+ * `pageCount` is the document total; `pageSize` is the media box in points.
18
+ *
19
+ * The numbers themselves are always exact. Two sizing caveats, both from the same chicken-and-egg (the
20
+ * content decides the count that the content displays): in the flowing BODY the box is reserved before the
21
+ * total is known, so a much wider final string can paint slightly past it; and a conditional header may
22
+ * SHRINK on later pages but must not GROW, because the body band was measured against the first build.
23
+ */
24
+ export declare function PageBuilder(build: (info: PageInfo) => PDFElement): PageBuilderElement;
25
+ /** `Text` options plus an `offset` added to the number (e.g. `-1` to not count a cover page). */
26
+ export interface PageNumberOptions extends TextOptions {
27
+ offset?: number;
28
+ }
29
+ /** The current page number as text. Sugar for `PageBuilder` - same thing, one word. */
30
+ export declare function PageNumber({ offset, ...style }?: PageNumberOptions): PageBuilderElement;
31
+ /** The document's total page count as text. Sugar for `PageBuilder`. */
32
+ export declare function PageCount({ offset, ...style }?: PageNumberOptions): PageBuilderElement;
@@ -0,0 +1,32 @@
1
+ import { PageBuilderElement } from "../elements/layout/page-builder-element.js";
2
+ import { Text } from "./text.js";
3
+ /**
4
+ * Builds content from the page it ends up on - this is how you show `pageNumber` / `pageCount`. Place it
5
+ * anywhere: in a `Page`'s `header`/`footer`, in the body, in a table cell, several times on one page.
6
+ * Because you write the closure, formatting and conditions are yours:
7
+ *
8
+ * ```ts
9
+ * PageBuilder(({ pageNumber, pageCount }) => Text(`Seite ${pageNumber} von ${pageCount}`))
10
+ * PageBuilder(({ pageNumber }) => (pageNumber === 1 ? Logo() : Text("Fortsetzung")))
11
+ * PageBuilder(({ pageNumber, pageCount }) => Text(`${pageNumber - 1} / ${pageCount - 1}`)) // cover page
12
+ * ```
13
+ *
14
+ * `pageNumber` is 1-based and counts PHYSICAL pages (an overflowing `Page` contributes several);
15
+ * `pageCount` is the document total; `pageSize` is the media box in points.
16
+ *
17
+ * The numbers themselves are always exact. Two sizing caveats, both from the same chicken-and-egg (the
18
+ * content decides the count that the content displays): in the flowing BODY the box is reserved before the
19
+ * total is known, so a much wider final string can paint slightly past it; and a conditional header may
20
+ * SHRINK on later pages but must not GROW, because the body band was measured against the first build.
21
+ */
22
+ export function PageBuilder(build) {
23
+ return new PageBuilderElement({ build });
24
+ }
25
+ /** The current page number as text. Sugar for `PageBuilder` - same thing, one word. */
26
+ export function PageNumber({ offset = 0, ...style } = {}) {
27
+ return PageBuilder(({ pageNumber }) => Text(String(pageNumber + offset), style));
28
+ }
29
+ /** The document's total page count as text. Sugar for `PageBuilder`. */
30
+ export function PageCount({ offset = 0, ...style } = {}) {
31
+ return PageBuilder(({ pageCount }) => Text(String(pageCount + offset), style));
32
+ }
@@ -132,6 +132,11 @@ export interface RenderOptions {
132
132
  standardFonts?: boolean;
133
133
  /** FlateDecode-compress the streams (default true). Set false for a greppable, uncompressed PDF. */
134
134
  compress?: boolean;
135
+ /** Kern the text - ON by default (still labelled experimental while we validate against react-pdf).
136
+ * Emits `TJ` adjustments from the font's own pairs: standard-14 (AFM), embedded `kern` table, and
137
+ * embedded `GPOS` Type 2, so every font path kerns. Set `false` to opt out (byte-identical to the
138
+ * pre-kerning output). Verified against headless Chrome and measured == drawn to the thousandth. */
139
+ kerning?: boolean;
135
140
  /** What to do when content is taller than a page and cannot break: `"error"` throws (default),
136
141
  * `"warn"` logs and clips, `"ignore"` clips silently. It is always clipped either way. */
137
142
  onOverflow?: OverflowPolicy;
@@ -137,6 +137,7 @@ export async function renderPdf(doc, options) {
137
137
  const om = this.objectManager;
138
138
  om.setCompress(options?.compress !== false); // FlateDecode streams by default
139
139
  om.setOverflowPolicy(options?.onOverflow ?? "error");
140
+ om.setKerning(options?.kerning !== false); // on by default (experimental); `kerning:false` opts out
140
141
  for (const [name, value] of Object.entries(fonts)) {
141
142
  if (isFontBytes(value)) {
142
143
  om.registerCustomFont(name, value);