@stll/folio-core 0.3.1 → 0.4.0

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 (47) hide show
  1. package/dist/ai-edits/headless.d.ts +2 -1
  2. package/dist/ai-edits/headless.js +9 -7
  3. package/dist/controller/layoutPipeline.js +22 -0
  4. package/dist/docx/encryption/agileDecryption.d.ts +6 -0
  5. package/dist/docx/encryption/agileDecryption.js +208 -0
  6. package/dist/docx/encryption/compoundFile.d.ts +20 -0
  7. package/dist/docx/encryption/compoundFile.js +249 -0
  8. package/dist/docx/encryption/containerFormat.d.ts +15 -0
  9. package/dist/docx/encryption/containerFormat.js +40 -0
  10. package/dist/docx/encryption/cryptoBytes.d.ts +11 -0
  11. package/dist/docx/encryption/cryptoBytes.js +55 -0
  12. package/dist/docx/encryption/encryptionInfo.d.ts +32 -0
  13. package/dist/docx/encryption/encryptionInfo.js +128 -0
  14. package/dist/docx/encryption/errors.d.ts +18 -0
  15. package/dist/docx/encryption/errors.js +13 -0
  16. package/dist/docx/encryption/index.d.ts +4 -0
  17. package/dist/docx/encryption/index.js +4 -0
  18. package/dist/docx/encryption/openEncryptedDocx.d.ts +28 -0
  19. package/dist/docx/encryption/openEncryptedDocx.js +65 -0
  20. package/dist/docx/index.d.ts +3 -1
  21. package/dist/docx/index.js +3 -1
  22. package/dist/docx/parser.d.ts +4 -5
  23. package/dist/docx/parser.js +8 -2
  24. package/dist/docx/unzip.d.ts +7 -5
  25. package/dist/docx/unzip.js +8 -2
  26. package/dist/layout-bridge/convert/headerFooterLayout.d.ts +1 -1
  27. package/dist/layout-bridge/convert/headerFooterLayout.js +16 -6
  28. package/dist/layout-bridge/convert/toFlowBlocks.js +2 -0
  29. package/dist/layout-engine/index.js +27 -1
  30. package/dist/layout-engine/measure/measureBlocks.js +15 -1
  31. package/dist/layout-engine/measure/measureParagraph.js +37 -6
  32. package/dist/layout-engine/types.d.ts +3 -0
  33. package/dist/layout-painter/renderPage.js +2 -1
  34. package/dist/layout-painter/renderParagraph.js +30 -5
  35. package/dist/layout-painter/renderTable.js +17 -0
  36. package/dist/managers/DocumentLoaderManager.d.ts +3 -1
  37. package/dist/managers/DocumentLoaderManager.js +3 -2
  38. package/dist/paged-layout/headerFooterMargins.d.ts +3 -1
  39. package/dist/paged-layout/headerFooterMargins.js +5 -3
  40. package/dist/prosemirror/attrs/index.js +1 -0
  41. package/dist/prosemirror/conversion/fromProseDoc.js +7 -2
  42. package/dist/prosemirror/conversion/toProseDoc.js +14 -7
  43. package/dist/prosemirror/extensions/core/ParagraphExtension.js +1 -0
  44. package/dist/prosemirror/extensions/nodes/TableExtension.js +2 -0
  45. package/dist/prosemirror/schema/nodes.d.ts +4 -2
  46. package/dist/utils/fontResolver.js +17 -0
  47. package/package.json +5 -1
@@ -5,7 +5,8 @@ import { FolioReviewChange, FolioReviewChangeKind } from "./read.js";
5
5
  //#region src/ai-edits/headless.d.ts
6
6
  /** Options for {@link FolioDocxReviewer.fromBuffer}. */
7
7
  type FolioDocxReviewerOptions = {
8
- /** Default author for tracked changes and comments. (default: `"AI"`) */author?: string;
8
+ /** Default author for tracked changes and comments. (default: `"AI"`) */author?: string; /** Password for Agile-encrypted .docx files (Office 2010+). */
9
+ password?: string | undefined;
9
10
  };
10
11
  /** Options for {@link FolioDocxReviewer.applyOperations}. */
11
12
  type FolioApplyOperationsOptions = {
@@ -181,17 +181,19 @@ var FolioDocxReviewer = class FolioDocxReviewer {
181
181
  static async fromBuffer(buffer, options = {}) {
182
182
  const baseDocument = await parseDocx(buffer, {
183
183
  detectVariables: false,
184
- preloadFonts: false
184
+ preloadFonts: false,
185
+ password: options.password
185
186
  });
186
187
  const plugins = singletonManager.getPlugins();
188
+ const state = ensureDeterministicParaIdsInState(EditorState.create({
189
+ schema,
190
+ doc: toProseDoc(baseDocument),
191
+ plugins
192
+ }));
187
193
  return new FolioDocxReviewer({
188
194
  baseDocument,
189
- originalBuffer: buffer,
190
- state: ensureDeterministicParaIdsInState(EditorState.create({
191
- schema,
192
- doc: toProseDoc(baseDocument),
193
- plugins
194
- })),
195
+ originalBuffer: baseDocument.originalBuffer ?? buffer,
196
+ state,
195
197
  author: options.author ?? "AI"
196
198
  });
197
199
  }
@@ -96,6 +96,15 @@ function runLayoutPipeline(deps, state, options = {}) {
96
96
  firstPageHeaderContent: firstPageHeaderForRender,
97
97
  firstPageFooterContent: firstPageFooterForRender
98
98
  };
99
+ const buildSectionHfExtenderContent = () => {
100
+ if (!sectionHeaderFooterRefs) return;
101
+ return sectionHeaderFooterRefs.map((refs) => ({
102
+ headerContent: pickSectionHeaderFooterContent(headerContentByRId, refs.headerDefault, refs.evenAndOddHeaders === true ? refs.headerEven : void 0),
103
+ footerContent: pickSectionHeaderFooterContent(footerContentByRId, refs.footerDefault, refs.evenAndOddHeaders === true ? refs.footerEven : void 0),
104
+ firstPageHeaderContent: refs.titlePg === true ? headerContentByRId?.get(refs.headerFirst ?? "") : void 0,
105
+ firstPageFooterContent: refs.titlePg === true ? footerContentByRId?.get(refs.footerFirst ?? "") : void 0
106
+ }));
107
+ };
99
108
  const hfWarn = (msg) => {
100
109
  console.warn(`[PagedEditor] ${msg}`);
101
110
  };
@@ -128,6 +137,7 @@ function runLayoutPipeline(deps, state, options = {}) {
128
137
  restoreSectionBreakMargins();
129
138
  extendSectionBreakMargins(sectionBreaks, {
130
139
  content,
140
+ sectionContent: buildSectionHfExtenderContent(),
131
141
  bodyPageSize: pageSize,
132
142
  bodyMargins,
133
143
  warn: hfWarn
@@ -402,5 +412,17 @@ function runLayoutPipeline(deps, state, options = {}) {
402
412
  syncCoordinator.onLayoutComplete(currentEpoch);
403
413
  return outcome;
404
414
  }
415
+ function pickSectionHeaderFooterContent(byRId, defaultRId, evenRId) {
416
+ const defaultContent = defaultRId ? byRId?.get(defaultRId) : void 0;
417
+ const evenContent = evenRId ? byRId?.get(evenRId) : void 0;
418
+ if (!defaultContent) return evenContent;
419
+ if (!evenContent) return defaultContent;
420
+ return headerFooterMarginPushHeight(evenContent) > headerFooterMarginPushHeight(defaultContent) ? evenContent : defaultContent;
421
+ }
422
+ function headerFooterMarginPushHeight(content) {
423
+ const bottom = content.marginPushBottom ?? content.visualBottom ?? content.height;
424
+ const top = content.marginPushTop ?? content.visualTop ?? 0;
425
+ return Math.max(bottom - top, content.height);
426
+ }
405
427
  //#endregion
406
428
  export { runLayoutPipeline };
@@ -0,0 +1,6 @@
1
+ import { AgileKeyMaterial } from "./encryptionInfo.js";
2
+
3
+ //#region src/docx/encryption/agileDecryption.d.ts
4
+ declare const decryptAgileEncryptedPackage: (password: string, material: AgileKeyMaterial, encryptedPackage: Uint8Array) => Promise<Uint8Array>;
5
+ //#endregion
6
+ export { decryptAgileEncryptedPackage };
@@ -0,0 +1,208 @@
1
+ import { bytesEqual, joinBytes, padToBlock, passwordToUtf16Le, toArrayBuffer, writeUint32Le } from "./cryptoBytes.js";
2
+ import { DOCX_ENCRYPTION_ERROR_CODES, DocxEncryptionError } from "./errors.js";
3
+ //#region src/docx/encryption/agileDecryption.ts
4
+ /**
5
+ * Agile Encryption decryption (Office 2010+) using Web Crypto.
6
+ *
7
+ * Algorithm steps follow [MS-OFFCRYPTO] sections 2.3.4.9 through 2.3.4.15.
8
+ *
9
+ * @see https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-offcrypto/
10
+ */
11
+ /** [MS-OFFCRYPTO] 2.3.4.15 — block keys used during password verification. */
12
+ const PASSWORD_INPUT_BLOCK_KEY = new Uint8Array([
13
+ 254,
14
+ 167,
15
+ 210,
16
+ 118,
17
+ 59,
18
+ 75,
19
+ 158,
20
+ 121
21
+ ]);
22
+ const PASSWORD_DIGEST_BLOCK_KEY = new Uint8Array([
23
+ 215,
24
+ 170,
25
+ 15,
26
+ 109,
27
+ 48,
28
+ 97,
29
+ 52,
30
+ 78
31
+ ]);
32
+ const INTERMEDIATE_KEY_BLOCK_KEY = new Uint8Array([
33
+ 20,
34
+ 110,
35
+ 11,
36
+ 231,
37
+ 171,
38
+ 172,
39
+ 208,
40
+ 214
41
+ ]);
42
+ const INTEGRITY_KEY_BLOCK_KEY = new Uint8Array([
43
+ 95,
44
+ 178,
45
+ 173,
46
+ 1,
47
+ 12,
48
+ 185,
49
+ 225,
50
+ 246
51
+ ]);
52
+ const INTEGRITY_DIGEST_BLOCK_KEY = new Uint8Array([
53
+ 160,
54
+ 103,
55
+ 127,
56
+ 2,
57
+ 178,
58
+ 44,
59
+ 132,
60
+ 51
61
+ ]);
62
+ const PACKAGE_PLAINTEXT_SIZE_BYTES = 8;
63
+ const PACKAGE_SEGMENT_BYTES = 4096;
64
+ const subtleCrypto = () => {
65
+ const crypto = globalThis.crypto?.subtle;
66
+ if (!crypto) throw new DocxEncryptionError({
67
+ code: DOCX_ENCRYPTION_ERROR_CODES.DECRYPTION_FAILED,
68
+ message: "Web Crypto API (SubtleCrypto) is not available in this environment"
69
+ });
70
+ return crypto;
71
+ };
72
+ const webHashName = (officeName) => {
73
+ const resolved = {
74
+ SHA1: "SHA-1",
75
+ "SHA-1": "SHA-1",
76
+ SHA256: "SHA-256",
77
+ "SHA-256": "SHA-256",
78
+ SHA384: "SHA-384",
79
+ "SHA-384": "SHA-384",
80
+ SHA512: "SHA-512",
81
+ "SHA-512": "SHA-512"
82
+ }[officeName];
83
+ if (!resolved) throw new DocxEncryptionError({
84
+ code: DOCX_ENCRYPTION_ERROR_CODES.DECRYPTION_FAILED,
85
+ message: `Unsupported hash algorithm: ${officeName}`
86
+ });
87
+ return resolved;
88
+ };
89
+ const digest = async (algorithm, input) => {
90
+ const output = await subtleCrypto().digest(algorithm, toArrayBuffer(input));
91
+ return new Uint8Array(output);
92
+ };
93
+ const importAesKey = (rawKey, usages) => subtleCrypto().importKey("raw", toArrayBuffer(rawKey), { name: "AES-CBC" }, false, usages);
94
+ const decryptAesCbc = async (rawKey, iv, ciphertext) => {
95
+ const blockSize = 16;
96
+ if (ciphertext.length === 0 || ciphertext.length % blockSize !== 0) throw new DocxEncryptionError({
97
+ code: DOCX_ENCRYPTION_ERROR_CODES.DECRYPTION_FAILED,
98
+ message: "AES ciphertext length must be a positive multiple of the block size"
99
+ });
100
+ const subtle = subtleCrypto();
101
+ const key = await importAesKey(rawKey, ["encrypt", "decrypt"]);
102
+ const lastBlock = ciphertext.subarray(ciphertext.length - blockSize);
103
+ const pkcs7PadPlaintext = new Uint8Array(blockSize);
104
+ pkcs7PadPlaintext.fill(blockSize);
105
+ const syntheticPadding = new Uint8Array(await subtle.encrypt({
106
+ name: "AES-CBC",
107
+ iv: toArrayBuffer(lastBlock)
108
+ }, key, pkcs7PadPlaintext)).subarray(0, blockSize);
109
+ const paddedCiphertext = new Uint8Array(ciphertext.length + blockSize);
110
+ paddedCiphertext.set(ciphertext);
111
+ paddedCiphertext.set(syntheticPadding, ciphertext.length);
112
+ const decrypted = await subtle.decrypt({
113
+ name: "AES-CBC",
114
+ iv: toArrayBuffer(iv)
115
+ }, key, toArrayBuffer(paddedCiphertext));
116
+ return new Uint8Array(decrypted).subarray(0, ciphertext.length);
117
+ };
118
+ /** [MS-OFFCRYPTO] 2.3.4.9 — iterated hash of password + salt. */
119
+ const hashPassword = async (password, salt, iterations, hashName) => {
120
+ const algorithm = webHashName(hashName);
121
+ let state = await digest(algorithm, joinBytes([salt, passwordToUtf16Le(password)]));
122
+ for (let i = 0; i < iterations; i++) state = await digest(algorithm, joinBytes([writeUint32Le(i), state]));
123
+ return state;
124
+ };
125
+ /** [MS-OFFCRYPTO] 2.3.4.9 — finalize a derived key with a block key suffix. */
126
+ const finalizeDerivedKey = async (passwordHash, blockKey, hashName, keyBits) => {
127
+ return (await digest(webHashName(hashName), joinBytes([passwordHash, blockKey]))).subarray(0, keyBits / 8);
128
+ };
129
+ const segmentIv = async (packageSalt, segmentIndex, hashName, blockBytes) => {
130
+ return (await digest(webHashName(hashName), joinBytes([packageSalt, writeUint32Le(segmentIndex)]))).subarray(0, blockBytes);
131
+ };
132
+ const integrityIv = async (packageSalt, blockKey, hashName, blockBytes) => {
133
+ return (await digest(webHashName(hashName), joinBytes([packageSalt, blockKey]))).subarray(0, blockBytes);
134
+ };
135
+ const unlockIntermediateKey = async (password, material) => {
136
+ const passwordHash = await hashPassword(password, material.passwordSalt, material.passwordIterations, material.passwordHash);
137
+ const verifierKey = await finalizeDerivedKey(passwordHash, PASSWORD_INPUT_BLOCK_KEY, material.passwordHash, material.passwordKeyBits);
138
+ const digestKey = await finalizeDerivedKey(passwordHash, PASSWORD_DIGEST_BLOCK_KEY, material.passwordHash, material.passwordKeyBits);
139
+ const intermediateKey = await finalizeDerivedKey(passwordHash, INTERMEDIATE_KEY_BLOCK_KEY, material.passwordHash, material.passwordKeyBits);
140
+ const verifierIv = padToBlock(material.passwordSalt, material.passwordBlockBytes);
141
+ const verifierPlaintext = await decryptAesCbc(verifierKey, verifierIv, material.encryptedVerifierInput);
142
+ const digestPlaintext = await decryptAesCbc(digestKey, verifierIv, material.encryptedVerifierDigest);
143
+ const actualDigest = await digest(webHashName(material.passwordHash), verifierPlaintext);
144
+ if (!bytesEqual(actualDigest, digestPlaintext.subarray(0, actualDigest.length))) throw new DocxEncryptionError({
145
+ code: DOCX_ENCRYPTION_ERROR_CODES.PASSWORD_INVALID,
146
+ message: "The password is incorrect"
147
+ });
148
+ return decryptAesCbc(intermediateKey, verifierIv, material.encryptedIntermediateKey);
149
+ };
150
+ const assertPackageIntegrity = async (intermediateKey, material, encryptedPackage) => {
151
+ const ivForKey = await integrityIv(material.packageSalt, INTEGRITY_KEY_BLOCK_KEY, material.packageHash, material.cipherBlockBytes);
152
+ const ivForDigest = await integrityIv(material.packageSalt, INTEGRITY_DIGEST_BLOCK_KEY, material.packageHash, material.cipherBlockBytes);
153
+ const integrityKey = await decryptAesCbc(intermediateKey, ivForKey, material.encryptedIntegrityKey);
154
+ const expectedDigest = await decryptAesCbc(intermediateKey, ivForDigest, material.encryptedIntegrityDigest);
155
+ const hmacKey = await subtleCrypto().importKey("raw", toArrayBuffer(integrityKey), {
156
+ name: "HMAC",
157
+ hash: webHashName(material.packageHash)
158
+ }, false, ["sign"]);
159
+ const actualDigest = new Uint8Array(await subtleCrypto().sign("HMAC", hmacKey, toArrayBuffer(encryptedPackage)));
160
+ if (!bytesEqual(actualDigest, expectedDigest.subarray(0, actualDigest.length))) throw new DocxEncryptionError({
161
+ code: DOCX_ENCRYPTION_ERROR_CODES.DECRYPTION_FAILED,
162
+ message: "Data integrity check failed — the encrypted package may be corrupt or tampered with"
163
+ });
164
+ };
165
+ const decryptEncryptedPackage = async (intermediateKey, material, encryptedPackage) => {
166
+ if (encryptedPackage.length < PACKAGE_PLAINTEXT_SIZE_BYTES) throw new DocxEncryptionError({
167
+ code: DOCX_ENCRYPTION_ERROR_CODES.DECRYPTION_FAILED,
168
+ message: "EncryptedPackage stream is too short to contain a size prefix"
169
+ });
170
+ const plaintextSizeBig = new DataView(encryptedPackage.buffer, encryptedPackage.byteOffset, PACKAGE_PLAINTEXT_SIZE_BYTES).getBigUint64(0, true);
171
+ if (plaintextSizeBig > BigInt(Number.MAX_SAFE_INTEGER)) throw new DocxEncryptionError({
172
+ code: DOCX_ENCRYPTION_ERROR_CODES.DECRYPTION_FAILED,
173
+ message: "EncryptedPackage plaintext size exceeds JavaScript's safe integer range"
174
+ });
175
+ const plaintextSize = Number(plaintextSizeBig);
176
+ const ciphertext = encryptedPackage.subarray(PACKAGE_PLAINTEXT_SIZE_BYTES);
177
+ const segmentCount = Math.ceil(ciphertext.length / PACKAGE_SEGMENT_BYTES);
178
+ const segments = [];
179
+ for (let index = 0; index < segmentCount; index++) {
180
+ const start = index * PACKAGE_SEGMENT_BYTES;
181
+ const end = Math.min(start + PACKAGE_SEGMENT_BYTES, ciphertext.length);
182
+ const chunk = ciphertext.subarray(start, end);
183
+ const iv = await segmentIv(material.packageSalt, index, material.packageHash, material.cipherBlockBytes);
184
+ segments.push(await decryptAesCbc(intermediateKey, iv, chunk));
185
+ }
186
+ const plaintext = joinBytes(segments);
187
+ if (plaintextSize > plaintext.length) throw new DocxEncryptionError({
188
+ code: DOCX_ENCRYPTION_ERROR_CODES.DECRYPTION_FAILED,
189
+ message: "EncryptedPackage plaintext size exceeds decrypted payload length"
190
+ });
191
+ return plaintext.subarray(0, plaintextSize);
192
+ };
193
+ const decryptAgileEncryptedPackage = async (password, material, encryptedPackage) => {
194
+ try {
195
+ const intermediateKey = await unlockIntermediateKey(password, material);
196
+ await assertPackageIntegrity(intermediateKey, material, encryptedPackage);
197
+ return await decryptEncryptedPackage(intermediateKey, material, encryptedPackage);
198
+ } catch (error) {
199
+ if (error instanceof DocxEncryptionError) throw error;
200
+ throw new DocxEncryptionError({
201
+ code: DOCX_ENCRYPTION_ERROR_CODES.DECRYPTION_FAILED,
202
+ message: "Decryption failed unexpectedly",
203
+ cause: error
204
+ });
205
+ }
206
+ };
207
+ //#endregion
208
+ export { decryptAgileEncryptedPackage };
@@ -0,0 +1,20 @@
1
+ //#region src/docx/encryption/compoundFile.d.ts
2
+ /**
3
+ * Read-only MS-CFB compound file access for encrypted OOXML packages.
4
+ *
5
+ * Encrypted `.docx` files store `EncryptionInfo` and `EncryptedPackage` streams
6
+ * at the compound-file root. This reader implements only what MS-OFFCRYPTO needs.
7
+ *
8
+ * @see https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-cfb/
9
+ */
10
+ type CompoundFileReader = {
11
+ readStream: (absolutePath: string) => Uint8Array | null;
12
+ };
13
+ declare const openCompoundFile: (data: ArrayBuffer | Uint8Array) => CompoundFileReader;
14
+ type EncryptedPackageStreams = {
15
+ encryptionInfo: Uint8Array;
16
+ encryptedPackage: Uint8Array;
17
+ };
18
+ declare const readEncryptedPackageStreams: (data: ArrayBuffer | Uint8Array) => EncryptedPackageStreams;
19
+ //#endregion
20
+ export { CompoundFileReader, EncryptedPackageStreams, openCompoundFile, readEncryptedPackageStreams };
@@ -0,0 +1,249 @@
1
+ import { joinBytes } from "./cryptoBytes.js";
2
+ //#region src/docx/encryption/compoundFile.ts
3
+ /**
4
+ * Read-only MS-CFB compound file access for encrypted OOXML packages.
5
+ *
6
+ * Encrypted `.docx` files store `EncryptionInfo` and `EncryptedPackage` streams
7
+ * at the compound-file root. This reader implements only what MS-OFFCRYPTO needs.
8
+ *
9
+ * @see https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-cfb/
10
+ */
11
+ const HEADER_BYTES = 512;
12
+ const DIRECTORY_RECORD_BYTES = 128;
13
+ const HEADER_DIFAT_SLOTS = 109;
14
+ const SIGNATURE = [
15
+ 208,
16
+ 207,
17
+ 17,
18
+ 224,
19
+ 161,
20
+ 177,
21
+ 26,
22
+ 225
23
+ ];
24
+ const LITTLE_ENDIAN_MARK = 65534;
25
+ const SECTOR_FREE = 4294967295;
26
+ const SECTOR_END = 4294967294;
27
+ const SECTOR_DIFAT = 4294967292;
28
+ const DIR_NONE = 4294967295;
29
+ const ENTRY_KIND = {
30
+ STORAGE: 1,
31
+ STREAM: 2,
32
+ ROOT: 5
33
+ };
34
+ const viewOf = (bytes) => new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
35
+ const u16 = (view, offset) => view.getUint16(offset, true);
36
+ const u32 = (view, offset) => view.getUint32(offset, true);
37
+ const u64 = (view, offset) => {
38
+ const low = BigInt(view.getUint32(offset, true));
39
+ return BigInt(view.getUint32(offset + 4, true)) << 32n | low;
40
+ };
41
+ const decodeName = (nameBytes) => {
42
+ const decoded = new TextDecoder("utf-16le").decode(nameBytes);
43
+ let end = decoded.length;
44
+ while (end > 0 && decoded.charCodeAt(end - 1) === 0) end--;
45
+ return decoded.slice(0, end);
46
+ };
47
+ const sectorOffset = (sectorId, sectorBytes) => (sectorId + 1) * sectorBytes;
48
+ const readHeader = (file) => {
49
+ if (file.length < HEADER_BYTES) throw new Error(`Compound file header truncated (${file.length} bytes)`);
50
+ for (let i = 0; i < SIGNATURE.length; i++) if (file[i] !== SIGNATURE[i]) throw new Error("Invalid compound file signature");
51
+ const view = viewOf(file);
52
+ const major = u16(view, 26);
53
+ const endian = u16(view, 28);
54
+ const sectorShift = u16(view, 30);
55
+ const miniShift = u16(view, 32);
56
+ const sectorBytes = 1 << sectorShift;
57
+ const miniSectorBytes = 1 << miniShift;
58
+ if (endian !== LITTLE_ENDIAN_MARK) throw new Error(`Unsupported compound file endianness: 0x${endian.toString(16)}`);
59
+ if (!(major === 3 && sectorBytes === 512 || major === 4 && sectorBytes === 4096)) throw new Error(`Unsupported compound file version ${major} (sector size ${sectorBytes})`);
60
+ const headerDifat = [];
61
+ for (let slot = 0; slot < HEADER_DIFAT_SLOTS; slot++) {
62
+ const id = u32(view, 76 + slot * 4);
63
+ if (id !== SECTOR_FREE) headerDifat.push(id);
64
+ }
65
+ return {
66
+ sectorBytes,
67
+ miniSectorBytes,
68
+ fatSectorCount: u32(view, 44),
69
+ directoryStartSector: u32(view, 48),
70
+ miniCutoff: u32(view, 56),
71
+ miniFatStart: u32(view, 60),
72
+ miniFatSectorCount: u32(view, 64),
73
+ difatStart: u32(view, 68),
74
+ difatSectorCount: u32(view, 72),
75
+ headerDifat
76
+ };
77
+ };
78
+ const loadSector = (file, header, sectorId) => {
79
+ if (sectorId >= SECTOR_DIFAT) throw new Error(`Invalid sector id 0x${sectorId.toString(16)}`);
80
+ const offset = sectorOffset(sectorId, header.sectorBytes);
81
+ if (offset + header.sectorBytes > file.length) throw new Error(`Sector ${sectorId} extends past end of file`);
82
+ return file.subarray(offset, offset + header.sectorBytes);
83
+ };
84
+ const loadFatSectorIds = (file, header) => {
85
+ const ids = [...header.headerDifat];
86
+ let chain = header.difatStart;
87
+ for (let step = 0; step < header.difatSectorCount; step++) {
88
+ if (chain === SECTOR_END || chain === SECTOR_FREE) break;
89
+ const view = viewOf(loadSector(file, header, chain));
90
+ const slotsPerSector = header.sectorBytes / 4 - 1;
91
+ for (let slot = 0; slot < slotsPerSector; slot++) {
92
+ const id = u32(view, slot * 4);
93
+ if (id !== SECTOR_FREE) ids.push(id);
94
+ }
95
+ chain = u32(view, header.sectorBytes - 4);
96
+ }
97
+ if (ids.length < header.fatSectorCount) throw new Error("DIFAT chain shorter than declared FAT sector count");
98
+ return ids.slice(0, header.fatSectorCount);
99
+ };
100
+ const loadFat = (file, header) => {
101
+ const table = [];
102
+ for (const fatSectorId of loadFatSectorIds(file, header)) {
103
+ const view = viewOf(loadSector(file, header, fatSectorId));
104
+ const entries = header.sectorBytes / 4;
105
+ for (let i = 0; i < entries; i++) table.push(u32(view, i * 4));
106
+ }
107
+ return table;
108
+ };
109
+ const followSectorChain = (file, header, fat, start) => {
110
+ if (start === SECTOR_END || start === SECTOR_FREE) return /* @__PURE__ */ new Uint8Array(0);
111
+ const parts = [];
112
+ const seen = /* @__PURE__ */ new Set();
113
+ let sectorId = start;
114
+ while (sectorId !== SECTOR_END) {
115
+ if (seen.has(sectorId)) throw new Error(`Sector chain loop at sector ${sectorId}`);
116
+ if (sectorId >= fat.length) throw new Error(`Sector ${sectorId} outside FAT`);
117
+ seen.add(sectorId);
118
+ parts.push(loadSector(file, header, sectorId));
119
+ const next = fat[sectorId];
120
+ if (next === void 0) throw new Error(`FAT missing link after sector ${sectorId}`);
121
+ sectorId = next;
122
+ }
123
+ return joinBytes(parts);
124
+ };
125
+ const loadDirectory = (file, header, fat) => {
126
+ const raw = followSectorChain(file, header, fat, header.directoryStartSector);
127
+ const records = [];
128
+ for (let pos = 0; pos + DIRECTORY_RECORD_BYTES <= raw.length; pos += DIRECTORY_RECORD_BYTES) {
129
+ const slice = raw.subarray(pos, pos + DIRECTORY_RECORD_BYTES);
130
+ const view = viewOf(slice);
131
+ const nameChars = Math.max(0, Math.min(u16(view, 64), 64) - 2);
132
+ records.push({
133
+ name: decodeName(slice.subarray(0, nameChars)),
134
+ kind: slice[66] ?? 0,
135
+ leftId: u32(view, 68),
136
+ rightId: u32(view, 72),
137
+ childId: u32(view, 76),
138
+ firstSector: u32(view, 116),
139
+ byteLength: (() => {
140
+ const size = u64(view, 120);
141
+ if (size > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error(`Stream too large: ${size.toString()}`);
142
+ return Number(size);
143
+ })()
144
+ });
145
+ }
146
+ return records;
147
+ };
148
+ /** Breadth-first sibling-tree walk (MS-CFB red-black sibling links). */
149
+ const walkSiblingTree = (records, rootId, visit) => {
150
+ const queue = [];
151
+ const enqueueSubtree = (id) => {
152
+ if (id === DIR_NONE || id >= records.length) return;
153
+ const stack = [id];
154
+ const queued = /* @__PURE__ */ new Set();
155
+ while (stack.length > 0) {
156
+ const current = stack.pop();
157
+ if (queued.has(current)) throw new Error(`Directory sibling loop at entry ${current}`);
158
+ queued.add(current);
159
+ queue.push(current);
160
+ const node = records[current];
161
+ if (!node) continue;
162
+ if (node.leftId !== DIR_NONE) stack.push(node.leftId);
163
+ if (node.rightId !== DIR_NONE) stack.push(node.rightId);
164
+ }
165
+ };
166
+ enqueueSubtree(rootId);
167
+ for (const id of queue) {
168
+ const record = records[id];
169
+ if (record) visit(record);
170
+ }
171
+ };
172
+ const resolvePath = (records, path) => {
173
+ const parts = path.split("/").filter(Boolean);
174
+ const root = records[0];
175
+ if (!root) return null;
176
+ let current = root;
177
+ for (const part of parts) {
178
+ if (current.kind !== ENTRY_KIND.ROOT && current.kind !== ENTRY_KIND.STORAGE) return null;
179
+ let match = null;
180
+ walkSiblingTree(records, current.childId, (candidate) => {
181
+ if (candidate.name.toLowerCase() === part.toLowerCase()) match = candidate;
182
+ });
183
+ if (!match) return null;
184
+ current = match;
185
+ }
186
+ return current;
187
+ };
188
+ const loadMiniFat = (file, header, fat) => {
189
+ if (header.miniFatSectorCount === 0 || header.miniFatStart === SECTOR_END) return [];
190
+ const bytes = followSectorChain(file, header, fat, header.miniFatStart);
191
+ const view = viewOf(bytes);
192
+ const count = Math.floor(bytes.length / 4);
193
+ const entries = [];
194
+ for (let i = 0; i < count; i++) entries.push(u32(view, i * 4));
195
+ return entries;
196
+ };
197
+ const followMiniChain = (container, miniFat, miniSectorBytes, start) => {
198
+ if (start === SECTOR_END || start === SECTOR_FREE) return /* @__PURE__ */ new Uint8Array(0);
199
+ const parts = [];
200
+ const seen = /* @__PURE__ */ new Set();
201
+ let sectorId = start;
202
+ while (sectorId !== SECTOR_END) {
203
+ if (seen.has(sectorId)) throw new Error(`Mini sector chain loop at ${sectorId}`);
204
+ if (sectorId >= miniFat.length) throw new Error(`Mini sector ${sectorId} outside MiniFAT`);
205
+ seen.add(sectorId);
206
+ const offset = sectorId * miniSectorBytes;
207
+ if (offset + miniSectorBytes > container.length) throw new Error(`Mini sector ${sectorId} extends past mini stream`);
208
+ parts.push(container.subarray(offset, offset + miniSectorBytes));
209
+ const next = miniFat[sectorId];
210
+ if (next === void 0) throw new Error(`MiniFAT missing link after mini sector ${sectorId}`);
211
+ sectorId = next;
212
+ }
213
+ return joinBytes(parts);
214
+ };
215
+ const openCompoundFile = (data) => {
216
+ const file = data instanceof Uint8Array ? data : new Uint8Array(data);
217
+ const header = readHeader(file);
218
+ const fat = loadFat(file, header);
219
+ const directory = loadDirectory(file, header, fat);
220
+ const root = directory[0];
221
+ if (!root || root.kind !== ENTRY_KIND.ROOT) throw new Error("Compound file root entry missing");
222
+ const miniFat = loadMiniFat(file, header, fat);
223
+ const miniContainer = root.byteLength > 0 && root.firstSector !== SECTOR_END ? followSectorChain(file, header, fat, root.firstSector).subarray(0, root.byteLength) : /* @__PURE__ */ new Uint8Array(0);
224
+ return { readStream(absolutePath) {
225
+ const entry = resolvePath(directory, absolutePath);
226
+ if (!entry || entry.kind !== ENTRY_KIND.STREAM) return null;
227
+ if (entry.byteLength < header.miniCutoff) return followMiniChain(miniContainer, miniFat, header.miniSectorBytes, entry.firstSector).subarray(0, entry.byteLength);
228
+ return followSectorChain(file, header, fat, entry.firstSector).subarray(0, entry.byteLength);
229
+ } };
230
+ };
231
+ const readEncryptedPackageStreams = (data) => {
232
+ const file = data instanceof Uint8Array ? data : new Uint8Array(data);
233
+ let reader;
234
+ try {
235
+ reader = openCompoundFile(file);
236
+ } catch (cause) {
237
+ throw new Error("Failed to parse encrypted OOXML compound file", { cause });
238
+ }
239
+ const encryptionInfo = reader.readStream("/EncryptionInfo");
240
+ const encryptedPackage = reader.readStream("/EncryptedPackage");
241
+ if (!encryptionInfo) throw new Error("Compound file missing EncryptionInfo stream");
242
+ if (!encryptedPackage) throw new Error("Compound file missing EncryptedPackage stream");
243
+ return {
244
+ encryptionInfo,
245
+ encryptedPackage
246
+ };
247
+ };
248
+ //#endregion
249
+ export { openCompoundFile, readEncryptedPackageStreams };
@@ -0,0 +1,15 @@
1
+ //#region src/docx/encryption/containerFormat.d.ts
2
+ /**
3
+ * OOXML container format detection (ZIP vs encrypted OLE compound file).
4
+ *
5
+ * @see https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-offcrypto/
6
+ */
7
+ declare const DOCX_CONTAINER_TYPES: {
8
+ readonly ZIP: "zip";
9
+ readonly CFB: "cfb";
10
+ readonly UNKNOWN: "unknown";
11
+ };
12
+ type DocxContainerType = (typeof DOCX_CONTAINER_TYPES)[keyof typeof DOCX_CONTAINER_TYPES];
13
+ declare const detectDocxContainerType: (data: ArrayBuffer | Uint8Array) => DocxContainerType;
14
+ //#endregion
15
+ export { DOCX_CONTAINER_TYPES, DocxContainerType, detectDocxContainerType };
@@ -0,0 +1,40 @@
1
+ //#region src/docx/encryption/containerFormat.ts
2
+ /**
3
+ * OOXML container format detection (ZIP vs encrypted OLE compound file).
4
+ *
5
+ * @see https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-offcrypto/
6
+ */
7
+ const DOCX_CONTAINER_TYPES = {
8
+ ZIP: "zip",
9
+ CFB: "cfb",
10
+ UNKNOWN: "unknown"
11
+ };
12
+ const ZIP_SIGNATURE = [
13
+ 80,
14
+ 75,
15
+ 3,
16
+ 4
17
+ ];
18
+ const OLE_SIGNATURE = [
19
+ 208,
20
+ 207,
21
+ 17,
22
+ 224,
23
+ 161,
24
+ 177,
25
+ 26,
26
+ 225
27
+ ];
28
+ const startsWith = (bytes, signature) => {
29
+ if (bytes.length < signature.length) return false;
30
+ for (let i = 0; i < signature.length; i++) if (bytes[i] !== signature[i]) return false;
31
+ return true;
32
+ };
33
+ const detectDocxContainerType = (data) => {
34
+ const bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
35
+ if (startsWith(bytes, ZIP_SIGNATURE)) return DOCX_CONTAINER_TYPES.ZIP;
36
+ if (startsWith(bytes, OLE_SIGNATURE)) return DOCX_CONTAINER_TYPES.CFB;
37
+ return DOCX_CONTAINER_TYPES.UNKNOWN;
38
+ };
39
+ //#endregion
40
+ export { DOCX_CONTAINER_TYPES, detectDocxContainerType };
@@ -0,0 +1,11 @@
1
+ //#region src/docx/encryption/cryptoBytes.d.ts
2
+ /** Byte helpers shared by MS-CFB and MS-OFFCRYPTO agile decryption. */
3
+ declare const joinBytes: (parts: readonly Uint8Array[]) => Uint8Array;
4
+ declare const writeUint32Le: (value: number) => Uint8Array;
5
+ declare const passwordToUtf16Le: (password: string) => Uint8Array;
6
+ declare const bytesEqual: (left: Uint8Array, right: Uint8Array) => boolean;
7
+ declare const toArrayBuffer: (bytes: Uint8Array) => ArrayBuffer;
8
+ declare const decodeBase64: (encoded: string) => Uint8Array;
9
+ declare const padToBlock: (bytes: Uint8Array, blockSize: number, padByte?: number) => Uint8Array;
10
+ //#endregion
11
+ export { bytesEqual, decodeBase64, joinBytes, padToBlock, passwordToUtf16Le, toArrayBuffer, writeUint32Le };