@ansonlai/docx-redline-js 0.8.4 → 0.10.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 (72) hide show
  1. package/ARCHITECTURE.md +679 -460
  2. package/CHANGELOG.md +208 -1
  3. package/README.md +152 -16
  4. package/core/current-properties.js +136 -0
  5. package/core/paragraph-text.js +211 -209
  6. package/core/types.js +9 -3
  7. package/core/word-revision-semantics.js +256 -0
  8. package/core/word-xml.js +160 -118
  9. package/dist/docx-redline-js.esm.js +4338 -796
  10. package/dist/docx-redline-js.esm.js.map +4 -4
  11. package/dist/docx-redline-js.esm.min.js +111 -108
  12. package/dist/docx-redline-js.esm.min.js.map +4 -4
  13. package/dist/docx-redline.bundle.cjs +4463 -921
  14. package/dist/docx-redline.bundle.cjs.map +4 -4
  15. package/dist/docx-redline.bundle.js +4463 -921
  16. package/dist/docx-redline.bundle.js.map +4 -4
  17. package/docs/AGENT_FAST_START.md +2 -0
  18. package/docs/AGENT_KNOWLEDGE_BASE.md +49 -7
  19. package/docs/SKILL_AUTHORING.md +169 -150
  20. package/docs/TESTING.md +1146 -1059
  21. package/docs/schemas/document-operations.schema.json +55 -2
  22. package/document/docx-document.js +1022 -1010
  23. package/engine/oxml-engine.js +21 -22
  24. package/engine/paragraph-mark-revisions.js +292 -0
  25. package/engine/reconstruction-mapper.js +312 -311
  26. package/engine/reconstruction-writer.js +67 -10
  27. package/engine/rpr-helpers.js +42 -0
  28. package/engine/run-builders.js +0 -73
  29. package/engine/surgical-diff-application.js +604 -605
  30. package/engine/table-mode.js +63 -63
  31. package/index.d.ts +53 -2
  32. package/index.js +13 -2
  33. package/node/cli-help.js +1 -0
  34. package/node/cli.js +21 -3
  35. package/orchestration/list-structural-fallback.js +67 -9
  36. package/package.json +1 -1
  37. package/pipeline/diff-engine.js +130 -1
  38. package/pipeline/ingestion-export.js +227 -243
  39. package/pipeline/ingestion-paragraph.js +93 -98
  40. package/pipeline/list-generation.js +415 -419
  41. package/pipeline/markdown-processor.js +5 -2
  42. package/pipeline/pipeline.js +206 -204
  43. package/scripts/export-validation-fixtures.mjs +19 -6
  44. package/scripts/generate-word-revision-semantics-matrix.mjs +134 -0
  45. package/scripts/lib/word-coverage-catalogue.mjs +7 -2
  46. package/scripts/render-multilevel-bullet-visual.ps1 +2 -2
  47. package/scripts/word-com-corpus-visual-suite.ps1 +2 -2
  48. package/scripts/word-com-differential.ps1 +443 -335
  49. package/scripts/word-com-visual-suite.ps1 +2 -2
  50. package/scripts/word-revision-semantics-matrix.ps1 +108 -0
  51. package/services/batch-operation-orchestrator.js +23 -3
  52. package/services/capture-engine.js +228 -226
  53. package/services/comment-replies.js +1 -1
  54. package/services/document-operation-applier.js +214 -0
  55. package/services/document-operation-contract.js +76 -0
  56. package/services/document-operation-mutations.js +688 -51
  57. package/services/error-recovery.js +17 -0
  58. package/services/inspection-comparison.js +82 -0
  59. package/services/list-conversion-diagnostics.js +296 -0
  60. package/services/list-format-change.js +586 -0
  61. package/services/list-format-mutation.js +416 -0
  62. package/services/list-format.js +305 -0
  63. package/services/numbering-allocation.js +642 -0
  64. package/services/numbering-helpers.js +354 -524
  65. package/services/operation-batch-compiler.js +183 -11
  66. package/services/operation-preflight.js +43 -33
  67. package/services/receipt-collector.js +288 -288
  68. package/services/revision-comment-management.js +38 -5
  69. package/services/revision-fidelity-verifier.js +484 -0
  70. package/services/standalone-docx-plumbing.js +627 -631
  71. package/services/standalone-operation-runner.d.ts +182 -5
  72. package/services/table-reconciliation.js +59 -7
@@ -42,6 +42,43 @@ export type HeaderFooterPartSelector =
42
42
  | string
43
43
  | { kind: 'header' | 'footer'; type?: 'default' | 'first' | 'even'; section?: number };
44
44
 
45
+ /**
46
+ * Structured list-conversion diagnostic (`list-conversion-diagnostics-v1`). Warnings appear in
47
+ * `diagnostics` (and as `CODE: message` strings in `warnings`) on an operation that succeeded;
48
+ * the same details appear on `error` when the strict policy refuses the operation. They are
49
+ * unrelated to `REVISION_FIDELITY_VIOLATION`.
50
+ */
51
+ export interface ListConversionDiagnostic {
52
+ code:
53
+ | 'LIST_MARKER_NOT_BOUND'
54
+ | 'UNSUPPORTED_LIST_CONVERSION'
55
+ | 'LIST_FORMAT_NOT_APPLIED'
56
+ | 'UNSUPPORTED_LIST_FORMAT_CHANGE'
57
+ | 'UNKNOWN_PARAGRAPH_FORMAT_PROPERTY';
58
+ severity: 'warning' | 'error';
59
+ message: string;
60
+ /** The literal marker text (for example `A.`) the edit introduced. */
61
+ listMarker?: string;
62
+ requestedList?: { kind: 'bullet' | 'numbered'; format: string };
63
+ /** The numbering definition the list item keeps (format-change diagnostics). */
64
+ sourceList?: { numId: string; level: number; kind: 'bullet' | 'numbered'; format: string };
65
+ targetExcerpt?: string;
66
+ operationIndex?: number;
67
+ /** Rejected `paragraph-format.properties` keys (UNKNOWN_PARAGRAPH_FORMAT_PROPERTY). */
68
+ properties?: string[];
69
+ recovery?: {
70
+ action: string;
71
+ hint?: string;
72
+ field?: string;
73
+ allowedValue?: string;
74
+ /**
75
+ * The list-format operation to send (add a target): convert-plain for LIST_MARKER_NOT_BOUND /
76
+ * UNSUPPORTED_LIST_CONVERSION, change-format (scope "list") for LIST_FORMAT_NOT_APPLIED / UNSUPPORTED_LIST_FORMAT_CHANGE.
77
+ */
78
+ suggestedOperation?: { type: 'list-format'; list: ListFormatRequest };
79
+ };
80
+ }
81
+
45
82
  export interface DocumentOperationBase {
46
83
  operationId?: string;
47
84
  /** Target a header or footer instead of the body. Comments are not supported there. */
@@ -54,6 +91,11 @@ export interface DocumentOperationBase {
54
91
  existingRevisions?: ExistingRevisionsPolicy;
55
92
  pairReplacements?: boolean;
56
93
  insertionAffinity?: InsertionAffinity;
94
+ /**
95
+ * `'literal'` accepts list-marker text as literal text and suppresses LIST_MARKER_NOT_BOUND,
96
+ * UNSUPPORTED_LIST_CONVERSION, LIST_FORMAT_NOT_APPLIED and UNSUPPORTED_LIST_FORMAT_CHANGE for this operation.
97
+ */
98
+ listMarkers?: 'literal';
57
99
  }
58
100
 
59
101
  interface RedlineDocumentOperationCommon extends DocumentOperationBase {
@@ -148,12 +190,19 @@ export interface CharacterFormatProperties {
148
190
  fontFamily?: string | null;
149
191
  }
150
192
 
193
+ /** Closed set. Unknown keys are rejected with INVALID_OPERATION (field `properties.<key>`) since 0.9.0. */
151
194
  export interface ParagraphFormatProperties {
152
195
  alignment?: 'left' | 'center' | 'right' | 'both';
153
196
  keepNext?: boolean;
154
197
  keepLines?: boolean;
155
198
  pageBreakBefore?: boolean;
156
199
  style?: string | null;
200
+ /**
201
+ * Binds the paragraph to an EXISTING list (`numId` and `level` as reported by inspect), or `null` to remove its direct
202
+ * list binding. Tracked as `w:pPrChange`; refuses `PARAGRAPH_NUMBERING_NOT_FOUND` when the numId/level is not defined.
203
+ * Capability `paragraph-numbering-binding-v1`.
204
+ */
205
+ numbering?: { numId: number | string; level?: number } | null;
157
206
  }
158
207
 
159
208
  export interface HighlightDocumentOperation extends DocumentOperationBase {
@@ -176,6 +225,108 @@ export interface ParagraphFormatDocumentOperation extends DocumentOperationBase
176
225
  properties: ParagraphFormatProperties;
177
226
  }
178
227
 
228
+ export type ListNumberFormat = 'decimal' | 'upperLetter' | 'lowerLetter' | 'upperRoman' | 'lowerRoman' | 'bullet';
229
+
230
+ /** `list-format` request. `convert-plain`: `list-format-v1` (CLI contract version 9); `change-format` and `join`: `list-format-change-v1`. */
231
+ export type ListFormatRequest = ListFormatConvertPlainRequest | ListFormatChangeFormatRequest | ListFormatJoinRequest;
232
+
233
+ /** `convert-plain` (`list-format-v1`): bind plain paragraphs to a NEW list. */
234
+ export interface ListFormatConvertPlainRequest {
235
+ mode: 'convert-plain';
236
+ kind?: 'bullet' | 'numbered';
237
+ /** Required with `strip: 'none'`; with `strip: 'marker'` it must match the typed markers (and disambiguates `I.`/`C.`). */
238
+ format?: 'decimal' | 'upperLetter' | 'lowerLetter' | 'upperRoman' | 'lowerRoman' | 'bullet';
239
+ /** 0-8, default 0. */
240
+ level?: number;
241
+ /** `'marker'` (default) removes the typed marker as a tracked deletion; `'none'` keeps the text and only binds. */
242
+ strip?: 'marker' | 'none';
243
+ /** `strip: 'none'` only. Defaults to continuing the batch's previous converted list of the same format, else 1. */
244
+ start?: number;
245
+ }
246
+
247
+ /**
248
+ * `change-format` (`list-format-change-v1`): change the numbering format of the target's list. Allocates a NEW
249
+ * definition (copying the source levels and geometry, one level redefined) and rebinds the items with tracked
250
+ * `w:pPrChange`s; the shared definition is never edited, so Reject All restores the original labels.
251
+ */
252
+ export interface ListFormatChangeFormatRequest {
253
+ mode: 'change-format';
254
+ kind?: 'bullet' | 'numbered';
255
+ /** The new format of the level (`kind: 'numbered'` alone is refused: name the format). */
256
+ format?: ListNumberFormat;
257
+ /** Level text: numbered `%1`..`%9` with the level's own placeholder (`"%1."`, `"(%2)"`); bullet: the bullet character. */
258
+ lvlText?: string;
259
+ /** Restart value of the level (the new definition's `w:start`); scope `'list'` only. */
260
+ start?: number;
261
+ /** The level to redefine; default: the one level the target paragraphs use. */
262
+ level?: number;
263
+ /** `'list'` (default): every live item of the list; `'range'`: the target range, which must run to the end of the list. */
264
+ scope?: 'list' | 'range';
265
+ }
266
+
267
+ /** `join` (`list-format-change-v1`): the target list (or a range running to its end) continues an earlier list. */
268
+ export interface ListFormatJoinRequest {
269
+ mode: 'join';
270
+ /** An item of the earlier list to continue; bound at batch start like `target`. */
271
+ continueFrom: ParagraphTargetDescriptor;
272
+ scope?: 'list' | 'range';
273
+ }
274
+
275
+ /**
276
+ * Binds plain paragraphs (target, or target..targetEnd in one container) to a NEW list definition in place. The
277
+ * binding is a tracked `w:pPrChange`; the stripped marker is a tracked `w:del`. Each typed marker's value is the
278
+ * item's value, so Accept All renders the labels the text showed. Body only (no `part`); text changes belong in a
279
+ * separate single-paragraph `redline` of the same paragraph that keeps the typed marker exactly: the batch compiler
280
+ * lets that pair share one batch and runs the redline first ("1. Old" -> "1. New": redline the text, convert-plain
281
+ * strips "1. "); other pairs fail LIST_FORMAT_COMPOSITION_CONFLICT. Refusals: LIST_MARKER_NOT_FOUND, LIST_MARKER_AMBIGUOUS, LIST_MARKER_UNSUPPORTED,
282
+ * LIST_FORMAT_TARGET_IS_LIST, LIST_FORMAT_STYLE_NUMBERED, PARAGRAPH_FORMAT_REVISION_CONFLICT.
283
+ *
284
+ * `change-format` / `join` (`list-format-change-v1`) rebind existing list items with tracked `w:pPrChange`s. With
285
+ * `scope: 'list'` every rebound item counts as a write of the operation in the batch compiler: only a single-paragraph
286
+ * `redline` per item composes with it (LIST_FORMAT_COMPOSITION_CONFLICT `reason: list-rebind` otherwise).
287
+ * Refusals: LIST_FORMAT_TARGET_NOT_LIST, LIST_FORMAT_MIXED_RANGE, LIST_FORMAT_STYLE_LINKED, LIST_FORMAT_STYLE_NUMBERED,
288
+ * LIST_FORMAT_SPLIT_UNSUPPORTED, LIST_FORMAT_DEFINITION_UNSUPPORTED, LIST_FORMAT_JOIN_INVALID,
289
+ * PARAGRAPH_FORMAT_REVISION_CONFLICT; a no-op is `ok` with a LIST_FORMAT_UNCHANGED warning.
290
+ */
291
+ export interface ListFormatDocumentOperation extends Omit<DocumentOperationBase, 'part'> {
292
+ type: 'list-format';
293
+ list: ListFormatRequest;
294
+ targetEnd?: ParagraphTargetDescriptor;
295
+ targetEndRef?: number | string | null;
296
+ }
297
+
298
+ /** What a `list-format` operation bound or rebound (`result.list`). */
299
+ export type ListFormatResult = ListFormatConvertPlainResult | ListFormatRebindResult;
300
+
301
+ /** `convert-plain` result. */
302
+ export interface ListFormatConvertPlainResult {
303
+ mode: 'convert-plain';
304
+ format: 'decimal' | 'upperLetter' | 'lowerLetter' | 'upperRoman' | 'lowerRoman' | 'bullet';
305
+ level: number;
306
+ strip: 'marker' | 'none';
307
+ items: Array<{ paragraphIndex: number; paragraphId: string | null; marker: string | null; value: number | null; numId: string }>;
308
+ /** Paragraphs of the range left as they were: empty, or (strip 'marker') body text without a marker. */
309
+ skipped: Array<{ paragraphIndex: number; reason: 'empty' | 'no-marker' }>;
310
+ }
311
+
312
+ /** `change-format` / `join` result: every paragraph rebound (scope `'list'` includes items outside the target). */
313
+ export interface ListFormatRebindResult {
314
+ mode: 'change-format' | 'join';
315
+ scope: 'list' | 'range';
316
+ sourceNumId: string;
317
+ /** change-format: the newly allocated numId; join: the continued list's numId. */
318
+ numId: string;
319
+ format?: ListNumberFormat;
320
+ level?: number;
321
+ lvlText?: string;
322
+ /** change-format: per-level `w:start` values of the new definition (carried restarts, split continuation, `start`). */
323
+ levelStarts?: Record<string, number>;
324
+ continueFrom?: { paragraphIndex: number | null; numId: string };
325
+ items: Array<{ paragraphIndex: number | null; paragraphId: string | null; level: number; numId: string; sourceNumId: string; inTarget: boolean }>;
326
+ /** Tracked-deleted paragraphs in the range keep their binding. */
327
+ skipped: Array<{ paragraphIndex: number | null; reason: 'tracked-deleted' }>;
328
+ }
329
+
179
330
  export type DocumentOperation =
180
331
  | RedlineDocumentOperation
181
332
  | RestoreDocumentOperation
@@ -185,7 +336,8 @@ export type DocumentOperation =
185
336
  | CommentResolveDocumentOperation
186
337
  | HighlightDocumentOperation
187
338
  | CharacterFormatDocumentOperation
188
- | ParagraphFormatDocumentOperation;
339
+ | ParagraphFormatDocumentOperation
340
+ | ListFormatDocumentOperation;
189
341
 
190
342
  export interface ResolvedDocumentTarget {
191
343
  index: number;
@@ -221,6 +373,20 @@ export interface StandaloneRunnerOptions {
221
373
  generateRedlines?: boolean;
222
374
  existingRevisions?: ExistingRevisionsPolicy;
223
375
  strictTargets?: boolean;
376
+ /**
377
+ * Make list-conversion diagnostics errors (UNSUPPORTED_LIST_CONVERSION, UNSUPPORTED_LIST_FORMAT_CHANGE)
378
+ * instead of warnings. Defaults to `atomic`; the CLI agent profile sets it. An operation's
379
+ * `listMarkers: 'literal'` always wins.
380
+ */
381
+ strictListConversion?: boolean;
382
+ /**
383
+ * Post-apply check that Word's Reject All of each tracked operation's output equals the source
384
+ * (paragraph count, text, properties, numbering, wrapper membership). Default `'enforce'` refuses
385
+ * the operation with `REVISION_FIDELITY_VIOLATION` (per-invariant `violations[]` and
386
+ * `recovery.retryGuidance`); `'warn'` applies it and appends the same message to `warnings`;
387
+ * `'off'` is for diagnosis only.
388
+ */
389
+ verifyRevisionFidelity?: 'enforce' | 'warn' | 'off';
224
390
  expectedRevision?: RevisionToken;
225
391
  pairReplacements?: boolean;
226
392
  insertionAffinity?: InsertionAffinity;
@@ -288,30 +454,36 @@ export interface DocumentOperationResult {
288
454
  commentsXmlMode?: 'merge' | 'replace';
289
455
  commentsExtendedXmlMode?: 'merge' | 'replace';
290
456
  warnings?: string[];
457
+ diagnostics?: ListConversionDiagnostic[];
291
458
  status?: RedlineStatus;
292
459
  error?: RedlineError;
293
- operationType: 'redline' | 'comment' | 'comment_reply' | 'comment_resolve' | 'highlight' | 'format' | 'paragraph-format';
460
+ operationType: 'redline' | 'comment' | 'comment_reply' | 'comment_resolve' | 'highlight' | 'format' | 'paragraph-format' | 'list-format';
294
461
  authorUsed: string;
295
462
  resolvedBy?: string;
296
463
  resolvedTarget?: ResolvedDocumentTarget;
297
464
  resolvedAnchor?: ResolvedCommentAnchor;
298
465
  receipt?: MutationReceipt;
299
466
  change?: LocalizedReplacementChange;
467
+ /** `list-format` operations: what was bound. */
468
+ list?: ListFormatResult;
300
469
  }
301
470
 
302
471
  export interface BatchOperationItemResult {
303
472
  index: number;
304
473
  type: string;
305
- operationType: 'redline' | 'comment' | 'comment_reply' | 'comment_resolve' | 'highlight' | 'format' | 'paragraph-format';
474
+ operationType: 'redline' | 'comment' | 'comment_reply' | 'comment_resolve' | 'highlight' | 'format' | 'paragraph-format' | 'list-format';
306
475
  status: 'applied' | 'no_change' | 'error';
307
476
  authorUsed: string;
308
477
  resolvedBy?: string;
309
478
  resolvedTarget?: ResolvedDocumentTarget;
310
479
  resolvedAnchor?: ResolvedCommentAnchor;
311
480
  warnings?: string[];
481
+ diagnostics?: ListConversionDiagnostic[];
312
482
  error?: RedlineError;
313
483
  receipt?: MutationReceipt;
314
484
  change?: LocalizedReplacementChange;
485
+ /** `list-format` operations: what was bound. */
486
+ list?: ListFormatResult;
315
487
  }
316
488
 
317
489
  export interface DocumentOperationBatchResult {
@@ -347,7 +519,7 @@ export interface MutationRetryPlan {
347
519
  export interface OperationPreflightItemResult {
348
520
  index: number;
349
521
  type: string;
350
- operationType: 'redline' | 'comment' | 'comment_reply' | 'comment_resolve' | 'highlight' | 'format' | 'paragraph-format';
522
+ operationType: 'redline' | 'comment' | 'comment_reply' | 'comment_resolve' | 'highlight' | 'format' | 'paragraph-format' | 'list-format';
351
523
  status: 'ready' | 'deferred' | 'error';
352
524
  authorUsed: string;
353
525
  resolvedBy?: string;
@@ -375,10 +547,15 @@ export interface OperationPreflightItemResult {
375
547
  }
376
548
 
377
549
  export interface OperationConflict {
378
- code: 'OVERLAPPING_SOURCE_TARGETS' | 'OVERLAPPING_TEXT_EDITS' | 'REVISION_ORDER_CONFLICT' | 'CAPTURE_FANOUT_CONFLICT' | string;
550
+ code: 'OVERLAPPING_SOURCE_TARGETS' | 'OVERLAPPING_TEXT_EDITS' | 'REVISION_ORDER_CONFLICT' | 'LIST_FORMAT_COMPOSITION_CONFLICT' | 'CAPTURE_FANOUT_CONFLICT' | string;
379
551
  message: string;
380
552
  operationIndexes: number[];
381
553
  target: ResolvedDocumentTarget;
554
+ /** LIST_FORMAT_COMPOSITION_CONFLICT: why the redline and the list-format do not compose. */
555
+ reason?: 'marker-changed' | 'redline-unchanged' | 'redline-shape' | 'list-rebind' | string;
556
+ sourceMarker?: string | null;
557
+ modifiedMarker?: string | null;
558
+ rebindingOperationIndexes?: number[];
382
559
  recoveryVersion?: number;
383
560
  category?: string;
384
561
  recovery?: RedlineError['recovery'];
@@ -8,7 +8,8 @@ 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
+ import { paragraphMarkPPrXml } from '../engine/paragraph-mark-revisions.js';
12
13
 
13
14
  /**
14
15
  * Generates a new w:tbl OOXML structure from Markdown table data.
@@ -75,10 +76,7 @@ export function generateTableOoxml(tableData, options = {}) {
75
76
  endOffset: cleanText.length
76
77
  }];
77
78
 
78
- const cellMarkMeta = trackRows ? createRevisionMetadata(author, revisionIdAllocator) : null;
79
- const cellPPr = cellMarkMeta
80
- ? `<w:pPr><w:rPr><w:ins w:id="${cellMarkMeta.id}" w:author="${escapeXml(cellMarkMeta.author)}" w:date="${cellMarkMeta.date}"/></w:rPr></w:pPr>`
81
- : null;
79
+ const cellPPr = trackRows ? paragraphMarkPPrXml('ins', { author, revisionIdAllocator }) : null;
82
80
  const runsOoxml = serializeToOoxml(runModel, cellPPr, formatHints, {
83
81
  author,
84
82
  generateRedlines,
@@ -153,8 +151,9 @@ export function diffTablesWithVirtualGrid(oldGrid, newTableData) {
153
151
  if (col > oldCell.gridCol) continue;
154
152
 
155
153
  if (newCellText !== undefined) {
156
- const oldText = oldCell.getText();
157
- if (oldText !== newCellText) {
154
+ // Markdown cannot represent a cell's trailing empty paragraphs, so they do not count as a change.
155
+ const oldText = trimTrailingNewlines(oldCell.getText());
156
+ if (oldText !== trimTrailingNewlines(newCellText)) {
158
157
  operations.push({
159
158
  type: 'cell_modify',
160
159
  gridRow: row,
@@ -311,7 +310,60 @@ function buildTableOperationIndex(operations) {
311
310
  };
312
311
  }
313
312
 
313
+ function trimTrailingNewlines(text) {
314
+ return typeof text === 'string' ? text.replace(/\n+$/, '') : text;
315
+ }
316
+
317
+ /**
318
+ * Reconciles a multi-paragraph cell paragraph by paragraph so unchanged paragraphs (including
319
+ * empty ones the Markdown cell text cannot express) are preserved exactly.
320
+ */
321
+ function reconcileMultiBlockCellContent(cell, newText, options) {
322
+ const { generateRedlines, author, revisionIdAllocator = null } = options;
323
+ const lines = String(newText ?? '').split('\n');
324
+ // Trailing blank lines carry no content; never create inserted paragraphs from them.
325
+ while (lines.length > cell.blocks.length && lines[lines.length - 1] === '') lines.pop();
326
+ let out = '';
327
+
328
+ cell.blocks.forEach((block, index) => {
329
+ const line = lines[index];
330
+ if (line === undefined || line === block.acceptedText) {
331
+ if (line === undefined && block.acceptedText !== '') {
332
+ // Markdown dropped this paragraph's text: track the text deletion.
333
+ const diffOps = computeWordLevelDiffOps(block.acceptedText, '');
334
+ const model = applyPatches(splitRunsAtDiffBoundaries(block.runModel, diffOps), diffOps, {
335
+ generateRedlines, author, formatHints: []
336
+ });
337
+ out += serializeToOoxml(model, block.pPr, [], { author, generateRedlines, revisionIdAllocator });
338
+ } else {
339
+ out += serializeToOoxml(block.runModel, block.pPr, [], {});
340
+ }
341
+ return;
342
+ }
343
+ const { cleanText, formatHints } = preprocessMarkdown(line);
344
+ const diffOps = computeWordLevelDiffOps(block.acceptedText, cleanText);
345
+ const model = applyPatches(splitRunsAtDiffBoundaries(block.runModel, diffOps), diffOps, {
346
+ generateRedlines, author, formatHints
347
+ });
348
+ out += serializeToOoxml(model, block.pPr, formatHints, { author, generateRedlines, revisionIdAllocator });
349
+ });
350
+
351
+ for (let i = cell.blocks.length; i < lines.length; i++) {
352
+ const { cleanText, formatHints } = preprocessMarkdown(lines[i]);
353
+ const runModel = [{
354
+ kind: generateRedlines ? RunKind.INSERTION : RunKind.TEXT,
355
+ text: cleanText, rPrXml: '', author, startOffset: 0, endOffset: cleanText.length
356
+ }];
357
+ const pPr = generateRedlines ? paragraphMarkPPrXml('ins', { author, revisionIdAllocator }) : null;
358
+ out += serializeToOoxml(runModel, pPr, formatHints, { author, generateRedlines, revisionIdAllocator });
359
+ }
360
+ return out;
361
+ }
362
+
314
363
  function reconcileCellContent(cell, newText, options) {
364
+ if (cell.blocks.length > 1) {
365
+ return reconcileMultiBlockCellContent(cell, newText, options);
366
+ }
315
367
  const { generateRedlines, author, revisionIdAllocator = null } = options;
316
368
  const { cleanText, formatHints } = preprocessMarkdown(newText);
317
369