@ansonlai/docx-redline-js 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/ARCHITECTURE.md +691 -669
  2. package/CHANGELOG.md +67 -1
  3. package/README.md +39 -8
  4. package/core/word-xml.js +160 -118
  5. package/dist/docx-redline-js.esm.js +528 -312
  6. package/dist/docx-redline-js.esm.js.map +4 -4
  7. package/dist/docx-redline-js.esm.min.js +94 -94
  8. package/dist/docx-redline-js.esm.min.js.map +4 -4
  9. package/dist/docx-redline.bundle.cjs +528 -312
  10. package/dist/docx-redline.bundle.cjs.map +4 -4
  11. package/dist/docx-redline.bundle.js +528 -312
  12. package/dist/docx-redline.bundle.js.map +4 -4
  13. package/docs/AGENT_KNOWLEDGE_BASE.md +64 -5
  14. package/docs/SKILL_AUTHORING.md +5 -1
  15. package/docs/TESTING.md +10 -1
  16. package/docs/schemas/document-operations.schema.json +1 -1
  17. package/engine/oxml-engine.js +748 -736
  18. package/engine/table-mode.js +7 -4
  19. package/index.d.ts +29 -2
  20. package/index.js +3 -0
  21. package/node/cli.js +993 -992
  22. package/package.json +1 -1
  23. package/pipeline/content-analysis.js +2 -1
  24. package/pipeline/markdown-processor.js +5 -2
  25. package/pipeline/structured-content.js +2 -1
  26. package/services/comment-parts-merge.js +124 -0
  27. package/services/comment-replies.js +1 -1
  28. package/services/document-operation-applier.js +1 -0
  29. package/services/document-operation-contract.js +1 -0
  30. package/services/document-operation-mutations.js +3362 -3346
  31. package/services/inspection-comparison.js +82 -0
  32. package/services/operation-heuristics.js +1 -1
  33. package/services/operation-preflight.js +42 -33
  34. package/services/revision-fidelity-verifier.js +5 -19
  35. package/services/standalone-docx-plumbing.js +38 -22
  36. package/services/table-reconciliation.js +11 -6
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Compares two `inspectDocumentParts` results by what a reader and reviewer see:
3
+ * paragraph text, table position, list label, review segments and comments.
4
+ *
5
+ * A host that writes a prepared package uses this to verify what it committed
6
+ * against what was prepared. Revision ids, dates, rsids, paragraph ids and
7
+ * run boundaries are ignored, because hosts regenerate them on write.
8
+ */
9
+
10
+ const commentIdentity = comment => [comment.author ?? null, comment.text ?? '', comment.anchoredText || ''];
11
+
12
+ // Adjacent review segments of one kind and author are one change, however the
13
+ // host split the runs.
14
+ function reviewSegments(segments = []) {
15
+ return segments.filter(segment => segment.kind !== 'baseline').reduce((result, segment) => {
16
+ const next = {
17
+ kind: segment.kind, author: segment.author || null, text: segment.text,
18
+ acceptedStart: segment.acceptedStart ?? null, rejectedStart: segment.rejectedStart ?? null
19
+ };
20
+ const previous = result.at(-1);
21
+ const adjacent = key => (previous[key] == null && next[key] == null)
22
+ || (previous[key] != null && previous[key] + previous.text.length === next[key]);
23
+ if (previous && previous.kind === next.kind && previous.author === next.author
24
+ && adjacent('acceptedStart') && adjacent('rejectedStart')) previous.text += next.text;
25
+ else result.push(next);
26
+ return result;
27
+ }, []);
28
+ }
29
+
30
+ /**
31
+ * The host-independent shape of one inspection.
32
+ * @param {{ paragraphs: Array<object>, comments?: Array<object> }} inspection
33
+ */
34
+ export function inspectionReviewShape(inspection) {
35
+ const comments = inspection?.comments || [];
36
+ return {
37
+ paragraphs: (inspection?.paragraphs || []).map(paragraph => ({
38
+ text: paragraph.exactText,
39
+ inTable: paragraph.inTable === true,
40
+ table: paragraph.table ? [paragraph.table.rowIndex, paragraph.table.cellIndex] : null,
41
+ list: paragraph.list ? [paragraph.list.format, paragraph.list.level, paragraph.list.label] : null,
42
+ review: {
43
+ present: paragraph.hasRevisions === true,
44
+ authors: [...(paragraph.revisionAuthors || [])].sort(),
45
+ segments: reviewSegments(paragraph.segments)
46
+ }
47
+ })),
48
+ comments: comments.map(comment => {
49
+ const parent = comment.parentCommentId != null
50
+ ? comments.find(item => String(item.id) === String(comment.parentCommentId))
51
+ : null;
52
+ return [...commentIdentity(comment), comment.done === true,
53
+ parent ? commentIdentity(parent)
54
+ : (comment.parentCommentId != null || comment.parentParaId ? 'unresolved parent' : null)];
55
+ }).map(item => JSON.stringify(item)).sort()
56
+ };
57
+ }
58
+
59
+ /**
60
+ * @param {{ paragraphs: Array<object>, comments?: Array<object> }} expected - e.g. inspection of the prepared package
61
+ * @param {{ paragraphs: Array<object>, comments?: Array<object> }} actual - e.g. inspection of what the host committed
62
+ * @returns {{ matches: boolean, differences: Array<{ path: string, expected: any, actual: any }> }}
63
+ * `differences` lists at most the first ten mismatches.
64
+ */
65
+ export function compareDocumentInspections(expected, actual) {
66
+ const left = inspectionReviewShape(expected);
67
+ const right = inspectionReviewShape(actual);
68
+ const differences = [];
69
+ const note = (path, a, b) => {
70
+ if (differences.length < 10 && JSON.stringify(a) !== JSON.stringify(b)) differences.push({ path, expected: a, actual: b });
71
+ };
72
+ note('paragraphs.length', left.paragraphs.length, right.paragraphs.length);
73
+ const count = Math.max(left.paragraphs.length, right.paragraphs.length);
74
+ for (let index = 0; index < count; index++) {
75
+ const a = left.paragraphs[index] ?? null;
76
+ const b = right.paragraphs[index] ?? null;
77
+ if (!a || !b) { note(`paragraphs[${index}]`, a, b); continue; }
78
+ for (const key of ['text', 'inTable', 'table', 'list', 'review']) note(`paragraphs[${index}].${key}`, a[key], b[key]);
79
+ }
80
+ note('comments', left.comments, right.comments);
81
+ return { matches: differences.length === 0, differences };
82
+ }
@@ -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
  }
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { parseOoxmlSafe } from '../adapters/xml-adapter.js';
6
6
  import { getDefaultAuthor } from '../adapters/config.js';
7
- import { containsTrackedChanges, getTrackedChangeAuthors } from '../core/word-xml.js';
7
+ import { containsTrackedChanges, getTrackedChangeAuthors, hasUnattributedTrackedChange, resolveExistingRevisionsPolicy } from '../core/word-xml.js';
8
8
  import { NS_W } from '../core/types.js';
9
9
  import { extractCanonicalParagraphText } from '../core/paragraph-text.js';
10
10
  import {
@@ -273,42 +273,49 @@ export function preflightOperations(documentXml, operations, author, options = {
273
273
  : null;
274
274
  const anchorFound = anchor == null
275
275
  || (anchorResolution ? anchorResolution.found : paragraphText.includes(anchor));
276
+ // The paragraphs from the target through targetEnd (just the target without one); null when the
277
+ // end does not resolve after the start.
278
+ const rangeParagraphs = opType => {
279
+ if (operation.targetEndRef) {
280
+ return resolveParagraphRangeByRefs(xmlDoc, operation.targetRef, operation.targetEndRef, {
281
+ opType,
282
+ targetRefSnapshot: options.targetRefSnapshot || null,
283
+ onInfo: options.onInfo,
284
+ onWarn: options.onWarn
285
+ });
286
+ }
287
+ if (!operation.targetEndDescriptor) return [paragraph];
288
+ const endResolved = resolveTargetParagraph(xmlDoc, {
289
+ targetText: operation.targetEndDescriptor.text,
290
+ targetRef: operation.targetEndDescriptor.index,
291
+ targetDescriptor: operation.targetEndDescriptor,
292
+ opType,
293
+ strictAmbiguity: strictTargets,
294
+ paragraphMetadataIndex: currentMetadataIndex,
295
+ metadataIndices,
296
+ onInfo: options.onInfo,
297
+ onWarn: options.onWarn
298
+ });
299
+ const allParagraphs = getDocumentParagraphNodes(xmlDoc);
300
+ const startIndex = allParagraphs.indexOf(paragraph);
301
+ const endIndex = allParagraphs.indexOf(endResolved?.paragraph);
302
+ return startIndex >= 0 && endIndex >= startIndex
303
+ ? allParagraphs.slice(startIndex, endIndex + 1)
304
+ : null;
305
+ };
276
306
  const hasRevisions = containsTrackedChanges(paragraph);
277
- const existingPolicy = operation.existingRevisions
278
- || options.existingRevisions
279
- || 'merge-same-author';
307
+ const requestedPolicy = operation.existingRevisions || options.existingRevisions || 'merge-same-author';
308
+ const existingPolicy = resolveExistingRevisionsPolicy(requestedPolicy,
309
+ requestedPolicy === 'auto' && operation.operationKind === 'redline' && (operation.targetEndRef || operation.targetEndDescriptor)
310
+ ? (rangeParagraphs('redline') || paragraph)
311
+ : paragraph,
312
+ authorUsed);
280
313
  const deletingWholeParagraph = operation.operationKind === 'redline' && operation.modified === '';
281
314
  const commentIds = deletingWholeParagraph ? getCommentIdsInParagraph(paragraph) : [];
282
315
 
283
316
  let error = null;
284
317
  if (operation.operationKind === 'restore') {
285
- let restorationParagraphs = operation.targetEndRef
286
- ? resolveParagraphRangeByRefs(xmlDoc, operation.targetRef, operation.targetEndRef, {
287
- opType: 'restore',
288
- targetRefSnapshot: options.targetRefSnapshot || null,
289
- onInfo: options.onInfo,
290
- onWarn: options.onWarn
291
- })
292
- : [paragraph];
293
- if (!operation.targetEndRef && operation.targetEndDescriptor) {
294
- const endResolved = resolveTargetParagraph(xmlDoc, {
295
- targetText: operation.targetEndDescriptor.text,
296
- targetRef: operation.targetEndDescriptor.index,
297
- targetDescriptor: operation.targetEndDescriptor,
298
- opType: 'restore',
299
- strictAmbiguity: strictTargets,
300
- paragraphMetadataIndex: currentMetadataIndex,
301
- metadataIndices,
302
- onInfo: options.onInfo,
303
- onWarn: options.onWarn
304
- });
305
- const allParagraphs = getDocumentParagraphNodes(xmlDoc);
306
- const startIndex = allParagraphs.indexOf(paragraph);
307
- const endIndex = allParagraphs.indexOf(endResolved?.paragraph);
308
- restorationParagraphs = startIndex >= 0 && endIndex >= startIndex
309
- ? allParagraphs.slice(startIndex, endIndex + 1)
310
- : null;
311
- }
318
+ const restorationParagraphs = rangeParagraphs('restore');
312
319
  const replacements = Array.isArray(operation.modified) ? operation.modified : [operation.modified];
313
320
  if (!restorationParagraphs || restorationParagraphs[0] !== paragraph || replacements.length !== restorationParagraphs.length) {
314
321
  error = {
@@ -364,11 +371,13 @@ export function preflightOperations(documentXml, operations, author, options = {
364
371
  if (existingPolicy === 'merge-same-author' || existingPolicy === 'slice-cross-author') {
365
372
  const authors = getTrackedChangeAuthors(paragraph);
366
373
  const opAuthor = String(authorUsed || '').trim().toLowerCase();
367
- const allSame = authors.length > 0 && authors.every(a => a.trim().toLowerCase() === opAuthor);
374
+ const allSame = authors.length > 0
375
+ && !hasUnattributedTrackedChange(paragraph)
376
+ && authors.every(a => a.trim().toLowerCase() === opAuthor);
368
377
  if (!allSame && existingPolicy === 'merge-same-author') {
369
378
  error = {
370
379
  code: 'EXISTING_REVISIONS',
371
- message: `Target paragraph contains tracked changes from another author (${authors.length ? authors.join(', ') : 'unattributed'}). Use existingRevisions: "slice-cross-author" for a surgical edit that preserves reviewer history; accepting or rejecting revisions requires separate authorization.`,
380
+ message: `Target paragraph contains tracked changes from another author (${[...authors, ...(hasUnattributedTrackedChange(paragraph) ? ['unattributed'] : [])].join(', ')}). Use existingRevisions: "slice-cross-author" for a surgical edit that preserves reviewer history; accepting or rejecting revisions requires separate authorization.`,
372
381
  revisionAuthors: authors,
373
382
  currentPolicy: existingPolicy
374
383
  };
@@ -200,15 +200,15 @@ function parseNumberingDefinitions(text) {
200
200
  const cached = numberingDefinitionCache.get(text);
201
201
  if (cached) return cached;
202
202
  const abstractLevels = new Map();
203
- for (const match of text.matchAll(/<(?:\w+:)?abstractNum([^>]*)>([\s\S]*?)<\/(?:\w+:)?abstractNum>/g)) {
203
+ for (const match of text.matchAll(/<(?:\w+:)?abstractNum\b([^>]*)>([\s\S]*?)<\/(?:\w+:)?abstractNum>/g)) {
204
204
  const id = /abstractNumId="([^"]*)"/.exec(match[1])?.[1];
205
205
  if (id == null) continue;
206
206
  const levels = abstractLevels.get(id) || new Set();
207
- for (const lvl of match[2].matchAll(/<(?:\w+:)?lvl[^>]*?ilvl="([^"]*)"/g)) levels.add(lvl[1]);
207
+ for (const lvl of match[2].matchAll(/<(?:\w+:)?lvl\b[^>]*?ilvl="([^"]*)"/g)) levels.add(lvl[1]);
208
208
  abstractLevels.set(id, levels);
209
209
  }
210
210
  const numToAbstract = new Map();
211
- for (const match of text.matchAll(/<(?:\w+:)?num([^>]*)>([\s\S]*?)<\/(?:\w+:)?num>/g)) {
211
+ for (const match of text.matchAll(/<(?:\w+:)?num\b([^>]*)>([\s\S]*?)<\/(?:\w+:)?num>/g)) {
212
212
  const id = /numId="([^"]*)"/.exec(match[1])?.[1];
213
213
  const abstractId = /abstractNumId[^>]*?val="([^"]*)"/.exec(match[2])?.[1];
214
214
  if (id != null && abstractId != null) numToAbstract.set(id, abstractId);
@@ -231,7 +231,7 @@ function parseNumberingDefinitions(text) {
231
231
  * @param {Document} input.after - post-mutation DOM
232
232
  * @param {Array<string|null|undefined>} input.numberingXmls - numbering part texts available at the seam
233
233
  */
234
- export function verifyNumberingReferences({ before, after, numberingXmls, beforeViews = null, afterViews = null }) {
234
+ export function verifyNumberingReferences({ before, after, numberingXmls }) {
235
235
  const references = documentNode => {
236
236
  const found = new Map();
237
237
  for (const numIdNode of getAllReferencedNumIdElements(documentNode)) {
@@ -247,21 +247,7 @@ export function verifyNumberingReferences({ before, after, numberingXmls, before
247
247
  const used = references(after);
248
248
  if (used.size === 0) return { ok: true };
249
249
  const known = references(before);
250
- const text = (numberingXmls || []).filter(Boolean).join('\n');
251
- const abstractLevels = new Map();
252
- for (const match of text.matchAll(/<(?:\w+:)?abstractNum\b([^>]*)>([\s\S]*?)<\/(?:\w+:)?abstractNum>/g)) {
253
- const id = /abstractNumId="([^"]*)"/.exec(match[1])?.[1];
254
- if (id == null) continue;
255
- const levels = abstractLevels.get(id) || new Set();
256
- for (const lvl of match[2].matchAll(/<(?:\w+:)?lvl\b[^>]*?ilvl="([^"]*)"/g)) levels.add(lvl[1]);
257
- abstractLevels.set(id, levels);
258
- }
259
- const numToAbstract = new Map();
260
- for (const match of text.matchAll(/<(?:\w+:)?num\b([^>]*)>([\s\S]*?)<\/(?:\w+:)?num>/g)) {
261
- const id = /numId="([^"]*)"/.exec(match[1])?.[1];
262
- const abstractId = /abstractNumId[^>]*?val="([^"]*)"/.exec(match[2])?.[1];
263
- if (id != null && abstractId != null) numToAbstract.set(id, abstractId);
264
- }
250
+ const { abstractLevels, numToAbstract } = parseNumberingDefinitions((numberingXmls || []).filter(Boolean).join('\n'));
265
251
  const violations = [];
266
252
  for (const [numId, levels] of used) {
267
253
  if (known.has(numId)) continue;
@@ -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