@jasy/pdf 1.0.0-alpha.4 → 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 (42) hide show
  1. package/README.md +5 -2
  2. package/dist/api/content.d.ts +3 -0
  3. package/dist/api/content.js +1 -0
  4. package/dist/api/structure.d.ts +19 -0
  5. package/dist/api/structure.js +25 -0
  6. package/dist/api/table.js +13 -5
  7. package/dist/api/text.d.ts +4 -1
  8. package/dist/api/text.js +1 -0
  9. package/dist/elements/image-element.d.ts +5 -1
  10. package/dist/elements/image-element.js +3 -1
  11. package/dist/elements/layout/struct-group.d.ts +28 -0
  12. package/dist/elements/layout/struct-group.js +42 -0
  13. package/dist/elements/pdf-element.d.ts +4 -0
  14. package/dist/elements/pdf-element.js +15 -0
  15. package/dist/elements/text-element.d.ts +7 -1
  16. package/dist/elements/text-element.js +5 -2
  17. package/dist/ir/display-list.d.ts +79 -1
  18. package/dist/layout/fragmentation.js +9 -2
  19. package/dist/renderer/emoji-image.d.ts +9 -0
  20. package/dist/renderer/emoji-image.js +68 -0
  21. package/dist/renderer/image-renderer.d.ts +1 -1
  22. package/dist/renderer/image-renderer.js +10 -2
  23. package/dist/renderer/page-renderer.js +19 -4
  24. package/dist/renderer/pdf-backend.d.ts +6 -2
  25. package/dist/renderer/pdf-backend.js +59 -3
  26. package/dist/renderer/pdf-renderer.js +11 -0
  27. package/dist/renderer/struct-group-renderer.d.ts +10 -0
  28. package/dist/renderer/struct-group-renderer.js +24 -0
  29. package/dist/renderer/text-renderer.d.ts +3 -0
  30. package/dist/renderer/text-renderer.js +182 -2
  31. package/dist/text/emoji-codepoints.d.ts +1 -0
  32. package/dist/text/emoji-codepoints.js +29 -0
  33. package/dist/text/line-breaker.js +6 -3
  34. package/dist/utils/pdf-object-manager.d.ts +20 -1
  35. package/dist/utils/pdf-object-manager.js +0 -0
  36. package/dist/utils/struct-tree.d.ts +44 -0
  37. package/dist/utils/struct-tree.js +130 -0
  38. package/dist/utils/ttf-parser.d.ts +73 -0
  39. package/dist/utils/ttf-parser.js +456 -16
  40. package/dist/utils/ua-xmp.d.ts +9 -0
  41. package/dist/utils/ua-xmp.js +32 -0
  42. package/package.json +1 -1
package/README.md CHANGED
@@ -117,8 +117,8 @@ const pdf = await renderToBytes(
117
117
  ```
118
118
 
119
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, and **real pagination** - content that
121
- 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.
122
122
 
123
123
  ---
124
124
 
@@ -132,6 +132,9 @@ verify all of it:
132
132
  740 KB font ships as a ~76 KB subset, and the text stays copy- and searchable.
133
133
  - **Compression.** Content streams and images are FlateDecode-compressed; the spreadsheets `jasy export`
134
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.
135
138
  - **Real font metrics.** Text is laid out with the Adobe AFM metrics of the standard-14 fonts - kerning
136
139
  and word-wrap are _computed_, not guessed.
137
140
  - **PDF/A-3, matched not approximated.** The conformance graph is hand-built and **passes veraPDF**, the
@@ -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
  }
@@ -39,12 +39,25 @@ export declare function Page(children: PDFElement[]): PageElement;
39
39
  export declare function Page(opts: PageOptions, children: PDFElement[]): PageElement;
40
40
  /** Document options: PDF metadata plus the inheritable text defaults (font, size, color, lineHeight,
41
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
+ };
42
53
  export interface DocumentOptions extends TextDefaults {
43
54
  /** PDF metadata. */
44
55
  meta?: {
45
56
  title?: string;
46
57
  author?: string;
47
58
  };
59
+ /** Color-emoji fallback: emoji code points not in the text font render from this source. */
60
+ emoji?: EmojiSource;
48
61
  }
49
62
  /** A single font file for `addFont`: a `.ttf` path (read on Node) or its raw bytes (e.g. a browser
50
63
  * upload). */
@@ -125,6 +138,12 @@ export interface RenderOptions {
125
138
  /** Encrypt the PDF with a password (AES-256, the newest standard). NOT compatible with PDF/A
126
139
  * (ZUGFeRD invoices) - encrypting one throws, since PDF/A forbids encryption. */
127
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;
128
147
  }
129
148
  /** Renders a `Document(...)` tree to the raw PDF string. */
130
149
  export declare function renderPdf(doc: PDFDocumentElement, options?: RenderOptions): Promise<string>;
@@ -8,6 +8,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
10
  import { createSecurityHandler } from "../crypto/security-handler.js";
11
+ import { uaXmp } from "../utils/ua-xmp.js";
11
12
  import { Column } from "./layout.js";
12
13
  import { toEdges } from "./insets.js";
13
14
  import { toTextStyleOverride } from "./text.js";
@@ -56,6 +57,8 @@ const docMeta = new WeakMap();
56
57
  // Fonts registered via doc.addFont(...), kept beside the element (like meta) and registered on the
57
58
  // object manager at render time. Path sources are read to bytes in addFont, so the map holds bytes.
58
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();
59
62
  /** Reads any path sources to bytes, leaving bytes / families as-is. */
60
63
  function resolveFontSource(source) {
61
64
  const read = (s) => (typeof s === "string" ? readFileBytes(s) : s);
@@ -81,6 +84,8 @@ export function Document(a, b) {
81
84
  });
82
85
  if (opts.meta)
83
86
  docMeta.set(doc, opts.meta);
87
+ if (opts.emoji)
88
+ docEmoji.set(doc, opts.emoji);
84
89
  // Managed font registry: addFont registers, getFonts/hasFont query, render reads it (below).
85
90
  const registry = new Map();
86
91
  docFonts.set(doc, registry);
@@ -153,6 +158,14 @@ export async function renderPdf(doc, options) {
153
158
  description: a.description,
154
159
  });
155
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");
156
169
  if (options?.xmp)
157
170
  om.setXmpMetadata(options.xmp);
158
171
  if (options?.outputIntent)
@@ -163,6 +176,18 @@ export async function renderPdf(doc, options) {
163
176
  om.enableDocumentId();
164
177
  if (securityHandler)
165
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
+ }
166
191
  }
167
192
  build() {
168
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). */
@@ -52,12 +52,15 @@ interface ImageElementParams {
52
52
  fit?: BoxFit;
53
53
  /** Corner radius in points; rounds the image box (0 = sharp, default). */
54
54
  radius?: number;
55
+ /** Alternate text for accessibility (tagged PDF). With `alt` the image is a Figure; without, decoration. */
56
+ alt?: string;
55
57
  }
56
58
  export declare class ImageElement extends SizedPDFElement {
57
59
  private image;
58
60
  private fit;
59
61
  private radius;
60
- constructor({ image, width, height, fit, radius }: ImageElementParams);
62
+ private readonly alt?;
63
+ constructor({ image, width, height, fit, radius, alt }: ImageElementParams);
61
64
  calculateLayout(constraints: BoxConstraints, offset: Offset, _ctx: LayoutContext): Size;
62
65
  getProps(): {
63
66
  x: number;
@@ -67,6 +70,7 @@ export declare class ImageElement extends SizedPDFElement {
67
70
  image: CustomImage;
68
71
  fit: BoxFit;
69
72
  radius: number;
73
+ alt: string | undefined;
70
74
  };
71
75
  }
72
76
  export {};
@@ -95,12 +95,13 @@ export class CustomBytesImage extends CustomImage {
95
95
  }
96
96
  }
97
97
  export class ImageElement extends SizedPDFElement {
98
- constructor({ image, width, height, fit = BoxFit.none, radius }) {
98
+ constructor({ image, width, height, fit = BoxFit.none, radius, alt }) {
99
99
  super({ x: 0, y: 0, width });
100
100
  this.image = image;
101
101
  this.height = height;
102
102
  this.fit = fit;
103
103
  this.radius = radius ?? 0;
104
+ this.alt = alt;
104
105
  }
105
106
  calculateLayout(constraints, offset, _ctx) {
106
107
  this.x = offset.x;
@@ -122,6 +123,7 @@ export class ImageElement extends SizedPDFElement {
122
123
  image: this.image,
123
124
  fit: this.fit,
124
125
  radius: this.radius,
126
+ alt: this.alt,
125
127
  };
126
128
  }
127
129
  }
@@ -0,0 +1,28 @@
1
+ import { PDFElement, LayoutContext } from "../pdf-element.js";
2
+ import { BoxConstraints, Offset, Size } from "../../layout/box-constraints.js";
3
+ import { Fragmentable, FragmentResult } from "../../layout/fragmentation.js";
4
+ /**
5
+ * A layout-TRANSPARENT wrapper that assigns an accessibility structure role (Table / TR / TH / TD / …) to
6
+ * its single child. It delegates ALL layout and fragmentation to the child, so the visual result is byte-
7
+ * identical; only its renderer opens a structure element and nests the child's tags underneath.
8
+ *
9
+ * Split across pages, the fitted and remainder halves share this group's `structId`, so it stays ONE
10
+ * logical structure element (a table broken over pages is a single <Table>, exactly as Acrobat produces).
11
+ */
12
+ export declare class StructGroup extends PDFElement implements Fragmentable {
13
+ private role;
14
+ private child;
15
+ constructor({ role, child }: {
16
+ role: string;
17
+ child: PDFElement;
18
+ });
19
+ calculateLayout(constraints: BoxConstraints, offset: Offset, ctx: LayoutContext): Size;
20
+ /** Only splittable when the wrapped child is - so a StructGroup(Row) is moved whole to the next page,
21
+ * not clipped at the boundary (isFragmentable honours this veto). */
22
+ canFragment(): boolean;
23
+ fragment(maxHeight: number, width: number, ctx: LayoutContext): FragmentResult;
24
+ getProps(): {
25
+ role: string;
26
+ child: PDFElement;
27
+ };
28
+ }
@@ -0,0 +1,42 @@
1
+ import { PDFElement } from "../pdf-element.js";
2
+ import { isFragmentable } from "../../layout/fragmentation.js";
3
+ /**
4
+ * A layout-TRANSPARENT wrapper that assigns an accessibility structure role (Table / TR / TH / TD / …) to
5
+ * its single child. It delegates ALL layout and fragmentation to the child, so the visual result is byte-
6
+ * identical; only its renderer opens a structure element and nests the child's tags underneath.
7
+ *
8
+ * Split across pages, the fitted and remainder halves share this group's `structId`, so it stays ONE
9
+ * logical structure element (a table broken over pages is a single <Table>, exactly as Acrobat produces).
10
+ */
11
+ export class StructGroup extends PDFElement {
12
+ constructor({ role, child }) {
13
+ super();
14
+ this.role = role;
15
+ this.child = child;
16
+ }
17
+ calculateLayout(constraints, offset, ctx) {
18
+ return this.child.calculateLayout(constraints, offset, ctx);
19
+ }
20
+ /** Only splittable when the wrapped child is - so a StructGroup(Row) is moved whole to the next page,
21
+ * not clipped at the boundary (isFragmentable honours this veto). */
22
+ canFragment() {
23
+ return isFragmentable(this.child);
24
+ }
25
+ fragment(maxHeight, width, ctx) {
26
+ if (!isFragmentable(this.child))
27
+ return { fitted: null, remainder: this };
28
+ const { fitted, remainder } = this.child.fragment(maxHeight, width, ctx);
29
+ // Re-wrap each half with the SAME role + identity, so both pages point at one structure element.
30
+ return {
31
+ fitted: fitted
32
+ ? new StructGroup({ role: this.role, child: fitted }).adoptStructId(this)
33
+ : null,
34
+ remainder: remainder
35
+ ? new StructGroup({ role: this.role, child: remainder }).adoptStructId(this)
36
+ : null,
37
+ };
38
+ }
39
+ getProps() {
40
+ return { role: this.role, child: this.child };
41
+ }
42
+ }
@@ -37,6 +37,10 @@ export interface LayoutContext {
37
37
  onOverflow?: OverflowPolicy;
38
38
  }
39
39
  export declare abstract class PDFElement {
40
+ private _structId;
41
+ get structId(): number;
42
+ /** Make this element share `from`'s tagging identity - used by fragmentation clones. Returns this. */
43
+ adoptStructId(from: PDFElement): this;
40
44
  abstract getProps(): unknown;
41
45
  /**
42
46
  * Lays the element out: `constraints` bound its size (flows DOWN), `offset` is the
@@ -1,4 +1,19 @@
1
+ let _nextStructId = 0;
1
2
  export class PDFElement {
3
+ constructor() {
4
+ // Stable identity for accessible (PDF/UA) tagging: assigned once at construction and carried through
5
+ // fragmentation copies (adoptStructId), so a paragraph or table split across pages stays ONE structure
6
+ // element. Only the elements that actually tag (Text, Image, StructGroup) read it.
7
+ this._structId = _nextStructId++;
8
+ }
9
+ get structId() {
10
+ return this._structId;
11
+ }
12
+ /** Make this element share `from`'s tagging identity - used by fragmentation clones. Returns this. */
13
+ adoptStructId(from) {
14
+ this._structId = from._structId;
15
+ return this;
16
+ }
2
17
  }
3
18
  export class SizedPDFElement extends PDFElement {
4
19
  constructor(data) {
@@ -11,6 +11,8 @@ export interface TextSegment {
11
11
  fontFamily?: string;
12
12
  fontSize?: number;
13
13
  }
14
+ /** Accessibility role for the tagged structure tree: a heading level or a paragraph (the default). */
15
+ export type TextRole = "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "p";
14
16
  interface TextElementParams {
15
17
  id?: string;
16
18
  /** Unset (undefined) inherits the cascaded size; see ResolvedTextStyle. */
@@ -26,6 +28,8 @@ interface TextElementParams {
26
28
  overflow?: TextOverflow;
27
29
  /** Line-height multiplier: each line is `fontSize * lineHeight` tall (default `1`). */
28
30
  lineHeight?: number;
31
+ /** Accessibility role for the tagged structure tree (heading level or paragraph; default `"p"`). */
32
+ role?: TextRole;
29
33
  }
30
34
  export declare class TextElement extends SizedPDFElement implements Fragmentable {
31
35
  private readonly rawFontSize?;
@@ -43,7 +47,8 @@ export declare class TextElement extends SizedPDFElement implements Fragmentable
43
47
  private content;
44
48
  private maxLines?;
45
49
  private overflow;
46
- constructor({ fontSize, content, fontFamily, fontStyle, color, textAlignment, maxLines, overflow, lineHeight, }: TextElementParams);
50
+ private readonly role?;
51
+ constructor({ fontSize, content, fontFamily, fontStyle, color, textAlignment, maxLines, overflow, lineHeight, role, }: TextElementParams);
47
52
  private resolveStyle;
48
53
  private applyStyle;
49
54
  /**
@@ -80,6 +85,7 @@ export declare class TextElement extends SizedPDFElement implements Fragmentable
80
85
  maxLines: number | undefined;
81
86
  overflow: TextOverflow;
82
87
  lineHeight: number;
88
+ role: TextRole | undefined;
83
89
  };
84
90
  }
85
91
  export {};
@@ -7,8 +7,9 @@ import { DEFAULT_TEXT_STYLE } from "../text/text-style.js";
7
7
  import { wrapStringIntoLines, breakSegmentsIntoLines, segmentLinesToSegments, } from "../text/line-breaker.js";
8
8
  import { SizedPDFElement } from "./pdf-element.js";
9
9
  export class TextElement extends SizedPDFElement {
10
- constructor({ fontSize, content, fontFamily, fontStyle, color, textAlignment, maxLines, overflow = "clip", lineHeight, }) {
10
+ constructor({ fontSize, content, fontFamily, fontStyle, color, textAlignment, maxLines, overflow = "clip", lineHeight, role, }) {
11
11
  super({ x: 0, y: 0 });
12
+ this.role = role;
12
13
  this.rawFontSize = fontSize;
13
14
  this.rawFontFamily = fontFamily;
14
15
  this.rawFontStyle = fontStyle;
@@ -97,7 +98,8 @@ export class TextElement extends SizedPDFElement {
97
98
  maxLines: this.maxLines,
98
99
  overflow: this.overflow,
99
100
  lineHeight: this.lineHeight,
100
- });
101
+ role: this.role,
102
+ }).adoptStructId(this); // a wrapped remainder is the SAME logical paragraph (one P across pages)
101
103
  }
102
104
  calculateLayout(constraints, offset, ctx) {
103
105
  this.resolveStyle(ctx);
@@ -157,6 +159,7 @@ export class TextElement extends SizedPDFElement {
157
159
  maxLines: this.maxLines,
158
160
  overflow: this.overflow,
159
161
  lineHeight: this.lineHeight,
162
+ role: this.role,
160
163
  };
161
164
  }
162
165
  }
@@ -11,6 +11,16 @@ import { FontStyle } from "../utils/pdf-object-manager.js";
11
11
  * Coordinates are in PDF points (1/72"). Which origin they use is still the
12
12
  * producer's concern for now; centralizing the Y-flip at this seam is Phase 3.
13
13
  */
14
+ /**
15
+ * Structure tag for accessible (PDF/UA) tagging. `role` is the PDF structure type the content maps to
16
+ * (`P`, `H1`..`H6`, `Figure`, `TD`, …); `key` groups the IR nodes of one logical element (e.g. every line
17
+ * of a paragraph) into a single structure element; `alt` is the alternate text for a figure. A drawable
18
+ * node WITHOUT a tag is emitted as an Artifact (decoration, skipped by screen readers).
19
+ */
20
+ export interface StructTag {
21
+ role: string;
22
+ key: number;
23
+ }
14
24
  /**
15
25
  * A single positioned run of text in one font / size / color. Line wrapping has
16
26
  * already happened upstream: the producer emits one `TextRun` per laid-out line or
@@ -25,6 +35,7 @@ export interface TextRun {
25
35
  fontStyle: FontStyle;
26
36
  fontSize: number;
27
37
  color: Color;
38
+ tag?: StructTag;
28
39
  }
29
40
  /** An axis-aligned rectangle. An absent `fill` or `stroke` means that part is not drawn. */
30
41
  export interface Rect {
@@ -38,6 +49,7 @@ export interface Rect {
38
49
  strokeWidth: number;
39
50
  /** Corner radius in points; absent/0 = sharp corners (plain `re`). */
40
51
  radius?: number;
52
+ tag?: StructTag;
41
53
  }
42
54
  /** A straight line segment between two points. */
43
55
  export interface Line {
@@ -48,6 +60,7 @@ export interface Line {
48
60
  y2: number;
49
61
  stroke: Color;
50
62
  strokeWidth: number;
63
+ tag?: StructTag;
51
64
  }
52
65
  /** A raster image, already resolved to bytes by the producer (no `CustomImage` here). */
53
66
  export interface Image {
@@ -71,6 +84,7 @@ export interface Image {
71
84
  };
72
85
  /** Corner radius in points for the image box; absent/0 = sharp corners. */
73
86
  radius?: number;
87
+ tag?: StructTag;
74
88
  }
75
89
  /**
76
90
  * Pushes a clipping region (a rectangle, rounded when `radius` is set). Everything between this and
@@ -89,5 +103,69 @@ export interface ClipPush {
89
103
  export interface ClipPop {
90
104
  type: "clip-pop";
91
105
  }
106
+ /**
107
+ * One drawing command of a `Path`, in absolute page coordinates. Curves are CUBIC because PDF
108
+ * content streams have no quadratic operator - a producer converts TrueType quads to cubics before
109
+ * emitting. `z` closes the current subpath (a glyph contour).
110
+ */
111
+ export type PathCommand = {
112
+ op: "m";
113
+ x: number;
114
+ y: number;
115
+ } | {
116
+ op: "l";
117
+ x: number;
118
+ y: number;
119
+ } | {
120
+ op: "c";
121
+ x1: number;
122
+ y1: number;
123
+ x2: number;
124
+ y2: number;
125
+ x: number;
126
+ y: number;
127
+ } | {
128
+ op: "z";
129
+ };
130
+ /** One color stop of a gradient: a position 0..1 along the axis and its color. */
131
+ export interface GradientStop {
132
+ offset: number;
133
+ color: Color;
134
+ }
135
+ /** An axial (linear) gradient between two points, in absolute page coordinates. */
136
+ export interface LinearGradient {
137
+ type: "linear";
138
+ x0: number;
139
+ y0: number;
140
+ x1: number;
141
+ y1: number;
142
+ stops: GradientStop[];
143
+ extend: "pad" | "repeat" | "reflect";
144
+ }
145
+ /** A radial gradient between two circles, in absolute page coordinates. */
146
+ export interface RadialGradient {
147
+ type: "radial";
148
+ x0: number;
149
+ y0: number;
150
+ r0: number;
151
+ x1: number;
152
+ y1: number;
153
+ r1: number;
154
+ stops: GradientStop[];
155
+ extend: "pad" | "repeat" | "reflect";
156
+ }
157
+ export type Gradient = LinearGradient | RadialGradient;
158
+ /**
159
+ * A filled vector path - one or more closed subpaths sharing a single fill, using the nonzero
160
+ * winding rule (so a glyph's inner contour cuts a hole). This is what a color glyph's layers draw
161
+ * onto: each COLR layer is one `Path` filled with a solid `Color` (v0) or a `Gradient` (v1).
162
+ */
163
+ export interface Path {
164
+ type: "path";
165
+ commands: PathCommand[];
166
+ fill: Color | Gradient;
167
+ /** Accessibility structure tag (absent = drawn as an artifact); color-emoji layers stay untagged. */
168
+ tag?: StructTag;
169
+ }
92
170
  /** The closed set of primitives the PDF backend knows how to draw. */
93
- export type IRNode = TextRun | Rect | Line | Image | ClipPush | ClipPop;
171
+ export type IRNode = TextRun | Rect | Line | Image | ClipPush | ClipPop | Path;
@@ -1,7 +1,14 @@
1
1
  import { BoxConstraints } from "./box-constraints.js";
2
2
  export function isFragmentable(element) {
3
- return ("fragment" in element &&
4
- typeof element.fragment === "function");
3
+ if (!("fragment" in element) ||
4
+ typeof element.fragment !== "function") {
5
+ return false;
6
+ }
7
+ // An element may veto splitting at runtime via canFragment() - a transparent StructGroup only fragments
8
+ // when its wrapped child does, so a non-splittable wrapped child (e.g. a table row) is moved whole to the
9
+ // next page instead of being clipped at the boundary. Elements without canFragment() are always splittable.
10
+ const canFragment = element.canFragment;
11
+ return typeof canFragment !== "function" || canFragment.call(element);
5
12
  }
6
13
  function reportOverflow(child, childHeight, maxHeight, policy) {
7
14
  if (policy === "ignore")
@@ -0,0 +1,9 @@
1
+ import { Image } from "../ir/display-list.js";
2
+ export declare function emojiImageUrl(codePoint: number, source: {
3
+ url: string;
4
+ format: string;
5
+ }): string;
6
+ export declare function emojiImageNode(codePoint: number, source: {
7
+ url: string;
8
+ format: string;
9
+ }, x: number, baselineY: number, em: number): Promise<Image | null>;
@@ -0,0 +1,68 @@
1
+ import { CustomBytesImage } from "../elements/image-element.js";
2
+ import { decodePngToRgbFlate } from "../utils/image-helper.js";
3
+ import { bytesFromLatin1 } from "../utils/bytes.js";
4
+ // Per-URL fetch cache (process-wide): an emoji image is fetched once, however many times it appears.
5
+ const cache = new Map();
6
+ const FETCH_TIMEOUT_MS = 5000; // a slow CDN must not stall the whole render
7
+ async function fetchBytes(url) {
8
+ const cached = cache.get(url);
9
+ if (cached)
10
+ return cached;
11
+ const pending = (async () => {
12
+ const controller = new AbortController();
13
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
14
+ try {
15
+ const r = await fetch(url, { signal: controller.signal });
16
+ return r.ok ? new Uint8Array(await r.arrayBuffer()) : null;
17
+ }
18
+ catch {
19
+ return null; // offline / 404 / timeout -> null; the caller leaves a blank gap (graceful)
20
+ }
21
+ finally {
22
+ clearTimeout(timer);
23
+ }
24
+ })();
25
+ cache.set(url, pending);
26
+ // Cache only SUCCESSFUL results: drop a failure so a transient error (offline) can be retried later.
27
+ void pending.then((bytes) => {
28
+ if (!bytes && cache.get(url) === pending)
29
+ cache.delete(url);
30
+ });
31
+ return pending;
32
+ }
33
+ // Twemoji-style URL: base + lowercase hex code point + "." + format (e.g. ".../1f600.png"). Single
34
+ // code points only - multi-code-point sequences (flags, ZWJ) are out of scope for now.
35
+ export function emojiImageUrl(codePoint, source) {
36
+ return `${source.url}${codePoint.toString(16)}.${source.format}`;
37
+ }
38
+ // Builds an `Image` IR node for an emoji drawn as a 1em square seated on the baseline (mostly above
39
+ // it, a little below - like a text glyph), or null when the fetch/decode fails.
40
+ export async function emojiImageNode(codePoint, source, x, baselineY, em) {
41
+ const bytes = await fetchBytes(emojiImageUrl(codePoint, source));
42
+ if (!bytes)
43
+ return null;
44
+ const img = new CustomBytesImage(bytes);
45
+ const imageType = await img.getImageType();
46
+ const fileData = await img.getFileData();
47
+ const dims = await img.getImageDimensions();
48
+ // JPEG embeds raw; PNG is decoded to DeviceRGB + an /SMask alpha channel (transparency).
49
+ let data = fileData;
50
+ let smask;
51
+ if (imageType === "FlateDecode") {
52
+ const decoded = await decodePngToRgbFlate(bytesFromLatin1(fileData));
53
+ data = decoded.data;
54
+ smask = decoded.smask;
55
+ }
56
+ return {
57
+ type: "image",
58
+ x,
59
+ y: baselineY - em * 0.82,
60
+ width: em,
61
+ height: em,
62
+ intrinsicWidth: dims.width,
63
+ intrinsicHeight: dims.height,
64
+ data,
65
+ imageType,
66
+ ...(smask ? { smask } : {}),
67
+ };
68
+ }
@@ -2,5 +2,5 @@ import { ImageElement } from "../elements/image-element.js";
2
2
  import { PDFObjectManager } from "../utils/pdf-object-manager.js";
3
3
  import { IRNode } from "../ir/display-list.js";
4
4
  export declare class ImageRenderer {
5
- static render(imageElement: ImageElement, _objectManager: PDFObjectManager): Promise<IRNode[]>;
5
+ static render(imageElement: ImageElement, objectManager: PDFObjectManager): Promise<IRNode[]>;
6
6
  }