@ansonlai/docx-redline-js 0.8.3 → 0.9.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 (71) hide show
  1. package/ARCHITECTURE.md +221 -12
  2. package/CHANGELOG.md +803 -586
  3. package/README.md +1018 -901
  4. package/core/current-properties.js +136 -0
  5. package/core/paragraph-text.js +211 -209
  6. package/core/types.js +9 -3
  7. package/core/word-revision-semantics.js +256 -0
  8. package/dist/docx-redline-js.esm.js +4551 -967
  9. package/dist/docx-redline-js.esm.js.map +4 -4
  10. package/dist/docx-redline-js.esm.min.js +112 -106
  11. package/dist/docx-redline-js.esm.min.js.map +4 -4
  12. package/dist/docx-redline.bundle.cjs +4690 -1106
  13. package/dist/docx-redline.bundle.cjs.map +4 -4
  14. package/dist/docx-redline.bundle.js +4690 -1106
  15. package/dist/docx-redline.bundle.js.map +4 -4
  16. package/docs/AGENT_FAST_START.md +2 -0
  17. package/docs/AGENT_KNOWLEDGE_BASE.md +23 -4
  18. package/docs/SKILL_AUTHORING.md +165 -150
  19. package/docs/TESTING.md +1142 -1059
  20. package/docs/schemas/document-operations.schema.json +221 -168
  21. package/document/docx-document.js +1022 -1010
  22. package/engine/oxml-engine.js +567 -560
  23. package/engine/paragraph-mark-revisions.js +292 -0
  24. package/engine/reconstruction-mapper.js +312 -311
  25. package/engine/reconstruction-mode.js +183 -183
  26. package/engine/reconstruction-writer.js +67 -10
  27. package/engine/rpr-helpers.js +42 -0
  28. package/engine/run-builders.js +0 -73
  29. package/engine/surgical-diff-application.js +604 -605
  30. package/engine/table-mode.js +63 -63
  31. package/index.d.ts +51 -1
  32. package/index.js +47 -3
  33. package/node/cli-help.js +1 -0
  34. package/node/cli.js +992 -974
  35. package/orchestration/list-structural-fallback.js +67 -9
  36. package/package.json +1 -1
  37. package/pipeline/diff-engine.js +130 -1
  38. package/pipeline/ingestion-export.js +227 -243
  39. package/pipeline/ingestion-paragraph.js +93 -98
  40. package/pipeline/list-generation.js +415 -419
  41. package/pipeline/pipeline.js +206 -204
  42. package/pipeline/serialization.js +111 -94
  43. package/scripts/export-validation-fixtures.mjs +19 -6
  44. package/scripts/generate-word-revision-semantics-matrix.mjs +134 -0
  45. package/scripts/lib/word-coverage-catalogue.mjs +7 -2
  46. package/scripts/render-multilevel-bullet-visual.ps1 +2 -2
  47. package/scripts/word-com-corpus-visual-suite.ps1 +2 -2
  48. package/scripts/word-com-differential.ps1 +443 -335
  49. package/scripts/word-com-visual-suite.ps1 +2 -2
  50. package/scripts/word-revision-semantics-matrix.ps1 +108 -0
  51. package/services/batch-operation-orchestrator.js +26 -0
  52. package/services/capture-engine.js +228 -226
  53. package/services/document-operation-applier.js +215 -0
  54. package/services/document-operation-contract.js +628 -537
  55. package/services/document-operation-mutations.js +789 -46
  56. package/services/error-recovery.js +17 -0
  57. package/services/list-conversion-diagnostics.js +296 -0
  58. package/services/list-format-change.js +586 -0
  59. package/services/list-format-mutation.js +416 -0
  60. package/services/list-format.js +305 -0
  61. package/services/numbering-allocation.js +642 -0
  62. package/services/numbering-helpers.js +354 -524
  63. package/services/operation-batch-compiler.js +612 -436
  64. package/services/operation-heuristics.js +197 -173
  65. package/services/operation-preflight.js +1 -0
  66. package/services/receipt-collector.js +288 -288
  67. package/services/revision-comment-management.js +634 -594
  68. package/services/revision-fidelity-verifier.js +498 -0
  69. package/services/standalone-docx-plumbing.js +627 -631
  70. package/services/standalone-operation-runner.d.ts +606 -427
  71. package/services/table-reconciliation.js +103 -49
@@ -1,183 +1,183 @@
1
- /**
2
- * Reconstruction reconciliation mode orchestration.
3
- */
4
-
5
- import { computeWordDiffs } from '../pipeline/diff-engine.js';
6
- import { buildReconstructionMapping, findReconstructionParagraphRange } from './reconstruction-mapper.js';
7
- import { applyReconstructionDiffs } from './reconstruction-writer.js';
8
- import { withOoxmlSourceType } from '../core/word-xml.js';
9
-
10
- /**
11
- * Applies reconstruction mode reconciliation.
12
- *
13
- * @param {Document} xmlDoc - XML document
14
- * @param {string} originalText - Original text (kept for signature compatibility)
15
- * @param {string} modifiedText - Modified text
16
- * @param {XMLSerializer} serializer - Serializer instance
17
- * @param {string} author - Author name
18
- * @param {Array} formatHints - Format hints
19
- * @param {boolean} [generateRedlines=true] - Track change toggle
20
- * @param {{ diffTimeoutSeconds?: number }} [diffOptions={}] - Diff configuration
21
- * @returns {{ oxml: string, hasChanges: boolean }}
22
- */
23
- export function applyReconstructionMode(xmlDoc, originalText, modifiedText, serializer, author, formatHints, generateRedlines = true, diffOptions = {}, options = {}) {
24
- const selectedParagraphs = findReconstructionParagraphRange(xmlDoc, originalText);
25
- if (selectedParagraphs === null) {
26
- return withOoxmlSourceType({
27
- oxml: serializer.serializeToString(xmlDoc),
28
- hasChanges: false,
29
- status: 'error',
30
- error: {
31
- code: 'PARTIAL_TARGET',
32
- message: 'Original text did not identify a complete contiguous paragraph range for reconstruction.'
33
- }
34
- });
35
- }
36
-
37
- const mapping = buildReconstructionMapping(xmlDoc, modifiedText, selectedParagraphs);
38
- if (mapping.paragraphs.length === 0) {
39
- return withOoxmlSourceType({ oxml: serializer.serializeToString(xmlDoc), hasChanges: false });
40
- }
41
-
42
- const wordDiffs = computeWordDiffs(mapping.originalFullText, mapping.processedModifiedText, {
43
- ...diffOptions,
44
- atomicChars: new Set(mapping.referenceMap.keys())
45
- });
46
- const diffs = keepUnchangedTextOutsideWrappers(anchorSharedReferences(wordDiffs, mapping.referenceMap), mapping);
47
-
48
- return withOoxmlSourceType(applyReconstructionDiffs(
49
- xmlDoc,
50
- diffs,
51
- mapping,
52
- serializer,
53
- author,
54
- formatHints,
55
- generateRedlines,
56
- options
57
- ));
58
- }
59
-
60
- /** Splits diffs into runs of equal parts and change groups (deleted + inserted text). */
61
- function forEachChangeGroup(diffs, onEqual, onGroup) {
62
- let index = 0;
63
- while (index < diffs.length) {
64
- if (diffs[index][0] === 0) {
65
- onEqual(diffs[index]);
66
- index++;
67
- continue;
68
- }
69
- const group = [];
70
- while (index < diffs.length && diffs[index][0] !== 0) group.push(diffs[index++]);
71
- onGroup(
72
- group,
73
- group.filter(([op]) => op === -1).map(([, text]) => text).join(''),
74
- group.filter(([op]) => op === 1).map(([, text]) => text).join('')
75
- );
76
- }
77
- }
78
-
79
- function pushChange(result, deleted, inserted) {
80
- if (deleted === inserted) {
81
- if (deleted) result.push([0, deleted]);
82
- return;
83
- }
84
- if (deleted) result.push([-1, deleted]);
85
- if (inserted) result.push([1, inserted]);
86
- }
87
-
88
- /**
89
- * The writer keeps a reference placeholder (manual break, note reference) that
90
- * appears in both the deleted and inserted text of one change group, but only
91
- * emits it with the insertion, after all deleted text. Semantic cleanup can
92
- * merge edits on both sides of a break into such a group, so Reject All would
93
- * move deleted text across the break. Split the group at shared references so
94
- * each one stays an unchanged anchor between its own before/after edits.
95
- */
96
- function anchorSharedReferences(diffs, referenceMap) {
97
- if (referenceMap.size === 0) return diffs;
98
- const result = [];
99
- forEachChangeGroup(diffs, part => result.push(part), (group, deleted, inserted) => {
100
- const deletedRefs = [...deleted].filter(char => referenceMap.has(char) && inserted.includes(char));
101
- const insertedRefs = [...inserted].filter(char => deletedRefs.includes(char));
102
- if (deletedRefs.length === 0 || deletedRefs.join('') !== insertedRefs.join('')) {
103
- result.push(...group);
104
- return;
105
- }
106
- let deletedStart = 0;
107
- let insertedStart = 0;
108
- for (const ref of deletedRefs) {
109
- const deletedAt = deleted.indexOf(ref, deletedStart);
110
- const insertedAt = inserted.indexOf(ref, insertedStart);
111
- pushChange(result, deleted.slice(deletedStart, deletedAt), inserted.slice(insertedStart, insertedAt));
112
- result.push([0, ref]);
113
- deletedStart = deletedAt + 1;
114
- insertedStart = insertedAt + 1;
115
- }
116
- pushChange(result, deleted.slice(deletedStart), inserted.slice(insertedStart));
117
- });
118
- return result;
119
- }
120
-
121
- function commonPrefixLength(left, right) {
122
- const limit = Math.min(left.length, right.length);
123
- let length = 0;
124
- while (length < limit && left[length] === right[length]) length++;
125
- return length;
126
- }
127
-
128
- function commonSuffixLength(left, right, limit) {
129
- let length = 0;
130
- while (length < limit && left[left.length - 1 - length] === right[right.length - 1 - length]) length++;
131
- return length;
132
- }
133
-
134
- /**
135
- * Word tokens can span a hyperlink boundary (`example.org` + plain `.`), so a
136
- * replaced token would delete and reinsert unchanged text across it and move
137
- * that text into or out of the link. Where the replacement's shared prefix or
138
- * suffix reaches a wrapper boundary in the original, keep that part unchanged.
139
- */
140
- function keepUnchangedTextOutsideWrappers(diffs, mapping) {
141
- const wrapperAt = index => mapping.getRunProperties(index).wrapper || null;
142
- const result = [];
143
- let originalOffset = 0;
144
-
145
- forEachChangeGroup(diffs, part => {
146
- result.push(part);
147
- originalOffset += part[1].length;
148
- }, (group, deleted, inserted) => {
149
- const start = originalOffset;
150
- const end = start + deleted.length;
151
- originalOffset = end;
152
- // Only a boundary strictly inside the deleted text splits it; an edge
153
- // of the whole change (`link` -> `hyperlink` inside a link) does not.
154
- const isWrapperBoundary = index => index > start && index < end
155
- && wrapperAt(index - 1) !== wrapperAt(index);
156
-
157
- let prefix = 0;
158
- let suffix = 0;
159
- if (deleted && inserted) {
160
- const sharedSuffix = commonSuffixLength(deleted, inserted, Math.min(deleted.length, inserted.length));
161
- for (let length = sharedSuffix; length > 0; length--) {
162
- if (isWrapperBoundary(end - length)) { suffix = length; break; }
163
- }
164
- const sharedPrefix = Math.min(
165
- commonPrefixLength(deleted, inserted),
166
- Math.min(deleted.length, inserted.length) - suffix
167
- );
168
- for (let length = sharedPrefix; length > 0; length--) {
169
- if (isWrapperBoundary(start + length)) { prefix = length; break; }
170
- }
171
- }
172
- if (prefix === 0 && suffix === 0) {
173
- result.push(...group);
174
- return;
175
- }
176
-
177
- if (prefix > 0) result.push([0, deleted.slice(0, prefix)]);
178
- pushChange(result, deleted.slice(prefix, deleted.length - suffix), inserted.slice(prefix, inserted.length - suffix));
179
- if (suffix > 0) result.push([0, deleted.slice(deleted.length - suffix)]);
180
- });
181
-
182
- return result;
183
- }
1
+ /**
2
+ * Reconstruction reconciliation mode orchestration.
3
+ */
4
+
5
+ import { computeWordDiffs } from '../pipeline/diff-engine.js';
6
+ import { buildReconstructionMapping, findReconstructionParagraphRange } from './reconstruction-mapper.js';
7
+ import { applyReconstructionDiffs } from './reconstruction-writer.js';
8
+ import { withOoxmlSourceType } from '../core/word-xml.js';
9
+
10
+ /**
11
+ * Applies reconstruction mode reconciliation.
12
+ *
13
+ * @param {Document} xmlDoc - XML document
14
+ * @param {string} originalText - Original text (kept for signature compatibility)
15
+ * @param {string} modifiedText - Modified text
16
+ * @param {XMLSerializer} serializer - Serializer instance
17
+ * @param {string} author - Author name
18
+ * @param {Array} formatHints - Format hints
19
+ * @param {boolean} [generateRedlines=true] - Track change toggle
20
+ * @param {{ diffTimeoutSeconds?: number }} [diffOptions={}] - Diff configuration
21
+ * @returns {{ oxml: string, hasChanges: boolean }}
22
+ */
23
+ export function applyReconstructionMode(xmlDoc, originalText, modifiedText, serializer, author, formatHints, generateRedlines = true, diffOptions = {}, options = {}) {
24
+ const selectedParagraphs = findReconstructionParagraphRange(xmlDoc, originalText);
25
+ if (selectedParagraphs === null) {
26
+ return withOoxmlSourceType({
27
+ oxml: serializer.serializeToString(xmlDoc),
28
+ hasChanges: false,
29
+ status: 'error',
30
+ error: {
31
+ code: 'PARTIAL_TARGET',
32
+ message: 'Original text did not identify a complete contiguous paragraph range for reconstruction.'
33
+ }
34
+ });
35
+ }
36
+
37
+ const mapping = buildReconstructionMapping(xmlDoc, modifiedText, selectedParagraphs);
38
+ if (mapping.paragraphs.length === 0) {
39
+ return withOoxmlSourceType({ oxml: serializer.serializeToString(xmlDoc), hasChanges: false });
40
+ }
41
+
42
+ const wordDiffs = computeWordDiffs(mapping.originalFullText, mapping.processedModifiedText, {
43
+ ...diffOptions,
44
+ atomicChars: new Set(mapping.referenceMap.keys())
45
+ });
46
+ const diffs = keepUnchangedTextOutsideWrappers(anchorSharedReferences(wordDiffs, mapping.referenceMap), mapping);
47
+
48
+ return withOoxmlSourceType(applyReconstructionDiffs(
49
+ xmlDoc,
50
+ diffs,
51
+ mapping,
52
+ serializer,
53
+ author,
54
+ formatHints,
55
+ generateRedlines,
56
+ options
57
+ ));
58
+ }
59
+
60
+ /** Splits diffs into runs of equal parts and change groups (deleted + inserted text). */
61
+ function forEachChangeGroup(diffs, onEqual, onGroup) {
62
+ let index = 0;
63
+ while (index < diffs.length) {
64
+ if (diffs[index][0] === 0) {
65
+ onEqual(diffs[index]);
66
+ index++;
67
+ continue;
68
+ }
69
+ const group = [];
70
+ while (index < diffs.length && diffs[index][0] !== 0) group.push(diffs[index++]);
71
+ onGroup(
72
+ group,
73
+ group.filter(([op]) => op === -1).map(([, text]) => text).join(''),
74
+ group.filter(([op]) => op === 1).map(([, text]) => text).join('')
75
+ );
76
+ }
77
+ }
78
+
79
+ function pushChange(result, deleted, inserted) {
80
+ if (deleted === inserted) {
81
+ if (deleted) result.push([0, deleted]);
82
+ return;
83
+ }
84
+ if (deleted) result.push([-1, deleted]);
85
+ if (inserted) result.push([1, inserted]);
86
+ }
87
+
88
+ /**
89
+ * The writer keeps a reference placeholder (manual break, note reference) that
90
+ * appears in both the deleted and inserted text of one change group, but only
91
+ * emits it with the insertion, after all deleted text. Semantic cleanup can
92
+ * merge edits on both sides of a break into such a group, so Reject All would
93
+ * move deleted text across the break. Split the group at shared references so
94
+ * each one stays an unchanged anchor between its own before/after edits.
95
+ */
96
+ function anchorSharedReferences(diffs, referenceMap) {
97
+ if (referenceMap.size === 0) return diffs;
98
+ const result = [];
99
+ forEachChangeGroup(diffs, part => result.push(part), (group, deleted, inserted) => {
100
+ const deletedRefs = [...deleted].filter(char => referenceMap.has(char) && inserted.includes(char));
101
+ const insertedRefs = [...inserted].filter(char => deletedRefs.includes(char));
102
+ if (deletedRefs.length === 0 || deletedRefs.join('') !== insertedRefs.join('')) {
103
+ result.push(...group);
104
+ return;
105
+ }
106
+ let deletedStart = 0;
107
+ let insertedStart = 0;
108
+ for (const ref of deletedRefs) {
109
+ const deletedAt = deleted.indexOf(ref, deletedStart);
110
+ const insertedAt = inserted.indexOf(ref, insertedStart);
111
+ pushChange(result, deleted.slice(deletedStart, deletedAt), inserted.slice(insertedStart, insertedAt));
112
+ result.push([0, ref]);
113
+ deletedStart = deletedAt + 1;
114
+ insertedStart = insertedAt + 1;
115
+ }
116
+ pushChange(result, deleted.slice(deletedStart), inserted.slice(insertedStart));
117
+ });
118
+ return result;
119
+ }
120
+
121
+ function commonPrefixLength(left, right) {
122
+ const limit = Math.min(left.length, right.length);
123
+ let length = 0;
124
+ while (length < limit && left[length] === right[length]) length++;
125
+ return length;
126
+ }
127
+
128
+ function commonSuffixLength(left, right, limit) {
129
+ let length = 0;
130
+ while (length < limit && left[left.length - 1 - length] === right[right.length - 1 - length]) length++;
131
+ return length;
132
+ }
133
+
134
+ /**
135
+ * Word tokens can span a hyperlink boundary (`example.org` + plain `.`), so a
136
+ * replaced token would delete and reinsert unchanged text across it and move
137
+ * that text into or out of the link. Where the replacement's shared prefix or
138
+ * suffix reaches a wrapper boundary in the original, keep that part unchanged.
139
+ */
140
+ function keepUnchangedTextOutsideWrappers(diffs, mapping) {
141
+ const wrapperAt = index => mapping.getRunProperties(index).wrapper || null;
142
+ const result = [];
143
+ let originalOffset = 0;
144
+
145
+ forEachChangeGroup(diffs, part => {
146
+ result.push(part);
147
+ originalOffset += part[1].length;
148
+ }, (group, deleted, inserted) => {
149
+ const start = originalOffset;
150
+ const end = start + deleted.length;
151
+ originalOffset = end;
152
+ // Only a boundary strictly inside the deleted text splits it; an edge
153
+ // of the whole change (`link` -> `hyperlink` inside a link) does not.
154
+ const isWrapperBoundary = index => index > start && index < end
155
+ && wrapperAt(index - 1) !== wrapperAt(index);
156
+
157
+ let prefix = 0;
158
+ let suffix = 0;
159
+ if (deleted && inserted) {
160
+ const sharedSuffix = commonSuffixLength(deleted, inserted, Math.min(deleted.length, inserted.length));
161
+ for (let length = sharedSuffix; length > 0; length--) {
162
+ if (isWrapperBoundary(end - length)) { suffix = length; break; }
163
+ }
164
+ const sharedPrefix = Math.min(
165
+ commonPrefixLength(deleted, inserted),
166
+ Math.min(deleted.length, inserted.length) - suffix
167
+ );
168
+ for (let length = sharedPrefix; length > 0; length--) {
169
+ if (isWrapperBoundary(start + length)) { prefix = length; break; }
170
+ }
171
+ }
172
+ if (prefix === 0 && suffix === 0) {
173
+ result.push(...group);
174
+ return;
175
+ }
176
+
177
+ if (prefix > 0) result.push([0, deleted.slice(0, prefix)]);
178
+ pushChange(result, deleted.slice(prefix, deleted.length - suffix), inserted.slice(prefix, inserted.length - suffix));
179
+ if (suffix > 0) result.push([0, deleted.slice(deleted.length - suffix)]);
180
+ });
181
+
182
+ return result;
183
+ }
@@ -7,14 +7,42 @@
7
7
  import { getApplicableFormatHints } from '../pipeline/markdown-processor.js';
8
8
  import {
9
9
  createTrackChange,
10
- createFormattedRuns,
11
- markParagraphMarkDeleted,
12
- markParagraphMarkInserted
10
+ createFormattedRuns
13
11
  } from './run-builders.js';
12
+ import { markParagraphDeleted, markParagraphInserted } from './paragraph-mark-revisions.js';
14
13
  import { getFirstElementByTagNSOrTag } from '../core/xml-query.js';
15
14
  import { NS_W, createReplacementRevisionEvent, createRevisionMetadata } from '../core/types.js';
16
15
  import { createWordElement, isWordElement } from '../core/word-xml.js';
17
16
 
17
+ const NS_W14 = 'http://schemas.microsoft.com/office/word/2010/wordml';
18
+ const IDENTITY_ATTRIBUTES = ['paraId', 'textId'];
19
+
20
+ function hasMarkRevision(paragraph, kind) {
21
+ const pPr = Array.from(paragraph.childNodes).find(node => isWordElement(node, 'pPr'));
22
+ const rPr = pPr && Array.from(pPr.childNodes).find(node => isWordElement(node, 'rPr'));
23
+ return !!rPr && Array.from(rPr.childNodes).some(node => isWordElement(node, kind));
24
+ }
25
+
26
+ /**
27
+ * Carries the source paragraph identity (`w14:paraId`, `w14:textId`) onto the rebuilt paragraph that survives
28
+ * for it. Several rebuilt paragraphs can derive from one source paragraph (an inserted split), so the identity
29
+ * goes to exactly one: the paragraph whose mark is not tracked, else one whose mark is not tracked-deleted,
30
+ * else the first. Ids therefore stay unique; inserted paragraphs carry none, as on
31
+ * every other insertion path (Word assigns them on open).
32
+ */
33
+ function carrySourceIdentity(claims) {
34
+ claims.forEach((rebuilt, source) => {
35
+ const survivor = rebuilt.find(p => !hasMarkRevision(p, 'ins') && !hasMarkRevision(p, 'del'))
36
+ || rebuilt.find(p => !hasMarkRevision(p, 'del'))
37
+ || rebuilt[0];
38
+ if (!survivor) return;
39
+ IDENTITY_ATTRIBUTES.forEach(name => {
40
+ const value = source.getAttributeNS?.(NS_W14, name) || source.getAttribute?.(`w14:${name}`);
41
+ if (value) survivor.setAttributeNS(NS_W14, `w14:${name}`, value);
42
+ });
43
+ });
44
+ }
45
+
18
46
  /**
19
47
  * Applies diffs to reconstruction context and writes updated XML.
20
48
  *
@@ -40,8 +68,13 @@ export function applyReconstructionDiffs(xmlDoc, diffs, context, serializer, aut
40
68
  isParagraphStart
41
69
  } = context;
42
70
 
43
- const createNewParagraph = (pPr) => {
71
+ const identityClaims = new Map();
72
+ const createNewParagraph = (pPr, sourceParagraph = null) => {
44
73
  const newParagraph = createWordElement(xmlDoc, 'w:p');
74
+ if (sourceParagraph) {
75
+ if (!identityClaims.has(sourceParagraph)) identityClaims.set(sourceParagraph, []);
76
+ identityClaims.get(sourceParagraph).push(newParagraph);
77
+ }
45
78
  if (pPr) {
46
79
  const clonedPPr = pPr.cloneNode(true);
47
80
  // Safeguard: Never place w:sectPr on both paragraphs during split
@@ -55,7 +88,7 @@ export function applyReconstructionDiffs(xmlDoc, diffs, context, serializer, aut
55
88
  };
56
89
 
57
90
  const startInfo = getParagraphInfo(0);
58
- let currentParagraph = createNewParagraph(startInfo.pPr);
91
+ let currentParagraph = createNewParagraph(startInfo.pPr, startInfo.paragraph);
59
92
  const initialFragment = containerFragments.get(startInfo.container);
60
93
  if (initialFragment) {
61
94
  initialFragment.appendChild(currentParagraph);
@@ -83,7 +116,7 @@ export function applyReconstructionDiffs(xmlDoc, diffs, context, serializer, aut
83
116
  if (diffs[k][0] === 0) break;
84
117
  }
85
118
  if (pairReplacements && generateRedlines && hasInsert) {
86
- pendingReplacementEvent = createReplacementRevisionEvent(author, xmlDoc);
119
+ pendingReplacementEvent = createReplacementRevisionEvent(author, xmlDoc, { deferReceipt: true });
87
120
  }
88
121
  }
89
122
  let offset = 0;
@@ -189,7 +222,7 @@ export function applyReconstructionDiffs(xmlDoc, diffs, context, serializer, aut
189
222
  const hasVisibleText = Array.from(node.getElementsByTagNameNS(NS_W, 't')).length > 0;
190
223
  const hasDeletedText = Array.from(node.getElementsByTagNameNS(NS_W, 'delText')).length > 0;
191
224
  if (paragraphs.length === 1 && !hasVisibleText && (hasDeletedText || context.originalFullText.trim() !== '')) {
192
- markParagraphMarkDeleted(xmlDoc, node, author);
225
+ markParagraphDeleted(node, { author, revisionIdAllocator: xmlDoc });
193
226
  }
194
227
  }
195
228
  });
@@ -215,6 +248,8 @@ export function applyReconstructionDiffs(xmlDoc, diffs, context, serializer, aut
215
248
  }
216
249
  });
217
250
 
251
+ carrySourceIdentity(identityClaims);
252
+
218
253
  const paragraphSet = new Set(paragraphs);
219
254
  const insertionAnchors = new Map();
220
255
  paragraphs.forEach(paragraph => {
@@ -327,11 +362,11 @@ function appendTextToCurrent(
327
362
 
328
363
  if (part === '\n') {
329
364
  const info = getParagraphInfo(localBaseIndex + 1);
330
- const nextParagraph = createNewParagraph(info.pPr);
365
+ const nextParagraph = createNewParagraph(info.pPr, info.paragraph);
331
366
  if (generateRedlines && type === 'insert') {
332
- markParagraphMarkInserted(xmlDoc, localParagraph, author);
367
+ markParagraphInserted(localParagraph, { author, revisionIdAllocator: xmlDoc });
333
368
  } else if (generateRedlines && type === 'delete') {
334
- markParagraphMarkDeleted(xmlDoc, localParagraph, author);
369
+ markParagraphDeleted(localParagraph, { author, revisionIdAllocator: xmlDoc });
335
370
  }
336
371
 
337
372
  const fragment = containerFragments.get(info.container);
@@ -370,6 +405,26 @@ function appendTextToCurrent(
370
405
  }
371
406
 
372
407
  if (referenceMap.has(part)) {
408
+ if (type === 'delete' && generateRedlines) {
409
+ // A deleted manual break, tab or inline object must be a tracked deletion so Reject All
410
+ // restores it; silently dropping the node loses it from the rejected view.
411
+ const refNode = referenceMap.get(part);
412
+ if (refNode) {
413
+ const run = createWordElement(xmlDoc, 'w:r');
414
+ if (rPr) run.appendChild(rPr.cloneNode(true));
415
+ run.appendChild(refNode.cloneNode(true));
416
+ let metadata = null;
417
+ if (replacementEvent) {
418
+ const id = replacementEvent.usedDeletionId
419
+ ? createRevisionMetadata(author, xmlDoc, 'del').id
420
+ : replacementEvent.deletionId;
421
+ if (!replacementEvent.usedDeletionId) replacementEvent.recordUsed?.('del');
422
+ replacementEvent.usedDeletionId = true;
423
+ metadata = { id, author: replacementEvent.author, date: replacementEvent.date };
424
+ }
425
+ localParagraph.appendChild(createTrackChange(xmlDoc, 'del', run, author, metadata));
426
+ }
427
+ }
373
428
  if (type !== 'delete') {
374
429
  const refNode = referenceMap.get(part);
375
430
  if (refNode) {
@@ -408,6 +463,7 @@ function appendTextToCurrent(
408
463
  const id = replacementEvent.usedDeletionId
409
464
  ? createRevisionMetadata(author, xmlDoc, 'del').id
410
465
  : replacementEvent.deletionId;
466
+ if (!replacementEvent.usedDeletionId) replacementEvent.recordUsed?.('del');
411
467
  replacementEvent.usedDeletionId = true;
412
468
  metadata = {
413
469
  id,
@@ -428,6 +484,7 @@ function appendTextToCurrent(
428
484
  const id = replacementEvent.usedInsertionId
429
485
  ? createRevisionMetadata(author, xmlDoc, 'ins').id
430
486
  : replacementEvent.insertionId;
487
+ if (!replacementEvent.usedInsertionId) replacementEvent.recordUsed?.('ins');
431
488
  replacementEvent.usedInsertionId = true;
432
489
  metadata = {
433
490
  id,
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import { createWordElement } from '../core/word-xml.js';
9
+ import { getCurrentChild } from '../core/current-properties.js';
9
10
 
10
11
  /**
11
12
  * Canonical OOXML run-property schema ordering.
@@ -419,9 +420,34 @@ export function checkParagraphPropertiesChanged(pPr, properties) {
419
420
  if (currentVal !== properties.style) return true;
420
421
  }
421
422
 
423
+ if (properties.numbering !== undefined) {
424
+ const current = readDirectNumbering(pPr);
425
+ const desired = properties.numbering;
426
+ if (desired === null) return current != null;
427
+ if (!current) return true;
428
+ if (current.numId !== String(Number(desired.numId))) return true;
429
+ if (current.level !== (Number(desired.level) || 0)) return true;
430
+ }
431
+
422
432
  return false;
423
433
  }
424
434
 
435
+ /**
436
+ * Reads the paragraph's own (direct, non-snapshot) numbering binding.
437
+ *
438
+ * @param {Element|null} pPr
439
+ * @returns {{ numId: string, level: number }|null}
440
+ */
441
+ function readDirectNumbering(pPr) {
442
+ const numPr = getCurrentChild(pPr, 'numPr');
443
+ if (!numPr) return null;
444
+ const read = (name) => {
445
+ const el = getCurrentChild(numPr, name);
446
+ return el ? (el.getAttribute('w:val') ?? el.getAttribute('val')) : null;
447
+ };
448
+ return { numId: String(Number(read('numId'))), level: Number(read('ilvl')) || 0 };
449
+ }
450
+
425
451
  /**
426
452
  * Applies explicit paragraph formatting to a pPr in schema order.
427
453
  *
@@ -492,4 +518,20 @@ export function applyParagraphPropertiesToPPr(xmlDoc, pPr, properties) {
492
518
  insertPPrChildInOrder(pPr, pStyle);
493
519
  }
494
520
  }
521
+
522
+ if (properties.numbering !== undefined) {
523
+ removeTags(['w:numPr']);
524
+ if (properties.numbering) {
525
+ // CT_PPr sequence: numPr follows widowControl and precedes suppressLineNumbers ... ind; inside
526
+ // CT_NumPr the order is ilvl, numId.
527
+ const numPr = createWordElement(xmlDoc, 'w:numPr');
528
+ const ilvl = createWordElement(xmlDoc, 'w:ilvl');
529
+ ilvl.setAttribute('w:val', String(Number(properties.numbering.level) || 0));
530
+ const numId = createWordElement(xmlDoc, 'w:numId');
531
+ numId.setAttribute('w:val', String(Number(properties.numbering.numId)));
532
+ numPr.appendChild(ilvl);
533
+ numPr.appendChild(numId);
534
+ insertPPrChildInOrder(pPr, numPr);
535
+ }
536
+ }
495
537
  }
@@ -33,79 +33,6 @@ export function createTrackChange(xmlDoc, type, run, author, revisionMetadata =
33
33
  return wrapper;
34
34
  }
35
35
 
36
- function getDirectWordChild(node, localName) {
37
- return Array.from(node?.childNodes || []).find(child => child.nodeType === 1
38
- && (child.localName === localName || child.nodeName === `w:${localName}`)) || null;
39
- }
40
-
41
- function ensureParagraphProperties(xmlDoc, paragraph) {
42
- let pPr = getDirectWordChild(paragraph, 'pPr');
43
- if (!pPr) {
44
- pPr = createWordElement(xmlDoc, 'w:pPr');
45
- paragraph.insertBefore(pPr, paragraph.firstChild || null);
46
- } else if (paragraph.firstChild !== pPr) {
47
- paragraph.insertBefore(pPr, paragraph.firstChild || null);
48
- }
49
- return pPr;
50
- }
51
-
52
- function ensureParagraphMarkRunProperties(xmlDoc, pPr) {
53
- let rPr = getDirectWordChild(pPr, 'rPr');
54
- if (!rPr) {
55
- rPr = createWordElement(xmlDoc, 'w:rPr');
56
- pPr.appendChild(rPr);
57
- } else if (pPr.lastChild !== rPr) {
58
- pPr.appendChild(rPr);
59
- }
60
- return rPr;
61
- }
62
-
63
- function markParagraphMark(xmlDoc, paragraph, author, type, revisionMetadata = null) {
64
- const pPr = ensureParagraphProperties(xmlDoc, paragraph);
65
- const rPr = ensureParagraphMarkRunProperties(xmlDoc, pPr);
66
-
67
- for (const child of Array.from(rPr.childNodes || [])) {
68
- if (child.nodeType !== 1) continue;
69
- if (child.localName === 'ins' || child.localName === 'del' || child.nodeName === 'w:ins' || child.nodeName === 'w:del') {
70
- rPr.removeChild(child);
71
- }
72
- }
73
-
74
- const marker = createWordElement(xmlDoc, type === 'ins' ? 'w:ins' : 'w:del');
75
- const metadata = revisionMetadata || createRevisionMetadata(author, xmlDoc, type === 'ins' ? 'ins' : 'del');
76
- marker.setAttribute('w:id', String(metadata.id));
77
- marker.setAttribute('w:author', metadata.author);
78
- marker.setAttribute('w:date', metadata.date);
79
- rPr.appendChild(marker);
80
- return marker;
81
- }
82
-
83
- /**
84
- * Marks a paragraph mark as inserted.
85
- *
86
- * @param {Document} xmlDoc - XML document
87
- * @param {Element} paragraph - Paragraph to mark
88
- * @param {string} author - Change author
89
- * @param {Object|null} [revisionMetadata=null] - Optional explicit revision metadata
90
- * @returns {Element}
91
- */
92
- export function markParagraphMarkInserted(xmlDoc, paragraph, author, revisionMetadata = null) {
93
- return markParagraphMark(xmlDoc, paragraph, author, 'ins', revisionMetadata);
94
- }
95
-
96
- /**
97
- * Marks a paragraph mark as deleted.
98
- *
99
- * @param {Document} xmlDoc - XML document
100
- * @param {Element} paragraph - Paragraph to mark
101
- * @param {string} author - Change author
102
- * @param {Object|null} [revisionMetadata=null] - Optional explicit revision metadata
103
- * @returns {Element}
104
- */
105
- export function markParagraphMarkDeleted(xmlDoc, paragraph, author, revisionMetadata = null) {
106
- return markParagraphMark(xmlDoc, paragraph, author, 'del', revisionMetadata);
107
- }
108
-
109
36
  /**
110
37
  * Creates a text run with optional formatting.
111
38
  *