@jasy/pdf 1.0.0-alpha.8 → 1.0.0-beta.1

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 (167) hide show
  1. package/README.md +29 -10
  2. package/dist/api/color.js +69 -0
  3. package/dist/api/content.d.ts +45 -5
  4. package/dist/api/content.js +96 -7
  5. package/dist/api/descriptor.js +6 -1
  6. package/dist/api/dimension.d.ts +57 -0
  7. package/dist/api/dimension.js +27 -0
  8. package/dist/api/font-url.d.ts +28 -0
  9. package/dist/api/font-url.js +135 -0
  10. package/dist/api/gradient.d.ts +53 -0
  11. package/dist/api/gradient.js +97 -0
  12. package/dist/api/index.d.ts +2 -0
  13. package/dist/api/index.js +2 -0
  14. package/dist/api/insets.d.ts +19 -12
  15. package/dist/api/insets.js +18 -7
  16. package/dist/api/layout.d.ts +20 -6
  17. package/dist/api/layout.js +31 -6
  18. package/dist/api/page-builder.d.ts +8 -0
  19. package/dist/api/page-builder.js +12 -0
  20. package/dist/api/structure.d.ts +12 -0
  21. package/dist/api/structure.js +54 -7
  22. package/dist/api/text.d.ts +65 -8
  23. package/dist/api/text.js +38 -5
  24. package/dist/canvas/painter.d.ts +102 -0
  25. package/dist/canvas/painter.js +183 -0
  26. package/dist/edit/document.d.ts +14 -4
  27. package/dist/edit/document.js +15 -22
  28. package/dist/edit/fill.d.ts +19 -2
  29. package/dist/edit/fill.js +63 -34
  30. package/dist/edit/flatten.d.ts +13 -1
  31. package/dist/edit/flatten.js +38 -13
  32. package/dist/edit/index.d.ts +2 -0
  33. package/dist/edit/index.js +3 -1
  34. package/dist/edit/objects.d.ts +5 -0
  35. package/dist/edit/objects.js +27 -0
  36. package/dist/edit/writer.d.ts +7 -0
  37. package/dist/edit/writer.js +23 -1
  38. package/dist/elements/canvas-element.d.ts +38 -0
  39. package/dist/elements/canvas-element.js +47 -0
  40. package/dist/elements/container-element.d.ts +23 -1
  41. package/dist/elements/container-element.js +31 -10
  42. package/dist/elements/image-element.d.ts +18 -4
  43. package/dist/elements/image-element.js +27 -12
  44. package/dist/elements/index.d.ts +2 -0
  45. package/dist/elements/index.js +2 -0
  46. package/dist/elements/layout/expanded-element.d.ts +5 -1
  47. package/dist/elements/layout/expanded-element.js +16 -3
  48. package/dist/elements/layout/keep-together-element.d.ts +2 -0
  49. package/dist/elements/layout/keep-together-element.js +4 -0
  50. package/dist/elements/layout/padding-element.d.ts +9 -2
  51. package/dist/elements/layout/padding-element.js +12 -2
  52. package/dist/elements/layout/page-builder-element.js +2 -0
  53. package/dist/elements/layout/struct-group.d.ts +2 -0
  54. package/dist/elements/layout/struct-group.js +4 -0
  55. package/dist/elements/pdf-element.d.ts +75 -1
  56. package/dist/elements/pdf-element.js +80 -1
  57. package/dist/elements/rectangle-element.d.ts +22 -6
  58. package/dist/elements/rectangle-element.js +31 -12
  59. package/dist/elements/row-element.d.ts +26 -1
  60. package/dist/elements/row-element.js +31 -10
  61. package/dist/elements/svg-element.d.ts +41 -0
  62. package/dist/elements/svg-element.js +93 -0
  63. package/dist/elements/text-element.d.ts +92 -1
  64. package/dist/elements/text-element.js +208 -33
  65. package/dist/ir/display-list.d.ts +67 -11
  66. package/dist/ir/display-list.js +8 -1
  67. package/dist/layout/alignment.d.ts +10 -0
  68. package/dist/layout/alignment.js +6 -0
  69. package/dist/layout/box-constraints.d.ts +53 -0
  70. package/dist/layout/box-constraints.js +72 -0
  71. package/dist/layout/insets.d.ts +28 -0
  72. package/dist/layout/insets.js +20 -0
  73. package/dist/layout/min-intrinsic.d.ts +29 -0
  74. package/dist/layout/min-intrinsic.js +37 -0
  75. package/dist/renderer/anchor-renderer.js +1 -1
  76. package/dist/renderer/bookmark-renderer.js +1 -1
  77. package/dist/renderer/canvas-renderer.d.ts +14 -0
  78. package/dist/renderer/canvas-renderer.js +36 -0
  79. package/dist/renderer/container-renderer.js +1 -3
  80. package/dist/renderer/default-text-style-renderer.js +1 -1
  81. package/dist/renderer/deferred-renderer.js +1 -1
  82. package/dist/renderer/expanded-renderer.js +1 -1
  83. package/dist/renderer/image-renderer.js +2 -1
  84. package/dist/renderer/index.d.ts +2 -0
  85. package/dist/renderer/index.js +2 -0
  86. package/dist/renderer/keep-together-renderer.js +1 -1
  87. package/dist/renderer/link-renderer.js +1 -1
  88. package/dist/renderer/padding-renderer.js +1 -1
  89. package/dist/renderer/page-builder-renderer.js +1 -1
  90. package/dist/renderer/page-renderer.js +1 -3
  91. package/dist/renderer/pdf-backend.d.ts +21 -4
  92. package/dist/renderer/pdf-backend.js +198 -61
  93. package/dist/renderer/pdf-document-renderer.js +8 -1
  94. package/dist/renderer/pdf-renderer.js +5 -1
  95. package/dist/renderer/positioned-renderer.js +1 -1
  96. package/dist/renderer/rectangle-renderer.js +9 -5
  97. package/dist/renderer/repeating-header-renderer.js +1 -2
  98. package/dist/renderer/rotated-renderer.js +1 -1
  99. package/dist/renderer/row-renderer.js +1 -3
  100. package/dist/renderer/struct-group-renderer.js +1 -1
  101. package/dist/renderer/svg-renderer.d.ts +11 -0
  102. package/dist/renderer/svg-renderer.js +27 -0
  103. package/dist/renderer/text-renderer.d.ts +2 -2
  104. package/dist/renderer/text-renderer.js +176 -57
  105. package/dist/svg/css.d.ts +38 -0
  106. package/dist/svg/css.js +115 -0
  107. package/dist/svg/errors.d.ts +24 -0
  108. package/dist/svg/errors.js +30 -0
  109. package/dist/svg/gradients.d.ts +34 -0
  110. package/dist/svg/gradients.js +156 -0
  111. package/dist/svg/index.d.ts +22 -0
  112. package/dist/svg/index.js +331 -0
  113. package/dist/svg/shapes.d.ts +6 -0
  114. package/dist/svg/shapes.js +177 -0
  115. package/dist/svg/style.d.ts +61 -0
  116. package/dist/svg/style.js +144 -0
  117. package/dist/svg/transform.d.ts +13 -0
  118. package/dist/svg/transform.js +68 -0
  119. package/dist/svg/units.d.ts +11 -0
  120. package/dist/svg/units.js +73 -0
  121. package/dist/svg/xml.d.ts +24 -0
  122. package/dist/svg/xml.js +158 -0
  123. package/dist/text/advance.d.ts +3 -0
  124. package/dist/text/advance.js +8 -4
  125. package/dist/text/arabic.d.ts +20 -0
  126. package/dist/text/arabic.js +101 -0
  127. package/dist/text/bidi.d.ts +33 -0
  128. package/dist/text/bidi.js +94 -0
  129. package/dist/text/font-fallback.d.ts +25 -0
  130. package/dist/text/font-fallback.js +38 -0
  131. package/dist/text/glyph-coverage.d.ts +15 -0
  132. package/dist/text/glyph-coverage.js +77 -0
  133. package/dist/text/line-breaker.d.ts +43 -1
  134. package/dist/text/line-breaker.js +181 -45
  135. package/dist/text/orphans-widows.d.ts +27 -0
  136. package/dist/text/orphans-widows.js +53 -0
  137. package/dist/text/shape.d.ts +29 -0
  138. package/dist/text/shape.js +91 -0
  139. package/dist/text/text-style.d.ts +38 -0
  140. package/dist/text/text-style.js +50 -1
  141. package/dist/text/whitespace.d.ts +4 -0
  142. package/dist/text/whitespace.js +43 -0
  143. package/dist/text/word-splitting.d.ts +42 -0
  144. package/dist/text/word-splitting.js +72 -0
  145. package/dist/utils/flex-layout.d.ts +24 -4
  146. package/dist/utils/flex-layout.js +218 -7
  147. package/dist/utils/font-metrics.d.ts +13 -2
  148. package/dist/utils/gsub.d.ts +36 -0
  149. package/dist/utils/gsub.js +178 -0
  150. package/dist/utils/inflate.d.ts +16 -0
  151. package/dist/utils/inflate.js +52 -0
  152. package/dist/utils/pdf-object-manager.d.ts +44 -3
  153. package/dist/utils/pdf-object-manager.js +176 -14
  154. package/dist/utils/renderer-registry.d.ts +13 -1
  155. package/dist/utils/renderer-registry.js +22 -1
  156. package/dist/utils/ttf-parser.d.ts +6 -0
  157. package/dist/utils/ttf-parser.js +19 -0
  158. package/dist/utils/utf8-to-windows1252-encoder.d.ts +6 -0
  159. package/dist/utils/utf8-to-windows1252-encoder.js +10 -0
  160. package/dist/utils/woff.d.ts +10 -0
  161. package/dist/utils/woff.js +116 -0
  162. package/dist/utils/woff2.d.ts +6 -0
  163. package/dist/utils/woff2.js +651 -0
  164. package/dist/validators/element-validator.js +9 -2
  165. package/dist/vector/path.d.ts +33 -0
  166. package/dist/vector/path.js +81 -0
  167. package/package.json +6 -2
package/README.md CHANGED
@@ -116,9 +116,11 @@ const pdf = await renderToBytes(
116
116
  );
117
117
  ```
118
118
 
119
- Real text layout (Adobe AFM metrics, kerning, word-wrap), flexbox-style `gap` / `justify` / `align`,
120
- boxes with radius and alpha, images, custom TrueType fonts, **AES-256 password encryption**, and **real
121
- pagination** - content that overflows flows onto the next page, headers and footers repeat.
119
+ Real text layout (real font metrics, kerning, ligatures, word-wrap), full flexbox (`gap` / `justify` /
120
+ `align`, wrap, `flexBasis`, `flexShrink`, `order`), boxes with per-corner radius, gradients and alpha, images and
121
+ **SVG**, a `Canvas` to draw into, custom fonts from a file, bytes or a URL, AcroForm fields,
122
+ **AES-256 password encryption**, tagged PDFs for screen readers, and **real pagination** - content that
123
+ overflows flows onto the next page, headers and footers repeat, and a block can refuse to be split.
122
124
 
123
125
  ---
124
126
 
@@ -140,9 +142,19 @@ verify all of it:
140
142
  - **PDF/A-3, matched not approximated.** The conformance graph is hand-built and **passes veraPDF**, the
141
143
  official ISO 19005 validator.
142
144
  - **Byte-exact round-trips.** Generate and parse are inverses: `generate → parse → regenerate` reproduces
143
- the identical XML. 307 tests hold the line.
145
+ the identical XML. Over 1,600 tests hold the line across the workspace.
144
146
  - **Schematron, local.** The official EN-16931 + XRechnung rules run via saxon-js (the real XSLT, in pure
145
147
  JS) - no Java, no upload.
148
+ - **Vectors, not bitmaps.** `Image("logo.svg")` keeps a logo a VECTOR - our own SVG reader and our own
149
+ XML parser, checked against 10,819 real files and compared pixel for pixel with headless Chrome. Need
150
+ a chart instead? `Canvas` hands you a typed pen for the same vector layer.
151
+ - **Fonts, whatever the container.** `.ttf`, `.woff` and `.woff2` are the same thing at the call site.
152
+ The WOFF2 container and its `glyf` transform are ours; only Brotli is a dependency, and it is loaded
153
+ only if a WOFF2 actually turns up.
154
+ - **Byte-stable output.** The same document rendered twice is byte-identical, PDF/A and ZUGFeRD
155
+ included, across separate processes. An archived invoice stays re-derivable and hashable years later.
156
+ - **Accessible if you ask.** `renderToBytes(doc, { accessible })` emits a full tagged structure tree,
157
+ and the output passes veraPDF's machine-verifiable PDF/UA-1 checks.
146
158
 
147
159
  ---
148
160
 
@@ -153,13 +165,16 @@ verify all of it:
153
165
  | **[@jasy/e-invoice](https://npmx.dev/@jasy/e-invoice)** | ZUGFeRD / XRechnung: your data → PDF/A-3 + EN-16931 XML, with local validation. **The prize.** |
154
166
  | **[@jasy/cli](https://npmx.dev/@jasy/cli)** | the `jasy` terminal: read · validate · export, headless **and** interactive |
155
167
  | **[@jasy/pdf](https://npmx.dev/@jasy/pdf)** | the declarative, Flutter-style PDF layout engine that powers them |
168
+ | **[@jasy/vue](https://npmx.dev/@jasy/vue)** | author PDFs as Vue components - and render them in the browser |
169
+ | **[@jasy/nuxt](https://npmx.dev/@jasy/nuxt)** | the Nuxt module: zero-config PDFs, client **or** server |
156
170
 
157
171
  ---
158
172
 
159
173
  ## Why you can trust it
160
174
 
161
- - **307 tests, green.** The generator and the parser are **byte-exact inverses**:
162
- `generate → parse → regenerate` reproduces the identical XML. Nothing is silently lost.
175
+ - **Over 1,600 tests, green** across the workspace (1,190 in the engine alone). The generator and the parser
176
+ are **byte-exact inverses**: `generate → parse → regenerate` reproduces the identical XML. Nothing is
177
+ silently lost.
163
178
  - **The same rules the authorities use** - the official KoSIT EN-16931 + XRechnung Schematron, and the
164
179
  official **veraPDF** for the full ISO 19005 (PDF/A) check.
165
180
  - **Proven on real third-party invoices**, not only our own.
@@ -169,10 +184,14 @@ verify all of it:
169
184
 
170
185
  ## Honest scope
171
186
 
172
- jasy targets the documents that matter here: invoices, reports, quotes, datasheets. It is **not** a
173
- LaTeX / WeasyPrint replacement - no microtypography, hyphenation or bidi, and arbitrary multi-page flow
174
- of _any_ content is still maturing. For e-invoices (a table, totals, a footer) it is complete - they
175
- even paginate. We would rather under-promise and over-deliver in the demo above.
187
+ jasy targets the documents that matter here: invoices, reports, quotes, datasheets - and it now sets
188
+ text properly: kerning and Latin ligatures from the font's own tables, justification, pluggable
189
+ hyphenation, right-to-left and Arabic shaping, underlines that step around descenders. Pagination is
190
+ real: text splits at line boxes, `keepTogether` moves a card whole, orphans and widows are honoured.
191
+
192
+ It is still **not** a LaTeX or WeasyPrint replacement, and does not try to be: no automatic
193
+ microtypography, no floats, no CSS engine. What it will not draw, it **names** - an SVG filter, a font
194
+ without the glyph, a word that cannot be split - rather than quietly drawing something else.
176
195
 
177
196
  > **Status:** young and pre-1.0. The API can still shift between minor versions. Everything shown here
178
197
  > works and is tested.
package/dist/api/color.js CHANGED
@@ -22,8 +22,77 @@ export function toColor(input) {
22
22
  const named = CSS_COLORS[s];
23
23
  if (named)
24
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);
25
28
  throw new Error(`Unknown color: "${input}"`);
26
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
+ }
27
96
  /** Flutter ARGB: 0xAARRGGBB, alpha FIRST. A 6-digit number has alpha 0x00 = transparent. */
28
97
  function fromArgbNumber(n) {
29
98
  const a = (n >>> 24) & 0xff;
@@ -2,7 +2,9 @@ import { ImageElement, CustomImage } from "../elements/image-element.js";
2
2
  import { PDFElement } from "../elements/pdf-element.js";
3
3
  import { ColorInput } from "./color.js";
4
4
  import { Insets } from "./insets.js";
5
- import { SizeInput } from "./dimension.js";
5
+ import { BoundsInput, SizeInput, RadiusInput } from "./dimension.js";
6
+ import { SvgElement, type SvgSource } from "../elements/svg-element.js";
7
+ import { CanvasElement, type CanvasPaint } from "../elements/canvas-element.js";
6
8
  /** A horizontal rule (locked §4). */
7
9
  export interface DividerOptions {
8
10
  /** Line color (default a light gray). */
@@ -18,25 +20,63 @@ export interface DividerOptions {
18
20
  * own, so the padding gives it vertical room and centers the rule. Use inside a Column.
19
21
  */
20
22
  export declare function Divider(opts?: DividerOptions): PDFElement;
23
+ /** Options for `Svg`. The same sizing and bounds as an image; `fit` and `radius` do not apply yet. */
24
+ export interface SvgOptions extends BoundsInput {
25
+ width?: SizeInput;
26
+ height?: SizeInput;
27
+ /** Alternate text (tagged PDF). With it the drawing is a `Figure`; without it, decoration. */
28
+ alt?: string;
29
+ }
21
30
  /** An image source: a local file path (Node), raw bytes (e.g. a browser fetch/upload), or a `CustomImage`. */
22
31
  export type ImageSource = string | Uint8Array | CustomImage;
23
32
  /** How the image fills its box (locked §4). Mirrors CSS `object-fit`. */
24
33
  export type ImageFit = "none" | "contain" | "cover" | "fill";
25
- export interface ImageOptions {
34
+ export interface ImageOptions extends BoundsInput {
26
35
  /** Size on each axis: points (fixed) or a percentage string like `"50%"` (a fraction of the offered
27
36
  * space). Pin exactly ONE axis and the other follows the image's aspect ratio (CSS `height: auto`). */
28
37
  width?: SizeInput;
29
38
  height?: SizeInput;
30
- /** Fit within the box (default `none`; `fill` when exactly one axis is pinned so it scales to fit). */
39
+ /** Fit within the box. Default `none`, except where the box was DERIVED rather than given outright -
40
+ * exactly one axis pinned, or an `aspectRatio` - which defaults to `fill` so the image scales into it. */
31
41
  fit?: ImageFit;
32
42
  /** Corner radius in points (rounds the image box). */
33
- radius?: number;
43
+ radius?: RadiusInput;
34
44
  /** Alternate text for accessibility (tagged PDF): describes the image for screen readers. With `alt`
35
45
  * the image is a `Figure`; without it (and when rendered `accessible`) it counts as decoration. */
36
46
  alt?: string;
37
47
  }
48
+ /** Options for `Canvas`. Sizing only - what is drawn is entirely the callback's business. */
49
+ export interface CanvasOptions extends BoundsInput {
50
+ width?: SizeInput;
51
+ height?: SizeInput;
52
+ /** Alternate text (tagged PDF). With it the drawing is a `Figure`; without it, decoration. */
53
+ alt?: string;
54
+ }
55
+ /**
56
+ * A box you draw into yourself - the escape hatch for what the declarative layer has no component
57
+ * for: a sparkline, a seal, a chart, a signature flourish.
58
+ *
59
+ * With no size it FILLS the box it is offered, because there is nothing to derive an intrinsic size
60
+ * from. The callback is handed that resolved size, so a drawing written against `size.width` works
61
+ * at any width. It is clipped to its own box: a canvas is a block IN the layout, never a way around
62
+ * it - text still belongs in `Text`, and this still paginates as one unbreakable block.
63
+ */
64
+ export declare function Canvas(opts: CanvasOptions, paint: CanvasPaint): CanvasElement;
65
+ export declare function Canvas(paint: CanvasPaint): CanvasElement;
66
+ /**
67
+ * A vector drawing from SVG. `source` is markup, a file path (Node) or the file's bytes. It is read
68
+ * and parsed HERE, not at render time, so a broken file fails on the line that named it.
69
+ *
70
+ * With no size it draws at its intrinsic size; pin one axis and the other follows the `viewBox`. The
71
+ * drawing is scaled uniformly and centred inside its box - SVG's own `preserveAspectRatio` default,
72
+ * which is `fit: "contain"` by another name.
73
+ */
74
+ export declare function Svg(source: SvgSource, opts?: SvgOptions): SvgElement;
38
75
  /**
39
76
  * An image. `src` is a local file path (wrapped in a `CustomLocalImage`) or a ready
40
77
  * `CustomImage` for non-filesystem sources. Maps to an `ImageElement`.
78
+ *
79
+ * An SVG source is routed to `Svg` instead, so `Image({ src: "logo.svg" })` just works and a logo
80
+ * stays a VECTOR in the PDF rather than becoming a bitmap.
41
81
  */
42
- export declare function Image(src: ImageSource, opts?: ImageOptions): ImageElement;
82
+ export declare function Image(src: ImageSource, opts?: ImageOptions): ImageElement | SvgElement;
@@ -3,7 +3,9 @@ import { PaddingElement } from "../elements/layout/padding-element.js";
3
3
  import { ImageElement, CustomLocalImage, CustomBytesImage, BoxFit, } from "../elements/image-element.js";
4
4
  import { toColor } from "./color.js";
5
5
  import { toEdges } from "./insets.js";
6
- import { toDimension } from "./dimension.js";
6
+ import { toBounds, toDimension, toRadius, } from "./dimension.js";
7
+ import { SvgElement } from "../elements/svg-element.js";
8
+ import { CanvasElement } from "../elements/canvas-element.js";
7
9
  const DEFAULT_DIVIDER_COLOR = "lightgray";
8
10
  const DEFAULT_DIVIDER_MARGIN = { y: 6 };
9
11
  /**
@@ -31,17 +33,100 @@ const FIT = {
31
33
  cover: BoxFit.cover,
32
34
  fill: BoxFit.fill,
33
35
  };
36
+ export function Canvas(a, b) {
37
+ const opts = typeof a === "function" ? {} : a;
38
+ const paint = typeof a === "function" ? a : b;
39
+ // Without it the renderer would call `undefined` and hand back a bare "paint is not a function",
40
+ // which says nothing about WHICH element or what to pass. A framework binding cannot cover this:
41
+ // Vue's `required: true` only warns, it still renders.
42
+ if (typeof paint !== "function") {
43
+ throw new Error("@jasy/pdf: Canvas needs a paint callback - Canvas({ width, height }, (c, size) => …).");
44
+ }
45
+ const w = opts.width !== undefined ? toDimension(opts.width) : undefined;
46
+ const h = opts.height !== undefined ? toDimension(opts.height) : undefined;
47
+ return new CanvasElement({
48
+ paint,
49
+ width: w?.points,
50
+ height: h?.points,
51
+ widthFactor: w?.factor,
52
+ heightFactor: h?.factor,
53
+ ...toBounds(opts),
54
+ alt: opts.alt,
55
+ })
56
+ .withAlignSelf(opts.alignSelf)
57
+ .withOrder(opts.order)
58
+ .withFlexShrink(opts.flexShrink);
59
+ }
60
+ /**
61
+ * A vector drawing from SVG. `source` is markup, a file path (Node) or the file's bytes. It is read
62
+ * and parsed HERE, not at render time, so a broken file fails on the line that named it.
63
+ *
64
+ * With no size it draws at its intrinsic size; pin one axis and the other follows the `viewBox`. The
65
+ * drawing is scaled uniformly and centred inside its box - SVG's own `preserveAspectRatio` default,
66
+ * which is `fit: "contain"` by another name.
67
+ */
68
+ export function Svg(source, opts = {}) {
69
+ const w = opts.width !== undefined ? toDimension(opts.width) : undefined;
70
+ const h = opts.height !== undefined ? toDimension(opts.height) : undefined;
71
+ return new SvgElement({
72
+ source,
73
+ width: w?.points,
74
+ height: h?.points,
75
+ widthFactor: w?.factor,
76
+ heightFactor: h?.factor,
77
+ ...toBounds(opts),
78
+ alt: opts.alt,
79
+ })
80
+ .withAlignSelf(opts.alignSelf)
81
+ .withOrder(opts.order)
82
+ .withFlexShrink(opts.flexShrink);
83
+ }
84
+ /** SVG is recognised by its extension or by its own opening bytes - never by a caller having to say. */
85
+ function isSvg(src) {
86
+ if (typeof src === "string") {
87
+ // `.svgz` is a GZIPPED svg. It is caught here rather than left to the image path, because the
88
+ // error a user needs says what the file is - not "unsupported image bytes".
89
+ if (/\.svgz$/i.test(src.trim())) {
90
+ throw new Error("@jasy/pdf: SVGZ (a gzip-compressed SVG) is not supported. Decompress it first " +
91
+ "(`gunzip logo.svgz`) and pass the plain .svg.");
92
+ }
93
+ return /\.svg$/i.test(src.trim()) || /^\s*[<\ufeff]/.test(src);
94
+ }
95
+ if (src instanceof Uint8Array) {
96
+ const head = new TextDecoder().decode(src.subarray(0, 200)).trimStart();
97
+ return head.startsWith("<?xml") || head.startsWith("<svg");
98
+ }
99
+ return false;
100
+ }
34
101
  /**
35
102
  * An image. `src` is a local file path (wrapped in a `CustomLocalImage`) or a ready
36
103
  * `CustomImage` for non-filesystem sources. Maps to an `ImageElement`.
104
+ *
105
+ * An SVG source is routed to `Svg` instead, so `Image({ src: "logo.svg" })` just works and a logo
106
+ * stays a VECTOR in the PDF rather than becoming a bitmap.
37
107
  */
38
108
  export function Image(src, opts = {}) {
109
+ if (isSvg(src)) {
110
+ if (opts.fit !== undefined && opts.fit !== "contain") {
111
+ throw new Error(`@jasy/pdf: fit: "${opts.fit}" is not supported for an SVG yet - it is always drawn ` +
112
+ `contained, which is SVG's own default. Remove the fit, or rasterise the file.`);
113
+ }
114
+ if (opts.radius !== undefined) {
115
+ throw new Error("@jasy/pdf: radius is not supported for an SVG yet - wrap it in a Box instead.");
116
+ }
117
+ return Svg(src, opts);
118
+ }
119
+ return rasterImage(src, opts);
120
+ }
121
+ function rasterImage(src, opts = {}) {
39
122
  const w = opts.width !== undefined ? toDimension(opts.width) : undefined;
40
123
  const h = opts.height !== undefined ? toDimension(opts.height) : undefined;
41
- // Exactly one axis pinned -> the other is derived from the aspect ratio, so scale the image to the
42
- // resulting box (fit: fill). Both or neither pinned keeps the default fit (none).
43
- const autoScale = (opts.width !== undefined) !== (opts.height !== undefined);
44
- const fit = opts.fit ? FIT[opts.fit] : autoScale ? BoxFit.fill : undefined;
124
+ // ANY explicit size scales the image into the box (fit: fill), which is what `<img width height>`
125
+ // does in HTML and what react-pdf does. Two pinned axes used to keep `none` - drawing the image at
126
+ // its pixel size regardless - so a logo asked for at 48x48 spanned a third of the page.
127
+ // An image with NO size given still draws at its intrinsic size, as an unsized `<img>` does.
128
+ const sized = opts.width !== undefined || opts.height !== undefined || opts.aspectRatio !== undefined;
129
+ const fit = opts.fit ? FIT[opts.fit] : sized ? BoxFit.fill : undefined;
45
130
  return new ImageElement({
46
131
  image: typeof src === "string"
47
132
  ? new CustomLocalImage(src)
@@ -52,8 +137,12 @@ export function Image(src, opts = {}) {
52
137
  height: h?.points,
53
138
  widthFactor: w?.factor,
54
139
  heightFactor: h?.factor,
140
+ ...toBounds(opts),
55
141
  fit,
56
- radius: opts.radius,
142
+ radius: opts.radius !== undefined ? toRadius(opts.radius) : undefined,
57
143
  alt: opts.alt,
58
- });
144
+ })
145
+ .withAlignSelf(opts.alignSelf)
146
+ .withOrder(opts.order)
147
+ .withFlexShrink(opts.flexShrink);
59
148
  }
@@ -1,6 +1,6 @@
1
1
  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
- import { Divider, Image } from "./content.js";
3
+ import { Canvas, Divider, Image, Svg } from "./content.js";
4
4
  import { Page, Document, DefaultTextStyle } from "./structure.js";
5
5
  import { PageNumber, PageCount } from "./page-builder.js";
6
6
  import { Table } from "./table.js";
@@ -90,6 +90,11 @@ const REGISTRY = {
90
90
  "keep-together": (_props, children) => keepTogether(elementChildren(children)),
91
91
  divider: (props) => Divider(props),
92
92
  image: (props) => Image(props.src, props),
93
+ // `src` is markup, a path or bytes - the same three the factory takes.
94
+ svg: (props) => Svg(props.src, props),
95
+ // `paint` is a function PROP - `<Canvas :paint="drawChart" />` in a template passes one like any
96
+ // other value. (Unlike `PageBuilder`, whose closure is the CHILD and has no template form.)
97
+ canvas: (props) => Canvas(props, props.paint),
93
98
  text: (props, children) => Text(textContent(children), props),
94
99
  paragraph: (props, children) => Paragraph(textContent(children), props),
95
100
  // `<Table>` reads its `<TableRow>`/`<TableCell>` structure raw; one row may be marked `header`.
@@ -1,3 +1,5 @@
1
+ import type { Radii } from "../ir/display-list.js";
2
+ import type { CrossAlign } from "../layout/alignment.js";
1
3
  /**
2
4
  * Size input for a Box's `width` / `height` (relative sizing). Either a number of PDF points (a
3
5
  * fixed size) or a percentage string like `"50%"` - a fraction of the space the parent offers on
@@ -18,3 +20,58 @@ export interface Dimension {
18
20
  * boundary so `resolveExtent` and the layout never see a negative size.
19
21
  */
20
22
  export declare function toDimension(value: SizeInput): Dimension;
23
+ /** The bound and ratio options every sized factory accepts, in the public `SizeInput` form. */
24
+ export interface BoundsInput {
25
+ /**
26
+ * This element's own cross-axis alignment inside its Column / Row (CSS `align-self`), overriding the
27
+ * container's `align`. On a `Spacer` / `Expanded` it is a no-op - a flex child fills the main axis and
28
+ * has no natural cross size to align.
29
+ */
30
+ alignSelf?: CrossAlign;
31
+ /**
32
+ * Where this child sits among its siblings (CSS `order`), lowest first, source order deciding ties.
33
+ * It moves the child in the LAYOUT only - the element tree is untouched, so a tagged PDF still
34
+ * exposes the source reading order. Default 0.
35
+ */
36
+ order?: number;
37
+ /**
38
+ * How willingly this child gives up main-axis space when the line overflows (CSS `flex-shrink`),
39
+ * weighted by its own size. Default 1, as in CSS: an overflowing line pulls its children back inside
40
+ * instead of letting them run past the edge. Set `0` to keep a child at its natural size and let it
41
+ * overflow. A child is never squeezed below what it can hold - a text stops at its longest word.
42
+ */
43
+ flexShrink?: number;
44
+ minWidth?: SizeInput;
45
+ maxWidth?: SizeInput;
46
+ minHeight?: SizeInput;
47
+ maxHeight?: SizeInput;
48
+ /**
49
+ * width / height (CSS `aspect-ratio`). Fills in whichever axis you leave open, and sizes the box
50
+ * from the offered width when you leave both open. An explicit `min`/`max` still wins over it.
51
+ */
52
+ aspectRatio?: number;
53
+ }
54
+ /** Split `BoundsInput` into the points-and-factors form the elements take. */
55
+ export declare function toBounds(o: BoundsInput): {
56
+ minWidth: number | undefined;
57
+ maxWidth: number | undefined;
58
+ minHeight: number | undefined;
59
+ maxHeight: number | undefined;
60
+ minWidthFactor: number | undefined;
61
+ maxWidthFactor: number | undefined;
62
+ minHeightFactor: number | undefined;
63
+ maxHeightFactor: number | undefined;
64
+ aspectRatio: number | undefined;
65
+ };
66
+ /**
67
+ * Corner radius input. A single number rounds all four corners; the object names them; the tuple is
68
+ * CSS order - `[topLeft, topRight, bottomRight, bottomLeft]`, clockwise from the top left.
69
+ */
70
+ export type RadiusInput = number | {
71
+ topLeft?: number;
72
+ topRight?: number;
73
+ bottomRight?: number;
74
+ bottomLeft?: number;
75
+ } | [number, number, number, number];
76
+ /** Normalizes a `RadiusInput` to the engine's short corner names. */
77
+ export declare function toRadius(r: RadiusInput): number | Radii;
@@ -14,3 +14,30 @@ export function toDimension(value) {
14
14
  return { factor: parseFloat(m[1]) / 100 };
15
15
  throw new Error(`Invalid size "${value}": use a number of points or a percentage like "50%".`);
16
16
  }
17
+ /** Split `BoundsInput` into the points-and-factors form the elements take. */
18
+ export function toBounds(o) {
19
+ const d = (v) => (v !== undefined ? toDimension(v) : undefined);
20
+ const [minW, maxW, minH, maxH] = [d(o.minWidth), d(o.maxWidth), d(o.minHeight), d(o.maxHeight)];
21
+ if (o.aspectRatio !== undefined && !(o.aspectRatio > 0)) {
22
+ throw new Error(`Invalid aspectRatio ${o.aspectRatio}: it is width / height and must be above 0.`);
23
+ }
24
+ return {
25
+ minWidth: minW?.points,
26
+ maxWidth: maxW?.points,
27
+ minHeight: minH?.points,
28
+ maxHeight: maxH?.points,
29
+ minWidthFactor: minW?.factor,
30
+ maxWidthFactor: maxW?.factor,
31
+ minHeightFactor: minH?.factor,
32
+ maxHeightFactor: maxH?.factor,
33
+ aspectRatio: o.aspectRatio,
34
+ };
35
+ }
36
+ /** Normalizes a `RadiusInput` to the engine's short corner names. */
37
+ export function toRadius(r) {
38
+ if (typeof r === "number")
39
+ return r;
40
+ if (Array.isArray(r))
41
+ return { tl: r[0], tr: r[1], br: r[2], bl: r[3] };
42
+ return { tl: r.topLeft, tr: r.topRight, br: r.bottomRight, bl: r.bottomLeft };
43
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Loading a font over the network for `doc.addFontFromUrl(...)`.
3
+ *
4
+ * `addFont` resolves its source AT REGISTRATION - a path is read there and then - and this keeps the
5
+ * same contract, differing only in being async. So the failure lands where you asked for the font,
6
+ * not somewhere inside a later render.
7
+ *
8
+ * `fetch` is used directly: it is in every browser and in Node since 18, so this needs no platform
9
+ * port, unlike the filesystem read beside it.
10
+ */
11
+ /** One font file by URL, or a styled family of them. Mirrors `FontSource`, with strings meaning URLs. */
12
+ export type UrlFontSource = string | {
13
+ normal: string;
14
+ bold?: string;
15
+ italic?: string;
16
+ boldItalic?: string;
17
+ };
18
+ /** Thrown when a font could not be fetched or is not one we can parse. Always says which URL. */
19
+ export declare class FontUrlError extends Error {
20
+ constructor(message: string);
21
+ }
22
+ /** Fetch a whole `UrlFontSource`, in parallel for a family. */
23
+ export declare function loadFontFromUrl(source: UrlFontSource): Promise<Uint8Array | {
24
+ normal: Uint8Array;
25
+ bold?: Uint8Array;
26
+ italic?: Uint8Array;
27
+ boldItalic?: Uint8Array;
28
+ }>;
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Loading a font over the network for `doc.addFontFromUrl(...)`.
3
+ *
4
+ * `addFont` resolves its source AT REGISTRATION - a path is read there and then - and this keeps the
5
+ * same contract, differing only in being async. So the failure lands where you asked for the font,
6
+ * not somewhere inside a later render.
7
+ *
8
+ * `fetch` is used directly: it is in every browser and in Node since 18, so this needs no platform
9
+ * port, unlike the filesystem read beside it.
10
+ */
11
+ /** Thrown when a font could not be fetched or is not one we can parse. Always says which URL. */
12
+ export class FontUrlError extends Error {
13
+ constructor(message) {
14
+ super(`@jasy/pdf: ${message}`);
15
+ }
16
+ }
17
+ /**
18
+ * What the first four bytes of an sfnt file say it is. Checked here because `TTFParser` does not look
19
+ * at them: it goes straight for the table directory, so an HTML error page or a WOFF would fail deep
20
+ * inside with `missing required table "head"` instead of saying what the file actually is.
21
+ */
22
+ function describeSignature(bytes) {
23
+ const tag = String.fromCharCode(...bytes.subarray(0, 4));
24
+ const v = (bytes[0] << 24) | (bytes[1] << 16) | (bytes[2] << 8) | bytes[3];
25
+ if (v === 0x00010000 || tag === "true")
26
+ return { ok: true, what: "TrueType" };
27
+ if (tag === "OTTO")
28
+ return { ok: false, what: "an OpenType/CFF font, which jasy does not parse yet" };
29
+ if (tag === "wOFF")
30
+ return { ok: true, what: "WOFF" };
31
+ if (tag === "wOF2")
32
+ return { ok: true, what: "WOFF2" };
33
+ if (tag === "ttcf")
34
+ return { ok: false, what: "a TrueType Collection, which jasy does not parse yet" };
35
+ if (tag.startsWith("<") || tag === "%PDF")
36
+ return { ok: false, what: "not a font at all" };
37
+ return { ok: false, what: "not a font we recognise" };
38
+ }
39
+ /** How long one font may take to arrive before we give up. A hung server must not hang a render. */
40
+ const TIMEOUT_MS = 15000;
41
+ /**
42
+ * How large a font file may be. Generous on purpose - a full CJK face is tens of megabytes and is a
43
+ * legitimate thing to embed - but not unbounded: a URL pointing at the wrong file (a video, a disk
44
+ * image) would otherwise be read into memory in full before anyone notices it is not a font.
45
+ */
46
+ const MAX_BYTES = 32 * 1024 * 1024;
47
+ /** Read the body with a running ceiling, so an oversized response is dropped WHILE it arrives. */
48
+ async function readBounded(response, url) {
49
+ // `Content-Length` is the cheap early exit; a server may omit or misstate it, so it is a hint only
50
+ // and the real ceiling is enforced below while reading.
51
+ const declared = Number(response.headers?.get?.("content-length") ?? NaN);
52
+ if (Number.isFinite(declared) && declared > MAX_BYTES) {
53
+ throw new FontUrlError(`the font at ${url} is ${declared} bytes; the limit is ${MAX_BYTES}`);
54
+ }
55
+ const body = response.body;
56
+ if (!body?.getReader) {
57
+ // No streaming body (an older runtime, or a stubbed response): fall back to reading it whole and
58
+ // checking afterwards. Weaker, but never worse than not checking at all.
59
+ const whole = new Uint8Array(await response.arrayBuffer());
60
+ if (whole.length > MAX_BYTES) {
61
+ throw new FontUrlError(`the font at ${url} is larger than the ${MAX_BYTES} byte limit`);
62
+ }
63
+ return whole;
64
+ }
65
+ const reader = body.getReader();
66
+ const chunks = [];
67
+ let total = 0;
68
+ for (;;) {
69
+ const { done, value } = await reader.read();
70
+ if (done)
71
+ break;
72
+ total += value.length;
73
+ if (total > MAX_BYTES) {
74
+ await reader.cancel();
75
+ throw new FontUrlError(`the font at ${url} is larger than the ${MAX_BYTES} byte limit`);
76
+ }
77
+ chunks.push(value);
78
+ }
79
+ if (chunks.length === 1)
80
+ return chunks[0];
81
+ const out = new Uint8Array(total);
82
+ let at = 0;
83
+ for (const c of chunks) {
84
+ out.set(c, at);
85
+ at += c.length;
86
+ }
87
+ return out;
88
+ }
89
+ /** Fetch one font file and hand back its bytes, refusing anything we could not embed. */
90
+ async function fetchFont(url) {
91
+ let response;
92
+ try {
93
+ response = await fetch(url, { signal: AbortSignal.timeout(TIMEOUT_MS) });
94
+ }
95
+ catch (e) {
96
+ const why = e?.name === "TimeoutError" || e?.name === "AbortError"
97
+ ? `it did not respond within ${TIMEOUT_MS} ms`
98
+ : String(e?.message ?? e);
99
+ throw new FontUrlError(`could not fetch the font at ${url}: ${why}`);
100
+ }
101
+ if (!response.ok) {
102
+ throw new FontUrlError(`the font at ${url} could not be fetched: HTTP ${response.status}`);
103
+ }
104
+ // Everything past here still talks to the network, so a failure mid-body must not escape as a raw
105
+ // TypeError - a caller catching FontUrlError would miss it.
106
+ let bytes;
107
+ try {
108
+ bytes = await readBounded(response, url);
109
+ }
110
+ catch (e) {
111
+ if (e instanceof FontUrlError)
112
+ throw e;
113
+ throw new FontUrlError(`the font at ${url} could not be read: ${String(e?.message ?? e)}`);
114
+ }
115
+ if (bytes.length < 4) {
116
+ throw new FontUrlError(`the font at ${url} is empty`);
117
+ }
118
+ const { ok, what } = describeSignature(bytes);
119
+ if (!ok) {
120
+ throw new FontUrlError(`the file at ${url} is ${what}. jasy embeds TrueType-flavoured fonts ` +
121
+ `(.ttf, .woff and .woff2)`);
122
+ }
123
+ return bytes;
124
+ }
125
+ /** Fetch a whole `UrlFontSource`, in parallel for a family. */
126
+ export async function loadFontFromUrl(source) {
127
+ if (typeof source === "string")
128
+ return fetchFont(source);
129
+ const styles = ["normal", "bold", "italic", "boldItalic"];
130
+ const wanted = styles.filter((s) => source[s] !== undefined);
131
+ const loaded = await Promise.all(wanted.map((s) => fetchFont(source[s])));
132
+ const family = { normal: new Uint8Array() };
133
+ wanted.forEach((s, i) => (family[s] = loaded[i]));
134
+ return family;
135
+ }