@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
@@ -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, 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));
@@ -89,6 +98,18 @@ const REGISTRY = {
89
98
  },
90
99
  positioned: (props, children) => Positioned(props, elementChildren(children)[0]),
91
100
  "default-text-style": (props, children) => DefaultTextStyle(props, elementChildren(children)),
101
+ // Navigation. `Link` takes exactly one of `href` (a URL) or `to` (an `Anchor` name) - the factory
102
+ // enforces that, so a template typo surfaces as an error instead of a dead link.
103
+ link: (props, children) => Link(props, wrappedChild(children)),
104
+ anchor: (props, children) => Anchor(props, wrappedChild(children)),
105
+ bookmark: (props, children) => Bookmark(props, wrappedChild(children)),
106
+ // Transforms.
107
+ rotated: (props, children) => Rotated(props, wrappedChild(children)),
108
+ "rotated-box": (props, children) => RotatedBox(props, wrappedChild(children)),
109
+ // Page numbers. `PageBuilder` itself is deliberately NOT exposed: it takes a closure, which a template
110
+ // cannot express. These two cover what the closure exists for.
111
+ "page-number": (props) => PageNumber(props),
112
+ "page-count": (props) => PageCount(props),
92
113
  };
93
114
  /**
94
115
  * 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";
@@ -4,10 +4,16 @@ 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
6
  import { PositionedElement, PositionedInsets } from "../elements/layout/positioned-element.js";
7
+ import { LinkElement } from "../elements/layout/link-element.js";
8
+ import { BookmarkElement } from "../elements/layout/bookmark-element.js";
9
+ import { AnchorElement } from "../elements/layout/anchor-element.js";
10
+ import { RotatedElement } from "../elements/layout/rotated-element.js";
11
+ import { RotatedBoxElement } from "../elements/layout/rotated-box-element.js";
7
12
  import { PDFElement } from "../elements/pdf-element.js";
8
13
  import { MainAlign, CrossAlign } from "../utils/flex-layout.js";
9
14
  import { ColorInput } from "./color.js";
10
15
  import { Insets } from "./insets.js";
16
+ import { SizeInput } from "./dimension.js";
11
17
  /** Options shared by the `Column` and `Row` stacks (locked §4). */
12
18
  export interface StackOptions {
13
19
  /** Space inserted between children, in points. */
@@ -17,6 +23,11 @@ export interface StackOptions {
17
23
  justify?: MainAlign;
18
24
  /** Position across the axis (CSS `align-items`): start (default) · center · end · stretch. */
19
25
  align?: CrossAlign;
26
+ /** Size on each axis: points (fixed) or a percentage string like `"50%"` (a fraction of the offered
27
+ * space). Omit to fill the offered width / shrink-wrap to the children. A percentage needs a bounded
28
+ * axis (`height: "50%"` only resolves where the parent bounds the height). */
29
+ width?: SizeInput;
30
+ height?: SizeInput;
20
31
  }
21
32
  /** A vertical stack. `Column(children)` or `Column(opts, children)`. */
22
33
  export declare function Column(children: PDFElement[]): ContainerElement;
@@ -41,9 +52,11 @@ export interface BoxOptions {
41
52
  bg?: ColorInput;
42
53
  /** Inner padding between the border and the children. */
43
54
  padding?: Insets;
44
- /** Fixed size; omit to fill the offered width and shrink-wrap the height. */
45
- width?: number;
46
- height?: number;
55
+ /** Size on each axis: a number of points (fixed) or a percentage string like `"50%"` (a fraction
56
+ * of the space the parent offers on that axis). Omit `width` to fill the offered width, omit
57
+ * `height` to shrink-wrap the content. A percentage only resolves in a bounded region. */
58
+ width?: SizeInput;
59
+ height?: SizeInput;
47
60
  /** Corner radius in points. */
48
61
  radius?: number;
49
62
  /** Make this box a positioning frame: `Positioned` children placed inside it resolve their
@@ -70,6 +83,55 @@ export declare function Padding(padding: Insets, child: PDFElement): PaddingElem
70
83
  * end-aligned with a pixel offset. An edge wins over an anchor on the same axis.
71
84
  */
72
85
  export declare function Positioned(opts: PositionedInsets, child: PDFElement): PositionedElement;
86
+ /**
87
+ * Makes any `child` a clickable hyperlink - the child's whole box is the clickable region (an image, a
88
+ * box, a row). Pass `href` for an external URL, or `to` for an internal jump to an `Anchor({ name })`
89
+ * elsewhere in the document (e.g. a clickable table-of-contents row). Exactly one of the two. The link
90
+ * draws nothing itself, so style the child - e.g. `Link({ href }, Text("jasy.dev", { color: "#1450aa" }))`
91
+ * or `Link({ to: "chapter-2" }, Text("Chapter 2 ....... 5"))`. For a link on part of a line, put `href`/
92
+ * `to` on a `span` instead (`Text([span("Visit "), span("jasy.dev", { href })])`), linking just that run.
93
+ */
94
+ export declare function Link(opts: {
95
+ href?: string;
96
+ to?: string;
97
+ }, child: PDFElement): LinkElement;
98
+ /**
99
+ * Marks `child` as a named jump target that an internal `Link({ to: name })` can point at (a table of
100
+ * contents linking to sections, cross-references). Layout-transparent: `child` renders exactly as it
101
+ * would on its own, on whatever page it lands on. `name` must match the link's `to`.
102
+ * `Anchor({ name: "chapter-2" }, Text("Chapter 2", { size: 24 }))`.
103
+ */
104
+ export declare function Anchor(opts: {
105
+ name: string;
106
+ }, child: PDFElement): AnchorElement;
107
+ /**
108
+ * Adds a bookmark to the document outline (the viewer's sidebar) that jumps to `child`. `title` is the
109
+ * label; `level` (1-based, default 1) nests it - a `level: 2` bookmark hangs under the nearest preceding
110
+ * `level: 1`, so you get a collapsible tree (chapters -> sections). It is layout-transparent: `child`
111
+ * renders exactly as it would on its own. `Bookmark({ title: "Chapter 2", level: 1 }, Text("Chapter 2"))`.
112
+ */
113
+ export declare function Bookmark(opts: {
114
+ title: string;
115
+ level?: number;
116
+ }, child: PDFElement): BookmarkElement;
117
+ /**
118
+ * Rotates `child` at PAINT time by `angle` degrees (clockwise), around its center - like CSS
119
+ * `transform: rotate()` / Flutter `Transform.rotate`. The child keeps its normal, unrotated layout box,
120
+ * so siblings do NOT reflow around the spun shape and the drawing may overflow its slot. Pair it with a
121
+ * `relative` Box + `Positioned` for a diagonal watermark or a "PAID" stamp laid over a document.
122
+ */
123
+ export declare function Rotated(opts: {
124
+ angle: number;
125
+ }, child: PDFElement): RotatedElement;
126
+ /**
127
+ * Rotates `child` by whole quarter-turns and, unlike `Rotated`, is LAYOUT-AWARE: a 90 / 270 turn swaps
128
+ * the box's width and height so siblings reflow around it (a tall label becomes a narrow, tall strip
129
+ * that reserves exactly that footprint). `turns` = clockwise 90-degree steps (1 = 90, 2 = 180, 3 = 270).
130
+ * Use it for a vertical label beside a table; use `Rotated` for a free-angle stamp / watermark.
131
+ */
132
+ export declare function RotatedBox(opts: {
133
+ turns: number;
134
+ }, child: PDFElement): RotatedBoxElement;
73
135
  /**
74
136
  * A flexible empty gap that pushes its siblings apart - `Row([a, Spacer(), b])` sends `a`
75
137
  * and `b` to the edges. `flex` weights it against other flex children (default 1).
@@ -4,9 +4,21 @@ 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
6
  import { PositionedElement } from "../elements/layout/positioned-element.js";
7
+ import { LinkElement } from "../elements/layout/link-element.js";
8
+ import { BookmarkElement } from "../elements/layout/bookmark-element.js";
9
+ import { AnchorElement } from "../elements/layout/anchor-element.js";
10
+ import { RotatedElement } from "../elements/layout/rotated-element.js";
11
+ import { RotatedBoxElement } from "../elements/layout/rotated-box-element.js";
7
12
  import { toColor } from "./color.js";
8
13
  import { toEdges } from "./insets.js";
14
+ import { toDimension } from "./dimension.js";
9
15
  import { splitArgs } from "./args.js";
16
+ /** Splits `StackOptions` width/height into the fixed points + fraction the stack elements expect. */
17
+ function stackSize(opts) {
18
+ const w = opts.width !== undefined ? toDimension(opts.width) : undefined;
19
+ const h = opts.height !== undefined ? toDimension(opts.height) : undefined;
20
+ return { width: w?.points, height: h?.points, widthFactor: w?.factor, heightFactor: h?.factor };
21
+ }
10
22
  // The public default for `cross` is `start` (locked §5) - i.e. don't stretch a child unless
11
23
  // asked. The engine default is `stretch`; the factory sets the friendlier public one.
12
24
  const DEFAULT_CROSS = "start";
@@ -19,6 +31,7 @@ export function Column(a, b) {
19
31
  gap: opts.gap,
20
32
  main: opts.justify, // undefined → engine default `start` (matches §5)
21
33
  cross: opts.align ?? DEFAULT_CROSS,
34
+ ...stackSize(opts),
22
35
  });
23
36
  }
24
37
  export function Row(a, b) {
@@ -28,6 +41,7 @@ export function Row(a, b) {
28
41
  gap: opts.gap,
29
42
  main: opts.justify,
30
43
  cross: opts.align ?? DEFAULT_CROSS,
44
+ ...stackSize(opts),
31
45
  });
32
46
  }
33
47
  export function Box(a, b) {
@@ -49,6 +63,9 @@ export function Box(a, b) {
49
63
  return c !== undefined ? toColor(c) : undefined;
50
64
  };
51
65
  const hasBorder = opts.border !== undefined || opts.borderWidth !== undefined || hasPerSide;
66
+ // A point size fills `width`/`height`; a percentage fills `widthFactor`/`heightFactor`.
67
+ const w = opts.width !== undefined ? toDimension(opts.width) : undefined;
68
+ const h = opts.height !== undefined ? toDimension(opts.height) : undefined;
52
69
  return new RectangleElement({
53
70
  x: 0,
54
71
  y: 0,
@@ -56,8 +73,10 @@ export function Box(a, b) {
56
73
  color: opts.border !== undefined ? toColor(opts.border) : undefined,
57
74
  backgroundColor: opts.bg !== undefined ? toColor(opts.bg) : undefined,
58
75
  borderWidth: hasBorder ? (opts.borderWidth ?? 1) : 0,
59
- width: opts.width,
60
- height: opts.height,
76
+ width: w?.points,
77
+ height: h?.points,
78
+ widthFactor: w?.factor,
79
+ heightFactor: h?.factor,
61
80
  radius: opts.radius,
62
81
  sideBorders: hasPerSide
63
82
  ? {
@@ -85,6 +104,56 @@ export function Padding(padding, child) {
85
104
  export function Positioned(opts, child) {
86
105
  return new PositionedElement({ child, ...opts });
87
106
  }
107
+ /**
108
+ * Makes any `child` a clickable hyperlink - the child's whole box is the clickable region (an image, a
109
+ * box, a row). Pass `href` for an external URL, or `to` for an internal jump to an `Anchor({ name })`
110
+ * elsewhere in the document (e.g. a clickable table-of-contents row). Exactly one of the two. The link
111
+ * draws nothing itself, so style the child - e.g. `Link({ href }, Text("jasy.dev", { color: "#1450aa" }))`
112
+ * or `Link({ to: "chapter-2" }, Text("Chapter 2 ....... 5"))`. For a link on part of a line, put `href`/
113
+ * `to` on a `span` instead (`Text([span("Visit "), span("jasy.dev", { href })])`), linking just that run.
114
+ */
115
+ export function Link(opts, child) {
116
+ if ((opts.href === undefined) === (opts.to === undefined)) {
117
+ throw new Error("Link needs exactly one of `href` (external URL) or `to` (internal Anchor name).");
118
+ }
119
+ return new LinkElement({ href: opts.href, dest: opts.to, child });
120
+ }
121
+ /**
122
+ * Marks `child` as a named jump target that an internal `Link({ to: name })` can point at (a table of
123
+ * contents linking to sections, cross-references). Layout-transparent: `child` renders exactly as it
124
+ * would on its own, on whatever page it lands on. `name` must match the link's `to`.
125
+ * `Anchor({ name: "chapter-2" }, Text("Chapter 2", { size: 24 }))`.
126
+ */
127
+ export function Anchor(opts, child) {
128
+ return new AnchorElement({ name: opts.name, child });
129
+ }
130
+ /**
131
+ * Adds a bookmark to the document outline (the viewer's sidebar) that jumps to `child`. `title` is the
132
+ * label; `level` (1-based, default 1) nests it - a `level: 2` bookmark hangs under the nearest preceding
133
+ * `level: 1`, so you get a collapsible tree (chapters -> sections). It is layout-transparent: `child`
134
+ * renders exactly as it would on its own. `Bookmark({ title: "Chapter 2", level: 1 }, Text("Chapter 2"))`.
135
+ */
136
+ export function Bookmark(opts, child) {
137
+ return new BookmarkElement({ title: opts.title, level: opts.level, child });
138
+ }
139
+ /**
140
+ * Rotates `child` at PAINT time by `angle` degrees (clockwise), around its center - like CSS
141
+ * `transform: rotate()` / Flutter `Transform.rotate`. The child keeps its normal, unrotated layout box,
142
+ * so siblings do NOT reflow around the spun shape and the drawing may overflow its slot. Pair it with a
143
+ * `relative` Box + `Positioned` for a diagonal watermark or a "PAID" stamp laid over a document.
144
+ */
145
+ export function Rotated(opts, child) {
146
+ return new RotatedElement({ angle: opts.angle, child });
147
+ }
148
+ /**
149
+ * Rotates `child` by whole quarter-turns and, unlike `Rotated`, is LAYOUT-AWARE: a 90 / 270 turn swaps
150
+ * the box's width and height so siblings reflow around it (a tall label becomes a narrow, tall strip
151
+ * that reserves exactly that footprint). `turns` = clockwise 90-degree steps (1 = 90, 2 = 180, 3 = 270).
152
+ * Use it for a vertical label beside a table; use `Rotated` for a free-angle stamp / watermark.
153
+ */
154
+ export function RotatedBox(opts, child) {
155
+ return new RotatedBoxElement({ turns: opts.turns, child });
156
+ }
88
157
  /**
89
158
  * A flexible empty gap that pushes its siblings apart - `Row([a, Spacer(), b])` sends `a`
90
159
  * and `b` to the edges. `flex` weights it against other flex children (default 1).
@@ -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
+ }
@@ -11,6 +11,12 @@ export interface TextStyle {
11
11
  bold?: boolean;
12
12
  italic?: boolean;
13
13
  color?: ColorInput;
14
+ /** External URL - makes this run an inline hyperlink (a /Link annotation over its glyphs). On a
15
+ * `span` it links just that run; on a whole `Text` (plain string) it links the whole text. */
16
+ href?: string;
17
+ /** Internal jump target - the `name` of an `Anchor` elsewhere in the document (like `href`, but a
18
+ * same-document /GoTo instead of a URL). Use for a clickable table of contents / cross-reference. */
19
+ to?: string;
14
20
  }
15
21
  export interface TextOptions extends TextStyle {
16
22
  /** Text-internal alignment (left/center/right) - independent of a parent's `cross` (§5). */
package/dist/api/text.js CHANGED
@@ -17,6 +17,26 @@ const ALIGN = {
17
17
  center: HorizontalAlignment.center,
18
18
  right: HorizontalAlignment.right,
19
19
  };
20
+ /**
21
+ * Validates a font `size` (a number of points). Rejects anything that would silently become a NaN
22
+ * height deep in layout - notably `Document({ size: "A4" })`, where `"A4"` is the PAGE size and belongs
23
+ * on `Page`, not a font size. `undefined` passes through (unset = inherit the cascade).
24
+ */
25
+ function toFontSize(size) {
26
+ if (size === undefined)
27
+ return undefined;
28
+ if (typeof size !== "number" || !Number.isFinite(size) || size <= 0) {
29
+ // Quote a string so "A4" reads as a string; show a bare number/NaN as-is (JSON turns NaN into null).
30
+ const shown = typeof size === "string" ? JSON.stringify(size) : String(size);
31
+ // The Page-vs-font-size mix-up only happens with a string like "A4"; for a plain bad number
32
+ // (NaN, negative, 0) the hint would just be noise, so keep the message generic there.
33
+ const hint = typeof size === "string"
34
+ ? ` Note: the page size goes on Page({ size: "A4" }), not on Document or Text.`
35
+ : "";
36
+ throw new Error(`Invalid font size ${shown} (expected a positive number of points).${hint}`);
37
+ }
38
+ return size;
39
+ }
20
40
  /**
21
41
  * An inline run for mixed-style `Text` (`Text([span("a", {bold:true}), span("b")])`). Each
22
42
  * field overrides the enclosing `Text`'s defaults for just this run; omitted fields inherit.
@@ -24,12 +44,14 @@ const ALIGN = {
24
44
  export function span(text, style = {}) {
25
45
  return {
26
46
  content: text,
27
- fontSize: style.size,
47
+ fontSize: toFontSize(style.size),
28
48
  fontFamily: style.font,
29
49
  fontStyle: style.bold !== undefined || style.italic !== undefined
30
50
  ? toFontStyle(style.bold, style.italic)
31
51
  : undefined,
32
52
  fontColor: style.color !== undefined ? toColor(style.color) : undefined,
53
+ href: style.href,
54
+ dest: style.to,
33
55
  };
34
56
  }
35
57
  /**
@@ -38,11 +60,16 @@ export function span(text, style = {}) {
38
60
  * text-internal `align`.
39
61
  */
40
62
  export function Text(content, opts = {}) {
63
+ // A plain string given an `href`/`to` becomes a single link span, so `Text("jasy.dev", { href })` (or
64
+ // `{ to }`) is a whole-text link (inline links use `span(...)`). This routes through the styled-segment
65
+ // path, which lays a single default-font segment out identically - only the /Link annotation is added.
66
+ const isLink = opts.href !== undefined || opts.to !== undefined;
67
+ const body = typeof content === "string" && isLink ? [span(content, opts)] : content;
41
68
  // Unset properties are left undefined so they inherit the cascaded TextStyle (Document default /
42
69
  // built-in). Only bold/italic that the caller actually set become an explicit FontStyle.
43
70
  return new TextElement({
44
- content,
45
- fontSize: opts.size,
71
+ content: body,
72
+ fontSize: toFontSize(opts.size),
46
73
  fontFamily: opts.font,
47
74
  fontStyle: opts.bold !== undefined || opts.italic !== undefined
48
75
  ? toFontStyle(opts.bold, opts.italic)
@@ -64,7 +91,7 @@ export function Paragraph(content, opts = {}) {
64
91
  export function toTextStyleOverride(opts) {
65
92
  const style = {};
66
93
  if (opts.size !== undefined)
67
- style.fontSize = opts.size;
94
+ style.fontSize = toFontSize(opts.size);
68
95
  if (opts.font !== undefined)
69
96
  style.fontFamily = opts.font;
70
97
  if (opts.bold !== undefined || opts.italic !== undefined) {
@@ -9,13 +9,17 @@ interface ContainerElementParams extends SizedElement, WithChildren {
9
9
  main?: MainAlign;
10
10
  /** Horizontal alignment of each child (cross axis); defaults to `stretch`. */
11
11
  cross?: CrossAlign;
12
+ /** Width/height as a fraction (0..1) of the offered box instead of `width`/`height` (relative sizing). */
13
+ widthFactor?: number;
14
+ heightFactor?: number;
12
15
  }
13
16
  export declare class ContainerElement extends SizedPDFElement implements Fragmentable {
14
17
  private children;
15
18
  private gap;
16
19
  private main;
17
20
  private cross;
18
- constructor({ x, y, width, height, children, gap, main, cross }: ContainerElementParams);
21
+ private requested;
22
+ constructor({ x, y, width, height, children, gap, main, cross, widthFactor, heightFactor, }: ContainerElementParams);
19
23
  /**
20
24
  * Splits the vertical stack across pages. Children are measured against the content
21
25
  * width and packed until one would exceed `maxHeight`. That straddling child is itself
@@ -29,6 +33,18 @@ export declare class ContainerElement extends SizedPDFElement implements Fragmen
29
33
  */
30
34
  fragment(maxHeight: number, width: number, ctx: LayoutContext): FragmentResult;
31
35
  private cloneWithChildren;
36
+ relativeSizeFactor(horizontal: boolean): number | undefined;
37
+ /**
38
+ * A Column stacks vertically, so a flex child of its OWN only ever needs a bounded height. But the need
39
+ * also propagates: a `Spacer` two levels down is just as unable to resolve against an infinite axis, so
40
+ * we ask the children too - on both axes, since a nested Row's `Spacer` needs a bounded WIDTH and a
41
+ * Column nested in a Row is exactly where that width goes missing.
42
+ *
43
+ * An explicit extent on an axis short-circuits it: the element already knows how big it is there, and
44
+ * with no flex descendant nothing needs distributing, so it keeps shrink-wrapping. That is what leaves
45
+ * every existing layout untouched.
46
+ */
47
+ needsBoundedMain(horizontal: boolean): boolean;
32
48
  calculateLayout(constraints: BoxConstraints, offset: Offset, ctx: LayoutContext): Size;
33
49
  getProps(): ContainerElementParams;
34
50
  }