@ansonlai/docx-redline-js 0.9.0 → 0.11.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 (36) hide show
  1. package/ARCHITECTURE.md +691 -669
  2. package/CHANGELOG.md +67 -1
  3. package/README.md +39 -8
  4. package/core/word-xml.js +160 -118
  5. package/dist/docx-redline-js.esm.js +528 -312
  6. package/dist/docx-redline-js.esm.js.map +4 -4
  7. package/dist/docx-redline-js.esm.min.js +94 -94
  8. package/dist/docx-redline-js.esm.min.js.map +4 -4
  9. package/dist/docx-redline.bundle.cjs +528 -312
  10. package/dist/docx-redline.bundle.cjs.map +4 -4
  11. package/dist/docx-redline.bundle.js +528 -312
  12. package/dist/docx-redline.bundle.js.map +4 -4
  13. package/docs/AGENT_KNOWLEDGE_BASE.md +64 -5
  14. package/docs/SKILL_AUTHORING.md +5 -1
  15. package/docs/TESTING.md +10 -1
  16. package/docs/schemas/document-operations.schema.json +1 -1
  17. package/engine/oxml-engine.js +748 -736
  18. package/engine/table-mode.js +7 -4
  19. package/index.d.ts +29 -2
  20. package/index.js +3 -0
  21. package/node/cli.js +993 -992
  22. package/package.json +1 -1
  23. package/pipeline/content-analysis.js +2 -1
  24. package/pipeline/markdown-processor.js +5 -2
  25. package/pipeline/structured-content.js +2 -1
  26. package/services/comment-parts-merge.js +124 -0
  27. package/services/comment-replies.js +1 -1
  28. package/services/document-operation-applier.js +1 -0
  29. package/services/document-operation-contract.js +1 -0
  30. package/services/document-operation-mutations.js +3362 -3346
  31. package/services/inspection-comparison.js +82 -0
  32. package/services/operation-heuristics.js +1 -1
  33. package/services/operation-preflight.js +42 -33
  34. package/services/revision-fidelity-verifier.js +5 -19
  35. package/services/standalone-docx-plumbing.js +38 -22
  36. package/services/table-reconciliation.js +11 -6
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ansonlai/docx-redline-js",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "Host-independent OOXML reconciliation engine for .docx manipulation with track changes",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -22,7 +22,8 @@ export function parseTable(text) {
22
22
 
23
23
  const isSeparatorLine = line => {
24
24
  const normalized = line.replace(/\s+/g, '');
25
- return /^\|:?-{3,}:?(\|:?-{3,}:?)+\|?$/.test(normalized);
25
+ // One column needs a pipe on both sides ("|---|"), so a bare "---" or "|---" is not a separator.
26
+ return /^\|:?-{3,}:?(?:(\|:?-{3,}:?)+\|?|\|)$/.test(normalized);
26
27
  };
27
28
 
28
29
  const hasHeader = lines.some(isSeparatorLine);
@@ -35,7 +35,10 @@ const MARKDOWN_PATTERNS = [
35
35
  { regex: /\*\*\+\+(.+?)\+\+\*\*/, format: { bold: true, underline: true } },
36
36
  // Bold: **text** or __text__
37
37
  { regex: /\*\*(.+?)\*\*/, format: { bold: true } },
38
- { regex: /__(.+?)__/, format: { bold: true } },
38
+ // Underscore delimiters follow CommonMark flanking rules: not intraword, not part of a
39
+ // longer underscore run, and never around whitespace. So signature lines ("_____")
40
+ // and snake_case identifiers stay literal text.
41
+ { regex: /(?<![A-Za-z0-9_])__(?=[^_\s])(.+?)(?<=[^_\s])__(?![A-Za-z0-9_])/, format: { bold: true } },
39
42
  // Underline: ++text++
40
43
  { regex: /\+\+(.+?)\+\+/, format: { underline: true } },
41
44
  // Strikethrough: ~~text~~ or ~text~
@@ -43,7 +46,7 @@ const MARKDOWN_PATTERNS = [
43
46
  { regex: /~(.+?)~/, format: { strikethrough: true } },
44
47
  // Italic: *text* or _text_ (using lookahead only for compatibility)
45
48
  { regex: /\*(?!\*)(.+?)\*(?!\*)/, format: { italic: true } },
46
- { regex: /_(?!_)(.+?)_(?!_)/, format: { italic: true } }
49
+ { regex: /(?<![A-Za-z0-9_])_(?=[^_\s])(.+?)(?<=[^_\s])_(?![A-Za-z0-9_])/, format: { italic: true } }
47
50
  ];
48
51
 
49
52
  /**
@@ -9,7 +9,8 @@
9
9
 
10
10
  import { matchListMarker } from './list-markers.js';
11
11
 
12
- const TABLE_SEPARATOR = /^\s*\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)+\|?\s*$/;
12
+ // A one-column separator needs a pipe on both sides ("| --- |"); two or more columns as before.
13
+ const TABLE_SEPARATOR = /^\s*(?:\|\s*:?-{3,}:?\s*\||\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)+\|?)\s*$/;
13
14
 
14
15
  function isBlank(line) {
15
16
  return !line || line.trim().length === 0;
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Parts-level comment merge: the comment half of writing a batch result back into a
3
+ * package, for hosts that hold package parts as XML strings rather than a ZIP (a Flat OPC
4
+ * package, a browser parts map). Strings in, strings out; no package, relationship or
5
+ * content-type handling, which stays with the host.
6
+ */
7
+
8
+ import { mergeCommentsPartXml, parseXmlStrictStandalone } from './standalone-docx-plumbing.js';
9
+ import { reconcileCommentSiblingParts } from './comment-thread-parts.js';
10
+
11
+ const PART_KEYS = Object.freeze({
12
+ comments: 'commentsXml',
13
+ commentsExtended: 'commentsExtendedXml',
14
+ commentsIds: 'commentsIdsXml',
15
+ commentsExtensible: 'commentsExtensibleXml'
16
+ });
17
+
18
+ const partsOf = parts => Object.fromEntries(Object.values(PART_KEYS).map(key => [key, parts?.[key] || null]));
19
+
20
+ /**
21
+ * Merges a batch result's comment output into a package's comment parts.
22
+ *
23
+ * - `commentsXml` is appended to the existing comments part (`commentsXmlMode: 'merge'`, the default) or replaces it
24
+ * (`'replace'`). commentsIds.xml and commentsExtensible.xml, when the package has them, are then brought in line with
25
+ * comments.xml; they are never created.
26
+ * - `commentsExtendedXml` replaces the thread-metadata part. A `'merge'` delta for a package that already has
27
+ * commentsExtended.xml cannot be merged safely and is refused.
28
+ *
29
+ * Never throws for document content; on error the input parts are returned unchanged.
30
+ *
31
+ * @param {{ commentsXml?: string|null, commentsExtendedXml?: string|null, commentsIdsXml?: string|null, commentsExtensibleXml?: string|null }} parts
32
+ * the package's current comment parts (absent parts null or omitted)
33
+ * @param {{ commentsXml?: string|null, commentsXmlMode?: 'merge'|'replace', commentsExtendedXml?: string|null, commentsExtendedXmlMode?: 'merge'|'replace' }} output
34
+ * comment output of a batch, for example an `applyOperationsToDocumentXml` result
35
+ * @returns {{ status: 'ok'|'error', hasChanges: boolean, parts: { commentsXml: string|null, commentsExtendedXml: string|null, commentsIdsXml: string|null, commentsExtensibleXml: string|null }, changedParts: string[], addedParts: string[], error?: { code: string, message: string, part?: string, commentId?: string } }}
36
+ * `changedParts` and `addedParts` name parts as `comments`, `commentsExtended`, `commentsIds`, `commentsExtensible`;
37
+ * `addedParts` lists parts the input did not have, which need a relationship and content type. It reflects part
38
+ * presence only: relationships are not visible here, so a host that owns them should also repair a missing
39
+ * relationship for any part in `changedParts`.
40
+ */
41
+ export function mergeCommentParts(parts = {}, output = {}) {
42
+ const before = partsOf(parts);
43
+ const after = { ...before };
44
+ const fail = error => ({ status: 'error', hasChanges: false, parts: before, changedParts: [], addedParts: [], error });
45
+ const commentsMode = output?.commentsXmlMode === 'replace' ? 'replace' : 'merge';
46
+ const extendedMode = output?.commentsExtendedXmlMode === 'replace' ? 'replace' : 'merge';
47
+
48
+ for (const [label, value] of [
49
+ ...Object.values(PART_KEYS).map(key => [`parts.${key}`, before[key]]),
50
+ ['output.commentsXml', output?.commentsXml], ['output.commentsExtendedXml', output?.commentsExtendedXml]
51
+ ]) {
52
+ if (value != null && typeof value !== 'string') {
53
+ return fail({ code: 'INVALID_COMMENT_PARTS', message: `${label} must be an XML string, null or omitted (got ${typeof value}).` });
54
+ }
55
+ }
56
+
57
+ // Parses a part and checks its root element, so a wrong part is refused rather than merged as empty.
58
+ const requirePart = (xml, part, rootName, label) => {
59
+ let doc;
60
+ try {
61
+ doc = parseXmlStrictStandalone(xml, label);
62
+ } catch (error) {
63
+ throw Object.assign(error, { part });
64
+ }
65
+ if (doc.documentElement?.localName !== rootName) {
66
+ throw Object.assign(new Error(`${label}: expected a <${rootName}> root element, found <${doc.documentElement?.localName || 'none'}>.`), { code: 'INVALID_COMMENT_PARTS', part });
67
+ }
68
+ return doc;
69
+ };
70
+
71
+ try {
72
+ if (output?.commentsXml) {
73
+ const incomingDoc = requirePart(output.commentsXml, 'comments', 'comments', 'word/comments.xml (incoming)');
74
+ // Ids must be unique within the incoming part in every mode (the merge below also checks them
75
+ // against the existing part).
76
+ const seen = new Set();
77
+ for (const node of Array.from(incomingDoc.getElementsByTagNameNS('*', 'comment'))) {
78
+ const id = node.getAttribute('w:id') || node.getAttribute('id');
79
+ if (!id) continue;
80
+ if (seen.has(id)) {
81
+ throw Object.assign(new Error(`Duplicate comment id: ${id}`), { code: 'DUPLICATE_COMMENT_ID', commentId: id, part: 'comments' });
82
+ }
83
+ seen.add(id);
84
+ }
85
+ if (before.commentsXml) requirePart(before.commentsXml, 'comments', 'comments', 'word/comments.xml (existing)');
86
+ // Sibling parts are reconciled below; a malformed one would otherwise be skipped silently.
87
+ if (before.commentsIdsXml) requirePart(before.commentsIdsXml, 'commentsIds', 'commentsIds', 'word/commentsIds.xml (existing)');
88
+ if (before.commentsExtensibleXml) requirePart(before.commentsExtensibleXml, 'commentsExtensible', 'commentsExtensible', 'word/commentsExtensible.xml (existing)');
89
+ after.commentsXml = mergeCommentsPartXml(before.commentsXml, output.commentsXml, { replaceExisting: commentsMode === 'replace' });
90
+ Object.assign(after, reconcileCommentSiblingParts({
91
+ commentsXml: after.commentsXml,
92
+ commentsIdsXml: after.commentsIdsXml,
93
+ commentsExtensibleXml: after.commentsExtensibleXml
94
+ }));
95
+ }
96
+ if (output?.commentsExtendedXml) {
97
+ if (extendedMode !== 'replace' && before.commentsExtendedXml) {
98
+ return fail({
99
+ code: 'UNSUPPORTED_EXISTING_COMMENT_THREAD_METADATA',
100
+ part: 'commentsExtended',
101
+ message: 'The output carries a commentsExtended delta (mode "merge") for parts that already have thread metadata; a delta cannot be merged into an existing commentsExtended.xml. Nothing was merged.'
102
+ });
103
+ }
104
+ requirePart(output.commentsExtendedXml, 'commentsExtended', 'commentsEx', 'word/commentsExtended.xml (incoming)');
105
+ after.commentsExtendedXml = output.commentsExtendedXml;
106
+ }
107
+ } catch (error) {
108
+ return fail({
109
+ code: error?.code || 'COMMENT_PARTS_MERGE_FAILED',
110
+ message: error?.message || String(error),
111
+ ...(error?.part || error?.code === 'DUPLICATE_COMMENT_ID' ? { part: error.part || 'comments' } : {}),
112
+ ...(error?.commentId ? { commentId: error.commentId } : {})
113
+ });
114
+ }
115
+
116
+ const changedParts = [];
117
+ const addedParts = [];
118
+ for (const [name, key] of Object.entries(PART_KEYS)) {
119
+ if (after[key] === before[key]) continue;
120
+ changedParts.push(name);
121
+ if (!before[key]) addedParts.push(name);
122
+ }
123
+ return { status: 'ok', hasChanges: changedParts.length > 0, parts: after, changedParts, addedParts };
124
+ }
@@ -32,7 +32,7 @@ export function applyCommentReplyToParts({ commentsXml, commentsExtendedXml = nu
32
32
  }
33
33
  const occupied = usedParaIds(commentsDoc, extendedDoc);
34
34
  // paraIds are unique across the whole package; body paragraphs can carry them too.
35
- if (documentXml) for (const match of String(documentXml).matchAll(/w14:paraId="([0-9A-Fa-f]{8})"/g)) occupied.add(match[1].toUpperCase());
35
+ if (documentXml) for (const match of String(documentXml).matchAll(/\bw14:paraId="([0-9A-Fa-f]{8})"/g)) occupied.add(match[1].toUpperCase());
36
36
  const parentParagraph = commentThreadParagraph(parent);
37
37
  if (!parentParagraph) return { status: 'error', error: { code: 'PARENT_COMMENT_INVALID', message: `Parent comment '${parentCommentId}' has no paragraph.` } };
38
38
  let parentParaId = commentParaId(parent);
@@ -258,6 +258,7 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
258
258
  ...(operation.existingRevisions ? { existingRevisions: operation.existingRevisions } : {}),
259
259
  structuredContent: typeof operation.structuredContent === 'boolean' ? operation.structuredContent : (options.structuredContent !== false),
260
260
  explicitStructuredContent: operation.structuredContent === true,
261
+ plainTextContent: operation.structuredContent === false || (operation.structuredContent == null && options.structuredContent === false),
261
262
  ...(operation._localizedReplacementCompilation && operation.structuredContent !== true ? { literalTextEdit: true } : {}),
262
263
  pairReplacements: typeof operation.pairReplacements === 'boolean' ? operation.pairReplacements : (options.pairReplacements !== false),
263
264
  ...(operation.insertionAffinity ? { insertionAffinity: operation.insertionAffinity } : {}),
@@ -28,6 +28,7 @@ const SUPPORTED_OPERATION_TYPES = new Set([
28
28
  ]);
29
29
 
30
30
  export const EXISTING_REVISIONS_POLICIES = Object.freeze([
31
+ 'auto',
31
32
  'merge-same-author',
32
33
  'slice-cross-author',
33
34
  'reject-input',