@ansonlai/docx-redline-js 0.8.3 → 0.9.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 (71) hide show
  1. package/ARCHITECTURE.md +221 -12
  2. package/CHANGELOG.md +803 -586
  3. package/README.md +1018 -901
  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/dist/docx-redline-js.esm.js +4551 -967
  9. package/dist/docx-redline-js.esm.js.map +4 -4
  10. package/dist/docx-redline-js.esm.min.js +112 -106
  11. package/dist/docx-redline-js.esm.min.js.map +4 -4
  12. package/dist/docx-redline.bundle.cjs +4690 -1106
  13. package/dist/docx-redline.bundle.cjs.map +4 -4
  14. package/dist/docx-redline.bundle.js +4690 -1106
  15. package/dist/docx-redline.bundle.js.map +4 -4
  16. package/docs/AGENT_FAST_START.md +2 -0
  17. package/docs/AGENT_KNOWLEDGE_BASE.md +23 -4
  18. package/docs/SKILL_AUTHORING.md +165 -150
  19. package/docs/TESTING.md +1142 -1059
  20. package/docs/schemas/document-operations.schema.json +221 -168
  21. package/document/docx-document.js +1022 -1010
  22. package/engine/oxml-engine.js +567 -560
  23. package/engine/paragraph-mark-revisions.js +292 -0
  24. package/engine/reconstruction-mapper.js +312 -311
  25. package/engine/reconstruction-mode.js +183 -183
  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 +51 -1
  32. package/index.js +47 -3
  33. package/node/cli-help.js +1 -0
  34. package/node/cli.js +992 -974
  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/pipeline.js +206 -204
  42. package/pipeline/serialization.js +111 -94
  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 +26 -0
  52. package/services/capture-engine.js +228 -226
  53. package/services/document-operation-applier.js +215 -0
  54. package/services/document-operation-contract.js +628 -537
  55. package/services/document-operation-mutations.js +789 -46
  56. package/services/error-recovery.js +17 -0
  57. package/services/list-conversion-diagnostics.js +296 -0
  58. package/services/list-format-change.js +586 -0
  59. package/services/list-format-mutation.js +416 -0
  60. package/services/list-format.js +305 -0
  61. package/services/numbering-allocation.js +642 -0
  62. package/services/numbering-helpers.js +354 -524
  63. package/services/operation-batch-compiler.js +612 -436
  64. package/services/operation-heuristics.js +197 -173
  65. package/services/operation-preflight.js +1 -0
  66. package/services/receipt-collector.js +288 -288
  67. package/services/revision-comment-management.js +634 -594
  68. package/services/revision-fidelity-verifier.js +498 -0
  69. package/services/standalone-docx-plumbing.js +627 -631
  70. package/services/standalone-operation-runner.d.ts +606 -427
  71. package/services/table-reconciliation.js +103 -49
@@ -3,15 +3,27 @@
3
3
  */
4
4
 
5
5
  import { createSerializer, parseOoxmlSafe } from '../adapters/xml-adapter.js';
6
+ import {
7
+ resolveListDiagnosticPolicy,
8
+ detectUnboundListMarker,
9
+ buildUnboundListMarkerDiagnostic,
10
+ detectRequestedListFormatMismatch,
11
+ buildListFormatDiagnostic,
12
+ findUnknownParagraphFormatKeys,
13
+ PARAGRAPH_FORMAT_REVISION_CONFLICT,
14
+ PARAGRAPH_NUMBERING_NOT_FOUND,
15
+ buildUnknownParagraphFormatDiagnostic,
16
+ diagnosticWarningText
17
+ } from './list-conversion-diagnostics.js';
18
+ import { getAllReferencedNumIdElements, getCurrentChild } from '../core/current-properties.js';
6
19
  import { findReconstructionParagraphRange } from '../engine/reconstruction-mapper.js';
7
20
  import { createRevisionMetadata } from '../core/types.js';
8
21
  import { containsTrackedChanges, getTrackedChangeAuthors, createWordElement, withOoxmlSourceType } from '../core/word-xml.js';
9
22
  import {
10
- markParagraphMarkInserted,
11
- markParagraphMarkDeleted,
12
23
  snapshotAndAttachRPrChange,
13
24
  snapshotAndAttachPPrChange
14
25
  } from '../engine/run-builders.js';
26
+ import { markParagraphInserted, markParagraphDeleted } from '../engine/paragraph-mark-revisions.js';
15
27
  import {
16
28
  applyCharacterFormatToRPr,
17
29
  checkRunPropertiesChanged,
@@ -42,6 +54,7 @@ import {
42
54
  getParagraphId,
43
55
  createParagraphFingerprint,
44
56
  isMarkdownTableText,
57
+ normalizeWhitespaceForTargeting,
45
58
  findContainingWordElement,
46
59
  resolveTargetParagraphWithSnapshot as resolveTargetParagraphWithSnapshotShared,
47
60
  resolveParagraphRangeByRefs,
@@ -70,11 +83,19 @@ import {
70
83
  import {
71
84
  reserveNextNumberingIdPair,
72
85
  remapNumberingPayloadForDocument,
73
- reuseSourceListNumbering,
74
86
  overwriteParagraphNumIds,
75
87
  extractFirstParagraphNumId,
76
88
  buildExplicitDecimalMultilevelNumberingXml
77
89
  } from './numbering-helpers.js';
90
+ import {
91
+ allocateNumberingForReplacement,
92
+ checkNumberingBinding,
93
+ continueBatchListSequence,
94
+ mergeNumberingParts
95
+ } from './numbering-allocation.js';
96
+ import { convertPlainParagraphsToList, numIdIsLive, sequenceMapOf } from './list-format-mutation.js';
97
+ import { changeListParagraphsFormat, joinListParagraphs } from './list-format-change.js';
98
+ import { LIST_FORMAT_UNCHANGED, normalizeListFormatRequest } from './list-format.js';
78
99
  import {
79
100
  extractReplacementNodesFromOoxml,
80
101
  normalizeBodySectionOrderStandalone
@@ -82,9 +103,12 @@ import {
82
103
  import { prepareRevisionAllocator } from './document-operation-session.js';
83
104
  import {
84
105
  buildExplicitRangeInsertionEntries,
106
+ deriveParagraphThenTableAppend,
85
107
  deriveSingleParagraphListAdjacencyInsertion,
86
108
  deriveSingleParagraphPlainAdjacencyInsertion
87
109
  } from './operation-heuristics.js';
110
+ import { generateTableOoxml } from './table-reconciliation.js';
111
+ import { preprocessMarkdown } from '../pipeline/markdown-processor.js';
88
112
  import { resolveTargetFromCapture, ensureParagraphIdsOnImportedNode } from './capture-engine.js';
89
113
  import {
90
114
  acceptTrackedChangesInOoxml,
@@ -320,6 +344,52 @@ function resolveTargetParagraph(xmlDoc, targetText, targetRef, opType, runtimeCo
320
344
  return resolved;
321
345
  }
322
346
 
347
+ /**
348
+ * Zero-based document-order positions `{ from, to }` of an operation's target (and its targetEnd, when it
349
+ * is a range), resolved against the current DOM without mutating, capturing or warning. Used by the
350
+ * revision fidelity verifier to scope accept-first baselines and the accepted-intent check exactly.
351
+ * Returns null when the target or its end cannot be resolved.
352
+ */
353
+ export function resolveOperationSourceRange(xmlDoc, targetText, targetRef, targetEndRef, runtimeContext, options = {}) {
354
+ const quiet = { ...options, _resolutionCapture: null, onInfo: () => { }, onWarn: () => { } };
355
+ const positionOf = paragraph => {
356
+ if (!paragraph) return null;
357
+ const position = getDocumentParagraphNodes(xmlDoc).indexOf(paragraph);
358
+ return position >= 0 ? position : null;
359
+ };
360
+ let start;
361
+ try {
362
+ start = resolveTargetParagraph(xmlDoc, targetText, targetRef, 'redline', runtimeContext, quiet);
363
+ } catch {
364
+ return null;
365
+ }
366
+ const from = positionOf(start?.paragraph);
367
+ if (from === null) return null;
368
+ const descriptor = options.targetEndDescriptor || null;
369
+ let endParagraph = null;
370
+ if (options._compiledSourceEndId && options._sourceTargetRegistry) {
371
+ endParagraph = options._sourceTargetRegistry.resolve(options._compiledSourceEndId, xmlDoc)?.paragraph || null;
372
+ } else if (descriptor) {
373
+ endParagraph = resolveTargetParagraph(xmlDoc, descriptor.text || '', descriptor.index, 'redline', runtimeContext, {
374
+ ...quiet,
375
+ targetDescriptor: descriptor,
376
+ _compiledSourceId: null
377
+ })?.paragraph || null;
378
+ } else if (targetEndRef) {
379
+ endParagraph = resolveTargetParagraphWithSnapshotShared(xmlDoc, {
380
+ targetRef: targetEndRef,
381
+ opType: 'redline',
382
+ targetRefSnapshot: runtimeContext?.targetRefSnapshot || null,
383
+ onInfo: () => { },
384
+ onWarn: () => { }
385
+ })?.paragraph || null;
386
+ } else {
387
+ return { from, to: from };
388
+ }
389
+ const to = positionOf(endParagraph);
390
+ return to !== null && to >= from ? { from, to } : null;
391
+ }
392
+
323
393
  function extractReplacementNodes(outputOxml) {
324
394
  return extractReplacementNodesFromOoxml(outputOxml);
325
395
  }
@@ -426,7 +496,7 @@ function buildInsertedListParagraph(xmlDoc, anchorParagraph, entry, revisionMeta
426
496
  // remove the list paragraph itself on Reject All instead of leaving an
427
497
  // empty bullet or number behind.
428
498
  if (generateRedlines) {
429
- markParagraphMarkInserted(xmlDoc, paragraph, author);
499
+ markParagraphInserted(paragraph, { author });
430
500
  }
431
501
 
432
502
  const run = createWordElement(xmlDoc, 'w:r');
@@ -518,7 +588,7 @@ function buildFallbackInsertedPlainParagraph(xmlDoc, text, revisionMetadata, aut
518
588
 
519
589
  if (generateRedlines) {
520
590
  if (options.trackParagraphMark === true) {
521
- markParagraphMarkInserted(xmlDoc, paragraph, author);
591
+ markParagraphInserted(paragraph, { author });
522
592
  }
523
593
  const metadata = revisionMetadata || createRevisionMetadata(author, xmlDoc, 'ins');
524
594
  const ins = createWordElement(xmlDoc, 'w:ins');
@@ -559,7 +629,7 @@ function wrapParagraphContentInInsertion(xmlDoc, paragraph, revisionMetadata, au
559
629
  if (pPr) wrappedParagraph.appendChild(pPr);
560
630
  if (options.paragraphId) wrappedParagraph.setAttributeNS(NS_W14, 'w14:paraId', options.paragraphId);
561
631
  if (options.trackParagraphMark === true) {
562
- markParagraphMarkInserted(xmlDoc, wrappedParagraph, author);
632
+ markParagraphInserted(wrappedParagraph, { author });
563
633
  }
564
634
 
565
635
  const ins = createWordElement(xmlDoc, 'w:ins');
@@ -620,8 +690,9 @@ async function buildInsertedPlainParagraph(xmlDoc, anchorParagraph, text, revisi
620
690
  function collectNumberingIdsFromNodes(nodes) {
621
691
  const ids = new Set();
622
692
  for (const node of nodes || []) {
623
- const numIdNodes = Array.from(node?.getElementsByTagNameNS?.('*', 'numId') || []);
624
- for (const numIdNode of numIdNodes) {
693
+ // Deliberately historical-inclusive: the numbering-part merge must define every id the content can
694
+ // reference, including ids held only by pPrChange snapshots (Reject restores them).
695
+ for (const numIdNode of getAllReferencedNumIdElements(node)) {
625
696
  const val = numIdNode.getAttribute('w:val') || numIdNode.getAttribute('val');
626
697
  if (val != null && val !== '') {
627
698
  ids.add(String(val));
@@ -1379,20 +1450,6 @@ async function tryExplicitDecimalHeaderListConversion({
1379
1450
  if (!strippedContent) return null;
1380
1451
 
1381
1452
  onInfo('[List] Applying explicit numeric header conversion with direct list binding.');
1382
- const redlineResult = await applyRedlineToOxml(
1383
- serializer.serializeToString(targetParagraph),
1384
- currentParagraphText,
1385
- strippedContent,
1386
- {
1387
- author,
1388
- generateRedlines,
1389
- _revisionIdAllocator: revisionIdAllocator
1390
- }
1391
- );
1392
- if (!redlineResult?.hasChanges || typeof redlineResult?.oxml !== 'string') return null;
1393
-
1394
- const extracted = extractReplacementNodes(redlineResult.oxml);
1395
- const replacementNodes = extracted.replacementNodes;
1396
1453
  const numberingAction = resolveSingleLineListFallbackNumberingAction(
1397
1454
  explicitPlan,
1398
1455
  runtimeContext?.listFallbackSequenceState || null
@@ -1423,6 +1480,22 @@ async function tryExplicitDecimalHeaderListConversion({
1423
1480
  onInfo(`[List] Using isolated explicit-start numbering (start ${explicitStart}, numId ${appliedNumId}, abstractNumId ${reservedPair.abstractNumId}).`);
1424
1481
  }
1425
1482
 
1483
+ // Redline only after numbering reservation succeeded so an aborted conversion never records receipt revisions.
1484
+ const redlineResult = await applyRedlineToOxml(
1485
+ serializer.serializeToString(targetParagraph),
1486
+ currentParagraphText,
1487
+ strippedContent,
1488
+ {
1489
+ author,
1490
+ generateRedlines,
1491
+ _revisionIdAllocator: revisionIdAllocator
1492
+ }
1493
+ );
1494
+ if (!redlineResult?.hasChanges || typeof redlineResult?.oxml !== 'string') return null;
1495
+
1496
+ const extracted = extractReplacementNodes(redlineResult.oxml);
1497
+ const replacementNodes = extracted.replacementNodes;
1498
+
1426
1499
  if (explicitPlan.numberingKey && runtimeContext?.listFallbackSharedNumIdByKey instanceof Map) {
1427
1500
  runtimeContext.listFallbackSharedNumIdByKey.delete(explicitPlan.numberingKey);
1428
1501
  }
@@ -1445,7 +1518,8 @@ async function tryExplicitDecimalHeaderListConversion({
1445
1518
  numId: appliedNumId,
1446
1519
  ilvl: 0,
1447
1520
  clearParagraphPropertyChanges: true,
1448
- removeListPropertyNode: true
1521
+ removeListPropertyNode: true,
1522
+ trackPropertyChange: generateRedlines ? { author, revisionIdAllocator } : null
1449
1523
  });
1450
1524
 
1451
1525
  const parent = targetParagraph.parentNode;
@@ -1472,6 +1546,145 @@ async function tryExplicitDecimalHeaderListConversion({
1472
1546
  };
1473
1547
  }
1474
1548
 
1549
+ const FALLBACK_STYLE_TO_LIST_FORMAT = Object.freeze({
1550
+ upperAlpha: 'upperLetter',
1551
+ lowerAlpha: 'lowerLetter',
1552
+ upperRoman: 'upperRoman',
1553
+ lowerRoman: 'lowerRoman',
1554
+ bullet: 'bullet'
1555
+ });
1556
+
1557
+ /**
1558
+ * Text-equal marker conversion ("A. Header" -> "A. Header") of a plain paragraph, done in place through the
1559
+ * list-format convert-plain core: the typed marker becomes a tracked deletion and the binding a tracked pPrChange
1560
+ * in the same paragraph. This replaces the whole-paragraph delete/insert for letters, roman numerals and bullets,
1561
+ * which lost w14:paraId, bookmarks and direct formatting and reviewed as one deleted and one inserted line. Shapes
1562
+ * the core refuses (marker inside a hyperlink, field or revision; changed marker punctuation) keep the structural
1563
+ * fallback below, which is still reject-exact.
1564
+ */
1565
+ function tryInPlaceMarkerListConversion({
1566
+ xmlDoc,
1567
+ serializer,
1568
+ documentXml,
1569
+ operationSession,
1570
+ targetParagraph,
1571
+ currentParagraphText,
1572
+ modifiedText,
1573
+ author,
1574
+ runtimeContext,
1575
+ revisionIdAllocator,
1576
+ generateRedlines = true,
1577
+ onInfo = () => { },
1578
+ options = {}
1579
+ }) {
1580
+ if (!targetParagraph || getParagraphListInfo(targetParagraph)) return null;
1581
+ if (normalizeWhitespaceForTargeting(currentParagraphText) !== normalizeWhitespaceForTargeting(modifiedText)) return null;
1582
+ const plan = buildSingleLineListStructuralFallbackPlan({
1583
+ oxml: serializer.serializeToString(targetParagraph),
1584
+ originalText: currentParagraphText,
1585
+ modifiedText,
1586
+ allowExistingList: false
1587
+ });
1588
+ const format = plan ? FALLBACK_STYLE_TO_LIST_FORMAT[plan.numberingStyle] : null;
1589
+ if (!format) return null;
1590
+ const allParagraphs = getDocumentParagraphNodes(xmlDoc);
1591
+ const conversion = convertPlainParagraphsToList(xmlDoc, [targetParagraph], normalizeListFormatRequest({
1592
+ mode: 'convert-plain',
1593
+ format,
1594
+ strip: 'marker'
1595
+ }), {
1596
+ author: author || getDefaultAuthor(),
1597
+ generateRedlines,
1598
+ revisionIdAllocator,
1599
+ runtimeContext,
1600
+ stylesXml: runtimeContext?.stylesXml || options.stylesXml || null,
1601
+ numberingXml: runtimeContext?.numberingXml || options.numberingXml || null,
1602
+ paragraphIndexOf: paragraph => allParagraphs.indexOf(paragraph) + 1
1603
+ });
1604
+ if (!conversion.ok) {
1605
+ onInfo(`[List] In-place marker conversion declined (${conversion.error.code}); using the structural fallback.`);
1606
+ return null;
1607
+ }
1608
+ onInfo(`[List] Converted typed ${format} marker in place (numId ${conversion.items[0].numId}).`);
1609
+ ensureParagraphIdsOnImportedNode(targetParagraph, operationSession);
1610
+ options?._mutationLiveNodes?.push(targetParagraph);
1611
+ for (const numId of conversion.allocatedNumIds) operationSession?.receiptCollector?.recordNumbering(numId);
1612
+ normalizeBodySectionOrder(xmlDoc);
1613
+ return {
1614
+ documentXml: completedDocumentXml(xmlDoc, serializer, documentXml, operationSession),
1615
+ hasChanges: true,
1616
+ numberingXml: conversion.numberingXml
1617
+ };
1618
+ }
1619
+
1620
+ // CT_PPrBase child order (ECMA-376), used to place copied direct properties on the new list paragraph.
1621
+ const PPR_CHILD_ORDER = [
1622
+ 'pStyle', 'keepNext', 'keepLines', 'pageBreakBefore', 'framePr', 'widowControl', 'numPr', 'suppressLineNumbers',
1623
+ 'pBdr', 'shd', 'tabs', 'suppressAutoHyphens', 'kinsoku', 'wordWrap', 'overflowPunct', 'topLinePunct',
1624
+ 'autoSpaceDE', 'autoSpaceDN', 'bidi', 'adjustRightInd', 'snapToGrid', 'spacing', 'ind', 'contextualSpacing',
1625
+ 'mirrorIndents', 'suppressOverlap', 'jc', 'textDirection', 'textAlignment', 'textboxTightWrap', 'outlineLvl',
1626
+ 'divId', 'cnfStyle', 'rPr', 'sectPr', 'pPrChange'
1627
+ ];
1628
+ const PPR_NOT_COPIED = new Set(['pStyle', 'numPr', 'rPr', 'sectPr', 'pPrChange']);
1629
+
1630
+ /**
1631
+ * The list structural fallback rebuilds the paragraph as a tracked-deleted source paragraph plus a new list
1632
+ * paragraph from text alone. The deleted paragraph must keep the source `pPr` (Reject All restores it, so the
1633
+ * Reject All verifier sees no property change), the new paragraph carries the source's direct properties that the
1634
+ * list binding does not own (`keepNext`, spacing, alignment...), and the paragraph that survives Accept All
1635
+ * keeps the source `w14:paraId` / `w14:textId`.
1636
+ */
1637
+ function restoreSourceIdentityAndPropertiesOnFallback(xmlDoc, replacementNodes, targetParagraph) {
1638
+ const paragraphs = replacementNodes.filter(node => node?.nodeType === 1 && node.localName === 'p');
1639
+ const markOf = (paragraph, kind) => {
1640
+ const rPr = getDirectWordChild(getDirectWordChild(paragraph, 'pPr'), 'rPr');
1641
+ return !!rPr && !!getDirectWordChild(rPr, kind);
1642
+ };
1643
+ const deleted = paragraphs.find(paragraph => markOf(paragraph, 'del'));
1644
+ const created = paragraphs.find(paragraph => !markOf(paragraph, 'del'));
1645
+ const sourcePPr = getDirectWordChild(targetParagraph, 'pPr');
1646
+ if (deleted && sourcePPr) {
1647
+ const restored = xmlDoc.importNode(sourcePPr, true);
1648
+ const mark = getDirectWordChild(getDirectWordChild(deleted, 'pPr'), 'rPr');
1649
+ const delMarker = mark && getDirectWordChild(mark, 'del');
1650
+ if (delMarker) {
1651
+ let rPr = getDirectWordChild(restored, 'rPr');
1652
+ if (!rPr) {
1653
+ rPr = createWordElement(xmlDoc, 'w:rPr');
1654
+ const successor = getDirectWordChild(restored, 'sectPr') || getDirectWordChild(restored, 'pPrChange');
1655
+ restored.insertBefore(rPr, successor || null);
1656
+ }
1657
+ Array.from(rPr.childNodes).forEach(child => {
1658
+ if (child.nodeType === 1 && (child.localName === 'ins' || child.localName === 'del')) rPr.removeChild(child);
1659
+ });
1660
+ rPr.appendChild(xmlDoc.importNode(delMarker, true));
1661
+ deleted.replaceChild(restored, getDirectWordChild(deleted, 'pPr'));
1662
+ }
1663
+ }
1664
+ if (created && sourcePPr) {
1665
+ let createdPPr = getDirectWordChild(created, 'pPr');
1666
+ if (!createdPPr) {
1667
+ createdPPr = createWordElement(xmlDoc, 'w:pPr');
1668
+ created.insertBefore(createdPPr, created.firstChild || null);
1669
+ }
1670
+ for (const child of Array.from(sourcePPr.childNodes)) {
1671
+ if (child.nodeType !== 1 || PPR_NOT_COPIED.has(child.localName)) continue;
1672
+ if (getDirectWordChild(createdPPr, child.localName)) continue;
1673
+ const rank = PPR_CHILD_ORDER.indexOf(child.localName);
1674
+ const successor = Array.from(createdPPr.childNodes).find(node => (
1675
+ node.nodeType === 1 && PPR_CHILD_ORDER.indexOf(node.localName) > rank
1676
+ ));
1677
+ createdPPr.insertBefore(xmlDoc.importNode(child, true), successor || null);
1678
+ }
1679
+ }
1680
+ if (created) {
1681
+ for (const name of ['paraId', 'textId']) {
1682
+ const value = targetParagraph.getAttributeNS?.(NS_W14, name) || targetParagraph.getAttribute?.(`w14:${name}`);
1683
+ if (value) created.setAttributeNS(NS_W14, `w14:${name}`, value);
1684
+ }
1685
+ }
1686
+ }
1687
+
1475
1688
  async function trySingleParagraphListStructuralFallback({
1476
1689
  xmlDoc,
1477
1690
  serializer,
@@ -1513,7 +1726,11 @@ async function trySingleParagraphListStructuralFallback({
1513
1726
  const extracted = extractReplacementNodes(fallbackResult.oxml);
1514
1727
  let replacementNodes = extracted.replacementNodes;
1515
1728
  let numberingXml = extracted.numberingXml || fallbackResult?.numberingXml || null;
1516
- const hasExplicitStartAt = Number.isInteger(fallbackPlan?.startAt) && fallbackPlan.startAt > 0;
1729
+ const hasPositiveStart = Number.isInteger(fallbackPlan?.startAt) && fallbackPlan.startAt > 0;
1730
+ // Only decimal starts use the explicit multilevel-decimal definition. Letter and roman starts (`C.`, `iv.`)
1731
+ // keep the generated definition, whose level-0 startOverride is already applied by the fallback executor.
1732
+ const hasExplicitStartAt = hasPositiveStart && fallbackPlan?.numberingStyle === 'decimal';
1733
+ const hasNonDecimalStart = hasPositiveStart && !hasExplicitStartAt;
1517
1734
  const numberingKey = fallbackResult?.listStructuralFallbackKey || fallbackPlan?.numberingKey || null;
1518
1735
  const numberingAction = resolveSingleLineListFallbackNumberingAction(
1519
1736
  fallbackPlan,
@@ -1569,7 +1786,8 @@ async function trySingleParagraphListStructuralFallback({
1569
1786
  numId: explicitNumIdForBinding,
1570
1787
  ilvl: 0,
1571
1788
  clearParagraphPropertyChanges: true,
1572
- removeListPropertyNode: true
1789
+ removeListPropertyNode: true,
1790
+ trackPropertyChange: generateRedlines ? { author, revisionIdAllocator } : null
1573
1791
  });
1574
1792
  }
1575
1793
  } else {
@@ -1584,7 +1802,23 @@ async function trySingleParagraphListStructuralFallback({
1584
1802
  );
1585
1803
  }
1586
1804
 
1587
- if (!hasExplicitStartAt && runtimeContext?.listFallbackSharedNumIdByKey instanceof Map) {
1805
+ if (!hasExplicitStartAt && numberingXml && runtimeContext) {
1806
+ // Separate operations of one batch that convert continuing markers (`A.` then `B.`) join one numbering
1807
+ // instance, through the same sequence state `list-format convert-plain` uses.
1808
+ const batch = continueBatchListSequence({
1809
+ numberingXml,
1810
+ replacementNodes,
1811
+ sequence: sequenceMapOf(runtimeContext),
1812
+ isNumIdLive: numId => numIdIsLive(xmlDoc, numId)
1813
+ });
1814
+ if (batch) {
1815
+ replacementNodes = batch.replacementNodes;
1816
+ numberingXml = null;
1817
+ onInfo(`[List] Continuing batch list numbering (numId ${batch.numId}).`);
1818
+ }
1819
+ }
1820
+
1821
+ if (!hasExplicitStartAt && !hasNonDecimalStart && runtimeContext?.listFallbackSharedNumIdByKey instanceof Map) {
1588
1822
  const sharedNumId = numberingKey ? runtimeContext.listFallbackSharedNumIdByKey.get(numberingKey) : null;
1589
1823
  if (sharedNumId) {
1590
1824
  overwriteParagraphNumIds(replacementNodes, sharedNumId);
@@ -1601,6 +1835,7 @@ async function trySingleParagraphListStructuralFallback({
1601
1835
 
1602
1836
  const parent = targetParagraph.parentNode;
1603
1837
  if (!parent) return null;
1838
+ if (generateRedlines) restoreSourceIdentityAndPropertiesOnFallback(xmlDoc, replacementNodes, targetParagraph);
1604
1839
  for (const node of replacementNodes) {
1605
1840
  const imported = xmlDoc.importNode(node, true);
1606
1841
  ensureParagraphIdsOnImportedNode(imported, operationSession);
@@ -1623,6 +1858,43 @@ async function trySingleParagraphListStructuralFallback({
1623
1858
  };
1624
1859
  }
1625
1860
 
1861
+ function listDiagnosticFailure(documentXml, diagnostic) {
1862
+ const { severity: _severity, ...error } = diagnostic;
1863
+ return { documentXml, hasChanges: false, numberingXml: null, status: 'error', error };
1864
+ }
1865
+
1866
+ function listDiagnosticFields(diagnostics) {
1867
+ return diagnostics.length > 0
1868
+ ? { diagnostics, warnings: diagnostics.map(diagnosticWarningText) }
1869
+ : {};
1870
+ }
1871
+
1872
+ /**
1873
+ * Compares the markers of a list edit with the numbering definition the source paragraphs keep.
1874
+ * Returns a diagnostic (warning or error severity per policy) or null.
1875
+ */
1876
+ function diagnoseListFormatNotApplied({ sourceParagraphs, modifiedText, runtimeContext, options }) {
1877
+ const policy = resolveListDiagnosticPolicy(options);
1878
+ if (policy === 'off') return null;
1879
+ const sourceNumberingXml = runtimeContext?.numberingIdState?.sourceNumberingXml;
1880
+ if (!sourceNumberingXml) return null;
1881
+ const sourceItems = [];
1882
+ for (const paragraph of sourceParagraphs || []) {
1883
+ const info = getParagraphListInfo(paragraph);
1884
+ if (info) sourceItems.push({ text: getParagraphText(paragraph), numId: info.numId, ilvl: info.ilvl });
1885
+ }
1886
+ const found = detectRequestedListFormatMismatch({ modifiedText, sourceItems, sourceNumberingXml });
1887
+ if (!found) return null;
1888
+ return buildListFormatDiagnostic(found.mismatch, {
1889
+ policy,
1890
+ numId: found.numId,
1891
+ level: found.level,
1892
+ marker: found.marker,
1893
+ targetText: found.targetText,
1894
+ operationIndex: options?._operationIndex
1895
+ });
1896
+ }
1897
+
1626
1898
  export async function applyToParagraphByExactText(documentXml, targetText, modifiedText, author, targetRef = null, targetEndRef = null, runtimeContext = null, options = {}) {
1627
1899
  const generateRedlines = options.generateRedlines !== false;
1628
1900
  const onInfo = typeof options?.onInfo === 'function' ? options.onInfo : () => { };
@@ -1798,7 +2070,7 @@ export async function applyToParagraphByExactText(documentXml, targetText, modif
1798
2070
  && !(hasRevisions && existingPolicy === 'merge-same-author')
1799
2071
  ) {
1800
2072
  if (generateRedlines) {
1801
- markParagraphMarkDeleted(xmlDoc, targetParagraph, author, createRevisionMetadata(author, xmlDoc));
2073
+ markParagraphDeleted(targetParagraph, { author });
1802
2074
  options?._mutationLiveNodes?.push(targetParagraph);
1803
2075
  } else {
1804
2076
  options?._mutationRemovedNodes?.push(targetParagraph);
@@ -1843,7 +2115,31 @@ export async function applyToParagraphByExactText(documentXml, targetText, modif
1843
2115
  let effectiveModifiedText = synthesizedTableMarkdown || modifiedText;
1844
2116
  const useTableScope = !!containingTable && isMarkdownTableText(effectiveModifiedText);
1845
2117
  const isTableMarkdownEdit = isMarkdownTableText(effectiveModifiedText);
1846
- const explicitRangeParagraphs = targetEndRef
2118
+ // A targetEnd given as a compact handle (paragraphId + fingerprint, no numeric index) has no targetEndRef;
2119
+ // its range comes from the batch-compiled end or the end descriptor, never silently dropped.
2120
+ let compiledEndRange = null;
2121
+ if (!targetEndRef && (options._compiledSourceEndId || options.targetEndDescriptor)) {
2122
+ const rangeResolution = resolveOperationParagraphRange(xmlDoc, targetText, targetRef, targetEndRef, 'redline', runtimeContext, {
2123
+ ...options,
2124
+ _resolutionCapture: null
2125
+ });
2126
+ if (rangeResolution.error) {
2127
+ return { documentXml, hasChanges: false, status: 'error', error: rangeResolution.error };
2128
+ }
2129
+ if (rangeResolution.paragraphs.length > 1) {
2130
+ const container = rangeResolution.paragraphs[0].parentNode;
2131
+ if (rangeResolution.paragraphs.some(paragraph => paragraph.parentNode !== container)) {
2132
+ return {
2133
+ documentXml,
2134
+ hasChanges: false,
2135
+ status: 'error',
2136
+ error: { code: 'TARGET_RANGE_INVALID', message: 'A redline target range must stay inside one container (body, table cell, header, ...); it spans a table or other container boundary. Edit the paragraphs on each side separately.' }
2137
+ };
2138
+ }
2139
+ compiledEndRange = rangeResolution.paragraphs;
2140
+ }
2141
+ }
2142
+ const explicitRangeParagraphs = compiledEndRange ? compiledEndRange : targetEndRef
1847
2143
  ? resolveParagraphRangeByRefs(xmlDoc, targetRef, targetEndRef, {
1848
2144
  opType: 'redline',
1849
2145
  targetRefSnapshot: runtimeContext?.targetRefSnapshot || null,
@@ -1861,6 +2157,13 @@ export async function applyToParagraphByExactText(documentXml, targetText, modif
1861
2157
  for (const entry of insertionEntries) {
1862
2158
  onInfo(`[List] Explicit-range insertion: ilvl=${entry.ilvl}, markerType=${entry.markerType}, text="${String(entry.text || '').slice(0, 80)}${String(entry.text || '').length > 80 ? '…' : ''}"`);
1863
2159
  }
2160
+ const rangeFormatDiagnostic = diagnoseListFormatNotApplied({
2161
+ sourceParagraphs: explicitRangeParagraphs,
2162
+ modifiedText: effectiveModifiedText,
2163
+ runtimeContext,
2164
+ options
2165
+ });
2166
+ if (rangeFormatDiagnostic?.severity === 'error') return listDiagnosticFailure(documentXml, rangeFormatDiagnostic);
1864
2167
  const applied = applyExplicitRangeListInsertions({
1865
2168
  xmlDoc,
1866
2169
  explicitRangeParagraphs,
@@ -1872,7 +2175,8 @@ export async function applyToParagraphByExactText(documentXml, targetText, modif
1872
2175
  return {
1873
2176
  documentXml: completedDocumentXml(xmlDoc, serializer, documentXml, operationSession),
1874
2177
  hasChanges: true,
1875
- numberingXml: null
2178
+ numberingXml: null,
2179
+ ...listDiagnosticFields(rangeFormatDiagnostic ? [rangeFormatDiagnostic] : [])
1876
2180
  };
1877
2181
  }
1878
2182
  }
@@ -1945,6 +2249,105 @@ export async function applyToParagraphByExactText(documentXml, targetText, modif
1945
2249
  };
1946
2250
  }
1947
2251
 
2252
+ // Retained paragraph followed by a Markdown table: keep the source paragraph in place (so its own
2253
+ // content, formatting and revisions are untouched), apply any requested inline formatting to it,
2254
+ // and insert the table after it instead of rewriting the paragraph as a deletion plus a copy.
2255
+ const tableAppendCandidate = (
2256
+ !useTableScope
2257
+ && !hasExplicitRangeScope
2258
+ && !targetListInfo
2259
+ && !containingTable
2260
+ && !targetEndRef
2261
+ )
2262
+ ? deriveParagraphThenTableAppend(effectiveModifiedText)
2263
+ : null;
2264
+ const tableAppendParagraph = tableAppendCandidate
2265
+ ? preprocessMarkdown(tableAppendCandidate.paragraphMarkdown)
2266
+ : null;
2267
+ if (
2268
+ tableAppendCandidate
2269
+ && normalizeWhitespaceForTargeting(tableAppendParagraph.cleanText) === normalizeWhitespaceForTargeting(currentParagraphText)
2270
+ ) {
2271
+ const tableData = parseMarkdownTable(tableAppendCandidate.tableText);
2272
+ if (tableData.headers.length > 0 || tableData.rows.length > 0) {
2273
+ onInfo('[Table] Applying retained-paragraph table append heuristic.');
2274
+ const parent = targetParagraph.parentNode;
2275
+ if (!parent) throw new Error('Target paragraph has no parent for table append');
2276
+ const followingNode = targetParagraph.nextSibling;
2277
+ if (tableAppendParagraph.formatHints.length > 0) {
2278
+ const formatted = await applyRedlineToOxml(
2279
+ serializer.serializeToString(targetParagraph),
2280
+ currentParagraphText || targetText,
2281
+ tableAppendCandidate.paragraphMarkdown,
2282
+ {
2283
+ author,
2284
+ generateRedlines,
2285
+ existingRevisions: options.existingRevisions || 'merge-same-author',
2286
+ structuredContent: false,
2287
+ _revisionIdAllocator: revisionIdAllocator
2288
+ }
2289
+ );
2290
+ if (formatted?.status === 'error') {
2291
+ return {
2292
+ documentXml,
2293
+ hasChanges: false,
2294
+ numberingXml: null,
2295
+ status: 'error',
2296
+ error: formatted.error
2297
+ };
2298
+ }
2299
+ if (formatted?.hasChanges) {
2300
+ if (typeof formatted.oxml !== 'string') {
2301
+ throw new Error('Reconciliation engine did not return OOXML for a changed paragraph format');
2302
+ }
2303
+ for (const node of extractReplacementNodes(formatted.oxml).replacementNodes) {
2304
+ const imported = xmlDoc.importNode(node, true);
2305
+ ensureParagraphIdsOnImportedNode(imported, operationSession);
2306
+ options?._mutationLiveNodes?.push(parent.insertBefore(imported, targetParagraph));
2307
+ }
2308
+ options?._mutationRemovedNodes?.push(targetParagraph);
2309
+ parent.removeChild(targetParagraph);
2310
+ }
2311
+ }
2312
+ const tableOoxml = generateTableOoxml(tableData, {
2313
+ generateRedlines,
2314
+ author,
2315
+ revisionIdAllocator,
2316
+ trackAsBlock: true
2317
+ });
2318
+ for (const node of extractReplacementNodes(tableOoxml).replacementNodes) {
2319
+ options?._mutationLiveNodes?.push(parent.insertBefore(xmlDoc.importNode(node, true), followingNode));
2320
+ }
2321
+ // Word cannot end the body (or a container) with a table: it would add an untracked paragraph
2322
+ // there that Reject All leaves behind. Supply the paragraph ourselves with a tracked-inserted
2323
+ // mark so Reject All removes it and the source paragraph count is restored.
2324
+ // Skip whitespace/comment nodes of pretty-printed documents when deciding what follows the table.
2325
+ let followingElement = followingNode;
2326
+ while (followingElement && followingElement.nodeType !== 1) followingElement = followingElement.nextSibling;
2327
+ const endsContainer = !followingElement
2328
+ || (followingElement.namespaceURI === NS_W && followingElement.localName === 'sectPr');
2329
+ if (endsContainer) {
2330
+ const trailing = createWordElement(xmlDoc, 'w:p');
2331
+ const trailingPPr = clonePropertiesWithoutRevisionHistory(getDirectWordChild(targetParagraph, 'pPr'));
2332
+ if (trailingPPr) {
2333
+ for (const child of Array.from(trailingPPr.childNodes)) {
2334
+ if (child.nodeType === 1 && ['sectPr', 'numPr'].includes(child.localName)) trailingPPr.removeChild(child);
2335
+ }
2336
+ trailing.appendChild(trailingPPr);
2337
+ }
2338
+ if (generateRedlines) markParagraphInserted(trailing, { author });
2339
+ ensureParagraphIdsOnImportedNode(trailing, operationSession);
2340
+ options?._mutationLiveNodes?.push(parent.insertBefore(trailing, followingNode));
2341
+ }
2342
+ normalizeBodySectionOrder(xmlDoc);
2343
+ return {
2344
+ documentXml: completedDocumentXml(xmlDoc, serializer, documentXml, operationSession),
2345
+ hasChanges: true,
2346
+ numberingXml: null
2347
+ };
2348
+ }
2349
+ }
2350
+
1948
2351
  const plainAdjacencyInsertionCandidate = (
1949
2352
  !bypassSingleParagraphHeuristics
1950
2353
  && !useTableScope
@@ -1998,6 +2401,13 @@ export async function applyToParagraphByExactText(documentXml, targetText, modif
1998
2401
  })
1999
2402
  : null;
2000
2403
  if (insertionOnlyPlan && insertionOnlyPlan.entries.length > 0) {
2404
+ const insertionFormatDiagnostic = diagnoseListFormatNotApplied({
2405
+ sourceParagraphs: [targetParagraph],
2406
+ modifiedText: effectiveModifiedText,
2407
+ runtimeContext,
2408
+ options
2409
+ });
2410
+ if (insertionFormatDiagnostic?.severity === 'error') return listDiagnosticFailure(documentXml, insertionFormatDiagnostic);
2001
2411
  onInfo(`[List] Applying insertion-only list redline heuristic (${insertionOnlyPlan.entries.length} new item(s)).`);
2002
2412
  for (const entry of insertionOnlyPlan.entries) {
2003
2413
  onInfo(`[List] Insertion entry resolved: ilvl=${entry.ilvl}, markerType=${entry.markerType}, text="${String(entry.text || '').slice(0, 80)}${String(entry.text || '').length > 80 ? '…' : ''}"`);
@@ -2022,7 +2432,8 @@ export async function applyToParagraphByExactText(documentXml, targetText, modif
2022
2432
  return {
2023
2433
  documentXml: completedDocumentXml(xmlDoc, serializer, documentXml, operationSession),
2024
2434
  hasChanges: true,
2025
- numberingXml: null
2435
+ numberingXml: null,
2436
+ ...listDiagnosticFields(insertionFormatDiagnostic ? [insertionFormatDiagnostic] : [])
2026
2437
  };
2027
2438
  }
2028
2439
 
@@ -2038,6 +2449,7 @@ export async function applyToParagraphByExactText(documentXml, targetText, modif
2038
2449
  effectiveModifiedText = listScopeEdit.modifiedText;
2039
2450
  }
2040
2451
 
2452
+ let unboundMarkerDiagnostic = null;
2041
2453
  if (!bypassSingleParagraphHeuristics && !useTableScope && !useListScope && !hasExplicitRangeScope) {
2042
2454
  const explicitHeaderListConversion = await tryExplicitDecimalHeaderListConversion({
2043
2455
  xmlDoc,
@@ -2056,6 +2468,23 @@ export async function applyToParagraphByExactText(documentXml, targetText, modif
2056
2468
  });
2057
2469
  if (explicitHeaderListConversion) return explicitHeaderListConversion;
2058
2470
 
2471
+ const inPlaceConversion = tryInPlaceMarkerListConversion({
2472
+ xmlDoc,
2473
+ serializer,
2474
+ documentXml,
2475
+ operationSession,
2476
+ targetParagraph,
2477
+ currentParagraphText,
2478
+ modifiedText: effectiveModifiedText,
2479
+ author,
2480
+ runtimeContext,
2481
+ revisionIdAllocator,
2482
+ generateRedlines,
2483
+ onInfo,
2484
+ options
2485
+ });
2486
+ if (inPlaceConversion) return inPlaceConversion;
2487
+
2059
2488
  const listFallback = await trySingleParagraphListStructuralFallback({
2060
2489
  xmlDoc,
2061
2490
  serializer,
@@ -2072,6 +2501,25 @@ export async function applyToParagraphByExactText(documentXml, targetText, modif
2072
2501
  options
2073
2502
  });
2074
2503
  if (listFallback) return listFallback;
2504
+
2505
+ // Both structural fallbacks declined. A plain paragraph whose new text starts a list marker
2506
+ // would be written as literal text: report that instead of returning a silent success.
2507
+ const unboundPolicy = resolveListDiagnosticPolicy(options);
2508
+ if (unboundPolicy !== 'off' && !targetListInfo) {
2509
+ const requestedMarker = detectUnboundListMarker({
2510
+ originalText: currentParagraphText,
2511
+ modifiedText: effectiveModifiedText,
2512
+ targetIsListItem: false
2513
+ });
2514
+ if (requestedMarker) {
2515
+ unboundMarkerDiagnostic = buildUnboundListMarkerDiagnostic(requestedMarker, {
2516
+ policy: unboundPolicy,
2517
+ targetText: currentParagraphText,
2518
+ operationIndex: options?._operationIndex
2519
+ });
2520
+ if (unboundPolicy === 'error') return listDiagnosticFailure(documentXml, unboundMarkerDiagnostic);
2521
+ }
2522
+ }
2075
2523
  }
2076
2524
 
2077
2525
  const originalTextForApply = useListScope
@@ -2115,6 +2563,7 @@ export async function applyToParagraphByExactText(documentXml, targetText, modif
2115
2563
  explicitStructuredContent: options.explicitStructuredContent === true,
2116
2564
  pairReplacements: options.pairReplacements === true,
2117
2565
  insertionAffinity: options.insertionAffinity || null,
2566
+ literalTextEdit: options.literalTextEdit === true,
2118
2567
  _revisionIdAllocator: revisionIdAllocator,
2119
2568
  _isolatedTableCell: useTableScope
2120
2569
  });
@@ -2138,18 +2587,16 @@ export async function applyToParagraphByExactText(documentXml, targetText, modif
2138
2587
  const extracted = extractReplacementNodes(result.oxml);
2139
2588
  let replacementNodes = removeListPackagingSentinel(extracted.replacementNodes, result.warnings);
2140
2589
  let numberingXml = extracted.numberingXml;
2141
- if (numberingXml && runtimeContext?.numberingIdState && !useTableScope) {
2142
- const sourceParagraphs = useListScope ? listScopeEdit.paragraphs : (explicitRangeParagraphs || [targetParagraph]);
2143
- const continued = reuseSourceListNumbering(numberingXml, replacementNodes, sourceParagraphs, runtimeContext.numberingIdState);
2144
- if (continued) {
2145
- replacementNodes = continued.replacementNodes;
2146
- numberingXml = null;
2147
- }
2148
- }
2149
2590
  if (numberingXml && runtimeContext?.numberingIdState) {
2150
- const normalizedNumbering = remapNumberingPayloadForDocument(numberingXml, replacementNodes, runtimeContext.numberingIdState);
2151
- replacementNodes = normalizedNumbering.replacementNodes;
2152
- numberingXml = normalizedNumbering.numberingXml;
2591
+ const allocated = allocateNumberingForReplacement({
2592
+ numberingXml,
2593
+ replacementNodes,
2594
+ sourceParagraphs: useListScope ? listScopeEdit.paragraphs : (explicitRangeParagraphs || [targetParagraph]),
2595
+ numberingIdState: runtimeContext.numberingIdState,
2596
+ allowContinueSource: !useTableScope
2597
+ });
2598
+ replacementNodes = allocated.replacementNodes;
2599
+ numberingXml = allocated.numberingXml;
2153
2600
  }
2154
2601
  const scopeNodes = useTableScope
2155
2602
  ? [containingTable]
@@ -2191,7 +2638,10 @@ export async function applyToParagraphByExactText(documentXml, targetText, modif
2191
2638
  hasChanges: true,
2192
2639
  numberingXml,
2193
2640
  status: 'ok',
2194
- ...(Array.isArray(result.warnings) && result.warnings.length > 0 ? { warnings: result.warnings } : {})
2641
+ ...(unboundMarkerDiagnostic ? {
2642
+ diagnostics: [unboundMarkerDiagnostic],
2643
+ warnings: [...(Array.isArray(result.warnings) ? result.warnings : []), diagnosticWarningText(unboundMarkerDiagnostic)]
2644
+ } : (Array.isArray(result.warnings) && result.warnings.length > 0 ? { warnings: result.warnings } : {}))
2195
2645
  };
2196
2646
  }
2197
2647
 
@@ -2429,7 +2879,12 @@ export async function applyFormattingToParagraphByExactText(
2429
2879
  }
2430
2880
 
2431
2881
  const targetDescriptor = options.targetDescriptor;
2432
- const occurrence = targetDescriptor?.occurrence ?? null;
2882
+ // `textOccurrence` is the dedicated in-paragraph selector. Legacy calls without it keep
2883
+ // reading `target.occurrence` (which also selects the N-th matching paragraph).
2884
+ const explicitTextOccurrence = Number.isInteger(options.textOccurrence) && options.textOccurrence > 0
2885
+ ? options.textOccurrence
2886
+ : null;
2887
+ const occurrence = explicitTextOccurrence ?? targetDescriptor?.occurrence ?? null;
2433
2888
  let matchStart = -1;
2434
2889
  if (occurrence != null && occurrence > 0) {
2435
2890
  let count = 0;
@@ -2448,6 +2903,18 @@ export async function applyFormattingToParagraphByExactText(
2448
2903
  matchStart = fullParaText.indexOf(textToFormat);
2449
2904
  }
2450
2905
 
2906
+ if (matchStart === -1 && explicitTextOccurrence != null && fullParaText.includes(textToFormat)) {
2907
+ return {
2908
+ documentXml,
2909
+ hasChanges: false,
2910
+ status: 'error',
2911
+ error: {
2912
+ code: 'PATCH_SOURCE_NOT_FOUND',
2913
+ message: `Occurrence ${explicitTextOccurrence} of text to format was not found in target paragraph: "${textToFormat}".`
2914
+ }
2915
+ };
2916
+ }
2917
+
2451
2918
  if (matchStart === -1) {
2452
2919
  return {
2453
2920
  documentXml,
@@ -2581,13 +3048,63 @@ export async function applyParagraphFormatToParagraphByExactText(
2581
3048
  targetParagraph.insertBefore(pPr, targetParagraph.firstChild);
2582
3049
  }
2583
3050
 
3051
+ const unknownPropertyKeys = findUnknownParagraphFormatKeys(properties);
3052
+ const propertyDiagnostics = unknownPropertyKeys.length > 0
3053
+ ? [buildUnknownParagraphFormatDiagnostic(unknownPropertyKeys)]
3054
+ : [];
3055
+ if (properties?.numbering) {
3056
+ const binding = checkNumberingBinding(
3057
+ runtimeContext?.numberingIdState,
3058
+ properties.numbering.numId,
3059
+ Number(properties.numbering.level) || 0
3060
+ );
3061
+ if (!binding.ok) {
3062
+ return {
3063
+ documentXml,
3064
+ hasChanges: false,
3065
+ status: 'error',
3066
+ error: {
3067
+ code: PARAGRAPH_NUMBERING_NOT_FOUND,
3068
+ message: `${binding.message} paragraph-format.numbering binds to an existing list; it never creates one.`,
3069
+ field: 'properties.numbering',
3070
+ numId: Number(properties.numbering.numId),
3071
+ level: Number(properties.numbering.level) || 0,
3072
+ reason: binding.reason,
3073
+ recovery: {
3074
+ action: 'inspect_numbering',
3075
+ hint: 'Inspect a paragraph already in the target list and pass its list.numId and list.level. To start a new list, use a list-change/redline operation.'
3076
+ }
3077
+ }
3078
+ };
3079
+ }
3080
+ }
2584
3081
  const hasChanges = checkParagraphPropertiesChanged(pPr, properties);
2585
3082
  if (!hasChanges) {
2586
- return { documentXml, hasChanges: false, status: 'no-op' };
3083
+ return { documentXml, hasChanges: false, status: 'no-op', ...listDiagnosticFields(propertyDiagnostics) };
2587
3084
  }
2588
3085
 
2589
3086
  if (generateRedlines) {
2590
- snapshotAndAttachPPrChange(xmlDoc, pPr, author || getDefaultAuthor());
3087
+ const authorUsed = author || getDefaultAuthor();
3088
+ const pendingChange = getCurrentChild(pPr, 'pPrChange');
3089
+ const pendingAuthor = pendingChange?.getAttribute('w:author') ?? pendingChange?.getAttribute('author') ?? '';
3090
+ if (pendingChange && pendingAuthor !== authorUsed) {
3091
+ return {
3092
+ documentXml,
3093
+ hasChanges: false,
3094
+ status: 'error',
3095
+ error: {
3096
+ code: PARAGRAPH_FORMAT_REVISION_CONFLICT,
3097
+ message: `Target paragraph already has a pending paragraph-property change by "${pendingAuthor || 'unattributed'}". Overwriting its snapshot would change what Reject All restores.`,
3098
+ pendingAuthor,
3099
+ recovery: {
3100
+ action: 'resolve_pending_revision',
3101
+ hint: 'Ask the user to accept or reject the existing paragraph formatting change first; this tool does not resolve revisions by other reviewers.'
3102
+ }
3103
+ }
3104
+ };
3105
+ }
3106
+ // A same-author pending snapshot already records the earliest "before" state; keep it.
3107
+ if (!pendingChange) snapshotAndAttachPPrChange(xmlDoc, pPr, authorUsed);
2591
3108
  }
2592
3109
 
2593
3110
  applyParagraphPropertiesToPPr(xmlDoc, pPr, properties);
@@ -2598,6 +3115,232 @@ export async function applyParagraphFormatToParagraphByExactText(
2598
3115
  return {
2599
3116
  documentXml: completedDocumentXml(xmlDoc, serializer, documentXml, operationSession),
2600
3117
  hasChanges: true,
2601
- status: 'ok'
3118
+ status: 'ok',
3119
+ ...listDiagnosticFields(propertyDiagnostics)
3120
+ };
3121
+ }
3122
+
3123
+ /**
3124
+ * Resolves an operation's target range (target plus optional targetEnd) to paragraphs in document order, the
3125
+ * same way restore does: batch-compiled source ids first, then legacy refs, then an end descriptor.
3126
+ */
3127
+ function resolveOperationParagraphRange(xmlDoc, targetText, targetRef, targetEndRef, opType, runtimeContext, options) {
3128
+ const resolved = resolveTargetParagraph(xmlDoc, targetText, targetRef, opType, runtimeContext, options);
3129
+ if (resolved?.error || !resolved?.paragraph) {
3130
+ return { error: resolved?.error || { code: 'TARGET_NOT_FOUND', message: 'Target paragraph not found.' } };
3131
+ }
3132
+ const first = resolved.paragraph;
3133
+ const hasEnd = options._compiledSourceEndId || targetEndRef != null || options.targetEndDescriptor;
3134
+ if (!hasEnd) return { paragraphs: [first] };
3135
+ let paragraphs = null;
3136
+ if (options._compiledSourceEndId || options.targetEndDescriptor) {
3137
+ const endResolved = resolveTargetParagraph(
3138
+ xmlDoc,
3139
+ options.targetEndDescriptor?.text || '',
3140
+ options._compiledSourceEndId ? targetEndRef : (options.targetEndDescriptor?.index ?? targetEndRef),
3141
+ opType,
3142
+ runtimeContext,
3143
+ {
3144
+ ...options,
3145
+ ...(options.targetEndDescriptor ? { targetDescriptor: options.targetEndDescriptor } : {}),
3146
+ _compiledSourceId: options._compiledSourceEndId || null,
3147
+ _resolutionCapture: null
3148
+ }
3149
+ );
3150
+ if (endResolved?.error) return { error: endResolved.error };
3151
+ const all = endResolved?.paragraph ? getDocumentParagraphNodes(xmlDoc) : [];
3152
+ const startIndex = all.indexOf(first);
3153
+ const endIndex = all.indexOf(endResolved?.paragraph);
3154
+ paragraphs = startIndex >= 0 && endIndex >= startIndex ? all.slice(startIndex, endIndex + 1) : null;
3155
+ } else {
3156
+ paragraphs = resolveParagraphRangeByRefs(xmlDoc, targetRef, targetEndRef, {
3157
+ opType,
3158
+ targetRefSnapshot: runtimeContext?.targetRefSnapshot || null,
3159
+ onInfo: options.onInfo,
3160
+ onWarn: options.onWarn
3161
+ });
3162
+ }
3163
+ if (!Array.isArray(paragraphs) || paragraphs.length === 0 || paragraphs[0] !== first) {
3164
+ return { error: { code: 'TARGET_RANGE_INVALID', message: 'targetEnd must resolve to the target paragraph or a paragraph after it.' } };
3165
+ }
3166
+ return { paragraphs };
3167
+ }
3168
+
3169
+ /**
3170
+ * `list-format` (`convert-plain`): binds plain paragraphs to a new list in place; see services/list-format-mutation.js.
3171
+ */
3172
+ export async function applyListFormatToParagraphByExactText(
3173
+ documentXml,
3174
+ targetText,
3175
+ list,
3176
+ author,
3177
+ targetRef = null,
3178
+ targetEndRef = null,
3179
+ runtimeContext = null,
3180
+ options = {}
3181
+ ) {
3182
+ const generateRedlines = options.generateRedlines !== false;
3183
+ const onInfo = typeof options?.onInfo === 'function' ? options.onInfo : () => { };
3184
+ const onWarn = typeof options?.onWarn === 'function' ? options.onWarn : () => { };
3185
+ const { serializer, xmlDoc, operationSession } = resolveMutationDocument(documentXml, options);
3186
+ if (!xmlDoc) {
3187
+ return { documentXml, hasChanges: false, status: 'error', error: { code: 'PARSE_ERROR', message: 'Could not parse document OOXML.' } };
3188
+ }
3189
+ const revisionIdAllocator = operationSession?.revisionIdAllocator || prepareRevisionAllocator(xmlDoc, options);
3190
+ const range = resolveOperationParagraphRange(xmlDoc, targetText, targetRef, targetEndRef, 'list-format', runtimeContext, {
3191
+ ...options,
3192
+ onInfo,
3193
+ onWarn
3194
+ });
3195
+ if (range.error) return { documentXml, hasChanges: false, status: 'error', error: range.error };
3196
+ const paragraphs = range.paragraphs;
3197
+ if (paragraphs.some(paragraph => paragraph.parentNode !== paragraphs[0].parentNode)) {
3198
+ return {
3199
+ documentXml,
3200
+ hasChanges: false,
3201
+ status: 'error',
3202
+ error: {
3203
+ code: 'TARGET_RANGE_INVALID',
3204
+ message: 'A list-format range must stay inside one container (the body, or one table cell).'
3205
+ }
3206
+ };
3207
+ }
3208
+ const allParagraphs = getDocumentParagraphNodes(xmlDoc);
3209
+ const request = normalizeListFormatRequest(list);
3210
+ if (request.mode === 'change-format' || request.mode === 'join') {
3211
+ return applyListRebinding(xmlDoc, paragraphs, request, {
3212
+ documentXml, serializer, operationSession, runtimeContext, options, author, generateRedlines, onInfo, onWarn,
3213
+ paragraphIndexOf: paragraph => {
3214
+ const index = allParagraphs.indexOf(paragraph);
3215
+ return index >= 0 ? index + 1 : null;
3216
+ }
3217
+ });
3218
+ }
3219
+ const conversion = convertPlainParagraphsToList(xmlDoc, paragraphs, request, {
3220
+ author: author || getDefaultAuthor(),
3221
+ generateRedlines,
3222
+ revisionIdAllocator,
3223
+ runtimeContext,
3224
+ stylesXml: runtimeContext?.stylesXml || options.stylesXml || null,
3225
+ numberingXml: runtimeContext?.numberingXml || options.numberingXml || null,
3226
+ paragraphIndexOf: paragraph => allParagraphs.indexOf(paragraph) + 1
3227
+ });
3228
+ if (!conversion.ok) return { documentXml, hasChanges: false, status: 'error', error: conversion.error };
3229
+
3230
+ for (const item of conversion.items) {
3231
+ ensureParagraphIdsOnImportedNode(item.paragraph, operationSession);
3232
+ options?._mutationLiveNodes?.push(item.paragraph);
3233
+ }
3234
+ for (const numId of conversion.allocatedNumIds) operationSession?.receiptCollector?.recordNumbering(numId);
3235
+ normalizeBodySectionOrder(xmlDoc);
3236
+ onInfo(`[List] list-format convert-plain bound ${conversion.items.length} paragraph(s) as ${conversion.format} (numId ${[...new Set(conversion.items.map(item => item.numId))].join(', ')}).`);
3237
+
3238
+ return {
3239
+ documentXml: completedDocumentXml(xmlDoc, serializer, documentXml, operationSession),
3240
+ hasChanges: true,
3241
+ status: 'ok',
3242
+ numberingXml: conversion.numberingXml,
3243
+ list: {
3244
+ mode: 'convert-plain',
3245
+ format: conversion.format,
3246
+ level: conversion.level,
3247
+ strip: conversion.strip,
3248
+ items: conversion.items.map(item => ({
3249
+ paragraphIndex: item.paragraphIndex,
3250
+ paragraphId: getParagraphId(item.paragraph),
3251
+ marker: item.marker,
3252
+ value: item.value,
3253
+ numId: item.numId
3254
+ })),
3255
+ skipped: conversion.skipped.map(item => ({ paragraphIndex: item.paragraphIndex, reason: item.reason }))
3256
+ }
3257
+ };
3258
+ }
3259
+
3260
+ /**
3261
+ * `list-format` `change-format` / `join`: rebinds existing list items (see services/list-format-change.js). The
3262
+ * definitions are read from the source numbering part plus the parts earlier operations of this batch allocated,
3263
+ * so a later operation can change a list an earlier one created or rebound.
3264
+ */
3265
+ function applyListRebinding(xmlDoc, paragraphs, request, ctx) {
3266
+ const { documentXml, serializer, operationSession, runtimeContext, options, author, generateRedlines, onInfo, paragraphIndexOf } = ctx;
3267
+ const refused = error => ({ documentXml, hasChanges: false, status: 'error', error });
3268
+ const sourceNumberingXml = runtimeContext?.numberingIdState?.sourceNumberingXml
3269
+ || runtimeContext?.numberingXml
3270
+ || options.numberingXml
3271
+ || '';
3272
+ const pendingParts = Array.isArray(operationSession?.numberingXmlParts) ? operationSession.numberingXmlParts : [];
3273
+ const numberingXml = pendingParts.length > 0 ? mergeNumberingParts(sourceNumberingXml, pendingParts) : sourceNumberingXml;
3274
+ const shared = {
3275
+ author: author || getDefaultAuthor(),
3276
+ generateRedlines,
3277
+ runtimeContext,
3278
+ stylesXml: runtimeContext?.stylesXml || options.stylesXml || null,
3279
+ numberingXml,
3280
+ paragraphIndexOf
3281
+ };
3282
+
3283
+ let outcome;
3284
+ if (request.mode === 'join') {
3285
+ const descriptor = options.continueFromDescriptor || null;
3286
+ const resolved = descriptor
3287
+ ? resolveTargetParagraph(xmlDoc, descriptor.text || '', descriptor.index ?? null, 'list-format', runtimeContext, {
3288
+ ...options,
3289
+ targetDescriptor: descriptor,
3290
+ _compiledSourceId: options._compiledContinueFromId || null,
3291
+ _compiledResolvedBy: null,
3292
+ _resolutionCapture: null
3293
+ })
3294
+ : null;
3295
+ if (!resolved?.paragraph) {
3296
+ return refused({
3297
+ code: resolved?.error?.code || 'TARGET_NOT_FOUND',
3298
+ message: `list.continueFrom: ${resolved?.error?.message || 'paragraph not found.'}`,
3299
+ field: 'list.continueFrom'
3300
+ });
3301
+ }
3302
+ outcome = joinListParagraphs(xmlDoc, paragraphs, resolved.paragraph, request, shared);
3303
+ } else {
3304
+ outcome = changeListParagraphsFormat(xmlDoc, paragraphs, request, shared);
3305
+ }
3306
+ if (!outcome.ok) return refused(outcome.error);
3307
+ if (outcome.unchanged) {
3308
+ const warning = `${LIST_FORMAT_UNCHANGED}: ${outcome.message}`;
3309
+ ctx.onWarn?.(warning);
3310
+ return { documentXml, hasChanges: false, status: 'ok', warnings: [warning] };
3311
+ }
3312
+
3313
+ for (const item of outcome.items) {
3314
+ ensureParagraphIdsOnImportedNode(item.paragraph, operationSession);
3315
+ options?._mutationLiveNodes?.push(item.paragraph);
3316
+ }
3317
+ for (const numId of outcome.allocatedNumIds) operationSession?.receiptCollector?.recordNumbering(numId);
3318
+ normalizeBodySectionOrder(xmlDoc);
3319
+ const outside = outcome.items.filter(item => !item.inTarget).length;
3320
+ onInfo(`[List] list-format ${outcome.mode} (${outcome.scope}) rebound ${outcome.items.length} paragraph(s) from numId ${outcome.sourceNumId} to numId ${outcome.numId}${outside ? ` (${outside} outside the target range)` : ''}.`);
3321
+
3322
+ return {
3323
+ documentXml: completedDocumentXml(xmlDoc, serializer, documentXml, operationSession),
3324
+ hasChanges: true,
3325
+ status: 'ok',
3326
+ numberingXml: outcome.numberingXml,
3327
+ list: {
3328
+ mode: outcome.mode,
3329
+ scope: outcome.scope,
3330
+ sourceNumId: outcome.sourceNumId,
3331
+ numId: outcome.numId,
3332
+ ...(outcome.mode === 'change-format'
3333
+ ? { format: outcome.format, level: outcome.level, lvlText: outcome.lvlText, levelStarts: outcome.levelStarts }
3334
+ : { continueFrom: outcome.continueFrom }),
3335
+ items: outcome.items.map(item => ({
3336
+ paragraphIndex: item.paragraphIndex,
3337
+ paragraphId: getParagraphId(item.paragraph),
3338
+ level: item.level,
3339
+ numId: item.numId,
3340
+ sourceNumId: item.sourceNumId,
3341
+ inTarget: item.inTarget
3342
+ })),
3343
+ skipped: outcome.skipped.map(item => ({ paragraphIndex: item.paragraphIndex, reason: item.reason }))
3344
+ }
2602
3345
  };
2603
3346
  }