@jasy/pdf 1.0.0-alpha.9 → 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 (110) hide show
  1. package/README.md +27 -9
  2. package/dist/api/color.js +69 -0
  3. package/dist/api/content.d.ts +40 -1
  4. package/dist/api/content.js +83 -0
  5. package/dist/api/descriptor.js +6 -1
  6. package/dist/api/dimension.d.ts +3 -2
  7. package/dist/api/font-url.js +3 -2
  8. package/dist/api/structure.d.ts +7 -0
  9. package/dist/api/structure.js +38 -3
  10. package/dist/api/text.d.ts +23 -2
  11. package/dist/api/text.js +9 -0
  12. package/dist/canvas/painter.d.ts +102 -0
  13. package/dist/canvas/painter.js +183 -0
  14. package/dist/elements/canvas-element.d.ts +38 -0
  15. package/dist/elements/canvas-element.js +47 -0
  16. package/dist/elements/container-element.d.ts +2 -0
  17. package/dist/elements/container-element.js +6 -0
  18. package/dist/elements/index.d.ts +2 -0
  19. package/dist/elements/index.js +2 -0
  20. package/dist/elements/layout/expanded-element.d.ts +2 -0
  21. package/dist/elements/layout/expanded-element.js +4 -0
  22. package/dist/elements/layout/keep-together-element.d.ts +2 -0
  23. package/dist/elements/layout/keep-together-element.js +4 -0
  24. package/dist/elements/layout/padding-element.d.ts +5 -0
  25. package/dist/elements/layout/padding-element.js +8 -0
  26. package/dist/elements/layout/struct-group.d.ts +2 -0
  27. package/dist/elements/layout/struct-group.js +4 -0
  28. package/dist/elements/pdf-element.d.ts +19 -3
  29. package/dist/elements/pdf-element.js +22 -4
  30. package/dist/elements/rectangle-element.d.ts +2 -0
  31. package/dist/elements/rectangle-element.js +6 -0
  32. package/dist/elements/row-element.d.ts +2 -0
  33. package/dist/elements/row-element.js +6 -0
  34. package/dist/elements/svg-element.d.ts +41 -0
  35. package/dist/elements/svg-element.js +93 -0
  36. package/dist/elements/text-element.d.ts +33 -1
  37. package/dist/elements/text-element.js +80 -4
  38. package/dist/ir/display-list.d.ts +44 -5
  39. package/dist/layout/min-intrinsic.d.ts +29 -0
  40. package/dist/layout/min-intrinsic.js +37 -0
  41. package/dist/renderer/anchor-renderer.js +1 -1
  42. package/dist/renderer/bookmark-renderer.js +1 -1
  43. package/dist/renderer/canvas-renderer.d.ts +14 -0
  44. package/dist/renderer/canvas-renderer.js +36 -0
  45. package/dist/renderer/container-renderer.js +1 -3
  46. package/dist/renderer/default-text-style-renderer.js +1 -1
  47. package/dist/renderer/deferred-renderer.js +1 -1
  48. package/dist/renderer/expanded-renderer.js +1 -1
  49. package/dist/renderer/index.d.ts +2 -0
  50. package/dist/renderer/index.js +2 -0
  51. package/dist/renderer/keep-together-renderer.js +1 -1
  52. package/dist/renderer/link-renderer.js +1 -1
  53. package/dist/renderer/padding-renderer.js +1 -1
  54. package/dist/renderer/page-builder-renderer.js +1 -1
  55. package/dist/renderer/page-renderer.js +1 -3
  56. package/dist/renderer/pdf-backend.d.ts +15 -4
  57. package/dist/renderer/pdf-backend.js +125 -35
  58. package/dist/renderer/pdf-renderer.js +5 -1
  59. package/dist/renderer/positioned-renderer.js +1 -1
  60. package/dist/renderer/rectangle-renderer.js +1 -3
  61. package/dist/renderer/repeating-header-renderer.js +1 -2
  62. package/dist/renderer/rotated-renderer.js +1 -1
  63. package/dist/renderer/row-renderer.js +1 -3
  64. package/dist/renderer/struct-group-renderer.js +1 -1
  65. package/dist/renderer/svg-renderer.d.ts +11 -0
  66. package/dist/renderer/svg-renderer.js +27 -0
  67. package/dist/renderer/text-renderer.js +30 -19
  68. package/dist/svg/css.d.ts +38 -0
  69. package/dist/svg/css.js +115 -0
  70. package/dist/svg/errors.d.ts +24 -0
  71. package/dist/svg/errors.js +30 -0
  72. package/dist/svg/gradients.d.ts +34 -0
  73. package/dist/svg/gradients.js +156 -0
  74. package/dist/svg/index.d.ts +22 -0
  75. package/dist/svg/index.js +331 -0
  76. package/dist/svg/shapes.d.ts +6 -0
  77. package/dist/svg/shapes.js +177 -0
  78. package/dist/svg/style.d.ts +61 -0
  79. package/dist/svg/style.js +144 -0
  80. package/dist/svg/transform.d.ts +13 -0
  81. package/dist/svg/transform.js +68 -0
  82. package/dist/svg/units.d.ts +11 -0
  83. package/dist/svg/units.js +73 -0
  84. package/dist/svg/xml.d.ts +24 -0
  85. package/dist/svg/xml.js +158 -0
  86. package/dist/text/advance.d.ts +3 -0
  87. package/dist/text/advance.js +4 -3
  88. package/dist/text/glyph-coverage.d.ts +15 -0
  89. package/dist/text/glyph-coverage.js +77 -0
  90. package/dist/text/line-breaker.d.ts +8 -0
  91. package/dist/text/line-breaker.js +146 -70
  92. package/dist/text/shape.d.ts +3 -3
  93. package/dist/text/shape.js +34 -10
  94. package/dist/text/text-style.d.ts +13 -0
  95. package/dist/text/text-style.js +6 -0
  96. package/dist/text/whitespace.d.ts +4 -0
  97. package/dist/text/whitespace.js +43 -0
  98. package/dist/text/word-splitting.d.ts +42 -0
  99. package/dist/text/word-splitting.js +72 -0
  100. package/dist/utils/flex-layout.js +67 -18
  101. package/dist/utils/font-metrics.d.ts +9 -3
  102. package/dist/utils/pdf-object-manager.d.ts +25 -5
  103. package/dist/utils/pdf-object-manager.js +82 -18
  104. package/dist/utils/renderer-registry.d.ts +13 -1
  105. package/dist/utils/renderer-registry.js +22 -1
  106. package/dist/utils/woff2.d.ts +6 -0
  107. package/dist/utils/woff2.js +651 -0
  108. package/dist/vector/path.d.ts +33 -0
  109. package/dist/vector/path.js +81 -0
  110. package/package.json +5 -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. Over 900 tests hold the line across the workspace.
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,12 +165,14 @@ 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
- - **Over 900 tests, green** across the workspace (850 in the engine alone). The generator and the parser
175
+ - **Over 1,600 tests, green** across the workspace (1,190 in the engine alone). The generator and the parser
162
176
  are **byte-exact inverses**: `generate → parse → regenerate` reproduces the identical XML. Nothing is
163
177
  silently lost.
164
178
  - **The same rules the authorities use** - the official KoSIT EN-16931 + XRechnung Schematron, and the
@@ -170,10 +184,14 @@ verify all of it:
170
184
 
171
185
  ## Honest scope
172
186
 
173
- jasy targets the documents that matter here: invoices, reports, quotes, datasheets. It is **not** a
174
- LaTeX / WeasyPrint replacement - no microtypography, hyphenation or bidi, and arbitrary multi-page flow
175
- of _any_ content is still maturing. For e-invoices (a table, totals, a footer) it is complete - they
176
- 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.
177
195
 
178
196
  > **Status:** young and pre-1.0. The API can still shift between minor versions. Everything shown here
179
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;
@@ -3,6 +3,8 @@ import { PDFElement } from "../elements/pdf-element.js";
3
3
  import { ColorInput } from "./color.js";
4
4
  import { Insets } from "./insets.js";
5
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,6 +20,13 @@ 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`. */
@@ -36,8 +45,38 @@ export interface ImageOptions extends BoundsInput {
36
45
  * the image is a `Figure`; without it (and when rendered `accessible`) it counts as decoration. */
37
46
  alt?: string;
38
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;
39
75
  /**
40
76
  * An image. `src` is a local file path (wrapped in a `CustomLocalImage`) or a ready
41
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.
42
81
  */
43
- export declare function Image(src: ImageSource, opts?: ImageOptions): ImageElement;
82
+ export declare function Image(src: ImageSource, opts?: ImageOptions): ImageElement | SvgElement;
@@ -4,6 +4,8 @@ import { ImageElement, CustomLocalImage, CustomBytesImage, BoxFit, } from "../el
4
4
  import { toColor } from "./color.js";
5
5
  import { toEdges } from "./insets.js";
6
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,11 +33,92 @@ 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
124
  // ANY explicit size scales the image into the box (fit: fill), which is what `<img width height>`
@@ -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`.
@@ -36,8 +36,9 @@ export interface BoundsInput {
36
36
  order?: number;
37
37
  /**
38
38
  * How willingly this child gives up main-axis space when the line overflows (CSS `flex-shrink`),
39
- * weighted by its own size. **Default 0, deliberately unlike CSS's 1** - shrinking changes what a
40
- * document looks like, so it is opted into per child rather than applied to everything at once.
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.
41
42
  */
42
43
  flexShrink?: number;
43
44
  minWidth?: SizeInput;
@@ -29,7 +29,7 @@ function describeSignature(bytes) {
29
29
  if (tag === "wOFF")
30
30
  return { ok: true, what: "WOFF" };
31
31
  if (tag === "wOF2")
32
- return { ok: false, what: "a WOFF2 font, which jasy does not parse yet" };
32
+ return { ok: true, what: "WOFF2" };
33
33
  if (tag === "ttcf")
34
34
  return { ok: false, what: "a TrueType Collection, which jasy does not parse yet" };
35
35
  if (tag.startsWith("<") || tag === "%PDF")
@@ -117,7 +117,8 @@ async function fetchFont(url) {
117
117
  }
118
118
  const { ok, what } = describeSignature(bytes);
119
119
  if (!ok) {
120
- throw new FontUrlError(`the file at ${url} is ${what}. jasy embeds TrueType-flavoured fonts (.ttf and .woff)`);
120
+ throw new FontUrlError(`the file at ${url} is ${what}. jasy embeds TrueType-flavoured fonts ` +
121
+ `(.ttf, .woff and .woff2)`);
121
122
  }
122
123
  return bytes;
123
124
  }
@@ -151,6 +151,13 @@ export interface RenderOptions {
151
151
  /** What to do when content is taller than a page and cannot break: `"error"` throws (default),
152
152
  * `"warn"` logs and clips, `"ignore"` clips silently. It is always clipped either way. */
153
153
  onOverflow?: OverflowPolicy;
154
+ /**
155
+ * Called once, after the render, with the characters no font could draw - they were removed rather
156
+ * than emitted as `.notdef`, which PDF/A forbids. Deliberately a callback and not a policy: a
157
+ * missing tick mark must never stop an invoice from being produced, but the application should be
158
+ * able to SAY which character went missing, where a server log would never be read.
159
+ */
160
+ onMissingGlyphs?: (characters: string[]) => void;
154
161
  /** Encrypt the PDF with a password (AES-256, the newest standard). NOT compatible with PDF/A
155
162
  * (ZUGFeRD invoices) - encrypting one throws, since PDF/A forbids encryption. */
156
163
  encrypt?: EncryptOptions;
@@ -14,6 +14,7 @@ import { Column } from "./layout.js";
14
14
  import { loadFontFromUrl } from "./font-url.js";
15
15
  import { toEdges } from "./insets.js";
16
16
  import { toTextStyleOverride } from "./text.js";
17
+ import { isWoff2, woff2ToSfnt } from "../utils/woff2.js";
17
18
  const MM_TO_PT = 72 / 25.4; // 1 mm in PDF points
18
19
  /** Millimetres → a custom page size, e.g. a 50×65 mm label: `Page({ size: mm(50, 65) }, …)`. */
19
20
  export function mm(width, height) {
@@ -67,6 +68,29 @@ const docMeta = new WeakMap();
67
68
  const docFonts = new WeakMap();
68
69
  // The document's color-emoji fallback source, kept beside the element and applied at render time.
69
70
  const docEmoji = new WeakMap();
71
+ /**
72
+ * Unwraps a WOFF2 into plain sfnt bytes. WOFF1 is left alone - `registerCustomFont` handles it
73
+ * synchronously, having no dependency to load - so only the async case needs to happen up here.
74
+ */
75
+ async function unwrapFontContainers(fonts) {
76
+ const convert = async (bytes) => isWoff2(bytes) ? await woff2ToSfnt(bytes) : bytes;
77
+ const out = {};
78
+ for (const [name, value] of Object.entries(fonts)) {
79
+ if (isFontBytes(value)) {
80
+ out[name] = await convert(value);
81
+ continue;
82
+ }
83
+ const family = { normal: await convert(value.normal) };
84
+ if (value.bold)
85
+ family.bold = await convert(value.bold);
86
+ if (value.italic)
87
+ family.italic = await convert(value.italic);
88
+ if (value.boldItalic)
89
+ family.boldItalic = await convert(value.boldItalic);
90
+ out[name] = family;
91
+ }
92
+ return out;
93
+ }
70
94
  /** Reads any path sources to bytes, leaving bytes / families as-is. */
71
95
  function resolveFontSource(source) {
72
96
  const read = (s) => (typeof s === "string" ? readFileBytes(s) : s);
@@ -130,10 +154,13 @@ export async function renderPdf(doc, options) {
130
154
  };
131
155
  // Fonts registered via doc.addFont(...) plus any passed in options (options win on a name clash).
132
156
  const registered = docFonts.get(doc);
133
- const fonts = {
157
+ // Containers are unwrapped HERE, before the (synchronous) document constructor registers anything.
158
+ // WOFF2 needs Brotli, which is imported lazily and is therefore async - and this is the last async
159
+ // point every font passes through, whether it came from `addFont`, a URL, or these options.
160
+ const fonts = await unwrapFontContainers({
134
161
  ...Object.fromEntries(registered ?? []),
135
162
  ...options?.fonts,
136
- };
163
+ });
137
164
  const attachments = options?.attachments ?? [];
138
165
  // The security handler's key derivation is async (WebCrypto), so build it here, before the sync
139
166
  // PDFDocument constructor wires it into the object manager.
@@ -143,10 +170,14 @@ export async function renderPdf(doc, options) {
143
170
  // A throwaway PDFDocument whose build() yields this tree, reusing the engine's standard
144
171
  // font registration + config handling (the constructor does both). Custom fonts are
145
172
  // registered here, before layout/render, so both the metrics and the backend see them.
173
+ // Captured so the caller can be told what had to be dropped: `render()` is static and builds its own
174
+ // instance, so this is the only place the object manager surfaces.
175
+ let manager;
146
176
  const Anon = class extends PDFDocument {
147
177
  constructor() {
148
178
  super(config);
149
179
  const om = this.objectManager;
180
+ manager = om;
150
181
  om.setCompress(options?.compress !== false); // FlateDecode streams by default
151
182
  om.setOverflowPolicy(options?.onOverflow ?? "error");
152
183
  om.setKerning(options?.kerning !== false); // on by default (experimental); `kerning:false` opts out
@@ -207,7 +238,11 @@ export async function renderPdf(doc, options) {
207
238
  return doc;
208
239
  }
209
240
  };
210
- return Anon.render();
241
+ const pdf = await Anon.render();
242
+ const missing = manager?.getMissingGlyphs() ?? [];
243
+ if (missing.length > 0)
244
+ options?.onMissingGlyphs?.(missing.map((cp) => String.fromCodePoint(cp)));
245
+ return pdf;
211
246
  }
212
247
  /** Renders a `Document(...)` tree to PDF bytes (e.g. for a download / save dialog). */
213
248
  export async function renderToBytes(doc, options) {
@@ -3,7 +3,8 @@ import { TextOverflow } from "../text/line-breaker.js";
3
3
  import type { Direction } from "../text/bidi.js";
4
4
  import { ResolvedTextStyle, type TextTransform } from "../text/text-style.js";
5
5
  import { ColorInput } from "./color.js";
6
- export type { TextOverflow };
6
+ import type { Hyphenator } from "../text/word-splitting.js";
7
+ export type { TextOverflow, Direction, TextTransform, Hyphenator };
7
8
  /** Text styling shared by `Text`, `Paragraph` and `span`. `bold`/`italic` are booleans
8
9
  * (locked §7.3) and combine into one engine `FontStyle`. */
9
10
  export interface TextStyle {
@@ -35,6 +36,16 @@ export interface TextStyle {
35
36
  wordSpacing?: number;
36
37
  /** CSS `text-indent`, in points: how far the FIRST line of a paragraph starts in. */
37
38
  textIndent?: number;
39
+ /** Split a word that is wider than its box, anywhere, no hyphen (CSS `overflow-wrap:
40
+ * break-word`). The floor under `hyphenate`: an e-mail, an IBAN or an invoice number has no
41
+ * valid hyphenation point anywhere. Off by default - a too-wide word overflows, as in CSS. */
42
+ breakWord?: boolean;
43
+ /** The ligatures the font's designer drew (`fi`, `fl`, `ffi`). ON by default, like kerning -
44
+ * `false` opts this text out. Embedded fonts only; the standard-14 have no GSUB. */
45
+ ligatures?: boolean;
46
+ /** Language-aware splitting: given a word, return its parts. A hyphen is drawn at the break.
47
+ * Pluggable, so no pattern data ships here: pass `hyphen`, `hyphenopoly`, or your own. */
48
+ hyphenate?: Hyphenator;
38
49
  /** CSS `vertical-align` for a SPAN: raises a footnote marker or lowers an index. It shifts the
39
50
  * baseline only - pass a smaller `size` too if you want the browser's `<sup>` look. */
40
51
  verticalAlign?: VerticalTextAlign;
@@ -45,7 +56,7 @@ export interface TextStyle {
45
56
  * same-document /GoTo instead of a URL). Use for a clickable table of contents / cross-reference. */
46
57
  to?: string;
47
58
  }
48
- export interface TextOptions extends TextStyle {
59
+ export interface TextOptions extends Omit<TextStyle, "verticalAlign"> {
49
60
  /** Text-internal alignment (left/center/right) - independent of a parent's `cross` (§5). */
50
61
  align?: "left" | "center" | "right" | "justify";
51
62
  /** Cap the number of wrapped lines (default: unlimited - the text grows down as far as it needs,
@@ -108,6 +119,16 @@ export interface TextDefaults {
108
119
  wordSpacing?: number;
109
120
  /** CSS `text-indent`, in points: how far the FIRST line of a paragraph starts in. */
110
121
  textIndent?: number;
122
+ /** Split a word that is wider than its box, anywhere, no hyphen (CSS `overflow-wrap:
123
+ * break-word`). The floor under `hyphenate`: an e-mail, an IBAN or an invoice number has no
124
+ * valid hyphenation point anywhere. Off by default - a too-wide word overflows, as in CSS. */
125
+ breakWord?: boolean;
126
+ /** The ligatures the font's designer drew (`fi`, `fl`, `ffi`). ON by default, like kerning -
127
+ * `false` opts this text out. Embedded fonts only; the standard-14 have no GSUB. */
128
+ ligatures?: boolean;
129
+ /** Language-aware splitting: given a word, return its parts. A hyphen is drawn at the break.
130
+ * Pluggable, so no pattern data ships here: pass `hyphen`, `hyphenopoly`, or your own. */
131
+ hyphenate?: Hyphenator;
111
132
  }
112
133
  /** Maps the `Text`-style option names onto a partial engine `ResolvedTextStyle` (only the set
113
134
  * fields), for seeding the inheritance cascade. */
package/dist/api/text.js CHANGED
@@ -100,6 +100,9 @@ export function Text(content, opts = {}) {
100
100
  textTransform: opts.textTransform,
101
101
  wordSpacing: opts.wordSpacing,
102
102
  textIndent: opts.textIndent,
103
+ breakWord: opts.breakWord,
104
+ ligatures: opts.ligatures,
105
+ hyphenate: opts.hyphenate,
103
106
  role: opts.role,
104
107
  });
105
108
  }
@@ -142,5 +145,11 @@ export function toTextStyleOverride(opts) {
142
145
  style.wordSpacing = opts.wordSpacing;
143
146
  if (opts.textIndent !== undefined)
144
147
  style.textIndent = opts.textIndent;
148
+ if (opts.breakWord !== undefined)
149
+ style.breakWord = opts.breakWord;
150
+ if (opts.ligatures !== undefined)
151
+ style.ligatures = opts.ligatures;
152
+ if (opts.hyphenate !== undefined)
153
+ style.hyphenate = opts.hyphenate;
145
154
  return style;
146
155
  }
@@ -0,0 +1,102 @@
1
+ import { type ColorInput } from "../api/color.js";
2
+ import { type GradientInput } from "../api/gradient.js";
3
+ import type { IRNode, StrokeStyle } from "../ir/display-list.js";
4
+ /**
5
+ * The imperative escape hatch: a box in the layout that you draw into yourself.
6
+ *
7
+ * It is the SECOND producer of the vector layer (SVG is the first), so it needs no new IR and no
8
+ * backend change - everything it draws is a `Path` node like any other, which means it tags, it
9
+ * paginates, and it works in the browser.
10
+ *
11
+ * Three deliberate departures from the PDFKit-shaped painters (react-pdf's included):
12
+ *
13
+ * 1. **Typed to the value.** `cap: "round"` autocompletes and a wrong one is a compile error, where
14
+ * react-pdf's `paint` callback receives `painter: any` - no help on the one thing you use.
15
+ * 2. **No hidden state.** There is no `lineWidth(2)` that a later `stroke()` happens to pick up, and
16
+ * no `save()`/`restore()` pair to forget: a transform or a clip is a SCOPE (`group`, `clipped`)
17
+ * that closes itself.
18
+ * 3. **One coordinate system.** Top-left, y down, points - the same as every other element in jasy
19
+ * and the same as SVG. The origin is the canvas box, so `0,0` is its own corner.
20
+ */
21
+ /** Where a shape ends up. A gradient resolves against the shape's own bounding box, as in SVG. */
22
+ export type PaintInput = ColorInput | GradientInput;
23
+ export interface StrokeOptions {
24
+ /** Line width in points. Default 1. */
25
+ width?: number;
26
+ cap?: StrokeStyle["cap"];
27
+ join?: StrokeStyle["join"];
28
+ miterLimit?: number;
29
+ /** Dash lengths in points, plus the phase to start at. */
30
+ dash?: number[];
31
+ dashOffset?: number;
32
+ }
33
+ export interface FillOptions {
34
+ /** `evenodd` cuts a hole where subpaths overlap an odd number of times. Default `nonzero`. */
35
+ rule?: "nonzero" | "evenodd";
36
+ }
37
+ /** A transform applied for the duration of a `group`. Composed in this order: translate, rotate, scale. */
38
+ export interface TransformOptions {
39
+ translate?: [number, number];
40
+ /** Degrees, clockwise on the page. */
41
+ rotate?: number;
42
+ /** One number scales both axes. */
43
+ scale?: number | [number, number];
44
+ /** The point `rotate` and `scale` happen around. Default `[0, 0]`. */
45
+ origin?: [number, number];
46
+ }
47
+ /** The size the callback was given, so a drawing can be written relative to its box. */
48
+ export interface CanvasSize {
49
+ width: number;
50
+ height: number;
51
+ }
52
+ export declare class CanvasPainter {
53
+ readonly size: CanvasSize;
54
+ /** The nodes drawn so far, in order. */
55
+ private readonly nodes;
56
+ /** The path being built. Emptied by every painting call, so a shape cannot leak into the next. */
57
+ private current;
58
+ private cursor;
59
+ private start;
60
+ constructor(size: CanvasSize);
61
+ /** Everything drawn, for the renderer. */
62
+ drawn(): IRNode[];
63
+ move(x: number, y: number): this;
64
+ line(x: number, y: number): this;
65
+ /** A cubic Bézier - the curve PDF draws natively. */
66
+ curve(x1: number, y1: number, x2: number, y2: number, x: number, y: number): this;
67
+ /** A quadratic Bézier. PDF has no operator for one, so it is raised to a cubic exactly. */
68
+ quad(cx: number, cy: number, x: number, y: number): this;
69
+ close(): this;
70
+ rect(x: number, y: number, width: number, height: number): this;
71
+ circle(cx: number, cy: number, r: number): this;
72
+ /** Four cubics; the constant is the standard circle-to-Bézier one. */
73
+ ellipse(cx: number, cy: number, rx: number, ry: number): this;
74
+ polygon(points: readonly (readonly [number, number])[]): this;
75
+ /** SVG path data, through the same reader `Svg()` uses - arcs and shorthands included. */
76
+ path(d: string): this;
77
+ /** Fills the path built so far and starts a new one. Default black, as in SVG and PDF. */
78
+ fill(paint?: PaintInput, options?: FillOptions): this;
79
+ /** Strokes the path built so far and starts a new one. */
80
+ stroke(color?: ColorInput, options?: StrokeOptions): this;
81
+ fillAndStroke(paint: PaintInput, color: ColorInput, options?: StrokeOptions & FillOptions): this;
82
+ /**
83
+ * Draws inside a transform. It closes itself, so there is no `restore()` to forget - and a nested
84
+ * `group` composes, exactly like a `<g transform>` in SVG.
85
+ */
86
+ group(transform: TransformOptions, draw: (c: CanvasPainter) => void): this;
87
+ /**
88
+ * Draws clipped to a path. `build` draws the clip shape (nothing is painted from it), `draw` draws
89
+ * what the clip applies to.
90
+ */
91
+ clipped(build: (c: CanvasPainter) => void, draw: (c: CanvasPainter) => void, options?: FillOptions): this;
92
+ private paint;
93
+ /**
94
+ * A path may not cross the edge of a scope. It would be built in one coordinate space and painted
95
+ * in another, and the version that just dropped it made a shape vanish without a word - exactly
96
+ * the silence this codebase refuses everywhere else.
97
+ */
98
+ private assertNoPendingPath;
99
+ private resolve;
100
+ private strokeStyle;
101
+ private matrixOf;
102
+ }