@ansonlai/docx-redline-js 0.8.4 → 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 (68) hide show
  1. package/ARCHITECTURE.md +221 -12
  2. package/CHANGELOG.md +176 -0
  3. package/README.md +125 -12
  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 +4189 -767
  9. package/dist/docx-redline-js.esm.js.map +4 -4
  10. package/dist/docx-redline-js.esm.min.js +111 -108
  11. package/dist/docx-redline-js.esm.min.js.map +4 -4
  12. package/dist/docx-redline.bundle.cjs +4292 -870
  13. package/dist/docx-redline.bundle.cjs.map +4 -4
  14. package/dist/docx-redline.bundle.js +4292 -870
  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 +19 -3
  18. package/docs/SKILL_AUTHORING.md +165 -150
  19. package/docs/TESTING.md +1142 -1059
  20. package/docs/schemas/document-operations.schema.json +54 -1
  21. package/document/docx-document.js +1022 -1010
  22. package/engine/oxml-engine.js +736 -749
  23. package/engine/paragraph-mark-revisions.js +292 -0
  24. package/engine/reconstruction-mapper.js +312 -311
  25. package/engine/reconstruction-writer.js +67 -10
  26. package/engine/rpr-helpers.js +42 -0
  27. package/engine/run-builders.js +0 -73
  28. package/engine/surgical-diff-application.js +604 -605
  29. package/engine/table-mode.js +63 -63
  30. package/index.d.ts +47 -1
  31. package/index.js +11 -2
  32. package/node/cli-help.js +1 -0
  33. package/node/cli.js +992 -975
  34. package/orchestration/list-structural-fallback.js +67 -9
  35. package/package.json +1 -1
  36. package/pipeline/diff-engine.js +130 -1
  37. package/pipeline/ingestion-export.js +227 -243
  38. package/pipeline/ingestion-paragraph.js +93 -98
  39. package/pipeline/list-generation.js +415 -419
  40. package/pipeline/pipeline.js +206 -204
  41. package/scripts/export-validation-fixtures.mjs +19 -6
  42. package/scripts/generate-word-revision-semantics-matrix.mjs +134 -0
  43. package/scripts/lib/word-coverage-catalogue.mjs +7 -2
  44. package/scripts/render-multilevel-bullet-visual.ps1 +2 -2
  45. package/scripts/word-com-corpus-visual-suite.ps1 +2 -2
  46. package/scripts/word-com-differential.ps1 +443 -335
  47. package/scripts/word-com-visual-suite.ps1 +2 -2
  48. package/scripts/word-revision-semantics-matrix.ps1 +108 -0
  49. package/services/batch-operation-orchestrator.js +23 -3
  50. package/services/capture-engine.js +228 -226
  51. package/services/document-operation-applier.js +213 -0
  52. package/services/document-operation-contract.js +75 -0
  53. package/services/document-operation-mutations.js +3346 -2721
  54. package/services/error-recovery.js +17 -0
  55. package/services/list-conversion-diagnostics.js +296 -0
  56. package/services/list-format-change.js +586 -0
  57. package/services/list-format-mutation.js +416 -0
  58. package/services/list-format.js +305 -0
  59. package/services/numbering-allocation.js +642 -0
  60. package/services/numbering-helpers.js +354 -524
  61. package/services/operation-batch-compiler.js +183 -11
  62. package/services/operation-preflight.js +1 -0
  63. package/services/receipt-collector.js +288 -288
  64. package/services/revision-comment-management.js +38 -5
  65. package/services/revision-fidelity-verifier.js +498 -0
  66. package/services/standalone-docx-plumbing.js +627 -631
  67. package/services/standalone-operation-runner.d.ts +182 -5
  68. package/services/table-reconciliation.js +59 -7
package/README.md CHANGED
@@ -15,14 +15,19 @@ Converts AI-generated or programmatic text/markdown edits into valid Office Open
15
15
  - Comment management: delete comments by author or for all authors
16
16
  - Highlights: apply highlight colors to runs
17
17
  - Markdown and OOXML conversion in both directions
18
+ - Reject All verification: every tracked operation is refused unless Word's Reject All of the output reproduces the source
18
19
  - Status/error result fields for parse, targeting, and existing-revision failures
19
20
  - Localized exact replacements against inspected targets, plus optional fail-closed speculative CLI edits
20
21
  - Package plumbing helpers for numbering.xml, comments.xml, content types, and relationships
21
22
  - Zero host dependencies: works in Node.js, browsers, Deno, and similar JS runtimes with DOM parsing support
22
23
 
23
- ## What's New in 0.8.x
24
+ ## What's New in 0.9.0 and 0.8.x
24
25
 
25
- The current release is 0.8.4. The CLI contract remains version 8 throughout the 0.8 line.
26
+ 0.9.0 moves the CLI contract to version 9 and adds the `list-format` operation (`convert-plain`, `change-format`, `join`),
27
+ `paragraph-format` numbering binding, list-conversion diagnostics, a single numbering allocator and merge, and a Reject All
28
+ revision fidelity verifier that refuses by default (`REVISION_FIDELITY_VIOLATION`). See the
29
+ [0.9.0 release notes](./docs/releases/0.9.0.md) and the [CHANGELOG](./CHANGELOG.md). The CLI contract was version 8
30
+ throughout the 0.8 line.
26
31
 
27
32
  - **0.8.0, universal document facade:** `openDocx` and `DocxDocument` are exported from the primary entry point and work
28
33
  on `Uint8Array` input in any JavaScript runtime. ZIP handling uses `fflate` and SHA-256 is pure JavaScript, so there are no
@@ -311,7 +316,9 @@ marker. Machine `index` and `ref` fields are for targeting and pagination, not
311
316
  user-facing Word locations; use `humanReference`, `provision`, or
312
317
  `nearestHeading` in reports.
313
318
 
314
- `docx-redline version` reports contract version 8 and the additive
319
+ `docx-redline version` reports contract version 9 (adds the `list-format` operation type). Contract 9 adds the
320
+ capabilities `revision-fidelity-v1`, `list-conversion-diagnostics-v1`, `paragraph-numbering-binding-v1`,
321
+ `list-format-v1` (`convert-plain`) and `list-format-change-v1` (`change-format` and `join`), alongside the additive
315
322
  `command-help-v1`, `inspection-context-v1`, `bounded-inspection-v1`,
316
323
  `human-document-references-v1`, `batch-start-source-binding`,
317
324
  `recovery-envelope-v1`, `require-complete-exit`, `operations-stdin`,
@@ -320,7 +327,9 @@ user-facing Word locations; use `humanReference`, `provision`, or
320
327
  `speculative-search-apply-v1`, `localized-change-summary-v1`, and
321
328
  `restore-shortcuts-v1`, plus `localized-space-equivalence-v1` capabilities. Header/footer
322
329
  `part` targeting, the `comment_resolve` operation and `delete-comments --comment-id` are advertised as
323
- `header-footer-parts-v1`, `comment-resolve-operation-v1` and `delete-comments-by-id-v1`.
330
+ `header-footer-parts-v1`, `comment-resolve-operation-v1` and `delete-comments-by-id-v1`. The remaining advertised
331
+ capabilities are `atomic-batch-results-on-package-failure`, `baseline-aware-validation`, `compact-mutation-results`,
332
+ `cross-author-revision-slicing`, `document-scoped-list-revision-ids` and `format-text-occurrence-v1`.
324
333
  Wrappers should negotiate only the
325
334
  capabilities they use. Run `docx-redline <command> --help` for that command's
326
335
  machine-readable options, behavior, exit codes, canonical GitHub documentation links, and compact examples.
@@ -371,6 +380,9 @@ See the [compact agent fast start](./docs/AGENT_FAST_START.md), the
371
380
  | `strictTargets` | `boolean` | `true` (CLI/facade) | Requires exact target descriptors (`exactText`, `paragraphId`, `index`, `occurrence`, `fingerprint`) and forbids ambiguous matching. Defaults to `false` in low-level runner for backwards compatibility. |
372
381
  | `existingRevisions` | `string` | `'merge-same-author'` | How to handle paragraphs with existing tracked changes. `'merge-same-author'` merges revisions from the same author and protects other authors with `EXISTING_REVISIONS`. `'slice-cross-author'` keeps that same-author merge behavior while allowing Word-native edits inside another author's pending insertion. Pass `'accept-all-first'` to normalize prior revisions or `'reject-input'` to refuse editing revised paragraphs. |
373
382
  | `removeFormatting` | `boolean` | `false` | When `true` and the text is unchanged with no Markdown hints, strips existing bold/italic/underline/strikethrough formatting. |
383
+ | `verifyRevisionFidelity` | `'enforce' \| 'warn' \| 'off'` | `'enforce'` | Post-apply Reject All check described under [Reject All verification](#reject-all-verification). CLI: `--revision-fidelity`; the agent profile rejects `off`. |
384
+ | `listMarkers` | `'literal'` | unset | Per operation. Accepts list-marker text as literal text and suppresses `LIST_MARKER_NOT_BOUND`, `UNSUPPORTED_LIST_CONVERSION`, `LIST_FORMAT_NOT_APPLIED` and `UNSUPPORTED_LIST_FORMAT_CHANGE` for that operation. |
385
+ | `strictListConversion` | `boolean` | `atomic` | Makes `UNSUPPORTED_LIST_CONVERSION` and `UNSUPPORTED_LIST_FORMAT_CHANGE` errors instead of warnings (capability `list-conversion-diagnostics-v1`). The CLI agent profile and `--atomic` set it; an operation's `listMarkers: 'literal'` wins. |
374
386
  | `sanitizeInput` | `boolean` | `false` | Opt-in removal of standalone leading assistant-preface lines. Literal dollar signs and `\n` sequences are always preserved. |
375
387
 
376
388
  Same-author revision merging refuses paragraphs containing comment anchors with
@@ -437,6 +449,58 @@ for it. For a complete `word/document.xml`, prefer
437
449
  `applyOperationToDocumentXml(...)` so replacement nodes and numbering artifacts
438
450
  are imported at the correct scope.
439
451
 
452
+ ### List operations (`list-format`)
453
+
454
+ Text edits never create or restyle list structure. Typed markers such as `1.` or `A.` stay literal text, and a
455
+ `list-change` that retypes markers keeps the old numbering (it warns `LIST_FORMAT_NOT_APPLIED`, or fails
456
+ `UNSUPPORTED_LIST_FORMAT_CHANGE` under the strict policy, with a `list-format` request in `recovery.suggestedOperation`).
457
+ Structure changes use the `list-format` operation. Every mode records each paragraph's previous `w:numPr` in a tracked
458
+ `w:pPrChange`, so Reject All restores the original labels exactly and Accept All shows the new ones. The shared numbering
459
+ definitions in `numbering.xml` are never edited in place: new definitions come from one allocator and are merged by one numbering
460
+ merge, and an id that already exists with different content fails with `NUMBERING_ID_CONFLICT`. `paragraph-format`
461
+ `properties.numbering` binds or removes a list on a paragraph the same way (tracked `w:pPrChange`). Unknown
462
+ `paragraph-format.properties` keys are rejected with `INVALID_OPERATION`. The CLI advertises `list-format-v1` (contract version 9) for
463
+ `convert-plain` and `list-format-change-v1` for `change-format` and `join`.
464
+
465
+ ```js
466
+ const doc = openDocx(bytes);
467
+ const result = await doc.applyOperations([
468
+ // Typed headers "A. Scope" .. "C. Fees" become a real A./B./C. list. Each marker is a tracked deletion, and body
469
+ // paragraphs in the range stay plain. A typed start value ("C.") is kept.
470
+ { type: 'list-format', target: { exactText: 'A. Scope' }, targetEnd: { exactText: 'C. Fees' },
471
+ list: { mode: 'convert-plain' } },
472
+ // Bullets become 1., 2., 3. A new definition copies the list's levels and indentation.
473
+ { type: 'list-format', target: { exactText: 'Photo identification' },
474
+ list: { mode: 'change-format', format: 'decimal' } },
475
+ // A later list continues an earlier one (1. 2. / 1. 2. becomes 1. 2. / 3. 4.).
476
+ { type: 'list-format', target: { exactText: 'Confidentiality' },
477
+ list: { mode: 'join', continueFrom: { exactText: 'Termination' } } }
478
+ ], { author: 'Editor' });
479
+ ```
480
+
481
+ A changed-text header (`1. Old Terms` -> `1. New Terms`) takes two operations on the same paragraph in one batch: a
482
+ `redline` of the text that keeps the typed marker exactly, and a `list-format` that converts the paragraph. The batch
483
+ compiler allows this pair and always runs the redline first, whatever the batch order. The list-format then strips the
484
+ unchanged marker. Accept All shows label `1.` with `New Terms`, and Reject All shows the literal `1. Old Terms`:
485
+
486
+ ```js
487
+ await doc.applyOperations([
488
+ { type: 'redline', target: { exactText: '1. Old Terms' }, replacements: [{ find: 'Old', replace: 'New' }] },
489
+ { type: 'list-format', target: { exactText: '1. Old Terms' }, list: { mode: 'convert-plain' } }
490
+ ], { author: 'Editor' });
491
+ ```
492
+
493
+ A redline in that pair that adds, removes or changes the marker is refused before anything is written, with
494
+ `LIST_FORMAT_COMPOSITION_CONFLICT` (`reason: 'marker-changed'`). So is a redline that spans paragraphs or leaves the text
495
+ unchanged. To give a paragraph without a typed marker a label, use `strip: 'none'` with `format` (and `start`). A
496
+ `change-format` or `join` with `scope: 'list'` (the default) also writes every other item of the list. In the same batch,
497
+ each of those items accepts only a single-paragraph `redline`; any other write to them fails with
498
+ `LIST_FORMAT_COMPOSITION_CONFLICT` (`reason: 'list-rebind'`).
499
+ Style-linked lists (heading numbering), mid-list splits, and markers inside hyperlinks or fields are refused with a
500
+ recovery envelope. They are not approximated. Removing a list binding is `paragraph-format` with `properties.numbering:
501
+ null`. A `list-format` `detach` mode is not available yet. See the
502
+ [knowledge base](docs/AGENT_KNOWLEDGE_BASE.md#operation-model-choose-by-output-shape) for every mode, field and refusal.
503
+
440
504
  ### Planning large mixed-content insertions
441
505
 
442
506
  For an attachment or schedule containing several kinds of content, use
@@ -509,7 +573,7 @@ an earlier block has already replaced.
509
573
  | `deleteCommentsByAuthorInOoxml(oxml, { author?, allAuthors? })` | Delete matching comment definitions and anchors present in the supplied OOXML payload. Real `.docx` packages require updating both `word/comments.xml` and `word/document.xml`. |
510
574
  | `generateTableOoxml(headers, rows, options)` | Generate a `w:tbl` from tabular data. |
511
575
  | `createDynamicNumberingIdState(numberingXml)` | Allocate numbering IDs without collisions. |
512
- | `ensureNumberingArtifactsInZip(zip, numberingXml, options)` | Add numbering artifacts to a `.docx` package. Replacement of existing numbering without `mergeNumberingXmlBySchemaOrder` is deprecated and will throw in the next major version. |
576
+ | `ensureNumberingArtifactsInZip(zip, numberingXml, options)` | Add numbering artifacts to a `.docx` package. Always merges into an existing `word/numbering.xml` through the package's single numbering merge (`mergeNumberingParts`); an incoming id that already exists with different content fails with `NUMBERING_ID_CONFLICT`. The former `mergeNumberingXml` option is ignored. |
513
577
  | `ensureCommentsArtifactsInZip(zip, commentsXml)` | Merge comments artifacts into a `.docx` package. |
514
578
  | `ensureCommentsExtendedArtifactsInZip(zip, commentsExtendedXml)` | Add or replace modern Word comment-thread metadata in a `.docx` package. |
515
579
  | `validateDocxPackage(zip)` | Validate `.docx` structural consistency. |
@@ -556,8 +620,9 @@ identities. Independent edits therefore do not need to be manually sorted when
556
620
  an earlier structural rewrite changes later paragraph indexes or fingerprints.
557
621
  Targets that deliberately refer to uniquely created paragraph text are compiled
558
622
  into an internal capture dependency. True overlap is not guessed: incompatible
559
- writes to one source fail before mutation with `OVERLAPPING_SOURCE_TARGETS` or
560
- `REVISION_ORDER_CONFLICT`, and mutating capture fan-out without distinct
623
+ writes to one source fail before mutation with `OVERLAPPING_SOURCE_TARGETS`,
624
+ `REVISION_ORDER_CONFLICT` or `LIST_FORMAT_COMPOSITION_CONFLICT` (the one composed pair, a
625
+ marker-preserving `redline` plus a `list-format`, runs redline first), and mutating capture fan-out without distinct
561
626
  selectors fails with `CAPTURE_FANOUT_CONFLICT`.
562
627
 
563
628
  Threaded replies use a comment operation with no body target:
@@ -667,6 +732,57 @@ chooses among multiple candidate paragraphs heuristically, it emits an
667
732
  guidance. Callers should pass `{ strictTargets: true }` and use strict descriptors
668
733
  (`paragraphId`, `index`, `occurrence`, or `fingerprint`) to prepare for v1.0.0.
669
734
 
735
+ ### Reject All verification
736
+
737
+ A tracked edit only adds revisions, so rejecting every revision in the output
738
+ must give back the source document. After each operation the library projects
739
+ Word's Reject All and Accept All of the output (`core/word-revision-semantics.js`,
740
+ validated against recorded desktop Word results) and checks these invariants:
741
+
742
+ - `REJECT_PARAGRAPH_COUNT`, `REJECT_PARAGRAPH_TEXT` (including tabs and breaks),
743
+ `REJECT_PARAGRAPH_PROPS`, `REJECT_NUMBERING_REFS` (list membership `numId`/`ilvl`)
744
+ and `REJECT_WRAPPERS` (hyperlink, field and content-control membership):
745
+ Reject All of the output equals Reject All of the source (under accept-first
746
+ `existingRevisions` policies the target range is compared with the source's
747
+ accepted view). `REJECT_NUMBERING_REFS` also refuses a `numId` that the output
748
+ binds, or Reject All restores (including inside `w:pPrChange`), when the source
749
+ did not already use it and no `w:num` -> `w:abstractNum` -> `w:lvl` in the
750
+ available numbering defines it.
751
+ - `ACCEPT_INTENT`: a tracked plain-text `redline` with complete `modified` (no
752
+ Markdown, list-marker or table syntax; a single paragraph or a `targetEnd`
753
+ range) is refused when Accept All of its target would not read as `modified`.
754
+ - `ACCEPT_NEIGHBORS`: an untracked edit (`generateRedlines: false`) may not
755
+ change a paragraph outside its target in the Accept All or Reject All view.
756
+ - `UNTRACKED_CREATED_REVISION`: an untracked edit may not create a tracked
757
+ revision that was not in the source (fragments of a sliced source revision
758
+ keep their element, author and date and are allowed).
759
+
760
+ A localized replacement is also checked before it is committed: the target's
761
+ accepted text must equal the compiled replacement text, otherwise the operation
762
+ is refused with `PATCH_ROUNDTRIP_MISMATCH` rather than dropping content.
763
+
764
+ By default a violation is refused with `REVISION_FIDELITY_VIOLATION`; the
765
+ document is unchanged, `status` is `error`, and the receipt is `refused`. The
766
+ error payload contains:
767
+
768
+ - `violations[]`, each with `invariant` (`REJECT_PARAGRAPH_COUNT`,
769
+ `REJECT_PARAGRAPH_TEXT`, `REJECT_PARAGRAPH_PROPS`, `REJECT_NUMBERING_REFS`,
770
+ `REJECT_WRAPPERS`, `ACCEPT_INTENT`, `ACCEPT_NEIGHBORS` or
771
+ `UNTRACKED_CREATED_REVISION`), a plain-language `explanation`,
772
+ `expected`/`actual` excerpts, and `source.sourceParagraphIndex` (the 1-based
773
+ paragraph index from extraction) with a text excerpt;
774
+ - `recovery.retryGuidance`, one retry instruction per reported invariant, for
775
+ example use `replacements` for a literal edit, keep breaks and hyperlink
776
+ boundaries, or move formatting and list changes into their own operation.
777
+
778
+ Control it with the `verifyRevisionFidelity` option (`'enforce'` default,
779
+ `'warn'`, `'off'`) or `--revision-fidelity enforce|warn|off` on the CLI. `warn`
780
+ applies the edit and appends the same text to `warnings`. The `--profile agent`
781
+ profile rejects `off`. A narrower retry that is also refused indicates an
782
+ unsupported shape; report it rather than disabling verification. Recovery
783
+ details are in the `REVISION_FIDELITY_VIOLATION` row of
784
+ [docs/AGENT_KNOWLEDGE_BASE.md](./docs/AGENT_KNOWLEDGE_BASE.md).
785
+
670
786
  ### Mutation Receipts
671
787
 
672
788
  Both single-operation (`applyOperationToDocumentXml`) and batch
@@ -722,7 +838,7 @@ Different APIs return different OOXML shapes. Use this as a packaging safety che
722
838
  ID. Generated bullet and numbered paragraphs must reference a positive ID
723
839
  whose definition is merged into `word/numbering.xml`.
724
840
  - Do use `extractReplacementNodesFromOoxml(...)` when you are consuming `result.oxml` from paragraph/range/table APIs.
725
- - Do merge numbering/comments artifacts with `ensureNumberingArtifactsInZip(...)` and `ensureCommentsArtifactsInZip(...)` when those parts are present. Supply `mergeNumberingXmlBySchemaOrder` when numbering already exists.
841
+ - Do merge numbering/comments artifacts with `ensureNumberingArtifactsInZip(...)` and `ensureCommentsArtifactsInZip(...)` when those parts are present. Numbering is always merged, never replaced; pass `numberingXml` or a shared `numberingIdState` to every call so generated ids cannot collide.
726
842
  - Don't write payloads that start with `<pkg:package` directly into `word/document.xml`.
727
843
  - Don't assume every `result.oxml` payload is a raw paragraph fragment.
728
844
 
@@ -744,7 +860,6 @@ import {
744
860
  applyRedlineToOxml,
745
861
  extractReplacementNodesFromOoxml,
746
862
  ensureNumberingArtifactsInZip,
747
- mergeNumberingXmlBySchemaOrder,
748
863
  validateDocxPackage
749
864
  } from '@ansonlai/docx-redline-js';
750
865
  import { applyOperationToDocumentXml } from '@ansonlai/docx-redline-js/standalone-runner';
@@ -807,9 +922,7 @@ const normalized = extractReplacementNodesFromOoxml(fragmentResult.oxml);
807
922
  // If sourceType === 'package', merge extracted content/artifacts instead of
808
923
  // writing the raw pkg:package payload into word/document.xml.
809
924
  if (normalized.numberingXml) {
810
- await ensureNumberingArtifactsInZip(zip, normalized.numberingXml, {
811
- mergeNumberingXml: mergeNumberingXmlBySchemaOrder
812
- });
925
+ await ensureNumberingArtifactsInZip(zip, normalized.numberingXml);
813
926
  }
814
927
 
815
928
  await validateDocxPackage(zip);
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Single accessor for CURRENT paragraph/run properties.
3
+ *
4
+ * Descendant lookups (`getElementsByTagNameNS`, `getWordDescendants`, ...) cross `w:pPrChange` and
5
+ * `w:rPrChange`, so a historical `numPr`/`pStyle`/`b` snapshot looks like a current value. Everything in
6
+ * this module reads direct children of `w:pPr` / `w:rPr` only. The `*PrChange` snapshot is reachable
7
+ * only through the explicit `getHistorical*` functions (reject simulation, restore).
8
+ *
9
+ * Depends only on `core/types.js`.
10
+ */
11
+
12
+ import { NS_W } from './types.js';
13
+
14
+ function isWordElement(node, localName) {
15
+ if (!node || node.nodeType !== 1) return false;
16
+ const name = String(node.localName || node.nodeName || '').replace(/^.*:/, '');
17
+ if (localName != null && name !== localName) return false;
18
+ return !node.namespaceURI || node.namespaceURI === NS_W;
19
+ }
20
+
21
+ /** First direct child element with the given WordprocessingML local name. */
22
+ export function getDirectWordChild(node, localName) {
23
+ if (!node) return null;
24
+ for (const child of Array.from(node.childNodes || [])) {
25
+ if (isWordElement(child, localName)) return child;
26
+ }
27
+ return null;
28
+ }
29
+
30
+ /** Direct child elements with the given local name (all direct Word elements when omitted). */
31
+ export function getDirectWordChildren(node, localName) {
32
+ if (!node) return [];
33
+ return Array.from(node.childNodes || []).filter(child => isWordElement(child, localName));
34
+ }
35
+
36
+ /** Direct child of a properties element; never descends into `*PrChange` snapshots. */
37
+ export function getCurrentChild(propsEl, localName) {
38
+ return getDirectWordChild(propsEl, localName);
39
+ }
40
+
41
+ function readVal(el) {
42
+ if (!el) return null;
43
+ const value = (typeof el.getAttributeNS === 'function' && el.getAttributeNS(NS_W, 'val'))
44
+ || el.getAttribute?.('w:val')
45
+ || el.getAttribute?.('val')
46
+ || null;
47
+ return value === '' ? null : value;
48
+ }
49
+
50
+ function readNumPr(propsEl) {
51
+ const numPr = getDirectWordChild(propsEl, 'numPr');
52
+ if (!numPr) return null;
53
+ return {
54
+ element: numPr,
55
+ numId: readVal(getDirectWordChild(numPr, 'numId')),
56
+ ilvl: readVal(getDirectWordChild(numPr, 'ilvl')),
57
+ numIdElement: getDirectWordChild(numPr, 'numId'),
58
+ ilvlElement: getDirectWordChild(numPr, 'ilvl')
59
+ };
60
+ }
61
+
62
+ function readParagraphProps(pPr) {
63
+ const pStyleEl = getDirectWordChild(pPr, 'pStyle');
64
+ const outlineEl = getDirectWordChild(pPr, 'outlineLvl');
65
+ return {
66
+ pPr: pPr || null,
67
+ numPr: readNumPr(pPr),
68
+ pStyle: readVal(pStyleEl),
69
+ pStyleElement: pStyleEl,
70
+ outlineLvl: readVal(outlineEl),
71
+ outlineLvlElement: outlineEl
72
+ };
73
+ }
74
+
75
+ /** Current paragraph properties: the paragraph's direct `w:pPr` only. */
76
+ export function getCurrentParagraphProperties(p) {
77
+ return readParagraphProps(getDirectWordChild(p, 'pPr'));
78
+ }
79
+
80
+ /** Current run properties: the run's direct `w:rPr` (null when absent). */
81
+ export function getCurrentRunProperties(r) {
82
+ return getDirectWordChild(r, 'rPr');
83
+ }
84
+
85
+ /** Paragraph-mark run properties (`w:pPr/w:rPr`), distinct from any run's `rPr`. */
86
+ export function getParagraphMarkProperties(p) {
87
+ return getDirectWordChild(getDirectWordChild(p, 'pPr'), 'rPr');
88
+ }
89
+
90
+ /**
91
+ * Historical paragraph properties: the `w:pPrChange/w:pPr` snapshot. Explicit opt-in; shape matches
92
+ * `getCurrentParagraphProperties`. Returns null when the paragraph has no tracked property change.
93
+ */
94
+ export function getHistoricalParagraphProperties(p) {
95
+ const change = getDirectWordChild(getDirectWordChild(p, 'pPr'), 'pPrChange');
96
+ const snapshot = getDirectWordChild(change, 'pPr');
97
+ return snapshot ? readParagraphProps(snapshot) : null;
98
+ }
99
+
100
+ /** Historical run properties: the `w:rPrChange/w:rPr` snapshot, or null. */
101
+ export function getHistoricalRunProperties(r) {
102
+ const change = getDirectWordChild(getDirectWordChild(r, 'rPr'), 'rPrChange');
103
+ return getDirectWordChild(change, 'rPr');
104
+ }
105
+
106
+ /** All `w:p` elements at or under `root` (the paragraph itself when `root` is one). */
107
+ function paragraphsUnder(root) {
108
+ if (!root || root.nodeType !== 1) return [];
109
+ const out = isWordElement(root, 'p') ? [root] : [];
110
+ if (typeof root.getElementsByTagNameNS === 'function') {
111
+ out.push(...Array.from(root.getElementsByTagNameNS(NS_W, 'p')));
112
+ }
113
+ return out;
114
+ }
115
+
116
+ /**
117
+ * Current `w:numId` elements (direct `pPr/numPr/numId`) of every paragraph at or under `root`.
118
+ * Excludes `pPrChange` snapshots, so Reject still restores the snapshot numId.
119
+ */
120
+ export function getCurrentNumIdElements(root) {
121
+ const out = [];
122
+ for (const p of paragraphsUnder(root)) {
123
+ const el = getCurrentParagraphProperties(p).numPr?.numIdElement;
124
+ if (el) out.push(el);
125
+ }
126
+ return out;
127
+ }
128
+
129
+ /**
130
+ * Every `w:numId` element referenced anywhere under `root`, INCLUDING `pPrChange` snapshots. Explicitly
131
+ * historical-inclusive: used when a numbering part must define every id the content can ever reference.
132
+ */
133
+ export function getAllReferencedNumIdElements(root) {
134
+ if (!root || typeof root.getElementsByTagNameNS !== 'function') return [];
135
+ return Array.from(root.getElementsByTagNameNS(NS_W, 'numId'));
136
+ }