@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.
@@ -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
@@ -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 });
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ansonlai/docx-redline-js",
3
- "version": "0.10.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);
@@ -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
+ }
@@ -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,}:?)+\|?$/.test(separator)) return null;
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
- onInfo('[Demo] Adding comments.xml');
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 } = preprocessMarkdown(cellText);
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 } = preprocessMarkdown(cellText);
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 } = preprocessMarkdown(line);
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 } = preprocessMarkdown(lines[i]);
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 } = preprocessMarkdown(newText);
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