@jasy/pdf 1.0.0-alpha.6 → 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 (61) hide show
  1. package/dist/api/descriptor.js +3 -1
  2. package/dist/api/layout.d.ts +47 -14
  3. package/dist/api/layout.js +47 -11
  4. package/dist/api/structure.d.ts +5 -0
  5. package/dist/api/structure.js +1 -0
  6. package/dist/api/text.d.ts +18 -2
  7. package/dist/api/text.js +15 -0
  8. package/dist/elements/container-element.d.ts +10 -1
  9. package/dist/elements/container-element.js +26 -8
  10. package/dist/elements/index.d.ts +2 -0
  11. package/dist/elements/index.js +2 -0
  12. package/dist/elements/layout/keep-together-element.d.ts +42 -0
  13. package/dist/elements/layout/keep-together-element.js +85 -0
  14. package/dist/elements/layout/padding-element.d.ts +3 -0
  15. package/dist/elements/layout/padding-element.js +12 -0
  16. package/dist/elements/layout/page-break-element.d.ts +17 -0
  17. package/dist/elements/layout/page-break-element.js +24 -0
  18. package/dist/elements/layout/positioned-element.d.ts +10 -7
  19. package/dist/elements/layout/positioned-element.js +21 -9
  20. package/dist/elements/page-element.d.ts +11 -1
  21. package/dist/elements/page-element.js +41 -16
  22. package/dist/elements/pdf-element.d.ts +28 -0
  23. package/dist/elements/pdf-element.js +32 -0
  24. package/dist/elements/rectangle-element.d.ts +10 -1
  25. package/dist/elements/rectangle-element.js +27 -6
  26. package/dist/elements/row-element.d.ts +9 -1
  27. package/dist/elements/row-element.js +9 -1
  28. package/dist/elements/text-element.d.ts +30 -4
  29. package/dist/elements/text-element.js +40 -17
  30. package/dist/ir/display-list.d.ts +1 -0
  31. package/dist/layout/fragmentation.d.ts +12 -0
  32. package/dist/layout/fragmentation.js +45 -8
  33. package/dist/renderer/keep-together-renderer.d.ts +10 -0
  34. package/dist/renderer/keep-together-renderer.js +12 -0
  35. package/dist/renderer/page-break-renderer.d.ts +14 -0
  36. package/dist/renderer/page-break-renderer.js +15 -0
  37. package/dist/renderer/pdf-backend.d.ts +7 -0
  38. package/dist/renderer/pdf-backend.js +40 -6
  39. package/dist/renderer/pdf-document-class.js +3 -0
  40. package/dist/renderer/pdf-document-renderer.js +26 -7
  41. package/dist/renderer/pdf-renderer.js +6 -0
  42. package/dist/renderer/text-renderer.d.ts +1 -1
  43. package/dist/renderer/text-renderer.js +107 -59
  44. package/dist/text/advance.d.ts +34 -0
  45. package/dist/text/advance.js +42 -0
  46. package/dist/text/line-breaker.d.ts +9 -8
  47. package/dist/text/line-breaker.js +28 -21
  48. package/dist/text/line-metrics.d.ts +49 -0
  49. package/dist/text/line-metrics.js +57 -0
  50. package/dist/text/text-decoration.d.ts +50 -0
  51. package/dist/text/text-decoration.js +62 -0
  52. package/dist/text/text-style.d.ts +13 -3
  53. package/dist/text/text-style.js +10 -3
  54. package/dist/utils/afm-parser.d.ts +34 -0
  55. package/dist/utils/afm-parser.js +67 -0
  56. package/dist/utils/font-metrics.d.ts +14 -0
  57. package/dist/utils/pdf-object-manager.d.ts +27 -1
  58. package/dist/utils/pdf-object-manager.js +134 -20
  59. package/dist/utils/ttf-parser.d.ts +53 -0
  60. package/dist/utils/ttf-parser.js +368 -0
  61. package/package.json +1 -1
@@ -1,5 +1,5 @@
1
1
  import { Text, Paragraph, span } from "./text.js";
2
- import { Column, Row, Box, Padding, Spacer, Expanded, Positioned, Link, Anchor, Bookmark, Rotated, RotatedBox, } 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
5
  import { PageNumber, PageCount } from "./page-builder.js";
@@ -85,6 +85,8 @@ const REGISTRY = {
85
85
  padding: (props, children) => Padding(props.insets ?? 0, elementChildren(children)[0]),
86
86
  expanded: (props, children) => Expanded(props, elementChildren(children)[0]),
87
87
  spacer: (props) => Spacer(props?.flex),
88
+ "page-break": () => PageBreak(),
89
+ "keep-together": (_props, children) => keepTogether(elementChildren(children)),
88
90
  divider: (props) => Divider(props),
89
91
  image: (props) => Image(props.src, props),
90
92
  text: (props, children) => Text(textContent(children), props),
@@ -1,8 +1,7 @@
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";
7
6
  import { LinkElement } from "../elements/layout/link-element.js";
8
7
  import { BookmarkElement } from "../elements/layout/bookmark-element.js";
@@ -28,13 +27,28 @@ export interface StackOptions {
28
27
  * axis (`height: "50%"` only resolves where the parent bounds the height). */
29
28
  width?: SizeInput;
30
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;
31
38
  }
32
39
  /** A vertical stack. `Column(children)` or `Column(opts, children)`. */
33
- export declare function Column(children: PDFElement[]): ContainerElement;
34
- 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;
35
42
  /** A horizontal stack. `Row(children)` or `Row(opts, children)`. */
36
- export declare function Row(children: PDFElement[]): RowElement;
37
- 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;
38
52
  /** A bordered / filled box that wraps its children (locked §4). */
39
53
  export interface BoxOptions {
40
54
  /** Border (stroke) color. A box has a border only when `border` or `borderWidth` is set. */
@@ -65,22 +79,35 @@ export interface BoxOptions {
65
79
  /** `"hidden"` crops children to the box (rounded corners included); `"visible"` (default) lets a
66
80
  * `Positioned` child spill over the edge. */
67
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;
68
90
  }
69
91
  /**
70
92
  * A box: maps to a `RectangleElement` (fill + border + radius) whose children are stacked
71
93
  * inside it, optionally inset by `padding` (a `PaddingElement` wrapping a `Column` of the
72
94
  * children). With no `border`/`borderWidth` the box has no outline (`borderWidth` 0).
73
95
  */
74
- export declare function Box(children: PDFElement[]): RectangleElement;
75
- 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;
76
98
  /** Insets a single child by `padding` (a number / `{x,y}` / `{top,…}` / 4-tuple). */
77
99
  export declare function Padding(padding: Insets, child: PDFElement): PaddingElement;
78
100
  /**
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.
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.
84
111
  */
85
112
  export declare function Positioned(opts: PositionedInsets, child: PDFElement): PositionedElement;
86
113
  /**
@@ -137,6 +164,12 @@ export declare function RotatedBox(opts: {
137
164
  * and `b` to the edges. `flex` weights it against other flex children (default 1).
138
165
  */
139
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;
140
173
  export interface ExpandedOptions {
141
174
  /** Share of the leftover space vs other flex children (default 1). */
142
175
  flex?: number;
@@ -3,6 +3,8 @@ 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";
7
9
  import { LinkElement } from "../elements/layout/link-element.js";
8
10
  import { BookmarkElement } from "../elements/layout/bookmark-element.js";
@@ -24,25 +26,44 @@ function stackSize(opts) {
24
26
  const DEFAULT_CROSS = "start";
25
27
  export function Column(a, b) {
26
28
  const { opts, children } = splitArgs(a, b);
27
- return new ContainerElement({
29
+ return maybeKeepTogether(opts, new ContainerElement({
28
30
  x: 0,
29
31
  y: 0,
30
32
  children,
31
33
  gap: opts.gap,
32
34
  main: opts.justify, // undefined → engine default `start` (matches §5)
33
35
  cross: opts.align ?? DEFAULT_CROSS,
36
+ breakBefore: opts.breakBefore,
37
+ breakAfter: opts.breakAfter,
34
38
  ...stackSize(opts),
35
- });
39
+ }));
36
40
  }
37
41
  export function Row(a, b) {
38
42
  const { opts, children } = splitArgs(a, b);
39
- return new RowElement({
43
+ return maybeKeepTogether(opts, new RowElement({
40
44
  children,
41
45
  gap: opts.gap,
42
46
  main: opts.justify,
43
47
  cross: opts.align ?? DEFAULT_CROSS,
48
+ breakBefore: opts.breakBefore,
49
+ breakAfter: opts.breakAfter,
44
50
  ...stackSize(opts),
45
- });
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 });
46
67
  }
47
68
  export function Box(a, b) {
48
69
  const { opts, children } = splitArgs(a, b);
@@ -66,7 +87,7 @@ export function Box(a, b) {
66
87
  // A point size fills `width`/`height`; a percentage fills `widthFactor`/`heightFactor`.
67
88
  const w = opts.width !== undefined ? toDimension(opts.width) : undefined;
68
89
  const h = opts.height !== undefined ? toDimension(opts.height) : undefined;
69
- return new RectangleElement({
90
+ return maybeKeepTogether(opts, new RectangleElement({
70
91
  x: 0,
71
92
  y: 0,
72
93
  children: content,
@@ -88,18 +109,25 @@ export function Box(a, b) {
88
109
  : undefined,
89
110
  relative: opts.relative,
90
111
  overflow: opts.overflow,
91
- });
112
+ breakBefore: opts.breakBefore,
113
+ breakAfter: opts.breakAfter,
114
+ }));
92
115
  }
93
116
  /** Insets a single child by `padding` (a number / `{x,y}` / `{top,…}` / 4-tuple). */
94
117
  export function Padding(padding, child) {
95
118
  return new PaddingElement({ margin: toEdges(padding), child });
96
119
  }
97
120
  /**
98
- * Places a child OUT OF FLOW, relative to the nearest enclosing `relative` Box. Two ways, pick per
99
- * axis: pin to EDGES - `Positioned({ top, left, right, bottom }, child)`, where a negative value
100
- * pokes into / out of the corner (a badge, a tab, a ribbon) and pinning both sides stretches; or
101
- * ANCHOR + nudge - `Positioned({ h: "center", v: "end", x: -10, y: -8 }, child)`, i.e. centered /
102
- * 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.
103
131
  */
104
132
  export function Positioned(opts, child) {
105
133
  return new PositionedElement({ child, ...opts });
@@ -161,6 +189,14 @@ export function RotatedBox(opts, child) {
161
189
  export function Spacer(flex = 1) {
162
190
  return new ExpandedElement({ flex, child: Column([]) });
163
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
+ }
164
200
  export function Expanded(a, b) {
165
201
  const isOptsForm = b !== undefined;
166
202
  const opts = (isOptsForm ? a : {});
@@ -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);
@@ -11,6 +11,17 @@ export interface TextStyle {
11
11
  bold?: boolean;
12
12
  italic?: boolean;
13
13
  color?: ColorInput;
14
+ /** Draw a line under the text. Its position and thickness come from the font, not from a guess.
15
+ * A `Link` is NOT underlined automatically - say so if you want it. */
16
+ underline?: boolean;
17
+ /** Draw a line through the text, at half its x-height (where a browser puts it). */
18
+ strikethrough?: boolean;
19
+ /** Let the underline step around descenders, like a browser (`text-decoration-skip-ink`). Needs an
20
+ * EMBEDDED font - the standard-14 outlines live in the viewer, so we cannot see where their ink
21
+ * is. Asking for it with a standard font is an error, not a silent no-op. */
22
+ skipInk?: boolean;
23
+ /** Extra space after every glyph, in points (CSS `letter-spacing`). Negative tightens. Default 0. */
24
+ letterSpacing?: number;
14
25
  /** External URL - makes this run an inline hyperlink (a /Link annotation over its glyphs). On a
15
26
  * `span` it links just that run; on a whole `Text` (plain string) it links the whole text. */
16
27
  href?: string;
@@ -26,8 +37,9 @@ export interface TextOptions extends TextStyle {
26
37
  maxLines?: number;
27
38
  /** What happens past `maxLines`: `"clip"` (default) cuts hard, `"ellipsis"` ends with "…". */
28
39
  overflow?: TextOverflow;
29
- /** Line-height multiplier (default `1`). `1.4` gives roomier body copy; each line is
30
- * `size * lineHeight` tall. */
40
+ /** Line-height multiplier: each line is `size * lineHeight` tall. Unset means the font's own
41
+ * natural line height (`ascent + descent + lineGap`), like CSS `line-height: normal` - which is
42
+ * what you want for body copy. `1` gives exactly one em, which most faces overflow slightly. */
31
43
  lineHeight?: number;
32
44
  /** Accessibility role for the tagged structure tree (only when rendered with `accessible`): a heading
33
45
  * level `"h1"`..`"h6"` or `"p"` (default). Purely semantic - it does not change the visual style. */
@@ -56,6 +68,10 @@ export interface TextDefaults {
56
68
  color?: ColorInput;
57
69
  align?: "left" | "center" | "right";
58
70
  lineHeight?: number;
71
+ underline?: boolean;
72
+ strikethrough?: boolean;
73
+ skipInk?: boolean;
74
+ letterSpacing?: number;
59
75
  }
60
76
  /** Maps the `Text`-style option names onto a partial engine `ResolvedTextStyle` (only the set
61
77
  * fields), for seeding the inheritance cascade. */
package/dist/api/text.js CHANGED
@@ -50,6 +50,9 @@ export function span(text, style = {}) {
50
50
  ? toFontStyle(style.bold, style.italic)
51
51
  : undefined,
52
52
  fontColor: style.color !== undefined ? toColor(style.color) : undefined,
53
+ underline: style.underline,
54
+ strikethrough: style.strikethrough,
55
+ letterSpacing: style.letterSpacing,
53
56
  href: style.href,
54
57
  dest: style.to,
55
58
  };
@@ -79,6 +82,10 @@ export function Text(content, opts = {}) {
79
82
  maxLines: opts.maxLines,
80
83
  overflow: opts.overflow,
81
84
  lineHeight: opts.lineHeight,
85
+ underline: opts.underline,
86
+ strikethrough: opts.strikethrough,
87
+ skipInk: opts.skipInk,
88
+ letterSpacing: opts.letterSpacing,
82
89
  role: opts.role,
83
90
  });
84
91
  }
@@ -103,5 +110,13 @@ export function toTextStyleOverride(opts) {
103
110
  style.textAlignment = ALIGN[opts.align];
104
111
  if (opts.lineHeight !== undefined)
105
112
  style.lineHeight = opts.lineHeight;
113
+ if (opts.underline !== undefined)
114
+ style.underline = opts.underline;
115
+ if (opts.strikethrough !== undefined)
116
+ style.strikethrough = opts.strikethrough;
117
+ if (opts.skipInk !== undefined)
118
+ style.skipInk = opts.skipInk;
119
+ if (opts.letterSpacing !== undefined)
120
+ style.letterSpacing = opts.letterSpacing;
106
121
  return style;
107
122
  }
@@ -12,14 +12,22 @@ interface ContainerElementParams extends SizedElement, WithChildren {
12
12
  /** Width/height as a fraction (0..1) of the offered box instead of `width`/`height` (relative sizing). */
13
13
  widthFactor?: number;
14
14
  heightFactor?: number;
15
+ /** Start this stack on a fresh page (CSS `break-before: page`). */
16
+ breakBefore?: boolean;
17
+ /** Start everything after this stack on a fresh page (CSS `break-after: page`). */
18
+ breakAfter?: boolean;
15
19
  }
16
20
  export declare class ContainerElement extends SizedPDFElement implements Fragmentable {
17
21
  private children;
18
22
  private gap;
19
23
  private main;
20
24
  private cross;
25
+ private breakBefore;
26
+ private breakAfter;
21
27
  private requested;
22
- constructor({ x, y, width, height, children, gap, main, cross, widthFactor, heightFactor, }: ContainerElementParams);
28
+ constructor({ x, y, width, height, children, gap, main, cross, widthFactor, heightFactor, breakBefore, breakAfter, }: ContainerElementParams);
29
+ breaksBefore(): boolean;
30
+ breaksAfter(): boolean;
23
31
  /**
24
32
  * Splits the vertical stack across pages. Children are measured against the content
25
33
  * width and packed until one would exceed `maxHeight`. That straddling child is itself
@@ -31,6 +39,7 @@ export declare class ContainerElement extends SizedPDFElement implements Fragmen
31
39
  * Flex children make the container absorb the leftover space, so it never overflows -
32
40
  * in that case we don't fragment and hand the whole container back as `fitted`.
33
41
  */
42
+ hasForcedBreak(): boolean;
34
43
  fragment(maxHeight: number, width: number, ctx: LayoutContext): FragmentResult;
35
44
  private cloneWithChildren;
36
45
  relativeSizeFactor(horizontal: boolean): number | undefined;
@@ -1,15 +1,23 @@
1
1
  import { FlexLayoutHelper, VERTICAL_AXIS } from "../utils/flex-layout.js";
2
2
  import { resolveExtent } from "../layout/box-constraints.js";
3
- import { packChildren } from "../layout/fragmentation.js";
3
+ import { childrenForceBreak, packChildren, } from "../layout/fragmentation.js";
4
4
  import { FlexiblePDFElement, SizedPDFElement, } from "./pdf-element.js";
5
5
  export class ContainerElement extends SizedPDFElement {
6
- constructor({ x, y, width, height, children, gap, main, cross, widthFactor, heightFactor, }) {
6
+ constructor({ x, y, width, height, children, gap, main, cross, widthFactor, heightFactor, breakBefore, breakAfter, }) {
7
7
  super({ x, y, width, height });
8
8
  this.children = children;
9
9
  this.gap = gap ?? 0;
10
10
  this.main = main ?? "start";
11
11
  this.cross = cross ?? "stretch";
12
12
  this.requested = { width, height, widthFactor, heightFactor };
13
+ this.breakBefore = breakBefore ?? false;
14
+ this.breakAfter = breakAfter ?? false;
15
+ }
16
+ breaksBefore() {
17
+ return this.breakBefore;
18
+ }
19
+ breaksAfter() {
20
+ return this.breakAfter;
13
21
  }
14
22
  /**
15
23
  * Splits the vertical stack across pages. Children are measured against the content
@@ -22,18 +30,27 @@ export class ContainerElement extends SizedPDFElement {
22
30
  * Flex children make the container absorb the leftover space, so it never overflows -
23
31
  * in that case we don't fragment and hand the whole container back as `fitted`.
24
32
  */
33
+ hasForcedBreak() {
34
+ return childrenForceBreak(this.children);
35
+ }
25
36
  fragment(maxHeight, width, ctx) {
26
- const { fitted, remainder } = packChildren(this.children, maxHeight, width, ctx, this.gap);
27
- // Fits as one region: hand the whole container back so the page renders unchanged
28
- // (its normal layout distributes flex / fills the page).
29
- if (remainder.length === 0)
37
+ const { fitted, remainder, forceBreak } = packChildren(this.children, maxHeight, width, ctx, this.gap);
38
+ // Fits as one region with no forced cut: hand the whole container back so the page renders
39
+ // unchanged (its normal layout distributes flex / fills the page). A forced break is excluded
40
+ // here even when nothing follows it (a trailing `PageBreak`): return the packed children, which
41
+ // no longer hold the consumed break marker, so it does not leak into the render pass.
42
+ if (remainder.length === 0 && !forceBreak)
30
43
  return { fitted: this, remainder: null };
31
44
  return {
32
45
  fitted: this.cloneWithChildren(fitted),
33
- remainder: this.cloneWithChildren(remainder),
46
+ // Only the CONTINUATION carries `breakAfter`, so a stack that splits across pages still breaks
47
+ // after its LAST fragment (not after the first one). `breakBefore` is dropped on both: it was
48
+ // already honoured by the parent before this stack was ever fragmented.
49
+ remainder: remainder.length === 0 ? null : this.cloneWithChildren(remainder, this.breakAfter),
50
+ forceBreak,
34
51
  };
35
52
  }
36
- cloneWithChildren(children) {
53
+ cloneWithChildren(children, breakAfter = false) {
37
54
  return new ContainerElement({
38
55
  x: this.x,
39
56
  y: this.y,
@@ -45,6 +62,7 @@ export class ContainerElement extends SizedPDFElement {
45
62
  gap: this.gap,
46
63
  main: this.main,
47
64
  cross: this.cross,
65
+ breakAfter,
48
66
  });
49
67
  }
50
68
  relativeSizeFactor(horizontal) {
@@ -6,6 +6,8 @@ export * from "./rectangle-element.js";
6
6
  export * from "./image-element.js";
7
7
  export * from "./layout/expanded-element.js";
8
8
  export * from "./layout/padding-element.js";
9
+ export * from "./layout/page-break-element.js";
10
+ export * from "./layout/keep-together-element.js";
9
11
  export * from "./layout/default-text-style-element.js";
10
12
  export * from "./layout/positioned-element.js";
11
13
  export * from "./layout/link-element.js";
@@ -6,6 +6,8 @@ export * from "./rectangle-element.js";
6
6
  export * from "./image-element.js";
7
7
  export * from "./layout/expanded-element.js";
8
8
  export * from "./layout/padding-element.js";
9
+ export * from "./layout/page-break-element.js";
10
+ export * from "./layout/keep-together-element.js";
9
11
  export * from "./layout/default-text-style-element.js";
10
12
  export * from "./layout/positioned-element.js";
11
13
  export * from "./layout/link-element.js";
@@ -0,0 +1,42 @@
1
+ import { BoxConstraints, Offset, Size } from "../../layout/box-constraints.js";
2
+ import { Fragmentable, FragmentResult } from "../../layout/fragmentation.js";
3
+ import { LayoutContext, PDFElement } from "../pdf-element.js";
4
+ /**
5
+ * A layout-TRANSPARENT wrapper that tries to keep its content on ONE page (CSS `break-inside: avoid`).
6
+ * It delegates all layout and rendering to its child, so the visual result is byte-identical to the
7
+ * unwrapped content; only pagination behaves differently.
8
+ *
9
+ * The rule, decided in `fragment`:
10
+ * 1. Fits in the space offered here -> place it whole.
11
+ * 2. Does not fit here but fits on a FRESH -> veto the split: defer the whole group to the next
12
+ * full page page (`fitted: null`), so it lands together there.
13
+ * 3. Taller than a whole page -> drop the veto and split normally. A group that can
14
+ * never fit on one page must break, or pagination
15
+ * could not terminate. Inner `keepTogether`s survive
16
+ * this (they are separate elements, re-evaluated when
17
+ * the child splits) - graceful degrade, outer->inner.
18
+ *
19
+ * A forced page break inside the group (a `PageBreak`, or a `breakBefore`/`breakAfter`) contradicts
20
+ * keeping it together: the break wins (CSS behaves the same), and we WARN once rather than silently
21
+ * swallowing either intent.
22
+ */
23
+ export declare class KeepTogetherElement extends PDFElement implements Fragmentable {
24
+ private child;
25
+ private warnedBreakConflict;
26
+ constructor({ child }: {
27
+ child: PDFElement;
28
+ });
29
+ calculateLayout(constraints: BoxConstraints, offset: Offset, ctx: LayoutContext): Size;
30
+ relativeSizeFactor(horizontal: boolean): number | undefined;
31
+ needsBoundedMain(horizontal: boolean): boolean;
32
+ hasForcedBreak(): boolean;
33
+ breaksBefore(): boolean;
34
+ breaksAfter(): boolean;
35
+ fragment(maxHeight: number, width: number, ctx: LayoutContext): FragmentResult;
36
+ /** Delegate the actual split to the child, unwrapping - once we have decided to break the group, the
37
+ * pieces just flow (any INNER keepTogether lives in the child subtree and still vetoes there). */
38
+ private splitChild;
39
+ getProps(): {
40
+ child: PDFElement;
41
+ };
42
+ }
@@ -0,0 +1,85 @@
1
+ import { BoxConstraints } from "../../layout/box-constraints.js";
2
+ import { isFragmentable } from "../../layout/fragmentation.js";
3
+ import { PDFElement } from "../pdf-element.js";
4
+ /**
5
+ * A layout-TRANSPARENT wrapper that tries to keep its content on ONE page (CSS `break-inside: avoid`).
6
+ * It delegates all layout and rendering to its child, so the visual result is byte-identical to the
7
+ * unwrapped content; only pagination behaves differently.
8
+ *
9
+ * The rule, decided in `fragment`:
10
+ * 1. Fits in the space offered here -> place it whole.
11
+ * 2. Does not fit here but fits on a FRESH -> veto the split: defer the whole group to the next
12
+ * full page page (`fitted: null`), so it lands together there.
13
+ * 3. Taller than a whole page -> drop the veto and split normally. A group that can
14
+ * never fit on one page must break, or pagination
15
+ * could not terminate. Inner `keepTogether`s survive
16
+ * this (they are separate elements, re-evaluated when
17
+ * the child splits) - graceful degrade, outer->inner.
18
+ *
19
+ * A forced page break inside the group (a `PageBreak`, or a `breakBefore`/`breakAfter`) contradicts
20
+ * keeping it together: the break wins (CSS behaves the same), and we WARN once rather than silently
21
+ * swallowing either intent.
22
+ */
23
+ export class KeepTogetherElement extends PDFElement {
24
+ constructor({ child }) {
25
+ super();
26
+ this.warnedBreakConflict = false;
27
+ this.child = child;
28
+ }
29
+ calculateLayout(constraints, offset, ctx) {
30
+ return this.child.calculateLayout(constraints, offset, ctx);
31
+ }
32
+ // Transparent: forward the sizing hints the flex parent reads, so a %-sized or Spacer-holding child
33
+ // keeps working when it is wrapped (the wrapper must not swallow these).
34
+ relativeSizeFactor(horizontal) {
35
+ return this.child.relativeSizeFactor(horizontal);
36
+ }
37
+ needsBoundedMain(horizontal) {
38
+ return this.child.needsBoundedMain(horizontal);
39
+ }
40
+ // Transparent to break control: a break-before/after on the wrapped element still applies to the group
41
+ // as a whole, and an inner forced break still forces the parent to fragment us (so `fragment` runs).
42
+ hasForcedBreak() {
43
+ return this.child.hasForcedBreak();
44
+ }
45
+ breaksBefore() {
46
+ return this.child.breaksBefore();
47
+ }
48
+ breaksAfter() {
49
+ return this.child.breaksAfter();
50
+ }
51
+ fragment(maxHeight, width, ctx) {
52
+ // Contradiction: a forced break inside the group beats keeping it together. Split at the break by
53
+ // delegating to the child (which honours it), and say so once - never keep such a group whole.
54
+ if (this.child.hasForcedBreak()) {
55
+ if (!this.warnedBreakConflict) {
56
+ this.warnedBreakConflict = true;
57
+ console.warn("keepTogether contains a forced page break (PageBreak / breakBefore / breakAfter). The break " +
58
+ "wins and the group is split. Remove the break, or the keepTogether, to resolve this.");
59
+ }
60
+ return this.splitChild(maxHeight, width, ctx);
61
+ }
62
+ const natural = this.child.calculateLayout(BoxConstraints.loose(width, Infinity), { x: 0, y: 0 }, ctx).height;
63
+ // 1. Fits in the offered space: keep whole.
64
+ if (natural <= maxHeight)
65
+ return { fitted: this, remainder: null };
66
+ // 2. Would fit on a fresh full page: veto the split, defer the whole group to the next page. Only
67
+ // reachable in a PARTIAL region (something is already on this page); alone on a full page,
68
+ // `maxHeight` IS the page body, so `natural > maxHeight` means case 3, never this.
69
+ const pageBody = ctx.pageBodyHeight ?? maxHeight;
70
+ if (natural <= pageBody)
71
+ return { fitted: null, remainder: this };
72
+ // 3. Bigger than a whole page: it can never be kept together, so split it.
73
+ return this.splitChild(maxHeight, width, ctx);
74
+ }
75
+ /** Delegate the actual split to the child, unwrapping - once we have decided to break the group, the
76
+ * pieces just flow (any INNER keepTogether lives in the child subtree and still vetoes there). */
77
+ splitChild(maxHeight, width, ctx) {
78
+ if (!isFragmentable(this.child))
79
+ return { fitted: null, remainder: this };
80
+ return this.child.fragment(maxHeight, width, ctx);
81
+ }
82
+ getProps() {
83
+ return { child: this.child };
84
+ }
85
+ }
@@ -15,6 +15,9 @@ export declare class PaddingElement extends SizedPDFElement implements Fragmenta
15
15
  * child can't be split, the whole padding moves on as the remainder.
16
16
  */
17
17
  fragment(maxHeight: number, width: number, ctx: LayoutContext): FragmentResult;
18
+ hasForcedBreak(): boolean;
19
+ breaksBefore(): boolean;
20
+ breaksAfter(): boolean;
18
21
  private cloneWithChild;
19
22
  calculateLayout(constraints: BoxConstraints, offset: Offset, ctx: LayoutContext): Size;
20
23
  getProps(): {
@@ -25,8 +25,20 @@ export class PaddingElement extends SizedPDFElement {
25
25
  return {
26
26
  fitted: split.fitted ? this.cloneWithChild(split.fitted) : null,
27
27
  remainder: split.remainder ? this.cloneWithChild(split.remainder) : null,
28
+ forceBreak: split.forceBreak,
28
29
  };
29
30
  }
31
+ hasForcedBreak() {
32
+ return this.child.hasForcedBreak();
33
+ }
34
+ // Padding is a transparent single-child wrapper, so a break-before/after on the wrapped element must
35
+ // still be seen by the parent packer at this boundary (otherwise it is swallowed).
36
+ breaksBefore() {
37
+ return this.child.breaksBefore();
38
+ }
39
+ breaksAfter() {
40
+ return this.child.breaksAfter();
41
+ }
30
42
  cloneWithChild(child) {
31
43
  return new PaddingElement({ margin: this.margin, child });
32
44
  }
@@ -0,0 +1,17 @@
1
+ import { BoxConstraints, Offset, Size } from "../../layout/box-constraints.js";
2
+ import { LayoutContext, PDFElement } from "../pdf-element.js";
3
+ /**
4
+ * A forced page break. Placed in a flow (a `Column`/`Row`/`Box`), it makes everything AFTER it start
5
+ * on a fresh page, no matter how much space is left. It takes zero space and draws nothing - the
6
+ * pagination packer consumes it at the cut, so it never reaches the render pass.
7
+ *
8
+ * Nesting works: a break deep inside a container is honoured because the packer fragments any child
9
+ * whose subtree `hasForcedBreak()`, and the resulting `forceBreak` bubbles up so the outer flow stops
10
+ * too. A break wins over a `keepTogether` (a forced break cannot be avoided) - CSS behaves the same.
11
+ */
12
+ export declare class PageBreakElement extends PDFElement {
13
+ calculateLayout(_constraints: BoxConstraints, _offset: Offset, _ctx: LayoutContext): Size;
14
+ isPageBreak(): boolean;
15
+ hasForcedBreak(): boolean;
16
+ getProps(): Record<string, never>;
17
+ }