@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
@@ -0,0 +1,55 @@
1
+ //#region src/docx/encryption/cryptoBytes.ts
2
+ /** Byte helpers shared by MS-CFB and MS-OFFCRYPTO agile decryption. */
3
+ const joinBytes = (parts) => {
4
+ let total = 0;
5
+ for (const part of parts) total += part.length;
6
+ const out = new Uint8Array(total);
7
+ let offset = 0;
8
+ for (const part of parts) {
9
+ out.set(part, offset);
10
+ offset += part.length;
11
+ }
12
+ return out;
13
+ };
14
+ const writeUint32Le = (value) => {
15
+ const out = /* @__PURE__ */ new Uint8Array(4);
16
+ out[0] = value & 255;
17
+ out[1] = value >>> 8 & 255;
18
+ out[2] = value >>> 16 & 255;
19
+ out[3] = value >>> 24 & 255;
20
+ return out;
21
+ };
22
+ const passwordToUtf16Le = (password) => {
23
+ const out = new Uint8Array(password.length * 2);
24
+ for (let i = 0; i < password.length; i++) {
25
+ const code = password.charCodeAt(i);
26
+ out[i * 2] = code & 255;
27
+ out[i * 2 + 1] = code >> 8;
28
+ }
29
+ return out;
30
+ };
31
+ const bytesEqual = (left, right) => {
32
+ if (left.length !== right.length) return false;
33
+ let mismatch = 0;
34
+ for (let i = 0; i < left.length; i++) mismatch |= left[i] ^ right[i];
35
+ return mismatch === 0;
36
+ };
37
+ const toArrayBuffer = (bytes) => bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
38
+ const decodeBase64 = (encoded) => {
39
+ if (typeof atob === "function") {
40
+ const binary = atob(encoded);
41
+ const out = new Uint8Array(binary.length);
42
+ for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
43
+ return out;
44
+ }
45
+ return new Uint8Array(Buffer.from(encoded, "base64"));
46
+ };
47
+ const padToBlock = (bytes, blockSize, padByte = 54) => {
48
+ if (bytes.length >= blockSize) return bytes.subarray(0, blockSize);
49
+ const out = new Uint8Array(blockSize);
50
+ out.set(bytes);
51
+ out.fill(padByte, bytes.length);
52
+ return out;
53
+ };
54
+ //#endregion
55
+ export { bytesEqual, decodeBase64, joinBytes, padToBlock, passwordToUtf16Le, toArrayBuffer, writeUint32Le };
@@ -0,0 +1,32 @@
1
+ //#region src/docx/encryption/encryptionInfo.d.ts
2
+ /**
3
+ * Parse the EncryptionInfo stream for Agile Encryption (Office 2010+).
4
+ *
5
+ * @see https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-offcrypto/87020a34-e73f-4139-99bc-bbdf6cf6fa55
6
+ */
7
+ type AgileKeyMaterial = {
8
+ packageSalt: Uint8Array;
9
+ packageKeyBits: number;
10
+ packageHash: string;
11
+ packageHashBytes: number;
12
+ cipherBlockBytes: number;
13
+ cipherName: string;
14
+ cipherMode: string;
15
+ passwordIterations: number;
16
+ passwordSalt: Uint8Array;
17
+ passwordHash: string;
18
+ passwordKeyBits: number;
19
+ passwordBlockBytes: number;
20
+ encryptedVerifierInput: Uint8Array;
21
+ encryptedVerifierDigest: Uint8Array;
22
+ encryptedIntermediateKey: Uint8Array;
23
+ encryptedIntegrityKey: Uint8Array;
24
+ encryptedIntegrityDigest: Uint8Array;
25
+ };
26
+ type AgileEncryptionDescriptor = {
27
+ scheme: "agile";
28
+ material: AgileKeyMaterial;
29
+ };
30
+ declare const parseAgileEncryptionInfo: (stream: Uint8Array) => AgileEncryptionDescriptor;
31
+ //#endregion
32
+ export { AgileEncryptionDescriptor, AgileKeyMaterial, parseAgileEncryptionInfo };
@@ -0,0 +1,128 @@
1
+ import { decodeBase64 } from "./cryptoBytes.js";
2
+ import { DOCX_ENCRYPTION_ERROR_CODES, DocxEncryptionError } from "./errors.js";
3
+ //#region src/docx/encryption/encryptionInfo.ts
4
+ /**
5
+ * Parse the EncryptionInfo stream for Agile Encryption (Office 2010+).
6
+ *
7
+ * @see https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-offcrypto/87020a34-e73f-4139-99bc-bbdf6cf6fa55
8
+ */
9
+ const AGILE_STREAM_VERSION = 4;
10
+ const AGILE_STREAM_KIND = 4;
11
+ const STANDARD_STREAM_KIND = 3;
12
+ const STREAM_PREFIX_BYTES = 8;
13
+ const readUint16Le = (bytes, offset) => bytes[offset] | bytes[offset + 1] << 8;
14
+ const findElement = (xml, localName) => {
15
+ let index = 0;
16
+ while (index < xml.length) {
17
+ const hit = xml.indexOf(localName, index);
18
+ if (hit === -1) return null;
19
+ const tagStart = xml.lastIndexOf("<", hit);
20
+ if (tagStart === -1 || hit - tagStart > 24) {
21
+ index = hit + localName.length;
22
+ continue;
23
+ }
24
+ const tagEnd = xml.indexOf(">", hit);
25
+ if (tagEnd === -1) return null;
26
+ const tag = xml.slice(tagStart, tagEnd + 1);
27
+ if (tag.includes(localName)) return tag;
28
+ index = hit + localName.length;
29
+ }
30
+ return null;
31
+ };
32
+ const parseAttributes = (tag) => {
33
+ const attrs = {};
34
+ let i = tag.indexOf(" ");
35
+ if (i === -1) return attrs;
36
+ while (i < tag.length) {
37
+ while (i < tag.length && (tag[i] === " " || tag[i] === "\n" || tag[i] === "\r" || tag[i] === " ")) i++;
38
+ const nameStart = i;
39
+ while (i < tag.length && tag[i] !== "=" && tag[i] !== ">" && tag[i] !== "/") i++;
40
+ if (i >= tag.length || tag[i] !== "=") break;
41
+ const name = tag.slice(nameStart, i).trim();
42
+ i++;
43
+ const quote = tag[i];
44
+ if (quote !== "\"" && quote !== "'") break;
45
+ i++;
46
+ const valueStart = i;
47
+ while (i < tag.length && tag[i] !== quote) i++;
48
+ if (i >= tag.length) break;
49
+ attrs[name] = tag.slice(valueStart, i);
50
+ i++;
51
+ }
52
+ return attrs;
53
+ };
54
+ const requireAttr = (attrs, name, element) => {
55
+ const value = attrs[name];
56
+ if (value === void 0) throw new DocxEncryptionError({
57
+ code: DOCX_ENCRYPTION_ERROR_CODES.DECRYPTION_FAILED,
58
+ message: `EncryptionInfo <${element}> missing attribute "${name}"`
59
+ });
60
+ return value;
61
+ };
62
+ const requireInt = (attrs, name, element) => {
63
+ const parsed = Number.parseInt(requireAttr(attrs, name, element), 10);
64
+ if (Number.isNaN(parsed)) throw new DocxEncryptionError({
65
+ code: DOCX_ENCRYPTION_ERROR_CODES.DECRYPTION_FAILED,
66
+ message: `EncryptionInfo <${element}> attribute "${name}" is not an integer`
67
+ });
68
+ return parsed;
69
+ };
70
+ const requireBase64 = (attrs, name, element) => decodeBase64(requireAttr(attrs, name, element));
71
+ const parseAgileXml = (xmlBytes) => {
72
+ const xml = new TextDecoder("utf-8").decode(xmlBytes);
73
+ const keyDataTag = findElement(xml, "keyData");
74
+ const integrityTag = findElement(xml, "dataIntegrity");
75
+ const encryptedKeyTag = findElement(xml, "encryptedKey");
76
+ if (!keyDataTag || !integrityTag || !encryptedKeyTag) throw new DocxEncryptionError({
77
+ code: DOCX_ENCRYPTION_ERROR_CODES.DECRYPTION_FAILED,
78
+ message: "EncryptionInfo XML is missing required agile encryption elements"
79
+ });
80
+ const keyData = parseAttributes(keyDataTag);
81
+ const integrity = parseAttributes(integrityTag);
82
+ const encryptedKey = parseAttributes(encryptedKeyTag);
83
+ return {
84
+ packageSalt: requireBase64(keyData, "saltValue", "keyData"),
85
+ packageKeyBits: requireInt(keyData, "keyBits", "keyData"),
86
+ packageHash: requireAttr(keyData, "hashAlgorithm", "keyData"),
87
+ packageHashBytes: requireInt(keyData, "hashSize", "keyData"),
88
+ cipherBlockBytes: requireInt(keyData, "blockSize", "keyData"),
89
+ cipherName: requireAttr(keyData, "cipherAlgorithm", "keyData"),
90
+ cipherMode: requireAttr(keyData, "cipherChaining", "keyData"),
91
+ passwordIterations: requireInt(encryptedKey, "spinCount", "encryptedKey"),
92
+ passwordSalt: requireBase64(encryptedKey, "saltValue", "encryptedKey"),
93
+ passwordHash: requireAttr(encryptedKey, "hashAlgorithm", "encryptedKey"),
94
+ passwordKeyBits: requireInt(encryptedKey, "keyBits", "encryptedKey"),
95
+ passwordBlockBytes: requireInt(encryptedKey, "blockSize", "encryptedKey"),
96
+ encryptedVerifierInput: requireBase64(encryptedKey, "encryptedVerifierHashInput", "encryptedKey"),
97
+ encryptedVerifierDigest: requireBase64(encryptedKey, "encryptedVerifierHashValue", "encryptedKey"),
98
+ encryptedIntermediateKey: requireBase64(encryptedKey, "encryptedKeyValue", "encryptedKey"),
99
+ encryptedIntegrityKey: requireBase64(integrity, "encryptedHmacKey", "dataIntegrity"),
100
+ encryptedIntegrityDigest: requireBase64(integrity, "encryptedHmacValue", "dataIntegrity")
101
+ };
102
+ };
103
+ const parseAgileEncryptionInfo = (stream) => {
104
+ if (stream.length < STREAM_PREFIX_BYTES) throw new DocxEncryptionError({
105
+ code: DOCX_ENCRYPTION_ERROR_CODES.DECRYPTION_FAILED,
106
+ message: `EncryptionInfo stream too short (${stream.length} bytes)`
107
+ });
108
+ const version = readUint16Le(stream, 0);
109
+ const kind = readUint16Le(stream, 2);
110
+ if (version === AGILE_STREAM_VERSION && kind === AGILE_STREAM_KIND) return {
111
+ scheme: "agile",
112
+ material: parseAgileXml(stream.subarray(STREAM_PREFIX_BYTES))
113
+ };
114
+ if ((version === 3 || version === AGILE_STREAM_VERSION) && kind === STANDARD_STREAM_KIND) throw new DocxEncryptionError({
115
+ code: DOCX_ENCRYPTION_ERROR_CODES.ENCRYPTION_UNSUPPORTED,
116
+ message: "Standard Encryption (Office 2007) is not supported. Only Agile Encryption (Office 2010+) is supported."
117
+ });
118
+ if (version <= 2) throw new DocxEncryptionError({
119
+ code: DOCX_ENCRYPTION_ERROR_CODES.ENCRYPTION_UNSUPPORTED,
120
+ message: "Legacy RC4 encryption is not supported. Only Agile Encryption (Office 2010+) is supported."
121
+ });
122
+ throw new DocxEncryptionError({
123
+ code: DOCX_ENCRYPTION_ERROR_CODES.ENCRYPTION_UNSUPPORTED,
124
+ message: `Unrecognized EncryptionInfo header version=${version} kind=${kind}`
125
+ });
126
+ };
127
+ //#endregion
128
+ export { parseAgileEncryptionInfo };
@@ -0,0 +1,18 @@
1
+ //#region src/docx/encryption/errors.d.ts
2
+ /** Machine-readable codes for password-protected OOXML failures. */
3
+ declare const DOCX_ENCRYPTION_ERROR_CODES: {
4
+ readonly PASSWORD_REQUIRED: "DOCX_PASSWORD_REQUIRED";
5
+ readonly PASSWORD_INVALID: "DOCX_PASSWORD_INVALID";
6
+ readonly ENCRYPTION_UNSUPPORTED: "DOCX_ENCRYPTION_UNSUPPORTED";
7
+ readonly DECRYPTION_FAILED: "DOCX_DECRYPTION_FAILED";
8
+ };
9
+ type DocxEncryptionErrorCode = (typeof DOCX_ENCRYPTION_ERROR_CODES)[keyof typeof DOCX_ENCRYPTION_ERROR_CODES];
10
+ declare const DocxEncryptionError_base: import("better-result").TaggedErrorClass<"DocxEncryptionError", {
11
+ message: string;
12
+ code: DocxEncryptionErrorCode;
13
+ cause?: unknown;
14
+ }>;
15
+ declare class DocxEncryptionError extends DocxEncryptionError_base {}
16
+ declare const isDocxEncryptionError: (error: unknown) => error is DocxEncryptionError;
17
+ //#endregion
18
+ export { DOCX_ENCRYPTION_ERROR_CODES, DocxEncryptionError, DocxEncryptionErrorCode, isDocxEncryptionError };
@@ -0,0 +1,13 @@
1
+ import { TaggedError } from "better-result";
2
+ //#region src/docx/encryption/errors.ts
3
+ /** Machine-readable codes for password-protected OOXML failures. */
4
+ const DOCX_ENCRYPTION_ERROR_CODES = {
5
+ PASSWORD_REQUIRED: "DOCX_PASSWORD_REQUIRED",
6
+ PASSWORD_INVALID: "DOCX_PASSWORD_INVALID",
7
+ ENCRYPTION_UNSUPPORTED: "DOCX_ENCRYPTION_UNSUPPORTED",
8
+ DECRYPTION_FAILED: "DOCX_DECRYPTION_FAILED"
9
+ };
10
+ var DocxEncryptionError = class extends TaggedError("DocxEncryptionError")() {};
11
+ const isDocxEncryptionError = (error) => error instanceof DocxEncryptionError;
12
+ //#endregion
13
+ export { DOCX_ENCRYPTION_ERROR_CODES, DocxEncryptionError, isDocxEncryptionError };
@@ -0,0 +1,4 @@
1
+ import { DOCX_CONTAINER_TYPES, DocxContainerType, detectDocxContainerType } from "./containerFormat.js";
2
+ import { DOCX_ENCRYPTION_ERROR_CODES, DocxEncryptionError, DocxEncryptionErrorCode, isDocxEncryptionError } from "./errors.js";
3
+ import { DecryptDocxOptions, DecryptDocxResult, decryptDocxIfNeeded, openDocxBuffer } from "./openEncryptedDocx.js";
4
+ export { DOCX_CONTAINER_TYPES, DOCX_ENCRYPTION_ERROR_CODES, type DecryptDocxOptions, type DecryptDocxResult, type DocxContainerType, DocxEncryptionError, type DocxEncryptionErrorCode, decryptDocxIfNeeded, detectDocxContainerType, isDocxEncryptionError, openDocxBuffer };
@@ -0,0 +1,4 @@
1
+ import { DOCX_ENCRYPTION_ERROR_CODES, DocxEncryptionError, isDocxEncryptionError } from "./errors.js";
2
+ import { DOCX_CONTAINER_TYPES, detectDocxContainerType } from "./containerFormat.js";
3
+ import { decryptDocxIfNeeded, openDocxBuffer } from "./openEncryptedDocx.js";
4
+ export { DOCX_CONTAINER_TYPES, DOCX_ENCRYPTION_ERROR_CODES, DocxEncryptionError, decryptDocxIfNeeded, detectDocxContainerType, isDocxEncryptionError, openDocxBuffer };
@@ -0,0 +1,28 @@
1
+ //#region src/docx/encryption/openEncryptedDocx.d.ts
2
+ /**
3
+ * Decrypt password-protected OOXML before ZIP extraction.
4
+ *
5
+ * Implementation is derived from [MS-OFFCRYPTO] and [MS-CFB] only.
6
+ *
7
+ * @see https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-offcrypto/
8
+ */
9
+ type DecryptDocxOptions = {
10
+ /** Password for Agile-encrypted .docx files (Office 2010+). */password?: string | undefined;
11
+ };
12
+ type DecryptDocxResult = {
13
+ /** Plaintext OOXML ZIP bytes, or the original buffer when unencrypted. */data: ArrayBuffer; /** True when the input was an encrypted CFB container and decryption ran. */
14
+ wasEncrypted: boolean;
15
+ };
16
+ /**
17
+ * Decrypt a password-protected `.docx` when needed, or return the input unchanged.
18
+ *
19
+ * Supports Agile Encryption (Office 2010+). Standard Encryption and legacy RC4
20
+ * are rejected with `DOCX_ENCRYPTION_UNSUPPORTED`.
21
+ *
22
+ * Save produces a standard unencrypted OOXML ZIP; folio does not re-encrypt on export.
23
+ */
24
+ declare const decryptDocxIfNeeded: (data: ArrayBuffer | Uint8Array, options?: DecryptDocxOptions) => Promise<DecryptDocxResult>;
25
+ /** Open a `.docx` buffer for parsing: decrypt when encrypted, then return the ZIP bytes. */
26
+ declare const openDocxBuffer: (data: ArrayBuffer | Uint8Array, options?: DecryptDocxOptions) => Promise<ArrayBuffer>;
27
+ //#endregion
28
+ export { DecryptDocxOptions, DecryptDocxResult, decryptDocxIfNeeded, openDocxBuffer };
@@ -0,0 +1,65 @@
1
+ import { toArrayBuffer } from "./cryptoBytes.js";
2
+ import { DOCX_ENCRYPTION_ERROR_CODES, DocxEncryptionError } from "./errors.js";
3
+ import { decryptAgileEncryptedPackage } from "./agileDecryption.js";
4
+ import { readEncryptedPackageStreams } from "./compoundFile.js";
5
+ import { DOCX_CONTAINER_TYPES, detectDocxContainerType } from "./containerFormat.js";
6
+ import { parseAgileEncryptionInfo } from "./encryptionInfo.js";
7
+ //#region src/docx/encryption/openEncryptedDocx.ts
8
+ /**
9
+ * Decrypt password-protected OOXML before ZIP extraction.
10
+ *
11
+ * Implementation is derived from [MS-OFFCRYPTO] and [MS-CFB] only.
12
+ *
13
+ * @see https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-offcrypto/
14
+ */
15
+ const assertZipPayload = (bytes) => {
16
+ if (detectDocxContainerType(bytes) !== DOCX_CONTAINER_TYPES.ZIP) throw new DocxEncryptionError({
17
+ code: DOCX_ENCRYPTION_ERROR_CODES.DECRYPTION_FAILED,
18
+ message: "Decrypted output is not a valid ZIP archive — the file may be corrupt"
19
+ });
20
+ };
21
+ /**
22
+ * Decrypt a password-protected `.docx` when needed, or return the input unchanged.
23
+ *
24
+ * Supports Agile Encryption (Office 2010+). Standard Encryption and legacy RC4
25
+ * are rejected with `DOCX_ENCRYPTION_UNSUPPORTED`.
26
+ *
27
+ * Save produces a standard unencrypted OOXML ZIP; folio does not re-encrypt on export.
28
+ */
29
+ const decryptDocxIfNeeded = async (data, options = {}) => {
30
+ const bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
31
+ const container = detectDocxContainerType(bytes);
32
+ if (container === DOCX_CONTAINER_TYPES.ZIP) return {
33
+ data: toArrayBuffer(bytes),
34
+ wasEncrypted: false
35
+ };
36
+ if (container !== DOCX_CONTAINER_TYPES.CFB) throw new DocxEncryptionError({
37
+ code: DOCX_ENCRYPTION_ERROR_CODES.DECRYPTION_FAILED,
38
+ message: "Unrecognized file format — expected a .docx (ZIP) or encrypted .docx (OLE/CFB)"
39
+ });
40
+ if (options.password == null) throw new DocxEncryptionError({
41
+ code: DOCX_ENCRYPTION_ERROR_CODES.PASSWORD_REQUIRED,
42
+ message: "This document is password-protected. A password is required to open it."
43
+ });
44
+ let streams;
45
+ try {
46
+ streams = readEncryptedPackageStreams(bytes);
47
+ } catch (cause) {
48
+ throw new DocxEncryptionError({
49
+ code: DOCX_ENCRYPTION_ERROR_CODES.DECRYPTION_FAILED,
50
+ message: "Failed to read encrypted OOXML streams",
51
+ cause
52
+ });
53
+ }
54
+ const descriptor = parseAgileEncryptionInfo(streams.encryptionInfo);
55
+ const decryptedZip = await decryptAgileEncryptedPackage(options.password, descriptor.material, streams.encryptedPackage);
56
+ assertZipPayload(decryptedZip);
57
+ return {
58
+ data: toArrayBuffer(decryptedZip),
59
+ wasEncrypted: true
60
+ };
61
+ };
62
+ /** Open a `.docx` buffer for parsing: decrypt when encrypted, then return the ZIP bytes. */
63
+ const openDocxBuffer = async (data, options = {}) => (await decryptDocxIfNeeded(data, options)).data;
64
+ //#endregion
65
+ export { decryptDocxIfNeeded, openDocxBuffer };
@@ -1,2 +1,4 @@
1
1
  import { getCachedNumberingMap } from "./numberingParser.js";
2
- export { getCachedNumberingMap };
2
+ import { DOCX_ENCRYPTION_ERROR_CODES, DocxEncryptionError, DocxEncryptionErrorCode, isDocxEncryptionError } from "./encryption/errors.js";
3
+ import { DecryptDocxOptions, DecryptDocxResult, decryptDocxIfNeeded, openDocxBuffer } from "./encryption/openEncryptedDocx.js";
4
+ export { DOCX_ENCRYPTION_ERROR_CODES, type DecryptDocxOptions, type DecryptDocxResult, DocxEncryptionError, type DocxEncryptionErrorCode, decryptDocxIfNeeded, getCachedNumberingMap, isDocxEncryptionError, openDocxBuffer };
@@ -1,2 +1,4 @@
1
1
  import { getCachedNumberingMap } from "./numberingParser.js";
2
- export { getCachedNumberingMap };
2
+ import { DOCX_ENCRYPTION_ERROR_CODES, DocxEncryptionError, isDocxEncryptionError } from "./encryption/errors.js";
3
+ import { decryptDocxIfNeeded, openDocxBuffer } from "./encryption/openEncryptedDocx.js";
4
+ export { DOCX_ENCRYPTION_ERROR_CODES, DocxEncryptionError, decryptDocxIfNeeded, getCachedNumberingMap, isDocxEncryptionError, openDocxBuffer };
@@ -1,5 +1,5 @@
1
1
  import { document_d_exports } from "../types/document.js";
2
- import { DocxUnzipLimits } from "./unzip.js";
2
+ import { DocxUnzipOptions } from "./unzip.js";
3
3
  import { DocxInput } from "../utils/docxInput.js";
4
4
 
5
5
  //#region src/docx/parser.d.ts
@@ -24,10 +24,9 @@ type ParseOptions = {
24
24
  preloadFonts?: boolean; /** Whether to parse headers/footers (default: true) */
25
25
  parseHeadersFooters?: boolean; /** Whether to parse footnotes/endnotes (default: true) */
26
26
  parseNotes?: boolean; /** Whether to detect template variables (default: true) */
27
- detectVariables?: boolean; /** Security limits for DOCX ZIP extraction */
28
- unzipLimits?: Partial<Omit<DocxUnzipLimits, "allowedMediaMimeTypes">> & {
29
- allowedMediaMimeTypes?: Iterable<string>;
30
- }; /** Optional async hook to override display URLs for non-browser media. */
27
+ detectVariables?: boolean; /** Password for Agile-encrypted .docx files (Office 2010+). */
28
+ password?: string | undefined; /** Security limits for DOCX ZIP extraction */
29
+ unzipLimits?: DocxUnzipOptions; /** Optional async hook to override display URLs for non-browser media. */
31
30
  mediaResolver?: MediaResolver;
32
31
  };
33
32
  /**
@@ -3,6 +3,7 @@ import { applyThemeFontLang, parseTheme } from "./themeParser.js";
3
3
  import { parseEndnotes, parseFootnotes } from "./footnoteParser.js";
4
4
  import { DocxModelValidationError, formatDocumentModelIssues, validateFolioDocumentModel } from "./modelValidation.js";
5
5
  import { parseNumbering } from "./numberingParser.js";
6
+ import { DocxEncryptionError } from "./encryption/errors.js";
6
7
  import { getMediaMimeType, mediaToDataUrl, unzipDocx } from "./unzip.js";
7
8
  import { parseComments } from "./commentParser.js";
8
9
  import { parseFooter, parseHeader } from "./headerFooterParser.js";
@@ -48,13 +49,17 @@ import { TaggedError } from "better-result";
48
49
  */
49
50
  async function parseDocx(input, options = {}) {
50
51
  const buffer = input instanceof ArrayBuffer ? input : await toArrayBuffer(input);
51
- const { onProgress = () => {}, preloadFonts = true, parseHeadersFooters = true, parseNotes = true, detectVariables = true, unzipLimits, mediaResolver } = options;
52
+ const { onProgress = () => {}, preloadFonts = true, parseHeadersFooters = true, parseNotes = true, detectVariables = true, password, unzipLimits, mediaResolver } = options;
52
53
  const warnings = [];
53
54
  try {
54
55
  const timeStage = (_name, fn) => fn();
55
56
  const timeStageAsync = async (_name, fn) => await fn();
56
57
  onProgress("Extracting DOCX...", 0);
57
- const raw = await timeStageAsync("unzip", () => unzipDocx(buffer, unzipLimits));
58
+ const raw = await timeStageAsync("unzip", () => unzipDocx(buffer, {
59
+ ...unzipLimits,
60
+ password
61
+ }));
62
+ if (raw.wasEncrypted) warnings.push("Document was opened from password-protected storage; saving writes an unencrypted .docx file.");
58
63
  warnings.push(...raw.warnings);
59
64
  onProgress("Extracted DOCX", 10);
60
65
  onProgress("Parsing relationships...", 10);
@@ -186,6 +191,7 @@ async function parseDocx(input, options = {}) {
186
191
  onProgress("Complete", 100);
187
192
  return document;
188
193
  } catch (error) {
194
+ if (error instanceof DocxEncryptionError) throw error;
189
195
  throw new DocxParseError({
190
196
  message: `Failed to parse DOCX: ${error instanceof Error ? error.message : String(error)}`,
191
197
  cause: error
@@ -13,8 +13,9 @@ type DocxUnzipLimits = {
13
13
  maxTotalUncompressedBytes: number;
14
14
  allowedMediaMimeTypes: ReadonlySet<string>;
15
15
  };
16
- type PartialDocxUnzipLimits = Partial<Omit<DocxUnzipLimits, "allowedMediaMimeTypes">> & {
17
- allowedMediaMimeTypes?: Iterable<string>;
16
+ type DocxUnzipOptions = Partial<Omit<DocxUnzipLimits, "allowedMediaMimeTypes">> & {
17
+ allowedMediaMimeTypes?: Iterable<string>; /** Password for Agile-encrypted .docx files (Office 2010+). */
18
+ password?: string | undefined;
18
19
  };
19
20
  /**
20
21
  * Raw extracted content from a DOCX file
@@ -45,7 +46,8 @@ type RawDocxContent = {
45
46
  allXml: Map<string, string>;
46
47
  originalZip: JSZip;
47
48
  originalBuffer: ArrayBuffer;
48
- warnings: string[];
49
+ warnings: string[]; /** True when the input was a password-protected CFB container. */
50
+ wasEncrypted: boolean;
49
51
  };
50
52
  /**
51
53
  * Extract all content from a DOCX file
@@ -53,7 +55,7 @@ type RawDocxContent = {
53
55
  * @param buffer - DOCX file as ArrayBuffer
54
56
  * @returns Promise resolving to extracted content
55
57
  */
56
- declare function unzipDocx(buffer: ArrayBuffer, options?: PartialDocxUnzipLimits): Promise<RawDocxContent>;
58
+ declare function unzipDocx(buffer: ArrayBuffer, options?: DocxUnzipOptions): Promise<RawDocxContent>;
57
59
  declare function isPreservableDocxEntry(path: string): boolean;
58
60
  /**
59
61
  * Get a list of all files in the DOCX
@@ -115,4 +117,4 @@ declare function getContentSummary(content: RawDocxContent): {
115
117
  totalFiles: number;
116
118
  };
117
119
  //#endregion
118
- export { DocxSecurityError, DocxUnzipLimits, RawDocxContent, extractFile, getContentSummary, getFileList, getMediaMimeType, hasFile, isPreservableDocxEntry, mediaToDataUrl, unzipDocx };
120
+ export { DocxSecurityError, DocxUnzipLimits, DocxUnzipOptions, RawDocxContent, extractFile, getContentSummary, getFileList, getMediaMimeType, hasFile, isPreservableDocxEntry, mediaToDataUrl, unzipDocx };
@@ -1,3 +1,5 @@
1
+ import { DOCX_CONTAINER_TYPES, detectDocxContainerType } from "./encryption/containerFormat.js";
2
+ import { openDocxBuffer } from "./encryption/openEncryptedDocx.js";
1
3
  import JSZip from "jszip";
2
4
  //#region src/docx/unzip.ts
3
5
  /**
@@ -71,7 +73,10 @@ const ZIP_CENTRAL_DIRECTORY_FILE_HEADER_SIZE = 46;
71
73
  async function unzipDocx(buffer, options = {}) {
72
74
  const limits = createUnzipLimits(options);
73
75
  if (buffer.byteLength > limits.maxInputBytes) throw new DocxSecurityError("DOCX file exceeds the maximum allowed size");
74
- const loaded = await loadDocxZip(buffer, limits.maxFiles);
76
+ const zipBuffer = await openDocxBuffer(buffer, { password: options.password });
77
+ if (zipBuffer.byteLength > limits.maxInputBytes) throw new DocxSecurityError("DOCX file exceeds the maximum allowed size");
78
+ const wasEncrypted = detectDocxContainerType(buffer) === DOCX_CONTAINER_TYPES.CFB;
79
+ const loaded = await loadDocxZip(zipBuffer, limits.maxFiles);
75
80
  if (loaded.buffer.byteLength > limits.maxInputBytes) throw new DocxSecurityError("DOCX file exceeds the maximum allowed size");
76
81
  const { zip } = loaded;
77
82
  const entries = Object.entries(zip.files).filter(([, file]) => !file.dir);
@@ -102,7 +107,8 @@ async function unzipDocx(buffer, options = {}) {
102
107
  allXml: /* @__PURE__ */ new Map(),
103
108
  originalZip: zip,
104
109
  originalBuffer: loaded.buffer,
105
- warnings: []
110
+ warnings: [],
111
+ wasEncrypted
106
112
  };
107
113
  let totalUncompressedBytes = 0;
108
114
  for (const [path, file] of entries) {
@@ -12,7 +12,7 @@ type HeaderFooterMetrics = {
12
12
  };
13
13
  margins: PageMargins;
14
14
  };
15
- declare function normalizeHeaderFooterMeasureBlocks(blocks: FlowBlock[]): FlowBlock[];
15
+ declare function normalizeHeaderFooterMeasureBlocks(blocks: FlowBlock[], section?: HeaderFooterMetrics["section"]): FlowBlock[];
16
16
  declare function resolveHeaderFooterPositionedVisualTop(position: ImageRunPosition | undefined, elementHeight: number, sourceY: number, flowHeight: number, metrics: HeaderFooterMetrics): number;
17
17
  declare function resolveHeaderFooterVisualTop(run: ImageRun, paragraphY: number, flowHeight: number, metrics: HeaderFooterMetrics): number;
18
18
  declare function calculateHeaderFooterVisualBounds(blocks: FlowBlock[], measures: Measure[], flowHeight: number, metrics: HeaderFooterMetrics): {
@@ -16,13 +16,13 @@ function hasAuthoredVisualContent(block) {
16
16
  if (attrs.spacingExplicit?.before || attrs.spacingExplicit?.after) return true;
17
17
  return false;
18
18
  }
19
- function normalizeHeaderFooterMeasureBlocks(blocks) {
20
- return normalizeFlowBlockArray(blocks);
19
+ function normalizeHeaderFooterMeasureBlocks(blocks, section = "header") {
20
+ return normalizeFlowBlockArray(blocks, { suppressTrailingEmptyAfterTable: section === "header" });
21
21
  }
22
- function normalizeFlowBlockArray(blocks) {
22
+ function normalizeFlowBlockArray(blocks, normalization) {
23
23
  const trailingEmptyAfterTable = /* @__PURE__ */ new Set();
24
24
  const lastIndex = blocks.length - 1;
25
- if (lastIndex > 0) {
25
+ if (normalization.suppressTrailingEmptyAfterTable && lastIndex > 0) {
26
26
  const cur = blocks[lastIndex];
27
27
  if (blocks[lastIndex - 1]?.kind === "table" && cur?.kind === "paragraph" && cur.runs.length === 0 && !hasAuthoredVisualContent(cur)) trailingEmptyAfterTable.add(lastIndex);
28
28
  }
@@ -70,12 +70,21 @@ function normalizeFlowBlockArray(blocks) {
70
70
  };
71
71
  });
72
72
  }
73
+ function isCanonicalTrailingEmptyParagraphAfterTable(blocks, index) {
74
+ const cur = blocks[index];
75
+ const prev = blocks[index - 1];
76
+ return index === blocks.length - 1 && prev?.kind === "table" && cur?.kind === "paragraph" && isVisuallyEmptyParagraph(cur) && !hasAuthoredVisualContent(cur);
77
+ }
78
+ function isVisuallyEmptyParagraph(block) {
79
+ if (block.kind !== "paragraph") return false;
80
+ return block.runs.every((run) => run.kind === "text" && run.text.trim().length === 0);
81
+ }
73
82
  function normalizeTableBlock(block) {
74
83
  const blockState = { changed: false };
75
84
  const rows = block.rows.map((row) => {
76
85
  const rowState = { changed: false };
77
86
  const cells = row.cells.map((cell) => {
78
- const normalizedBlocks = normalizeFlowBlockArray(cell.blocks);
87
+ const normalizedBlocks = normalizeFlowBlockArray(cell.blocks, { suppressTrailingEmptyAfterTable: true });
79
88
  if (!normalizedBlocks.some((normalizedBlock, idx) => normalizedBlock !== cell.blocks[idx])) return cell;
80
89
  rowState.changed = true;
81
90
  return {
@@ -232,6 +241,7 @@ function calculateHeaderFooterMarginPushBounds(blocks, measures, flowHeight, met
232
241
  const block = blocks[i];
233
242
  const measure = measures[i];
234
243
  if (!block || !measure) continue;
244
+ if (isCanonicalTrailingEmptyParagraphAfterTable(blocks, i)) continue;
235
245
  if (block.kind === "paragraph" && measure.kind === "paragraph") {
236
246
  const paragraphBottomY = cursorY + measure.totalHeight;
237
247
  top = Math.min(top, cursorY);
@@ -312,7 +322,7 @@ function convertHeaderFooterPmDocToContent(pmDoc, contentWidth, metrics, options
312
322
  }
313
323
  function finalizeHeaderFooterContent(blocks, contentWidth, metrics, options) {
314
324
  if (blocks.length === 0) return;
315
- const blocksForMeasure = normalizeHeaderFooterMeasureBlocks(blocks);
325
+ const blocksForMeasure = normalizeHeaderFooterMeasureBlocks(blocks, metrics.section);
316
326
  const measures = options.measureBlocks(blocksForMeasure, contentWidth);
317
327
  let flowHeight = 0;
318
328
  for (let i = 0; i < measures.length; i++) {
@@ -732,6 +732,7 @@ function convertParagraphAttrs(pmAttrs, theme, listCounters, listAbstractCounter
732
732
  if (pmAttrs.pageBreakBefore) attrs.pageBreakBefore = true;
733
733
  if (pmAttrs.keepNext) attrs.keepNext = true;
734
734
  if (pmAttrs.keepLines) attrs.keepLines = true;
735
+ if (pmAttrs.widowControl === false) attrs.widowControl = false;
735
736
  if (pmAttrs.contextualSpacing) attrs.contextualSpacing = true;
736
737
  if (pmAttrs.runInWithNext) attrs.runInWithNext = true;
737
738
  if (directionIsRtl(pmAttrs.direction)) attrs.bidi = true;
@@ -941,6 +942,7 @@ function convertTableRow(node, startPos, options, tableCellMargins) {
941
942
  if (attrs.height) row.height = twipsToPixels(attrs.height);
942
943
  if (attrs.heightRule) row.heightRule = attrs.heightRule;
943
944
  if (attrs.isHeader) row.isHeader = attrs.isHeader;
945
+ if (attrs.hidden) row.hidden = attrs.hidden;
944
946
  return row;
945
947
  }
946
948
  /**
@@ -72,6 +72,9 @@ function getSpacingAfter(block) {
72
72
  if (isEmptyParagraph(block) && !block.attrs?.spacingExplicit?.after) return 0;
73
73
  return value;
74
74
  }
75
+ function hasWidowControl(block) {
76
+ return block.attrs?.widowControl !== false;
77
+ }
75
78
  /**
76
79
  * Apply contextual spacing suppression (OOXML §17.3.1.9).
77
80
  *
@@ -309,6 +312,28 @@ function layoutParagraph(block, measure, paginator, contentWidth, footnoteHeight
309
312
  fittingLines++;
310
313
  } else break;
311
314
  }
315
+ let forceBreakAfterFragment = false;
316
+ if (hasWidowControl(block)) {
317
+ const remainingAfter = lines.length - (currentLineIndex + fittingLines);
318
+ if (fittingLines > 1 && remainingAfter === 1) {
319
+ if (currentLineIndex === 0 && fittingLines === 2 && state.cursorY !== state.topMargin) {
320
+ paginator.forceColumnBreak();
321
+ continue;
322
+ }
323
+ fittingLines -= 1;
324
+ forceBreakAfterFragment = true;
325
+ linesHeight = 0;
326
+ linesFnHeight = 0;
327
+ linesFnIds.length = 0;
328
+ for (let j = currentLineIndex; j < currentLineIndex + fittingLines; j++) {
329
+ const line = lines[j];
330
+ linesHeight += measuredLineAdvance(line);
331
+ const lineRefs = getLineFootnoteRefs(block, line.fromRun, line.toRun, footnoteHeightById);
332
+ linesFnHeight += lineRefs.height;
333
+ for (const id of lineRefs.ids) linesFnIds.push(id);
334
+ }
335
+ }
336
+ }
312
337
  const isFirstFragment = currentLineIndex === 0;
313
338
  const isLastFragment = currentLineIndex + fittingLines >= lines.length;
314
339
  const effectiveSpaceBefore = isFirstFragment ? spaceBefore : 0;
@@ -336,7 +361,8 @@ function layoutParagraph(block, measure, paginator, contentWidth, footnoteHeight
336
361
  fragment.y = paginator.addFragment(fragment, linesHeight, effectiveSpaceBefore, effectiveSpaceAfter).y;
337
362
  if (linesFnHeight > 0) paginator.addFootnoteHeight(linesFnHeight, linesFnIds);
338
363
  currentLineIndex += fittingLines;
339
- if (currentLineIndex < lines.length) paginator.ensureFits(measuredLineAdvance(lines[currentLineIndex]));
364
+ if (currentLineIndex < lines.length) if (forceBreakAfterFragment) paginator.forceColumnBreak();
365
+ else paginator.ensureFits(measuredLineAdvance(lines[currentLineIndex]));
340
366
  }
341
367
  }
342
368
  /**