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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/README.md +7 -7
  2. package/dist/api/descriptor.js +26 -1
  3. package/dist/api/forms.d.ts +347 -0
  4. package/dist/api/forms.js +189 -0
  5. package/dist/api/index.d.ts +1 -0
  6. package/dist/api/index.js +1 -0
  7. package/dist/api/layout.d.ts +47 -14
  8. package/dist/api/layout.js +47 -11
  9. package/dist/api/structure.d.ts +11 -0
  10. package/dist/api/structure.js +2 -0
  11. package/dist/api/text.d.ts +18 -2
  12. package/dist/api/text.js +15 -0
  13. package/dist/crypto/legacy-handler.d.ts +43 -0
  14. package/dist/crypto/legacy-handler.js +145 -0
  15. package/dist/crypto/security-handler.d.ts +16 -0
  16. package/dist/crypto/security-handler.js +26 -0
  17. package/dist/edit/acroform-reader.d.ts +70 -0
  18. package/dist/edit/acroform-reader.js +126 -0
  19. package/dist/edit/appearance.d.ts +47 -0
  20. package/dist/edit/appearance.js +171 -0
  21. package/dist/edit/document.d.ts +135 -0
  22. package/dist/edit/document.js +649 -0
  23. package/dist/edit/fill.d.ts +48 -0
  24. package/dist/edit/fill.js +395 -0
  25. package/dist/edit/flatten.d.ts +42 -0
  26. package/dist/edit/flatten.js +437 -0
  27. package/dist/edit/index.d.ts +18 -0
  28. package/dist/edit/index.js +16 -0
  29. package/dist/edit/lexer.d.ts +30 -0
  30. package/dist/edit/lexer.js +285 -0
  31. package/dist/edit/objects.d.ts +63 -0
  32. package/dist/edit/objects.js +56 -0
  33. package/dist/edit/writer.d.ts +53 -0
  34. package/dist/edit/writer.js +261 -0
  35. package/dist/elements/container-element.d.ts +10 -1
  36. package/dist/elements/container-element.js +26 -8
  37. package/dist/elements/forms/checkbox-element.d.ts +51 -0
  38. package/dist/elements/forms/checkbox-element.js +48 -0
  39. package/dist/elements/forms/choice-element.d.ts +53 -0
  40. package/dist/elements/forms/choice-element.js +59 -0
  41. package/dist/elements/forms/push-button-element.d.ts +56 -0
  42. package/dist/elements/forms/push-button-element.js +49 -0
  43. package/dist/elements/forms/radio-element.d.ts +52 -0
  44. package/dist/elements/forms/radio-element.js +49 -0
  45. package/dist/elements/forms/signature-element.d.ts +54 -0
  46. package/dist/elements/forms/signature-element.js +47 -0
  47. package/dist/elements/forms/text-field-element.d.ts +61 -0
  48. package/dist/elements/forms/text-field-element.js +54 -0
  49. package/dist/elements/index.d.ts +8 -0
  50. package/dist/elements/index.js +8 -0
  51. package/dist/elements/layout/keep-together-element.d.ts +42 -0
  52. package/dist/elements/layout/keep-together-element.js +85 -0
  53. package/dist/elements/layout/padding-element.d.ts +3 -0
  54. package/dist/elements/layout/padding-element.js +12 -0
  55. package/dist/elements/layout/page-break-element.d.ts +17 -0
  56. package/dist/elements/layout/page-break-element.js +24 -0
  57. package/dist/elements/layout/positioned-element.d.ts +10 -7
  58. package/dist/elements/layout/positioned-element.js +21 -9
  59. package/dist/elements/page-element.d.ts +11 -1
  60. package/dist/elements/page-element.js +41 -16
  61. package/dist/elements/pdf-element.d.ts +28 -0
  62. package/dist/elements/pdf-element.js +32 -0
  63. package/dist/elements/rectangle-element.d.ts +10 -1
  64. package/dist/elements/rectangle-element.js +27 -6
  65. package/dist/elements/row-element.d.ts +9 -1
  66. package/dist/elements/row-element.js +9 -1
  67. package/dist/elements/text-element.d.ts +30 -4
  68. package/dist/elements/text-element.js +40 -17
  69. package/dist/forms/acroform.d.ts +63 -0
  70. package/dist/forms/acroform.js +427 -0
  71. package/dist/forms/appearance.d.ts +58 -0
  72. package/dist/forms/appearance.js +204 -0
  73. package/dist/forms/field.d.ts +164 -0
  74. package/dist/forms/field.js +1 -0
  75. package/dist/forms/pdf.d.ts +19 -0
  76. package/dist/forms/pdf.js +28 -0
  77. package/dist/ir/display-list.d.ts +18 -1
  78. package/dist/layout/fragmentation.d.ts +12 -0
  79. package/dist/layout/fragmentation.js +45 -8
  80. package/dist/renderer/form-field-renderer.d.ts +20 -0
  81. package/dist/renderer/form-field-renderer.js +12 -0
  82. package/dist/renderer/keep-together-renderer.d.ts +10 -0
  83. package/dist/renderer/keep-together-renderer.js +12 -0
  84. package/dist/renderer/page-break-renderer.d.ts +14 -0
  85. package/dist/renderer/page-break-renderer.js +15 -0
  86. package/dist/renderer/page-renderer.js +8 -2
  87. package/dist/renderer/pdf-backend.d.ts +7 -0
  88. package/dist/renderer/pdf-backend.js +48 -6
  89. package/dist/renderer/pdf-document-class.js +3 -0
  90. package/dist/renderer/pdf-document-renderer.js +26 -7
  91. package/dist/renderer/pdf-renderer.js +25 -1
  92. package/dist/renderer/text-renderer.d.ts +1 -1
  93. package/dist/renderer/text-renderer.js +107 -59
  94. package/dist/text/advance.d.ts +34 -0
  95. package/dist/text/advance.js +42 -0
  96. package/dist/text/line-breaker.d.ts +9 -8
  97. package/dist/text/line-breaker.js +28 -21
  98. package/dist/text/line-metrics.d.ts +49 -0
  99. package/dist/text/line-metrics.js +57 -0
  100. package/dist/text/text-decoration.d.ts +50 -0
  101. package/dist/text/text-decoration.js +62 -0
  102. package/dist/text/text-style.d.ts +13 -3
  103. package/dist/text/text-style.js +10 -3
  104. package/dist/utils/afm-parser.d.ts +34 -0
  105. package/dist/utils/afm-parser.js +67 -0
  106. package/dist/utils/dest-registry.d.ts +5 -2
  107. package/dist/utils/dest-registry.js +5 -5
  108. package/dist/utils/font-metrics.d.ts +14 -0
  109. package/dist/utils/outline.js +1 -2
  110. package/dist/utils/pdf-object-manager.d.ts +45 -1
  111. package/dist/utils/pdf-object-manager.js +193 -26
  112. package/dist/utils/struct-tree.js +2 -3
  113. package/dist/utils/ttf-parser.d.ts +53 -0
  114. package/dist/utils/ttf-parser.js +368 -0
  115. package/dist/validators/element-validator.js +7 -3
  116. package/package.json +9 -2
@@ -1,14 +1,17 @@
1
1
  import { BoxConstraints } from "../../layout/box-constraints.js";
2
2
  import { PDFElement } from "../pdf-element.js";
3
3
  /**
4
- * An out-of-flow child, placed relative to the nearest enclosing positioning frame (a `relative`
5
- * Box). It takes ZERO space in the normal flow - `calculateLayout` returns `Size(0,0)` and instead
6
- * registers a placement closure on the frame. The frame runs that closure once it knows its own
7
- * size, so `right`/`bottom` resolve against the final box and the child can overflow it (a negative
8
- * `top`/`left` makes it poke into / out of the corner, e.g. a badge or tab).
4
+ * An out-of-flow child, placed relative to the nearest enclosing positioning frame: a `relative`
5
+ * Box, or failing that the page's content box. It takes ZERO space in the normal flow -
6
+ * `calculateLayout` returns `Size(0,0)` and instead registers a placement closure on the frame. The
7
+ * frame runs that closure once it knows its own size, so `right`/`bottom` resolve against the final
8
+ * box and the child can overflow it (a negative `top`/`left` makes it poke into / out of the
9
+ * corner, e.g. a badge or tab).
9
10
  *
10
- * With no frame in scope the element is a no-op (nothing is drawn) - `Positioned` only makes sense
11
- * inside a `relative` Box.
11
+ * Every page supplies a frame - header, footer and body alike - so within a document there is
12
+ * always one. Outside of one we REFUSE rather than draw: the element used to fall through to its
13
+ * child's default (0, 0), which is how a header watermark landed silently in the page corner
14
+ * (ISSUE-4). Content that lands somewhere unasked is worse than content that does not render.
12
15
  */
13
16
  export class PositionedElement extends PDFElement {
14
17
  constructor({ child, top, right, bottom, left, h, v, x, y }) {
@@ -17,8 +20,12 @@ export class PositionedElement extends PDFElement {
17
20
  this.insets = { top, right, bottom, left, h, v, x, y };
18
21
  }
19
22
  calculateLayout(_constraints, _offset, ctx) {
23
+ if (!ctx.frame) {
24
+ throw new Error("Positioned found no positioning frame. It must sit inside a Page (header, footer or body) " +
25
+ "or inside a Box({ relative: true }).");
26
+ }
20
27
  // Defer to the frame: it calls back once it has sized itself. Out of flow either way.
21
- ctx.frame?.place.push((frame, frameCtx) => this.placeInFrame(frame, frameCtx));
28
+ ctx.frame.place.push((frame, frameCtx) => this.placeInFrame(frame, frameCtx));
22
29
  return { width: 0, height: 0 };
23
30
  }
24
31
  /** Lays the child out at the resolved position inside (or overflowing) the frame box. */
@@ -35,7 +42,12 @@ export class PositionedElement extends PDFElement {
35
42
  : Infinity;
36
43
  const constraints = BoxConstraints.loose(width, height);
37
44
  // Measure first (so end/center anchors and right/bottom edges resolve against the child's size).
38
- const measured = this.child.calculateLayout(constraints, { x: 0, y: 0 }, ctx);
45
+ // The measuring pass gets a throwaway `place` list: this subtree is laid out twice, and a nested
46
+ // out-of-flow child would otherwise register its placement on both passes.
47
+ const measureCtx = ctx.frame
48
+ ? { ...ctx, frame: { ...ctx.frame, place: [] } }
49
+ : ctx;
50
+ const measured = this.child.calculateLayout(constraints, { x: 0, y: 0 }, measureCtx);
39
51
  // Resolve one axis: a near-edge pins to origin, a far-edge pins to the far side; otherwise the
40
52
  // anchor (default `start`) positions the child and the nudge shifts it from there.
41
53
  const place = (origin, frameExtent, childExtent, nearEdge, farEdge, anchor, nudge) => {
@@ -2,7 +2,7 @@ import { PageSize } from "../constants/page-sizes.js";
2
2
  import { Orientation } from "../renderer/pdf-config.js";
3
3
  import type { ColorMode, DefaultFont, Margin } from "../renderer/index.js";
4
4
  import { BoxConstraints, Offset, Size } from "../layout/box-constraints.js";
5
- import { LayoutContext, PDFElement, WithChildren } from "./pdf-element.js";
5
+ import { LayoutContext, PDFElement, PositioningFrame, WithChildren } from "./pdf-element.js";
6
6
  import { TextElement } from "./text-element.js";
7
7
  export interface PDFPageConfig {
8
8
  pageSize?: PageSize;
@@ -54,6 +54,16 @@ export interface PageBands {
54
54
  * no header/footer the bands are zero and the body equals the full content box - identical
55
55
  * to a page without them.
56
56
  */
57
+ /**
58
+ * The page's positioning frame: its CONTENT box (paper minus margins). Header, footer and body all
59
+ * resolve a `Positioned` against this same box, so `top`/`bottom` mean the same thing in each.
60
+ *
61
+ * `layoutPageBands` lays the bands out, so its caller must hand it a context carrying this frame -
62
+ * otherwise a `Positioned` in a band has nothing to resolve against (ISSUE-4). A caller that only
63
+ * wants the band HEIGHTS may throw the frame away without draining it: an out-of-flow child
64
+ * contributes no height.
65
+ */
66
+ export declare function pageFrame(config: PDFPageConfig): PositioningFrame;
57
67
  export declare function layoutPageBands(config: PDFPageConfig, header: PDFElement | undefined, footer: PDFElement | undefined, ctx: LayoutContext): PageBands;
58
68
  export declare class PageElement extends PDFElement {
59
69
  private config;
@@ -36,6 +36,19 @@ export function resolvePageContentBox(config) {
36
36
  * no header/footer the bands are zero and the body equals the full content box - identical
37
37
  * to a page without them.
38
38
  */
39
+ /**
40
+ * The page's positioning frame: its CONTENT box (paper minus margins). Header, footer and body all
41
+ * resolve a `Positioned` against this same box, so `top`/`bottom` mean the same thing in each.
42
+ *
43
+ * `layoutPageBands` lays the bands out, so its caller must hand it a context carrying this frame -
44
+ * otherwise a `Positioned` in a band has nothing to resolve against (ISSUE-4). A caller that only
45
+ * wants the band HEIGHTS may throw the frame away without draining it: an out-of-flow child
46
+ * contributes no height.
47
+ */
48
+ export function pageFrame(config) {
49
+ const { origin, width, height } = resolvePageContentBox(config);
50
+ return { origin, size: { width, height }, place: [] };
51
+ }
39
52
  export function layoutPageBands(config, header, footer, ctx) {
40
53
  const { origin, width, height } = resolvePageContentBox(config);
41
54
  let headerHeight = 0;
@@ -44,8 +57,13 @@ export function layoutPageBands(config, header, footer, ctx) {
44
57
  }
45
58
  let footerHeight = 0;
46
59
  if (footer) {
47
- // Measure first to learn its height, then place it flush against the bottom edge.
48
- footerHeight = footer.calculateLayout(BoxConstraints.loose(width, Infinity), origin, ctx).height;
60
+ // Measure first to learn its height, then place it flush against the bottom edge. The MEASURING
61
+ // pass gets a throwaway `place` list: an out-of-flow child registers a placement every time it is
62
+ // laid out, and this subtree is laid out twice. Only the second, real pass may register.
63
+ const measureCtx = ctx.frame
64
+ ? { ...ctx, frame: { ...ctx.frame, place: [] } }
65
+ : ctx;
66
+ footerHeight = footer.calculateLayout(BoxConstraints.loose(width, Infinity), origin, measureCtx).height;
49
67
  footer.calculateLayout(BoxConstraints.loose(width, Infinity), { x: origin.x, y: origin.y + height - footerHeight }, ctx);
50
68
  }
51
69
  return {
@@ -76,24 +94,31 @@ export class PageElement extends PDFElement {
76
94
  onOverflow: ctx.onOverflow,
77
95
  pageInfo: ctx.pageInfo, // the render pass supplies it; absent during pagination
78
96
  };
97
+ // The PAGE is a positioning frame, and the frame is the content box - not the body region left
98
+ // over between the bands. So `Positioned` means the same thing in the header, in the footer and
99
+ // in the body, `bottom: 0` is the foot of the page rather than the top of the footer, and adding
100
+ // a header no longer shifts a page-level `Positioned` that nobody touched. A `relative` Box
101
+ // still overrides it for its own subtree.
102
+ //
103
+ // Built BEFORE the bands are laid out: they need to see it, and their heights are not known yet
104
+ // anyway. (That is the whole of ISSUE-4 - a `Positioned` in a band had no frame to resolve
105
+ // against, so it silently stayed at the page's top-left corner.)
106
+ const frame = pageFrame(this.config);
107
+ const frameCtx = { ...pageCtx, frame };
79
108
  // Place the header/footer bands; the body gets the region left in between (the whole
80
109
  // content box when there is neither - byte-identical to a plain page).
81
- const bands = layoutPageBands(this.config, this.header, this.footer, pageCtx);
110
+ const bands = layoutPageBands(this.config, this.header, this.footer, frameCtx);
82
111
  const childConstraints = BoxConstraints.loose(bands.bodyWidth, bands.bodyHeight);
83
- // The page body is itself a positioning frame: a `Positioned` with no `relative` ancestor
84
- // resolves against the content box (a `relative` Box overrides it for its own subtree). Drained
85
- // after the body is laid out, so a page-level Positioned isn't a silent no-op.
86
- const frame = {
87
- origin: bands.bodyOrigin,
88
- size: { width: bands.bodyWidth, height: bands.bodyHeight },
89
- place: [],
90
- };
91
- const bodyCtx = { ...pageCtx, frame };
92
- this.children.forEach((child) => child.calculateLayout(childConstraints, bands.bodyOrigin, bodyCtx));
112
+ this.children.forEach((child) => child.calculateLayout(childConstraints, bands.bodyOrigin, frameCtx));
113
+ // Drained last, once every band and the body have registered: an out-of-flow child is placed
114
+ // against the final frame box and paints on top of the flow content.
115
+ //
116
+ // The callback gets `frameCtx`, not `pageCtx`: a `Positioned` NESTED inside an out-of-flow child
117
+ // must still see a frame. It appends to `frame.place` while we walk it, and a `for..of` over an
118
+ // array visits what was appended, so nested placements drain in the same loop.
93
119
  for (const place of frame.place)
94
- place(frame, pageCtx);
95
- const { width, height } = resolvePageContentBox(this.config);
96
- return { width, height };
120
+ place(frame, frameCtx);
121
+ return { width: frame.size.width, height: frame.size.height };
97
122
  }
98
123
  getProps() {
99
124
  return {
@@ -51,6 +51,10 @@ export interface LayoutContext {
51
51
  /** What to do when an element overflows a page region and cannot break (set from the render option;
52
52
  * absent = clip silently). Evaluated in `packChildren` where the forced placement happens. */
53
53
  onOverflow?: OverflowPolicy;
54
+ /** The FULL body height of the current page (between header and footer), set by the page driver. A
55
+ * `keepTogether` group reads it to decide the degrade rule: veto splitting only when the group would
56
+ * fit on a fresh page; a group taller than a whole page drops the veto and splits. */
57
+ pageBodyHeight?: number;
54
58
  }
55
59
  export declare abstract class PDFElement {
56
60
  private _structId;
@@ -80,6 +84,30 @@ export declare abstract class PDFElement {
80
84
  * the parent hands it the line's bounded main extent instead of `Infinity`. Default: no.
81
85
  */
82
86
  needsBoundedMain(_horizontal: boolean): boolean;
87
+ /**
88
+ * Whether this element IS a forced page break (a `PageBreak`). The pagination packer cuts the flow
89
+ * at such a marker: everything before it stays, everything after starts a new page. Default: no.
90
+ */
91
+ isPageBreak(): boolean;
92
+ /**
93
+ * Whether this element is, or CONTAINS anywhere in its subtree, a forced page break. A container
94
+ * that merely fits by height must still be fragmented when this is true, so a break nested inside
95
+ * it is honoured rather than swallowed. Containers override this to recurse into their children.
96
+ * Default: no.
97
+ */
98
+ hasForcedBreak(): boolean;
99
+ /**
100
+ * Whether this element asks to START on a fresh page (CSS `break-before: page`, react-pdf's `break`
101
+ * prop). Read by the parent packer at the child boundary, not by the element itself; ignored when the
102
+ * element is already at the top of a region (nothing before it), so it never makes an empty page.
103
+ * Default: no.
104
+ */
105
+ breaksBefore(): boolean;
106
+ /**
107
+ * Whether everything AFTER this element must start a fresh page (CSS `break-after: page`). Read by the
108
+ * parent packer once this element has been placed whole. Default: no.
109
+ */
110
+ breaksAfter(): boolean;
83
111
  }
84
112
  export declare abstract class SizedPDFElement extends PDFElement {
85
113
  protected x: number;
@@ -33,6 +33,38 @@ export class PDFElement {
33
33
  needsBoundedMain(_horizontal) {
34
34
  return false;
35
35
  }
36
+ /**
37
+ * Whether this element IS a forced page break (a `PageBreak`). The pagination packer cuts the flow
38
+ * at such a marker: everything before it stays, everything after starts a new page. Default: no.
39
+ */
40
+ isPageBreak() {
41
+ return false;
42
+ }
43
+ /**
44
+ * Whether this element is, or CONTAINS anywhere in its subtree, a forced page break. A container
45
+ * that merely fits by height must still be fragmented when this is true, so a break nested inside
46
+ * it is honoured rather than swallowed. Containers override this to recurse into their children.
47
+ * Default: no.
48
+ */
49
+ hasForcedBreak() {
50
+ return false;
51
+ }
52
+ /**
53
+ * Whether this element asks to START on a fresh page (CSS `break-before: page`, react-pdf's `break`
54
+ * prop). Read by the parent packer at the child boundary, not by the element itself; ignored when the
55
+ * element is already at the top of a region (nothing before it), so it never makes an empty page.
56
+ * Default: no.
57
+ */
58
+ breaksBefore() {
59
+ return false;
60
+ }
61
+ /**
62
+ * Whether everything AFTER this element must start a fresh page (CSS `break-after: page`). Read by the
63
+ * parent packer once this element has been placed whole. Default: no.
64
+ */
65
+ breaksAfter() {
66
+ return false;
67
+ }
36
68
  }
37
69
  export class SizedPDFElement extends PDFElement {
38
70
  constructor(data) {
@@ -28,6 +28,10 @@ interface RectangleElementParams extends SizedElement, WithChildren {
28
28
  widthFactor?: number;
29
29
  /** Height as a fraction (0..1) of the offered height; see `widthFactor`. */
30
30
  heightFactor?: number;
31
+ /** Start this box on a fresh page (CSS `break-before: page`). */
32
+ breakBefore?: boolean;
33
+ /** Start everything after this box on a fresh page (CSS `break-after: page`). */
34
+ breakAfter?: boolean;
31
35
  }
32
36
  export declare class RectangleElement extends SizedPDFElement implements Fragmentable {
33
37
  private children;
@@ -38,8 +42,12 @@ export declare class RectangleElement extends SizedPDFElement implements Fragmen
38
42
  private sideBorders?;
39
43
  private relative;
40
44
  private overflow;
45
+ private breakBefore;
46
+ private breakAfter;
41
47
  private sizeMemory;
42
- constructor({ children, color, backgroundColor, borderWidth, width, height, radius, sideBorders, relative, overflow, widthFactor, heightFactor, }: RectangleElementParams);
48
+ constructor({ children, color, backgroundColor, borderWidth, width, height, radius, sideBorders, relative, overflow, widthFactor, heightFactor, breakBefore, breakAfter, }: RectangleElementParams);
49
+ breaksBefore(): boolean;
50
+ breaksAfter(): boolean;
43
51
  /**
44
52
  * Splits the bordered box across pages (box-decoration-break: clone - every fragment
45
53
  * gets its own full border). The children stack is packed into the space left after
@@ -49,6 +57,7 @@ export declare class RectangleElement extends SizedPDFElement implements Fragmen
49
57
  * box is sized by what it actually holds on each page.
50
58
  */
51
59
  fragment(maxHeight: number, width: number, ctx: LayoutContext): FragmentResult;
60
+ hasForcedBreak(): boolean;
52
61
  private cloneWithChildren;
53
62
  relativeSizeFactor(horizontal: boolean): number | undefined;
54
63
  /** A box has no flex children of its own, but it must not swallow the need of the stack inside it: a
@@ -1,9 +1,9 @@
1
1
  import { Color } from "../common/color.js";
2
2
  import { BoxConstraints, resolveExtent } from "../layout/box-constraints.js";
3
- import { packChildren } from "../layout/fragmentation.js";
3
+ import { childrenForceBreak, packChildren, } from "../layout/fragmentation.js";
4
4
  import { SizedPDFElement, } from "./pdf-element.js";
5
5
  export class RectangleElement extends SizedPDFElement {
6
- constructor({ children = [], color = new Color(0, 0, 0), backgroundColor, borderWidth, width, height, radius, sideBorders, relative, overflow, widthFactor, heightFactor, }) {
6
+ constructor({ children = [], color = new Color(0, 0, 0), backgroundColor, borderWidth, width, height, radius, sideBorders, relative, overflow, widthFactor, heightFactor, breakBefore, breakAfter, }) {
7
7
  super({ x: 0, y: 0, width, height });
8
8
  this.children = [];
9
9
  this.children = children;
@@ -15,8 +15,16 @@ export class RectangleElement extends SizedPDFElement {
15
15
  this.sideBorders = sideBorders;
16
16
  this.relative = relative ?? false;
17
17
  this.overflow = overflow ?? "visible";
18
+ this.breakBefore = breakBefore ?? false;
19
+ this.breakAfter = breakAfter ?? false;
18
20
  this.sizeMemory = { x: 0, y: 0, width, height, widthFactor, heightFactor };
19
21
  }
22
+ breaksBefore() {
23
+ return this.breakBefore;
24
+ }
25
+ breaksAfter() {
26
+ return this.breakAfter;
27
+ }
20
28
  /**
21
29
  * Splits the bordered box across pages (box-decoration-break: clone - every fragment
22
30
  * gets its own full border). The children stack is packed into the space left after
@@ -34,18 +42,30 @@ export class RectangleElement extends SizedPDFElement {
34
42
  // Border-box: the content is inset by the border on top and bottom, so a fragment
35
43
  // holding `c` of content is `c + 2*border` tall. (Derived, not a fudge factor.)
36
44
  const innerMaxHeight = maxHeight - 2 * this.borderWidth;
37
- const { fitted, remainder } = packChildren(this.children, innerMaxHeight, innerWidth, ctx);
38
- if (remainder.length === 0)
45
+ const { fitted, remainder, forceBreak } = packChildren(this.children, innerMaxHeight, innerWidth, ctx);
46
+ // Fits with no forced cut: return the whole box unchanged. A trailing forced break is the
47
+ // exception - fall through so the box is rebuilt from the packed children (without the consumed
48
+ // break marker), rather than handing back the original that still holds it.
49
+ if (remainder.length === 0 && !forceBreak)
39
50
  return { fitted: this, remainder: null };
40
51
  const contentHeight = (kids) => kids.reduce((sum, child) => sum +
41
52
  child.calculateLayout(BoxConstraints.loose(innerWidth, Infinity), { x: 0, y: 0 }, ctx)
42
53
  .height, 0);
43
54
  return {
44
55
  fitted: this.cloneWithChildren(fitted, contentHeight(fitted) + 2 * this.borderWidth),
45
- remainder: this.cloneWithChildren(remainder, contentHeight(remainder) + 2 * this.borderWidth),
56
+ // A trailing forced break leaves nothing to carry over: no remainder box, no extra page.
57
+ // Only the CONTINUATION carries `breakAfter` (a box that splits still breaks after its LAST
58
+ // fragment, not its first); `breakBefore` is dropped, already honoured before the split.
59
+ remainder: remainder.length === 0
60
+ ? null
61
+ : this.cloneWithChildren(remainder, contentHeight(remainder) + 2 * this.borderWidth, this.breakAfter),
62
+ forceBreak,
46
63
  };
47
64
  }
48
- cloneWithChildren(children, height) {
65
+ hasForcedBreak() {
66
+ return childrenForceBreak(this.children);
67
+ }
68
+ cloneWithChildren(children, height, breakAfter = false) {
49
69
  return new RectangleElement({
50
70
  x: 0,
51
71
  y: 0,
@@ -62,6 +82,7 @@ export class RectangleElement extends SizedPDFElement {
62
82
  sideBorders: this.sideBorders,
63
83
  relative: this.relative,
64
84
  overflow: this.overflow,
85
+ breakAfter,
65
86
  });
66
87
  }
67
88
  relativeSizeFactor(horizontal) {
@@ -13,6 +13,10 @@ interface RowElementParams extends WithChildren {
13
13
  height?: number;
14
14
  widthFactor?: number;
15
15
  heightFactor?: number;
16
+ /** Start this row on a fresh page (CSS `break-before: page`). */
17
+ breakBefore?: boolean;
18
+ /** Start everything after this row on a fresh page (CSS `break-after: page`). */
19
+ breakAfter?: boolean;
16
20
  }
17
21
  /**
18
22
  * Horizontal stack: the mirror of `ContainerElement` (Column). Children are laid out
@@ -31,8 +35,12 @@ export declare class RowElement extends SizedPDFElement {
31
35
  private gap;
32
36
  private main;
33
37
  private cross;
38
+ private breakBefore;
39
+ private breakAfter;
34
40
  private requested;
35
- constructor({ children, gap, main, cross, width, height, widthFactor, heightFactor, }: RowElementParams);
41
+ constructor({ children, gap, main, cross, width, height, widthFactor, heightFactor, breakBefore, breakAfter, }: RowElementParams);
42
+ breaksBefore(): boolean;
43
+ breaksAfter(): boolean;
36
44
  relativeSizeFactor(horizontal: boolean): number | undefined;
37
45
  /** The mirror of `ContainerElement`: a Row's OWN flex child needs a bounded width, and the need of any
38
46
  * descendant propagates on both axes (see the Column for the reasoning). */
@@ -14,13 +14,21 @@ import { FlexiblePDFElement, SizedPDFElement, } from "./pdf-element.js";
14
14
  * Grid/Table concern.
15
15
  */
16
16
  export class RowElement extends SizedPDFElement {
17
- constructor({ children, gap, main, cross, width, height, widthFactor, heightFactor, }) {
17
+ constructor({ children, gap, main, cross, width, height, widthFactor, heightFactor, breakBefore, breakAfter, }) {
18
18
  super({ x: 0, y: 0 });
19
19
  this.children = children;
20
20
  this.gap = gap ?? 0;
21
21
  this.main = main ?? "start";
22
22
  this.cross = cross ?? "stretch";
23
23
  this.requested = { width, height, widthFactor, heightFactor };
24
+ this.breakBefore = breakBefore ?? false;
25
+ this.breakAfter = breakAfter ?? false;
26
+ }
27
+ breaksBefore() {
28
+ return this.breakBefore;
29
+ }
30
+ breaksAfter() {
31
+ return this.breakAfter;
24
32
  }
25
33
  relativeSizeFactor(horizontal) {
26
34
  return horizontal ? this.requested.widthFactor : this.requested.heightFactor;
@@ -14,6 +14,11 @@ export interface TextSegment {
14
14
  href?: string;
15
15
  /** Internal named destination (an `Anchor`): this segment links to it (a /GoTo /Link annotation). */
16
16
  dest?: string;
17
+ /** Unset inherits the Text's own setting; `true`/`false` overrides it for this run only. */
18
+ underline?: boolean;
19
+ strikethrough?: boolean;
20
+ /** Extra space after every glyph, in points; unset inherits the Text's own value. */
21
+ letterSpacing?: number;
17
22
  }
18
23
  /** Accessibility role for the tagged structure tree: a heading level or a paragraph (the default). */
19
24
  export type TextRole = "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "p";
@@ -30,8 +35,17 @@ interface TextElementParams {
30
35
  maxLines?: number;
31
36
  /** What to do past `maxLines`: `"clip"` (default) drops them, `"ellipsis"` ends with "…". */
32
37
  overflow?: TextOverflow;
33
- /** Line-height multiplier: each line is `fontSize * lineHeight` tall (default `1`). */
38
+ /** Line-height multiplier: each line is `fontSize * lineHeight` tall. Unset means the font's
39
+ * natural line height (`ascent + descent + lineGap`), like CSS `line-height: normal`. */
34
40
  lineHeight?: number;
41
+ /** Draw a line under the text, at the position and thickness the font declares. */
42
+ underline?: boolean;
43
+ /** Draw a line through the text, at half its x-height. */
44
+ strikethrough?: boolean;
45
+ /** Let the underline step around descenders. Needs an embedded font. */
46
+ skipInk?: boolean;
47
+ /** Extra space after every glyph, in points (CSS `letter-spacing`). Default 0. */
48
+ letterSpacing?: number;
35
49
  /** Accessibility role for the tagged structure tree (heading level or paragraph; default `"p"`). */
36
50
  role?: TextRole;
37
51
  }
@@ -42,17 +56,25 @@ export declare class TextElement extends SizedPDFElement implements Fragmentable
42
56
  private readonly rawColor?;
43
57
  private readonly rawTextAlignment?;
44
58
  private readonly rawLineHeight?;
59
+ private readonly rawUnderline?;
60
+ private readonly rawStrikethrough?;
61
+ private readonly rawSkipInk?;
62
+ private readonly rawLetterSpacing?;
45
63
  private fontSize;
46
64
  private fontFamily;
47
65
  private fontStyle;
48
66
  private color;
49
67
  private textAlignment;
50
- private lineHeight;
68
+ private lineHeight?;
69
+ private underline;
70
+ private strikethrough;
71
+ private skipInk;
72
+ private letterSpacing;
51
73
  private content;
52
74
  private maxLines?;
53
75
  private overflow;
54
76
  private readonly role?;
55
- constructor({ fontSize, content, fontFamily, fontStyle, color, textAlignment, maxLines, overflow, lineHeight, role, }: TextElementParams);
77
+ constructor({ fontSize, content, fontFamily, fontStyle, color, textAlignment, maxLines, overflow, lineHeight, underline, strikethrough, skipInk, letterSpacing, role, }: TextElementParams);
56
78
  private resolveStyle;
57
79
  private applyStyle;
58
80
  /**
@@ -88,7 +110,11 @@ export declare class TextElement extends SizedPDFElement implements Fragmentable
88
110
  textAlignment: HorizontalAlignment;
89
111
  maxLines: number | undefined;
90
112
  overflow: TextOverflow;
91
- lineHeight: number;
113
+ lineHeight: number | undefined;
114
+ underline: boolean;
115
+ strikethrough: boolean;
116
+ skipInk: boolean;
117
+ letterSpacing: number;
92
118
  role: TextRole | undefined;
93
119
  };
94
120
  }
@@ -5,9 +5,11 @@
5
5
  import { TextRenderer } from "../renderer/text-renderer.js";
6
6
  import { DEFAULT_TEXT_STYLE } from "../text/text-style.js";
7
7
  import { wrapStringIntoLines, breakSegmentsIntoLines, segmentLinesToSegments, } from "../text/line-breaker.js";
8
+ import { lineBoxForSegmentLine, lineBoxForString } from "../text/line-metrics.js";
9
+ import { runAdvance } from "../text/advance.js";
8
10
  import { SizedPDFElement } from "./pdf-element.js";
9
11
  export class TextElement extends SizedPDFElement {
10
- constructor({ fontSize, content, fontFamily, fontStyle, color, textAlignment, maxLines, overflow = "clip", lineHeight, role, }) {
12
+ constructor({ fontSize, content, fontFamily, fontStyle, color, textAlignment, maxLines, overflow = "clip", lineHeight, underline, strikethrough, skipInk, letterSpacing, role, }) {
11
13
  super({ x: 0, y: 0 });
12
14
  this.role = role;
13
15
  this.rawFontSize = fontSize;
@@ -16,6 +18,10 @@ export class TextElement extends SizedPDFElement {
16
18
  this.rawColor = color;
17
19
  this.rawTextAlignment = textAlignment;
18
20
  this.rawLineHeight = lineHeight;
21
+ this.rawUnderline = underline;
22
+ this.rawStrikethrough = strikethrough;
23
+ this.rawSkipInk = skipInk;
24
+ this.rawLetterSpacing = letterSpacing;
19
25
  this.content = content;
20
26
  this.maxLines = maxLines;
21
27
  this.overflow = overflow;
@@ -32,6 +38,10 @@ export class TextElement extends SizedPDFElement {
32
38
  this.color = this.rawColor ?? ts.color;
33
39
  this.textAlignment = this.rawTextAlignment ?? ts.textAlignment;
34
40
  this.lineHeight = this.rawLineHeight ?? ts.lineHeight;
41
+ this.underline = this.rawUnderline ?? ts.underline;
42
+ this.strikethrough = this.rawStrikethrough ?? ts.strikethrough;
43
+ this.skipInk = this.rawSkipInk ?? ts.skipInk;
44
+ this.letterSpacing = this.rawLetterSpacing ?? ts.letterSpacing;
35
45
  }
36
46
  /**
37
47
  * Splits the paragraph at line boxes (Slice 1). The lines that fit in `maxHeight` stay;
@@ -45,11 +55,12 @@ export class TextElement extends SizedPDFElement {
45
55
  ? this.fragmentString(this.content, maxHeight, width, ctx)
46
56
  : this.fragmentSegments(this.content, maxHeight, width, ctx);
47
57
  }
48
- // Plain string: every wrapped line is `fontSize` tall (matches calculateTextHeight),
49
- // so `floor(maxHeight / fontSize)` lines fit.
58
+ // Plain string: every wrapped line gets the same box (the same one calculateTextHeight uses),
59
+ // so `floor(maxHeight / lineBox)` lines fit.
50
60
  fragmentString(content, maxHeight, width, ctx) {
51
- const lines = wrapStringIntoLines(content, this.fontFamily, this.fontSize, this.fontStyle, width, ctx.metrics, this.maxLines, this.overflow);
52
- const fittedLineCount = Math.floor(maxHeight / (this.fontSize * this.lineHeight));
61
+ const lines = wrapStringIntoLines(content, this.fontFamily, this.fontSize, this.fontStyle, width, ctx.metrics, this.maxLines, this.overflow, this.letterSpacing);
62
+ const box = lineBoxForString(ctx.metrics, this.fontFamily, this.fontStyle, this.fontSize, this.lineHeight);
63
+ const fittedLineCount = Math.floor(maxHeight / box.height);
53
64
  if (fittedLineCount <= 0)
54
65
  return { fitted: null, remainder: this };
55
66
  if (fittedLineCount >= lines.length)
@@ -59,18 +70,20 @@ export class TextElement extends SizedPDFElement {
59
70
  remainder: this.cloneWithContent(lines.slice(fittedLineCount).join(" ")),
60
71
  };
61
72
  }
62
- // Styled segments: each line's height is its tallest font (maxFontSize), so pack lines
63
- // by cumulative leading. Rebuild the fitted/remainder halves back into TextSegment[].
73
+ // Styled segments: each line's height comes from the fonts on it, so pack lines by cumulative
74
+ // box height. Rebuild the fitted/remainder halves back into TextSegment[].
64
75
  fragmentSegments(content, maxHeight, width, ctx) {
65
- const lines = breakSegmentsIntoLines(content, {
76
+ const defaults = {
66
77
  fontFamily: this.fontFamily,
67
78
  fontSize: this.fontSize,
68
79
  fontStyle: this.fontStyle,
69
- }, width, ctx.metrics, this.maxLines, this.overflow);
80
+ letterSpacing: this.letterSpacing,
81
+ };
82
+ const lines = breakSegmentsIntoLines(content, defaults, width, ctx.metrics, this.maxLines, this.overflow);
70
83
  let used = 0;
71
84
  let fittedLineCount = 0;
72
85
  for (const line of lines) {
73
- const lineBox = line.maxFontSize * this.lineHeight;
86
+ const lineBox = lineBoxForSegmentLine(line, defaults, ctx.metrics, this.lineHeight).height;
74
87
  if (used + lineBox > maxHeight)
75
88
  break;
76
89
  used += lineBox;
@@ -98,6 +111,10 @@ export class TextElement extends SizedPDFElement {
98
111
  maxLines: this.maxLines,
99
112
  overflow: this.overflow,
100
113
  lineHeight: this.lineHeight,
114
+ underline: this.underline,
115
+ strikethrough: this.strikethrough,
116
+ skipInk: this.skipInk,
117
+ letterSpacing: this.letterSpacing,
101
118
  role: this.role,
102
119
  }).adoptStructId(this); // a wrapped remainder is the SAME logical paragraph (one P across pages)
103
120
  }
@@ -112,7 +129,7 @@ export class TextElement extends SizedPDFElement {
112
129
  ? constraints.maxWidth
113
130
  : this.naturalWidth(ctx.metrics);
114
131
  const wrapWidth = this.width ?? 0;
115
- this.height = TextRenderer.calculateTextHeight(this.content, this.fontSize, this.fontFamily, this.fontStyle, ctx.metrics, wrapWidth, this.maxLines, this.overflow, this.lineHeight);
132
+ this.height = TextRenderer.calculateTextHeight(this.content, this.fontSize, this.fontFamily, this.fontStyle, ctx.metrics, wrapWidth, this.maxLines, this.overflow, this.lineHeight, this.letterSpacing);
116
133
  // Top-left coordinates (y = top of the text box). The baseline offset and the
117
134
  // Y-flip are applied downstream (the line-builder positions baselines, the seam
118
135
  // flips to PDF), so the element stays coordinate-system-blind.
@@ -127,22 +144,24 @@ export class TextElement extends SizedPDFElement {
127
144
  * - enough to tip a borderline string (e.g. "20 Jun 2026", wider than "04 Jul 2026" only because
128
145
  * 'n' beats 'l') one bit over its own width, dropping the last word onto a second line. */
129
146
  naturalWidth(metrics) {
130
- const oneLine = (text, family, size, style) => {
147
+ const oneLine = (text, family, size, style, letterSpacing) => {
148
+ const font = { fontFamily: family, fontSize: size, fontStyle: style };
131
149
  const words = text.split(" ");
132
- const space = metrics.getCharWidth(" ", size, undefined, family, style);
150
+ const space = runAdvance(metrics, " ", font, letterSpacing);
133
151
  let width = 0;
134
152
  words.forEach((word, i) => {
135
- const w = metrics.getStringWidth(word, family, size, style);
136
- // Group (word + space) as one term, exactly like the breaker - see the note above.
153
+ const w = runAdvance(metrics, word, font, letterSpacing);
154
+ // Group (word + space) as one term, exactly like the breaker - see the note above. Both use
155
+ // the same `runAdvance`, so the two agree bit for bit even with letterSpacing.
137
156
  width += i < words.length - 1 ? w + space : w;
138
157
  });
139
158
  return width;
140
159
  };
141
160
  if (typeof this.content === "string") {
142
- return oneLine(this.content, this.fontFamily, this.fontSize, this.fontStyle);
161
+ return oneLine(this.content, this.fontFamily, this.fontSize, this.fontStyle, this.letterSpacing);
143
162
  }
144
163
  return this.content.reduce((sum, seg) => sum +
145
- oneLine(seg.content, seg.fontFamily ?? this.fontFamily, seg.fontSize ?? this.fontSize, seg.fontStyle ?? this.fontStyle), 0);
164
+ oneLine(seg.content, seg.fontFamily ?? this.fontFamily, seg.fontSize ?? this.fontSize, seg.fontStyle ?? this.fontStyle, seg.letterSpacing ?? this.letterSpacing), 0);
146
165
  }
147
166
  getProps() {
148
167
  return {
@@ -159,6 +178,10 @@ export class TextElement extends SizedPDFElement {
159
178
  maxLines: this.maxLines,
160
179
  overflow: this.overflow,
161
180
  lineHeight: this.lineHeight,
181
+ underline: this.underline,
182
+ strikethrough: this.strikethrough,
183
+ skipInk: this.skipInk,
184
+ letterSpacing: this.letterSpacing,
162
185
  role: this.role,
163
186
  };
164
187
  }