|
| 1 | +# `.docx` extraction |
| 2 | + |
| 3 | +What `review --file contract.docx` actually reads, and the two things a naive extractor silently throws away. |
| 4 | + |
| 5 | +## Word does not store list numbers |
| 6 | + |
| 7 | +A paragraph numbered `7.` in Word contains no `7` anywhere in its text. It carries only a pointer: |
| 8 | + |
| 9 | +```xml |
| 10 | +<w:p> |
| 11 | + <w:pPr><w:numPr><w:ilvl w:val="0"/><w:numId w:val="3"/></w:numPr></w:pPr> |
| 12 | + <w:r><w:t>Each party acknowledges …</w:t></w:r> |
| 13 | +</w:p> |
| 14 | +``` |
| 15 | + |
| 16 | +The visible number is computed at render time by walking `word/numbering.xml`: |
| 17 | + |
| 18 | +``` |
| 19 | +numId → abstractNumId → per-level { numFmt, lvlText, start } |
| 20 | +``` |
| 21 | + |
| 22 | +An extractor that reads only `w:t` nodes therefore produces a **silently unnumbered document**. A contract whose operative clauses are numbered 1–20 in Word arrives as prose with no numbers, and an in-body cross-reference to "Section 7" has nothing to point at. Nothing errors; the numbers simply never existed in the text being analyzed. |
| 23 | + |
| 24 | +The `textutil` fallback has the same loss — it renders numbered paragraphs as tab-indented bullets. |
| 25 | + |
| 26 | +## What we resolve |
| 27 | + |
| 28 | +`_extract_docx_text` walks `w:body` paragraphs in document order and prepends each one's computed number. |
| 29 | + |
| 30 | +| Supported | Notes | |
| 31 | +|---|---| |
| 32 | +| `numFmt` | `decimal`, `decimalZero`, `lowerLetter`, `upperLetter`, `lowerRoman`, `upperRoman`, `none` | |
| 33 | +| `lvlText` | `%1`–`%9` substitution, so multi-level `"%1.%2"` patterns render as `2.1` | |
| 34 | +| `start` / `w:startOverride` | Honoured per level | |
| 35 | +| `w:lvlOverride` | Per-level `numFmt` / `lvlText` overrides | |
| 36 | +| Level resets | A new item at level *N* restarts every deeper level | |
| 37 | +| Style-inherited numbering | `w:pStyle` → `w:basedOn` chain in `styles.xml`; contract templates frequently number through a style rather than on the paragraph | |
| 38 | +| `bullet` | Skipped — a bullet has no number, and that is not a failure | |
| 39 | + |
| 40 | +Counters are keyed by `(numId, ilvl)`, so two lists in the same document count independently. A nested `(a) (b)` exceptions list under its own `numId` does not disturb the outer `1. 2. 3.` clause numbering. |
| 41 | + |
| 42 | +## Paragraph structure |
| 43 | + |
| 44 | +The extractor joins paragraphs with newlines rather than flattening the document with `" ".join(...)`. |
| 45 | + |
| 46 | +This is not cosmetic. `locate_clause()` splits on lines to find the nearest heading above a match, `extract_clause_snippet()` splits on blank lines to anchor snippets to legal blocks, and `paragraph_index` counts lines. A flattened document reports `clause_heading: ""` and `paragraph_index: 1` for every finding — the `--why` evidence degrades to nothing while still looking well-formed. |
| 47 | + |
| 48 | +## Fail visible, not silent |
| 49 | + |
| 50 | +Every extraction reports whether numbering was resolved. `review` surfaces it as `coverage.numbering_resolved`: |
| 51 | + |
| 52 | +| Value | Meaning | |
| 53 | +|---|---| |
| 54 | +| `true` | Every numbered paragraph was resolved to its visible number. A document with no numbered paragraphs is also `true` — nothing to resolve is not a failure to resolve. | |
| 55 | +| `false` | `numbering.xml` is absent or unparseable, a `numFmt` isn't supported, or the `textutil` fallback was used. **The numbers you see in Word are not in this text.** | |
| 56 | +| `null` | Numbering isn't a concept for this input (`.txt`, `.pdf`, stdin, in-memory text). | |
| 57 | + |
| 58 | +`_read_any_text()` additionally returns a `numbering` record with `resolved`, `numbered_paragraphs`, and a human-readable `reason` when resolution failed. |
| 59 | + |
| 60 | +**Downstream consumers should degrade, not assert.** A tool that checks cross-references or numbering gaps must not report "broken cross-reference to Section 7" as an error when `numbering_resolved` is `false` — it never saw the numbers. Downgrade such findings to warnings and say why. |
| 61 | + |
| 62 | +## See also |
| 63 | + |
| 64 | +- [scoring.md](scoring.md) — the `coverage` block and the `needs_review` decision. |
| 65 | +- [policy.md](policy.md) — the two playbook shapes. |
| 66 | +- [../../SECURITY.md](../../SECURITY.md) — why `.docx` XML is parsed through `_safe_xml_fromstring` (entity-expansion defence). |
0 commit comments