@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
@@ -0,0 +1,46 @@
1
+ /** A handler encrypts/decrypts the strings + streams of a document and describes itself as an `/Encrypt` dict. */
2
+ export interface SecurityHandler {
3
+ /** The `/Encrypt` dictionary body (between `<<` and `>>`). Its own strings are never encrypted. */
4
+ encryptDict(): string;
5
+ /** Encrypt one string/stream. `ref` is unused for V5/R6 (one file key) but kept for future per-object schemes. */
6
+ encrypt(data: Uint8Array, ref?: {
7
+ num: number;
8
+ gen: number;
9
+ }): Promise<Uint8Array>;
10
+ /** Decrypt one string/stream (the groundwork the future "open/edit existing PDF" path reuses). */
11
+ decrypt(data: Uint8Array, ref?: {
12
+ num: number;
13
+ gen: number;
14
+ }): Promise<Uint8Array>;
15
+ }
16
+ /** What the user grants; everything defaults to allowed. Maps to the `/P` bitfield (ISO 32000-2 Table 22). */
17
+ export interface Permissions {
18
+ printing?: boolean;
19
+ copying?: boolean;
20
+ modifying?: boolean;
21
+ annotating?: boolean;
22
+ }
23
+ export interface EncryptOptions {
24
+ /** Only "aes-256" today - the seam for future algorithms. */
25
+ algorithm?: "aes-256";
26
+ userPassword: string;
27
+ /** Full-rights password; defaults to the user password if omitted. */
28
+ ownerPassword?: string;
29
+ permissions?: Permissions;
30
+ }
31
+ declare class StandardAes256 implements SecurityHandler {
32
+ private readonly fileKey;
33
+ private readonly dict;
34
+ private constructor();
35
+ static create(opts: EncryptOptions): Promise<StandardAes256>;
36
+ encryptDict(): string;
37
+ encrypt(data: Uint8Array): Promise<Uint8Array>;
38
+ decrypt(data: Uint8Array): Promise<Uint8Array>;
39
+ /** Recover the file key from a password as a reader does: validate it against /U FIRST (so a wrong
40
+ * password is rejected, not silently turned into a garbage key), then decrypt /UE. The groundwork the
41
+ * future "open/edit existing PDF" path reuses. Throws on a wrong password. */
42
+ static recoverFileKey(userPassword: string, u: Uint8Array, ue: Uint8Array): Promise<Uint8Array>;
43
+ }
44
+ /** The factory = the seam. Today it always returns the AES-256 handler. */
45
+ export declare function createSecurityHandler(opts: EncryptOptions): Promise<SecurityHandler>;
46
+ export { StandardAes256 };
@@ -0,0 +1,129 @@
1
+ // PDF Standard security handler. The PDFObjectManager talks to the `SecurityHandler` interface, never to a
2
+ // concrete algorithm - so adding another scheme later (a new revision, or per-object RC4/AES-128) is just a
3
+ // second implementation + a factory branch, with the writer untouched. Today we ship exactly one:
4
+ // `StandardAes256` = AES-256, V5/R6 (ISO 32000-2 / PDF 2.0), the newest standard PDF encryption.
5
+ import { aesCbcDecrypt, aesCbcDecryptNoPad, aesCbcEncrypt, aesCbcEncryptNoPad, randomBytes, sha, } from "./webcrypto.js";
6
+ const utf8 = (s) => new TextEncoder().encode(s).subarray(0, 127); // R6 truncates passwords to 127 bytes
7
+ const concat = (...parts) => {
8
+ const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
9
+ let o = 0;
10
+ for (const p of parts) {
11
+ out.set(p, o);
12
+ o += p.length;
13
+ }
14
+ return out;
15
+ };
16
+ const toHex = (a) => [...a].map((b) => b.toString(16).padStart(2, "0")).join("");
17
+ // Constant-time-ish equality for password validation (no early-out on the first differing byte).
18
+ const bytesEqual = (a, b) => {
19
+ if (a.length !== b.length)
20
+ return false;
21
+ let diff = 0;
22
+ for (let i = 0; i < a.length; i++)
23
+ diff |= a[i] ^ b[i];
24
+ return diff === 0;
25
+ };
26
+ // ISO 32000-2 Algorithm 2.B: the hardened password hash. Loops AES-128-CBC + SHA-256/384/512 until the
27
+ // stopping rule. `udata` is the 48-byte /U for the owner computations, empty for the user ones.
28
+ async function hash2B(password, salt, udata) {
29
+ let k = await sha(256, concat(password, salt, udata));
30
+ let e = new Uint8Array(0);
31
+ // At least 64 rounds; then stop once the last byte of E <= round - 32 (ISO 32000-2 Algorithm 2.B).
32
+ for (let round = 0; round < 64 || e[e.length - 1] > round - 32; round++) {
33
+ const block = concat(password, k, udata);
34
+ const k1 = new Uint8Array(block.length * 64);
35
+ for (let i = 0; i < 64; i++)
36
+ k1.set(block, i * block.length);
37
+ e = await aesCbcEncryptNoPad(k.subarray(0, 16), k.subarray(16, 32), k1);
38
+ let sum = 0;
39
+ for (let i = 0; i < 16; i++)
40
+ sum += e[i]; // big-endian 128-bit mod 3 == byte-sum mod 3 (256 ≡ 1 mod 3)
41
+ k = await sha([256, 384, 512][sum % 3], e);
42
+ }
43
+ return k.subarray(0, 32);
44
+ }
45
+ function permissionBits(p = {}) {
46
+ const { printing = true, copying = true, modifying = true, annotating = true } = p;
47
+ let bits = 0xfffff000 | 0b11000000; // reserved-1 bits (positions 7-8 and 13-32)
48
+ if (printing)
49
+ bits |= 4 | 2048;
50
+ if (modifying)
51
+ bits |= 8 | 1024;
52
+ if (copying)
53
+ bits |= 16 | 512;
54
+ if (annotating)
55
+ bits |= 32 | 256;
56
+ return bits | 0; // int32
57
+ }
58
+ class StandardAes256 {
59
+ constructor(fileKey, dict) {
60
+ this.fileKey = fileKey;
61
+ this.dict = dict;
62
+ }
63
+ static async create(opts) {
64
+ const user = utf8(opts.userPassword);
65
+ const owner = utf8(opts.ownerPassword ?? opts.userPassword);
66
+ const fileKey = randomBytes(32);
67
+ const p = permissionBits(opts.permissions);
68
+ // Algorithm 8: /U and /UE.
69
+ const uVS = randomBytes(8);
70
+ const uKS = randomBytes(8);
71
+ const empty = new Uint8Array(0);
72
+ const u = concat(await hash2B(user, uVS, empty), uVS, uKS); // 48 bytes
73
+ const ue = await aesCbcEncryptNoPad(await hash2B(user, uKS, empty), new Uint8Array(16), fileKey);
74
+ // Algorithm 9: /O and /OE (these also fold in /U).
75
+ const oVS = randomBytes(8);
76
+ const oKS = randomBytes(8);
77
+ const o = concat(await hash2B(owner, oVS, u), oVS, oKS); // 48 bytes
78
+ const oe = await aesCbcEncryptNoPad(await hash2B(owner, oKS, u), new Uint8Array(16), fileKey);
79
+ // Algorithm 10: /Perms (a 16-byte block, ECB == single-block CBC with a zero IV).
80
+ const perms = new Uint8Array(16);
81
+ perms[0] = p & 0xff;
82
+ perms[1] = (p >> 8) & 0xff;
83
+ perms[2] = (p >> 16) & 0xff;
84
+ perms[3] = (p >> 24) & 0xff;
85
+ perms[4] = perms[5] = perms[6] = perms[7] = 0xff;
86
+ perms[8] = 0x46; // 'F' - metadata (XMP) stays unencrypted (standard; keeps it indexer-readable)
87
+ perms[9] = 0x61; // 'a'
88
+ perms[10] = 0x64; // 'd'
89
+ perms[11] = 0x62; // 'b'
90
+ perms.set(randomBytes(4), 12);
91
+ const permsEnc = await aesCbcEncryptNoPad(fileKey, new Uint8Array(16), perms);
92
+ const dict = `/Filter /Standard /V 5 /R 6 /Length 256 /P ${p} /EncryptMetadata false ` +
93
+ `/CF << /StdCF << /CFM /AESV3 /AuthEvent /DocOpen /Length 32 >> >> /StmF /StdCF /StrF /StdCF ` +
94
+ `/U <${toHex(u)}> /UE <${toHex(ue)}> /O <${toHex(o)}> /OE <${toHex(oe)}> /Perms <${toHex(permsEnc)}>`;
95
+ return new StandardAes256(fileKey, dict);
96
+ }
97
+ encryptDict() {
98
+ return this.dict;
99
+ }
100
+ async encrypt(data) {
101
+ const iv = randomBytes(16);
102
+ const ct = await aesCbcEncrypt(this.fileKey, iv, data);
103
+ return concat(iv, ct);
104
+ }
105
+ async decrypt(data) {
106
+ return aesCbcDecrypt(this.fileKey, data.subarray(0, 16), data.subarray(16));
107
+ }
108
+ /** Recover the file key from a password as a reader does: validate it against /U FIRST (so a wrong
109
+ * password is rejected, not silently turned into a garbage key), then decrypt /UE. The groundwork the
110
+ * future "open/edit existing PDF" path reuses. Throws on a wrong password. */
111
+ static async recoverFileKey(userPassword, u, ue) {
112
+ const pw = utf8(userPassword);
113
+ // Algorithm 11: hash(password + validation salt) must equal the first 32 bytes of /U.
114
+ const check = await hash2B(pw, u.subarray(32, 40), new Uint8Array(0));
115
+ if (!bytesEqual(check, u.subarray(0, 32))) {
116
+ throw new Error("@jasy/pdf: wrong password.");
117
+ }
118
+ const intermediate = await hash2B(pw, u.subarray(40, 48), new Uint8Array(0));
119
+ return aesCbcDecryptNoPad(intermediate, new Uint8Array(16), ue);
120
+ }
121
+ }
122
+ /** The factory = the seam. Today it always returns the AES-256 handler. */
123
+ export async function createSecurityHandler(opts) {
124
+ if (opts.algorithm && opts.algorithm !== "aes-256") {
125
+ throw new Error(`@jasy/pdf: unsupported encryption algorithm "${opts.algorithm}" (only "aes-256").`);
126
+ }
127
+ return StandardAes256.create(opts);
128
+ }
129
+ export { StandardAes256 };
@@ -0,0 +1,11 @@
1
+ export declare function randomBytes(n: number): Uint8Array;
2
+ export declare function sha(bits: 256 | 384 | 512, data: Uint8Array): Promise<Uint8Array>;
3
+ /** AES-CBC with PKCS#7 padding (the AESV3 mode for PDF strings/streams). */
4
+ export declare function aesCbcEncrypt(key: Uint8Array, iv: Uint8Array, data: Uint8Array): Promise<Uint8Array>;
5
+ export declare function aesCbcDecrypt(key: Uint8Array, iv: Uint8Array, data: Uint8Array): Promise<Uint8Array>;
6
+ /** AES-CBC WITHOUT padding (`data.length` must be a multiple of 16). WebCrypto pads unconditionally, so we
7
+ * encrypt and drop the extra full pad block - the leading blocks are the true unpadded CBC. */
8
+ export declare function aesCbcEncryptNoPad(key: Uint8Array, iv: Uint8Array, data: Uint8Array): Promise<Uint8Array>;
9
+ /** AES-CBC no-padding DECRYPT (`data` a multiple of 16). We append one cipher block crafted to decrypt to a
10
+ * full 0x10 PKCS#7 pad (so WebCrypto accepts and strips it), leaving the original. */
11
+ export declare function aesCbcDecryptNoPad(key: Uint8Array, iv: Uint8Array, data: Uint8Array): Promise<Uint8Array>;
@@ -0,0 +1,62 @@
1
+ // Isomorphic crypto via the platform WebCrypto (`globalThis.crypto.subtle`) - native in the browser AND in
2
+ // Node 20+, with zero dependencies. This is the primitive layer the PDF Standard security handler (AES-256,
3
+ // R6) builds on. Everything is async (so is `render()`), so that is no constraint.
4
+ //
5
+ // One wrinkle: WebCrypto's AES-CBC ALWAYS applies PKCS#7 padding and offers no raw/no-pad mode. The PDF R6
6
+ // key derivation (Algorithm 2.B, /UE, /OE, /Perms) needs UNPADDED AES, so we synthesize it from the padded
7
+ // primitive (encrypt: drop the trailing pad block; decrypt: append a block that decrypts to a full pad, then
8
+ // let WebCrypto strip it). Document strings/streams use the padded primitive directly - that IS AESV3.
9
+ const subtle = () => {
10
+ const c = globalThis.crypto;
11
+ if (!c?.subtle) {
12
+ throw new Error("@jasy/pdf: PDF encryption needs WebCrypto (globalThis.crypto.subtle), unavailable here.");
13
+ }
14
+ return c.subtle;
15
+ };
16
+ export function randomBytes(n) {
17
+ const b = new Uint8Array(n);
18
+ globalThis.crypto.getRandomValues(b);
19
+ return b;
20
+ }
21
+ // WebCrypto's types want a BufferSource backed by ArrayBuffer; our Uint8Arrays are ArrayBuffer-backed, so
22
+ // this cast at the platform boundary is safe (TS 5.7+ just can't prove it isn't a SharedArrayBuffer).
23
+ const buf = (a) => a;
24
+ export async function sha(bits, data) {
25
+ return new Uint8Array(await subtle().digest(`SHA-${bits}`, buf(data)));
26
+ }
27
+ async function aesKey(key, usage) {
28
+ return subtle().importKey("raw", buf(key), { name: "AES-CBC" }, false, [usage]);
29
+ }
30
+ /** AES-CBC with PKCS#7 padding (the AESV3 mode for PDF strings/streams). */
31
+ export async function aesCbcEncrypt(key, iv, data) {
32
+ return new Uint8Array(await subtle().encrypt({ name: "AES-CBC", iv: buf(iv) }, await aesKey(key, "encrypt"), buf(data)));
33
+ }
34
+ export async function aesCbcDecrypt(key, iv, data) {
35
+ return new Uint8Array(await subtle().decrypt({ name: "AES-CBC", iv: buf(iv) }, await aesKey(key, "decrypt"), buf(data)));
36
+ }
37
+ /** AES-CBC WITHOUT padding (`data.length` must be a multiple of 16). WebCrypto pads unconditionally, so we
38
+ * encrypt and drop the extra full pad block - the leading blocks are the true unpadded CBC. */
39
+ export async function aesCbcEncryptNoPad(key, iv, data) {
40
+ if (data.length % 16 !== 0)
41
+ throw new Error("aesCbcEncryptNoPad: data must be a multiple of 16 bytes.");
42
+ return (await aesCbcEncrypt(key, iv, data)).subarray(0, data.length);
43
+ }
44
+ const xor16 = (a, b) => {
45
+ const o = new Uint8Array(16);
46
+ for (let i = 0; i < 16; i++)
47
+ o[i] = a[i] ^ b[i];
48
+ return o;
49
+ };
50
+ /** AES-CBC no-padding DECRYPT (`data` a multiple of 16). We append one cipher block crafted to decrypt to a
51
+ * full 0x10 PKCS#7 pad (so WebCrypto accepts and strips it), leaving the original. */
52
+ export async function aesCbcDecryptNoPad(key, iv, data) {
53
+ if (data.length === 0 || data.length % 16 !== 0)
54
+ throw new Error("aesCbcDecryptNoPad: data must be a non-zero multiple of 16 bytes.");
55
+ const prev = data.length >= 16 ? data.subarray(data.length - 16) : iv;
56
+ // ECB(x) == first block of CBC(iv=0, x); we want the appended block to decrypt to 0x10*16 after the CBC xor.
57
+ const appended = await aesCbcEncryptNoPad(key, new Uint8Array(16), xor16(new Uint8Array(16).fill(16), prev));
58
+ const withPad = new Uint8Array(data.length + 16);
59
+ withPad.set(data);
60
+ withPad.set(appended, data.length);
61
+ return aesCbcDecrypt(key, iv, withPad);
62
+ }
@@ -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>;