@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.
- package/LICENSE +21 -0
- package/README.md +28 -15
- package/dist/api/content.d.ts +3 -0
- package/dist/api/content.js +1 -0
- package/dist/api/structure.d.ts +24 -0
- package/dist/api/structure.js +33 -0
- package/dist/api/table.js +13 -5
- package/dist/api/text.d.ts +4 -1
- package/dist/api/text.js +1 -0
- package/dist/assets/font-data.js +17 -2
- package/dist/assets/font-data.ts +32 -2
- package/dist/crypto/security-handler.d.ts +46 -0
- package/dist/crypto/security-handler.js +129 -0
- package/dist/crypto/webcrypto.d.ts +11 -0
- package/dist/crypto/webcrypto.js +62 -0
- package/dist/elements/image-element.d.ts +5 -1
- package/dist/elements/image-element.js +3 -1
- package/dist/elements/layout/struct-group.d.ts +28 -0
- package/dist/elements/layout/struct-group.js +42 -0
- package/dist/elements/pdf-element.d.ts +4 -0
- package/dist/elements/pdf-element.js +15 -0
- package/dist/elements/text-element.d.ts +7 -1
- package/dist/elements/text-element.js +5 -2
- package/dist/ir/display-list.d.ts +79 -1
- package/dist/layout/fragmentation.js +9 -2
- package/dist/renderer/emoji-image.d.ts +9 -0
- package/dist/renderer/emoji-image.js +68 -0
- package/dist/renderer/image-renderer.d.ts +1 -1
- package/dist/renderer/image-renderer.js +10 -2
- package/dist/renderer/page-renderer.js +19 -4
- package/dist/renderer/pdf-backend.d.ts +6 -2
- package/dist/renderer/pdf-backend.js +59 -3
- package/dist/renderer/pdf-renderer.js +13 -0
- package/dist/renderer/struct-group-renderer.d.ts +10 -0
- package/dist/renderer/struct-group-renderer.js +24 -0
- package/dist/renderer/text-renderer.d.ts +3 -0
- package/dist/renderer/text-renderer.js +182 -2
- package/dist/text/emoji-codepoints.d.ts +1 -0
- package/dist/text/emoji-codepoints.js +29 -0
- package/dist/text/line-breaker.js +6 -3
- package/dist/utils/md5.js +4 -5
- package/dist/utils/pdf-object-manager.d.ts +28 -1
- package/dist/utils/pdf-object-manager.js +0 -0
- package/dist/utils/struct-tree.d.ts +44 -0
- package/dist/utils/struct-tree.js +130 -0
- package/dist/utils/ttf-parser.d.ts +73 -0
- package/dist/utils/ttf-parser.js +456 -16
- package/dist/utils/ttf-subsetter.js +1 -1
- package/dist/utils/ua-xmp.d.ts +9 -0
- package/dist/utils/ua-xmp.js +32 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4
|
-
typeof element.fragment
|
|
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>;
|