@usejunior/docx-core 0.20.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 (125) 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/generation/compile.d.ts.map +1 -1
  7. package/dist/generation/compile.js +1 -3
  8. package/dist/generation/compile.js.map +1 -1
  9. package/dist/index.d.ts +2 -1
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +2 -1
  12. package/dist/index.js.map +1 -1
  13. package/dist/integration/libreoffice-oracle.d.ts +3 -1
  14. package/dist/integration/libreoffice-oracle.d.ts.map +1 -1
  15. package/dist/integration/libreoffice-oracle.js +9 -3
  16. package/dist/integration/libreoffice-oracle.js.map +1 -1
  17. package/dist/primitives/accept_changes.d.ts +3 -4
  18. package/dist/primitives/accept_changes.d.ts.map +1 -1
  19. package/dist/primitives/accept_changes.js +165 -79
  20. package/dist/primitives/accept_changes.js.map +1 -1
  21. package/dist/primitives/bookmarks.d.ts +7 -2
  22. package/dist/primitives/bookmarks.d.ts.map +1 -1
  23. package/dist/primitives/bookmarks.js +30 -6
  24. package/dist/primitives/bookmarks.js.map +1 -1
  25. package/dist/primitives/comments.d.ts +73 -4
  26. package/dist/primitives/comments.d.ts.map +1 -1
  27. package/dist/primitives/comments.js +266 -165
  28. package/dist/primitives/comments.js.map +1 -1
  29. package/dist/primitives/conformance.d.ts +42 -0
  30. package/dist/primitives/conformance.d.ts.map +1 -0
  31. package/dist/primitives/conformance.js +61 -0
  32. package/dist/primitives/conformance.js.map +1 -0
  33. package/dist/primitives/document.d.ts +98 -1
  34. package/dist/primitives/document.d.ts.map +1 -1
  35. package/dist/primitives/document.js +315 -22
  36. package/dist/primitives/document.js.map +1 -1
  37. package/dist/primitives/errors.d.ts +3 -2
  38. package/dist/primitives/errors.d.ts.map +1 -1
  39. package/dist/primitives/errors.js +3 -1
  40. package/dist/primitives/errors.js.map +1 -1
  41. package/dist/primitives/extract_revisions.d.ts +23 -6
  42. package/dist/primitives/extract_revisions.d.ts.map +1 -1
  43. package/dist/primitives/extract_revisions.js +208 -38
  44. package/dist/primitives/extract_revisions.js.map +1 -1
  45. package/dist/primitives/footnotes.d.ts +36 -0
  46. package/dist/primitives/footnotes.d.ts.map +1 -1
  47. package/dist/primitives/footnotes.js +173 -75
  48. package/dist/primitives/footnotes.js.map +1 -1
  49. package/dist/primitives/index.d.ts +7 -1
  50. package/dist/primitives/index.d.ts.map +1 -1
  51. package/dist/primitives/index.js +7 -1
  52. package/dist/primitives/index.js.map +1 -1
  53. package/dist/primitives/paragraph-index.d.ts +31 -0
  54. package/dist/primitives/paragraph-index.d.ts.map +1 -0
  55. package/dist/primitives/paragraph-index.js +140 -0
  56. package/dist/primitives/paragraph-index.js.map +1 -0
  57. package/dist/primitives/paragraph_merge_formatting.d.ts +24 -0
  58. package/dist/primitives/paragraph_merge_formatting.d.ts.map +1 -0
  59. package/dist/primitives/paragraph_merge_formatting.js +86 -0
  60. package/dist/primitives/paragraph_merge_formatting.js.map +1 -0
  61. package/dist/primitives/paragraph_structure.d.ts +19 -0
  62. package/dist/primitives/paragraph_structure.d.ts.map +1 -0
  63. package/dist/primitives/paragraph_structure.js +66 -0
  64. package/dist/primitives/paragraph_structure.js.map +1 -0
  65. package/dist/primitives/reject_changes.d.ts +3 -4
  66. package/dist/primitives/reject_changes.d.ts.map +1 -1
  67. package/dist/primitives/reject_changes.js +166 -79
  68. package/dist/primitives/reject_changes.js.map +1 -1
  69. package/dist/primitives/relationships.d.ts +22 -0
  70. package/dist/primitives/relationships.d.ts.map +1 -1
  71. package/dist/primitives/relationships.js +87 -0
  72. package/dist/primitives/relationships.js.map +1 -1
  73. package/dist/primitives/revision-parts.d.ts +15 -0
  74. package/dist/primitives/revision-parts.d.ts.map +1 -1
  75. package/dist/primitives/revision-parts.js +33 -1
  76. package/dist/primitives/revision-parts.js.map +1 -1
  77. package/dist/primitives/sections.d.ts +2 -1
  78. package/dist/primitives/sections.d.ts.map +1 -1
  79. package/dist/primitives/sections.js +2 -2
  80. package/dist/primitives/sections.js.map +1 -1
  81. package/dist/primitives/styles.d.ts +1 -0
  82. package/dist/primitives/styles.d.ts.map +1 -1
  83. package/dist/primitives/styles.js +1 -0
  84. package/dist/primitives/styles.js.map +1 -1
  85. package/dist/primitives/table_cells.d.ts +49 -0
  86. package/dist/primitives/table_cells.d.ts.map +1 -0
  87. package/dist/primitives/table_cells.js +149 -0
  88. package/dist/primitives/table_cells.js.map +1 -0
  89. package/dist/primitives/table_columns.d.ts +50 -0
  90. package/dist/primitives/table_columns.d.ts.map +1 -0
  91. package/dist/primitives/table_columns.js +138 -0
  92. package/dist/primitives/table_columns.js.map +1 -0
  93. package/dist/primitives/table_edit_common.d.ts +39 -0
  94. package/dist/primitives/table_edit_common.d.ts.map +1 -0
  95. package/dist/primitives/table_edit_common.js +163 -0
  96. package/dist/primitives/table_edit_common.js.map +1 -0
  97. package/dist/primitives/table_occupancy.d.ts +39 -0
  98. package/dist/primitives/table_occupancy.d.ts.map +1 -0
  99. package/dist/primitives/table_occupancy.js +145 -0
  100. package/dist/primitives/table_occupancy.js.map +1 -0
  101. package/dist/primitives/table_rows.d.ts +56 -0
  102. package/dist/primitives/table_rows.d.ts.map +1 -0
  103. package/dist/primitives/table_rows.js +390 -0
  104. package/dist/primitives/table_rows.js.map +1 -0
  105. package/dist/primitives/text.d.ts +40 -1
  106. package/dist/primitives/text.d.ts.map +1 -1
  107. package/dist/primitives/text.js +309 -129
  108. package/dist/primitives/text.js.map +1 -1
  109. package/dist/primitives/track-changes-emitter.d.ts +16 -2
  110. package/dist/primitives/track-changes-emitter.d.ts.map +1 -1
  111. package/dist/primitives/track-changes-emitter.js +38 -13
  112. package/dist/primitives/track-changes-emitter.js.map +1 -1
  113. package/dist/primitives/zip.d.ts +22 -1
  114. package/dist/primitives/zip.d.ts.map +1 -1
  115. package/dist/primitives/zip.js +47 -8
  116. package/dist/primitives/zip.js.map +1 -1
  117. package/dist/shared/docx/DocxArchive.d.ts +6 -2
  118. package/dist/shared/docx/DocxArchive.d.ts.map +1 -1
  119. package/dist/shared/docx/DocxArchive.js +10 -4
  120. package/dist/shared/docx/DocxArchive.js.map +1 -1
  121. package/dist/shared/field-structure.d.ts +3 -0
  122. package/dist/shared/field-structure.d.ts.map +1 -1
  123. package/dist/shared/field-structure.js +7 -5
  124. package/dist/shared/field-structure.js.map +1 -1
  125. package/package.json +3 -4
@@ -2,9 +2,10 @@ 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';
@@ -15,7 +16,7 @@ import { parseNumberingXml } from './numbering.js';
15
16
  import { getDirectParagraphNumbering, setDirectParagraphNumbering, } from './paragraph_numbering.js';
16
17
  import { getDocumentSections, insertSectionBreak, setSectionPageNumberStart, updateSectionProperties, } from './sections.js';
17
18
  import { findUniqueSubstringMatch } from './matching.js';
18
- import { parseDocumentRels } from './relationships.js';
19
+ import { parseDocumentRels, relationshipPartPath } from './relationships.js';
19
20
  import { setParagraphSpacing, setTableCellPadding, setTableRowHeight, } from './layout.js';
20
21
  import { extractTables, } from './tables.js';
21
22
  import { mergeRuns } from './merge_runs.js';
@@ -24,13 +25,16 @@ import { simplifyRedlines } from './simplify_redlines.js';
24
25
  import { preventDoubleElevation } from './prevent_double_elevation.js';
25
26
  import { validateDocument } from './validate_document.js';
26
27
  import { validateAiRevisions as validateAiRevisionsImpl, } from './validate_ai_revisions.js';
27
- import { enumerateRevisionStoryPartPaths, REVISION_STORY_PART_PATHS, } from './revision-parts.js';
28
+ import { enumerateSelectedHeaderFooterPartPaths, enumerateSelectedRevisionStoryPartPaths, } from './revision-parts.js';
28
29
  import { acceptChanges as acceptChangesImpl } from './accept_changes.js';
29
30
  import { rejectChanges as rejectChangesImpl } from './reject_changes.js';
30
31
  import { collectRevisionElements, resolveSelectedIds, detectAmbiguousOverlaps, selectedIdFilter, AmbiguousRevisionOverlapError, } from './accept_ai_edits.js';
31
32
  import { revisionElementId } from './accept_changes.js';
32
33
  import { TRACKED_CHANGE_ELEMENT_NAME_SET } from './revision-vocabulary.js';
33
- 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';
34
38
  import { bootstrapFootnoteParts, getFootnotes as getFootnotesImpl, getFootnote as getFootnoteImpl, addFootnote as addFootnoteImpl, updateFootnoteText as updateFootnoteTextImpl, deleteFootnote as deleteFootnoteImpl, } from './footnotes.js';
35
39
  function emptyAcceptChangesResult() {
36
40
  return {
@@ -154,6 +158,74 @@ function nextElementSibling(node) {
154
158
  }
155
159
  return null;
156
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
+ }
157
229
  function collectNamedBookmarkIds(doc) {
158
230
  const ids = new Set();
159
231
  const starts = doc.getElementsByTagNameNS(OOXML.W_NS, W.bookmarkStart);
@@ -176,6 +248,7 @@ export class DocxDocument {
176
248
  relsMap;
177
249
  dirty;
178
250
  documentViewCache;
251
+ paragraphBookmarkReservation;
179
252
  /**
180
253
  * Raw document.xml text as loaded, before normalize()/edits mutate the DOM.
181
254
  * Reference for minimal re-serialization in toBuffer(); null for instances
@@ -192,12 +265,16 @@ export class DocxDocument {
192
265
  this.relsMap = relsMap;
193
266
  this.dirty = false;
194
267
  this.documentViewCache = null;
268
+ this.paragraphBookmarkReservation = undefined;
195
269
  this.originalDocumentXmlText = originalDocumentXmlText;
196
270
  }
197
271
  static async load(buffer) {
198
272
  const zip = await DocxZip.load(buffer);
199
273
  const xml = await zip.readText('word/document.xml');
200
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);
201
278
  // Optional parts used for fidelity: list labels + style fingerprints.
202
279
  const stylesText = await zip.readTextOrNull('word/styles.xml');
203
280
  const themeText = await zip.readTextOrNull('word/theme/theme1.xml');
@@ -211,7 +288,15 @@ export class DocxDocument {
211
288
  // Load document relationships for hyperlink resolution.
212
289
  const relsText = await zip.readTextOrNull('word/_rels/document.xml.rels');
213
290
  const relsMap = relsText ? parseDocumentRels(parseXml(relsText)) : new Map();
214
- return new DocxDocument(zip, doc, stylesXml, themeXml, 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;
215
300
  }
216
301
  getParagraphs() {
217
302
  const body = this.documentXml.getElementsByTagNameNS(OOXML.W_NS, W.body).item(0);
@@ -228,8 +313,184 @@ export class DocxDocument {
228
313
  return null;
229
314
  return getParagraphText(p);
230
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
+ }
231
492
  insertParagraphBookmarks(attachmentId) {
232
- const res = insertParagraphBookmarks(this.documentXml, attachmentId);
493
+ const res = insertParagraphBookmarks(this.documentXml, attachmentId, this.paragraphBookmarkReservation);
233
494
  if (res.indexedParagraphs > 0)
234
495
  this.dirty = true;
235
496
  return { paragraphCount: res.indexedParagraphs };
@@ -270,7 +531,7 @@ export class DocxDocument {
270
531
  }
271
532
  async validateAiRevisions(aiAuthor, touched) {
272
533
  const stories = [{ part: 'word/document.xml', doc: this.documentXml }];
273
- for (const partPath of enumerateRevisionStoryPartPaths(this.zip)) {
534
+ for (const partPath of await enumerateSelectedRevisionStoryPartPaths(this.zip)) {
274
535
  const xml = await this.zip.readTextOrNull(partPath);
275
536
  if (!xml)
276
537
  continue;
@@ -298,7 +559,7 @@ export class DocxDocument {
298
559
  const liveFootnoteRefIds = bodyResult.deletionsAccepted > 0
299
560
  ? collectLiveFootnoteRefIds(this.documentXml)
300
561
  : null;
301
- for (const partPath of REVISION_STORY_PART_PATHS) {
562
+ for (const partPath of await enumerateSelectedRevisionStoryPartPaths(this.zip)) {
302
563
  const xml = await this.zip.readTextOrNull(partPath);
303
564
  if (!xml)
304
565
  continue;
@@ -336,7 +597,7 @@ export class DocxDocument {
336
597
  const liveFootnoteRefIds = bodyResult.insertionsRemoved > 0
337
598
  ? collectLiveFootnoteRefIds(this.documentXml)
338
599
  : null;
339
- for (const partPath of REVISION_STORY_PART_PATHS) {
600
+ for (const partPath of await enumerateSelectedRevisionStoryPartPaths(this.zip)) {
340
601
  const xml = await this.zip.readTextOrNull(partPath);
341
602
  if (!xml)
342
603
  continue;
@@ -370,7 +631,7 @@ export class DocxDocument {
370
631
  const stories = [
371
632
  { path: null, doc: this.documentXml },
372
633
  ];
373
- for (const partPath of REVISION_STORY_PART_PATHS) {
634
+ for (const partPath of await enumerateSelectedRevisionStoryPartPaths(this.zip)) {
374
635
  const xml = await this.zip.readTextOrNull(partPath);
375
636
  if (xml)
376
637
  stories.push({ path: partPath, doc: parseXml(xml) });
@@ -564,6 +825,14 @@ export class DocxDocument {
564
825
  this.dirty = true;
565
826
  this.documentViewCache = null;
566
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
+ }
567
836
  insertParagraph(params, ctx) {
568
837
  const { positionalAnchorNodeId, relativePosition, newText, newParagraphId: _newParagraphId, styleSourceId, runStyleSourceText } = params;
569
838
  const anchor = findParagraphByBookmarkId(this.documentXml, positionalAnchorNodeId);
@@ -794,7 +1063,7 @@ export class DocxDocument {
794
1063
  newP.appendChild(newRun);
795
1064
  }
796
1065
  parent.insertBefore(newP, cursor);
797
- const id = insertSingleParagraphBookmark(doc, newP);
1066
+ const id = insertSingleParagraphBookmark(doc, newP, this.paragraphBookmarkReservation);
798
1067
  insertedIds.push(id);
799
1068
  if (relativePosition === 'AFTER') {
800
1069
  const endEl = nextElementSibling(newP);
@@ -834,7 +1103,7 @@ export class DocxDocument {
834
1103
  return getDocumentSections(this.documentXml);
835
1104
  }
836
1105
  insertSectionBreak(mutation, ctx) {
837
- const result = insertSectionBreak(this.documentXml, mutation, ctx);
1106
+ const result = insertSectionBreak(this.documentXml, mutation, ctx, this.paragraphBookmarkReservation);
838
1107
  this.dirty = true;
839
1108
  this.documentViewCache = null;
840
1109
  return result;
@@ -910,6 +1179,8 @@ export class DocxDocument {
910
1179
  author: params.author,
911
1180
  text: params.text,
912
1181
  initials: params.initials,
1182
+ body: params.body,
1183
+ date: params.date,
913
1184
  }, ctx);
914
1185
  this.dirty = true;
915
1186
  this.documentViewCache = null;
@@ -928,13 +1199,15 @@ export class DocxDocument {
928
1199
  author: params.author,
929
1200
  text: params.text,
930
1201
  initials: params.initials,
1202
+ body: params.body,
1203
+ date: params.date,
931
1204
  }, ctx);
932
1205
  this.dirty = true;
933
1206
  this.documentViewCache = null;
934
1207
  return result;
935
1208
  }
936
1209
  async getComments() {
937
- return getCommentsImpl(this.zip, this.documentXml);
1210
+ return getCommentsImpl(this.zip, this.documentXml, this.getStylesModel(), parseThemeXml(this.themeXml));
938
1211
  }
939
1212
  async getComment(commentId) {
940
1213
  return getCommentImpl(this.zip, this.documentXml, commentId);
@@ -944,6 +1217,10 @@ export class DocxDocument {
944
1217
  this.dirty = true;
945
1218
  this.documentViewCache = null;
946
1219
  }
1220
+ async updateCommentBody(params) {
1221
+ await updateCommentBodyImpl(this.zip, params);
1222
+ this.dirty = true;
1223
+ }
947
1224
  /**
948
1225
  * Convert selected root Word comments to footnotes in document order.
949
1226
  * The method preflights the complete selection before mutating the package;
@@ -994,21 +1271,17 @@ export class DocxDocument {
994
1271
  const paragraph = findParagraphByBookmarkId(this.documentXml, comment.anchoredParagraphId);
995
1272
  if (!paragraph)
996
1273
  throw new Error(`Comment ID ${comment.id} anchor was not found`);
997
- let afterText;
998
1274
  if (comment.endTextOffset !== undefined) {
999
1275
  const paragraphText = getParagraphText(paragraph);
1000
1276
  if (comment.endTextOffset > paragraphText.length) {
1001
1277
  throw new Error(`Comment ID ${comment.id} has an invalid visible-text range endpoint`);
1002
1278
  }
1003
- afterText = paragraphText.slice(0, comment.endTextOffset);
1004
- if (!afterText)
1005
- afterText = undefined;
1006
1279
  }
1007
1280
  const replyText = comment.replies.map((reply) => `\n${reply.author ? `${reply.author}: ` : ''}${reply.text}`).join('');
1008
1281
  return {
1009
1282
  comment,
1010
1283
  paragraph,
1011
- afterText,
1284
+ visibleOffset: comment.endTextOffset,
1012
1285
  text: `${comment.text}${replyText}`,
1013
1286
  };
1014
1287
  });
@@ -1021,7 +1294,7 @@ export class DocxDocument {
1021
1294
  // not the adjacent footnote run introduced here.
1022
1295
  const result = await addFootnoteImpl(this.documentXml, this.zip, {
1023
1296
  paragraphEl: item.paragraph,
1024
- afterText: item.afterText,
1297
+ visibleOffset: item.visibleOffset,
1025
1298
  text: item.text,
1026
1299
  presentation: options.presentation,
1027
1300
  });
@@ -1110,6 +1383,7 @@ export class DocxDocument {
1110
1383
  const result = await addFootnoteImpl(this.documentXml, this.zip, {
1111
1384
  paragraphEl: p,
1112
1385
  afterText: params.afterText,
1386
+ visibleOffset: params.visibleOffset,
1113
1387
  text: params.text,
1114
1388
  presentation: params.presentation,
1115
1389
  }, ctx);
@@ -1154,6 +1428,20 @@ export class DocxDocument {
1154
1428
  return null;
1155
1429
  return parseXml(commentsText);
1156
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
+ }
1157
1445
  /**
1158
1446
  * Serialize the document to a .docx buffer.
1159
1447
  *
@@ -1165,9 +1453,14 @@ export class DocxDocument {
1165
1453
  * Falls back to full re-serialization (blocksRestored: 0) when no original
1166
1454
  * text was captured or reconciliation fails.
1167
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
+ *
1168
1459
  * @see https://github.com/UseJunior/safe-docx/issues/408
1460
+ * @see https://github.com/UseJunior/safe-docx/issues/1110
1169
1461
  */
1170
1462
  async toBuffer(opts) {
1463
+ const zipOptions = opts?.fileDate ? { fileDate: opts.fileDate } : undefined;
1171
1464
  // Always write the latest document.xml when saving.
1172
1465
  // Important: when cleanBookmarks=true (download), we must NOT mutate session state.
1173
1466
  const xmlWithBookmarks = serializeXml(this.documentXml);
@@ -1194,11 +1487,11 @@ export class DocxDocument {
1194
1487
  // Temporarily swap document.xml in the zip for output, then restore.
1195
1488
  maybeCaptureEmittedDocumentXml(cleanedXml);
1196
1489
  this.zip.writeText('word/document.xml', cleanedXml);
1197
- const buffer = await this.zip.toBuffer();
1490
+ const buffer = await this.zip.toBuffer(zipOptions);
1198
1491
  this.zip.writeText('word/document.xml', xmlWithBookmarks);
1199
1492
  return { buffer, bookmarksRemoved, blocksRestored };
1200
1493
  }
1201
- const buffer = await this.zip.toBuffer();
1494
+ const buffer = await this.zip.toBuffer(zipOptions);
1202
1495
  return { buffer, bookmarksRemoved: 0, blocksRestored: 0 };
1203
1496
  }
1204
1497
  /**