@stll/folio-core 0.6.1 → 0.8.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 (107) hide show
  1. package/dist/ai-edits/blockRange.d.ts +13 -2
  2. package/dist/ai-edits/blockRange.js +23 -2
  3. package/dist/ai-edits/index.d.ts +4 -2
  4. package/dist/ai-edits/index.js +3 -1
  5. package/dist/ai-edits/scoped-reading.d.ts +9 -0
  6. package/dist/ai-edits/scoped-reading.js +60 -0
  7. package/dist/ai-edits/snapshot.js +13 -4
  8. package/dist/ai-edits/types.d.ts +44 -2
  9. package/dist/compat/eigenpal.d.ts +5 -2
  10. package/dist/compat/eigenpal.js +4 -1
  11. package/dist/controller/fontReadiness.d.ts +29 -0
  12. package/dist/controller/fontReadiness.js +139 -0
  13. package/dist/controller/layoutPipeline.js +86 -11
  14. package/dist/docx/blockContentParser.js +51 -8
  15. package/dist/docx/capabilities.d.ts +41 -0
  16. package/dist/docx/capabilities.js +322 -0
  17. package/dist/docx/compatibility.d.ts +34 -2
  18. package/dist/docx/compatibility.js +128 -38
  19. package/dist/docx/conformance.d.ts +6 -0
  20. package/dist/docx/conformance.js +18 -0
  21. package/dist/docx/ensureParaIds.d.ts +29 -0
  22. package/dist/docx/ensureParaIds.js +423 -0
  23. package/dist/docx/groupDrawingParser.d.ts +1 -1
  24. package/dist/docx/groupDrawingParser.js +49 -8
  25. package/dist/docx/numberingParser.js +1 -0
  26. package/dist/docx/paragraphParser.js +9 -0
  27. package/dist/docx/paragraphTextBoxEnrichment.js +141 -2
  28. package/dist/docx/parser.js +2 -0
  29. package/dist/docx/runParser.js +11 -2
  30. package/dist/docx/selectiveXmlPatch.d.ts +10 -1
  31. package/dist/docx/selectiveXmlPatch.js +1 -1
  32. package/dist/docx/serializer/paragraphSerializer.js +4 -0
  33. package/dist/docx/serializer/runSerializer.js +7 -2
  34. package/dist/docx/serializer/tableSerializer.js +4 -0
  35. package/dist/docx/server/boundedArchive.d.ts +24 -0
  36. package/dist/docx/server/boundedArchive.js +106 -0
  37. package/dist/docx/server/extractDocxText.d.ts +23 -0
  38. package/dist/docx/server/extractDocxText.js +154 -0
  39. package/dist/docx/tableParser.js +8 -0
  40. package/dist/docx/textBoxParser.js +6 -1
  41. package/dist/docx/vmlImageParser.js +145 -1
  42. package/dist/index.d.ts +5 -2
  43. package/dist/index.js +4 -1
  44. package/dist/layout-bridge/convert/headerFooterLayout.js +16 -2
  45. package/dist/layout-bridge/convert/paragraphFrames.d.ts +7 -0
  46. package/dist/layout-bridge/convert/paragraphFrames.js +136 -0
  47. package/dist/layout-bridge/convert/toFlowBlocks.js +106 -13
  48. package/dist/layout-bridge/engine/hitTest.js +2 -1
  49. package/dist/layout-bridge/engine/selectionRects.js +2 -1
  50. package/dist/layout-engine/headerFooterRefs.d.ts +7 -0
  51. package/dist/layout-engine/headerFooterRefs.js +40 -0
  52. package/dist/layout-engine/index.d.ts +3 -2
  53. package/dist/layout-engine/index.js +125 -72
  54. package/dist/layout-engine/keep-together.d.ts +7 -5
  55. package/dist/layout-engine/keep-together.js +20 -4
  56. package/dist/layout-engine/measure/cache.js +2 -0
  57. package/dist/layout-engine/measure/listMarkerWidth.d.ts +3 -1
  58. package/dist/layout-engine/measure/listMarkerWidth.js +23 -4
  59. package/dist/layout-engine/measure/measureBlocks.d.ts +3 -0
  60. package/dist/layout-engine/measure/measureBlocks.js +133 -51
  61. package/dist/layout-engine/measure/measureParagraph.js +42 -15
  62. package/dist/layout-engine/measure/tableCellGrid.d.ts +15 -0
  63. package/dist/layout-engine/measure/tableCellGrid.js +62 -0
  64. package/dist/layout-engine/measure/textBoxParagraphLayout.d.ts +18 -0
  65. package/dist/layout-engine/measure/textBoxParagraphLayout.js +32 -0
  66. package/dist/layout-engine/paginator.d.ts +2 -1
  67. package/dist/layout-engine/paginator.js +17 -8
  68. package/dist/layout-engine/paragraphFrame.d.ts +22 -0
  69. package/dist/layout-engine/paragraphFrame.js +17 -0
  70. package/dist/layout-engine/paragraphSequence.d.ts +7 -0
  71. package/dist/layout-engine/paragraphSequence.js +25 -0
  72. package/dist/layout-engine/paragraphSpacing.d.ts +17 -0
  73. package/dist/layout-engine/paragraphSpacing.js +30 -0
  74. package/dist/layout-engine/renderedBreakReconciliation.d.ts +58 -0
  75. package/dist/layout-engine/renderedBreakReconciliation.js +63 -0
  76. package/dist/layout-engine/types.d.ts +31 -10
  77. package/dist/layout-engine/types.js +15 -2
  78. package/dist/layout-painter/renderPage.js +2 -2
  79. package/dist/layout-painter/renderParagraph.js +94 -10
  80. package/dist/layout-painter/renderTable.js +42 -12
  81. package/dist/layout-painter/renderTextBox.js +17 -7
  82. package/dist/managers/DocumentLoaderManager.js +1 -1
  83. package/dist/paged-layout/sectionBlockWidths.d.ts +3 -0
  84. package/dist/paged-layout/sectionBlockWidths.js +9 -0
  85. package/dist/paged-layout/sectionGeometry.js +1 -1
  86. package/dist/prosemirror/attrs/index.js +11 -0
  87. package/dist/prosemirror/conversion/fromProseDoc.js +2 -0
  88. package/dist/prosemirror/conversion/toProseDoc.js +29 -23
  89. package/dist/prosemirror/extensions/core/ParagraphExtension.js +1 -0
  90. package/dist/prosemirror/extensions/features/ListExtension.js +1 -0
  91. package/dist/prosemirror/extensions/features/ParaIdAllocatorExtension.js +68 -16
  92. package/dist/prosemirror/extensions/nodes/TableExtension.js +1 -0
  93. package/dist/prosemirror/extensions/nodes/TextBoxExtension.d.ts +2 -1
  94. package/dist/prosemirror/extensions/nodes/TextBoxExtension.js +7 -0
  95. package/dist/prosemirror/schema/nodes.d.ts +6 -3
  96. package/dist/prosemirror/styles/resolvedStyleAttrs.js +1 -0
  97. package/dist/prosemirror/utils/tabCalculator.js +1 -1
  98. package/dist/server.d.ts +6 -2
  99. package/dist/server.js +5 -1
  100. package/dist/types/index.d.ts +2 -1
  101. package/dist/utils/createDocument.js +26 -18
  102. package/dist/utils/formatToStyle.js +3 -3
  103. package/dist/utils/hexId.d.ts +8 -1
  104. package/dist/utils/hexId.js +11 -3
  105. package/dist/utils/units.d.ts +6 -6
  106. package/dist/utils/units.js +8 -8
  107. package/package.json +1 -1
@@ -1,77 +1,167 @@
1
+ import { getFolioDocxCapability } from "./capabilities.js";
2
+ import { DOCX_CONFORMANCE_CLASSES } from "@stll/docx-core/model";
1
3
  //#region src/docx/compatibility.ts
2
- const COMPATIBLE_DOCX = {
3
- canSafelyEdit: true,
4
- reasons: [],
5
- unsupportedContentCount: 0
4
+ const resolveCompatibilityContext = (doc, options) => ({
5
+ host: options.host ?? "unknown",
6
+ profile: options.profile ?? doc.package.conformanceClass ?? DOCX_CONFORMANCE_CLASSES.UNKNOWN
7
+ });
8
+ const getCoverageState = (value, coveredValues) => {
9
+ if (value === "unknown") return "unknown";
10
+ return coveredValues.includes(value) ? "covered" : "unverified";
6
11
  };
7
- function inspectDocxCompatibility(doc) {
12
+ const inspectDocxCompatibility = (doc, options = {}) => {
13
+ const context = resolveCompatibilityContext(doc, options);
8
14
  const reasons = /* @__PURE__ */ new Set();
9
- let unsupportedContentCount = 0;
10
- const record = (reason) => {
11
- reasons.add(reason);
12
- unsupportedContentCount += 1;
15
+ const issues = [];
16
+ const record = (location) => {
17
+ const code = "opaqueDrawing";
18
+ const capability = getFolioDocxCapability(code);
19
+ reasons.add(code);
20
+ issues.push({
21
+ code,
22
+ capability,
23
+ coverage: {
24
+ host: getCoverageState(context.host, capability.hosts),
25
+ profile: getCoverageState(context.profile, capability.profiles)
26
+ },
27
+ location
28
+ });
13
29
  };
14
- inspectBlocks(doc.package.document.content, record);
15
- for (const header of doc.package.headers?.values() ?? []) inspectHeaderFooter(header, record);
16
- for (const footer of doc.package.footers?.values() ?? []) inspectHeaderFooter(footer, record);
17
- for (const footnote of doc.package.footnotes ?? []) inspectBlocks(footnote.content, record);
18
- for (const endnote of doc.package.endnotes ?? []) inspectBlocks(endnote.content, record);
19
- if (unsupportedContentCount === 0) return COMPATIBLE_DOCX;
30
+ inspectBlocks(doc.package.document.content, {
31
+ part: { type: "document" },
32
+ path: "package.document.content",
33
+ record
34
+ });
35
+ for (const [relationshipId, header] of doc.package.headers?.entries() ?? []) inspectHeaderFooter(header, {
36
+ part: {
37
+ type: "header",
38
+ relationshipId
39
+ },
40
+ path: `package.headers.get(${JSON.stringify(relationshipId)}).content`,
41
+ record
42
+ });
43
+ for (const [relationshipId, footer] of doc.package.footers?.entries() ?? []) inspectHeaderFooter(footer, {
44
+ part: {
45
+ type: "footer",
46
+ relationshipId
47
+ },
48
+ path: `package.footers.get(${JSON.stringify(relationshipId)}).content`,
49
+ record
50
+ });
51
+ for (const footnote of doc.package.footnotes ?? []) inspectBlocks(footnote.content, {
52
+ part: {
53
+ type: "footnote",
54
+ id: footnote.id
55
+ },
56
+ path: `package.footnotes[id=${footnote.id}].content`,
57
+ record
58
+ });
59
+ for (const endnote of doc.package.endnotes ?? []) inspectBlocks(endnote.content, {
60
+ part: {
61
+ type: "endnote",
62
+ id: endnote.id
63
+ },
64
+ path: `package.endnotes[id=${endnote.id}].content`,
65
+ record
66
+ });
20
67
  return {
21
- canSafelyEdit: false,
68
+ schemaVersion: 1,
69
+ context,
70
+ canSafelyEdit: issues.length === 0,
71
+ issues,
22
72
  reasons: Array.from(reasons),
23
- unsupportedContentCount
73
+ unsupportedContentCount: issues.length
24
74
  };
25
- }
26
- function inspectBlocks(blocks, record) {
27
- for (const block of blocks) {
75
+ };
76
+ function inspectBlocks(blocks, context) {
77
+ for (const [blockIndex, block] of blocks.entries()) {
78
+ const blockPath = `${context.path}[${blockIndex}]`;
28
79
  if (block.type === "paragraph") {
29
- inspectParagraphContent(block.content, record);
80
+ inspectParagraphContent(block.content, {
81
+ ...block.paraId === void 0 ? {} : { blockId: block.paraId },
82
+ part: context.part,
83
+ path: `${blockPath}.content`,
84
+ record: context.record
85
+ });
30
86
  continue;
31
87
  }
32
88
  if (block.type === "table") {
33
- for (const row of block.rows) for (const cell of row.cells) inspectBlocks(cell.content, record);
89
+ for (const [rowIndex, row] of block.rows.entries()) for (const [cellIndex, cell] of row.cells.entries()) inspectBlocks(cell.content, {
90
+ part: context.part,
91
+ path: `${blockPath}.rows[${rowIndex}].cells[${cellIndex}].content`,
92
+ record: context.record
93
+ });
34
94
  continue;
35
95
  }
36
- inspectBlocks(block.content, record);
96
+ inspectBlocks(block.content, {
97
+ part: context.part,
98
+ path: `${blockPath}.content`,
99
+ record: context.record
100
+ });
37
101
  }
38
102
  }
39
- function inspectHeaderFooter(headerFooter, record) {
40
- inspectBlocks(headerFooter.content, record);
103
+ function inspectHeaderFooter(headerFooter, context) {
104
+ inspectBlocks(headerFooter.content, context);
41
105
  }
42
- function inspectParagraphContent(content, record) {
43
- for (const item of content) {
106
+ function inspectParagraphContent(content, context) {
107
+ for (const [itemIndex, item] of content.entries()) {
108
+ const itemContext = {
109
+ ...context,
110
+ path: `${context.path}[${itemIndex}]`
111
+ };
44
112
  if (item.type === "run") {
45
- inspectRun(item, record);
113
+ inspectRun(item, itemContext);
46
114
  continue;
47
115
  }
48
116
  if (item.type === "hyperlink") {
49
- inspectHyperlink(item, record);
117
+ inspectHyperlink(item, itemContext);
50
118
  continue;
51
119
  }
52
120
  if (item.type === "inlineSdt") {
53
- inspectParagraphContent(item.content, record);
121
+ inspectParagraphContent(item.content, {
122
+ ...itemContext,
123
+ path: `${itemContext.path}.content`
124
+ });
54
125
  continue;
55
126
  }
56
127
  if (item.type === "insertion" || item.type === "deletion" || item.type === "moveFrom" || item.type === "moveTo") {
57
- inspectParagraphContent(item.content, record);
128
+ inspectParagraphContent(item.content, {
129
+ ...itemContext,
130
+ path: `${itemContext.path}.content`
131
+ });
58
132
  continue;
59
133
  }
60
134
  if (item.type === "simpleField") {
61
- inspectParagraphContent(item.content, record);
135
+ inspectParagraphContent(item.content, {
136
+ ...itemContext,
137
+ path: `${itemContext.path}.content`
138
+ });
62
139
  continue;
63
140
  }
64
141
  if (item.type === "complexField") {
65
- for (const run of item.fieldCode) inspectRun(run, record);
66
- for (const run of item.fieldResult) inspectRun(run, record);
142
+ for (const [runIndex, run] of item.fieldCode.entries()) inspectRun(run, {
143
+ ...itemContext,
144
+ path: `${itemContext.path}.fieldCode[${runIndex}]`
145
+ });
146
+ for (const [runIndex, run] of item.fieldResult.entries()) inspectRun(run, {
147
+ ...itemContext,
148
+ path: `${itemContext.path}.fieldResult[${runIndex}]`
149
+ });
67
150
  }
68
151
  }
69
152
  }
70
- function inspectHyperlink(hyperlink, record) {
71
- for (const child of hyperlink.children) if (child.type === "run") inspectRun(child, record);
153
+ function inspectHyperlink(hyperlink, context) {
154
+ for (const [childIndex, child] of hyperlink.children.entries()) if (child.type === "run") inspectRun(child, {
155
+ ...context,
156
+ path: `${context.path}.children[${childIndex}]`
157
+ });
72
158
  }
73
- function inspectRun(run, record) {
74
- for (const content of run.content) if (content.type === "drawing" && content.rawXml) record("opaqueDrawing");
159
+ function inspectRun(run, context) {
160
+ for (const [contentIndex, content] of run.content.entries()) if (content.type === "drawing" && content.rawXml) context.record({
161
+ ...context.blockId === void 0 ? {} : { blockId: context.blockId },
162
+ part: context.part,
163
+ path: `${context.path}.content[${contentIndex}]`
164
+ });
75
165
  }
76
166
  //#endregion
77
167
  export { inspectDocxCompatibility };
@@ -0,0 +1,6 @@
1
+ import { document_d_exports } from "../types/document.js";
2
+
3
+ //#region src/docx/conformance.d.ts
4
+ declare const detectDocxConformanceClass: (documentXml: string | null) => document_d_exports.DocxConformanceClass;
5
+ //#endregion
6
+ export { detectDocxConformanceClass };
@@ -0,0 +1,18 @@
1
+ import { getLocalName, getNamespacePrefix, parseXmlDocument } from "./xmlParser.js";
2
+ import { DOCX_CONFORMANCE_CLASSES } from "@stll/docx-core/model";
3
+ //#region src/docx/conformance.ts
4
+ const STRICT_MAIN_NAMESPACE = "http://purl.oclc.org/ooxml/wordprocessingml/main";
5
+ const TRANSITIONAL_MAIN_NAMESPACE = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
6
+ const detectDocxConformanceClass = (documentXml) => {
7
+ if (documentXml === null) return DOCX_CONFORMANCE_CLASSES.UNKNOWN;
8
+ const root = parseXmlDocument(documentXml);
9
+ if (root === null || getLocalName(root.name ?? "") !== "document") return DOCX_CONFORMANCE_CLASSES.UNKNOWN;
10
+ const prefix = getNamespacePrefix(root.name ?? "");
11
+ const namespaceAttribute = prefix === null ? "xmlns" : `xmlns:${prefix}`;
12
+ const namespace = root.attributes?.[namespaceAttribute];
13
+ if (namespace === STRICT_MAIN_NAMESPACE) return DOCX_CONFORMANCE_CLASSES.STRICT;
14
+ if (namespace === TRANSITIONAL_MAIN_NAMESPACE) return DOCX_CONFORMANCE_CLASSES.TRANSITIONAL;
15
+ return DOCX_CONFORMANCE_CLASSES.UNKNOWN;
16
+ };
17
+ //#endregion
18
+ export { detectDocxConformanceClass };
@@ -0,0 +1,29 @@
1
+ //#region src/docx/ensureParaIds.d.ts
2
+ declare const EnsureParaIdsError_base: import("better-result").TaggedErrorClass<"EnsureParaIdsError", {
3
+ message: string;
4
+ cause?: unknown;
5
+ }>;
6
+ /** A malformed or unsupported package prevented paragraph-ID normalization. */
7
+ declare class EnsureParaIdsError extends EnsureParaIdsError_base {}
8
+ /** Counts and normalized bytes returned by {@link ensureParaIds}. */
9
+ type EnsureParaIdsResult = {
10
+ /** The normalized `.docx`; the input bytes verbatim when `alreadyComplete`. */docx: Uint8Array; /** Paragraphs that received a paraId (missing or all-zero before). */
11
+ assigned: number; /** Duplicate paraIds reassigned (the first occurrence keeps the id). */
12
+ deduplicated: number; /** True when the input already had full, unique coverage. */
13
+ alreadyComplete: boolean;
14
+ };
15
+ /** Controls mutation of package metadata that has security implications. */
16
+ type EnsureParaIdsOptions = {
17
+ /**
18
+ * Allow normalization to invalidate existing OPC digital signatures.
19
+ * Callers must warn the user before opting in.
20
+ */
21
+ allowSignedPackageMutation?: boolean;
22
+ };
23
+ /**
24
+ * Backfill `w14:paraId` on every paragraph of a `.docx` buffer. All package,
25
+ * XML, and compression failures are surfaced as {@link EnsureParaIdsError}.
26
+ */
27
+ declare const ensureParaIds: (docx: Uint8Array | ArrayBuffer, options?: EnsureParaIdsOptions) => Promise<EnsureParaIdsResult>;
28
+ //#endregion
29
+ export { EnsureParaIdsError, EnsureParaIdsOptions, EnsureParaIdsResult, ensureParaIds };
@@ -0,0 +1,423 @@
1
+ import { isXmlNameBoundary } from "./selectiveXmlPatch.js";
2
+ import { deterministicHexId } from "../utils/hexId.js";
3
+ import { TaggedError } from "better-result";
4
+ import JSZip from "jszip";
5
+ //#region src/docx/ensureParaIds.ts
6
+ /**
7
+ * Headless `w14:paraId` normalization for a `.docx` buffer.
8
+ *
9
+ * Word 2010+ stamps every paragraph with a `w14:paraId`, but Google Docs
10
+ * exports, LibreOffice, python-docx, and docx4j generally do not. Paragraphs
11
+ * without one are invisible to everything that anchors on paraId (block ids,
12
+ * comment threads, AI-edit targeting) and fall back to positional `seq-NNNN`
13
+ * ids that renumber after structural edits. Hosts call {@link ensureParaIds}
14
+ * once at ingest so every stored version has full id coverage before any
15
+ * snapshot or external anchor is created.
16
+ *
17
+ * IDs remain stable in folio and identity-preserving DOCX round-trips.
18
+ * Microsoft Word can establish a new `w14:docId` and replace all paragraph
19
+ * IDs on its first save of a non-Word-produced package; callers that ingest
20
+ * such an externally edited version must normalize it again and cannot assume
21
+ * the pre-save IDs still match.
22
+ *
23
+ * The pass patches part XML in place (string splices, no model round-trip),
24
+ * so documents carrying features folio's parser does not model come back
25
+ * with those features byte-identical. Contract:
26
+ *
27
+ * - Parts covered: `word/document.xml`, `word/header*.xml`, `word/footer*.xml`,
28
+ * `word/footnotes.xml`, `word/endnotes.xml`. Paragraphs nested in table
29
+ * cells and in `mc:Choice` text boxes are plain `<w:p>` elements inside
30
+ * those parts and are covered by the same scan. The comments part mints its
31
+ * own deterministic paraIds at save time (see `commentSerializer`) and is
32
+ * left untouched; its ids still count toward uniqueness.
33
+ * - Paragraphs inside `mc:Fallback` are never modified: Word regenerates the
34
+ * fallback branch (duplicating the `mc:Choice` ids) on save, so stamping or
35
+ * deduplicating there would churn on every Word round-trip.
36
+ * - Existing ids are preserved. Only three cases get a fresh id: a missing
37
+ * `paraId`, the reserved all-zero value (Word reads `00000000` as "no id"),
38
+ * and a duplicate of an id already seen earlier in the scan (first
39
+ * occurrence keeps it — the same rule `ParaIdAllocatorExtension` applies in
40
+ * the editor). `w14:textId` is written alongside a newly minted `paraId`
41
+ * (same value) and never touched otherwise; it is a text-revision marker,
42
+ * not identity.
43
+ * - Fresh ids are deterministic (`deterministicHexId` over document content,
44
+ * part path, and paragraph ordinal), so the pass is a pure function of the
45
+ * input bytes: retrying an ingest produces identical output.
46
+ * - Each patched part's root element gets `xmlns:w14` / `xmlns:mc`
47
+ * declarations and a `mc:Ignorable` listing `w14` when missing — non-Word
48
+ * producers declare neither, and absent `mc:Ignorable` handling is what
49
+ * makes pre-2010 consumers choke on the new attributes.
50
+ * - Idempotent: a document that already has full coverage is returned as the
51
+ * original bytes, untouched (`alreadyComplete: true`).
52
+ * - Digitally signed packages are returned untouched when already complete.
53
+ * When normalization would rewrite the package, it fails unless the caller
54
+ * explicitly allows signature invalidation after warning the user.
55
+ */
56
+ /** A malformed or unsupported package prevented paragraph-ID normalization. */
57
+ var EnsureParaIdsError = class extends TaggedError("EnsureParaIdsError")() {};
58
+ const W14_NAMESPACE_URI = "http://schemas.microsoft.com/office/word/2010/wordml";
59
+ const MC_NAMESPACE_URI = "http://schemas.openxmlformats.org/markup-compatibility/2006";
60
+ const MC_IGNORABLE_W14 = "w14";
61
+ const DIGITAL_SIGNATURE_PART_PREFIX = "_xmlsignatures/";
62
+ /** Word reads an all-zero `w14:paraId` as "no id assigned". */
63
+ const RESERVED_ZERO_ID_PATTERN = /^0*$/u;
64
+ const DOCUMENT_PART = "word/document.xml";
65
+ const TARGET_PART_PATTERNS = [
66
+ /^word\/document\.xml$/u,
67
+ /^word\/header\d*\.xml$/u,
68
+ /^word\/footer\d*\.xml$/u,
69
+ /^word\/footnotes\.xml$/u,
70
+ /^word\/endnotes\.xml$/u
71
+ ];
72
+ const PARAGRAPH_OPEN = "<w:p";
73
+ const FALLBACK_OPEN = "<mc:Fallback";
74
+ const FALLBACK_CLOSE = "</mc:Fallback>";
75
+ const OPAQUE_XML_REGIONS = [
76
+ {
77
+ open: "<!--",
78
+ close: "-->"
79
+ },
80
+ {
81
+ open: "<![CDATA[",
82
+ close: "]]>"
83
+ },
84
+ {
85
+ open: "<?",
86
+ close: "?>"
87
+ }
88
+ ];
89
+ /**
90
+ * The parser accepts the `w:` fallback prefix for paraId, and comment parts
91
+ * link replies via `w15:paraId` — all three count toward uniqueness.
92
+ */
93
+ const ANY_PARA_ID_PATTERN = /\bw(?:14|15)?:paraId=(?<quote>["'])(?<id>[\s\S]*?)\k<quote>/gu;
94
+ const OPEN_TAG_PARA_ID_PATTERN = /\sw(?:14)?:paraId=(?<quote>["'])(?<id>[\s\S]*?)\k<quote>/u;
95
+ const OPEN_TAG_TEXT_ID_PATTERN = /\sw(?:14)?:textId=(?<quote>["'])(?<id>[\s\S]*?)\k<quote>/u;
96
+ const XMLNS_W14_PATTERN = /\sxmlns:w14=(?<quote>["'])(?<value>[\s\S]*?)\k<quote>/u;
97
+ const XMLNS_MC_PATTERN = /\sxmlns:mc=(?<quote>["'])(?<value>[\s\S]*?)\k<quote>/u;
98
+ const MC_IGNORABLE_PATTERN = /\smc:Ignorable=(?<quote>["'])(?<value>[\s\S]*?)\k<quote>/u;
99
+ const applySplices = (xml, edits) => {
100
+ const ordered = [...edits].sort((a, b) => b.start - a.start);
101
+ let result = xml;
102
+ for (const { start, end, text } of ordered) result = result.slice(0, start) + text + result.slice(end);
103
+ return result;
104
+ };
105
+ const createEnsureParaIdsError = (message, cause) => new EnsureParaIdsError({
106
+ message,
107
+ ...cause === void 0 ? {} : { cause }
108
+ });
109
+ const skipOpaqueXmlRegion = (xml, start, partPath) => {
110
+ for (const { open, close } of OPAQUE_XML_REGIONS) {
111
+ if (!xml.startsWith(open, start)) continue;
112
+ const closeStart = xml.indexOf(close, start + open.length);
113
+ if (closeStart === -1) throw createEnsureParaIdsError(`Unterminated ${open} region in ${partPath}`);
114
+ return closeStart + close.length;
115
+ }
116
+ return null;
117
+ };
118
+ const attributeValueRange = (match, absoluteOffset, group) => {
119
+ const quote = match.groups["quote"];
120
+ const value = match.groups[group];
121
+ const start = absoluteOffset + match.index + match[0].indexOf(quote) + 1;
122
+ return {
123
+ start,
124
+ end: start + value.length
125
+ };
126
+ };
127
+ const collectExistingParaIds = (xml, into) => {
128
+ for (const match of xml.matchAll(ANY_PARA_ID_PATTERN)) {
129
+ const id = match.groups["id"];
130
+ if (id.length > 0) into.add(id.toUpperCase());
131
+ }
132
+ };
133
+ const collectFallbackParaIds = (xml, partPath, into) => {
134
+ let fallbackDepth = 0;
135
+ let pos = 0;
136
+ while (pos < xml.length) {
137
+ const tagStart = xml.indexOf("<", pos);
138
+ if (tagStart === -1) return;
139
+ const opaqueEnd = skipOpaqueXmlRegion(xml, tagStart, partPath);
140
+ if (opaqueEnd !== null) {
141
+ pos = opaqueEnd;
142
+ continue;
143
+ }
144
+ if (xml.startsWith(FALLBACK_OPEN, tagStart) && isXmlNameBoundary(xml[tagStart + 12])) {
145
+ const tagEnd = xml.indexOf(">", tagStart);
146
+ if (tagEnd === -1) throw createEnsureParaIdsError(`Unterminated <mc:Fallback> tag in ${partPath}`);
147
+ if (xml[tagEnd - 1] !== "/") fallbackDepth += 1;
148
+ pos = tagEnd + 1;
149
+ continue;
150
+ }
151
+ if (xml.startsWith(FALLBACK_CLOSE, tagStart)) {
152
+ fallbackDepth = Math.max(0, fallbackDepth - 1);
153
+ pos = tagStart + 14;
154
+ continue;
155
+ }
156
+ if (fallbackDepth === 0 || !xml.startsWith(PARAGRAPH_OPEN, tagStart) || !isXmlNameBoundary(xml[tagStart + 4])) {
157
+ pos = tagStart + 1;
158
+ continue;
159
+ }
160
+ const tagEnd = xml.indexOf(">", tagStart);
161
+ if (tagEnd === -1) throw createEnsureParaIdsError(`Unterminated <w:p> tag in ${partPath}`);
162
+ const id = OPEN_TAG_PARA_ID_PATTERN.exec(xml.slice(tagStart, tagEnd + 1))?.groups?.["id"];
163
+ if (id) into.add(id.toUpperCase());
164
+ pos = tagEnd + 1;
165
+ }
166
+ };
167
+ /**
168
+ * Deterministic fresh id for the paragraph at `ordinal` in `partPath`,
169
+ * salted past collisions with any id anywhere in the document.
170
+ */
171
+ const mintParaId = (context, partPath, ordinal) => {
172
+ const seed = `${context.docKey}:${partPath}:${ordinal}`;
173
+ let id = deterministicHexId(seed);
174
+ for (let salt = 1; context.taken.has(id); salt += 1) id = deterministicHexId(`${seed}:${salt}`);
175
+ context.taken.add(id);
176
+ return id;
177
+ };
178
+ /**
179
+ * One forward scan over a part: every `<w:p>` open tag outside `mc:Fallback`
180
+ * either keeps its paraId (valid, first occurrence) or gets a splice edit
181
+ * minting / replacing one. `seen` spans parts so the first-occurrence rule is
182
+ * document-wide across the scan order.
183
+ */
184
+ const scanPart = (xml, partPath, context, seen) => {
185
+ const edits = [];
186
+ let assigned = 0;
187
+ let deduplicated = 0;
188
+ let ordinal = 0;
189
+ let fallbackDepth = 0;
190
+ let pos = 0;
191
+ while (pos < xml.length) {
192
+ const tagStart = xml.indexOf("<", pos);
193
+ if (tagStart === -1) break;
194
+ const opaqueEnd = skipOpaqueXmlRegion(xml, tagStart, partPath);
195
+ if (opaqueEnd !== null) {
196
+ pos = opaqueEnd;
197
+ continue;
198
+ }
199
+ if (xml.startsWith(FALLBACK_OPEN, tagStart) && isXmlNameBoundary(xml[tagStart + 12])) {
200
+ const tagEnd = xml.indexOf(">", tagStart);
201
+ if (tagEnd === -1) throw createEnsureParaIdsError(`Unterminated <mc:Fallback> tag in ${partPath}`);
202
+ if (xml[tagEnd - 1] !== "/") fallbackDepth += 1;
203
+ pos = tagEnd + 1;
204
+ continue;
205
+ }
206
+ if (xml.startsWith(FALLBACK_CLOSE, tagStart)) {
207
+ fallbackDepth = Math.max(0, fallbackDepth - 1);
208
+ pos = tagStart + 14;
209
+ continue;
210
+ }
211
+ if (!xml.startsWith(PARAGRAPH_OPEN, tagStart) || !isXmlNameBoundary(xml[tagStart + 4])) {
212
+ pos = tagStart + 1;
213
+ continue;
214
+ }
215
+ const tagEnd = xml.indexOf(">", tagStart);
216
+ if (tagEnd === -1) throw createEnsureParaIdsError(`Unterminated <w:p> tag in ${partPath}`);
217
+ pos = tagEnd + 1;
218
+ if (fallbackDepth > 0) continue;
219
+ ordinal += 1;
220
+ const openTag = xml.slice(tagStart, tagEnd + 1);
221
+ const paraIdMatch = OPEN_TAG_PARA_ID_PATTERN.exec(openTag);
222
+ if (!paraIdMatch) {
223
+ const id = mintParaId(context, partPath, ordinal);
224
+ const insertAt = tagStart + 4;
225
+ const textIdMatch = OPEN_TAG_TEXT_ID_PATTERN.exec(openTag);
226
+ if (textIdMatch) {
227
+ edits.push({
228
+ start: insertAt,
229
+ end: insertAt,
230
+ text: ` w14:paraId="${id}"`
231
+ });
232
+ const range = attributeValueRange(textIdMatch, tagStart, "id");
233
+ edits.push({
234
+ ...range,
235
+ text: id
236
+ });
237
+ } else edits.push({
238
+ start: insertAt,
239
+ end: insertAt,
240
+ text: ` w14:paraId="${id}" w14:textId="${id}"`
241
+ });
242
+ assigned += 1;
243
+ seen.add(id);
244
+ continue;
245
+ }
246
+ const value = paraIdMatch.groups["id"].toUpperCase();
247
+ const unassigned = RESERVED_ZERO_ID_PATTERN.test(value);
248
+ if (!unassigned && !seen.has(value)) {
249
+ seen.add(value);
250
+ continue;
251
+ }
252
+ const id = mintParaId(context, partPath, ordinal);
253
+ edits.push({
254
+ ...attributeValueRange(paraIdMatch, tagStart, "id"),
255
+ text: id
256
+ });
257
+ const textIdMatch = OPEN_TAG_TEXT_ID_PATTERN.exec(openTag);
258
+ if (textIdMatch) edits.push({
259
+ ...attributeValueRange(textIdMatch, tagStart, "id"),
260
+ text: id
261
+ });
262
+ else {
263
+ const insertAt = tagStart + 4;
264
+ edits.push({
265
+ start: insertAt,
266
+ end: insertAt,
267
+ text: ` w14:textId="${id}"`
268
+ });
269
+ }
270
+ if (unassigned) assigned += 1;
271
+ else deduplicated += 1;
272
+ seen.add(id);
273
+ }
274
+ return {
275
+ edits,
276
+ assigned,
277
+ deduplicated
278
+ };
279
+ };
280
+ /**
281
+ * Splice edits ensuring the part's root element declares `xmlns:w14` /
282
+ * `xmlns:mc` and lists `w14` in `mc:Ignorable`, so consumers that predate the
283
+ * 2010 extensions skip the new attributes instead of rejecting the part.
284
+ */
285
+ const ensureRootNamespaces = (xml, partPath) => {
286
+ let pos = 0;
287
+ let rootStart = -1;
288
+ while (pos < xml.length) {
289
+ const lt = xml.indexOf("<", pos);
290
+ if (lt === -1) break;
291
+ const opaqueEnd = skipOpaqueXmlRegion(xml, lt, partPath);
292
+ if (opaqueEnd !== null) {
293
+ pos = opaqueEnd;
294
+ continue;
295
+ }
296
+ if (xml[lt + 1] === "!") {
297
+ const skipTo = xml.indexOf(">", lt);
298
+ if (skipTo === -1) throw createEnsureParaIdsError(`Unterminated prolog in ${partPath}`);
299
+ pos = skipTo + 1;
300
+ continue;
301
+ }
302
+ rootStart = lt;
303
+ break;
304
+ }
305
+ if (rootStart === -1) throw createEnsureParaIdsError(`No root element in ${partPath}`);
306
+ const rootEnd = xml.indexOf(">", rootStart);
307
+ if (rootEnd === -1) throw createEnsureParaIdsError(`Unterminated root element in ${partPath}`);
308
+ const rootTag = xml.slice(rootStart, rootEnd + 1);
309
+ const edits = [];
310
+ const declarations = [];
311
+ const mcNamespace = XMLNS_MC_PATTERN.exec(rootTag);
312
+ if (mcNamespace) {
313
+ if (mcNamespace.groups["value"] !== MC_NAMESPACE_URI) throw createEnsureParaIdsError(`xmlns:mc has an unexpected namespace URI in ${partPath}`);
314
+ } else declarations.push(` xmlns:mc="${MC_NAMESPACE_URI}"`);
315
+ const w14Namespace = XMLNS_W14_PATTERN.exec(rootTag);
316
+ if (w14Namespace) {
317
+ if (w14Namespace.groups["value"] !== W14_NAMESPACE_URI) throw createEnsureParaIdsError(`xmlns:w14 has an unexpected namespace URI in ${partPath}`);
318
+ } else declarations.push(` xmlns:w14="${W14_NAMESPACE_URI}"`);
319
+ const ignorable = MC_IGNORABLE_PATTERN.exec(rootTag);
320
+ if (ignorable) {
321
+ const tokens = ignorable.groups["value"].split(/\s+/u).filter((token) => token.length > 0);
322
+ if (!tokens.includes(MC_IGNORABLE_W14)) {
323
+ const { end } = attributeValueRange(ignorable, rootStart, "value");
324
+ const text = tokens.length === 0 ? MC_IGNORABLE_W14 : ` ${MC_IGNORABLE_W14}`;
325
+ edits.push({
326
+ start: end,
327
+ end,
328
+ text
329
+ });
330
+ }
331
+ } else declarations.push(` mc:Ignorable="${MC_IGNORABLE_W14}"`);
332
+ if (declarations.length > 0) {
333
+ let nameEnd = rootStart + 1;
334
+ while (nameEnd < xml.length && !isXmlNameBoundary(xml[nameEnd])) nameEnd += 1;
335
+ edits.push({
336
+ start: nameEnd,
337
+ end: nameEnd,
338
+ text: declarations.join("")
339
+ });
340
+ }
341
+ return edits;
342
+ };
343
+ /** OPC part names are case-insensitive; compare lowercased. */
344
+ const isTargetPart = (path) => TARGET_PART_PATTERNS.some((pattern) => pattern.test(path.toLowerCase()));
345
+ const toUint8Array = (docx) => docx instanceof Uint8Array ? docx : new Uint8Array(docx);
346
+ const hasDigitalSignatureParts = (zip) => Object.values(zip.files).some(({ dir, name }) => !dir && name.toLowerCase().startsWith(DIGITAL_SIGNATURE_PART_PREFIX));
347
+ /**
348
+ * Backfill `w14:paraId` on every paragraph of a `.docx` buffer. See the
349
+ * module doc for the exact contract. Throws {@link EnsureParaIdsError} when
350
+ * the buffer is not a WordprocessingML package or a part is malformed.
351
+ */
352
+ const ensureParaIdsInternal = async (docx, options) => {
353
+ const zip = await JSZip.loadAsync(docx);
354
+ const xmlPartNames = [];
355
+ zip.forEach((relativePath, entry) => {
356
+ const lower = relativePath.toLowerCase();
357
+ if (!entry.dir && lower.startsWith("word/") && lower.endsWith(".xml")) xmlPartNames.push(relativePath);
358
+ });
359
+ const documentPartName = xmlPartNames.find((name) => name.toLowerCase() === DOCUMENT_PART);
360
+ if (documentPartName === void 0) throw createEnsureParaIdsError("word/document.xml not found: not a WordprocessingML package");
361
+ const partTexts = /* @__PURE__ */ new Map();
362
+ for (const name of xmlPartNames) {
363
+ const entry = zip.file(name);
364
+ if (entry) partTexts.set(name, await entry.async("text"));
365
+ }
366
+ const taken = /* @__PURE__ */ new Set();
367
+ for (const text of partTexts.values()) collectExistingParaIds(text, taken);
368
+ const context = {
369
+ taken,
370
+ docKey: deterministicHexId(partTexts.get(documentPartName))
371
+ };
372
+ const targetParts = [documentPartName, ...xmlPartNames.filter((name) => name !== documentPartName && isTargetPart(name)).sort((a, b) => a.localeCompare(b))];
373
+ const seen = /* @__PURE__ */ new Set();
374
+ for (const [partPath, xml] of partTexts) if (isTargetPart(partPath)) collectFallbackParaIds(xml, partPath, seen);
375
+ else collectExistingParaIds(xml, seen);
376
+ const updates = /* @__PURE__ */ new Map();
377
+ let assigned = 0;
378
+ let deduplicated = 0;
379
+ for (const partPath of targetParts) {
380
+ const xml = partTexts.get(partPath);
381
+ const scan = scanPart(xml, partPath, context, seen);
382
+ if (scan.edits.length === 0) continue;
383
+ assigned += scan.assigned;
384
+ deduplicated += scan.deduplicated;
385
+ updates.set(partPath, applySplices(xml, [...scan.edits, ...ensureRootNamespaces(xml, partPath)]));
386
+ }
387
+ if (updates.size === 0) return {
388
+ docx: toUint8Array(docx),
389
+ assigned: 0,
390
+ deduplicated: 0,
391
+ alreadyComplete: true
392
+ };
393
+ if (hasDigitalSignatureParts(zip) && options.allowSignedPackageMutation !== true) throw createEnsureParaIdsError("Refusing to normalize a digitally signed package because rewriting OOXML invalidates its signatures. Warn the user and pass allowSignedPackageMutation only if invalidation is acceptable.");
394
+ for (const [partPath, content] of updates) zip.file(partPath, content, {
395
+ compression: "DEFLATE",
396
+ compressionOptions: { level: 6 }
397
+ });
398
+ return {
399
+ docx: await zip.generateAsync({
400
+ type: "uint8array",
401
+ compression: "DEFLATE",
402
+ compressionOptions: { level: 6 }
403
+ }),
404
+ assigned,
405
+ deduplicated,
406
+ alreadyComplete: false
407
+ };
408
+ };
409
+ /**
410
+ * Backfill `w14:paraId` on every paragraph of a `.docx` buffer. All package,
411
+ * XML, and compression failures are surfaced as {@link EnsureParaIdsError}.
412
+ */
413
+ const ensureParaIds = async (docx, options = {}) => {
414
+ try {
415
+ return await ensureParaIdsInternal(docx, options);
416
+ } catch (error) {
417
+ if (error instanceof EnsureParaIdsError) throw error;
418
+ const message = error instanceof Error ? error.message : String(error);
419
+ throw createEnsureParaIdsError(`Failed to normalize paragraph IDs: ${message}`, error);
420
+ }
421
+ };
422
+ //#endregion
423
+ export { EnsureParaIdsError, ensureParaIds };