@jasy/pdf 1.0.0-alpha.3 → 1.0.0-alpha.5

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 (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +28 -15
  3. package/dist/api/content.d.ts +3 -0
  4. package/dist/api/content.js +1 -0
  5. package/dist/api/structure.d.ts +24 -0
  6. package/dist/api/structure.js +33 -0
  7. package/dist/api/table.js +13 -5
  8. package/dist/api/text.d.ts +4 -1
  9. package/dist/api/text.js +1 -0
  10. package/dist/assets/font-data.js +17 -2
  11. package/dist/assets/font-data.ts +32 -2
  12. package/dist/crypto/security-handler.d.ts +46 -0
  13. package/dist/crypto/security-handler.js +129 -0
  14. package/dist/crypto/webcrypto.d.ts +11 -0
  15. package/dist/crypto/webcrypto.js +62 -0
  16. package/dist/elements/image-element.d.ts +5 -1
  17. package/dist/elements/image-element.js +3 -1
  18. package/dist/elements/layout/struct-group.d.ts +28 -0
  19. package/dist/elements/layout/struct-group.js +42 -0
  20. package/dist/elements/pdf-element.d.ts +4 -0
  21. package/dist/elements/pdf-element.js +15 -0
  22. package/dist/elements/text-element.d.ts +7 -1
  23. package/dist/elements/text-element.js +5 -2
  24. package/dist/ir/display-list.d.ts +79 -1
  25. package/dist/layout/fragmentation.js +9 -2
  26. package/dist/renderer/emoji-image.d.ts +9 -0
  27. package/dist/renderer/emoji-image.js +68 -0
  28. package/dist/renderer/image-renderer.d.ts +1 -1
  29. package/dist/renderer/image-renderer.js +10 -2
  30. package/dist/renderer/page-renderer.js +19 -4
  31. package/dist/renderer/pdf-backend.d.ts +6 -2
  32. package/dist/renderer/pdf-backend.js +59 -3
  33. package/dist/renderer/pdf-renderer.js +13 -0
  34. package/dist/renderer/struct-group-renderer.d.ts +10 -0
  35. package/dist/renderer/struct-group-renderer.js +24 -0
  36. package/dist/renderer/text-renderer.d.ts +3 -0
  37. package/dist/renderer/text-renderer.js +182 -2
  38. package/dist/text/emoji-codepoints.d.ts +1 -0
  39. package/dist/text/emoji-codepoints.js +29 -0
  40. package/dist/text/line-breaker.js +6 -3
  41. package/dist/utils/md5.js +4 -5
  42. package/dist/utils/pdf-object-manager.d.ts +28 -1
  43. package/dist/utils/pdf-object-manager.js +0 -0
  44. package/dist/utils/struct-tree.d.ts +44 -0
  45. package/dist/utils/struct-tree.js +130 -0
  46. package/dist/utils/ttf-parser.d.ts +73 -0
  47. package/dist/utils/ttf-parser.js +456 -16
  48. package/dist/utils/ttf-subsetter.js +1 -1
  49. package/dist/utils/ua-xmp.d.ts +9 -0
  50. package/dist/utils/ua-xmp.js +32 -0
  51. package/package.json +1 -1
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Florian Heuberger
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -37,7 +37,7 @@ account, no upload, nothing leaves your machine.
37
37
  ## You bring the line items. jasy does the rest.
38
38
 
39
39
  You never compute a total, a VAT breakdown or a rounding again. You hand jasy the line items - it
40
- **derives** the document totals and the EN-16931 VAT breakdown, spec-correct. That is *why* the
40
+ **derives** the document totals and the EN-16931 VAT breakdown, spec-correct. That is _why_ the
41
41
  invoices validate: the amounts are correct **by construction**, so the single biggest class of
42
42
  EN-16931 failures (the BR-CO total checks) simply cannot happen.
43
43
 
@@ -48,13 +48,23 @@ const { bytes, xml } = await renderZugferd({
48
48
  number: "RE-2026-001",
49
49
  issueDate: "2026-06-17",
50
50
  currency: "EUR",
51
- seller: { name: "Northwind Studio GmbH", vatId: "DE123456789",
52
- address: { city: "Berlin", postCode: "10115", country: "DE" } },
53
- buyer: { name: "Globex Corporation Ltd",
54
- address: { city: "Munich", postCode: "80331", country: "DE" } },
51
+ seller: {
52
+ name: "Northwind Studio GmbH",
53
+ vatId: "DE123456789",
54
+ address: { city: "Berlin", postCode: "10115", country: "DE" },
55
+ },
56
+ buyer: {
57
+ name: "Globex Corporation Ltd",
58
+ address: { city: "Munich", postCode: "80331", country: "DE" },
59
+ },
55
60
  lines: [
56
- { name: "Brand identity design", quantity: 2, unit: "HUR", netUnitPrice: 100,
57
- vat: { category: "S", ratePercent: 19 } },
61
+ {
62
+ name: "Brand identity design",
63
+ quantity: 2,
64
+ unit: "HUR",
65
+ netUnitPrice: 100,
66
+ vat: { category: "S", ratePercent: 19 },
67
+ },
58
68
  ],
59
69
  });
60
70
  // bytes -> a valid ZUGFeRD PDF/A-3 · xml -> the embedded EN-16931 XML
@@ -107,8 +117,8 @@ const pdf = await renderToBytes(
107
117
  ```
108
118
 
109
119
  Real text layout (Adobe AFM metrics, kerning, word-wrap), flexbox-style `gap` / `justify` / `align`,
110
- boxes with radius and alpha, images, custom TrueType fonts, and **real pagination** - content that
111
- overflows flows onto the next page, headers and footers repeat.
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.
112
122
 
113
123
  ---
114
124
 
@@ -122,8 +132,11 @@ verify all of it:
122
132
  740 KB font ships as a ~76 KB subset, and the text stays copy- and searchable.
123
133
  - **Compression.** Content streams and images are FlateDecode-compressed; the spreadsheets `jasy export`
124
134
  writes are real `.xlsx` ZIPs we deflate with our own writer and CRC32, zero dependencies.
135
+ - **AES-256 encryption, pure JS.** Lock a PDF with `renderToBytes(doc, { encrypt: { userPassword } })` - the
136
+ newest standard handler (AES-256, R6 / ISO 32000-2) via the platform WebCrypto: no native crypto, no deps,
137
+ the same code in Node **and** the browser. Owner password + permissions optional.
125
138
  - **Real font metrics.** Text is laid out with the Adobe AFM metrics of the standard-14 fonts - kerning
126
- and word-wrap are *computed*, not guessed.
139
+ and word-wrap are _computed_, not guessed.
127
140
  - **PDF/A-3, matched not approximated.** The conformance graph is hand-built and **passes veraPDF**, the
128
141
  official ISO 19005 validator.
129
142
  - **Byte-exact round-trips.** Generate and parse are inverses: `generate → parse → regenerate` reproduces
@@ -135,11 +148,11 @@ verify all of it:
135
148
 
136
149
  ## Packages
137
150
 
138
- | Package | What it is |
139
- | --- | --- |
151
+ | Package | What it is |
152
+ | --------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
140
153
  | **[@jasy/zugferd](https://npmx.dev/@jasy/zugferd)** | ZUGFeRD / XRechnung: your data → PDF/A-3 + EN-16931 XML, with local validation. **The prize.** |
141
- | **[@jasy/cli](https://npmx.dev/@jasy/cli)** | the `jasy` terminal: read · validate · export, headless **and** interactive |
142
- | **[@jasy/pdf](https://npmx.dev/@jasy/pdf)** | the declarative, Flutter-style PDF layout engine that powers them |
154
+ | **[@jasy/cli](https://npmx.dev/@jasy/cli)** | the `jasy` terminal: read · validate · export, headless **and** interactive |
155
+ | **[@jasy/pdf](https://npmx.dev/@jasy/pdf)** | the declarative, Flutter-style PDF layout engine that powers them |
143
156
 
144
157
  ---
145
158
 
@@ -158,7 +171,7 @@ verify all of it:
158
171
 
159
172
  jasy targets the documents that matter here: invoices, reports, quotes, datasheets. It is **not** a
160
173
  LaTeX / WeasyPrint replacement - no microtypography, hyphenation or bidi, and arbitrary multi-page flow
161
- of *any* content is still maturing. For e-invoices (a table, totals, a footer) it is complete - they
174
+ of _any_ content is still maturing. For e-invoices (a table, totals, a footer) it is complete - they
162
175
  even paginate. We would rather under-promise and over-deliver in the demo above.
163
176
 
164
177
  > **Status:** young and pre-1.0. The API can still shift between minor versions. Everything shown here
@@ -28,6 +28,9 @@ export interface ImageOptions {
28
28
  fit?: ImageFit;
29
29
  /** Corner radius in points (rounds the image box). */
30
30
  radius?: number;
31
+ /** Alternate text for accessibility (tagged PDF): describes the image for screen readers. With `alt`
32
+ * the image is a `Figure`; without it (and when rendered `accessible`) it counts as decoration. */
33
+ alt?: string;
31
34
  }
32
35
  /**
33
36
  * An image. `src` is a local file path (wrapped in a `CustomLocalImage`) or a ready
@@ -45,5 +45,6 @@ export function Image(src, opts = {}) {
45
45
  height: opts.height,
46
46
  fit: opts.fit ? FIT[opts.fit] : undefined,
47
47
  radius: opts.radius,
48
+ alt: opts.alt,
48
49
  });
49
50
  }
@@ -4,6 +4,8 @@ import { PDFElement } from "../elements/pdf-element.js";
4
4
  import { DefaultTextStyleElement } from "../elements/layout/default-text-style-element.js";
5
5
  import type { OverflowPolicy } from "../layout/fragmentation.js";
6
6
  import { PageSize } from "../constants/page-sizes.js";
7
+ import { type EncryptOptions } from "../crypto/security-handler.js";
8
+ export type { EncryptOptions, Permissions } from "../crypto/security-handler.js";
7
9
  import { StackOptions } from "./layout.js";
8
10
  import { Insets } from "./insets.js";
9
11
  import { TextDefaults } from "./text.js";
@@ -37,12 +39,25 @@ export declare function Page(children: PDFElement[]): PageElement;
37
39
  export declare function Page(opts: PageOptions, children: PDFElement[]): PageElement;
38
40
  /** Document options: PDF metadata plus the inheritable text defaults (font, size, color, lineHeight,
39
41
  * align, bold/italic) every `Text` inherits unless it sets its own - Flutter's `DefaultTextStyle`. */
42
+ /** Where color emoji come from when a code point isn't in the current text font, so `Text("Hi 😅")`
43
+ * works in one string. Either a **font** (native vector, offline - recommended; register it in
44
+ * `fonts`) as a string name or `{ font }`, or an **image/CDN source** `{ url, format }` (react-pdf
45
+ * style: fetches `${url}${hexCodePoint}.${format}`, e.g. a Twemoji CDN). The font source is better
46
+ * (vector, no network); the image source exists for parity + as an escape hatch. */
47
+ export type EmojiSource = string | {
48
+ font: string;
49
+ } | {
50
+ url: string;
51
+ format?: "png";
52
+ };
40
53
  export interface DocumentOptions extends TextDefaults {
41
54
  /** PDF metadata. */
42
55
  meta?: {
43
56
  title?: string;
44
57
  author?: string;
45
58
  };
59
+ /** Color-emoji fallback: emoji code points not in the text font render from this source. */
60
+ emoji?: EmojiSource;
46
61
  }
47
62
  /** A single font file for `addFont`: a `.ttf` path (read on Node) or its raw bytes (e.g. a browser
48
63
  * upload). */
@@ -120,6 +135,15 @@ export interface RenderOptions {
120
135
  /** What to do when content is taller than a page and cannot break: `"error"` throws (default),
121
136
  * `"warn"` logs and clips, `"ignore"` clips silently. It is always clipped either way. */
122
137
  onOverflow?: OverflowPolicy;
138
+ /** Encrypt the PDF with a password (AES-256, the newest standard). NOT compatible with PDF/A
139
+ * (ZUGFeRD invoices) - encrypting one throws, since PDF/A forbids encryption. */
140
+ encrypt?: EncryptOptions;
141
+ /** Emit an accessible, tagged PDF (structure tree for screen readers). */
142
+ accessible?: boolean;
143
+ /** Document language for accessibility, e.g. `"en-US"` / `"de-DE"` (default `"en-US"`). */
144
+ lang?: string;
145
+ /** Document title (used by accessible readers; also good metadata). */
146
+ title?: string;
123
147
  }
124
148
  /** Renders a `Document(...)` tree to the raw PDF string. */
125
149
  export declare function renderPdf(doc: PDFDocumentElement, options?: RenderOptions): Promise<string>;
@@ -7,6 +7,8 @@ import { Orientation } from "../renderer/pdf-config.js";
7
7
  import { PDFDocument } from "../renderer/pdf-document-class.js";
8
8
  import { FontStyle } from "../utils/pdf-object-manager.js";
9
9
  import { getArrayBuffer } from "../utils/utf8-to-windows1252-encoder.js";
10
+ import { createSecurityHandler } from "../crypto/security-handler.js";
11
+ import { uaXmp } from "../utils/ua-xmp.js";
10
12
  import { Column } from "./layout.js";
11
13
  import { toEdges } from "./insets.js";
12
14
  import { toTextStyleOverride } from "./text.js";
@@ -55,6 +57,8 @@ const docMeta = new WeakMap();
55
57
  // Fonts registered via doc.addFont(...), kept beside the element (like meta) and registered on the
56
58
  // object manager at render time. Path sources are read to bytes in addFont, so the map holds bytes.
57
59
  const docFonts = new WeakMap();
60
+ // The document's color-emoji fallback source, kept beside the element and applied at render time.
61
+ const docEmoji = new WeakMap();
58
62
  /** Reads any path sources to bytes, leaving bytes / families as-is. */
59
63
  function resolveFontSource(source) {
60
64
  const read = (s) => (typeof s === "string" ? readFileBytes(s) : s);
@@ -80,6 +84,8 @@ export function Document(a, b) {
80
84
  });
81
85
  if (opts.meta)
82
86
  docMeta.set(doc, opts.meta);
87
+ if (opts.emoji)
88
+ docEmoji.set(doc, opts.emoji);
83
89
  // Managed font registry: addFont registers, getFonts/hasFont query, render reads it (below).
84
90
  const registry = new Map();
85
91
  docFonts.set(doc, registry);
@@ -117,6 +123,11 @@ export async function renderPdf(doc, options) {
117
123
  ...options?.fonts,
118
124
  };
119
125
  const attachments = options?.attachments ?? [];
126
+ // The security handler's key derivation is async (WebCrypto), so build it here, before the sync
127
+ // PDFDocument constructor wires it into the object manager.
128
+ const securityHandler = options?.encrypt
129
+ ? await createSecurityHandler(options.encrypt)
130
+ : undefined;
120
131
  // A throwaway PDFDocument whose build() yields this tree, reusing the engine's standard
121
132
  // font registration + config handling (the constructor does both). Custom fonts are
122
133
  // registered here, before layout/render, so both the metrics and the backend see them.
@@ -147,6 +158,14 @@ export async function renderPdf(doc, options) {
147
158
  description: a.description,
148
159
  });
149
160
  }
161
+ // Color-emoji fallback (Document({ emoji })): a font (native vector) or a CDN/image source.
162
+ const emoji = docEmoji.get(doc);
163
+ if (typeof emoji === "string")
164
+ om.setEmojiFont(emoji);
165
+ else if (emoji && "font" in emoji)
166
+ om.setEmojiFont(emoji.font);
167
+ else if (emoji && "url" in emoji)
168
+ om.setEmojiImageSource(emoji.url, emoji.format ?? "png");
150
169
  if (options?.xmp)
151
170
  om.setXmpMetadata(options.xmp);
152
171
  if (options?.outputIntent)
@@ -155,6 +174,20 @@ export async function renderPdf(doc, options) {
155
174
  om.setPdfVersion(options.pdfVersion);
156
175
  if (options?.documentId)
157
176
  om.enableDocumentId();
177
+ if (securityHandler)
178
+ om.setSecurityHandler(securityHandler);
179
+ if (options?.accessible) {
180
+ om.struct.enabled = true;
181
+ if (options.lang)
182
+ om.struct.lang = options.lang;
183
+ // Fall back to the document's own title (Document({ meta })) when no explicit title is given.
184
+ const title = options.title ?? meta?.title;
185
+ if (title)
186
+ om.struct.title = title;
187
+ // Declare PDF/UA-1 in the XMP metadata, unless the caller supplied their own packet.
188
+ if (!options.xmp)
189
+ om.setXmpMetadata(uaXmp({ title }));
190
+ }
158
191
  }
159
192
  build() {
160
193
  return doc;
package/dist/api/table.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { BoxConstraints } from "../layout/box-constraints.js";
2
2
  import { RepeatingHeaderElement } from "../elements/layout/repeating-header-element.js";
3
3
  import { DeferredElement } from "../elements/layout/deferred-element.js";
4
+ import { StructGroup } from "../elements/layout/struct-group.js";
4
5
  import { Column, Row, Box, Expanded, Padding } from "./layout.js";
5
6
  import { Text } from "./text.js";
6
7
  function asElement(c) {
@@ -52,8 +53,11 @@ function composeTable(opts, rows, columns) {
52
53
  ...(firstCol ? { borderLeft: cb } : {}),
53
54
  };
54
55
  // `ruled` rows get just a bottom line (the header underline / the table foot), without the full grid.
55
- const wrap = (cell, col, firstRow, firstCol, ruled) => {
56
- const inner = cellPadding !== undefined ? Padding(cellPadding, asElement(cell)) : asElement(cell);
56
+ const wrap = (cell, col, firstRow, firstCol, ruled, isHeader) => {
57
+ const content = cellPadding !== undefined ? Padding(cellPadding, asElement(cell)) : asElement(cell);
58
+ // Tag the cell (TH for a header cell, else TD). StructGroup is layout-transparent - visually nothing
59
+ // changes; it only nests the cell's content under a table-cell element in the accessible structure tree.
60
+ const inner = new StructGroup({ role: isHeader ? "TH" : "TD", child: content });
57
61
  // The border lives on the wrapper, which stretches to the row height (crisp lines).
58
62
  const border = {
59
63
  ...borderFor(firstRow, firstCol),
@@ -68,14 +72,18 @@ function composeTable(opts, rows, columns) {
68
72
  throw new Error(`Unsupported column width "${col}" - use a number of points or "<n>fr"`);
69
73
  };
70
74
  // align:stretch → equal-height cells, so a wrapping cell keeps the row's bottom rule straight.
71
- const buildRow = (cells, firstRow, ruled = false) => Row({ gap: colGap, align: "stretch" }, cells.map((cell, i) => wrap(cell, columns[i] ?? "1fr", firstRow, i === 0, ruled)));
75
+ const buildRow = (cells, firstRow, ruled = false, isHeader = false) => new StructGroup({
76
+ role: "TR",
77
+ child: Row({ gap: colGap, align: "stretch" }, cells.map((cell, i) => wrap(cell, columns[i] ?? "1fr", firstRow, i === 0, ruled, isHeader))),
78
+ });
72
79
  // The first row (which gets the top border) is the header if present, else body row 0.
73
80
  // `rule` underlines the header and the last body row (the table foot).
74
81
  const last = rows.length - 1;
75
82
  const body = Column({ gap: rowGap }, rows.map((cells, idx) => buildRow(cells, !opts.header && idx === 0, rule !== undefined && idx === last)));
76
- return opts.header
77
- ? new RepeatingHeaderElement(buildRow(opts.header, true, rule !== undefined), body, rowGap)
83
+ const table = opts.header
84
+ ? new RepeatingHeaderElement(buildRow(opts.header, true, rule !== undefined, true), body, rowGap)
78
85
  : body;
86
+ return new StructGroup({ role: "Table", child: table });
79
87
  }
80
88
  /**
81
89
  * A `Column` of `Row`s. Fixed-point columns sit in a fixed-width `Box`, `"Nfr"` fractions
@@ -1,4 +1,4 @@
1
- import { TextElement, TextSegment } from "../elements/text-element.js";
1
+ import { TextElement, TextRole, TextSegment } from "../elements/text-element.js";
2
2
  import { TextOverflow } from "../text/line-breaker.js";
3
3
  import { ResolvedTextStyle } from "../text/text-style.js";
4
4
  import { ColorInput } from "./color.js";
@@ -23,6 +23,9 @@ export interface TextOptions extends TextStyle {
23
23
  /** Line-height multiplier (default `1`). `1.4` gives roomier body copy; each line is
24
24
  * `size * lineHeight` tall. */
25
25
  lineHeight?: number;
26
+ /** Accessibility role for the tagged structure tree (only when rendered with `accessible`): a heading
27
+ * level `"h1"`..`"h6"` or `"p"` (default). Purely semantic - it does not change the visual style. */
28
+ role?: TextRole;
26
29
  }
27
30
  /**
28
31
  * An inline run for mixed-style `Text` (`Text([span("a", {bold:true}), span("b")])`). Each
package/dist/api/text.js CHANGED
@@ -52,6 +52,7 @@ export function Text(content, opts = {}) {
52
52
  maxLines: opts.maxLines,
53
53
  overflow: opts.overflow,
54
54
  lineHeight: opts.lineHeight,
55
+ role: opts.role,
55
56
  });
56
57
  }
57
58
  /** `Text` with body-paragraph defaults (same options; a separate name reads as intent). */