@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
@@ -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
- import { containsTrackedChanges, getTrackedChangeAuthors, createWordElement, withOoxmlSourceType } from '../core/word-xml.js';
21
+ import { containsTrackedChanges, getTrackedChangeAuthors, hasUnattributedTrackedChange, createWordElement, resolveExistingRevisionsPolicy, 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,
@@ -71,11 +83,19 @@ import {
71
83
  import {
72
84
  reserveNextNumberingIdPair,
73
85
  remapNumberingPayloadForDocument,
74
- reuseSourceListNumbering,
75
86
  overwriteParagraphNumIds,
76
87
  extractFirstParagraphNumId,
77
88
  buildExplicitDecimalMultilevelNumberingXml
78
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';
79
99
  import {
80
100
  extractReplacementNodesFromOoxml,
81
101
  normalizeBodySectionOrderStandalone
@@ -324,6 +344,52 @@ function resolveTargetParagraph(xmlDoc, targetText, targetRef, opType, runtimeCo
324
344
  return resolved;
325
345
  }
326
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
+
327
393
  function extractReplacementNodes(outputOxml) {
328
394
  return extractReplacementNodesFromOoxml(outputOxml);
329
395
  }
@@ -430,7 +496,7 @@ function buildInsertedListParagraph(xmlDoc, anchorParagraph, entry, revisionMeta
430
496
  // remove the list paragraph itself on Reject All instead of leaving an
431
497
  // empty bullet or number behind.
432
498
  if (generateRedlines) {
433
- markParagraphMarkInserted(xmlDoc, paragraph, author);
499
+ markParagraphInserted(paragraph, { author });
434
500
  }
435
501
 
436
502
  const run = createWordElement(xmlDoc, 'w:r');
@@ -522,7 +588,7 @@ function buildFallbackInsertedPlainParagraph(xmlDoc, text, revisionMetadata, aut
522
588
 
523
589
  if (generateRedlines) {
524
590
  if (options.trackParagraphMark === true) {
525
- markParagraphMarkInserted(xmlDoc, paragraph, author);
591
+ markParagraphInserted(paragraph, { author });
526
592
  }
527
593
  const metadata = revisionMetadata || createRevisionMetadata(author, xmlDoc, 'ins');
528
594
  const ins = createWordElement(xmlDoc, 'w:ins');
@@ -563,7 +629,7 @@ function wrapParagraphContentInInsertion(xmlDoc, paragraph, revisionMetadata, au
563
629
  if (pPr) wrappedParagraph.appendChild(pPr);
564
630
  if (options.paragraphId) wrappedParagraph.setAttributeNS(NS_W14, 'w14:paraId', options.paragraphId);
565
631
  if (options.trackParagraphMark === true) {
566
- markParagraphMarkInserted(xmlDoc, wrappedParagraph, author);
632
+ markParagraphInserted(wrappedParagraph, { author });
567
633
  }
568
634
 
569
635
  const ins = createWordElement(xmlDoc, 'w:ins');
@@ -624,8 +690,9 @@ async function buildInsertedPlainParagraph(xmlDoc, anchorParagraph, text, revisi
624
690
  function collectNumberingIdsFromNodes(nodes) {
625
691
  const ids = new Set();
626
692
  for (const node of nodes || []) {
627
- const numIdNodes = Array.from(node?.getElementsByTagNameNS?.('*', 'numId') || []);
628
- 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)) {
629
696
  const val = numIdNode.getAttribute('w:val') || numIdNode.getAttribute('val');
630
697
  if (val != null && val !== '') {
631
698
  ids.add(String(val));
@@ -1383,20 +1450,6 @@ async function tryExplicitDecimalHeaderListConversion({
1383
1450
  if (!strippedContent) return null;
1384
1451
 
1385
1452
  onInfo('[List] Applying explicit numeric header conversion with direct list binding.');
1386
- const redlineResult = await applyRedlineToOxml(
1387
- serializer.serializeToString(targetParagraph),
1388
- currentParagraphText,
1389
- strippedContent,
1390
- {
1391
- author,
1392
- generateRedlines,
1393
- _revisionIdAllocator: revisionIdAllocator
1394
- }
1395
- );
1396
- if (!redlineResult?.hasChanges || typeof redlineResult?.oxml !== 'string') return null;
1397
-
1398
- const extracted = extractReplacementNodes(redlineResult.oxml);
1399
- const replacementNodes = extracted.replacementNodes;
1400
1453
  const numberingAction = resolveSingleLineListFallbackNumberingAction(
1401
1454
  explicitPlan,
1402
1455
  runtimeContext?.listFallbackSequenceState || null
@@ -1427,6 +1480,22 @@ async function tryExplicitDecimalHeaderListConversion({
1427
1480
  onInfo(`[List] Using isolated explicit-start numbering (start ${explicitStart}, numId ${appliedNumId}, abstractNumId ${reservedPair.abstractNumId}).`);
1428
1481
  }
1429
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
+
1430
1499
  if (explicitPlan.numberingKey && runtimeContext?.listFallbackSharedNumIdByKey instanceof Map) {
1431
1500
  runtimeContext.listFallbackSharedNumIdByKey.delete(explicitPlan.numberingKey);
1432
1501
  }
@@ -1449,7 +1518,8 @@ async function tryExplicitDecimalHeaderListConversion({
1449
1518
  numId: appliedNumId,
1450
1519
  ilvl: 0,
1451
1520
  clearParagraphPropertyChanges: true,
1452
- removeListPropertyNode: true
1521
+ removeListPropertyNode: true,
1522
+ trackPropertyChange: generateRedlines ? { author, revisionIdAllocator } : null
1453
1523
  });
1454
1524
 
1455
1525
  const parent = targetParagraph.parentNode;
@@ -1476,6 +1546,145 @@ async function tryExplicitDecimalHeaderListConversion({
1476
1546
  };
1477
1547
  }
1478
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
+
1479
1688
  async function trySingleParagraphListStructuralFallback({
1480
1689
  xmlDoc,
1481
1690
  serializer,
@@ -1517,7 +1726,11 @@ async function trySingleParagraphListStructuralFallback({
1517
1726
  const extracted = extractReplacementNodes(fallbackResult.oxml);
1518
1727
  let replacementNodes = extracted.replacementNodes;
1519
1728
  let numberingXml = extracted.numberingXml || fallbackResult?.numberingXml || null;
1520
- 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;
1521
1734
  const numberingKey = fallbackResult?.listStructuralFallbackKey || fallbackPlan?.numberingKey || null;
1522
1735
  const numberingAction = resolveSingleLineListFallbackNumberingAction(
1523
1736
  fallbackPlan,
@@ -1573,7 +1786,8 @@ async function trySingleParagraphListStructuralFallback({
1573
1786
  numId: explicitNumIdForBinding,
1574
1787
  ilvl: 0,
1575
1788
  clearParagraphPropertyChanges: true,
1576
- removeListPropertyNode: true
1789
+ removeListPropertyNode: true,
1790
+ trackPropertyChange: generateRedlines ? { author, revisionIdAllocator } : null
1577
1791
  });
1578
1792
  }
1579
1793
  } else {
@@ -1588,7 +1802,23 @@ async function trySingleParagraphListStructuralFallback({
1588
1802
  );
1589
1803
  }
1590
1804
 
1591
- 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) {
1592
1822
  const sharedNumId = numberingKey ? runtimeContext.listFallbackSharedNumIdByKey.get(numberingKey) : null;
1593
1823
  if (sharedNumId) {
1594
1824
  overwriteParagraphNumIds(replacementNodes, sharedNumId);
@@ -1605,6 +1835,7 @@ async function trySingleParagraphListStructuralFallback({
1605
1835
 
1606
1836
  const parent = targetParagraph.parentNode;
1607
1837
  if (!parent) return null;
1838
+ if (generateRedlines) restoreSourceIdentityAndPropertiesOnFallback(xmlDoc, replacementNodes, targetParagraph);
1608
1839
  for (const node of replacementNodes) {
1609
1840
  const imported = xmlDoc.importNode(node, true);
1610
1841
  ensureParagraphIdsOnImportedNode(imported, operationSession);
@@ -1627,6 +1858,43 @@ async function trySingleParagraphListStructuralFallback({
1627
1858
  };
1628
1859
  }
1629
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
+
1630
1898
  export async function applyToParagraphByExactText(documentXml, targetText, modifiedText, author, targetRef = null, targetEndRef = null, runtimeContext = null, options = {}) {
1631
1899
  const generateRedlines = options.generateRedlines !== false;
1632
1900
  const onInfo = typeof options?.onInfo === 'function' ? options.onInfo : () => { };
@@ -1699,6 +1967,12 @@ export async function applyToParagraphByExactText(documentXml, targetText, modif
1699
1967
  }
1700
1968
  };
1701
1969
  }
1970
+ // 'auto' resolves against the target paragraph here and again against the whole
1971
+ // range once a targetEnd range is resolved below.
1972
+ const autoExistingRevisions = options.existingRevisions === 'auto';
1973
+ if (autoExistingRevisions) {
1974
+ options = { ...options, existingRevisions: resolveExistingRevisionsPolicy('auto', targetParagraph, author) };
1975
+ }
1702
1976
  const hasRevisions = containsTrackedChanges(targetParagraph);
1703
1977
  const existingPolicy = options.existingRevisions || 'merge-same-author';
1704
1978
  if (
@@ -1709,7 +1983,9 @@ export async function applyToParagraphByExactText(documentXml, targetText, modif
1709
1983
  if (existingPolicy === 'merge-same-author' || existingPolicy === 'slice-cross-author') {
1710
1984
  const authors = getTrackedChangeAuthors(targetParagraph);
1711
1985
  const opAuthor = String(author || '').trim().toLowerCase();
1712
- const allSame = authors.length > 0 && authors.every(a => a.trim().toLowerCase() === opAuthor);
1986
+ const allSame = authors.length > 0
1987
+ && !hasUnattributedTrackedChange(targetParagraph)
1988
+ && authors.every(a => a.trim().toLowerCase() === opAuthor);
1713
1989
  if (!allSame && existingPolicy === 'merge-same-author') {
1714
1990
  return {
1715
1991
  documentXml,
@@ -1718,7 +1994,7 @@ export async function applyToParagraphByExactText(documentXml, targetText, modif
1718
1994
  status: 'error',
1719
1995
  error: {
1720
1996
  code: 'EXISTING_REVISIONS',
1721
- 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.`,
1997
+ message: `Target paragraph contains tracked changes from another author (${[...authors, ...(hasUnattributedTrackedChange(targetParagraph) ? ['unattributed'] : [])].join(', ')}). Use existingRevisions: "slice-cross-author" for a surgical edit that preserves reviewer history; accepting or rejecting revisions requires separate authorization.`,
1722
1998
  revisionAuthors: authors,
1723
1999
  currentPolicy: existingPolicy
1724
2000
  }
@@ -1802,7 +2078,7 @@ export async function applyToParagraphByExactText(documentXml, targetText, modif
1802
2078
  && !(hasRevisions && existingPolicy === 'merge-same-author')
1803
2079
  ) {
1804
2080
  if (generateRedlines) {
1805
- markParagraphMarkDeleted(xmlDoc, targetParagraph, author, createRevisionMetadata(author, xmlDoc));
2081
+ markParagraphDeleted(targetParagraph, { author });
1806
2082
  options?._mutationLiveNodes?.push(targetParagraph);
1807
2083
  } else {
1808
2084
  options?._mutationRemovedNodes?.push(targetParagraph);
@@ -1847,7 +2123,31 @@ export async function applyToParagraphByExactText(documentXml, targetText, modif
1847
2123
  let effectiveModifiedText = synthesizedTableMarkdown || modifiedText;
1848
2124
  const useTableScope = !!containingTable && isMarkdownTableText(effectiveModifiedText);
1849
2125
  const isTableMarkdownEdit = isMarkdownTableText(effectiveModifiedText);
1850
- const explicitRangeParagraphs = targetEndRef
2126
+ // A targetEnd given as a compact handle (paragraphId + fingerprint, no numeric index) has no targetEndRef;
2127
+ // its range comes from the batch-compiled end or the end descriptor, never silently dropped.
2128
+ let compiledEndRange = null;
2129
+ if (!targetEndRef && (options._compiledSourceEndId || options.targetEndDescriptor)) {
2130
+ const rangeResolution = resolveOperationParagraphRange(xmlDoc, targetText, targetRef, targetEndRef, 'redline', runtimeContext, {
2131
+ ...options,
2132
+ _resolutionCapture: null
2133
+ });
2134
+ if (rangeResolution.error) {
2135
+ return { documentXml, hasChanges: false, status: 'error', error: rangeResolution.error };
2136
+ }
2137
+ if (rangeResolution.paragraphs.length > 1) {
2138
+ const container = rangeResolution.paragraphs[0].parentNode;
2139
+ if (rangeResolution.paragraphs.some(paragraph => paragraph.parentNode !== container)) {
2140
+ return {
2141
+ documentXml,
2142
+ hasChanges: false,
2143
+ status: 'error',
2144
+ 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.' }
2145
+ };
2146
+ }
2147
+ compiledEndRange = rangeResolution.paragraphs;
2148
+ }
2149
+ }
2150
+ const explicitRangeParagraphs = compiledEndRange ? compiledEndRange : targetEndRef
1851
2151
  ? resolveParagraphRangeByRefs(xmlDoc, targetRef, targetEndRef, {
1852
2152
  opType: 'redline',
1853
2153
  targetRefSnapshot: runtimeContext?.targetRefSnapshot || null,
@@ -1858,6 +2158,9 @@ export async function applyToParagraphByExactText(documentXml, targetText, modif
1858
2158
  ? findReconstructionParagraphRange(xmlDoc, targetText)
1859
2159
  : null);
1860
2160
  const hasExplicitRangeScope = Array.isArray(explicitRangeParagraphs) && explicitRangeParagraphs.length > 0;
2161
+ if (autoExistingRevisions && hasExplicitRangeScope) {
2162
+ options = { ...options, existingRevisions: resolveExistingRevisionsPolicy('auto', explicitRangeParagraphs, author) };
2163
+ }
1861
2164
  if (!useTableScope && hasExplicitRangeScope) {
1862
2165
  const insertionEntries = buildExplicitRangeInsertionEntries(explicitRangeParagraphs, effectiveModifiedText);
1863
2166
  if (insertionEntries && insertionEntries.length > 0) {
@@ -1865,6 +2168,13 @@ export async function applyToParagraphByExactText(documentXml, targetText, modif
1865
2168
  for (const entry of insertionEntries) {
1866
2169
  onInfo(`[List] Explicit-range insertion: ilvl=${entry.ilvl}, markerType=${entry.markerType}, text="${String(entry.text || '').slice(0, 80)}${String(entry.text || '').length > 80 ? '…' : ''}"`);
1867
2170
  }
2171
+ const rangeFormatDiagnostic = diagnoseListFormatNotApplied({
2172
+ sourceParagraphs: explicitRangeParagraphs,
2173
+ modifiedText: effectiveModifiedText,
2174
+ runtimeContext,
2175
+ options
2176
+ });
2177
+ if (rangeFormatDiagnostic?.severity === 'error') return listDiagnosticFailure(documentXml, rangeFormatDiagnostic);
1868
2178
  const applied = applyExplicitRangeListInsertions({
1869
2179
  xmlDoc,
1870
2180
  explicitRangeParagraphs,
@@ -1876,7 +2186,8 @@ export async function applyToParagraphByExactText(documentXml, targetText, modif
1876
2186
  return {
1877
2187
  documentXml: completedDocumentXml(xmlDoc, serializer, documentXml, operationSession),
1878
2188
  hasChanges: true,
1879
- numberingXml: null
2189
+ numberingXml: null,
2190
+ ...listDiagnosticFields(rangeFormatDiagnostic ? [rangeFormatDiagnostic] : [])
1880
2191
  };
1881
2192
  }
1882
2193
  }
@@ -2021,8 +2332,11 @@ export async function applyToParagraphByExactText(documentXml, targetText, modif
2021
2332
  // Word cannot end the body (or a container) with a table: it would add an untracked paragraph
2022
2333
  // there that Reject All leaves behind. Supply the paragraph ourselves with a tracked-inserted
2023
2334
  // mark so Reject All removes it and the source paragraph count is restored.
2024
- const endsContainer = !followingNode
2025
- || (followingNode.nodeType === 1 && followingNode.namespaceURI === NS_W && followingNode.localName === 'sectPr');
2335
+ // Skip whitespace/comment nodes of pretty-printed documents when deciding what follows the table.
2336
+ let followingElement = followingNode;
2337
+ while (followingElement && followingElement.nodeType !== 1) followingElement = followingElement.nextSibling;
2338
+ const endsContainer = !followingElement
2339
+ || (followingElement.namespaceURI === NS_W && followingElement.localName === 'sectPr');
2026
2340
  if (endsContainer) {
2027
2341
  const trailing = createWordElement(xmlDoc, 'w:p');
2028
2342
  const trailingPPr = clonePropertiesWithoutRevisionHistory(getDirectWordChild(targetParagraph, 'pPr'));
@@ -2032,7 +2346,7 @@ export async function applyToParagraphByExactText(documentXml, targetText, modif
2032
2346
  }
2033
2347
  trailing.appendChild(trailingPPr);
2034
2348
  }
2035
- if (generateRedlines) markParagraphMarkInserted(xmlDoc, trailing, author);
2349
+ if (generateRedlines) markParagraphInserted(trailing, { author });
2036
2350
  ensureParagraphIdsOnImportedNode(trailing, operationSession);
2037
2351
  options?._mutationLiveNodes?.push(parent.insertBefore(trailing, followingNode));
2038
2352
  }
@@ -2098,6 +2412,13 @@ export async function applyToParagraphByExactText(documentXml, targetText, modif
2098
2412
  })
2099
2413
  : null;
2100
2414
  if (insertionOnlyPlan && insertionOnlyPlan.entries.length > 0) {
2415
+ const insertionFormatDiagnostic = diagnoseListFormatNotApplied({
2416
+ sourceParagraphs: [targetParagraph],
2417
+ modifiedText: effectiveModifiedText,
2418
+ runtimeContext,
2419
+ options
2420
+ });
2421
+ if (insertionFormatDiagnostic?.severity === 'error') return listDiagnosticFailure(documentXml, insertionFormatDiagnostic);
2101
2422
  onInfo(`[List] Applying insertion-only list redline heuristic (${insertionOnlyPlan.entries.length} new item(s)).`);
2102
2423
  for (const entry of insertionOnlyPlan.entries) {
2103
2424
  onInfo(`[List] Insertion entry resolved: ilvl=${entry.ilvl}, markerType=${entry.markerType}, text="${String(entry.text || '').slice(0, 80)}${String(entry.text || '').length > 80 ? '…' : ''}"`);
@@ -2122,7 +2443,8 @@ export async function applyToParagraphByExactText(documentXml, targetText, modif
2122
2443
  return {
2123
2444
  documentXml: completedDocumentXml(xmlDoc, serializer, documentXml, operationSession),
2124
2445
  hasChanges: true,
2125
- numberingXml: null
2446
+ numberingXml: null,
2447
+ ...listDiagnosticFields(insertionFormatDiagnostic ? [insertionFormatDiagnostic] : [])
2126
2448
  };
2127
2449
  }
2128
2450
 
@@ -2138,6 +2460,7 @@ export async function applyToParagraphByExactText(documentXml, targetText, modif
2138
2460
  effectiveModifiedText = listScopeEdit.modifiedText;
2139
2461
  }
2140
2462
 
2463
+ let unboundMarkerDiagnostic = null;
2141
2464
  if (!bypassSingleParagraphHeuristics && !useTableScope && !useListScope && !hasExplicitRangeScope) {
2142
2465
  const explicitHeaderListConversion = await tryExplicitDecimalHeaderListConversion({
2143
2466
  xmlDoc,
@@ -2156,6 +2479,23 @@ export async function applyToParagraphByExactText(documentXml, targetText, modif
2156
2479
  });
2157
2480
  if (explicitHeaderListConversion) return explicitHeaderListConversion;
2158
2481
 
2482
+ const inPlaceConversion = tryInPlaceMarkerListConversion({
2483
+ xmlDoc,
2484
+ serializer,
2485
+ documentXml,
2486
+ operationSession,
2487
+ targetParagraph,
2488
+ currentParagraphText,
2489
+ modifiedText: effectiveModifiedText,
2490
+ author,
2491
+ runtimeContext,
2492
+ revisionIdAllocator,
2493
+ generateRedlines,
2494
+ onInfo,
2495
+ options
2496
+ });
2497
+ if (inPlaceConversion) return inPlaceConversion;
2498
+
2159
2499
  const listFallback = await trySingleParagraphListStructuralFallback({
2160
2500
  xmlDoc,
2161
2501
  serializer,
@@ -2172,6 +2512,25 @@ export async function applyToParagraphByExactText(documentXml, targetText, modif
2172
2512
  options
2173
2513
  });
2174
2514
  if (listFallback) return listFallback;
2515
+
2516
+ // Both structural fallbacks declined. A plain paragraph whose new text starts a list marker
2517
+ // would be written as literal text: report that instead of returning a silent success.
2518
+ const unboundPolicy = resolveListDiagnosticPolicy(options);
2519
+ if (unboundPolicy !== 'off' && !targetListInfo) {
2520
+ const requestedMarker = detectUnboundListMarker({
2521
+ originalText: currentParagraphText,
2522
+ modifiedText: effectiveModifiedText,
2523
+ targetIsListItem: false
2524
+ });
2525
+ if (requestedMarker) {
2526
+ unboundMarkerDiagnostic = buildUnboundListMarkerDiagnostic(requestedMarker, {
2527
+ policy: unboundPolicy,
2528
+ targetText: currentParagraphText,
2529
+ operationIndex: options?._operationIndex
2530
+ });
2531
+ if (unboundPolicy === 'error') return listDiagnosticFailure(documentXml, unboundMarkerDiagnostic);
2532
+ }
2533
+ }
2175
2534
  }
2176
2535
 
2177
2536
  const originalTextForApply = useListScope
@@ -2216,6 +2575,7 @@ export async function applyToParagraphByExactText(documentXml, targetText, modif
2216
2575
  pairReplacements: options.pairReplacements === true,
2217
2576
  insertionAffinity: options.insertionAffinity || null,
2218
2577
  literalTextEdit: options.literalTextEdit === true,
2578
+ plainTextContent: options.plainTextContent === true,
2219
2579
  _revisionIdAllocator: revisionIdAllocator,
2220
2580
  _isolatedTableCell: useTableScope
2221
2581
  });
@@ -2239,18 +2599,16 @@ export async function applyToParagraphByExactText(documentXml, targetText, modif
2239
2599
  const extracted = extractReplacementNodes(result.oxml);
2240
2600
  let replacementNodes = removeListPackagingSentinel(extracted.replacementNodes, result.warnings);
2241
2601
  let numberingXml = extracted.numberingXml;
2242
- if (numberingXml && runtimeContext?.numberingIdState && !useTableScope) {
2243
- const sourceParagraphs = useListScope ? listScopeEdit.paragraphs : (explicitRangeParagraphs || [targetParagraph]);
2244
- const continued = reuseSourceListNumbering(numberingXml, replacementNodes, sourceParagraphs, runtimeContext.numberingIdState);
2245
- if (continued) {
2246
- replacementNodes = continued.replacementNodes;
2247
- numberingXml = null;
2248
- }
2249
- }
2250
2602
  if (numberingXml && runtimeContext?.numberingIdState) {
2251
- const normalizedNumbering = remapNumberingPayloadForDocument(numberingXml, replacementNodes, runtimeContext.numberingIdState);
2252
- replacementNodes = normalizedNumbering.replacementNodes;
2253
- numberingXml = normalizedNumbering.numberingXml;
2603
+ const allocated = allocateNumberingForReplacement({
2604
+ numberingXml,
2605
+ replacementNodes,
2606
+ sourceParagraphs: useListScope ? listScopeEdit.paragraphs : (explicitRangeParagraphs || [targetParagraph]),
2607
+ numberingIdState: runtimeContext.numberingIdState,
2608
+ allowContinueSource: !useTableScope
2609
+ });
2610
+ replacementNodes = allocated.replacementNodes;
2611
+ numberingXml = allocated.numberingXml;
2254
2612
  }
2255
2613
  const scopeNodes = useTableScope
2256
2614
  ? [containingTable]
@@ -2292,7 +2650,10 @@ export async function applyToParagraphByExactText(documentXml, targetText, modif
2292
2650
  hasChanges: true,
2293
2651
  numberingXml,
2294
2652
  status: 'ok',
2295
- ...(Array.isArray(result.warnings) && result.warnings.length > 0 ? { warnings: result.warnings } : {})
2653
+ ...(unboundMarkerDiagnostic ? {
2654
+ diagnostics: [unboundMarkerDiagnostic],
2655
+ warnings: [...(Array.isArray(result.warnings) ? result.warnings : []), diagnosticWarningText(unboundMarkerDiagnostic)]
2656
+ } : (Array.isArray(result.warnings) && result.warnings.length > 0 ? { warnings: result.warnings } : {}))
2296
2657
  };
2297
2658
  }
2298
2659
 
@@ -2699,13 +3060,63 @@ export async function applyParagraphFormatToParagraphByExactText(
2699
3060
  targetParagraph.insertBefore(pPr, targetParagraph.firstChild);
2700
3061
  }
2701
3062
 
3063
+ const unknownPropertyKeys = findUnknownParagraphFormatKeys(properties);
3064
+ const propertyDiagnostics = unknownPropertyKeys.length > 0
3065
+ ? [buildUnknownParagraphFormatDiagnostic(unknownPropertyKeys)]
3066
+ : [];
3067
+ if (properties?.numbering) {
3068
+ const binding = checkNumberingBinding(
3069
+ runtimeContext?.numberingIdState,
3070
+ properties.numbering.numId,
3071
+ Number(properties.numbering.level) || 0
3072
+ );
3073
+ if (!binding.ok) {
3074
+ return {
3075
+ documentXml,
3076
+ hasChanges: false,
3077
+ status: 'error',
3078
+ error: {
3079
+ code: PARAGRAPH_NUMBERING_NOT_FOUND,
3080
+ message: `${binding.message} paragraph-format.numbering binds to an existing list; it never creates one.`,
3081
+ field: 'properties.numbering',
3082
+ numId: Number(properties.numbering.numId),
3083
+ level: Number(properties.numbering.level) || 0,
3084
+ reason: binding.reason,
3085
+ recovery: {
3086
+ action: 'inspect_numbering',
3087
+ 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.'
3088
+ }
3089
+ }
3090
+ };
3091
+ }
3092
+ }
2702
3093
  const hasChanges = checkParagraphPropertiesChanged(pPr, properties);
2703
3094
  if (!hasChanges) {
2704
- return { documentXml, hasChanges: false, status: 'no-op' };
3095
+ return { documentXml, hasChanges: false, status: 'no-op', ...listDiagnosticFields(propertyDiagnostics) };
2705
3096
  }
2706
3097
 
2707
3098
  if (generateRedlines) {
2708
- snapshotAndAttachPPrChange(xmlDoc, pPr, author || getDefaultAuthor());
3099
+ const authorUsed = author || getDefaultAuthor();
3100
+ const pendingChange = getCurrentChild(pPr, 'pPrChange');
3101
+ const pendingAuthor = pendingChange?.getAttribute('w:author') ?? pendingChange?.getAttribute('author') ?? '';
3102
+ if (pendingChange && pendingAuthor !== authorUsed) {
3103
+ return {
3104
+ documentXml,
3105
+ hasChanges: false,
3106
+ status: 'error',
3107
+ error: {
3108
+ code: PARAGRAPH_FORMAT_REVISION_CONFLICT,
3109
+ message: `Target paragraph already has a pending paragraph-property change by "${pendingAuthor || 'unattributed'}". Overwriting its snapshot would change what Reject All restores.`,
3110
+ pendingAuthor,
3111
+ recovery: {
3112
+ action: 'resolve_pending_revision',
3113
+ hint: 'Ask the user to accept or reject the existing paragraph formatting change first; this tool does not resolve revisions by other reviewers.'
3114
+ }
3115
+ }
3116
+ };
3117
+ }
3118
+ // A same-author pending snapshot already records the earliest "before" state; keep it.
3119
+ if (!pendingChange) snapshotAndAttachPPrChange(xmlDoc, pPr, authorUsed);
2709
3120
  }
2710
3121
 
2711
3122
  applyParagraphPropertiesToPPr(xmlDoc, pPr, properties);
@@ -2716,6 +3127,232 @@ export async function applyParagraphFormatToParagraphByExactText(
2716
3127
  return {
2717
3128
  documentXml: completedDocumentXml(xmlDoc, serializer, documentXml, operationSession),
2718
3129
  hasChanges: true,
2719
- status: 'ok'
3130
+ status: 'ok',
3131
+ ...listDiagnosticFields(propertyDiagnostics)
3132
+ };
3133
+ }
3134
+
3135
+ /**
3136
+ * Resolves an operation's target range (target plus optional targetEnd) to paragraphs in document order, the
3137
+ * same way restore does: batch-compiled source ids first, then legacy refs, then an end descriptor.
3138
+ */
3139
+ function resolveOperationParagraphRange(xmlDoc, targetText, targetRef, targetEndRef, opType, runtimeContext, options) {
3140
+ const resolved = resolveTargetParagraph(xmlDoc, targetText, targetRef, opType, runtimeContext, options);
3141
+ if (resolved?.error || !resolved?.paragraph) {
3142
+ return { error: resolved?.error || { code: 'TARGET_NOT_FOUND', message: 'Target paragraph not found.' } };
3143
+ }
3144
+ const first = resolved.paragraph;
3145
+ const hasEnd = options._compiledSourceEndId || targetEndRef != null || options.targetEndDescriptor;
3146
+ if (!hasEnd) return { paragraphs: [first] };
3147
+ let paragraphs = null;
3148
+ if (options._compiledSourceEndId || options.targetEndDescriptor) {
3149
+ const endResolved = resolveTargetParagraph(
3150
+ xmlDoc,
3151
+ options.targetEndDescriptor?.text || '',
3152
+ options._compiledSourceEndId ? targetEndRef : (options.targetEndDescriptor?.index ?? targetEndRef),
3153
+ opType,
3154
+ runtimeContext,
3155
+ {
3156
+ ...options,
3157
+ ...(options.targetEndDescriptor ? { targetDescriptor: options.targetEndDescriptor } : {}),
3158
+ _compiledSourceId: options._compiledSourceEndId || null,
3159
+ _resolutionCapture: null
3160
+ }
3161
+ );
3162
+ if (endResolved?.error) return { error: endResolved.error };
3163
+ const all = endResolved?.paragraph ? getDocumentParagraphNodes(xmlDoc) : [];
3164
+ const startIndex = all.indexOf(first);
3165
+ const endIndex = all.indexOf(endResolved?.paragraph);
3166
+ paragraphs = startIndex >= 0 && endIndex >= startIndex ? all.slice(startIndex, endIndex + 1) : null;
3167
+ } else {
3168
+ paragraphs = resolveParagraphRangeByRefs(xmlDoc, targetRef, targetEndRef, {
3169
+ opType,
3170
+ targetRefSnapshot: runtimeContext?.targetRefSnapshot || null,
3171
+ onInfo: options.onInfo,
3172
+ onWarn: options.onWarn
3173
+ });
3174
+ }
3175
+ if (!Array.isArray(paragraphs) || paragraphs.length === 0 || paragraphs[0] !== first) {
3176
+ return { error: { code: 'TARGET_RANGE_INVALID', message: 'targetEnd must resolve to the target paragraph or a paragraph after it.' } };
3177
+ }
3178
+ return { paragraphs };
3179
+ }
3180
+
3181
+ /**
3182
+ * `list-format` (`convert-plain`): binds plain paragraphs to a new list in place; see services/list-format-mutation.js.
3183
+ */
3184
+ export async function applyListFormatToParagraphByExactText(
3185
+ documentXml,
3186
+ targetText,
3187
+ list,
3188
+ author,
3189
+ targetRef = null,
3190
+ targetEndRef = null,
3191
+ runtimeContext = null,
3192
+ options = {}
3193
+ ) {
3194
+ const generateRedlines = options.generateRedlines !== false;
3195
+ const onInfo = typeof options?.onInfo === 'function' ? options.onInfo : () => { };
3196
+ const onWarn = typeof options?.onWarn === 'function' ? options.onWarn : () => { };
3197
+ const { serializer, xmlDoc, operationSession } = resolveMutationDocument(documentXml, options);
3198
+ if (!xmlDoc) {
3199
+ return { documentXml, hasChanges: false, status: 'error', error: { code: 'PARSE_ERROR', message: 'Could not parse document OOXML.' } };
3200
+ }
3201
+ const revisionIdAllocator = operationSession?.revisionIdAllocator || prepareRevisionAllocator(xmlDoc, options);
3202
+ const range = resolveOperationParagraphRange(xmlDoc, targetText, targetRef, targetEndRef, 'list-format', runtimeContext, {
3203
+ ...options,
3204
+ onInfo,
3205
+ onWarn
3206
+ });
3207
+ if (range.error) return { documentXml, hasChanges: false, status: 'error', error: range.error };
3208
+ const paragraphs = range.paragraphs;
3209
+ if (paragraphs.some(paragraph => paragraph.parentNode !== paragraphs[0].parentNode)) {
3210
+ return {
3211
+ documentXml,
3212
+ hasChanges: false,
3213
+ status: 'error',
3214
+ error: {
3215
+ code: 'TARGET_RANGE_INVALID',
3216
+ message: 'A list-format range must stay inside one container (the body, or one table cell).'
3217
+ }
3218
+ };
3219
+ }
3220
+ const allParagraphs = getDocumentParagraphNodes(xmlDoc);
3221
+ const request = normalizeListFormatRequest(list);
3222
+ if (request.mode === 'change-format' || request.mode === 'join') {
3223
+ return applyListRebinding(xmlDoc, paragraphs, request, {
3224
+ documentXml, serializer, operationSession, runtimeContext, options, author, generateRedlines, onInfo, onWarn,
3225
+ paragraphIndexOf: paragraph => {
3226
+ const index = allParagraphs.indexOf(paragraph);
3227
+ return index >= 0 ? index + 1 : null;
3228
+ }
3229
+ });
3230
+ }
3231
+ const conversion = convertPlainParagraphsToList(xmlDoc, paragraphs, request, {
3232
+ author: author || getDefaultAuthor(),
3233
+ generateRedlines,
3234
+ revisionIdAllocator,
3235
+ runtimeContext,
3236
+ stylesXml: runtimeContext?.stylesXml || options.stylesXml || null,
3237
+ numberingXml: runtimeContext?.numberingXml || options.numberingXml || null,
3238
+ paragraphIndexOf: paragraph => allParagraphs.indexOf(paragraph) + 1
3239
+ });
3240
+ if (!conversion.ok) return { documentXml, hasChanges: false, status: 'error', error: conversion.error };
3241
+
3242
+ for (const item of conversion.items) {
3243
+ ensureParagraphIdsOnImportedNode(item.paragraph, operationSession);
3244
+ options?._mutationLiveNodes?.push(item.paragraph);
3245
+ }
3246
+ for (const numId of conversion.allocatedNumIds) operationSession?.receiptCollector?.recordNumbering(numId);
3247
+ normalizeBodySectionOrder(xmlDoc);
3248
+ 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(', ')}).`);
3249
+
3250
+ return {
3251
+ documentXml: completedDocumentXml(xmlDoc, serializer, documentXml, operationSession),
3252
+ hasChanges: true,
3253
+ status: 'ok',
3254
+ numberingXml: conversion.numberingXml,
3255
+ list: {
3256
+ mode: 'convert-plain',
3257
+ format: conversion.format,
3258
+ level: conversion.level,
3259
+ strip: conversion.strip,
3260
+ items: conversion.items.map(item => ({
3261
+ paragraphIndex: item.paragraphIndex,
3262
+ paragraphId: getParagraphId(item.paragraph),
3263
+ marker: item.marker,
3264
+ value: item.value,
3265
+ numId: item.numId
3266
+ })),
3267
+ skipped: conversion.skipped.map(item => ({ paragraphIndex: item.paragraphIndex, reason: item.reason }))
3268
+ }
3269
+ };
3270
+ }
3271
+
3272
+ /**
3273
+ * `list-format` `change-format` / `join`: rebinds existing list items (see services/list-format-change.js). The
3274
+ * definitions are read from the source numbering part plus the parts earlier operations of this batch allocated,
3275
+ * so a later operation can change a list an earlier one created or rebound.
3276
+ */
3277
+ function applyListRebinding(xmlDoc, paragraphs, request, ctx) {
3278
+ const { documentXml, serializer, operationSession, runtimeContext, options, author, generateRedlines, onInfo, paragraphIndexOf } = ctx;
3279
+ const refused = error => ({ documentXml, hasChanges: false, status: 'error', error });
3280
+ const sourceNumberingXml = runtimeContext?.numberingIdState?.sourceNumberingXml
3281
+ || runtimeContext?.numberingXml
3282
+ || options.numberingXml
3283
+ || '';
3284
+ const pendingParts = Array.isArray(operationSession?.numberingXmlParts) ? operationSession.numberingXmlParts : [];
3285
+ const numberingXml = pendingParts.length > 0 ? mergeNumberingParts(sourceNumberingXml, pendingParts) : sourceNumberingXml;
3286
+ const shared = {
3287
+ author: author || getDefaultAuthor(),
3288
+ generateRedlines,
3289
+ runtimeContext,
3290
+ stylesXml: runtimeContext?.stylesXml || options.stylesXml || null,
3291
+ numberingXml,
3292
+ paragraphIndexOf
3293
+ };
3294
+
3295
+ let outcome;
3296
+ if (request.mode === 'join') {
3297
+ const descriptor = options.continueFromDescriptor || null;
3298
+ const resolved = descriptor
3299
+ ? resolveTargetParagraph(xmlDoc, descriptor.text || '', descriptor.index ?? null, 'list-format', runtimeContext, {
3300
+ ...options,
3301
+ targetDescriptor: descriptor,
3302
+ _compiledSourceId: options._compiledContinueFromId || null,
3303
+ _compiledResolvedBy: null,
3304
+ _resolutionCapture: null
3305
+ })
3306
+ : null;
3307
+ if (!resolved?.paragraph) {
3308
+ return refused({
3309
+ code: resolved?.error?.code || 'TARGET_NOT_FOUND',
3310
+ message: `list.continueFrom: ${resolved?.error?.message || 'paragraph not found.'}`,
3311
+ field: 'list.continueFrom'
3312
+ });
3313
+ }
3314
+ outcome = joinListParagraphs(xmlDoc, paragraphs, resolved.paragraph, request, shared);
3315
+ } else {
3316
+ outcome = changeListParagraphsFormat(xmlDoc, paragraphs, request, shared);
3317
+ }
3318
+ if (!outcome.ok) return refused(outcome.error);
3319
+ if (outcome.unchanged) {
3320
+ const warning = `${LIST_FORMAT_UNCHANGED}: ${outcome.message}`;
3321
+ ctx.onWarn?.(warning);
3322
+ return { documentXml, hasChanges: false, status: 'ok', warnings: [warning] };
3323
+ }
3324
+
3325
+ for (const item of outcome.items) {
3326
+ ensureParagraphIdsOnImportedNode(item.paragraph, operationSession);
3327
+ options?._mutationLiveNodes?.push(item.paragraph);
3328
+ }
3329
+ for (const numId of outcome.allocatedNumIds) operationSession?.receiptCollector?.recordNumbering(numId);
3330
+ normalizeBodySectionOrder(xmlDoc);
3331
+ const outside = outcome.items.filter(item => !item.inTarget).length;
3332
+ 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)` : ''}.`);
3333
+
3334
+ return {
3335
+ documentXml: completedDocumentXml(xmlDoc, serializer, documentXml, operationSession),
3336
+ hasChanges: true,
3337
+ status: 'ok',
3338
+ numberingXml: outcome.numberingXml,
3339
+ list: {
3340
+ mode: outcome.mode,
3341
+ scope: outcome.scope,
3342
+ sourceNumId: outcome.sourceNumId,
3343
+ numId: outcome.numId,
3344
+ ...(outcome.mode === 'change-format'
3345
+ ? { format: outcome.format, level: outcome.level, lvlText: outcome.lvlText, levelStarts: outcome.levelStarts }
3346
+ : { continueFrom: outcome.continueFrom }),
3347
+ items: outcome.items.map(item => ({
3348
+ paragraphIndex: item.paragraphIndex,
3349
+ paragraphId: getParagraphId(item.paragraph),
3350
+ level: item.level,
3351
+ numId: item.numId,
3352
+ sourceNumId: item.sourceNumId,
3353
+ inTarget: item.inTarget
3354
+ })),
3355
+ skipped: outcome.skipped.map(item => ({ paragraphIndex: item.paragraphIndex, reason: item.reason }))
3356
+ }
2720
3357
  };
2721
3358
  }