@jasy/pdf 1.0.0-beta.4 → 1.0.0-beta.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 (111) hide show
  1. package/README.md +3 -2
  2. package/dist/api/color.d.ts +1 -24
  3. package/dist/api/color.js +2 -278
  4. package/dist/api/content.d.ts +7 -2
  5. package/dist/api/content.js +17 -7
  6. package/dist/api/descriptor.js +30 -3
  7. package/dist/api/flow.d.ts +55 -0
  8. package/dist/api/flow.js +71 -0
  9. package/dist/api/gradient.d.ts +1 -53
  10. package/dist/api/gradient.js +2 -97
  11. package/dist/api/index.d.ts +2 -0
  12. package/dist/api/index.js +2 -0
  13. package/dist/api/layout.js +4 -3
  14. package/dist/api/structure.d.ts +1 -1
  15. package/dist/api/table.js +3 -1
  16. package/dist/api/text.d.ts +8 -8
  17. package/dist/api/text.js +6 -0
  18. package/dist/canvas/painter.d.ts +2 -2
  19. package/dist/canvas/painter.js +2 -2
  20. package/dist/common/color-input.d.ts +24 -0
  21. package/dist/common/color-input.js +278 -0
  22. package/dist/common/diagnostics.d.ts +9 -1
  23. package/dist/common/gradient.d.ts +53 -0
  24. package/dist/common/gradient.js +97 -0
  25. package/dist/elements/container-element.d.ts +3 -0
  26. package/dist/elements/container-element.js +17 -5
  27. package/dist/elements/image-element.d.ts +37 -26
  28. package/dist/elements/image-element.js +88 -50
  29. package/dist/elements/index.d.ts +3 -0
  30. package/dist/elements/index.js +3 -0
  31. package/dist/elements/layout/column-break-element.d.ts +17 -0
  32. package/dist/elements/layout/column-break-element.js +26 -0
  33. package/dist/elements/layout/deferred-element.d.ts +9 -1
  34. package/dist/elements/layout/deferred-element.js +18 -3
  35. package/dist/elements/layout/expanded-element.d.ts +4 -0
  36. package/dist/elements/layout/expanded-element.js +14 -1
  37. package/dist/elements/layout/flow-element.d.ts +109 -0
  38. package/dist/elements/layout/flow-element.js +317 -0
  39. package/dist/elements/layout/keep-together-element.js +14 -2
  40. package/dist/elements/layout/padding-element.d.ts +3 -0
  41. package/dist/elements/layout/padding-element.js +9 -2
  42. package/dist/elements/layout/page-builder-element.js +2 -2
  43. package/dist/elements/layout/positioned-element.d.ts +9 -2
  44. package/dist/elements/layout/positioned-element.js +6 -2
  45. package/dist/elements/layout/proxy-element.d.ts +4 -0
  46. package/dist/elements/layout/proxy-element.js +12 -0
  47. package/dist/elements/layout/region-element.d.ts +53 -0
  48. package/dist/elements/layout/region-element.js +109 -0
  49. package/dist/elements/layout/repeating-header-element.d.ts +3 -0
  50. package/dist/elements/layout/repeating-header-element.js +16 -1
  51. package/dist/elements/layout/rotated-element.d.ts +2 -0
  52. package/dist/elements/layout/rotated-element.js +8 -0
  53. package/dist/elements/pdf-element.d.ts +32 -3
  54. package/dist/elements/pdf-element.js +50 -1
  55. package/dist/elements/rectangle-element.d.ts +12 -6
  56. package/dist/elements/rectangle-element.js +31 -12
  57. package/dist/elements/row-element.d.ts +6 -12
  58. package/dist/elements/row-element.js +34 -4
  59. package/dist/elements/svg-element.js +4 -10
  60. package/dist/elements/text-element.d.ts +17 -1
  61. package/dist/elements/text-element.js +63 -40
  62. package/dist/ir/display-list.d.ts +6 -1
  63. package/dist/layout/box-constraints.d.ts +12 -0
  64. package/dist/layout/box-constraints.js +17 -0
  65. package/dist/layout/collect-images.js +8 -4
  66. package/dist/layout/flex-item.d.ts +2 -0
  67. package/dist/layout/flex-item.js +14 -2
  68. package/dist/layout/fragmentation.d.ts +11 -5
  69. package/dist/layout/fragmentation.js +27 -7
  70. package/dist/layout/layout-cache.d.ts +8 -8
  71. package/dist/layout/layout-cache.js +10 -7
  72. package/dist/platform/browser-fs.d.ts +1 -2
  73. package/dist/platform/browser-fs.js +3 -6
  74. package/dist/platform/node-fs.d.ts +0 -1
  75. package/dist/platform/node-fs.js +0 -4
  76. package/dist/renderer/column-break-renderer.d.ts +8 -0
  77. package/dist/renderer/column-break-renderer.js +15 -0
  78. package/dist/renderer/emoji-image.js +3 -1
  79. package/dist/renderer/flow-renderer.d.ts +16 -0
  80. package/dist/renderer/flow-renderer.js +21 -0
  81. package/dist/renderer/image-renderer.js +31 -54
  82. package/dist/renderer/index.d.ts +2 -0
  83. package/dist/renderer/index.js +2 -0
  84. package/dist/renderer/page-renderer.js +3 -4
  85. package/dist/renderer/pdf-backend.js +9 -2
  86. package/dist/renderer/pdf-document-renderer.js +2 -1
  87. package/dist/renderer/pdf-renderer.js +9 -0
  88. package/dist/renderer/positioned-renderer.d.ts +14 -2
  89. package/dist/renderer/positioned-renderer.js +40 -4
  90. package/dist/renderer/rectangle-renderer.d.ts +1 -1
  91. package/dist/renderer/rectangle-renderer.js +11 -6
  92. package/dist/renderer/rotated-renderer.js +3 -2
  93. package/dist/renderer/text-renderer.js +25 -28
  94. package/dist/svg/index.d.ts +1 -1
  95. package/dist/svg/index.js +26 -5
  96. package/dist/svg/style.js +1 -1
  97. package/dist/text/line-breaker.d.ts +23 -0
  98. package/dist/text/line-breaker.js +142 -65
  99. package/dist/text/line-metrics.d.ts +2 -0
  100. package/dist/text/line-metrics.js +5 -1
  101. package/dist/text/word-splitting.js +11 -5
  102. package/dist/utils/flex-layout.d.ts +3 -1
  103. package/dist/utils/flex-layout.js +141 -33
  104. package/dist/utils/image-helper.d.ts +59 -11
  105. package/dist/utils/image-helper.js +207 -80
  106. package/dist/utils/pdf-object-manager.d.ts +6 -3
  107. package/dist/utils/pdf-object-manager.js +31 -7
  108. package/dist/utils/renderer-registry.d.ts +6 -0
  109. package/dist/utils/renderer-registry.js +1 -1
  110. package/dist/utils/ttf-parser.js +3 -2
  111. package/package.json +9 -2
package/README.md CHANGED
@@ -142,7 +142,7 @@ verify all of it:
142
142
  - **PDF/A-3, matched not approximated.** The conformance graph is hand-built and **passes veraPDF**, the
143
143
  official ISO 19005 validator.
144
144
  - **Byte-exact round-trips.** Generate and parse are inverses: `generate → parse → regenerate` reproduces
145
- the identical XML. Over 1,600 tests hold the line across the workspace.
145
+ the identical XML. Over 2,400 tests hold the line across the workspace.
146
146
  - **Schematron, local.** The official EN-16931 + XRechnung rules run via saxon-js (the real XSLT, in pure
147
147
  JS) - no Java, no upload.
148
148
  - **Vectors, not bitmaps.** `Image("logo.svg")` keeps a logo a VECTOR - our own SVG reader and our own
@@ -166,13 +166,14 @@ verify all of it:
166
166
  | **[@jasy/cli](https://npmx.dev/@jasy/cli)** | the `jasy` terminal: read · validate · export, headless **and** interactive |
167
167
  | **[@jasy/pdf](https://npmx.dev/@jasy/pdf)** | the declarative, Flutter-style PDF layout engine that powers them |
168
168
  | **[@jasy/vue](https://npmx.dev/@jasy/vue)** | author PDFs as Vue components - and render them in the browser |
169
+ | **[@jasy/react](https://npmx.dev/@jasy/react)** | author PDFs as React components - and render them in the browser |
169
170
  | **[@jasy/nuxt](https://npmx.dev/@jasy/nuxt)** | the Nuxt module: zero-config PDFs, client **or** server |
170
171
 
171
172
  ---
172
173
 
173
174
  ## Why you can trust it
174
175
 
175
- - **Over 1,600 tests, green** across the workspace (1,190 in the engine alone). The generator and the parser
176
+ - **Over 2,400 tests, green** across the workspace (over 1,700 in the engine alone). The generator and the parser
176
177
  are **byte-exact inverses**: `generate → parse → regenerate` reproduces the identical XML. Nothing is
177
178
  silently lost.
178
179
  - **The same rules the authorities use** - the official KoSIT EN-16931 + XRechnung Schematron, and the
@@ -1,24 +1 @@
1
- import { Color } from "../common/color.js";
2
- /**
3
- * Every way to name a color. The FORM picks the convention, so there is never a guess
4
- * (locked design §2):
5
- *
6
- * | form | example | meaning |
7
- * |---------------------------|--------------------------|--------------------------------|
8
- * | named (full CSS set) | `"steelblue"`, `"transparent"` | the ~148 CSS color names |
9
- * | hex 6 / 3 | `"#1450aa"` / `"#14a"` | CSS RGB |
10
- * | hex 8 / 4 | `"#1450aacc"` / `"#14ac"`| CSS RGBA (alpha LAST) |
11
- * | number | `0xff1450aa` | Flutter ARGB (alpha FIRST) |
12
- * | `rgb()` / `rgba()` | `rgb(20,90,170)` | channels 0–255 (`rgba` a=0–1) |
13
- * | `Color` instance | `new Color(20,90,170)` | the engine layer, still valid |
14
- */
15
- export type ColorInput = string | number | Color;
16
- /** Channels 0–255, fully opaque. */
17
- export declare function rgb(r: number, g: number, b: number): Color;
18
- /** Channels 0–255, alpha 0–1. */
19
- export declare function rgba(r: number, g: number, b: number, a: number): Color;
20
- /**
21
- * Normalizes any `ColorInput` to an engine `Color`. The single funnel every factory uses,
22
- * so a color means the same thing no matter how it was written.
23
- */
24
- export declare function toColor(input: ColorInput): Color;
1
+ export * from "../common/color-input.js";
package/dist/api/color.js CHANGED
@@ -1,278 +1,2 @@
1
- import { Color } from "../common/color.js";
2
- /** Channels 0–255, fully opaque. */
3
- export function rgb(r, g, b) {
4
- return new Color(r, g, b, 1);
5
- }
6
- /** Channels 0–255, alpha 0–1. */
7
- export function rgba(r, g, b, a) {
8
- return new Color(r, g, b, a);
9
- }
10
- /**
11
- * Normalizes any `ColorInput` to an engine `Color`. The single funnel every factory uses,
12
- * so a color means the same thing no matter how it was written.
13
- */
14
- export function toColor(input) {
15
- if (input instanceof Color)
16
- return input;
17
- if (typeof input === "number")
18
- return fromArgbNumber(input);
19
- const s = input.trim().toLowerCase();
20
- if (s.startsWith("#"))
21
- return fromHex(s);
22
- const named = CSS_COLORS[s];
23
- if (named)
24
- return new Color(named[0], named[1], named[2], named[3] ?? 1);
25
- const call = /^([a-z-]+)\(([^)]*)\)$/.exec(s);
26
- if (call)
27
- return fromFunction(call[1], call[2], input);
28
- throw new Error(`Unknown color: "${input}"`);
29
- }
30
- /**
31
- * The CSS colour FUNCTIONS. `rgb()`/`rgba()` and `hsl()`/`hsla()` are the sRGB family, so they convert
32
- * exactly and are supported; a wide-gamut function (`color()`, `lab()`, `oklch()`) is named and
33
- * refused rather than silently squashed into sRGB, since the result would be a colour nobody chose.
34
- *
35
- * Both syntaxes are accepted, because both occur in the wild: the legacy comma form
36
- * `rgb(20, 90, 170)` and the modern space form with an optional slash-alpha `rgb(20 90 170 / 50%)`.
37
- */
38
- function fromFunction(name, body, original) {
39
- if (name !== "rgb" && name !== "rgba" && name !== "hsl" && name !== "hsla") {
40
- throw new Error(`Unsupported color function "${name}()" in "${String(original)}". ` +
41
- `Use a hex value, a CSS colour name, rgb()/rgba() or hsl()/hsla().`);
42
- }
43
- const [main, alphaPart] = body.split("/");
44
- const parts = main
45
- .trim()
46
- .split(/[\s,]+/)
47
- .filter(Boolean);
48
- const alphaText = alphaPart !== undefined ? alphaPart.trim() : parts[3];
49
- if (parts.length < 3)
50
- throw new Error(`Unknown color: "${String(original)}"`);
51
- const alpha = alphaText === undefined ? 1 : ratio(alphaText, original);
52
- const [a, b, c] = parts;
53
- if (name === "rgb" || name === "rgba") {
54
- return new Color(channel(a, original), channel(b, original), channel(c, original), alpha);
55
- }
56
- const [r, g, bl] = hslToRgb(number(a, original), ratio(b, original), ratio(c, original));
57
- return new Color(r, g, bl, alpha);
58
- }
59
- const NUMERIC = /^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/;
60
- /**
61
- * One component of a colour function. `Number.parseFloat` stops at the first character it cannot use
62
- * and returns what it had, so `rgb(20abc, 90, 170)` would silently become `rgb(20, 90, 170)` - a
63
- * colour nobody wrote. The whole token has to be a number.
64
- */
65
- function number(text, original) {
66
- const t = text.trim();
67
- if (!NUMERIC.test(t))
68
- throw new Error(`Unknown color: "${String(original)}"`);
69
- return Number(t);
70
- }
71
- /** An rgb channel: 0-255, or a percentage of 255. */
72
- const channel = (text, original) => text.trim().endsWith("%")
73
- ? (number(text.trim().slice(0, -1), original) / 100) * 255
74
- : number(text, original);
75
- /** A 0..1 ratio written either as a number or as a percentage (alpha, saturation, lightness). */
76
- const ratio = (text, original) => text.trim().endsWith("%")
77
- ? number(text.trim().slice(0, -1), original) / 100
78
- : number(text, original);
79
- /** CSS hue-saturation-lightness to 0-255 channels. */
80
- function hslToRgb(hue, s, l) {
81
- const h = ((hue % 360) + 360) % 360;
82
- const c = (1 - Math.abs(2 * l - 1)) * s;
83
- const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
84
- const m = l - c / 2;
85
- const sector = Math.floor(h / 60) % 6;
86
- const rgbPrime = [
87
- [c, x, 0],
88
- [x, c, 0],
89
- [0, c, x],
90
- [0, x, c],
91
- [x, 0, c],
92
- [c, 0, x],
93
- ][sector];
94
- return rgbPrime.map((v) => Math.round((v + m) * 255));
95
- }
96
- /** Flutter ARGB: 0xAARRGGBB, alpha FIRST. A 6-digit number has alpha 0x00 = transparent. */
97
- function fromArgbNumber(n) {
98
- const a = (n >>> 24) & 0xff;
99
- const r = (n >>> 16) & 0xff;
100
- const g = (n >>> 8) & 0xff;
101
- const b = n & 0xff;
102
- return new Color(r, g, b, a / 255);
103
- }
104
- /** CSS hex: #RGB, #RGBA, #RRGGBB, #RRGGBBAA. Alpha is LAST. */
105
- function fromHex(s) {
106
- const hex = s.slice(1);
107
- const expand = (h) => h
108
- .split("")
109
- .map((c) => c + c)
110
- .join("");
111
- let full;
112
- if (hex.length === 3 || hex.length === 4)
113
- full = expand(hex);
114
- else if (hex.length === 6 || hex.length === 8)
115
- full = hex;
116
- else
117
- throw new Error(`Invalid hex color: "${s}"`);
118
- const r = parseInt(full.slice(0, 2), 16);
119
- const g = parseInt(full.slice(2, 4), 16);
120
- const b = parseInt(full.slice(4, 6), 16);
121
- const a = full.length === 8 ? parseInt(full.slice(6, 8), 16) / 255 : 1;
122
- if ([r, g, b].some(Number.isNaN))
123
- throw new Error(`Invalid hex color: "${s}"`);
124
- return new Color(r, g, b, a);
125
- }
126
- // The full CSS named-color set (~148, incl. the synonyms grey/gray and `transparent`).
127
- // [r, g, b] or [r, g, b, alpha]. Lower-cased keys; `toColor` lower-cases the input.
128
- const CSS_COLORS = {
129
- transparent: [0, 0, 0, 0],
130
- aliceblue: [240, 248, 255],
131
- antiquewhite: [250, 235, 215],
132
- aqua: [0, 255, 255],
133
- aquamarine: [127, 255, 212],
134
- azure: [240, 255, 255],
135
- beige: [245, 245, 220],
136
- bisque: [255, 228, 196],
137
- black: [0, 0, 0],
138
- blanchedalmond: [255, 235, 205],
139
- blue: [0, 0, 255],
140
- blueviolet: [138, 43, 226],
141
- brown: [165, 42, 42],
142
- burlywood: [222, 184, 135],
143
- cadetblue: [95, 158, 160],
144
- chartreuse: [127, 255, 0],
145
- chocolate: [210, 105, 30],
146
- coral: [255, 127, 80],
147
- cornflowerblue: [100, 149, 237],
148
- cornsilk: [255, 248, 220],
149
- crimson: [220, 20, 60],
150
- cyan: [0, 255, 255],
151
- darkblue: [0, 0, 139],
152
- darkcyan: [0, 139, 139],
153
- darkgoldenrod: [184, 134, 11],
154
- darkgray: [169, 169, 169],
155
- darkgrey: [169, 169, 169],
156
- darkgreen: [0, 100, 0],
157
- darkkhaki: [189, 183, 107],
158
- darkmagenta: [139, 0, 139],
159
- darkolivegreen: [85, 107, 47],
160
- darkorange: [255, 140, 0],
161
- darkorchid: [153, 50, 204],
162
- darkred: [139, 0, 0],
163
- darksalmon: [233, 150, 122],
164
- darkseagreen: [143, 188, 143],
165
- darkslateblue: [72, 61, 139],
166
- darkslategray: [47, 79, 79],
167
- darkslategrey: [47, 79, 79],
168
- darkturquoise: [0, 206, 209],
169
- darkviolet: [148, 0, 211],
170
- deeppink: [255, 20, 147],
171
- deepskyblue: [0, 191, 255],
172
- dimgray: [105, 105, 105],
173
- dimgrey: [105, 105, 105],
174
- dodgerblue: [30, 144, 255],
175
- firebrick: [178, 34, 34],
176
- floralwhite: [255, 250, 240],
177
- forestgreen: [34, 139, 34],
178
- fuchsia: [255, 0, 255],
179
- gainsboro: [220, 220, 220],
180
- ghostwhite: [248, 248, 255],
181
- gold: [255, 215, 0],
182
- goldenrod: [218, 165, 32],
183
- gray: [128, 128, 128],
184
- grey: [128, 128, 128],
185
- green: [0, 128, 0],
186
- greenyellow: [173, 255, 47],
187
- honeydew: [240, 255, 240],
188
- hotpink: [255, 105, 180],
189
- indianred: [205, 92, 92],
190
- indigo: [75, 0, 130],
191
- ivory: [255, 255, 240],
192
- khaki: [240, 230, 140],
193
- lavender: [230, 230, 250],
194
- lavenderblush: [255, 240, 245],
195
- lawngreen: [124, 252, 0],
196
- lemonchiffon: [255, 250, 205],
197
- lightblue: [173, 216, 230],
198
- lightcoral: [240, 128, 128],
199
- lightcyan: [224, 255, 255],
200
- lightgoldenrodyellow: [250, 250, 210],
201
- lightgray: [211, 211, 211],
202
- lightgrey: [211, 211, 211],
203
- lightgreen: [144, 238, 144],
204
- lightpink: [255, 182, 193],
205
- lightsalmon: [255, 160, 122],
206
- lightseagreen: [32, 178, 170],
207
- lightskyblue: [135, 206, 250],
208
- lightslategray: [119, 136, 153],
209
- lightslategrey: [119, 136, 153],
210
- lightsteelblue: [176, 196, 222],
211
- lightyellow: [255, 255, 224],
212
- lime: [0, 255, 0],
213
- limegreen: [50, 205, 50],
214
- linen: [250, 240, 230],
215
- magenta: [255, 0, 255],
216
- maroon: [128, 0, 0],
217
- mediumaquamarine: [102, 205, 170],
218
- mediumblue: [0, 0, 205],
219
- mediumorchid: [186, 85, 211],
220
- mediumpurple: [147, 112, 219],
221
- mediumseagreen: [60, 179, 113],
222
- mediumslateblue: [123, 104, 238],
223
- mediumspringgreen: [0, 250, 154],
224
- mediumturquoise: [72, 209, 204],
225
- mediumvioletred: [199, 21, 133],
226
- midnightblue: [25, 25, 112],
227
- mintcream: [245, 255, 250],
228
- mistyrose: [255, 228, 225],
229
- moccasin: [255, 228, 181],
230
- navajowhite: [255, 222, 173],
231
- navy: [0, 0, 128],
232
- oldlace: [253, 245, 230],
233
- olive: [128, 128, 0],
234
- olivedrab: [107, 142, 35],
235
- orange: [255, 165, 0],
236
- orangered: [255, 69, 0],
237
- orchid: [218, 112, 214],
238
- palegoldenrod: [238, 232, 170],
239
- palegreen: [152, 251, 152],
240
- paleturquoise: [175, 238, 238],
241
- palevioletred: [219, 112, 147],
242
- papayawhip: [255, 239, 213],
243
- peachpuff: [255, 218, 185],
244
- peru: [205, 133, 63],
245
- pink: [255, 192, 203],
246
- plum: [221, 160, 221],
247
- powderblue: [176, 224, 230],
248
- purple: [128, 0, 128],
249
- rebeccapurple: [102, 51, 153],
250
- red: [255, 0, 0],
251
- rosybrown: [188, 143, 143],
252
- royalblue: [65, 105, 225],
253
- saddlebrown: [139, 69, 19],
254
- salmon: [250, 128, 114],
255
- sandybrown: [244, 164, 96],
256
- seagreen: [46, 139, 87],
257
- seashell: [255, 245, 238],
258
- sienna: [160, 82, 45],
259
- silver: [192, 192, 192],
260
- skyblue: [135, 206, 235],
261
- slateblue: [106, 90, 205],
262
- slategray: [112, 128, 144],
263
- slategrey: [112, 128, 144],
264
- snow: [255, 250, 250],
265
- springgreen: [0, 255, 127],
266
- steelblue: [70, 130, 180],
267
- tan: [210, 180, 140],
268
- teal: [0, 128, 128],
269
- thistle: [216, 191, 216],
270
- tomato: [255, 99, 71],
271
- turquoise: [64, 224, 208],
272
- violet: [238, 130, 238],
273
- wheat: [245, 222, 179],
274
- white: [255, 255, 255],
275
- whitesmoke: [245, 245, 245],
276
- yellow: [255, 255, 0],
277
- yellowgreen: [154, 205, 50],
278
- };
1
+ // Lives in `common/` because the engine reads it too; this is its public face.
2
+ export * from "../common/color-input.js";
@@ -31,14 +31,19 @@ export interface SvgOptions extends BoundsInput {
31
31
  export type ImageSource = string | Uint8Array | CustomImage;
32
32
  /** How the image fills its box (locked §4). Mirrors CSS `object-fit`. */
33
33
  export type ImageFit = "none" | "contain" | "cover" | "fill";
34
+ /** Where an image sits in its own box - the image's counterpart of a Text's `align`. */
35
+ export type ImageAlign = "center" | "top" | "bottom" | "left" | "right" | "top left" | "top right" | "bottom left" | "bottom right";
34
36
  export interface ImageOptions extends BoundsInput {
35
37
  /** Size on each axis: points (fixed) or a percentage string like `"50%"` (a fraction of the offered
36
38
  * space). Pin exactly ONE axis and the other follows the image's aspect ratio (CSS `height: auto`). */
37
39
  width?: SizeInput;
38
40
  height?: SizeInput;
39
- /** Fit within the box. Default `none` (the image's own size); with any explicit size or an
40
- * `aspectRatio`, `fill` - the image scales into its box, as `<img width height>` does. */
41
+ /** How the image fills its box (CSS `object-fit`). Default `fill`. Given no size, the box is the image's
42
+ * own size, scaled down to fit what is offered (never up) - so `fill` draws it undistorted. */
41
43
  fit?: ImageFit;
44
+ /** Where the image sits in its box when `fit` leaves space or cuts it off. Default `"center"`; a
45
+ * portrait in a `cover` box keeps its top with `"top"`. */
46
+ align?: ImageAlign;
42
47
  /** Corner radius in points (rounds the image box). */
43
48
  radius?: RadiusInput;
44
49
  /** Alternate text for accessibility (tagged PDF): describes the image for screen readers. With `alt`
@@ -33,6 +33,17 @@ const FIT = {
33
33
  cover: BoxFit.cover,
34
34
  fill: BoxFit.fill,
35
35
  };
36
+ const IMAGE_ALIGN = {
37
+ center: { x: 0.5, y: 0.5 },
38
+ top: { x: 0.5, y: 0 },
39
+ bottom: { x: 0.5, y: 1 },
40
+ left: { x: 0, y: 0.5 },
41
+ right: { x: 1, y: 0.5 },
42
+ "top left": { x: 0, y: 0 },
43
+ "top right": { x: 1, y: 0 },
44
+ "bottom left": { x: 0, y: 1 },
45
+ "bottom right": { x: 1, y: 1 },
46
+ };
36
47
  export function Canvas(a, b) {
37
48
  const opts = typeof a === "function" ? {} : a;
38
49
  const paint = typeof a === "function" ? a : b;
@@ -118,6 +129,10 @@ export function Image(src, opts = {}) {
118
129
  if (opts.radius !== undefined) {
119
130
  throw new Error("@jasy/pdf: radius is not supported for an SVG yet - wrap it in a Box instead.");
120
131
  }
132
+ if (opts.align !== undefined) {
133
+ throw new Error("@jasy/pdf: align is not supported for an SVG yet - it is always drawn centred in its box. " +
134
+ "Size the box to the drawing, or place it with the parent's align.");
135
+ }
121
136
  return Svg(src, opts);
122
137
  }
123
138
  return rasterImage(src, opts);
@@ -125,12 +140,6 @@ export function Image(src, opts = {}) {
125
140
  function rasterImage(src, opts = {}) {
126
141
  const w = opts.width !== undefined ? toDimension(opts.width) : undefined;
127
142
  const h = opts.height !== undefined ? toDimension(opts.height) : undefined;
128
- // ANY explicit size scales the image into the box (fit: fill), which is what `<img width height>`
129
- // does in HTML and what react-pdf does. Two pinned axes used to keep `none` - drawing the image at
130
- // its pixel size regardless - so a logo asked for at 48x48 spanned a third of the page.
131
- // An image with NO size given still draws at its intrinsic size, as an unsized `<img>` does.
132
- const sized = opts.width !== undefined || opts.height !== undefined || opts.aspectRatio !== undefined;
133
- const fit = opts.fit ? FIT[opts.fit] : sized ? BoxFit.fill : undefined;
134
143
  return new ImageElement({
135
144
  image: typeof src === "string"
136
145
  ? isImageUrl(src)
@@ -144,7 +153,8 @@ function rasterImage(src, opts = {}) {
144
153
  widthFactor: w?.factor,
145
154
  heightFactor: h?.factor,
146
155
  ...toBounds(opts),
147
- fit,
156
+ fit: opts.fit ? FIT[opts.fit] : undefined,
157
+ align: opts.align ? IMAGE_ALIGN[opts.align] : undefined,
148
158
  radius: opts.radius !== undefined ? toRadius(opts.radius) : undefined,
149
159
  alt: opts.alt,
150
160
  })
@@ -2,8 +2,9 @@ import { Text, Paragraph, span } from "./text.js";
2
2
  import { Column, Row, Box, Padding, Spacer, PageBreak, keepTogether, Expanded, Positioned, Link, Anchor, Bookmark, Rotated, RotatedBox, } from "./layout.js";
3
3
  import { Canvas, Divider, Image, Svg } from "./content.js";
4
4
  import { Page, Document, DefaultTextStyle } from "./structure.js";
5
- import { PageNumber, PageCount } from "./page-builder.js";
5
+ import { PageBuilder, PageCount, PageNumber, SubPageCount, SubPageNumber, } from "./page-builder.js";
6
6
  import { Table } from "./table.js";
7
+ import { ColumnBreak, Flow, FlowWithoutRegionError, Region, SplitView, } from "./flow.js";
7
8
  import { Checkbox, Dropdown, ListBox, PushButton, RadioGroup, SignatureField, TextField, } from "./forms.js";
8
9
  // Element children (Column/Row/Box/Page/…) → built recursively. Text/Paragraph children are
9
10
  // strings or `span` descriptors and become content instead.
@@ -104,6 +105,20 @@ const REGISTRY = {
104
105
  const body = rows.filter((r) => !r.props?.header);
105
106
  return Table({ ...props, header: header ? rowCells(header) : undefined }, body.map(rowCells));
106
107
  },
108
+ // A Flow's `page` is a function prop: the binding renders one page of the template per call.
109
+ flow: (props, children) => Flow({
110
+ gap: props.gap,
111
+ balance: props.balance,
112
+ template: (info) => {
113
+ const page = props.page?.(info) ?? [];
114
+ if (page.length === 0)
115
+ throw new FlowWithoutRegionError();
116
+ return wrappedChild(page);
117
+ },
118
+ }, elementChildren(children)),
119
+ region: (props) => Region(props),
120
+ "column-break": () => ColumnBreak(),
121
+ "split-view": (props, children) => SplitView(props, elementChildren(children)),
107
122
  positioned: (props, children) => Positioned(props, elementChildren(children)[0]),
108
123
  "default-text-style": (props, children) => DefaultTextStyle(props, elementChildren(children)),
109
124
  // Navigation. `Link` takes exactly one of `href` (a URL) or `to` (an `Anchor` name) - the factory
@@ -114,10 +129,22 @@ const REGISTRY = {
114
129
  // Transforms.
115
130
  rotated: (props, children) => Rotated(props, wrappedChild(children)),
116
131
  "rotated-box": (props, children) => RotatedBox(props, wrappedChild(children)),
117
- // Page numbers. `PageBuilder` itself is deliberately NOT exposed: it takes a closure, which a template
118
- // cannot express. These two cover what the closure exists for.
132
+ // Page numbers. A binding renders `build` per page, like a Flow's `page`; nothing built is an empty
133
+ // Column, so a builder can show something on some pages only.
134
+ "page-builder": (props) => {
135
+ // A descriptor posted as JSON loses its functions; say so here, not mid-layout.
136
+ if (typeof props.build !== "function") {
137
+ throw new Error("A page-builder needs a `build` function prop (lost if the descriptor went through JSON).");
138
+ }
139
+ return PageBuilder((info) => {
140
+ const built = props.build(info) ?? [];
141
+ return built.length === 0 ? Column([]) : wrappedChild(built);
142
+ });
143
+ },
119
144
  "page-number": (props) => PageNumber(props),
120
145
  "page-count": (props) => PageCount(props),
146
+ "sub-page-number": (props) => SubPageNumber(props),
147
+ "sub-page-count": (props) => SubPageCount(props),
121
148
  // Form fields. The three list-like ones take their choices as a SECOND argument in TypeScript; a
122
149
  // template has no second argument, so here they arrive as an `options` prop. `choiceList` accepts both
123
150
  // the short spelling and the explicit one, because an export value and its label are usually the same
@@ -0,0 +1,55 @@
1
+ import { FlowElement, type FlowTemplate } from "../elements/layout/flow-element.js";
2
+ import { RegionElement } from "../elements/layout/region-element.js";
3
+ import { ColumnBreakElement } from "../elements/layout/column-break-element.js";
4
+ import { PDFElement } from "../elements/pdf-element.js";
5
+ import { BoundsInput, SizeInput } from "./dimension.js";
6
+ export type { FlowPage, FlowTemplate } from "../elements/layout/flow-element.js";
7
+ export { FlowRegionsEmptyError, FlowUnboundedError, FlowWithoutRegionError, RegionInPageBuilderError, } from "../elements/layout/flow-element.js";
8
+ export { RegionOutsideFlowError, RegionReusedError } from "../elements/layout/region-element.js";
9
+ export interface FlowOptions {
10
+ /** The layout of one page of the Flow, built per page; its `Region`s are where the content goes. */
11
+ template: FlowTemplate;
12
+ /** Space between the content's elements, in points. */
13
+ gap?: number;
14
+ /** On the last page, spread the content so the Regions come out about equally full (default false). */
15
+ balance?: boolean;
16
+ }
17
+ /**
18
+ * Content flowing through the `Region`s of a template: the first Region full, then the next, then the
19
+ * next page. It takes its height from where it sits - the rest of the page, or a box with a height.
20
+ *
21
+ * @experimental The shape may still change.
22
+ */
23
+ export declare function Flow(opts: FlowOptions, content: PDFElement[]): FlowElement;
24
+ export interface RegionOptions extends BoundsInput {
25
+ width?: SizeInput;
26
+ height?: SizeInput;
27
+ }
28
+ /**
29
+ * A place in a `Flow`'s template that the content flows through, in tree order. Sized by the layout
30
+ * around it (`Expanded`, `width`, `height`); with nothing given it fills what it is offered.
31
+ *
32
+ * @experimental
33
+ */
34
+ export declare function Region(opts?: RegionOptions): RegionElement;
35
+ /** Continue in the next `Region` of the Flow it sits in (from the last one, the next page's first). */
36
+ export declare function ColumnBreak(): ColumnBreakElement;
37
+ export interface SplitViewOptions {
38
+ /** How many columns, side by side and equally wide (default 2). */
39
+ columns?: number;
40
+ /** Gap between both the columns and the content's elements (default 0). */
41
+ gap?: number;
42
+ /** Gap between the content's elements (overrides `gap`). */
43
+ rowGap?: number;
44
+ /** Gap between the columns (overrides `gap`). */
45
+ colGap?: number;
46
+ /** On the last page, columns of about equal height, as CSS columns are (default true). */
47
+ balance?: boolean;
48
+ }
49
+ /**
50
+ * Newspaper columns: the content fills the first column, then the next, then the next page. A `Flow`
51
+ * with a ready template - for anything else (a title area, columns of different widths) build your own.
52
+ *
53
+ * @experimental
54
+ */
55
+ export declare function SplitView(opts: SplitViewOptions, content: PDFElement[]): FlowElement;
@@ -0,0 +1,71 @@
1
+ import { ContainerElement } from "../elements/container-element.js";
2
+ import { FlowElement } from "../elements/layout/flow-element.js";
3
+ import { RegionElement } from "../elements/layout/region-element.js";
4
+ import { ColumnBreakElement } from "../elements/layout/column-break-element.js";
5
+ import { toBounds, toDimension } from "./dimension.js";
6
+ import { Expanded, Row } from "./layout.js";
7
+ export { FlowRegionsEmptyError, FlowUnboundedError, FlowWithoutRegionError, RegionInPageBuilderError, } from "../elements/layout/flow-element.js";
8
+ export { RegionOutsideFlowError, RegionReusedError } from "../elements/layout/region-element.js";
9
+ /**
10
+ * Content flowing through the `Region`s of a template: the first Region full, then the next, then the
11
+ * next page. It takes its height from where it sits - the rest of the page, or a box with a height.
12
+ *
13
+ * @experimental The shape may still change.
14
+ */
15
+ export function Flow(opts, content) {
16
+ return new FlowElement({
17
+ content: new ContainerElement({ x: 0, y: 0, children: content, gap: opts.gap }),
18
+ template: opts.template,
19
+ balance: opts.balance,
20
+ });
21
+ }
22
+ /**
23
+ * A place in a `Flow`'s template that the content flows through, in tree order. Sized by the layout
24
+ * around it (`Expanded`, `width`, `height`); with nothing given it fills what it is offered.
25
+ *
26
+ * @experimental
27
+ */
28
+ export function Region(opts = {}) {
29
+ const w = opts.width !== undefined ? toDimension(opts.width) : undefined;
30
+ const h = opts.height !== undefined ? toDimension(opts.height) : undefined;
31
+ return new RegionElement({
32
+ width: w?.points,
33
+ height: h?.points,
34
+ widthFactor: w?.factor,
35
+ heightFactor: h?.factor,
36
+ ...toBounds(opts),
37
+ })
38
+ .withAlignSelf(opts.alignSelf)
39
+ .withOrder(opts.order)
40
+ .withFlexShrink(opts.flexShrink);
41
+ }
42
+ /** Continue in the next `Region` of the Flow it sits in (from the last one, the next page's first). */
43
+ export function ColumnBreak() {
44
+ return new ColumnBreakElement();
45
+ }
46
+ /** A value as the author wrote it: `"2"` quoted, `NaN` as NaN. */
47
+ const shown = (value) => typeof value === "string" ? JSON.stringify(value) : String(value);
48
+ /**
49
+ * Newspaper columns: the content fills the first column, then the next, then the next page. A `Flow`
50
+ * with a ready template - for anything else (a title area, columns of different widths) build your own.
51
+ *
52
+ * @experimental
53
+ */
54
+ export function SplitView(opts, content) {
55
+ const columns = opts.columns ?? 2;
56
+ if (!Number.isInteger(columns) || columns < 1) {
57
+ throw new Error(`Invalid SplitView columns ${shown(columns)}: use a whole number of 1 or more.`);
58
+ }
59
+ for (const key of ["gap", "rowGap", "colGap"]) {
60
+ const value = opts[key];
61
+ if (value !== undefined && !(Number.isFinite(value) && value >= 0)) {
62
+ throw new Error(`Invalid SplitView ${key} ${shown(value)}: use a number of points, 0 or more.`);
63
+ }
64
+ }
65
+ const colGap = opts.colGap ?? opts.gap;
66
+ return Flow({
67
+ gap: opts.rowGap ?? opts.gap,
68
+ balance: opts.balance ?? true,
69
+ template: () => Row({ gap: colGap }, Array.from({ length: columns }, () => Expanded(Region()))),
70
+ }, content);
71
+ }
@@ -1,53 +1 @@
1
- import { type ColorInput } from "./color.js";
2
- import type { Gradient } from "../ir/display-list.js";
3
- /**
4
- * Gradients for a box background.
5
- *
6
- * The public form is BOX-RELATIVE - an angle and some stops - because that is what an author knows
7
- * when writing the document. The engine's `Gradient` wants absolute page coordinates, so the renderer
8
- * resolves one against the box it is painting; see `resolveGradient`.
9
- */
10
- /** A stop as written: a colour, optionally pinned to a position 0..1 along the axis. */
11
- export type StopInput = ColorInput | {
12
- color: ColorInput;
13
- at: number;
14
- };
15
- export interface LinearGradientInput {
16
- kind: "linear";
17
- /** Direction in degrees, CSS convention: 0 = to the top, 90 = to the right (default 180, downwards). */
18
- angle: number;
19
- stops: StopInput[];
20
- }
21
- export interface RadialGradientInput {
22
- kind: "radial";
23
- /** Centre as a fraction of the box, `[0.5, 0.5]` being the middle (the default). */
24
- center: [number, number];
25
- /** Outer radius as a fraction of the box's larger side (default 0.5, i.e. it touches the edges). */
26
- radius: number;
27
- stops: StopInput[];
28
- }
29
- export type GradientInput = LinearGradientInput | RadialGradientInput;
30
- export declare const isGradientInput: (v: unknown) => v is GradientInput;
31
- /**
32
- * A linear gradient. `linearGradient("#fff", "#000")` runs top to bottom; pass `{ angle }` for any
33
- * other direction, and `{ color, at }` for a stop that is not evenly spaced.
34
- */
35
- export declare function linearGradient(...args: [...StopInput[]] | [{
36
- angle?: number;
37
- stops: StopInput[];
38
- }]): LinearGradientInput;
39
- /** A radial gradient, centred by default and reaching the edges. */
40
- export declare function radialGradient(...args: [...StopInput[]] | [{
41
- center?: [number, number];
42
- radius?: number;
43
- stops: StopInput[];
44
- }]): RadialGradientInput;
45
- /**
46
- * Resolve a box-relative gradient against the box it paints, into the absolute page coordinates the
47
- * IR wants. Called by the renderer, which is the first place the box's geometry is known.
48
- *
49
- * The angle follows CSS: 0 points to the top and it turns clockwise, so 90 goes to the right. The
50
- * axis runs through the box centre and is scaled so it spans the box on that diagonal - which is why
51
- * a 45-degree gradient reaches the corners rather than stopping short of them.
52
- */
53
- export declare function resolveGradient(g: GradientInput, x: number, y: number, width: number, height: number): Gradient;
1
+ export * from "../common/gradient.js";