@ansonlai/docx-redline-js 0.10.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 +13 -1
- package/CHANGELOG.md +35 -0
- package/README.md +12 -4
- package/dist/docx-redline-js.esm.js +358 -262
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +92 -92
- package/dist/docx-redline-js.esm.min.js.map +4 -4
- package/dist/docx-redline.bundle.cjs +358 -262
- package/dist/docx-redline.bundle.cjs.map +4 -4
- package/dist/docx-redline.bundle.js +358 -262
- package/dist/docx-redline.bundle.js.map +4 -4
- package/docs/AGENT_KNOWLEDGE_BASE.md +35 -2
- package/docs/TESTING.md +6 -1
- package/engine/oxml-engine.js +2 -2
- package/engine/table-mode.js +7 -4
- package/index.d.ts +23 -1
- package/index.js +1 -0
- package/package.json +1 -1
- package/pipeline/content-analysis.js +2 -1
- package/pipeline/structured-content.js +2 -1
- package/services/comment-parts-merge.js +124 -0
- package/services/document-operation-mutations.js +6 -2
- package/services/operation-heuristics.js +1 -1
- package/services/standalone-docx-plumbing.js +38 -22
- package/services/table-reconciliation.js +11 -6
|
@@ -73,6 +73,7 @@ instructions that will exist in another clone or in CI.
|
|
|
73
73
|
| Validation, receipts, and lifecycle oracles | `core/redline-validation.js`, `services/receipt-collector.js`, `services/revision-comment-management.js` | `tests/redline_validation_tests.mjs`, `tests/mutation_receipt_tests.mjs`, `tests/roundtrip_oracle_tests.mjs` |
|
|
74
74
|
| Existing-revision policy resolution (`auto`) | `core/word-xml.js` (`resolveExistingRevisionsPolicy`), `services/document-operation-mutations.js`, `services/operation-preflight.js` | `tests/existing_revisions_auto_tests.mjs`, `tests/existing_revisions_auto_paths_tests.mjs` |
|
|
75
75
|
| Comparing a committed document with a prepared one | `services/inspection-comparison.js` | `tests/inspection_comparison_tests.mjs` |
|
|
76
|
+
| Comment parts outside a ZIP | `services/comment-parts-merge.js`, `services/standalone-docx-plumbing.js` (`mergeCommentsPartXml`), `services/comment-thread-parts.js` | `tests/comment_parts_merge_tests.mjs` |
|
|
76
77
|
|
|
77
78
|
Run one focused suite with `node tests/<name>.mjs`. Run `npm test` only when the
|
|
78
79
|
change crosses several subsystems or before release-level handoff. The Word COM,
|
|
@@ -385,6 +386,31 @@ const inspection = inspectDocumentParts({ documentXml, commentsXml, numberingXml
|
|
|
385
386
|
Reuse `exactText` plus `paragraphId` or `fingerprint` in an operation. Computed
|
|
386
387
|
list labels and excerpts are for display, not replacements for exact targets.
|
|
387
388
|
|
|
389
|
+
### Merge comment output into parts held outside a ZIP
|
|
390
|
+
|
|
391
|
+
```js
|
|
392
|
+
import { mergeCommentParts } from '@ansonlai/docx-redline-js';
|
|
393
|
+
const merged = mergeCommentParts(
|
|
394
|
+
{ commentsXml, commentsExtendedXml, commentsIdsXml, commentsExtensibleXml }, // current parts, null when absent
|
|
395
|
+
batchResult // commentsXml / commentsXmlMode, commentsExtendedXml / commentsExtendedXmlMode
|
|
396
|
+
);
|
|
397
|
+
if (merged.status === 'ok') { /* write merged.parts; ensure rels/content types for merged.changedParts */ }
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
Use it when a host holds package parts as strings (a Flat OPC package from a Word
|
|
401
|
+
add-in, a browser parts map) instead of handing a ZIP to `openDocx`, which does the
|
|
402
|
+
same merge internally. commentsIds.xml and commentsExtensible.xml are reconciled
|
|
403
|
+
only when present, never created. A `'merge'` commentsExtended delta for parts that
|
|
404
|
+
already have thread metadata is refused with
|
|
405
|
+
`UNSUPPORTED_EXISTING_COMMENT_THREAD_METADATA`. Other codes: `PARSE_ERROR`,
|
|
406
|
+
`INVALID_COMMENT_PARTS` (non-string input, wrong root element) and
|
|
407
|
+
`DUPLICATE_COMMENT_ID`; `error.part` names the part (and `error.commentId` the
|
|
408
|
+
duplicate). An unexpected merge failure reports `COMMENT_PARTS_MERGE_FAILED`. It
|
|
409
|
+
never throws for document content. On any error the input parts
|
|
410
|
+
come back unchanged. `addedParts` reflects part presence only; the function cannot see
|
|
411
|
+
relationships, so a host that owns them should ensure one for every part in
|
|
412
|
+
`changedParts` (a package can have comments.xml without its relationship).
|
|
413
|
+
|
|
388
414
|
### Verify a committed document against the prepared one
|
|
389
415
|
|
|
390
416
|
```js
|
|
@@ -892,9 +918,16 @@ Accept/Reject checks.
|
|
|
892
918
|
|
|
893
919
|
```js
|
|
894
920
|
import { reconcileMarkdownTableOoxml } from '@ansonlai/docx-redline-js';
|
|
895
|
-
const result = await reconcileMarkdownTableOoxml(tableOoxml, originalText, markdownTable);
|
|
921
|
+
const result = await reconcileMarkdownTableOoxml(tableOoxml, originalText, markdownTable, options); // options optional
|
|
896
922
|
```
|
|
897
923
|
|
|
924
|
+
A multi-line `redline` of a table-cell paragraph whose first line is the cell's
|
|
925
|
+
current text (`'Cell text.\nSecond line'`) inserts the extra lines as new rows below
|
|
926
|
+
the target row, in the target column (mirrored across columns for symmetric label
|
|
927
|
+
rows such as signature blocks). It does not add paragraphs inside the cell. With
|
|
928
|
+
`structuredContent: false` the new cell text stays literal. One-column tables use
|
|
929
|
+
`| --- |` as the separator.
|
|
930
|
+
|
|
898
931
|
## Detailed Architecture
|
|
899
932
|
|
|
900
933
|
Use the layout and task shortcuts at the top of this file for normal work.
|
|
@@ -911,7 +944,7 @@ reconstruct the architecture by repeatedly listing the repository.
|
|
|
911
944
|
| `generateRedlines` | `boolean` | `true` | When `true`, emits Word-native tracked changes (`w:ins`/`w:del`). When `false`, applies direct text edits without revision markup. **Note: Redlines are not always the preferred method** — pass `generateRedlines: false` (or `--no-redlines` via CLI) when producing clean execution drafts, restructuring documents, or when revision clutter is unwanted. |
|
|
912
945
|
| `author` | `string` | `'AI Redliner'` | Reviewer/author name stamped on generated tracked changes and comments. Overridable via `DOCX_REDLINE_AUTHOR` environment variable. |
|
|
913
946
|
| `atomic` | `boolean` | `false` | Batch transaction mode. By default (`false`), valid edits are applied directly and failing operations report errors. When `true`, any operation failure rolls back the entire batch to the original document state (`rolledBack: true`, `hasChanges: false`). Use `atomic: true` (or `--atomic` in CLI) for high-assurance workflows. |
|
|
914
|
-
| `structuredContent` | `boolean` | `true` | Auto-detects Markdown tables, headings (`#`), and lists in replacement text and renders them as native Word elements (`w:tbl`, `w:pStyle`, `w:numPr`). Pass `false` to treat replacement text strictly as plain text: inline Markdown markers (`**`, `__`, `_`, `~~`, `++`) are kept literally. With `true`, underscores follow CommonMark flanking rules, so signature lines (`_____`) and `snake_case` stay literal. |
|
|
947
|
+
| `structuredContent` | `boolean` | `true` | Auto-detects Markdown tables, headings (`#`), and lists in replacement text and renders them as native Word elements (`w:tbl`, `w:pStyle`, `w:numPr`). Pass `false` to treat replacement text strictly as plain text, including paragraphs a redline adds and table-cell text: no headings, and inline Markdown markers (`**`, `__`, `_`, `~~`, `++`) are kept literally. With `true`, underscores follow CommonMark flanking rules, so signature lines (`_____`) and `snake_case` stay literal. |
|
|
915
948
|
| `pairReplacements` | `boolean` | `true` | Links adjacent `<w:del>` and `<w:ins>` revisions with matching timestamps so Word groups them as a single replacement in the Reviewing Pane. |
|
|
916
949
|
| `strictTargets` | `boolean` | `true` (CLI/facade) | Requires exact target descriptors (`exactText`, `paragraphId`, `index`, `occurrence`, `fingerprint`) and forbids ambiguous matching. Defaults to `false` in low-level runner for backwards compatibility. |
|
|
917
950
|
| `existingRevisions` | `string` | `'merge-same-author'` | How to handle paragraphs with existing tracked changes. `'merge-same-author'` merges the same author's work and protects other authors with `EXISTING_REVISIONS`. `'slice-cross-author'` retains same-author merging while allowing Word-native edits inside another author's pending insertion. Pass `'accept-all-first'` to normalize prior revisions or `'reject-input'` to refuse editing revised paragraphs. `'auto'` chooses per target: `'merge-same-author'` when the target has no tracked changes or only the operation author's, otherwise `'slice-cross-author'` (preflight reports the resolved policy). |
|
package/docs/TESTING.md
CHANGED
|
@@ -38,6 +38,10 @@ fixtures rarely contain.
|
|
|
38
38
|
| Universal document facade & ZIP | `node tests/universal_docx_document_tests.mjs`, `node tests/universal_zip_archive_tests.mjs`, `node tests/universal_facade_edge_cases_tests.mjs` | Universal `Uint8Array` binary handling, `fflate` compression/decompression, optimistic concurrency, and complete-DOCX operations without Node built-ins | Desktop Word rendering |
|
|
39
39
|
| Sandbox bundle isolation | `node tests/sandbox_bundle_isolation_tests.mjs` | Zero-dependency standalone ESM/CJS bundles execute in restricted VM sandboxes without `process`, `Buffer`, or `require`, with zero Node built-in imports | Web Worker latency |
|
|
40
40
|
| Existing-revision policy `auto` | `node tests/existing_revisions_auto_tests.mjs`, `node tests/existing_revisions_auto_paths_tests.mjs` | Per-target resolution to `merge-same-author` or `slice-cross-author` (mixed, unattributed and deletion-only history, whole `targetEnd` ranges, table cells), equivalence with the explicit policy through `applyRedlineToOxml` and the runner, preflight reporting, untracked edits, `openDocx`, `--existing-revisions auto`, schema and the CLI capability | Desktop Word rendering |
|
|
41
|
+
| Existing-revision policy `merge-same-author` | `node tests/merge_same_author_tests.mjs` | Same-author turns merge against their baseline, foreign-author and mixed-history targets are refused or protected, and an unattributed tracked change counts as another reviewer's (merge refused with `EXISTING_REVISIONS`; slicing keeps its baseline) | Desktop Word rendering |
|
|
42
|
+
| Plain-text paragraph insertion | `node tests/plain_text_paragraph_insertion_tests.mjs` | A caller's `structuredContent: false` keeps paragraphs a redline adds literal (before/after/around the target, several, signature lines, headings, changed target, batch vs per-operation, `replace`, untracked, `openDocx`, ranges, list siblings at level 0/1 and bullets) with Reject All restoring the source | Desktop Word rendering |
|
|
43
|
+
| Table-cell multi-line edits | `node tests/table_cell_paragraph_insertion_tests.mjs` | One-column separator recognition (and bare `---` rejection), multi-line cell edits inserting rows below the target in one- and two-column tables in every `structuredContent` mode, caller-written one-column tables (reconcile and text-to-table), the plain-text contract in cells, Reject All and validation, `openDocx` | Desktop Word rendering |
|
|
44
|
+
| Comment parts merge | `node tests/comment_parts_merge_tests.mjs`, `node tests/comment_parts_merge_edge_tests.mjs` | `mergeCommentParts` append/replace, sibling reconciliation (never created, multi-paragraph and paraId-less comments, unique durable ids), structured refusals with unchanged input (types, roots, malformed siblings, duplicates), no-ops, idempotence, special characters, and part-for-part equivalence with `openDocx` for a new comment, a reply, a resolve, a first comment, a comments-only package, sequential batches and a mixed batch | A host's own package and relationship writing |
|
|
41
45
|
| Comment reply paraIds | `node tests/comment_reply_para_id_tests.mjs` | A reply's `w14:paraId` avoids body paragraph ids (case-insensitive) and stays deterministic without a collision | Word's own id allocation |
|
|
42
46
|
| Inspection comparison | `node tests/inspection_comparison_tests.mjs` | `compareDocumentInspections` ignores ids, dates, comment order and run splits and reports text, review-segment (author, kind, position), table, list-label and comment (text, author, anchor, resolved, thread) differences, capped at 10; works on `openDocx` inspections | A host's own write path |
|
|
43
47
|
| Literal underscores and plain text | `node tests/literal_underscore_text_tests.mjs` | Signature lines and `snake_case` survive table reconciliation and rewrites; CommonMark underscore flanking; a caller's `structuredContent: false` keeps inline Markdown literal | Desktop Word rendering |
|
|
@@ -948,7 +952,8 @@ The corpus packager starts from the original `.docx`, replaces only the declared
|
|
|
948
952
|
revision part (`word/document.xml` by default, or a named header part), and
|
|
949
953
|
verifies every untouched ZIP part byte-for-byte before Word opens the result.
|
|
950
954
|
The Word oracle derives multi-change expectations by applying each independently
|
|
951
|
-
declared replacement to Word's source text and reads header-story text separately
|
|
955
|
+
declared replacement to Word's source text and reads header-story text separately.
|
|
956
|
+
|
|
952
957
|
## Release validation and independent oracles
|
|
953
958
|
|
|
954
959
|
This package works on OOXML strings and intentionally leaves `.docx` zip
|
package/engine/oxml-engine.js
CHANGED
|
@@ -577,12 +577,12 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
|
|
|
577
577
|
if (isMarkdownTable && !hasTables) {
|
|
578
578
|
recordRouteSelection(options, 'table', { transformation: 'text-to-table' });
|
|
579
579
|
log('[OxmlEngine] Text-to-table transformation: generating new table from Markdown');
|
|
580
|
-
return finalize(applyTextToTableTransformation(xmlDoc, cleanModifiedText, serializer, null, author, generateRedlines));
|
|
580
|
+
return finalize(applyTextToTableTransformation(xmlDoc, cleanModifiedText, serializer, null, author, generateRedlines, { plainTextContent: options.plainTextContent === true }));
|
|
581
581
|
}
|
|
582
582
|
|
|
583
583
|
if (hasTables && isMarkdownTable) {
|
|
584
584
|
recordRouteSelection(options, 'table', { transformation: 'table-reconciliation' });
|
|
585
|
-
return finalize(applyTableReconciliation(xmlDoc, cleanModifiedText, serializer, null, author, generateRedlines));
|
|
585
|
+
return finalize(applyTableReconciliation(xmlDoc, cleanModifiedText, serializer, null, author, generateRedlines, { plainTextContent: options.plainTextContent === true }));
|
|
586
586
|
}
|
|
587
587
|
if (usesSurgicalTextMode) {
|
|
588
588
|
recordRouteSelection(options, 'surgical', { tableScoped: hasTables });
|
package/engine/table-mode.js
CHANGED
|
@@ -36,9 +36,10 @@ function noChanges(serializer, xmlDoc) {
|
|
|
36
36
|
* @param {DOMParser} parser - Parser instance
|
|
37
37
|
* @param {string} author - Author name
|
|
38
38
|
* @param {boolean} [generateRedlines=true] - Track change toggle
|
|
39
|
+
* @param {{ plainTextContent?: boolean }} [extra] - plainTextContent keeps cell text literal
|
|
39
40
|
* @returns {{ oxml: string, hasChanges: boolean }}
|
|
40
41
|
*/
|
|
41
|
-
export function applyTableReconciliation(xmlDoc, modifiedText, serializer, parser, author, generateRedlines = true) {
|
|
42
|
+
export function applyTableReconciliation(xmlDoc, modifiedText, serializer, parser, author, generateRedlines = true, extra = {}) {
|
|
42
43
|
const tableNodes = getElementsByTagNSOrTag(xmlDoc, NS_W, 'tbl');
|
|
43
44
|
const newTableData = parseTable(modifiedText);
|
|
44
45
|
const hasNewContent = newTableData.rows.length > 0 || newTableData.headers.length > 0;
|
|
@@ -58,7 +59,8 @@ export function applyTableReconciliation(xmlDoc, modifiedText, serializer, parse
|
|
|
58
59
|
const options = {
|
|
59
60
|
generateRedlines,
|
|
60
61
|
author,
|
|
61
|
-
revisionIdAllocator: getRevisionIdAllocatorForDocument(xmlDoc)
|
|
62
|
+
revisionIdAllocator: getRevisionIdAllocatorForDocument(xmlDoc),
|
|
63
|
+
plainTextContent: extra.plainTextContent === true
|
|
62
64
|
};
|
|
63
65
|
const reconciledOxml = serializeVirtualGridToOoxml(oldGrid, operations, options);
|
|
64
66
|
const wrappedOxml = `<root xmlns:w="${NS_W}">${reconciledOxml}</root>`;
|
|
@@ -92,9 +94,10 @@ export function applyTableReconciliation(xmlDoc, modifiedText, serializer, parse
|
|
|
92
94
|
* @param {DOMParser} parser - Parser instance
|
|
93
95
|
* @param {string} author - Author name
|
|
94
96
|
* @param {boolean} generateRedlines - Track change toggle
|
|
97
|
+
* @param {{ plainTextContent?: boolean }} [extra] - plainTextContent keeps cell text literal
|
|
95
98
|
* @returns {{ oxml: string, hasChanges: boolean }}
|
|
96
99
|
*/
|
|
97
|
-
export function applyTextToTableTransformation(xmlDoc, modifiedText, serializer, parser, author, generateRedlines) {
|
|
100
|
+
export function applyTextToTableTransformation(xmlDoc, modifiedText, serializer, parser, author, generateRedlines, extra = {}) {
|
|
98
101
|
const revisionIdAllocator = getRevisionIdAllocatorForDocument(xmlDoc);
|
|
99
102
|
const tableData = parseTable(modifiedText);
|
|
100
103
|
if (!tableData || (tableData.rows.length === 0 && tableData.headers.length === 0)) {
|
|
@@ -102,7 +105,7 @@ export function applyTextToTableTransformation(xmlDoc, modifiedText, serializer,
|
|
|
102
105
|
return noChanges(serializer, xmlDoc);
|
|
103
106
|
}
|
|
104
107
|
|
|
105
|
-
const tableOoxml = generateTableOoxml(tableData, { generateRedlines, author, revisionIdAllocator, trackAsBlock: true });
|
|
108
|
+
const tableOoxml = generateTableOoxml(tableData, { generateRedlines, author, revisionIdAllocator, trackAsBlock: true, plainTextContent: extra.plainTextContent === true });
|
|
106
109
|
const tableDoc = parseOoxmlSafe(`<root xmlns:w="${NS_W}">${tableOoxml}</root>`, 'application/xml').doc;
|
|
107
110
|
if (!tableDoc) return noChanges(serializer, xmlDoc);
|
|
108
111
|
|
package/index.d.ts
CHANGED
|
@@ -390,7 +390,29 @@ export function wrapInDocumentFragment(rawOoxml: string, options?: Record<string
|
|
|
390
390
|
|
|
391
391
|
export function injectCommentsIntoOoxml(oxml: string, comments: unknown[], options?: Record<string, unknown>): unknown;
|
|
392
392
|
export function injectCommentsIntoPackage(packageXml: string, comments: unknown[], options?: Record<string, unknown>): unknown;
|
|
393
|
-
export function applyCommentReplyToParts(options: { commentsXml: string; commentsExtendedXml?: string | null; parentCommentId: string | number; commentId: string | number; commentContent: string; author: string; date?: string }): { status: 'ok' | 'error'; hasChanges?: boolean; commentsXml?: string; commentsExtendedXml?: string; commentsXmlMode?: 'replace'; commentsExtendedXmlMode?: 'replace'; parentCommentId?: string; paraId?: string; parentParaId?: string; error?: RedlineError };
|
|
393
|
+
export function applyCommentReplyToParts(options: { commentsXml: string; commentsExtendedXml?: string | null; documentXml?: string | null; parentCommentId: string | number; commentId: string | number; commentContent: string; author: string; date?: string }): { status: 'ok' | 'error'; hasChanges?: boolean; commentsXml?: string; commentsExtendedXml?: string; commentsXmlMode?: 'replace'; commentsExtendedXmlMode?: 'replace'; parentCommentId?: string; paraId?: string; parentParaId?: string; error?: RedlineError };
|
|
394
|
+
export interface CommentParts {
|
|
395
|
+
commentsXml: string | null;
|
|
396
|
+
commentsExtendedXml: string | null;
|
|
397
|
+
commentsIdsXml: string | null;
|
|
398
|
+
commentsExtensibleXml: string | null;
|
|
399
|
+
}
|
|
400
|
+
export type CommentPartName = 'comments' | 'commentsExtended' | 'commentsIds' | 'commentsExtensible';
|
|
401
|
+
export interface MergeCommentPartsResult {
|
|
402
|
+
status: 'ok' | 'error';
|
|
403
|
+
hasChanges: boolean;
|
|
404
|
+
/** Updated parts on success; the input parts unchanged on error. Absent parts are null. */
|
|
405
|
+
parts: CommentParts;
|
|
406
|
+
changedParts: CommentPartName[];
|
|
407
|
+
/** Parts the input did not have; the host adds their relationship and content type. Part presence only: also repair a missing relationship for any changed part. */
|
|
408
|
+
addedParts: CommentPartName[];
|
|
409
|
+
error?: { code: 'PARSE_ERROR' | 'INVALID_COMMENT_PARTS' | 'DUPLICATE_COMMENT_ID' | 'UNSUPPORTED_EXISTING_COMMENT_THREAD_METADATA' | string; message: string; part?: CommentPartName; commentId?: string };
|
|
410
|
+
}
|
|
411
|
+
/** Merge a batch result's comment output into comment parts held as XML strings (no ZIP); reconciles commentsIds/commentsExtensible. */
|
|
412
|
+
export function mergeCommentParts(
|
|
413
|
+
parts?: Partial<CommentParts>,
|
|
414
|
+
output?: { commentsXml?: string | null; commentsXmlMode?: 'merge' | 'replace'; commentsExtendedXml?: string | null; commentsExtendedXmlMode?: 'merge' | 'replace' }
|
|
415
|
+
): MergeCommentPartsResult;
|
|
394
416
|
export function acceptTrackedChangesInOoxml(oxml: string, options?: RevisionFilterOptions): AcceptTrackedChangesResult;
|
|
395
417
|
export function rejectTrackedChangesInOoxml(oxml: string, options?: RevisionFilterOptions): RejectTrackedChangesResult;
|
|
396
418
|
export function deleteCommentsByAuthorInOoxml(oxml: string, options?: RevisionFilterOptions): DeleteCommentsResult;
|
package/index.js
CHANGED
|
@@ -307,6 +307,7 @@ export {
|
|
|
307
307
|
buildCommentsPartXml
|
|
308
308
|
} from './services/comment-engine.js';
|
|
309
309
|
export { applyCommentReplyToParts } from './services/comment-replies.js';
|
|
310
|
+
export { mergeCommentParts } from './services/comment-parts-merge.js';
|
|
310
311
|
export {
|
|
311
312
|
acceptTrackedChangesInOoxml,
|
|
312
313
|
rejectTrackedChangesInOoxml,
|
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);
|
|
@@ -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
|
+
}
|
|
@@ -658,7 +658,10 @@ async function buildInsertedPlainParagraph(xmlDoc, anchorParagraph, text, revisi
|
|
|
658
658
|
String(text || ''),
|
|
659
659
|
{
|
|
660
660
|
author,
|
|
661
|
-
generateRedlines: false
|
|
661
|
+
generateRedlines: false,
|
|
662
|
+
// A caller's structuredContent: false keeps inserted paragraphs plain too: no headings,
|
|
663
|
+
// and inline Markdown markers stay literal.
|
|
664
|
+
...(options.plainTextContent === true ? { structuredContent: false, plainTextContent: true } : {})
|
|
662
665
|
}
|
|
663
666
|
);
|
|
664
667
|
|
|
@@ -2388,7 +2391,7 @@ export async function applyToParagraphByExactText(documentXml, targetText, modif
|
|
|
2388
2391
|
author,
|
|
2389
2392
|
// A wholly inserted paragraph must track its own paragraph mark; otherwise Word's
|
|
2390
2393
|
// Reject All removes the text but leaves an empty paragraph behind.
|
|
2391
|
-
{ generateRedlines, trackParagraphMark: true }
|
|
2394
|
+
{ generateRedlines, trackParagraphMark: true, plainTextContent: options.plainTextContent === true }
|
|
2392
2395
|
);
|
|
2393
2396
|
const imported = xmlDoc.importNode(plainParagraph, true);
|
|
2394
2397
|
ensureParagraphIdsOnImportedNode(imported, operationSession);
|
|
@@ -2563,6 +2566,7 @@ export async function applyToParagraphByExactText(documentXml, targetText, modif
|
|
|
2563
2566
|
existingRevisions: options.existingRevisions || 'merge-same-author',
|
|
2564
2567
|
structuredContent: options.structuredContent !== false,
|
|
2565
2568
|
explicitStructuredContent: options.explicitStructuredContent === true,
|
|
2569
|
+
plainTextContent: options.plainTextContent === true,
|
|
2566
2570
|
_revisionIdAllocator: revisionIdAllocator,
|
|
2567
2571
|
_isolatedTableCell: useTableScope
|
|
2568
2572
|
})
|
|
@@ -192,6 +192,6 @@ export function deriveParagraphThenTableAppend(modifiedText) {
|
|
|
192
192
|
const tableLines = lines.slice(1);
|
|
193
193
|
if (!tableLines.every(line => line.trim().startsWith('|'))) return null;
|
|
194
194
|
const separator = tableLines[1].replace(/\s+/g, '');
|
|
195
|
-
if (!/^\|:?-{3,}:?(\|:?-{3,}:?)
|
|
195
|
+
if (!/^\|:?-{3,}:?(?:(\|:?-{3,}:?)+\|?|\|)$/.test(separator)) return null;
|
|
196
196
|
return { paragraphMarkdown: lines[0].trim(), tableText: tableLines.join('\n') };
|
|
197
197
|
}
|
|
@@ -310,12 +310,47 @@ export async function ensureNumberingArtifactsInZip(zip, numberingXmlList, optio
|
|
|
310
310
|
}
|
|
311
311
|
}
|
|
312
312
|
|
|
313
|
+
/**
|
|
314
|
+
* Merges incoming comment definitions into an existing comments.xml string. With no
|
|
315
|
+
* existing part, or `replaceExisting`, the incoming part is returned as is (unparsed).
|
|
316
|
+
* Otherwise throws a `PARSE_ERROR` for malformed XML and a `DUPLICATE_COMMENT_ID` for an
|
|
317
|
+
* incoming id the existing part (or an earlier incoming comment) already uses. An incoming
|
|
318
|
+
* part with no comments leaves the existing string as is.
|
|
319
|
+
*
|
|
320
|
+
* @param {string|null|undefined} existingXml
|
|
321
|
+
* @param {string} commentsXml
|
|
322
|
+
* @param {{ replaceExisting?: boolean }} [options]
|
|
323
|
+
* @returns {string}
|
|
324
|
+
*/
|
|
325
|
+
export function mergeCommentsPartXml(existingXml, commentsXml, options = {}) {
|
|
326
|
+
if (!existingXml || options.replaceExisting === true) return commentsXml;
|
|
327
|
+
const existingDoc = parseXmlStrictStandalone(existingXml, 'word/comments.xml (existing)');
|
|
328
|
+
const incomingDoc = parseXmlStrictStandalone(commentsXml, 'word/comments.xml (incoming)');
|
|
329
|
+
const incomingComments = Array.from(incomingDoc.documentElement.getElementsByTagNameNS(NS_W, 'comment'));
|
|
330
|
+
if (incomingComments.length === 0) return existingXml;
|
|
331
|
+
const existingRoot = existingDoc.documentElement;
|
|
332
|
+
const usedIds = new Set(
|
|
333
|
+
Array.from(existingRoot.getElementsByTagNameNS(NS_W, 'comment'))
|
|
334
|
+
.map(comment => comment.getAttribute('w:id') || comment.getAttribute('id'))
|
|
335
|
+
.filter(Boolean)
|
|
336
|
+
);
|
|
337
|
+
for (const incomingComment of incomingComments) {
|
|
338
|
+
const id = incomingComment.getAttribute('w:id') || incomingComment.getAttribute('id');
|
|
339
|
+
if (id && usedIds.has(id)) {
|
|
340
|
+
throw Object.assign(new Error(`Duplicate comment id: ${id}`), { code: 'DUPLICATE_COMMENT_ID', commentId: id });
|
|
341
|
+
}
|
|
342
|
+
if (id) usedIds.add(id);
|
|
343
|
+
existingRoot.appendChild(existingDoc.importNode(incomingComment, true));
|
|
344
|
+
}
|
|
345
|
+
return createSerializer().serializeToString(existingDoc);
|
|
346
|
+
}
|
|
347
|
+
|
|
313
348
|
/**
|
|
314
349
|
* Ensures comments part + package metadata exist and merges incoming comments.
|
|
315
350
|
*
|
|
316
351
|
* @param {any} zip
|
|
317
352
|
* @param {string|null|undefined} commentsXml
|
|
318
|
-
* @param {{ onInfo?: (message: string) => void }} [options]
|
|
353
|
+
* @param {{ onInfo?: (message: string) => void, replaceExisting?: boolean }} [options]
|
|
319
354
|
*/
|
|
320
355
|
export async function ensureCommentsArtifactsInZip(zip, commentsXml, options = {}) {
|
|
321
356
|
const onInfo = typeof options?.onInfo === 'function' ? options.onInfo : () => {};
|
|
@@ -323,27 +358,8 @@ export async function ensureCommentsArtifactsInZip(zip, commentsXml, options = {
|
|
|
323
358
|
|
|
324
359
|
const serializer = createSerializer();
|
|
325
360
|
const existingText = await readZipText(zip, COMMENTS_PATH);
|
|
326
|
-
if (!existingText || options.replaceExisting === true)
|
|
327
|
-
|
|
328
|
-
zip.file(COMMENTS_PATH, commentsXml);
|
|
329
|
-
} else {
|
|
330
|
-
const existingDoc = parseXmlStrictStandalone(existingText, 'word/comments.xml (existing)');
|
|
331
|
-
const incomingDoc = parseXmlStrictStandalone(commentsXml, 'word/comments.xml (incoming)');
|
|
332
|
-
const existingRoot = existingDoc.documentElement;
|
|
333
|
-
const existingIds = new Set(
|
|
334
|
-
Array.from(existingRoot.getElementsByTagNameNS(NS_W, 'comment'))
|
|
335
|
-
.map(comment => comment.getAttribute('w:id') || comment.getAttribute('id'))
|
|
336
|
-
.filter(Boolean)
|
|
337
|
-
);
|
|
338
|
-
for (const incomingComment of Array.from(incomingDoc.documentElement.getElementsByTagNameNS(NS_W, 'comment'))) {
|
|
339
|
-
const id = incomingComment.getAttribute('w:id') || incomingComment.getAttribute('id');
|
|
340
|
-
if (id && existingIds.has(id)) {
|
|
341
|
-
throw new Error(`Duplicate comment id: ${id}`);
|
|
342
|
-
}
|
|
343
|
-
existingRoot.appendChild(existingDoc.importNode(incomingComment, true));
|
|
344
|
-
}
|
|
345
|
-
zip.file(COMMENTS_PATH, serializer.serializeToString(existingDoc));
|
|
346
|
-
}
|
|
361
|
+
if (!existingText || options.replaceExisting === true) onInfo('[Demo] Adding comments.xml');
|
|
362
|
+
zip.file(COMMENTS_PATH, mergeCommentsPartXml(existingText, commentsXml, options));
|
|
347
363
|
|
|
348
364
|
const ctText = await readZipText(zip, CONTENT_TYPES_PATH);
|
|
349
365
|
if (ctText) {
|
|
@@ -8,7 +8,12 @@ import { computeWordLevelDiffOps } from '../pipeline/diff-engine.js';
|
|
|
8
8
|
import { splitRunsAtDiffBoundaries, applyPatches } from '../pipeline/patching.js';
|
|
9
9
|
import { serializeToOoxml } from '../pipeline/serialization.js';
|
|
10
10
|
import { createRevisionMetadata, escapeXml, RunKind } from '../core/types.js';
|
|
11
|
-
import { preprocessMarkdown } from '../pipeline/markdown-processor.js';
|
|
11
|
+
import { preprocessMarkdown } from '../pipeline/markdown-processor.js';
|
|
12
|
+
|
|
13
|
+
// A caller's structuredContent: false (options.plainTextContent) keeps cell text literal: no inline Markdown.
|
|
14
|
+
const cellMarkdown = (text, options) => (options?.plainTextContent === true
|
|
15
|
+
? { cleanText: String(text ?? ''), formatHints: [] }
|
|
16
|
+
: preprocessMarkdown(text));
|
|
12
17
|
import { paragraphMarkPPrXml } from '../engine/paragraph-mark-revisions.js';
|
|
13
18
|
|
|
14
19
|
/**
|
|
@@ -64,7 +69,7 @@ export function generateTableOoxml(tableData, options = {}) {
|
|
|
64
69
|
|
|
65
70
|
for (let c = 0; c < numCols; c++) {
|
|
66
71
|
const cellText = row[c] || '';
|
|
67
|
-
const { cleanText, formatHints } =
|
|
72
|
+
const { cleanText, formatHints } = cellMarkdown(cellText, options);
|
|
68
73
|
|
|
69
74
|
// Build run model for the cell
|
|
70
75
|
const runModel = [{
|
|
@@ -239,7 +244,7 @@ export function serializeVirtualGridToOoxml(grid, operations, options) {
|
|
|
239
244
|
const rowInsertMeta = generateRedlines ? createRevisionMetadata(author, revisionIdAllocator) : null;
|
|
240
245
|
|
|
241
246
|
for (const cellText of op.cells) {
|
|
242
|
-
const { cleanText, formatHints } =
|
|
247
|
+
const { cleanText, formatHints } = cellMarkdown(cellText, options);
|
|
243
248
|
// Simple run model for new cell
|
|
244
249
|
const runModel = [{
|
|
245
250
|
kind: generateRedlines ? RunKind.INSERTION : RunKind.TEXT,
|
|
@@ -340,7 +345,7 @@ function reconcileMultiBlockCellContent(cell, newText, options) {
|
|
|
340
345
|
}
|
|
341
346
|
return;
|
|
342
347
|
}
|
|
343
|
-
const { cleanText, formatHints } =
|
|
348
|
+
const { cleanText, formatHints } = cellMarkdown(line, options);
|
|
344
349
|
const diffOps = computeWordLevelDiffOps(block.acceptedText, cleanText);
|
|
345
350
|
const model = applyPatches(splitRunsAtDiffBoundaries(block.runModel, diffOps), diffOps, {
|
|
346
351
|
generateRedlines, author, formatHints
|
|
@@ -349,7 +354,7 @@ function reconcileMultiBlockCellContent(cell, newText, options) {
|
|
|
349
354
|
});
|
|
350
355
|
|
|
351
356
|
for (let i = cell.blocks.length; i < lines.length; i++) {
|
|
352
|
-
const { cleanText, formatHints } =
|
|
357
|
+
const { cleanText, formatHints } = cellMarkdown(lines[i], options);
|
|
353
358
|
const runModel = [{
|
|
354
359
|
kind: generateRedlines ? RunKind.INSERTION : RunKind.TEXT,
|
|
355
360
|
text: cleanText, rPrXml: '', author, startOffset: 0, endOffset: cleanText.length
|
|
@@ -365,7 +370,7 @@ function reconcileCellContent(cell, newText, options) {
|
|
|
365
370
|
return reconcileMultiBlockCellContent(cell, newText, options);
|
|
366
371
|
}
|
|
367
372
|
const { generateRedlines, author, revisionIdAllocator = null } = options;
|
|
368
|
-
const { cleanText, formatHints } =
|
|
373
|
+
const { cleanText, formatHints } = cellMarkdown(newText, options);
|
|
369
374
|
|
|
370
375
|
// For now, satisfy with single block or join blocks
|
|
371
376
|
// In a full implementation, we'd diff paragraphs within the cell
|