@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.
- package/ARCHITECTURE.md +691 -669
- package/CHANGELOG.md +67 -1
- package/README.md +39 -8
- package/core/word-xml.js +160 -118
- package/dist/docx-redline-js.esm.js +528 -312
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +94 -94
- package/dist/docx-redline-js.esm.min.js.map +4 -4
- package/dist/docx-redline.bundle.cjs +528 -312
- package/dist/docx-redline.bundle.cjs.map +4 -4
- package/dist/docx-redline.bundle.js +528 -312
- package/dist/docx-redline.bundle.js.map +4 -4
- package/docs/AGENT_KNOWLEDGE_BASE.md +64 -5
- package/docs/SKILL_AUTHORING.md +5 -1
- package/docs/TESTING.md +10 -1
- package/docs/schemas/document-operations.schema.json +1 -1
- package/engine/oxml-engine.js +748 -736
- package/engine/table-mode.js +7 -4
- package/index.d.ts +29 -2
- package/index.js +3 -0
- package/node/cli.js +993 -992
- package/package.json +1 -1
- package/pipeline/content-analysis.js +2 -1
- package/pipeline/markdown-processor.js +5 -2
- package/pipeline/structured-content.js +2 -1
- package/services/comment-parts-merge.js +124 -0
- package/services/comment-replies.js +1 -1
- package/services/document-operation-applier.js +1 -0
- package/services/document-operation-contract.js +1 -0
- package/services/document-operation-mutations.js +3362 -3346
- package/services/inspection-comparison.js +82 -0
- package/services/operation-heuristics.js +1 -1
- package/services/operation-preflight.js +42 -33
- package/services/revision-fidelity-verifier.js +5 -19
- package/services/standalone-docx-plumbing.js +38 -22
- package/services/table-reconciliation.js +11 -6
package/package.json
CHANGED
|
@@ -22,7 +22,8 @@ export function parseTable(text) {
|
|
|
22
22
|
|
|
23
23
|
const isSeparatorLine = line => {
|
|
24
24
|
const normalized = line.replace(/\s+/g, '');
|
|
25
|
-
|
|
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
|
-
|
|
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: /_(
|
|
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
|
-
|
|
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(
|
|
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 } : {}),
|