@usejunior/docx-core 0.19.1 → 0.21.2

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 (180) hide show
  1. package/README.md +12 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/cli/conformance-adapter.d.ts.map +1 -1
  4. package/dist/cli/conformance-adapter.js +0 -27
  5. package/dist/cli/conformance-adapter.js.map +1 -1
  6. package/dist/core-types.d.ts +1 -139
  7. package/dist/core-types.d.ts.map +1 -1
  8. package/dist/core-types.js +1 -3
  9. package/dist/core-types.js.map +1 -1
  10. package/dist/generation/compile.d.ts.map +1 -1
  11. package/dist/generation/compile.js +1 -3
  12. package/dist/generation/compile.js.map +1 -1
  13. package/dist/index.d.ts +5 -1
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +13 -1
  16. package/dist/index.js.map +1 -1
  17. package/dist/integration/generation-probes.d.ts +36 -2
  18. package/dist/integration/generation-probes.d.ts.map +1 -1
  19. package/dist/integration/generation-probes.js +137 -15
  20. package/dist/integration/generation-probes.js.map +1 -1
  21. package/dist/integration/libreoffice-oracle.d.ts +3 -1
  22. package/dist/integration/libreoffice-oracle.d.ts.map +1 -1
  23. package/dist/integration/libreoffice-oracle.js +10 -4
  24. package/dist/integration/libreoffice-oracle.js.map +1 -1
  25. package/dist/integration/synthetic-docx-fixture.d.ts.map +1 -1
  26. package/dist/integration/synthetic-docx-fixture.js +8 -0
  27. package/dist/integration/synthetic-docx-fixture.js.map +1 -1
  28. package/dist/primitives/accept_ai_edits.js +14 -2
  29. package/dist/primitives/accept_ai_edits.js.map +1 -1
  30. package/dist/primitives/accept_changes.d.ts +6 -0
  31. package/dist/primitives/accept_changes.d.ts.map +1 -1
  32. package/dist/primitives/accept_changes.js +236 -62
  33. package/dist/primitives/accept_changes.js.map +1 -1
  34. package/dist/primitives/bookmarks.d.ts +8 -2
  35. package/dist/primitives/bookmarks.d.ts.map +1 -1
  36. package/dist/primitives/bookmarks.js +31 -7
  37. package/dist/primitives/bookmarks.js.map +1 -1
  38. package/dist/primitives/comments.d.ts +101 -5
  39. package/dist/primitives/comments.d.ts.map +1 -1
  40. package/dist/primitives/comments.js +356 -156
  41. package/dist/primitives/comments.js.map +1 -1
  42. package/dist/primitives/conformance.d.ts +42 -0
  43. package/dist/primitives/conformance.d.ts.map +1 -0
  44. package/dist/primitives/conformance.js +61 -0
  45. package/dist/primitives/conformance.js.map +1 -0
  46. package/dist/primitives/document.d.ts +145 -2
  47. package/dist/primitives/document.d.ts.map +1 -1
  48. package/dist/primitives/document.js +505 -27
  49. package/dist/primitives/document.js.map +1 -1
  50. package/dist/primitives/document_view-headings.d.ts +3 -1
  51. package/dist/primitives/document_view-headings.d.ts.map +1 -1
  52. package/dist/primitives/document_view-headings.js +5 -5
  53. package/dist/primitives/document_view-headings.js.map +1 -1
  54. package/dist/primitives/document_view.d.ts +2 -0
  55. package/dist/primitives/document_view.d.ts.map +1 -1
  56. package/dist/primitives/document_view.js +18 -4
  57. package/dist/primitives/document_view.js.map +1 -1
  58. package/dist/primitives/errors.d.ts +3 -2
  59. package/dist/primitives/errors.d.ts.map +1 -1
  60. package/dist/primitives/errors.js +3 -1
  61. package/dist/primitives/errors.js.map +1 -1
  62. package/dist/primitives/extract_revisions.d.ts +23 -6
  63. package/dist/primitives/extract_revisions.d.ts.map +1 -1
  64. package/dist/primitives/extract_revisions.js +208 -38
  65. package/dist/primitives/extract_revisions.js.map +1 -1
  66. package/dist/primitives/field_evaluation.d.ts +70 -0
  67. package/dist/primitives/field_evaluation.d.ts.map +1 -0
  68. package/dist/primitives/field_evaluation.js +462 -0
  69. package/dist/primitives/field_evaluation.js.map +1 -0
  70. package/dist/primitives/footnotes.d.ts +53 -3
  71. package/dist/primitives/footnotes.d.ts.map +1 -1
  72. package/dist/primitives/footnotes.js +228 -83
  73. package/dist/primitives/footnotes.js.map +1 -1
  74. package/dist/primitives/index.d.ts +12 -1
  75. package/dist/primitives/index.d.ts.map +1 -1
  76. package/dist/primitives/index.js +12 -1
  77. package/dist/primitives/index.js.map +1 -1
  78. package/dist/primitives/merge_runs.d.ts +3 -1
  79. package/dist/primitives/merge_runs.d.ts.map +1 -1
  80. package/dist/primitives/merge_runs.js +12 -2
  81. package/dist/primitives/merge_runs.js.map +1 -1
  82. package/dist/primitives/namespaces.d.ts +10 -2
  83. package/dist/primitives/namespaces.d.ts.map +1 -1
  84. package/dist/primitives/namespaces.js +11 -2
  85. package/dist/primitives/namespaces.js.map +1 -1
  86. package/dist/primitives/note_conversion.d.ts +11 -0
  87. package/dist/primitives/note_conversion.d.ts.map +1 -0
  88. package/dist/primitives/note_conversion.js +12 -0
  89. package/dist/primitives/note_conversion.js.map +1 -0
  90. package/dist/primitives/paragraph-index.d.ts +31 -0
  91. package/dist/primitives/paragraph-index.d.ts.map +1 -0
  92. package/dist/primitives/paragraph-index.js +140 -0
  93. package/dist/primitives/paragraph-index.js.map +1 -0
  94. package/dist/primitives/paragraph_merge_formatting.d.ts +24 -0
  95. package/dist/primitives/paragraph_merge_formatting.d.ts.map +1 -0
  96. package/dist/primitives/paragraph_merge_formatting.js +86 -0
  97. package/dist/primitives/paragraph_merge_formatting.js.map +1 -0
  98. package/dist/primitives/paragraph_numbering.d.ts +40 -0
  99. package/dist/primitives/paragraph_numbering.d.ts.map +1 -0
  100. package/dist/primitives/paragraph_numbering.js +198 -0
  101. package/dist/primitives/paragraph_numbering.js.map +1 -0
  102. package/dist/primitives/paragraph_structure.d.ts +19 -0
  103. package/dist/primitives/paragraph_structure.d.ts.map +1 -0
  104. package/dist/primitives/paragraph_structure.js +66 -0
  105. package/dist/primitives/paragraph_structure.js.map +1 -0
  106. package/dist/primitives/reject_changes.d.ts +6 -0
  107. package/dist/primitives/reject_changes.d.ts.map +1 -1
  108. package/dist/primitives/reject_changes.js +278 -68
  109. package/dist/primitives/reject_changes.js.map +1 -1
  110. package/dist/primitives/relationships.d.ts +22 -0
  111. package/dist/primitives/relationships.d.ts.map +1 -1
  112. package/dist/primitives/relationships.js +87 -0
  113. package/dist/primitives/relationships.js.map +1 -1
  114. package/dist/primitives/revision-parts.d.ts +15 -0
  115. package/dist/primitives/revision-parts.d.ts.map +1 -1
  116. package/dist/primitives/revision-parts.js +33 -1
  117. package/dist/primitives/revision-parts.js.map +1 -1
  118. package/dist/primitives/sectPrAudit.d.ts.map +1 -1
  119. package/dist/primitives/sectPrAudit.js +10 -1
  120. package/dist/primitives/sectPrAudit.js.map +1 -1
  121. package/dist/primitives/sections.d.ts +135 -0
  122. package/dist/primitives/sections.d.ts.map +1 -0
  123. package/dist/primitives/sections.js +644 -0
  124. package/dist/primitives/sections.js.map +1 -0
  125. package/dist/primitives/styles.d.ts +61 -0
  126. package/dist/primitives/styles.d.ts.map +1 -1
  127. package/dist/primitives/styles.js +207 -25
  128. package/dist/primitives/styles.js.map +1 -1
  129. package/dist/primitives/symbol_run_content.d.ts +42 -0
  130. package/dist/primitives/symbol_run_content.d.ts.map +1 -0
  131. package/dist/primitives/symbol_run_content.js +79 -0
  132. package/dist/primitives/symbol_run_content.js.map +1 -0
  133. package/dist/primitives/table_cells.d.ts +49 -0
  134. package/dist/primitives/table_cells.d.ts.map +1 -0
  135. package/dist/primitives/table_cells.js +149 -0
  136. package/dist/primitives/table_cells.js.map +1 -0
  137. package/dist/primitives/table_columns.d.ts +50 -0
  138. package/dist/primitives/table_columns.d.ts.map +1 -0
  139. package/dist/primitives/table_columns.js +138 -0
  140. package/dist/primitives/table_columns.js.map +1 -0
  141. package/dist/primitives/table_edit_common.d.ts +39 -0
  142. package/dist/primitives/table_edit_common.d.ts.map +1 -0
  143. package/dist/primitives/table_edit_common.js +163 -0
  144. package/dist/primitives/table_edit_common.js.map +1 -0
  145. package/dist/primitives/table_occupancy.d.ts +39 -0
  146. package/dist/primitives/table_occupancy.d.ts.map +1 -0
  147. package/dist/primitives/table_occupancy.js +145 -0
  148. package/dist/primitives/table_occupancy.js.map +1 -0
  149. package/dist/primitives/table_rows.d.ts +56 -0
  150. package/dist/primitives/table_rows.d.ts.map +1 -0
  151. package/dist/primitives/table_rows.js +390 -0
  152. package/dist/primitives/table_rows.js.map +1 -0
  153. package/dist/primitives/text.d.ts +69 -1
  154. package/dist/primitives/text.d.ts.map +1 -1
  155. package/dist/primitives/text.js +592 -102
  156. package/dist/primitives/text.js.map +1 -1
  157. package/dist/primitives/track-changes-emitter.d.ts +23 -0
  158. package/dist/primitives/track-changes-emitter.d.ts.map +1 -1
  159. package/dist/primitives/track-changes-emitter.js +64 -1
  160. package/dist/primitives/track-changes-emitter.js.map +1 -1
  161. package/dist/primitives/validate_document.d.ts.map +1 -1
  162. package/dist/primitives/validate_document.js +22 -1
  163. package/dist/primitives/validate_document.js.map +1 -1
  164. package/dist/primitives/zip.d.ts +22 -1
  165. package/dist/primitives/zip.d.ts.map +1 -1
  166. package/dist/primitives/zip.js +47 -8
  167. package/dist/primitives/zip.js.map +1 -1
  168. package/dist/shared/docx/DocxArchive.d.ts +6 -2
  169. package/dist/shared/docx/DocxArchive.d.ts.map +1 -1
  170. package/dist/shared/docx/DocxArchive.js +17 -4
  171. package/dist/shared/docx/DocxArchive.js.map +1 -1
  172. package/dist/shared/field-semantics.d.ts +26 -0
  173. package/dist/shared/field-semantics.d.ts.map +1 -0
  174. package/dist/shared/field-semantics.js +232 -0
  175. package/dist/shared/field-semantics.js.map +1 -0
  176. package/dist/shared/field-structure.d.ts +10 -1
  177. package/dist/shared/field-structure.d.ts.map +1 -1
  178. package/dist/shared/field-structure.js +31 -12
  179. package/dist/shared/field-structure.js.map +1 -1
  180. package/package.json +4 -5
@@ -2,18 +2,21 @@ import { DocxZip } from './zip.js';
2
2
  import { parseXml, serializeXml } from './xml.js';
3
3
  import { maybeCaptureEmittedDocumentXml } from './schema-corpus-capture.js';
4
4
  import { OOXML, W } from './namespaces.js';
5
+ import { assertTransitionalWordprocessingML } from './conformance.js';
5
6
  import { createWmlElement, isW, getDirectChildrenByName } from './dom-helpers.js';
6
- import { findParagraphByBookmarkId, insertParagraphBookmarks, cleanupInternalBookmarks, getParagraphBookmarkId, insertSingleParagraphBookmark, } from './bookmarks.js';
7
- import { getParagraphRuns, getParagraphText, replaceParagraphTextRange } from './text.js';
7
+ import { findParagraphByBookmarkId, insertParagraphBookmarks, cleanupInternalBookmarks, collectBookmarkReservation, getParagraphBookmarkId, insertSingleParagraphBookmark, } from './bookmarks.js';
8
+ import { formatParagraphTextRange, getParagraphRuns, getParagraphText, replaceParagraphTextRange } from './text.js';
8
9
  import { allocateRevisionId, createRevisionContainer, } from './track-changes-emitter.js';
9
10
  import { buildNodesForDocumentView, collectViewParagraphs } from './document_view.js';
10
11
  import { serializeToMarkdown } from './serialize_markdown.js';
11
12
  import { serializeToHtml } from './serialize_html.js';
12
13
  import { serializeToPlainText } from './serialize_plaintext.js';
13
- import { parseStylesXml } from './styles.js';
14
+ import { parseStylesXml, parseThemeXml } from './styles.js';
14
15
  import { parseNumberingXml } from './numbering.js';
16
+ import { getDirectParagraphNumbering, setDirectParagraphNumbering, } from './paragraph_numbering.js';
17
+ import { getDocumentSections, insertSectionBreak, setSectionPageNumberStart, updateSectionProperties, } from './sections.js';
15
18
  import { findUniqueSubstringMatch } from './matching.js';
16
- import { parseDocumentRels } from './relationships.js';
19
+ import { parseDocumentRels, relationshipPartPath } from './relationships.js';
17
20
  import { setParagraphSpacing, setTableCellPadding, setTableRowHeight, } from './layout.js';
18
21
  import { extractTables, } from './tables.js';
19
22
  import { mergeRuns } from './merge_runs.js';
@@ -22,16 +25,25 @@ import { simplifyRedlines } from './simplify_redlines.js';
22
25
  import { preventDoubleElevation } from './prevent_double_elevation.js';
23
26
  import { validateDocument } from './validate_document.js';
24
27
  import { validateAiRevisions as validateAiRevisionsImpl, } from './validate_ai_revisions.js';
25
- import { enumerateRevisionStoryPartPaths, REVISION_STORY_PART_PATHS, } from './revision-parts.js';
28
+ import { enumerateSelectedHeaderFooterPartPaths, enumerateSelectedRevisionStoryPartPaths, } from './revision-parts.js';
26
29
  import { acceptChanges as acceptChangesImpl } from './accept_changes.js';
27
30
  import { rejectChanges as rejectChangesImpl } from './reject_changes.js';
28
31
  import { collectRevisionElements, resolveSelectedIds, detectAmbiguousOverlaps, selectedIdFilter, AmbiguousRevisionOverlapError, } from './accept_ai_edits.js';
29
32
  import { revisionElementId } from './accept_changes.js';
30
33
  import { TRACKED_CHANGE_ELEMENT_NAME_SET } from './revision-vocabulary.js';
31
- import { bootstrapCommentParts, addComment as addCommentImpl, addCommentReply as addCommentReplyImpl, getComments as getCommentsImpl, getComment as getCommentImpl, deleteComment as deleteCommentImpl, } from './comments.js';
34
+ import { insertTableRow as insertTableRowImpl, deleteTableRow as deleteTableRowImpl, removeOrphanedRangeEndpointsForSubtree, } from './table_rows.js';
35
+ import { insertTableColumn as insertTableColumnImpl, deleteTableColumn as deleteTableColumnImpl, } from './table_columns.js';
36
+ import { splitTableCell as splitTableCellImpl, absorbTableCell as absorbTableCellImpl, } from './table_cells.js';
37
+ import { bootstrapCommentParts, addComment as addCommentImpl, addCommentReply as addCommentReplyImpl, getComments as getCommentsImpl, getComment as getCommentImpl, deleteComment as deleteCommentImpl, updateCommentBody as updateCommentBodyImpl, } from './comments.js';
32
38
  import { bootstrapFootnoteParts, getFootnotes as getFootnotesImpl, getFootnote as getFootnoteImpl, addFootnote as addFootnoteImpl, updateFootnoteText as updateFootnoteTextImpl, deleteFootnote as deleteFootnoteImpl, } from './footnotes.js';
33
39
  function emptyAcceptChangesResult() {
34
- return { insertionsAccepted: 0, deletionsAccepted: 0, movesResolved: 0, propertyChangesResolved: 0 };
40
+ return {
41
+ insertionsAccepted: 0,
42
+ deletionsAccepted: 0,
43
+ movesResolved: 0,
44
+ propertyChangesResolved: 0,
45
+ unresolvedRowRevisions: 0,
46
+ };
35
47
  }
36
48
  function hasAcceptedChanges(result) {
37
49
  return (result.insertionsAccepted > 0 ||
@@ -44,9 +56,16 @@ function addAcceptChangesResult(total, result) {
44
56
  total.deletionsAccepted += result.deletionsAccepted;
45
57
  total.movesResolved += result.movesResolved;
46
58
  total.propertyChangesResolved += result.propertyChangesResolved;
59
+ total.unresolvedRowRevisions += result.unresolvedRowRevisions;
47
60
  }
48
61
  function emptyRejectChangesResult() {
49
- return { insertionsRemoved: 0, deletionsRestored: 0, movesReverted: 0, propertyChangesReverted: 0 };
62
+ return {
63
+ insertionsRemoved: 0,
64
+ deletionsRestored: 0,
65
+ movesReverted: 0,
66
+ propertyChangesReverted: 0,
67
+ unresolvedRowRevisions: 0,
68
+ };
50
69
  }
51
70
  function hasRejectedChanges(result) {
52
71
  return (result.insertionsRemoved > 0 ||
@@ -59,6 +78,7 @@ function addRejectChangesResult(total, result) {
59
78
  total.deletionsRestored += result.deletionsRestored;
60
79
  total.movesReverted += result.movesReverted;
61
80
  total.propertyChangesReverted += result.propertyChangesReverted;
81
+ total.unresolvedRowRevisions += result.unresolvedRowRevisions;
62
82
  }
63
83
  function parseWId(el) {
64
84
  const idStr = el.getAttributeNS(OOXML.W_NS, 'id') ?? el.getAttribute('w:id');
@@ -138,6 +158,74 @@ function nextElementSibling(node) {
138
158
  }
139
159
  return null;
140
160
  }
161
+ function nearestWordAncestor(element, localName) {
162
+ for (let current = element.parentElement; current; current = current.parentElement) {
163
+ if (isW(current, localName))
164
+ return current;
165
+ }
166
+ return null;
167
+ }
168
+ /**
169
+ * Classify the physical table-cell constraints around a story paragraph.
170
+ *
171
+ * @conformance ECMA-376 edition 5, Part 1 § 17.4.66
172
+ * @conformance ECMA-376 edition 5, Part 1 § 17.4.84
173
+ * @see https://github.com/UseJunior/safe-docx/issues/998
174
+ */
175
+ function storyParagraphTableContext(paragraph) {
176
+ const cell = nearestWordAncestor(paragraph, W.tc);
177
+ if (!cell) {
178
+ return {
179
+ inTableCell: false,
180
+ verticalMergeContinuation: false,
181
+ directCellParagraphs: 0,
182
+ isTrailingDirectCellParagraph: false,
183
+ };
184
+ }
185
+ const directParagraphs = getDirectChildrenByName(cell, W.p);
186
+ const cellProperties = getDirectChildrenByName(cell, W.tcPr)[0];
187
+ const verticalMerge = cellProperties
188
+ ? getDirectChildrenByName(cellProperties, 'vMerge')[0]
189
+ : undefined;
190
+ const mergeValue = verticalMerge?.getAttributeNS(OOXML.W_NS, 'val')
191
+ ?? verticalMerge?.getAttribute('w:val')
192
+ ?? verticalMerge?.getAttribute('val');
193
+ return {
194
+ inTableCell: true,
195
+ verticalMergeContinuation: Boolean(verticalMerge && (!mergeValue || mergeValue === 'continue')),
196
+ directCellParagraphs: directParagraphs.length,
197
+ isTrailingDirectCellParagraph: directParagraphs.at(-1) === paragraph,
198
+ };
199
+ }
200
+ function assertStoryParagraphMutationSafe(paragraph, operation, styleSource) {
201
+ const parent = paragraph.parentElement;
202
+ const root = paragraph.ownerDocument.documentElement;
203
+ const cell = nearestWordAncestor(paragraph, W.tc);
204
+ if (parent !== root && parent !== cell) {
205
+ throw new Error('Story paragraph must be a direct child of the story root or table cell');
206
+ }
207
+ const context = storyParagraphTableContext(paragraph);
208
+ if (context.verticalMergeContinuation) {
209
+ throw new Error('Story paragraph mutation in a vertical-merge continuation cell is unsupported');
210
+ }
211
+ const table = cell ? nearestWordAncestor(cell, W.tbl) : null;
212
+ if (table && nearestWordAncestor(table, W.tc)) {
213
+ throw new Error('Story paragraph mutation inside a nested table is unsupported');
214
+ }
215
+ if (operation === 'insert' && styleSource) {
216
+ const styleCell = nearestWordAncestor(styleSource, W.tc);
217
+ if (styleCell !== cell)
218
+ throw new Error('Story paragraph style source must be in the anchor table cell');
219
+ }
220
+ if (operation !== 'delete' || !cell)
221
+ return;
222
+ const remainingBlocks = Array.from(cell.childNodes).filter((child) => child.nodeType === 1
223
+ && child !== paragraph
224
+ && ['p', 'tbl', 'sdt', 'customXml', 'altChunk'].includes(child.localName));
225
+ if (remainingBlocks.at(-1)?.localName !== W.p) {
226
+ throw new Error('Story paragraph deletion would leave a table cell without a trailing paragraph');
227
+ }
228
+ }
141
229
  function collectNamedBookmarkIds(doc) {
142
230
  const ids = new Set();
143
231
  const starts = doc.getElementsByTagNameNS(OOXML.W_NS, W.bookmarkStart);
@@ -154,36 +242,45 @@ export class DocxDocument {
154
242
  zip;
155
243
  documentXml;
156
244
  stylesXml;
245
+ themeXml;
157
246
  numberingXml;
158
247
  footnotesXml;
159
248
  relsMap;
160
249
  dirty;
161
250
  documentViewCache;
251
+ paragraphBookmarkReservation;
162
252
  /**
163
253
  * Raw document.xml text as loaded, before normalize()/edits mutate the DOM.
164
254
  * Reference for minimal re-serialization in toBuffer(); null for instances
165
255
  * not created via load().
166
256
  */
167
257
  originalDocumentXmlText;
168
- constructor(zip, documentXml, stylesXml, numberingXml, footnotesXml, relsMap, originalDocumentXmlText = null) {
258
+ constructor(zip, documentXml, stylesXml, themeXml, numberingXml, footnotesXml, relsMap, originalDocumentXmlText = null) {
169
259
  this.zip = zip;
170
260
  this.documentXml = documentXml;
171
261
  this.stylesXml = stylesXml;
262
+ this.themeXml = themeXml;
172
263
  this.numberingXml = numberingXml;
173
264
  this.footnotesXml = footnotesXml;
174
265
  this.relsMap = relsMap;
175
266
  this.dirty = false;
176
267
  this.documentViewCache = null;
268
+ this.paragraphBookmarkReservation = undefined;
177
269
  this.originalDocumentXmlText = originalDocumentXmlText;
178
270
  }
179
271
  static async load(buffer) {
180
272
  const zip = await DocxZip.load(buffer);
181
273
  const xml = await zip.readText('word/document.xml');
182
274
  const doc = parseXml(xml);
275
+ // Refuse WML Strict packages here, before any Transitional-only lookup
276
+ // could read them as empty (#1025).
277
+ assertTransitionalWordprocessingML(doc);
183
278
  // Optional parts used for fidelity: list labels + style fingerprints.
184
279
  const stylesText = await zip.readTextOrNull('word/styles.xml');
280
+ const themeText = await zip.readTextOrNull('word/theme/theme1.xml');
185
281
  const numberingText = await zip.readTextOrNull('word/numbering.xml');
186
282
  const stylesXml = stylesText ? parseXml(stylesText) : null;
283
+ const themeXml = themeText ? parseXml(themeText) : null;
187
284
  const numberingXml = numberingText ? parseXml(numberingText) : null;
188
285
  // Load footnotes for [^N] marker rendering in document view.
189
286
  const footnotesText = await zip.readTextOrNull('word/footnotes.xml');
@@ -191,7 +288,15 @@ export class DocxDocument {
191
288
  // Load document relationships for hyperlink resolution.
192
289
  const relsText = await zip.readTextOrNull('word/_rels/document.xml.rels');
193
290
  const relsMap = relsText ? parseDocumentRels(parseXml(relsText)) : new Map();
194
- return new DocxDocument(zip, doc, stylesXml, numberingXml, footnotesXml, relsMap, xml);
291
+ const document = new DocxDocument(zip, doc, stylesXml, themeXml, numberingXml, footnotesXml, relsMap, xml);
292
+ const storyDocuments = [];
293
+ for (const partPath of await enumerateSelectedHeaderFooterPartPaths(zip)) {
294
+ const storyXml = await zip.readTextOrNull(partPath);
295
+ if (storyXml)
296
+ storyDocuments.push(parseXml(storyXml));
297
+ }
298
+ document.paragraphBookmarkReservation = collectBookmarkReservation([doc, ...storyDocuments]);
299
+ return document;
195
300
  }
196
301
  getParagraphs() {
197
302
  const body = this.documentXml.getElementsByTagNameNS(OOXML.W_NS, W.body).item(0);
@@ -208,8 +313,184 @@ export class DocxDocument {
208
313
  return null;
209
314
  return getParagraphText(p);
210
315
  }
316
+ async selectedStoryDocument(partPath) {
317
+ const selected = await enumerateSelectedHeaderFooterPartPaths(this.zip);
318
+ if (!selected.includes(partPath)) {
319
+ throw new Error(`Header/footer story is not selected by a section relationship: ${partPath}`);
320
+ }
321
+ const xml = await this.zip.readTextOrNull(partPath);
322
+ if (!xml)
323
+ throw new Error(`Selected header/footer story part is missing: ${partPath}`);
324
+ return parseXml(xml);
325
+ }
326
+ withTemporaryDocumentXml(story, mutation, bookmarkReservation) {
327
+ const bodyDocument = this.documentXml;
328
+ const dirty = this.dirty;
329
+ const viewCache = this.documentViewCache;
330
+ const previousReservation = this.paragraphBookmarkReservation;
331
+ this.documentXml = story;
332
+ this.paragraphBookmarkReservation = bookmarkReservation;
333
+ try {
334
+ return mutation();
335
+ }
336
+ finally {
337
+ this.documentXml = bodyDocument;
338
+ this.dirty = dirty;
339
+ this.documentViewCache = viewCache;
340
+ this.paragraphBookmarkReservation = previousReservation;
341
+ }
342
+ }
343
+ async getStoryParagraphTextById(partPath, bookmarkId) {
344
+ const story = await this.selectedStoryDocument(partPath);
345
+ const paragraph = findParagraphByBookmarkId(story, bookmarkId);
346
+ return paragraph ? getParagraphText(paragraph) : null;
347
+ }
348
+ async getStoryParagraphById(partPath, bookmarkId) {
349
+ const text = await this.getStoryParagraphTextById(partPath, bookmarkId);
350
+ return text === null ? null : { id: bookmarkId, text };
351
+ }
352
+ async insertStoryParagraphBookmarks(partPath, attachmentId) {
353
+ const story = await this.selectedStoryDocument(partPath);
354
+ const reservation = this.paragraphBookmarkReservation;
355
+ const result = insertParagraphBookmarks(story, attachmentId, reservation);
356
+ if (result.indexedParagraphs > 0)
357
+ this.zip.writeText(partPath, serializeXml(story));
358
+ return { paragraphCount: result.indexedParagraphs };
359
+ }
360
+ async validateStoryParagraphTableCell(partPath, bookmarkId) {
361
+ const story = await this.selectedStoryDocument(partPath);
362
+ const paragraph = findParagraphByBookmarkId(story, bookmarkId);
363
+ if (!paragraph)
364
+ throw new Error(`Paragraph not found in ${partPath}: ${bookmarkId}`);
365
+ return storyParagraphTableContext(paragraph);
366
+ }
367
+ /** Mutate intended-clean story text; comparison is responsible for tracked output. */
368
+ async replaceStoryTextAtRange(params) {
369
+ const story = await this.selectedStoryDocument(params.partPath);
370
+ const paragraph = findParagraphByBookmarkId(story, params.targetParagraphId);
371
+ if (!paragraph)
372
+ throw new Error(`Paragraph not found in ${params.partPath}: ${params.targetParagraphId}`);
373
+ assertStoryParagraphMutationSafe(paragraph, 'replace');
374
+ replaceParagraphTextRange(paragraph, params.start, params.end, params.replaceText);
375
+ this.zip.writeText(params.partPath, serializeXml(story));
376
+ }
377
+ /** Mutate intended-clean story text; comparison is responsible for tracked output. */
378
+ async replaceStoryText(params) {
379
+ const story = await this.selectedStoryDocument(params.partPath);
380
+ const paragraph = findParagraphByBookmarkId(story, params.targetParagraphId);
381
+ if (!paragraph)
382
+ throw new Error(`Paragraph not found in ${params.partPath}: ${params.targetParagraphId}`);
383
+ assertStoryParagraphMutationSafe(paragraph, 'replace');
384
+ const match = findUniqueSubstringMatch(getParagraphText(paragraph), params.findText);
385
+ if (match.status === 'not_found') {
386
+ throw new Error(`Text not found in paragraph ${params.targetParagraphId}`);
387
+ }
388
+ if (match.status === 'multiple') {
389
+ throw new Error(`Multiple matches (${match.matchCount}) found in paragraph ${params.targetParagraphId} using ${match.mode} matching`);
390
+ }
391
+ replaceParagraphTextRange(paragraph, match.start, match.end, params.replaceText);
392
+ this.zip.writeText(params.partPath, serializeXml(story));
393
+ }
394
+ /**
395
+ * Apply a bounded run-format edit in one selected physical story paragraph.
396
+ *
397
+ * @conformance ECMA-376 edition 5, Part 1 § 17.3.2.28
398
+ * @see #1034
399
+ */
400
+ async formatStoryTextAtRange(params) {
401
+ const story = await this.selectedStoryDocument(params.partPath);
402
+ const paragraph = findParagraphByBookmarkId(story, params.targetParagraphId);
403
+ if (!paragraph)
404
+ throw new Error(`Paragraph not found in ${params.partPath}: ${params.targetParagraphId}`);
405
+ assertStoryParagraphMutationSafe(paragraph, 'replace');
406
+ formatParagraphTextRange(paragraph, params.start, params.end, params.format);
407
+ this.zip.writeText(params.partPath, serializeXml(story));
408
+ }
409
+ async insertStoryParagraph(params, ctx) {
410
+ const story = await this.selectedStoryDocument(params.partPath);
411
+ const anchor = findParagraphByBookmarkId(story, params.positionalAnchorNodeId);
412
+ if (!anchor)
413
+ throw new Error(`Anchor paragraph not found in ${params.partPath}: ${params.positionalAnchorNodeId}`);
414
+ const styleSource = params.styleSourceId
415
+ ? findParagraphByBookmarkId(story, params.styleSourceId)
416
+ : anchor;
417
+ if (params.styleSourceId && !styleSource) {
418
+ throw new Error(`Style source paragraph not found in ${params.partPath}: ${params.styleSourceId}`);
419
+ }
420
+ assertStoryParagraphMutationSafe(anchor, 'insert', styleSource ?? undefined);
421
+ const bookmarkReservation = this.paragraphBookmarkReservation;
422
+ const result = this.withTemporaryDocumentXml(story, () => this.insertParagraph({
423
+ positionalAnchorNodeId: params.positionalAnchorNodeId,
424
+ relativePosition: params.relativePosition,
425
+ newText: params.newText,
426
+ styleSourceId: params.styleSourceId,
427
+ runStyleSourceText: params.runStyleSourceText,
428
+ }, ctx), bookmarkReservation);
429
+ this.zip.writeText(params.partPath, serializeXml(story));
430
+ return result;
431
+ }
432
+ /** Remove an intended-clean story paragraph; comparison is responsible for tracked output. */
433
+ async deleteStoryParagraph(partPath, paragraphId) {
434
+ const story = await this.selectedStoryDocument(partPath);
435
+ const paragraph = findParagraphByBookmarkId(story, paragraphId);
436
+ if (!paragraph)
437
+ throw new Error(`Paragraph not found in ${partPath}: ${paragraphId}`);
438
+ assertStoryParagraphMutationSafe(paragraph, 'delete');
439
+ const bookmarkStart = Array.from(story.getElementsByTagNameNS(OOXML.W_NS, W.bookmarkStart))
440
+ .find((start) => (start.getAttributeNS(OOXML.W_NS, 'name') ?? start.getAttribute('w:name')) === paragraphId);
441
+ const bookmarkNumericId = bookmarkStart?.getAttributeNS(OOXML.W_NS, 'id')
442
+ ?? bookmarkStart?.getAttribute('w:id');
443
+ const bookmarkEnd = bookmarkNumericId
444
+ ? Array.from(story.getElementsByTagNameNS(OOXML.W_NS, W.bookmarkEnd)).find((end) => (end.getAttributeNS(OOXML.W_NS, 'id') ?? end.getAttribute('w:id')) === bookmarkNumericId)
445
+ : undefined;
446
+ removeOrphanedRangeEndpointsForSubtree(story.documentElement, paragraph);
447
+ paragraph.parentNode?.removeChild(paragraph);
448
+ bookmarkStart?.parentNode?.removeChild(bookmarkStart);
449
+ bookmarkEnd?.parentNode?.removeChild(bookmarkEnd);
450
+ this.zip.writeText(partPath, serializeXml(story));
451
+ }
452
+ insertTableRow(params, ctx) {
453
+ const result = insertTableRowImpl(this.documentXml, params, ctx, this.paragraphBookmarkReservation);
454
+ this.dirty = true;
455
+ this.documentViewCache = null;
456
+ return result;
457
+ }
458
+ deleteTableRow(params, ctx) {
459
+ const result = deleteTableRowImpl(this.documentXml, params, ctx);
460
+ this.dirty = true;
461
+ this.documentViewCache = null;
462
+ return result;
463
+ }
464
+ /** Apply a planned clean column edit to the existing DOM, preserving held element references. */
465
+ insertTableColumn(params, ctx) {
466
+ const result = insertTableColumnImpl(this.documentXml, params, ctx, this.paragraphBookmarkReservation);
467
+ this.dirty = true;
468
+ this.documentViewCache = null;
469
+ return result;
470
+ }
471
+ /** Apply a planned clean column deletion to the existing DOM. */
472
+ deleteTableColumn(params, ctx) {
473
+ const result = deleteTableColumnImpl(this.documentXml, params, ctx);
474
+ this.dirty = true;
475
+ this.documentViewCache = null;
476
+ return result;
477
+ }
478
+ /** Split one horizontal-spanning physical cell, preserving its authored content on the chosen side. */
479
+ splitTableCell(params, ctx) {
480
+ const result = splitTableCellImpl(this.documentXml, params, ctx, this.paragraphBookmarkReservation);
481
+ this.dirty = true;
482
+ this.documentViewCache = null;
483
+ return result;
484
+ }
485
+ /** Remove a physical cell and discard its content while an adjacent sibling absorbs its span. */
486
+ absorbTableCell(params, ctx) {
487
+ const result = absorbTableCellImpl(this.documentXml, params, ctx);
488
+ this.dirty = true;
489
+ this.documentViewCache = null;
490
+ return result;
491
+ }
211
492
  insertParagraphBookmarks(attachmentId) {
212
- const res = insertParagraphBookmarks(this.documentXml, attachmentId);
493
+ const res = insertParagraphBookmarks(this.documentXml, attachmentId, this.paragraphBookmarkReservation);
213
494
  if (res.indexedParagraphs > 0)
214
495
  this.dirty = true;
215
496
  return { paragraphCount: res.indexedParagraphs };
@@ -250,7 +531,7 @@ export class DocxDocument {
250
531
  }
251
532
  async validateAiRevisions(aiAuthor, touched) {
252
533
  const stories = [{ part: 'word/document.xml', doc: this.documentXml }];
253
- for (const partPath of enumerateRevisionStoryPartPaths(this.zip)) {
534
+ for (const partPath of await enumerateSelectedRevisionStoryPartPaths(this.zip)) {
254
535
  const xml = await this.zip.readTextOrNull(partPath);
255
536
  if (!xml)
256
537
  continue;
@@ -278,7 +559,7 @@ export class DocxDocument {
278
559
  const liveFootnoteRefIds = bodyResult.deletionsAccepted > 0
279
560
  ? collectLiveFootnoteRefIds(this.documentXml)
280
561
  : null;
281
- for (const partPath of REVISION_STORY_PART_PATHS) {
562
+ for (const partPath of await enumerateSelectedRevisionStoryPartPaths(this.zip)) {
282
563
  const xml = await this.zip.readTextOrNull(partPath);
283
564
  if (!xml)
284
565
  continue;
@@ -316,7 +597,7 @@ export class DocxDocument {
316
597
  const liveFootnoteRefIds = bodyResult.insertionsRemoved > 0
317
598
  ? collectLiveFootnoteRefIds(this.documentXml)
318
599
  : null;
319
- for (const partPath of REVISION_STORY_PART_PATHS) {
600
+ for (const partPath of await enumerateSelectedRevisionStoryPartPaths(this.zip)) {
320
601
  const xml = await this.zip.readTextOrNull(partPath);
321
602
  if (!xml)
322
603
  continue;
@@ -350,7 +631,7 @@ export class DocxDocument {
350
631
  const stories = [
351
632
  { path: null, doc: this.documentXml },
352
633
  ];
353
- for (const partPath of REVISION_STORY_PART_PATHS) {
634
+ for (const partPath of await enumerateSelectedRevisionStoryPartPaths(this.zip)) {
354
635
  const xml = await this.zip.readTextOrNull(partPath);
355
636
  if (xml)
356
637
  stories.push({ path: partPath, doc: parseXml(xml) });
@@ -501,6 +782,7 @@ export class DocxDocument {
501
782
  const { nodes, styles } = buildNodesForDocumentView({
502
783
  paragraphs,
503
784
  stylesXml: this.stylesXml,
785
+ themeXml: this.themeXml,
504
786
  numberingXml: this.numberingXml,
505
787
  include_semantic_tags: includeSemanticTags,
506
788
  show_formatting: showFormatting,
@@ -543,8 +825,16 @@ export class DocxDocument {
543
825
  this.dirty = true;
544
826
  this.documentViewCache = null;
545
827
  }
828
+ formatTextAtRange(params) {
829
+ const paragraph = findParagraphByBookmarkId(this.documentXml, params.targetParagraphId);
830
+ if (!paragraph)
831
+ throw new Error(`Paragraph not found: ${params.targetParagraphId}`);
832
+ formatParagraphTextRange(paragraph, params.start, params.end, params.format);
833
+ this.dirty = true;
834
+ this.documentViewCache = null;
835
+ }
546
836
  insertParagraph(params, ctx) {
547
- const { positionalAnchorNodeId, relativePosition, newText, newParagraphId: _newParagraphId, styleSourceId } = params;
837
+ const { positionalAnchorNodeId, relativePosition, newText, newParagraphId: _newParagraphId, styleSourceId, runStyleSourceText } = params;
548
838
  const anchor = findParagraphByBookmarkId(this.documentXml, positionalAnchorNodeId);
549
839
  if (!anchor)
550
840
  throw new Error(`Anchor paragraph not found: ${positionalAnchorNodeId}`);
@@ -713,14 +1003,41 @@ export class DocxDocument {
713
1003
  return next.nextSibling;
714
1004
  return anchorP.nextSibling;
715
1005
  }
716
- // Choose a run in the formatting source to use as formatting template: pick the run with the most visible text.
1006
+ // Choose a run in the formatting source to use as formatting template. A
1007
+ // caller may pin one unique visible source substring; legacy callers retain
1008
+ // the predominant-run behavior.
717
1009
  const sourceVisibleRuns = getParagraphRuns(formattingSource);
718
1010
  let templateRun = null;
719
1011
  let bestLen = -1;
720
- for (const tr of sourceVisibleRuns) {
721
- if (tr.text.length > bestLen) {
722
- bestLen = tr.text.length;
723
- templateRun = tr.r;
1012
+ if (runStyleSourceText !== undefined) {
1013
+ const sourceText = sourceVisibleRuns.map((run) => run.text).join('');
1014
+ const start = sourceText.indexOf(runStyleSourceText);
1015
+ if (!runStyleSourceText || start < 0 || sourceText.indexOf(runStyleSourceText, start + runStyleSourceText.length) >= 0) {
1016
+ throw new Error('runStyleSourceText must identify one non-empty substring in the formatting source paragraph');
1017
+ }
1018
+ let offset = 0;
1019
+ const selectedRuns = [];
1020
+ for (const tr of sourceVisibleRuns) {
1021
+ if (start < offset + tr.text.length && start + runStyleSourceText.length > offset) {
1022
+ selectedRuns.push(tr.r);
1023
+ }
1024
+ offset += tr.text.length;
1025
+ }
1026
+ const runPropertySignatures = new Set(selectedRuns.map((run) => {
1027
+ const rPr = getDirectChildrenByName(run, W.rPr)[0];
1028
+ return rPr?.toString() ?? '';
1029
+ }));
1030
+ if (selectedRuns.length === 0 || runPropertySignatures.size !== 1) {
1031
+ throw new Error('runStyleSourceText must occupy one direct run-formatting class');
1032
+ }
1033
+ templateRun = selectedRuns[0];
1034
+ }
1035
+ else {
1036
+ for (const tr of sourceVisibleRuns) {
1037
+ if (tr.text.length > bestLen) {
1038
+ bestLen = tr.text.length;
1039
+ templateRun = tr.r;
1040
+ }
724
1041
  }
725
1042
  }
726
1043
  if (!templateRun) {
@@ -746,7 +1063,7 @@ export class DocxDocument {
746
1063
  newP.appendChild(newRun);
747
1064
  }
748
1065
  parent.insertBefore(newP, cursor);
749
- const id = insertSingleParagraphBookmark(doc, newP);
1066
+ const id = insertSingleParagraphBookmark(doc, newP, this.paragraphBookmarkReservation);
750
1067
  insertedIds.push(id);
751
1068
  if (relativePosition === 'AFTER') {
752
1069
  const endEl = nextElementSibling(newP);
@@ -771,6 +1088,42 @@ export class DocxDocument {
771
1088
  }
772
1089
  return result;
773
1090
  }
1091
+ getDirectParagraphNumbering(paragraphId) {
1092
+ return getDirectParagraphNumbering(this.documentXml, paragraphId);
1093
+ }
1094
+ setDirectParagraphNumbering(mutation, ctx) {
1095
+ const result = setDirectParagraphNumbering(this.documentXml, this.numberingXml, mutation, ctx);
1096
+ if (result.changed) {
1097
+ this.dirty = true;
1098
+ this.documentViewCache = null;
1099
+ }
1100
+ return result;
1101
+ }
1102
+ getSections() {
1103
+ return getDocumentSections(this.documentXml);
1104
+ }
1105
+ insertSectionBreak(mutation, ctx) {
1106
+ const result = insertSectionBreak(this.documentXml, mutation, ctx, this.paragraphBookmarkReservation);
1107
+ this.dirty = true;
1108
+ this.documentViewCache = null;
1109
+ return result;
1110
+ }
1111
+ setSectionPageNumberStart(mutation, ctx) {
1112
+ const result = setSectionPageNumberStart(this.documentXml, mutation, ctx);
1113
+ if (result.changed) {
1114
+ this.dirty = true;
1115
+ this.documentViewCache = null;
1116
+ }
1117
+ return result;
1118
+ }
1119
+ updateSectionProperties(mutation, ctx) {
1120
+ const result = updateSectionProperties(this.documentXml, mutation, ctx);
1121
+ if (result.changed) {
1122
+ this.dirty = true;
1123
+ this.documentViewCache = null;
1124
+ }
1125
+ return result;
1126
+ }
774
1127
  setTableRowHeight(mutation, ctx) {
775
1128
  const result = setTableRowHeight(this.documentXml, mutation, ctx);
776
1129
  if (result.affectedRows > 0) {
@@ -826,6 +1179,8 @@ export class DocxDocument {
826
1179
  author: params.author,
827
1180
  text: params.text,
828
1181
  initials: params.initials,
1182
+ body: params.body,
1183
+ date: params.date,
829
1184
  }, ctx);
830
1185
  this.dirty = true;
831
1186
  this.documentViewCache = null;
@@ -844,13 +1199,15 @@ export class DocxDocument {
844
1199
  author: params.author,
845
1200
  text: params.text,
846
1201
  initials: params.initials,
1202
+ body: params.body,
1203
+ date: params.date,
847
1204
  }, ctx);
848
1205
  this.dirty = true;
849
1206
  this.documentViewCache = null;
850
1207
  return result;
851
1208
  }
852
1209
  async getComments() {
853
- return getCommentsImpl(this.zip, this.documentXml);
1210
+ return getCommentsImpl(this.zip, this.documentXml, this.getStylesModel(), parseThemeXml(this.themeXml));
854
1211
  }
855
1212
  async getComment(commentId) {
856
1213
  return getCommentImpl(this.zip, this.documentXml, commentId);
@@ -860,13 +1217,113 @@ export class DocxDocument {
860
1217
  this.dirty = true;
861
1218
  this.documentViewCache = null;
862
1219
  }
1220
+ async updateCommentBody(params) {
1221
+ await updateCommentBodyImpl(this.zip, params);
1222
+ this.dirty = true;
1223
+ }
1224
+ /**
1225
+ * Convert selected root Word comments to footnotes in document order.
1226
+ * The method preflights the complete selection before mutating the package;
1227
+ * callers that need buffer-level atomicity should invoke it on a freshly
1228
+ * loaded document and publish only the returned serialization.
1229
+ *
1230
+ * @conformance ECMA-376 edition 5, Part 1 § 17.13.4.2
1231
+ * @conformance ECMA-376 edition 5, Part 1 § 17.11.14
1232
+ */
1233
+ async convertCommentsToFootnotes(options = {}) {
1234
+ const colors = [options.presentation?.prefixStyle?.color, options.presentation?.bodyStyle?.color];
1235
+ for (const color of colors) {
1236
+ if (color && !/^[0-9A-Fa-f]{6}$/.test(color)) {
1237
+ throw new Error(`Invalid Word color '${color}'; expected six hexadecimal digits`);
1238
+ }
1239
+ }
1240
+ const allowedHighlights = new Set([
1241
+ 'black', 'blue', 'cyan', 'green', 'magenta', 'red', 'yellow', 'white',
1242
+ 'darkBlue', 'darkCyan', 'darkGreen', 'darkMagenta', 'darkRed',
1243
+ 'darkYellow', 'darkGray', 'lightGray', 'none',
1244
+ ]);
1245
+ const highlights = [options.presentation?.prefixStyle?.highlight, options.presentation?.bodyStyle?.highlight];
1246
+ for (const highlight of highlights) {
1247
+ if (highlight && !allowedHighlights.has(highlight)) {
1248
+ throw new Error(`Invalid Word highlight '${highlight}'`);
1249
+ }
1250
+ }
1251
+ // Materialize stable paragraph bookmarks before comment-range extraction;
1252
+ // source DOCX files are not required to carry Safe DOCX anchors already.
1253
+ this.insertParagraphBookmarks('note-conversion');
1254
+ const roots = await this.getComments();
1255
+ const beforeFootnotes = (await this.getFootnotes()).length;
1256
+ const requested = options.commentIds ? new Set(options.commentIds) : null;
1257
+ const selected = requested ? roots.filter((comment) => requested.has(comment.id)) : roots;
1258
+ if (requested) {
1259
+ const found = new Set(selected.map((comment) => comment.id));
1260
+ const missing = [...requested].filter((id) => !found.has(id));
1261
+ if (missing.length)
1262
+ throw new Error(`Root comment IDs not found: ${missing.join(', ')}`);
1263
+ }
1264
+ const prepared = selected.map((comment) => {
1265
+ if (!comment.anchoredParagraphId || !comment.endParagraphId || comment.anchoredParagraphId !== comment.endParagraphId) {
1266
+ throw new Error(`Comment ID ${comment.id} does not have one supported paragraph anchor`);
1267
+ }
1268
+ if (comment.replies.length && !options.flattenThreads) {
1269
+ throw new Error(`Comment ID ${comment.id} has replies; pass flattenThreads to perform a lossy conversion`);
1270
+ }
1271
+ const paragraph = findParagraphByBookmarkId(this.documentXml, comment.anchoredParagraphId);
1272
+ if (!paragraph)
1273
+ throw new Error(`Comment ID ${comment.id} anchor was not found`);
1274
+ if (comment.endTextOffset !== undefined) {
1275
+ const paragraphText = getParagraphText(paragraph);
1276
+ if (comment.endTextOffset > paragraphText.length) {
1277
+ throw new Error(`Comment ID ${comment.id} has an invalid visible-text range endpoint`);
1278
+ }
1279
+ }
1280
+ const replyText = comment.replies.map((reply) => `\n${reply.author ? `${reply.author}: ` : ''}${reply.text}`).join('');
1281
+ return {
1282
+ comment,
1283
+ paragraph,
1284
+ visibleOffset: comment.endTextOffset,
1285
+ text: `${comment.text}${replyText}`,
1286
+ };
1287
+ });
1288
+ const converted = [];
1289
+ if (prepared.length > 0)
1290
+ await bootstrapFootnoteParts(this.zip);
1291
+ for (const item of prepared) {
1292
+ // Insert first while the preflighted comment markers still identify the
1293
+ // source range. deleteComment removes only comment markers/references,
1294
+ // not the adjacent footnote run introduced here.
1295
+ const result = await addFootnoteImpl(this.documentXml, this.zip, {
1296
+ paragraphEl: item.paragraph,
1297
+ visibleOffset: item.visibleOffset,
1298
+ text: item.text,
1299
+ presentation: options.presentation,
1300
+ });
1301
+ await deleteCommentImpl(this.documentXml, this.zip, { commentId: item.comment.id });
1302
+ converted.push({
1303
+ commentId: item.comment.id,
1304
+ footnoteId: result.noteId,
1305
+ paragraphId: item.comment.anchoredParagraphId,
1306
+ flattenedReplies: item.comment.replies.length,
1307
+ });
1308
+ }
1309
+ await this.refreshFootnotesXml();
1310
+ this.dirty = converted.length > 0;
1311
+ this.documentViewCache = null;
1312
+ return {
1313
+ selected: selected.length,
1314
+ converted,
1315
+ before: { comments: roots.length, footnotes: beforeFootnotes },
1316
+ after: { comments: (await this.getComments()).length, footnotes: (await this.getFootnotes()).length },
1317
+ lossy: converted.some((entry) => entry.flattenedReplies > 0),
1318
+ };
1319
+ }
863
1320
  // ── Footnote methods ──────────────────────────────────────────────────
864
1321
  async refreshFootnotesXml() {
865
1322
  const text = await this.zip.readTextOrNull('word/footnotes.xml');
866
1323
  this.footnotesXml = text ? parseXml(text) : null;
867
1324
  }
868
1325
  async getFootnotes() {
869
- return getFootnotesImpl(this.zip, this.documentXml, this.getStylesModel());
1326
+ return getFootnotesImpl(this.zip, this.documentXml, this.getStylesModel(), parseThemeXml(this.themeXml));
870
1327
  }
871
1328
  /**
872
1329
  * Serialize the document to GitHub-Flavored Markdown. Convenience wrapper that wires the
@@ -910,7 +1367,7 @@ export class DocxDocument {
910
1367
  return serializeToPlainText(nodes, footnotes, opts);
911
1368
  }
912
1369
  async getFootnote(noteId) {
913
- return getFootnoteImpl(this.zip, this.documentXml, noteId, this.getStylesModel());
1370
+ return getFootnoteImpl(this.zip, this.documentXml, noteId, this.getStylesModel(), parseThemeXml(this.themeXml));
914
1371
  }
915
1372
  /**
916
1373
  * Add a footnote anchored to a paragraph, optionally after specific text.
@@ -926,7 +1383,9 @@ export class DocxDocument {
926
1383
  const result = await addFootnoteImpl(this.documentXml, this.zip, {
927
1384
  paragraphEl: p,
928
1385
  afterText: params.afterText,
1386
+ visibleOffset: params.visibleOffset,
929
1387
  text: params.text,
1388
+ presentation: params.presentation,
930
1389
  }, ctx);
931
1390
  await this.refreshFootnotesXml();
932
1391
  this.dirty = true;
@@ -969,6 +1428,20 @@ export class DocxDocument {
969
1428
  return null;
970
1429
  return parseXml(commentsText);
971
1430
  }
1431
+ /** Return a deep clone of footnotes.xml, or null when the part is absent. */
1432
+ async getFootnotesXmlClone() {
1433
+ const footnotesText = await this.zip.readTextOrNull('word/footnotes.xml');
1434
+ if (!footnotesText)
1435
+ return null;
1436
+ return parseXml(footnotesText);
1437
+ }
1438
+ /** Return a source part's Relationships DOM, or null when it has none. */
1439
+ async getPartRelationshipsXmlClone(sourcePartPath) {
1440
+ const relationshipsText = await this.zip.readTextOrNull(relationshipPartPath(sourcePartPath));
1441
+ if (!relationshipsText)
1442
+ return null;
1443
+ return parseXml(relationshipsText);
1444
+ }
972
1445
  /**
973
1446
  * Serialize the document to a .docx buffer.
974
1447
  *
@@ -980,9 +1453,14 @@ export class DocxDocument {
980
1453
  * Falls back to full re-serialization (blocksRestored: 0) when no original
981
1454
  * text was captured or reconciliation fails.
982
1455
  *
1456
+ * With `fileDate`, every ZIP entry is written with that date instead of the
1457
+ * wall clock, so the bytes depend only on the document's content.
1458
+ *
983
1459
  * @see https://github.com/UseJunior/safe-docx/issues/408
1460
+ * @see https://github.com/UseJunior/safe-docx/issues/1110
984
1461
  */
985
1462
  async toBuffer(opts) {
1463
+ const zipOptions = opts?.fileDate ? { fileDate: opts.fileDate } : undefined;
986
1464
  // Always write the latest document.xml when saving.
987
1465
  // Important: when cleanBookmarks=true (download), we must NOT mutate session state.
988
1466
  const xmlWithBookmarks = serializeXml(this.documentXml);
@@ -1009,11 +1487,11 @@ export class DocxDocument {
1009
1487
  // Temporarily swap document.xml in the zip for output, then restore.
1010
1488
  maybeCaptureEmittedDocumentXml(cleanedXml);
1011
1489
  this.zip.writeText('word/document.xml', cleanedXml);
1012
- const buffer = await this.zip.toBuffer();
1490
+ const buffer = await this.zip.toBuffer(zipOptions);
1013
1491
  this.zip.writeText('word/document.xml', xmlWithBookmarks);
1014
1492
  return { buffer, bookmarksRemoved, blocksRestored };
1015
1493
  }
1016
- const buffer = await this.zip.toBuffer();
1494
+ const buffer = await this.zip.toBuffer(zipOptions);
1017
1495
  return { buffer, bookmarksRemoved: 0, blocksRestored: 0 };
1018
1496
  }
1019
1497
  /**