bantamkit 0.27.0__py3-none-any.whl

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 (119) hide show
  1. bantamkit/__init__.py +32 -0
  2. bantamkit/agent.py +458 -0
  3. bantamkit/assets/contracts/default.yaml +90 -0
  4. bantamkit/assets/evals/devteam/manifest.yaml +351 -0
  5. bantamkit/assets/evals/devteam/repo/HISTORY.md +18 -0
  6. bantamkit/assets/evals/devteam/repo/README.md +12 -0
  7. bantamkit/assets/evals/devteam/repo/docs/architecture.md +17 -0
  8. bantamkit/assets/evals/devteam/repo/docs/runbook.md +10 -0
  9. bantamkit/assets/evals/devteam/repo/issues/142-settlement-timeout.md +23 -0
  10. bantamkit/assets/evals/devteam/repo/patches/0009-retry-budget.patch +38 -0
  11. bantamkit/assets/evals/devteam/repo/src/ledger/__init__.py +3 -0
  12. bantamkit/assets/evals/devteam/repo/src/ledger/config.py +35 -0
  13. bantamkit/assets/evals/devteam/repo/src/ledger/errors.py +13 -0
  14. bantamkit/assets/evals/devteam/repo/src/ledger/posting.py +12 -0
  15. bantamkit/assets/evals/devteam/repo/src/ledger/registry.py +7 -0
  16. bantamkit/assets/evals/devteam/repo/src/ledger/report.py +9 -0
  17. bantamkit/assets/evals/devteam/repo/src/ledger/retry.py +17 -0
  18. bantamkit/assets/evals/devteam/repo/src/ledger/settle.py +16 -0
  19. bantamkit/assets/evals/devteam/repo/src/ledger/validate.py +14 -0
  20. bantamkit/assets/evals/devteam/repo/tests/test_posting.py +13 -0
  21. bantamkit/assets/evals/devteam/repo/tests/test_settle.py +9 -0
  22. bantamkit/assets/evals/devteam/tasks/dt-error-contract.yaml +186 -0
  23. bantamkit/assets/evals/devteam/tasks/dt-handler-map.yaml +183 -0
  24. bantamkit/assets/evals/devteam/tasks/dt-patch-before-after.yaml +182 -0
  25. bantamkit/assets/evals/devteam/tasks/dt-retry-attempts.yaml +181 -0
  26. bantamkit/assets/evals/devteam/tasks/dt-settlement-config.yaml +185 -0
  27. bantamkit/assets/evals/devteam/tasks/dt-symbol-home.yaml +181 -0
  28. bantamkit/assets/evals/devteam/tasks/dt-trace-blame.yaml +182 -0
  29. bantamkit/assets/evals/devteam/tasks/dt-unread-key.yaml +180 -0
  30. bantamkit/assets/evals/document/tasks/doc-large-in-137.yaml +38 -0
  31. bantamkit/assets/evals/document/tasks/doc-large-in-359.yaml +44 -0
  32. bantamkit/assets/evals/document/tasks/doc-large-in-372.yaml +38 -0
  33. bantamkit/assets/evals/document/tasks/doc-large-out-11764.yaml +37 -0
  34. bantamkit/assets/evals/document/tasks/doc-large-out-4137.yaml +37 -0
  35. bantamkit/assets/evals/document/tasks/doc-large-out-8022.yaml +37 -0
  36. bantamkit/assets/evals/document/tasks/doc-small-137.yaml +37 -0
  37. bantamkit/assets/evals/document/tasks/doc-small-261.yaml +37 -0
  38. bantamkit/assets/evals/document/tasks/doc-small-388.yaml +37 -0
  39. bantamkit/assets/evals/fixtures/.gitkeep +0 -0
  40. bantamkit/assets/evals/fixtures/catalog.json +6 -0
  41. bantamkit/assets/evals/perturbations/task-completion.yaml +576 -0
  42. bantamkit/assets/evals/tasks/.gitkeep +0 -0
  43. bantamkit/assets/evals/tasks/extract-contact.yaml +14 -0
  44. bantamkit/assets/evals/tasks/extract-invoice.yaml +14 -0
  45. bantamkit/assets/evals/tasks/extract-order.yaml +15 -0
  46. bantamkit/assets/evals/tasks/extract-schedule.yaml +14 -0
  47. bantamkit/assets/evals/tasks/extract-versions.yaml +17 -0
  48. bantamkit/assets/evals/tasks/nav-prod-port.yaml +84 -0
  49. bantamkit/assets/evals/tasks/nav-release-bundle.yaml +87 -0
  50. bantamkit/assets/evals/tasks/recall-audit-retention.yaml +17 -0
  51. bantamkit/assets/evals/tasks/recall-cache-ttl.yaml +13 -0
  52. bantamkit/assets/evals/tasks/recall-db-port.yaml +17 -0
  53. bantamkit/assets/evals/tasks/recall-deploy.yaml +13 -0
  54. bantamkit/assets/evals/tasks/recall-env-endpoint.yaml +18 -0
  55. bantamkit/assets/evals/tasks/recall-oncall-rotation.yaml +21 -0
  56. bantamkit/assets/evals/tasks/recall-oncall.yaml +13 -0
  57. bantamkit/assets/evals/tasks/recall-org-quota.yaml +18 -0
  58. bantamkit/assets/evals/tasks/recall-owner.yaml +13 -0
  59. bantamkit/assets/evals/tasks/shop-basket-total.yaml +10 -0
  60. bantamkit/assets/evals/tasks/shop-cheapest.yaml +9 -0
  61. bantamkit/assets/evals/tasks/shop-compare.yaml +9 -0
  62. bantamkit/assets/evals/tasks/shop-gadget-value.yaml +9 -0
  63. bantamkit/assets/evals/tasks/shop-stock-total.yaml +9 -0
  64. bantamkit/assets/evals/tasks/shop-total.yaml +9 -0
  65. bantamkit/assets/profiles/default.yaml +31 -0
  66. bantamkit/assets/profiles/patient.yaml +31 -0
  67. bantamkit/assets/rubrics/.gitkeep +0 -0
  68. bantamkit/assets/rubrics/code-quality.yaml +20 -0
  69. bantamkit/assets/rubrics/grounded-completion.yaml +37 -0
  70. bantamkit/assets/rubrics/task-completion.yaml +28 -0
  71. bantamkit/assets/schemas/shiftwork-checkpoint.json +188 -0
  72. bantamkit/assets/skills/.gitkeep +0 -0
  73. bantamkit/assets/skills/file-graph.md +7 -0
  74. bantamkit/assets/skills/memory.md +35 -0
  75. bantamkit/assets/tools/.gitkeep +0 -0
  76. bantamkit/assets/tools/bantamkit_read.json +48 -0
  77. bantamkit/assets/tools/bantamkit_status.json +25 -0
  78. bantamkit/assets/tools/build_identity.json +17 -0
  79. bantamkit/assets/tools/document_list.json +12 -0
  80. bantamkit/assets/tools/document_read.json +31 -0
  81. bantamkit/assets/tools/file_graph.json +12 -0
  82. bantamkit/assets/tools/memory_compact.json +31 -0
  83. bantamkit/assets/tools/memory_recall.json +38 -0
  84. bantamkit/assets/tools/memory_save.json +61 -0
  85. bantamkit/assets/tools/shiftwork_clock_in.json +25 -0
  86. bantamkit/assets/tools/shiftwork_clock_out.json +60 -0
  87. bantamkit/assets/tools/shiftwork_status.json +25 -0
  88. bantamkit/assets/tools/skill_audit.json +70 -0
  89. bantamkit/assets/tools/validate_json.json +31 -0
  90. bantamkit/assets.py +67 -0
  91. bantamkit/budget.py +114 -0
  92. bantamkit/client.py +329 -0
  93. bantamkit/contract.py +522 -0
  94. bantamkit/criticreplay.py +3241 -0
  95. bantamkit/critique.py +301 -0
  96. bantamkit/docread.py +1744 -0
  97. bantamkit/evalrun.py +2003 -0
  98. bantamkit/eventlog.py +282 -0
  99. bantamkit/filegraph.py +218 -0
  100. bantamkit/loopguard.py +101 -0
  101. bantamkit/mcpreport.py +763 -0
  102. bantamkit/mcpserver.py +1334 -0
  103. bantamkit/memory/__init__.py +28 -0
  104. bantamkit/memory/__main__.py +291 -0
  105. bantamkit/memory/component.py +569 -0
  106. bantamkit/memory/divergence.py +744 -0
  107. bantamkit/memory/layers.py +257 -0
  108. bantamkit/memory/store.py +940 -0
  109. bantamkit/pdfread.py +1402 -0
  110. bantamkit/profile.py +46 -0
  111. bantamkit/shiftwork.py +212 -0
  112. bantamkit/skillaudit.py +853 -0
  113. bantamkit/statusline.py +313 -0
  114. bantamkit/structured.py +125 -0
  115. bantamkit/textutil.py +30 -0
  116. bantamkit-0.27.0.dist-info/METADATA +207 -0
  117. bantamkit-0.27.0.dist-info/RECORD +119 -0
  118. bantamkit-0.27.0.dist-info/WHEEL +4 -0
  119. bantamkit-0.27.0.dist-info/entry_points.txt +2 -0
bantamkit/docread.py ADDED
@@ -0,0 +1,1744 @@
1
+ """Deterministic text extraction, addressable by part and row, dispatched on what a file IS.
2
+
3
+ Standard library only — `zipfile` and `xml.etree` — so the runtime dependency list stays at
4
+ three packages. J10-PREP measured that this is sufficient: 13 of 13 real Excel- and
5
+ Word-produced files parsed, including a 37-sheet workbook.
6
+
7
+ **A suffix is a hint, and on a real corpus it is measurably a lie.** J25-PREP ran the J10
8
+ reader over the user's `~/Downloads` (34 files, 2026-08-20) and found two files whose names
9
+ do not describe their bytes: `ข้อความ….pdf` is a **`.docx`** (`PK\x03\x04`, and the archive
10
+ holds `word/document.xml`), and the one `.doc` is not the OLE2 binary the suffix promises but
11
+ a **MIME/MHTML Confluence export** whose first bytes are `Date: Wed, 19 Aug 2026`. Under
12
+ suffix dispatch both refused, and the reasons they gave were about their names. So `extract()`
13
+ now **sniffs the container** (`sniff()`, magic bytes plus, for a zip, its member list) and the
14
+ suffix is never consulted for a decision. `Document.kind` reports what the file is, not what
15
+ it is called.
16
+
17
+ The second half of the same rule: **a file this reader cannot read must refuse with a reason
18
+ that names what it actually is.** Returning empty text for a scanned PDF is the defect, not
19
+ the fallback — a caller cannot tell "the page held nothing" from "I could not see the page".
20
+ Every path out of `extract()` is either rows or a `DocumentReadError` naming the container.
21
+
22
+ Readable containers, and what reads them:
23
+
24
+ - `text` — nothing but a strict UTF-8 decode; a plain text file has no container to open.
25
+ It is listed FIRST because it is the bulk of the work: measured 2026-08-21 over
26
+ `~/Documents/Claude/Projects`, 43,629 of 49,555 files (88.04%) sniff as `text`, and
27
+ `extract` used to raise on every one of them because `text` was not a key in `_EXTRACTORS`.
28
+ "Needs no reader" is not the same as "is not readable", and a single entry point that a
29
+ program hands any file to must not answer a refusal for plain content.
30
+ - `xlsx`, `docx` — `zipfile` + `xml.etree`, unchanged; these are J10's committed measurements.
31
+ - `pdf` — `bantamkit.pdfread`, written for this program because J25-PREP measured every PDF
32
+ library and every command-line converter absent from this host and the dependency list is
33
+ fixed at three. One `Part` per page, so a page that is a picture can say so where its row
34
+ count is stated instead of rendering as an empty page. **A character whose meaning the file
35
+ does not state is never emitted** — a composite font with no `/ToUnicode` map addresses
36
+ glyphs, not characters, and decoding those anyway is the mojibake class above with a
37
+ different cause. Measured over the user's corpus at `6994f96`: 27 of 28 PDFs read, 514
38
+ characters refused across 6 of them, 0 files returned empty.
39
+ - `html`, `mhtml` — `email` + `html.parser`, still stdlib. MHTML is the format a "Save as .doc"
40
+ from Confluence or Word actually writes, and `email` decodes its quoted-printable parts
41
+ correctly where `/usr/bin/textutil` (measured, 2026-08-20) leaves the soft breaks in and
42
+ splits `signature` into `s= ignature`.
43
+ - `doc` (real OLE2) and `rtf` — `/usr/bin/textutil`, a **macOS built-in**, therefore
44
+ **probed at every call and refused by name when absent** (`textutil_path()`). A reader that
45
+ assumes it is a reader that is broken on Linux and in CI. This is the one extraction here
46
+ whose bytes are the host's rather than this module's, and that is declared, not hidden:
47
+ the byte-determinism promised below covers the stdlib kinds.
48
+
49
+ **Why this shape.** The extraction returns a `Document` of `Part`s, each holding a tuple of
50
+ already-rendered rows, and `page()` slices those rows. Three constraints forced it. (1) The
51
+ corpus does not fit the worker window — J10-PREP measured 34,465–172,736 tokens of extracted
52
+ text against `WORKER_NUM_CTX = 32768` — so "return the whole thing" is not an option and the
53
+ extraction has to be sliceable; the slice unit is the **row**, because a spreadsheet lookup is
54
+ answered by a row and a character range would cut one in half. (2) A lookup needs to know which
55
+ value sat in which column, so a row renders as positional tab-separated fields with **empty
56
+ cells preserved as empty fields** (the cell's `r` reference decides its column, never its
57
+ position in the XML) — flattening that drops gaps would silently shift every value left of an
58
+ answer. (3) Every downstream measurement is noise unless the same file gives the same bytes, so
59
+ nothing here is reformatted: a numeric cell emits the stored lexical form of `<v>` **verbatim**,
60
+ with no `float()` round-trip and therefore no repr drift across platforms; sheets come in
61
+ `xl/workbook.xml` declaration order resolved through `r:id`; rows come in sheet-XML order.
62
+
63
+ **Lossiness that is counted, not just documented.** J25-PREP measured this reader over the
64
+ user's own `~/Downloads`: `step test.xlsx` is 18.62 MB and the reader reported `4 parts, 28
65
+ rows, 27 bytes` and said nothing at all about the **56 embedded images, 18,590,162 bytes**
66
+ that are the file's actual content, nor that 3 of its 4 sheets hold no cell whatsoever, nor
67
+ that 26 of those 28 rows render as empty lines. Every one of those numbers was available and
68
+ none of them was said. That is `RB-P51`'s rule with a new subject: *a check that quietly
69
+ passes on data it cannot see is not the same as one that reports it read nothing.*
70
+
71
+ So an extraction now carries `Omission` records — `Document.omissions` for the package,
72
+ `Part.omissions` for one sheet — and each is a **count** with the fact behind it, never an
73
+ adjective. They are deliberately not sentences: `contract.document_manifest` renders them,
74
+ this layer only counts. `Omission.subject` is one of the `OMIT_*` tokens below, and a renderer
75
+ that meets a token it does not know must still print the count rather than drop it.
76
+
77
+ Deliberate lossiness, stated so downstream does not have to guess, and — where a count exists
78
+ — reported as an `Omission` rather than left to the docstring:
79
+
80
+ - **Number formats are not applied.** A date cell stores a serial number and renders as that
81
+ serial number: the timesheet's `Month / Year:` really is `46235.0` in the file. Rendering it
82
+ as a date would require a format engine and a locale, i.e. a non-deterministic dependency on
83
+ how the file was authored, and it would break the byte-determinism the rest of this module
84
+ rests on. **The fix is disclosure, not conversion**: `OMIT_NUMBER_FORMAT` names the columns,
85
+ the cell count, and the file's own `numFmt` **format code**, so a caller that wants the date
86
+ has everything needed to compute it and this reader has guessed nothing. Detection is by
87
+ format code alone (`_is_date_format`) — the only thing in the file that distinguishes a
88
+ serial date from a plain number — and it is confined to **date and time** formats, where the
89
+ rendered digits share nothing with the value a person sees. Currency, percent and accounting
90
+ formats are not reported: they change how digits are punctuated, not what they are.
91
+ - **Images and embedded objects are not rendered at all**, and `OMIT_MEDIA` says how many and
92
+ how many bytes. The package-level count comes from the archive's own member list so it
93
+ cannot silently be zero; the per-part count follows the relationship graph from the sheet to
94
+ its drawings, and a picture shown on two sheets is counted once in the package total and
95
+ once in each part, so the part counts need not sum to it.
96
+ - **Undeclared rows are not materialised.** A sheet with data in row 1 and row 10000 renders
97
+ two rows, not ten thousand. Consequently a row offset is an offset into the *rendering*, not
98
+ a spreadsheet row number, and `page()` reports it as such.
99
+ - **A declared row with no cell value renders as an empty line and still counts.** That is why
100
+ `OMIT_BLANK_ROWS` exists and dropped `.docx` paragraphs get no such record: a blank row
101
+ inflates `row_count`, the manifest's own headline number, so a model reading "28 rows" is
102
+ being told something false about what it can page through. A dropped blank paragraph removes
103
+ nothing a lookup could have used and is not counted, only documented.
104
+ - **Empty paragraphs are dropped** from `.docx`; real Word documents are full of them and they
105
+ carry nothing a lookup can use.
106
+ - **Tabs and newlines inside a cell or paragraph become spaces**, so one rendered row is
107
+ exactly one line and row-slicing cannot cut a line in half.
108
+ - **HTML markup is discarded, not rendered.** Script and style bodies are dropped, block
109
+ elements end a row and `<td>`/`<th>` separate fields with a tab, so an HTML table renders
110
+ in the same tab-separated shape a worksheet row does. No CSS is applied and no layout is
111
+ reconstructed; a `<div>` grid will not come back as columns.
112
+ - **A PDF page is not a spreadsheet row and is not claimed to be one.** A PDF holds glyphs at
113
+ coordinates; `pdfread` groups them into rows by baseline and orders them by x so that
114
+ `page()` has a slice unit. A two-column page interleaves and a table does not come back as
115
+ columns. `OMIT_UNREAD_PAGE` and `OMIT_UNMAPPED` are what keep the difference between "this
116
+ page held nothing" and "this page was not readable" from collapsing into silence, and
117
+ `OMIT_UNREAD_PAGE` carries the four counts that say WHICH of the two it is — the same four
118
+ `_pdf_refusal` chooses between one grain up.
119
+ - **A plain-text file is rendered LINE for line and nothing is reformatted** — indentation
120
+ stays (77.9% of the corpus's text files carry an indented line), tabs stay (they are the same
121
+ field separator a worksheet row renders with), and blank lines stay, so for this one
122
+ container a row offset really is a line offset. What a text file loses is only what this
123
+ reader could not read: `OMIT_UNREAD_TAIL` counts bytes past the point the file stops being
124
+ text — `sniff` votes on 4,096 bytes and `extract` meets the rest — and `OMIT_SIZE_CAP`
125
+ counts bytes past `TEXT_MAX_BYTES`. Neither is ever a silent truncation, and no character is
126
+ ever decoded with `errors="replace"`.
127
+ - **An empty extraction from a text container is a refusal, not a document.** `.xlsx` keeps
128
+ its existing behaviour — a declared-but-empty sheet is a real part with no rows, and J10's
129
+ rows are committed measurements — but a `.doc`, `.rtf`, `.html` or `.mhtml` that yields no
130
+ text at all is a file this reader failed to read, and it says so.
131
+
132
+ `Part.text_bytes` is the size of the **extracted text**, never the file size. J10-PREP measured
133
+ the ratio between the two ranging 0.0033×–5.48× across 13 real files, a spread of 1660×, so any
134
+ caller that budgets from `stat().st_size` is wrong by up to three orders of magnitude.
135
+ """
136
+
137
+ from __future__ import annotations
138
+
139
+ import codecs
140
+ import email
141
+ import email.message
142
+ import email.policy
143
+ import html.parser
144
+ import os
145
+ import posixpath
146
+ import re
147
+ import subprocess
148
+ import types
149
+ import zipfile
150
+ import zlib
151
+ from dataclasses import dataclass
152
+ from pathlib import Path
153
+ from xml.etree import ElementTree as ET
154
+
155
+ from bantamkit import pdfread
156
+ from bantamkit.client import BantamError
157
+
158
+ NS_S = "{http://schemas.openxmlformats.org/spreadsheetml/2006/main}"
159
+ NS_W = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}"
160
+ NS_R = "{http://schemas.openxmlformats.org/officeDocument/2006/relationships}"
161
+
162
+ # Read directly, by this module, with nothing outside the standard library.
163
+ SUPPORTED = ("text", "xlsx", "docx", "pdf", "html", "mhtml")
164
+ # Read only if the host has `/usr/bin/textutil`. Probed, never assumed — see `textutil_path`.
165
+ TEXTUTIL_SUPPORTED = ("doc", "rtf")
166
+ TEXTUTIL = "/usr/bin/textutil"
167
+ TEXTUTIL_TIMEOUT = 60
168
+ DEFAULT_ROW_LIMIT = 50
169
+ # The page ceilings every pager over this module shares: `evalrun`'s `document_read` and
170
+ # `mcpserver`'s `bantamkit_read` (job43) slice with the same three numbers, and they live here
171
+ # so that neither has to import the other to agree. The byte ceiling is the READER's, not the
172
+ # loop's: `page()` stops on a row boundary and reports the shortfall out of band, whereas a
173
+ # loop that cut an over-budget observation in band would hand the model a page that lies
174
+ # about where it stopped.
175
+ PAGE_MAX_ROWS = 200
176
+ PAGE_MAX_BYTES = 3072
177
+ # The most of ONE plain-text file this reader will materialise. Plain text is the only
178
+ # container here whose size no structure bounds, and it is now the majority of what `extract`
179
+ # is handed, so "read the whole thing" needs a stated ceiling rather than a hope.
180
+ #
181
+ # It is not hypothetical. MEASURED 2026-08-21 over `~/Documents/Claude/Projects`,
182
+ # `~/Documents` and `~/Downloads` (pruned at node_modules, .venv, venv, .git, __pycache__,
183
+ # dist, build, .next, target, site-packages, .cache): 116,529 files sniff as `text` and the
184
+ # largest is a 764,017,864-byte `.sql` dump, with a 134,000,722-byte one behind it. Reading
185
+ # either whole is a different failure from refusing it.
186
+ #
187
+ # 16 MiB, because that is measured to cover the corpus and to cost little. Every one of the
188
+ # 43,629 text files under `~/Documents/Claude/Projects` is smaller than it (largest 1,474,568
189
+ # bytes, 11x under) and so are 116,527 of the 116,529 across all three roots; materialising
190
+ # 16 MiB of log measured 58.2 MiB peak and 0.04 s, against 232.1 MiB for 64 MiB and roughly
191
+ # 2.7 GB for that 764 MB file read whole. Named, not inlined, so a bar can vary it and prove
192
+ # the shortfall is REPORTED rather than the content quietly truncated.
193
+ TEXT_MAX_BYTES = 16 * 1024 * 1024
194
+
195
+ # The subjects an `Omission` can carry. Stable tokens, because a renderer switches on them.
196
+ OMIT_MEDIA = "media"
197
+ OMIT_BLANK_ROWS = "blank-rows"
198
+ OMIT_NUMBER_FORMAT = "number-format"
199
+ # A page that produced no row at all: a scan or graphics-only page that ran no text-showing
200
+ # operator; a page whose every character came from a font with no character map; a page whose
201
+ # every character mapped and is WHITESPACE; or one whose operators placed no character either
202
+ # way. It is a page nobody could read rather than a page holding nothing, and that difference
203
+ # is the whole reason this token exists — so which of the four it is travels in `facts`.
204
+ OMIT_UNREAD_PAGE = "unread-page"
205
+ # Characters this reader met and refused to guess at: codes shown through a font that carries
206
+ # no `/ToUnicode` map, i.e. glyph indices. Dropped from the rows and counted here.
207
+ OMIT_UNMAPPED = "unmapped-text"
208
+ # Bytes of a plain-text file that lie past the point where the file stops BEING text: a
209
+ # sequence no UTF-8 continuation can complete, or a C0 code that is binary framing. `sniff`
210
+ # votes on a 4096-byte head, so `extract` routinely meets bytes that verdict never saw, and
211
+ # those bytes can contradict it. The prefix that IS text is content; the rest is COUNTED.
212
+ OMIT_UNREAD_TAIL = "unread-tail"
213
+ # Bytes past `TEXT_MAX_BYTES`, the ceiling this reader states for one plain-text file. Not a
214
+ # property of the file -- a limit this reader imposes -- which is exactly why it is declared
215
+ # as a count instead of applied in silence.
216
+ OMIT_SIZE_CAP = "size-cap"
217
+ # A worksheet cell whose own `r` reference could not place it: not letters-then-digits, or a
218
+ # column past the last one the format has. The cell's TEXT is in the rows, at its XML
219
+ # position; what the rows do not carry is the column the file asked for. Counted per reason,
220
+ # with the columns it landed in, exactly as `number-format` is counted per format code.
221
+ OMIT_UNPLACED_CELL = "unplaced-cell"
222
+
223
+
224
+ class DocumentReadError(BantamError):
225
+ """Extraction failed. The message names what was seen, never just the format's own error.
226
+
227
+ A reader that lets a bare `BadZipFile` reach the agent has told it nothing it can act on.
228
+ """
229
+
230
+
231
+ @dataclass(frozen=True)
232
+ class Omission:
233
+ """Something the file holds that the rows do not carry, as a COUNT and the fact behind it.
234
+
235
+ Deliberately not a sentence — this is Layer 1, and `contract.document_manifest` is what
236
+ turns it into one. Every field is a measurement:
237
+
238
+ - `subject` — one of the `OMIT_*` tokens. A renderer switches on it and MUST still print
239
+ an unknown token's count; silently dropping an omission is the defect this class exists
240
+ to close.
241
+ - `count` — how many. Never an estimate, never a flag.
242
+ - `size` — bytes those things occupy in the container, `0` when that is not knowable.
243
+ - `where` — the column letters it applies to, in column order; `()` for the whole part.
244
+ - `what` — the exact machine fact: the `numFmt` format code, the MIME types. What a caller
245
+ needs to act on the omission itself rather than merely be told about it.
246
+ - `facts` — named counts, in a fixed order, for a subject that needs MORE THAN ONE number
247
+ to say what it is. `count`/`size`/`what` are three slots; a subject whose reason is a
248
+ choice between four cases does not fit in them, and cramming it in was measured to be
249
+ illegible rather than merely tight: the first reader to consult an `unread-page` record
250
+ read its `count` (the images on the page) as a number of PAGES and published a coverage
251
+ figure that was wrong. A number that has to be decoded is a number that will be. Every
252
+ entry is a name and an integer, and `what` is rendered FROM them so the two cannot
253
+ disagree.
254
+ """
255
+
256
+ subject: str
257
+ count: int
258
+ size: int = 0
259
+ where: tuple[str, ...] = ()
260
+ what: str = ""
261
+ facts: tuple[tuple[str, int], ...] = ()
262
+
263
+ def as_dict(self) -> dict:
264
+ """The primitive form the contract layer takes. `contract.py` may not import this."""
265
+ return {
266
+ "subject": self.subject,
267
+ "count": self.count,
268
+ "size": self.size,
269
+ "where": list(self.where),
270
+ "what": self.what,
271
+ "facts": dict(self.facts),
272
+ }
273
+
274
+
275
+ @dataclass(frozen=True)
276
+ class Part:
277
+ """One addressable unit: a worksheet, or a `.docx` body. `rows` are rendered lines."""
278
+
279
+ name: str
280
+ index: int
281
+ rows: tuple[str, ...]
282
+ omissions: tuple[Omission, ...] = ()
283
+
284
+ @property
285
+ def row_count(self) -> int:
286
+ return len(self.rows)
287
+
288
+ @property
289
+ def text_bytes(self) -> int:
290
+ """UTF-8 bytes of this part's full rendering. EXTRACTED size, not file size."""
291
+ return len("\n".join(self.rows).encode())
292
+
293
+
294
+ # An index-shaped part key: what `Document.part` hands to `int()`. ASCII on purpose.
295
+ _INDEX_KEY = re.compile(r"-?[0-9]+")
296
+
297
+
298
+ @dataclass(frozen=True)
299
+ class Document:
300
+ kind: str
301
+ parts: tuple[Part, ...]
302
+ omissions: tuple[Omission, ...] = ()
303
+
304
+ @property
305
+ def text_bytes(self) -> int:
306
+ return sum(p.text_bytes for p in self.parts)
307
+
308
+ def part(self, key: str | int) -> Part:
309
+ """Resolve by exact sheet name, else by 0-based index. Names win over numeric keys."""
310
+ for p in self.parts:
311
+ if p.name == key:
312
+ return p
313
+ # The guard has to be EXACTLY what `int()` below accepts, or the ValueError leaks:
314
+ # `"--1".lstrip("-").isdigit()` was True and `int("--1")` raised, and `"²".isdigit()`
315
+ # is True while `int("²")` raises too. Measured on the wire (job43 R4): the model saw
316
+ # `isError: invalid literal for int() with base 10: '--1'` instead of the unknown-part
317
+ # sentence. An index is an optionally-negative run of ASCII digits — the same rule the
318
+ # Node port applies — and anything else is a NAME this document does not have.
319
+ # ... and `int()` refuses one more shape the regex admits: CPython caps a decimal
320
+ # string at 4300 digits (`sys.int_info.str_digits_check_threshold`) and raises
321
+ # `ValueError` past it. Measured (job43 F2): a 4301-digit key reached the wire as
322
+ # `isError: Exceeds the limit (4300 digits) for integer string conversion`. No
323
+ # document has that many parts, so a key `int()` rejects is simply not an index.
324
+ if isinstance(key, int) or (isinstance(key, str) and _INDEX_KEY.fullmatch(key)):
325
+ try:
326
+ index = int(key)
327
+ except ValueError:
328
+ index = -1
329
+ if 0 <= index < len(self.parts):
330
+ return self.parts[index]
331
+ names = ", ".join(repr(p.name) for p in self.parts)
332
+ raise DocumentReadError(f"no part {key!r}; this document has {len(self.parts)}: {names}")
333
+
334
+
335
+ @dataclass(frozen=True)
336
+ class Page:
337
+ """A row slice of one part. `next_offset` is None exactly when the part is exhausted."""
338
+
339
+ part: str
340
+ offset: int
341
+ rows: tuple[str, ...]
342
+ total_rows: int
343
+ next_offset: int | None
344
+ truncated_bytes: int = 0
345
+
346
+ @property
347
+ def text(self) -> str:
348
+ return "\n".join(self.rows)
349
+
350
+
351
+ def _clean(text: str) -> str:
352
+ """One rendered row is one line: no embedded newline or tab may survive a cell value."""
353
+ return text.replace("\t", " ").replace("\r", " ").replace("\n", " ")
354
+
355
+
356
+ # --------------------------------------------------------------- what a file IS, from bytes
357
+
358
+
359
+ @dataclass(frozen=True)
360
+ class Container:
361
+ """The container a file's own bytes declare it to be. `kind` decides; `what` explains.
362
+
363
+ `named` is the suffix, carried only so a refusal can say the two disagree. Nothing
364
+ dispatches on it.
365
+ """
366
+
367
+ kind: str
368
+ what: str
369
+ named: str
370
+
371
+ @property
372
+ def suffix_lies(self) -> bool:
373
+ """True when the name promises a container the bytes do not deliver.
374
+
375
+ A suffix this reader has no expectation for (`.bin`, none at all) is not a lie.
376
+ """
377
+ expected = SUFFIX_KINDS.get(self.named)
378
+ return expected is not None and expected != self.kind
379
+
380
+
381
+ # The canonical suffixes of each sniffable container. Used ONLY to detect disagreement.
382
+ SUFFIX_KINDS = {
383
+ "xlsx": "xlsx",
384
+ "docx": "docx",
385
+ "pptx": "pptx",
386
+ "pdf": "pdf",
387
+ "doc": "doc",
388
+ "xls": "doc",
389
+ "ppt": "doc",
390
+ "rtf": "rtf",
391
+ "htm": "html",
392
+ "html": "html",
393
+ "mht": "mhtml",
394
+ "mhtml": "mhtml",
395
+ "eml": "mhtml",
396
+ "mov": "isobmff",
397
+ "mp4": "isobmff",
398
+ "m4a": "isobmff",
399
+ "m4v": "isobmff",
400
+ "png": "png",
401
+ "jpg": "jpeg",
402
+ "jpeg": "jpeg",
403
+ "gif": "gif",
404
+ "gz": "gzip",
405
+ "zip": "zip",
406
+ }
407
+
408
+ # The phrase each container is named by, so a refusal and a sniff cannot drift apart.
409
+ _WHAT = {
410
+ "doc": "an OLE2 compound file (the pre-2007 Office binary)",
411
+ "rtf": "an RTF document",
412
+ "woff2": "a WOFF2 web font",
413
+ "woff": "a WOFF web font",
414
+ "zstd": "a zstd-compressed stream",
415
+ "xz": "an xz-compressed stream",
416
+ "sqlite": "a SQLite 3 database",
417
+ "wasm": "a WebAssembly module",
418
+ }
419
+
420
+ # Leading magic that settles a container outright, longest prefix first.
421
+ _MAGIC = (
422
+ (b"%PDF-", "pdf"),
423
+ (b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1", "doc"),
424
+ (b"{\\rtf", "rtf"),
425
+ (b"\x89PNG\r\n\x1a\n", "png"),
426
+ (b"\xff\xd8\xff", "jpeg"),
427
+ (b"GIF8", "gif"),
428
+ (b"\x1f\x8b", "gzip"),
429
+ (b"Rar!\x1a\x07", "rar"),
430
+ (b"7z\xbc\xaf\x27\x1c", "7z"),
431
+ (b"%!PS", "postscript"),
432
+ (b"\x7fELF", "elf"),
433
+ (b"BZh", "bzip2"),
434
+ # ADDED 2026-08-22 after counting what actually lands in `unknown` on the real corpus
435
+ # under ~/Downloads and ~/Documents/Claude/Projects. Of 5,036 files this reader called
436
+ # "not a recognised container", 977 carry one of these signatures: 818 WOFF2, 146 zstd,
437
+ # 7 SQLite, 6 PE. Naming them does not make any of them READABLE — a font carries no
438
+ # prose and the refusal stands either way — but "it is a WOFF2 web font" tells a caller
439
+ # what it is holding, and "not a recognised container; it starts with b'wOF2...'" tells
440
+ # them to go and look it up. The refusal is the product here, so its accuracy is the
441
+ # feature.
442
+ (b"SQLite format 3\x00", "sqlite"),
443
+ (b"\xfd7zXZ\x00", "xz"),
444
+ # THE FONT SIGNATURES CARRY THEIR FLAVOUR, AND THE FIRST DRAFT OF THIS TABLE DID NOT.
445
+ # `wOF2` alone is four ASCII characters, and this table is consulted BEFORE
446
+ # `_text_kind`: a note beginning "wOF2 is a font container format" was classified
447
+ # `woff2` and refused as a font. The node written to keep the "four bytes is long
448
+ # enough" claim honest is the thing that refuted it, on the same afternoon it was
449
+ # written. Including the sfnt flavour makes the signature eight bytes with four of
450
+ # them non-printable, which prose cannot reach by accident. Measured: all 818 WOFF2
451
+ # files on the real corpus carry flavour 00010000, so nothing is lost by requiring it.
452
+ (b"wOF2\x00\x01\x00\x00", "woff2"),
453
+ (b"wOF2OTTO", "woff2"),
454
+ (b"wOFF\x00\x01\x00\x00", "woff"),
455
+ (b"wOFFOTTO", "woff"),
456
+ (b"\x28\xb5\x2f\xfd", "zstd"),
457
+ (b"\x00asm", "wasm"),
458
+ # DELIBERATELY ABSENT. `OTTO` on its own, for the reason above and with no corpus file
459
+ # to justify the risk: "OTTOman" is a word. `MZ`, the DOS/PE header, worth 6 files
460
+ # here: two bytes is short enough that ordinary prose starts with them. TrueType's
461
+ # `\x00\x01\x00\x00` bare, which collides with too much else — none of the 2,347
462
+ # four-zero-byte files here are fonts.
463
+ )
464
+ # A zip is not a format, it is a box. Its member list says what is in the box.
465
+ _ZIP_MEMBERS = (
466
+ ("xl/workbook.xml", "xlsx"),
467
+ ("word/document.xml", "docx"),
468
+ ("ppt/presentation.xml", "pptx"),
469
+ )
470
+ # How much of a file `sniff` looks at. Named, not inlined, so a bar can vary it and prove the
471
+ # verdict does not move with it -- the value is an efficiency choice and nothing more.
472
+ _HEAD_BYTES = 4096
473
+ _HTML_HEAD = re.compile(r"<(?:!doctype\s+html|html\b|head\b|body\b)", re.IGNORECASE)
474
+ _MIME_HEADER = re.compile(r"^[A-Za-z][A-Za-z0-9\-]*:[ \t]")
475
+
476
+
477
+ def _zip_kind(path: Path, head: bytes) -> Container:
478
+ named = path.suffix.lower().lstrip(".")
479
+ try:
480
+ with zipfile.ZipFile(path) as zf:
481
+ names = zf.namelist()
482
+ members = set(names)
483
+ # `_read`, not `zf.read`: an encrypted `mimetype` is the encrypted sentence naming
484
+ # that member, never "damaged" (review round 3 measured the latter on an .odt).
485
+ mimetype = ""
486
+ if "mimetype" in members:
487
+ mimetype = _read(zf, "mimetype", path).decode(errors="replace")
488
+ except (zipfile.BadZipFile, OSError, KeyError):
489
+ return Container(
490
+ "zip", f"a truncated or damaged zip archive (it starts with {head[:8]!r})", named
491
+ )
492
+ for member, kind in _ZIP_MEMBERS:
493
+ if member in members:
494
+ return Container(kind, f"an OOXML package holding {member}", named)
495
+ if mimetype.startswith("application/vnd.oasis.opendocument"):
496
+ return Container("odf", f"an OpenDocument package ({mimetype})", named)
497
+ sample = ", ".join(sorted(members)[:5]) or "(empty archive)"
498
+ return Container("zip", f"a zip archive that is no OOXML package; it holds: {sample}", named)
499
+
500
+
501
+ # The C0 codes a plain-text document legitimately carries: tab, the newline family, and ESC.
502
+ # ESC is the ECMA-48 introducer for the ANSI colour sequences that ordinary terminal logs are
503
+ # written with, and a coloured log is a document a reader can hand back. Every other C0 code --
504
+ # NUL first among them -- is binary framing, and its presence is taken as evidence these bytes
505
+ # are not text. This is a rule about which codes mean what, not a tolerance to be tuned.
506
+ _TEXT_CONTROLS = frozenset({0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x1B})
507
+
508
+
509
+ def _is_binary_control(ch: str) -> bool:
510
+ code = ord(ch)
511
+ return code < 0x20 and code not in _TEXT_CONTROLS
512
+
513
+
514
+ def _decode_head(head: bytes) -> str | None:
515
+ """Decode a TRUNCATED sample, tolerating a character the sample cut in half.
516
+
517
+ Where the read stopped is a fact about this reader, never a fact about the file, so it must
518
+ not be allowed to decide the verdict. An incremental decoder holds an incomplete final
519
+ character back instead of failing on it; only a genuinely invalid sequence -- one that no
520
+ continuation could complete -- returns None. Raising the sample size would only move the
521
+ boundary; this removes its vote.
522
+ """
523
+ decoder = codecs.getincrementaldecoder("utf-8")()
524
+ # A boundary exists exactly when the read hit its cap, and `final` is lowered exactly
525
+ # there. A file shorter than the cap was read whole, so a dangling partial character in it
526
+ # is real damage rather than an artefact of sampling, and still refuses.
527
+ try:
528
+ return decoder.decode(head, final=len(head) < _HEAD_BYTES)
529
+ except UnicodeDecodeError:
530
+ return None
531
+
532
+
533
+ def _text_kind(head: bytes, named: str) -> Container | None:
534
+ """MHTML, HTML, or plain text — the three that have no magic number to check."""
535
+ text = _decode_head(head)
536
+ if text is None:
537
+ return None
538
+ stripped = text.lstrip(" \t\r\n")
539
+ lines = [line for line in stripped.splitlines() if line.strip()]
540
+ if lines and _MIME_HEADER.match(lines[0]):
541
+ block = "\n".join(lines[:20]).lower()
542
+ if "mime-version:" in block or "content-type:" in block:
543
+ return Container("mhtml", "a MIME message / MHTML web archive", named)
544
+ if _HTML_HEAD.search(stripped[:2048]):
545
+ return Container("html", "an HTML document", named)
546
+ if not any(_is_binary_control(ch) for ch in text):
547
+ return Container("text", "plain text in no document container", named)
548
+ return None
549
+
550
+
551
+ def sniff(path: str | Path) -> Container:
552
+ """What `path` IS. Magic bytes, then a zip's member list. The suffix is never consulted.
553
+
554
+ This is the whole of the "do not trust the suffix" rule, in one function, so that every
555
+ caller — the extractor and the refusal alike — reads the file the same way.
556
+ """
557
+ path = Path(path)
558
+ if path.is_dir():
559
+ raise DocumentReadError(f"{path} is a directory, not a document")
560
+ if not path.exists():
561
+ raise DocumentReadError(f"no such file: {path}")
562
+ named = path.suffix.lower().lstrip(".")
563
+ with path.open("rb") as handle:
564
+ head = handle.read(_HEAD_BYTES)
565
+ if not head:
566
+ return Container("empty", "an empty file (0 bytes)", named)
567
+ if head[:2] == b"PK":
568
+ return _zip_kind(path, head)
569
+ for magic, kind in _MAGIC:
570
+ if head.startswith(magic):
571
+ if kind == "pdf":
572
+ version = head[1:8].decode("latin-1").strip()
573
+ return Container("pdf", f"a PDF document ({version})", named)
574
+ return Container(kind, _WHAT.get(kind, f"a {kind} file"), named)
575
+ if head[4:8] == b"ftyp":
576
+ brand = head[8:12].decode("latin-1").strip() or "?"
577
+ return Container(
578
+ "isobmff", f"an ISO base-media container, brand {brand!r} (video/audio)", named
579
+ )
580
+ guessed = _text_kind(head, named)
581
+ if guessed is not None:
582
+ return guessed
583
+ return Container("unknown", f"not a recognised container; it starts with {head[:12]!r}", named)
584
+
585
+
586
+ def _refuse(path: Path, container: Container, remedy: str) -> DocumentReadError:
587
+ """The one refusal sentence, and it names the CONTENT before it names anything else."""
588
+ size = path.stat().st_size
589
+ lie = ""
590
+ if container.suffix_lies:
591
+ lie = f"; its name says .{container.named}, which its bytes do not"
592
+ return DocumentReadError(
593
+ f"cannot read {path.name}: it is {container.what}, {size} bytes on disk{lie}. {remedy}"
594
+ )
595
+
596
+
597
+ def _unsupported_remedy() -> str:
598
+ direct = ", ".join(SUPPORTED[:-1]) + f" and {SUPPORTED[-1]}"
599
+ extra = ", ".join(TEXTUTIL_SUPPORTED[:-1]) + f" and {TEXTUTIL_SUPPORTED[-1]}"
600
+ return f"this reader reads {direct} directly, and {extra} through {TEXTUTIL}"
601
+
602
+
603
+ def _open(path: Path) -> zipfile.ZipFile:
604
+ if not path.exists():
605
+ raise DocumentReadError(f"no such file: {path}")
606
+ try:
607
+ return zipfile.ZipFile(path)
608
+ except zipfile.BadZipFile:
609
+ head = path.open("rb").read(8)
610
+ raise DocumentReadError(
611
+ f"{path.name} is not a zip archive, so it is not an OOXML document; "
612
+ f"it starts with {head!r} ({path.stat().st_size} bytes on disk)"
613
+ ) from None
614
+
615
+
616
+ # Why a tolerant read of an optional member (`.rels`, `styles.xml`) yields nothing rather
617
+ # than raising: malformed, absent, unreadable, encrypted (`RuntimeError`), unsupported method
618
+ # (`NotImplementedError`, a `RuntimeError`), a lying checksum or a broken deflate stream.
619
+ _UNREADABLE_OPTIONAL = (
620
+ ET.ParseError, KeyError, OSError, RuntimeError, zipfile.BadZipFile, zlib.error,
621
+ ) # fmt: skip
622
+
623
+
624
+ def _read(zf: zipfile.ZipFile, name: str, path: Path) -> bytes:
625
+ """One member this reader cannot do without, or a refusal in the reader's own words.
626
+
627
+ Two ways `ZipFile.read` refuses. A name that is not in the archive is a `KeyError`. A
628
+ member whose general-purpose flag bit 0 is set is `RuntimeError("File 'word/document.xml'
629
+ is encrypted, password required for extraction")` — and until job43 G1 that one crossed
630
+ the MCP wire as an `isError` frame carrying zipfile's text, on BOTH runtimes (measured on
631
+ `tests/data/docread/encrypted-member.docx`). The sentence names the member and the fact,
632
+ and nothing zipfile said: the Node `ZipReader` reads the same flag and must print the
633
+ same words.
634
+
635
+ Two more, found by review round 3 and measured on the reviewer's own files. A member whose
636
+ compression method `zipfile` has no decoder for (9, deflate64) is a `NotImplementedError`
637
+ — a `RuntimeError` subclass, so until H1 it printed the ENCRYPTED sentence; it is caught
638
+ first and names the method number instead. A member whose bytes do not check out is a
639
+ `BadZipFile` (`Bad CRC-32 for file 'word/document.xml'`) or a `zlib.error` (`Error -3
640
+ while decompressing data: invalid block type`), and both reached the wire as `isError`;
641
+ the damaged sentence carries the library's phrase in parentheses because the two are
642
+ different facts about the file (a stored checksum that lies, a deflate stream that is
643
+ not one), and it is the only part of the sentence the Node port cannot print from the
644
+ same words — the ruling in docs/porting.md quotes it.
645
+ """
646
+ try:
647
+ return zf.read(name)
648
+ except KeyError:
649
+ sample = ", ".join(sorted(zf.namelist())[:8]) or "(empty archive)"
650
+ raise DocumentReadError(
651
+ f"{path.name} is a zip but has no {name}; it contains: {sample}"
652
+ ) from None
653
+ except NotImplementedError:
654
+ method = zf.getinfo(name).compress_type
655
+ raise DocumentReadError(
656
+ f"{path.name} is a zip but its {name} uses compression method {method}, "
657
+ "which this reader cannot decompress"
658
+ ) from None
659
+ except RuntimeError:
660
+ raise DocumentReadError(
661
+ f"{path.name} is a zip but its {name} is encrypted, "
662
+ "so this reader cannot read it without a password"
663
+ ) from None
664
+ except (zipfile.BadZipFile, zlib.error) as exc:
665
+ raise DocumentReadError(
666
+ f"{path.name} is a zip but its {name} is damaged ({exc}), "
667
+ "so this reader cannot read it"
668
+ ) from None
669
+
670
+
671
+ def _parse(data: bytes, member: str, path: Path) -> ET.Element:
672
+ """Parse one part this reader cannot do without, or refuse in the reader's own words.
673
+
674
+ A bare `&` in a `<t>` run is enough to make expat raise `ET.ParseError`, and until
675
+ job43 F2 that exception crossed the MCP wire as an `isError` frame carrying expat's
676
+ text (measured). The sentence deliberately carries NO line, column or parser phrase:
677
+ the Node port walks the XML with its own hand-written scanner, and a sentence built
678
+ from expat's diagnostics is one the two runtimes could never print identically.
679
+ `_rel_targets` and `_date_formats` are not routed through here on purpose — a broken
680
+ rels or styles part costs an omission or a date format, never the rows.
681
+ """
682
+ try:
683
+ return ET.fromstring(data)
684
+ except ET.ParseError:
685
+ raise DocumentReadError(
686
+ f"{path.name} is a zip but its {member} is not well-formed XML, "
687
+ "so this reader cannot parse it"
688
+ ) from None
689
+
690
+
691
+ # ------------------------------------------- what an OOXML package holds that no row carries
692
+
693
+ # Any member under a directory named `media` or `embeddings`: pictures, OLE objects, fonts
694
+ # for an embedded chart. Taken from the archive's own member list, so the package-level count
695
+ # cannot silently be zero even if the relationship graph below is unreadable.
696
+ _MEDIA_MEMBER = re.compile(r"(?:^|/)(?:media|embeddings)/[^/]+$")
697
+
698
+
699
+ def _media_index(zf: zipfile.ZipFile) -> dict[str, int]:
700
+ """Every embedded file in the package, mapped to its UNCOMPRESSED size."""
701
+ return {
702
+ info.filename: info.file_size
703
+ for info in zf.infolist()
704
+ if not info.is_dir() and _MEDIA_MEMBER.search(info.filename)
705
+ }
706
+
707
+
708
+ def _rel_targets(zf: zipfile.ZipFile, member: str, members: set[str]) -> list[str]:
709
+ """The in-package parts `member`'s `.rels` points at, resolved to archive paths.
710
+
711
+ External and hyperlink targets are dropped: they are not in the package, so they are not
712
+ something this reader failed to render. A `.rels` that will not parse yields nothing
713
+ rather than raising — a readable sheet must not become unreadable because its
714
+ relationship graph is malformed, and the package-level count is computed from the member
715
+ list instead, so nothing goes unreported.
716
+ """
717
+ rels = posixpath.join(posixpath.dirname(member), "_rels", posixpath.basename(member) + ".rels")
718
+ if rels not in members:
719
+ return []
720
+ try:
721
+ tree = ET.fromstring(zf.read(rels))
722
+ except _UNREADABLE_OPTIONAL:
723
+ return []
724
+ out = []
725
+ for rel in tree:
726
+ target = rel.get("Target") or ""
727
+ if not target or rel.get("TargetMode") == "External" or "://" in target:
728
+ continue
729
+ if target.startswith("/"):
730
+ out.append(target.lstrip("/"))
731
+ else:
732
+ out.append(posixpath.normpath(posixpath.join(posixpath.dirname(member), target)))
733
+ return out
734
+
735
+
736
+ def _anchored_media(zf: zipfile.ZipFile, member: str, media: dict[str, int]) -> dict[str, int]:
737
+ """The embedded files reachable from one part, one hop and two.
738
+
739
+ Two hops is what a worksheet needs: the sheet points at `xl/drawings/drawingN.xml` and the
740
+ DRAWING points at `xl/media/imageN.png`. One hop alone catches a sheet's own OLE objects.
741
+ """
742
+ members = set(zf.namelist())
743
+ reached: dict[str, int] = {}
744
+ for first in _rel_targets(zf, member, members):
745
+ if first in media:
746
+ reached[first] = media[first]
747
+ continue
748
+ for second in _rel_targets(zf, first, members):
749
+ if second in media:
750
+ reached[second] = media[second]
751
+ return reached
752
+
753
+
754
+ # ECMA-376 18.8.30: the built-in number formats that are dates or times. Their codes are fixed
755
+ # by the standard, so naming them here is quoting a spec, not guessing at a file.
756
+ _BUILTIN_DATE_FORMATS = {
757
+ 14: "mm-dd-yy",
758
+ 15: "d-mmm-yy",
759
+ 16: "d-mmm",
760
+ 17: "mmm-yy",
761
+ 18: "h:mm AM/PM",
762
+ 19: "h:mm:ss AM/PM",
763
+ 20: "h:mm",
764
+ 21: "h:mm:ss",
765
+ 22: "m/d/yy h:mm",
766
+ 45: "mm:ss",
767
+ 46: "[h]:mm:ss",
768
+ 47: "mmss.0",
769
+ }
770
+ # 27-36 and 50-58 are the East Asian date built-ins. The standard makes them dates but leaves
771
+ # the code locale-dependent, so this reader names the id and refuses to invent a code for it.
772
+ _LOCALE_DATE_IDS = frozenset(range(27, 37)) | frozenset(range(50, 59))
773
+ # Everything in a format code that is a literal rather than a field: a bracketed section
774
+ # (`[$-409]`, `[Red]`, `[h]`), a quoted run (`"$"`, `" kg"`), a backslash escape (`\-`).
775
+ _FORMAT_LITERAL = re.compile(r'\[[^\]]*\]|"[^"]*"|\\.')
776
+
777
+
778
+ def _is_date_format(code: str) -> bool:
779
+ """Is this `numFmt` code a date or time format?
780
+
781
+ The cell's number format is the ONLY thing in an `.xlsx` that separates a serial date from
782
+ a plain number — `46235` is both, and nothing else in the file disambiguates them. So this
783
+ is the whole of the detection, and it is why the reader discloses rather than converts:
784
+ the answer here decides whether to say something, never what value to render.
785
+
786
+ Literals are removed first, because a currency format carries `"$"` and an accounting one
787
+ carries `\\(`, and a naive scan for `d` or `m` in the raw code would call both dates. Only
788
+ the first `;`-section is examined: the later sections are the negative/zero/text branches
789
+ and a date format does not use them for a different type.
790
+ """
791
+ bare = _FORMAT_LITERAL.sub("", code).split(";")[0].lower()
792
+ return any(ch in bare for ch in "ymdhs")
793
+
794
+
795
+ def _date_formats(zf: zipfile.ZipFile) -> tuple[str, ...]:
796
+ """`cellXfs` index -> the date/time format code at that style, `""` when it is not one.
797
+
798
+ A cell's `s` attribute indexes `cellXfs`; that entry's `numFmtId` is either a built-in or
799
+ points into the file's own `<numFmts>`. Both are resolved here so the caller only has to
800
+ ask "does this style render a date".
801
+
802
+ DECLARED GAP (J25-D2). A `xl/styles.xml` that will not parse yields no formats and
803
+ therefore no disclosure, silently — which is the very failure this module was changed to
804
+ end, one level down. Unlike media there is no member-list fallback to fall back ON: the
805
+ formats exist nowhere else in the package, so there is no count to report. Refusing the
806
+ whole workbook is worse (the cells are readable and their values are right; only the
807
+ reason a number looks like `46235` is lost). It is left as a known limit rather than
808
+ papered over, and it is not hypothetical: a fixture with an unescaped `"` in a format code
809
+ made a test in `test_docread.py` pass for exactly this reason until a mutation run caught
810
+ it.
811
+ """
812
+ if "xl/styles.xml" not in zf.namelist():
813
+ return ()
814
+ try:
815
+ root = ET.fromstring(zf.read("xl/styles.xml"))
816
+ except _UNREADABLE_OPTIONAL:
817
+ return ()
818
+ custom = {}
819
+ for node in root.iter(NS_S + "numFmt"):
820
+ try:
821
+ custom[int(node.get("numFmtId") or -1)] = node.get("formatCode") or ""
822
+ except ValueError:
823
+ continue
824
+ cell_xfs = root.find(NS_S + "cellXfs")
825
+ out: list[str] = []
826
+ for xf in cell_xfs if cell_xfs is not None else ():
827
+ try:
828
+ fmt_id = int(xf.get("numFmtId") or 0)
829
+ except ValueError:
830
+ out.append("")
831
+ continue
832
+ if fmt_id in custom:
833
+ code = custom[fmt_id]
834
+ out.append(code if _is_date_format(code) else "")
835
+ elif fmt_id in _BUILTIN_DATE_FORMATS:
836
+ out.append(_BUILTIN_DATE_FORMATS[fmt_id])
837
+ elif fmt_id in _LOCALE_DATE_IDS:
838
+ out.append(f"built-in numFmtId {fmt_id} (locale-dependent date)")
839
+ else:
840
+ out.append("")
841
+ return tuple(out)
842
+
843
+
844
+ def _letter(index: int) -> str:
845
+ """0 -> `A`. The inverse of `_column`, so an omission can name the column a lookup uses."""
846
+ letters = ""
847
+ index += 1
848
+ while index:
849
+ index, rem = divmod(index - 1, 26)
850
+ letters = chr(65 + rem) + letters
851
+ return letters
852
+
853
+
854
+ _CELL_REF = re.compile(r"([A-Za-z]+)[0-9]+")
855
+ # ECMA-376 gives a worksheet 16,384 columns, the last of them `XFD`. A reference past that
856
+ # does not name a column of any workbook, so this reader will not build a row wide enough to
857
+ # reach one. THE FORMAT'S NUMBER, not a limit invented here, which is why it is spelled as
858
+ # the last column's name rather than as a round figure someone liked.
859
+ XLSX_MAX_COLUMNS = 16384
860
+ # Four letters is already 18,278 (`AAAA`), past `XFD` whatever the letters are. Checking the
861
+ # LENGTH before the arithmetic is what keeps a megabyte of letters from being turned into a
862
+ # megabyte-long integer on the way to being refused.
863
+ _MAX_COLUMN_LETTERS = 3
864
+ # Why a cell's own reference could not place it. Fixed strings, never the reference itself:
865
+ # `r` is whatever the file says, and an omission that echoed it would carry the file's bytes
866
+ # into the manifest with no bound at all.
867
+ UNPLACED_SHAPE = "the column of a cell whose reference is not letters then digits"
868
+ UNPLACED_RANGE = "the column of a cell past XFD, the last column the format has"
869
+
870
+
871
+ def _column(ref: str | None, fallback: int) -> tuple[int, str]:
872
+ """`B7` -> `(1, "")`. The cell's own reference decides its column; XML order is a fallback.
873
+
874
+ A reference is ASCII letters then ASCII digits naming a column the format has, or this
875
+ reader cannot place it and says WHY. Review round 3 measured `r="ß1"`: `str.isalpha`
876
+ accepted the ß, `ord("ß".upper())` — `"SS"`, two code points — raised `TypeError`, and
877
+ the frame crossed the MCP wire as `isError` while the Node port read the sheet. The match
878
+ is on the reference AS WRITTEN, not on its uppercase: `"ß1"` uppercases to `"SS1"`, which
879
+ would be column 486 on one runtime and something else on every other, and a column number
880
+ that depends on a Unicode case table is not a fact about the workbook.
881
+
882
+ AN ANSWER AND NOT A RAISE, which is review round 4 (M2). Round 3 spelled the refusal as a
883
+ `DocumentReadError` out of a function `_sheet_rows` does not catch, so ONE cell the reader
884
+ could not place refused the entire workbook — measured on a two-sheet fixture where sheet
885
+ `Good` is clean and sheet `Bad` holds one cell `r="1"`: no manifest, no parts, the clean
886
+ sheet unreachable. `r="1"`, `r="A"` and `r="B7 "` all read before round 3. The strictness
887
+ was right and its price was not: the cell keeps its text and takes its XML position, which
888
+ is where it sat before round 3 and is the same position on both runtimes, and the column
889
+ it did not get is disclosed as an `Omission` rather than charged to the whole document.
890
+
891
+ THE CEILING is review round 4 (M5). Round 3 made this function stricter about the SHAPE of
892
+ a reference and left the resulting index unbounded: measured, a 1,755-byte xlsx whose one
893
+ cell is `r="ZZZZZ1"` produced a 12,356,630-byte row, 7,041x the file. Row width is now
894
+ bounded by the format at `XLSX_MAX_COLUMNS` fields however many bytes the file spends
895
+ asking for more.
896
+ """
897
+ if not ref:
898
+ return fallback, ""
899
+ match = _CELL_REF.fullmatch(ref)
900
+ if match is None:
901
+ return fallback, UNPLACED_SHAPE
902
+ letters = match.group(1)
903
+ if len(letters) > _MAX_COLUMN_LETTERS:
904
+ return fallback, UNPLACED_RANGE
905
+ index = 0
906
+ for char in letters.upper():
907
+ index = index * 26 + (ord(char) - 64)
908
+ if index > XLSX_MAX_COLUMNS:
909
+ return fallback, UNPLACED_RANGE
910
+ return index - 1, ""
911
+
912
+
913
+ def _shared_strings(zf: zipfile.ZipFile, path: Path) -> list[str]:
914
+ """TRAP 1. A cell with `t="s"` holds an INDEX here, not a literal."""
915
+ if "xl/sharedStrings.xml" not in zf.namelist():
916
+ return []
917
+ out = []
918
+ member = "xl/sharedStrings.xml"
919
+ for si in _parse(_read(zf, member, path), member, path):
920
+ runs = []
921
+ for child in si: # direct <t>, or <r><t> runs. <rPh> phonetics are skipped.
922
+ if child.tag == NS_S + "t":
923
+ runs.append(child.text or "")
924
+ elif child.tag == NS_S + "r":
925
+ runs.extend(sub.text or "" for sub in child if sub.tag == NS_S + "t")
926
+ out.append("".join(runs))
927
+ return out
928
+
929
+
930
+ def _cell_text(cell: ET.Element, shared: list[str]) -> str:
931
+ kind = cell.get("t")
932
+ if kind == "inlineStr": # TRAP 2. The other spelling: text lives in the sheet itself.
933
+ node = cell.find(NS_S + "is")
934
+ return "" if node is None else "".join(t.text or "" for t in node.iter(NS_S + "t"))
935
+ value = cell.find(NS_S + "v")
936
+ raw = "" if value is None or value.text is None else value.text
937
+ if kind == "s":
938
+ try:
939
+ return shared[int(raw)]
940
+ except (ValueError, IndexError):
941
+ raise DocumentReadError(
942
+ f"cell {cell.get('r')} indexes shared string {raw!r}, "
943
+ f"but the table has {len(shared)} entries"
944
+ ) from None
945
+ if kind == "b":
946
+ return "TRUE" if raw == "1" else "FALSE"
947
+ return raw # numbers, cached formula strings (`str`), errors (`e`): stored form, verbatim
948
+
949
+
950
+ def _sheet_rows(
951
+ root: ET.Element, shared: list[str], date_styles: tuple[str, ...] = ()
952
+ ) -> tuple[tuple[str, ...], tuple[Omission, ...]]:
953
+ """The rendered rows of one sheet, and a count of what the rendering did not carry.
954
+
955
+ The omissions are gathered in the same pass that renders, never by a second scan: a count
956
+ derived from a different walk of the XML can disagree with the rows it claims to describe,
957
+ and a disclosure that disagrees with the thing it discloses is worse than none.
958
+
959
+ A cell `_column` cannot place is one of those omissions and NOT a refusal (review round 4,
960
+ M2): it keeps its text at its XML position and loses only the column the file asked for.
961
+ Counted per reason and rendered after the format codes, so the order of this tuple is
962
+ blank rows, then number formats by code, then unplaced cells by reason.
963
+ """
964
+ rows = []
965
+ blank = 0
966
+ dated: dict[str, dict[int, int]] = {}
967
+ unplaced: dict[str, dict[int, int]] = {}
968
+ for row in root.iter(NS_S + "row"):
969
+ cells: dict[int, str] = {}
970
+ for position, cell in enumerate(row.iter(NS_S + "c")):
971
+ text = _clean(_cell_text(cell, shared))
972
+ if not text:
973
+ continue
974
+ column, unplaceable = _column(cell.get("r"), position)
975
+ cells[column] = text
976
+ if unplaceable:
977
+ unplaced.setdefault(unplaceable, {})
978
+ unplaced[unplaceable][column] = unplaced[unplaceable].get(column, 0) + 1
979
+ if cell.get("t") in (None, "n"): # a stored number; anything else is not a serial
980
+ try:
981
+ code = date_styles[int(cell.get("s") or 0)]
982
+ except (ValueError, IndexError):
983
+ code = ""
984
+ if code:
985
+ dated.setdefault(code, {})
986
+ dated[code][column] = dated[code].get(column, 0) + 1
987
+ width = max(cells) + 1 if cells else 0
988
+ line = "\t".join(cells.get(i, "") for i in range(width))
989
+ if not line:
990
+ blank += 1
991
+ rows.append(line)
992
+ omissions = []
993
+ if blank:
994
+ omissions.append(Omission(OMIT_BLANK_ROWS, blank))
995
+ for code in sorted(dated):
996
+ columns = dated[code]
997
+ omissions.append(
998
+ Omission(
999
+ OMIT_NUMBER_FORMAT,
1000
+ sum(columns.values()),
1001
+ where=tuple(_letter(c) for c in sorted(columns)),
1002
+ what=code,
1003
+ )
1004
+ )
1005
+ for reason in sorted(unplaced):
1006
+ columns = unplaced[reason]
1007
+ omissions.append(
1008
+ Omission(
1009
+ OMIT_UNPLACED_CELL,
1010
+ sum(columns.values()),
1011
+ where=tuple(_letter(c) for c in sorted(columns)),
1012
+ what=reason,
1013
+ )
1014
+ )
1015
+ return tuple(rows), tuple(omissions)
1016
+
1017
+
1018
+ def _worksheet_targets(zf: zipfile.ZipFile, path: Path) -> list[tuple[str, str | None]]:
1019
+ """TRAP 3. Sheet NAMES are in workbook.xml, sheet FILES are behind an `r:id` in the rels.
1020
+
1021
+ Guessing `sheet1.xml` from the first `<sheet>` element is wrong on real workbooks: the
1022
+ declaration order and the part numbering are independent.
1023
+ """
1024
+ workbook = _parse(_read(zf, "xl/workbook.xml", path), "xl/workbook.xml", path)
1025
+ rels = {}
1026
+ member = "xl/_rels/workbook.xml.rels"
1027
+ for rel in _parse(_read(zf, member, path), member, path):
1028
+ target = rel.get("Target") or ""
1029
+ if target.startswith("/"):
1030
+ rels[rel.get("Id")] = target.lstrip("/")
1031
+ else:
1032
+ rels[rel.get("Id")] = posixpath.normpath(posixpath.join("xl", target))
1033
+ out = []
1034
+ for order, sheet in enumerate(workbook.iter(NS_S + "sheet")):
1035
+ rid = sheet.get(NS_R + "id")
1036
+ target = rels.get(rid) if rid else f"xl/worksheets/sheet{order + 1}.xml"
1037
+ out.append((sheet.get("name") or f"sheet{order + 1}", target))
1038
+ return out
1039
+
1040
+
1041
+ def _media_omission(media: dict[str, int]) -> tuple[Omission, ...]:
1042
+ """`OMIT_MEDIA` for a set of embedded members, or nothing at all when there are none.
1043
+
1044
+ `what` carries the distinct extensions so a caller can tell 56 screenshots from one
1045
+ embedded font without this layer deciding which of those matters.
1046
+ """
1047
+ if not media:
1048
+ return ()
1049
+ kinds = sorted({posixpath.splitext(name)[1].lstrip(".").lower() or "?" for name in media})
1050
+ return (Omission(OMIT_MEDIA, len(media), size=sum(media.values()), what=", ".join(kinds)),)
1051
+
1052
+
1053
+ def extract_xlsx(path: str | Path) -> Document:
1054
+ path = Path(path)
1055
+ with _open(path) as zf:
1056
+ shared = _shared_strings(zf, path)
1057
+ date_styles = _date_formats(zf)
1058
+ media = _media_index(zf)
1059
+ parts = []
1060
+ for index, (name, target) in enumerate(_worksheet_targets(zf, path)):
1061
+ if target is None:
1062
+ raise DocumentReadError(f"sheet {name!r} has no resolvable worksheet part")
1063
+ sheet = _parse(_read(zf, target, path), target, path)
1064
+ rows, omissions = _sheet_rows(sheet, shared, date_styles)
1065
+ omissions = _media_omission(_anchored_media(zf, target, media)) + omissions
1066
+ parts.append(Part(name=name, index=index, rows=rows, omissions=omissions))
1067
+ return Document(kind="xlsx", parts=tuple(parts), omissions=_media_omission(media))
1068
+
1069
+
1070
+ def extract_docx(path: str | Path) -> Document:
1071
+ path = Path(path)
1072
+ with _open(path) as zf:
1073
+ root = _parse(_read(zf, "word/document.xml", path), "word/document.xml", path)
1074
+ media = _media_index(zf)
1075
+ anchored = _media_omission(_anchored_media(zf, "word/document.xml", media))
1076
+ rows = []
1077
+ for para in root.iter(NS_W + "p"):
1078
+ runs = []
1079
+ for node in para.iter():
1080
+ if node.tag == NS_W + "t":
1081
+ runs.append(node.text or "")
1082
+ elif node.tag in (NS_W + "tab", NS_W + "br", NS_W + "cr"):
1083
+ runs.append(" ")
1084
+ text = _clean("".join(runs)).strip()
1085
+ if text: # blank paragraphs are dropped: real Word files are full of them
1086
+ rows.append(text)
1087
+ body = Part(name="document", index=0, rows=tuple(rows), omissions=anchored)
1088
+ return Document(kind="docx", parts=(body,), omissions=_media_omission(media))
1089
+
1090
+
1091
+ # ------------------------------------------------------------------- HTML and MIME (stdlib)
1092
+
1093
+ # Markup that ends a row, and markup whose text is not the document's text.
1094
+ _BLOCK_TAGS = frozenset(
1095
+ "p div br hr li tr h1 h2 h3 h4 h5 h6 table tbody thead blockquote pre section article "
1096
+ "header footer nav aside figure figcaption dt dd ul ol form fieldset title".split()
1097
+ )
1098
+ _FIELD_TAGS = frozenset({"td", "th"})
1099
+ _SILENT_TAGS = frozenset({"script", "style", "template", "noscript"})
1100
+
1101
+
1102
+ # A decimal numeric character reference `html.unescape` would hand to `int()` as MORE than
1103
+ # 4300 digits. Greedy over the digits and the optional `;` exactly as `html._charref` is, so
1104
+ # the span replaced is the span `unescape` would have consumed.
1105
+ _LONG_DECIMAL_CHARREF = re.compile(r"&#([0-9]{4301,})(;?)")
1106
+
1107
+
1108
+ def _cap_charrefs(text: str) -> str:
1109
+ """Rewrite every decimal character reference `int()` would refuse in one TEXT chunk.
1110
+
1111
+ `HTMLParser(convert_charrefs=True)` calls `html.unescape` on each run of text between
1112
+ tags, and `unescape` does `int(digits)` on a decimal reference; CPython refuses a decimal
1113
+ string over 4300 digits with a `ValueError` — the same cap `mcpserver._ArgMetadata`
1114
+ documents for a `part` key. Measured (job43 G1, `tests/data/docread/charref-4301-
1115
+ digits.html`): `&#` + 4301 × `1` + `;` raised out of `extract_html` and reached the
1116
+ model as an `isError` frame; the Node port, whose `parseInt` overflows to `Infinity`,
1117
+ rendered U+FFFD and read the page.
1118
+
1119
+ The rewrite reproduces what `unescape` computes for a number it CAN parse. Leading zeros
1120
+ are stripped first, because `&#0…065;` is `A` to both `unescape` and `parseInt` however
1121
+ many zeros precede it; what is left is either short enough to hand back to `unescape`
1122
+ unchanged, or a number past U+10FFFF, for which `unescape` — and `parseInt`'s `Infinity`
1123
+ — is U+FFFD. Hexadecimal references are not capped: `int(s, 16)` has no digit limit.
1124
+ It runs on the chunk the parser is about to unescape and on nothing else — see
1125
+ `_HtmlText.goahead` — so a tag, an attribute or the content of a CDATA element never
1126
+ sees it.
1127
+ """
1128
+
1129
+ def cap(match: re.Match[str]) -> str:
1130
+ digits = match.group(1).lstrip("0") or "0"
1131
+ if len(digits) <= 4300:
1132
+ return f"&#{digits}{match.group(2)}"
1133
+ return "\ufffd"
1134
+
1135
+ return _LONG_DECIMAL_CHARREF.sub(cap, text)
1136
+
1137
+
1138
+ def _with_bounded_unescape(name: str):
1139
+ """`HTMLParser.<name>`, as the library wrote it, with `unescape` bound to the capped one.
1140
+
1141
+ The two methods that call `unescape` (`goahead` on text, `parse_starttag` on attribute
1142
+ values) look it up in `html.parser`'s globals; a copy of the code object with one entry
1143
+ of that namespace replaced is the same loop calling the same helpers, and nothing else
1144
+ in the process — no other parser, not `html.unescape` itself — sees the change.
1145
+ """
1146
+ method = getattr(html.parser.HTMLParser, name)
1147
+ return types.FunctionType(
1148
+ method.__code__,
1149
+ {**vars(html.parser), "unescape": lambda text: html.unescape(_cap_charrefs(text))},
1150
+ name,
1151
+ method.__defaults__,
1152
+ )
1153
+
1154
+
1155
+ class _HtmlText(html.parser.HTMLParser):
1156
+ """HTML to rows. Block markup ends a row, `<td>`/`<th>` separate fields with a tab.
1157
+
1158
+ The tab is the point: the rest of this module renders a row as tab-separated fields, so
1159
+ an HTML table arrives in the same shape a worksheet row does and a caller does not need
1160
+ to know which container a row came from.
1161
+ """
1162
+
1163
+ # The parser's own `goahead` loop with ONE name rebound: the `unescape` it calls on each
1164
+ # text chunk (and `parse_starttag` on each attribute value) is bounded. Everything
1165
+ # else — which chunks are text, that the content of `xmp`/`iframe`/`noembed`/
1166
+ # `noframes`/`script`/`style` is CDATA and never unescaped, how a tag is delimited —
1167
+ # stays the library's. Two alternatives were
1168
+ # measured and refused (H1): rewriting long references over the whole markup before
1169
+ # parsing turned `&#<4301 digits>;` inside `<xmp>` into U+FFFD where the parser keeps
1170
+ # the digits (review round 3); `convert_charrefs=False` with bounded `handle_charref`/
1171
+ # `handle_entityref` changes the library's chunking — `&#65b` is handed over as `&#`
1172
+ # and `65b`, and an `&#` with no `;` anywhere after it makes the parser emit the rest
1173
+ # of the document, tags included, as data at `close()`.
1174
+ goahead = _with_bounded_unescape("goahead")
1175
+ parse_starttag = _with_bounded_unescape("parse_starttag") # attribute values, likewise
1176
+
1177
+ def __init__(self) -> None:
1178
+ super().__init__(convert_charrefs=True)
1179
+ self.rows: list[str] = []
1180
+ self._fields: list[str] = []
1181
+ self._silent = 0
1182
+
1183
+ def handle_starttag(self, tag: str, attrs: object) -> None:
1184
+ if tag in _SILENT_TAGS:
1185
+ self._silent += 1
1186
+ elif tag in _FIELD_TAGS:
1187
+ self._fields.append("")
1188
+ elif tag in _BLOCK_TAGS:
1189
+ self._flush()
1190
+
1191
+ def handle_endtag(self, tag: str) -> None:
1192
+ if tag in _SILENT_TAGS:
1193
+ self._silent = max(0, self._silent - 1)
1194
+ elif tag in _BLOCK_TAGS:
1195
+ self._flush()
1196
+
1197
+ def handle_data(self, data: str) -> None:
1198
+ if self._silent:
1199
+ return
1200
+ text = " ".join(data.split())
1201
+ if not text:
1202
+ return
1203
+ if self._fields:
1204
+ self._fields[-1] = f"{self._fields[-1]} {text}".strip()
1205
+ else:
1206
+ self._fields.append(text)
1207
+
1208
+ def _flush(self) -> None:
1209
+ # Each field is cleaned BEFORE the join, never after: `_clean` maps a tab to a space,
1210
+ # so cleaning the joined line would erase the field separator this row is built from.
1211
+ line = "\t".join(_clean(field).strip() for field in self._fields).strip("\t").strip()
1212
+ self._fields = []
1213
+ if line:
1214
+ self.rows.append(line)
1215
+
1216
+ def close(self) -> None: # noqa: A003 - HTMLParser's own name
1217
+ super().close()
1218
+ self._flush()
1219
+
1220
+
1221
+ def html_rows(markup: str) -> tuple[str, ...]:
1222
+ """Rendered rows of one HTML fragment. A pure function of the string: no I/O, no host."""
1223
+ parser = _HtmlText()
1224
+ parser.feed(markup)
1225
+ parser.close()
1226
+ return tuple(parser.rows)
1227
+
1228
+
1229
+ def _decoded_body(part: email.message.Message) -> str:
1230
+ """A part's bytes as text. A `charset` label decides HOW they are read, never WHETHER.
1231
+
1232
+ `LookupError` alone was not the class. Review round 4 (M4) swept every alias in
1233
+ `encodings.aliases` through this function: 22 labels raise `LookupError` (`base64`,
1234
+ `bz2`, `hex`, `mbcs` off Windows — bytes-to-bytes codecs and absent ones), and THREE
1235
+ raise a `UnicodeError` instead — `undefined`, `idna` and `punycode`, codecs that exist,
1236
+ are reached, and refuse. `errors="replace"` does not save them: those three never consult
1237
+ the handler, `idna` raises `UnicodeError("Unsupported error handling replace")` on being
1238
+ handed one at all. Before this, each escaped `extract` uncaught and crossed the MCP wire
1239
+ as `isError` while the Node port read the same archive.
1240
+
1241
+ Both are caught, and the fallback is the one an unknown label already took, so a label
1242
+ whose codec raises and a label with no codec give the same answer rather than two.
1243
+ """
1244
+ payload = part.get_payload(decode=True)
1245
+ if payload is None:
1246
+ return ""
1247
+ charset = part.get_content_charset() or "utf-8"
1248
+ try:
1249
+ return payload.decode(charset, errors="replace")
1250
+ except (LookupError, UnicodeError):
1251
+ return payload.decode("utf-8", errors="replace")
1252
+
1253
+
1254
+ def extract_mhtml(path: str | Path) -> Document:
1255
+ """A MIME message / MHTML archive: every text part, in message order.
1256
+
1257
+ `email` is what makes this correct rather than approximate. The one real file J25-PREP
1258
+ found is `Content-Transfer-Encoding: quoted-printable`, and `get_payload(decode=True)`
1259
+ rejoins its soft line breaks; `/usr/bin/textutil` on the same file (measured, 2026-08-20)
1260
+ returned the bytes unchanged — it read the archive as plain text — and a later
1261
+ `-format html` pass left `signature` split as `s= ignature`.
1262
+ """
1263
+ path = Path(path)
1264
+ with path.open("rb") as handle:
1265
+ message = email.message_from_binary_file(handle, policy=email.policy.default)
1266
+ bodies: list[tuple[str, str]] = []
1267
+ skipped: dict[str, int] = {}
1268
+ skipped_bytes = 0
1269
+ for part in message.walk():
1270
+ if part.get_content_maintype() == "multipart":
1271
+ continue # a container, not content: its children are walked in their own right
1272
+ subtype = part.get_content_subtype()
1273
+ if part.get_content_maintype() != "text" or subtype not in ("html", "plain"):
1274
+ # MEASURED, 2026-08-20: the one real `.doc` on the user's corpus is an MHTML whose
1275
+ # four `application/octet-stream` parts are 174,918 bytes this reader renders as
1276
+ # nothing. Dropping them is right; dropping them SILENTLY is the defect.
1277
+ payload = part.get_payload(decode=True)
1278
+ skipped[part.get_content_type()] = skipped.get(part.get_content_type(), 0) + 1
1279
+ skipped_bytes += len(payload) if payload else 0
1280
+ continue
1281
+ body = _decoded_body(part)
1282
+ if body.strip():
1283
+ bodies.append((subtype, body))
1284
+ parts = []
1285
+ for index, (subtype, body) in enumerate(bodies):
1286
+ rows = html_rows(body) if subtype == "html" else _plain_rows(body)
1287
+ name = "document" if len(bodies) == 1 else f"part{index}"
1288
+ parts.append(Part(name=name, index=index, rows=rows))
1289
+ omissions = ()
1290
+ if skipped:
1291
+ omissions = (
1292
+ Omission(
1293
+ OMIT_MEDIA,
1294
+ sum(skipped.values()),
1295
+ size=skipped_bytes,
1296
+ what=", ".join(sorted(skipped)),
1297
+ ),
1298
+ )
1299
+ return _nonempty(
1300
+ Document(kind="mhtml", parts=tuple(parts), omissions=omissions),
1301
+ path,
1302
+ "no text/html or text/plain part carried any text",
1303
+ )
1304
+
1305
+
1306
+ def extract_html(path: str | Path) -> Document:
1307
+ path = Path(path)
1308
+ raw = path.read_bytes()
1309
+ markup = raw.decode("utf-8", errors="replace")
1310
+ doc = Document(kind="html", parts=(Part(name="document", index=0, rows=html_rows(markup)),))
1311
+ return _nonempty(doc, path, "its markup carried no text outside script and style")
1312
+
1313
+
1314
+ def _plain_rows(text: str) -> tuple[str, ...]:
1315
+ return tuple(line for line in (_clean(raw).strip() for raw in text.splitlines()) if line)
1316
+
1317
+
1318
+ def _nonempty(doc: Document, path: Path, why: str) -> Document:
1319
+ """Empty text from a text container is a failure to read, and it must say so.
1320
+
1321
+ `.xlsx` is deliberately exempt: a declared-but-empty sheet is a real part with no rows and
1322
+ J10's row counts are committed measurements. Here there is no such thing — a `.doc` that
1323
+ renders nothing is a `.doc` this reader did not read.
1324
+ """
1325
+ if any(part.rows for part in doc.parts):
1326
+ return doc
1327
+ raise DocumentReadError(
1328
+ f"cannot read {path.name}: it is a {doc.kind} container but {why}, so this reader has "
1329
+ "no text for it — it is not an empty document"
1330
+ )
1331
+
1332
+
1333
+ # --------------------------------------------------------------- plain text, in no container
1334
+
1335
+ # The C0 codes that are binary framing, as a pattern over a whole file. DERIVED from
1336
+ # `_TEXT_CONTROLS` rather than re-listed, so one set decides what a control code means here
1337
+ # and in `sniff` alike and the two cannot drift; `test_the_whole_file_scan_agrees_with_the_head
1338
+ # _rule` is what holds that. A regex because this runs over as much as `TEXT_MAX_BYTES`, where
1339
+ # `_is_binary_control` runs per character.
1340
+ _BINARY_CONTROL = re.compile(
1341
+ "[" + "".join(re.escape(chr(code)) for code in range(0x20) if code not in _TEXT_CONTROLS) + "]"
1342
+ )
1343
+
1344
+
1345
+ def _text_rows(text: str) -> tuple[str, ...]:
1346
+ """One LINE of the file is one row, verbatim. No strip, no tab flattening, no line dropped.
1347
+
1348
+ Deliberately not `_plain_rows`, and the difference is measured rather than stylistic.
1349
+ `_plain_rows` renders a `text/plain` MIME part, where the source was a mail body; here the
1350
+ source is a file whose own bytes are the document, and the module's byte-determinism rule
1351
+ ("nothing here is reformatted") applies to it directly:
1352
+
1353
+ - **Leading whitespace is content.** MEASURED 2026-08-21: 33,990 of the 43,629 text files
1354
+ under `~/Documents/Claude/Projects` -- 77.9% -- carry at least one indented line. These
1355
+ are source files, and `.strip()` would silently return every one of them de-indented.
1356
+ - **A tab is the field separator**, the same one a worksheet row renders with, so flattening
1357
+ it to a space would collapse the columns of a `.tsv` exactly where the rest of this module
1358
+ spells columns out with tabs. 86 files in that corpus contain one.
1359
+ - **A blank line is kept**, so a row offset here IS a line offset: `page(offset=n)` starts at
1360
+ line n+1. `OMIT_BLANK_ROWS` exists because an `.xlsx` row offset can never mean that;
1361
+ this is the one container where it can, and dropping 16.2% of the corpus's lines (909,880
1362
+ of 5,618,108, measured) would throw the property away for nothing.
1363
+
1364
+ No `\n` can survive inside a row, which is the invariant row slicing actually rests on: the
1365
+ split is on `\n` and a CRLF terminator's `\r` goes with it.
1366
+ """
1367
+ if not text:
1368
+ return ()
1369
+ if text.endswith("\n"):
1370
+ text = text[:-1] # a final line break terminates the last row; it does not open a new one
1371
+ return tuple(line[:-1] if line.endswith("\r") else line for line in text.split("\n"))
1372
+
1373
+
1374
+ def extract_text(path: str | Path) -> Document:
1375
+ """Plain UTF-8 text in no container. It is CONTENT, and refusing it was the defect.
1376
+
1377
+ MEASURED 2026-08-21 over `~/Documents/Claude/Projects` (49,555 files, pruned as above):
1378
+ 43,629 of them -- 88.04% -- sniff as `text`, and before this function existed `extract`
1379
+ could hand back 76. "Needs no reader" is only true if every caller knows to fall back to a
1380
+ plain read, and this module's whole premise is that a program hands one entry point a file
1381
+ and gets content or a stated refusal.
1382
+
1383
+ Three decisions, none of them silent:
1384
+
1385
+ **The encoding is not reported, because it is not a finding.** `sniff` returns `text` only
1386
+ where the head decoded as UTF-8 with no binary control code, so UTF-8 is what the verdict
1387
+ MEANS, not a guess this function made and could report. An `Omission` field is a
1388
+ measurement, never a constant. A file in latin-1 or UTF-16 does not arrive here at all --
1389
+ it sniffs `unknown` and refuses -- and that, not this, is where that lever lives.
1390
+
1391
+ **Bytes `sniff` never saw can contradict it, and are counted.** The verdict is cast on
1392
+ `_HEAD_BYTES`; a NUL at byte 5000 is invisible to it. So the whole file is decoded STRICTLY
1393
+ -- never `errors="replace"`, which would emit characters the file does not state, the one
1394
+ thing this module refuses to do anywhere -- and the read stops at the first byte that is
1395
+ not text. The prefix is rows; the remainder is an `OMIT_UNREAD_TAIL` count with the offset
1396
+ and the reason. MEASURED across all three roots: 0 of 116,529 text files hit this today.
1397
+ It is handled because it is constructible and unbounded, not because it is common.
1398
+
1399
+ **A file bigger than `TEXT_MAX_BYTES` is read to the cap and says how much it left**, as
1400
+ `OMIT_SIZE_CAP`. See that constant for why the number is what it is.
1401
+
1402
+ Where the read stops early, for either reason, the last line has no end in this reader's
1403
+ hands, so the rows are cut back to the last line break and the partial line's bytes go into
1404
+ the same count. A row this reader cannot see the end of is a row it cannot vouch for -- and
1405
+ the alternative, emitting it, is the one shape this module never takes.
1406
+ """
1407
+ path = Path(path)
1408
+ size = path.stat().st_size
1409
+ with path.open("rb") as handle:
1410
+ raw = handle.read(TEXT_MAX_BYTES + 1)
1411
+ capped = len(raw) > TEXT_MAX_BYTES
1412
+ if capped:
1413
+ raw = raw[:TEXT_MAX_BYTES]
1414
+ # `final` is lowered exactly where the CAP cut the read, and for `_decode_head`'s reason: a
1415
+ # character this reader's own ceiling cut in half is a fact about the reader, not the file.
1416
+ decoder = codecs.getincrementaldecoder("utf-8")()
1417
+ why = ""
1418
+ try:
1419
+ text = decoder.decode(raw, final=not capped)
1420
+ except UnicodeDecodeError as exc:
1421
+ text = raw[: exc.start].decode("utf-8")
1422
+ why = f"byte {exc.start} begins a sequence that is not UTF-8 ({exc.reason})"
1423
+ control = _BINARY_CONTROL.search(text)
1424
+ if control is not None:
1425
+ at = len(text[: control.start()].encode())
1426
+ why = f"byte {at} is control code 0x{ord(control.group()):02x}, which is binary framing"
1427
+ text = text[: control.start()]
1428
+ if why or capped:
1429
+ text = text[: text.rfind("\n") + 1]
1430
+ dropped = size - len(text.encode())
1431
+ omissions: tuple[Omission, ...] = ()
1432
+ if dropped > 0:
1433
+ subject = OMIT_UNREAD_TAIL if why else OMIT_SIZE_CAP
1434
+ what = why or f"{size} bytes on disk; this reader reads {TEXT_MAX_BYTES}"
1435
+ omissions = (Omission(subject, dropped, size=dropped, what=what),)
1436
+ doc = Document(
1437
+ kind="text",
1438
+ parts=(Part(name="document", index=0, rows=_text_rows(text)),),
1439
+ omissions=omissions,
1440
+ )
1441
+ if any(row.strip() for part in doc.parts for row in part.rows):
1442
+ return doc
1443
+ # Whitespace only, or nothing survived the stop. `_nonempty` cannot answer this one: its
1444
+ # test is whether any row exists, and here a file of blank lines has plenty.
1445
+ raise _refuse(
1446
+ path,
1447
+ sniff(path),
1448
+ why or "no line in it carries a character, so this reader has no text for it",
1449
+ )
1450
+
1451
+
1452
+ # ------------------------------------------------------------ legacy `.doc` / `.rtf`, probed
1453
+
1454
+
1455
+ def textutil_path() -> str | None:
1456
+ """`/usr/bin/textutil` if this host has it, else None. Probed at call time.
1457
+
1458
+ It is a macOS built-in and nothing else ships it, so assuming it would make this module
1459
+ silently wrong on Linux and in CI. J25-PREP measured every other converter absent on this
1460
+ machine (`pypdf`, `pdftotext`, `mutool`, `qpdf`, `ffmpeg`, ...), which is why the one that
1461
+ IS present gets a probe rather than a dependency.
1462
+ """
1463
+ return TEXTUTIL if os.path.isfile(TEXTUTIL) and os.access(TEXTUTIL, os.X_OK) else None
1464
+
1465
+
1466
+ def _textutil_run(exe: str, path: Path, *args: str) -> subprocess.CompletedProcess[bytes]:
1467
+ try:
1468
+ done = subprocess.run( # noqa: S603 - absolute path, argument list, no shell
1469
+ [exe, *args, str(path)],
1470
+ capture_output=True,
1471
+ timeout=TEXTUTIL_TIMEOUT,
1472
+ check=False,
1473
+ )
1474
+ except (OSError, subprocess.SubprocessError) as exc:
1475
+ raise DocumentReadError(
1476
+ f"cannot read {path.name}: {TEXTUTIL} failed to run: {exc}"
1477
+ ) from None
1478
+ if done.returncode != 0:
1479
+ said = done.stderr.decode(errors="replace").strip().splitlines()
1480
+ detail = said[-1] if said else f"exit status {done.returncode}"
1481
+ raise DocumentReadError(f"cannot read {path.name}: {TEXTUTIL} refused it: {detail}")
1482
+ return done
1483
+
1484
+
1485
+ def _textutil_type(exe: str, path: Path) -> str:
1486
+ """What `textutil` itself says the file is. `plain text` means it recognised nothing."""
1487
+ text = _textutil_run(exe, path, "-info").stdout.decode(errors="replace")
1488
+ for line in text.splitlines():
1489
+ label, _, value = line.partition(":")
1490
+ if label.strip() == "Type":
1491
+ return value.strip()
1492
+ return ""
1493
+
1494
+
1495
+ def extract_textutil(path: str | Path, kind: str = "doc") -> Document:
1496
+ """Convert through `textutil` and render its plain text as rows.
1497
+
1498
+ The bytes of this extraction are the HOST's, not this module's, which is the one place
1499
+ where the byte-determinism the module docstring promises does not hold. It is confined to
1500
+ the two containers that have no stdlib reader.
1501
+ """
1502
+ path = Path(path)
1503
+ exe = textutil_path()
1504
+ if exe is None:
1505
+ raise DocumentReadError(
1506
+ f"cannot read {path.name}: it is {_WHAT[kind]}, which this reader converts with "
1507
+ f"{TEXTUTIL} — and {TEXTUTIL} is not on this host"
1508
+ )
1509
+ reported = _textutil_type(exe, path)
1510
+ if reported == "plain text":
1511
+ # MEASURED, 2026-08-20. Handed an OLE2 file it cannot parse, `textutil -convert txt`
1512
+ # exits 0 and echoes the raw bytes decoded as Mac Roman — mojibake, not text, and a
1513
+ # caller cannot tell it from a document. `sniff` has already established these bytes
1514
+ # are an OLE2 or RTF signature, so `Type: plain text` is textutil saying it did not
1515
+ # recognise the container. That disagreement is the refusal.
1516
+ raise DocumentReadError(
1517
+ f"cannot read {path.name}: its bytes are a {kind} container, but {TEXTUTIL} does "
1518
+ "not recognise it and reports 'Type: plain text' — converting it would return the "
1519
+ "raw bytes re-encoded, not the document's text"
1520
+ )
1521
+ done = _textutil_run(exe, path, "-convert", "txt", "-stdout")
1522
+ rows = _plain_rows(done.stdout.decode("utf-8", errors="replace"))
1523
+ doc = Document(kind=kind, parts=(Part(name="document", index=0, rows=rows),))
1524
+ return _nonempty(doc, path, f"{TEXTUTIL} converted it to no text at all")
1525
+
1526
+
1527
+ # ------------------------------------------------------------------ PDF, stdlib, `pdfread`
1528
+
1529
+
1530
+ def _pdf_refusal(text: pdfread.PdfText) -> str:
1531
+ """The one sentence for a PDF that yielded no text, and it says WHICH of the reasons."""
1532
+ pages = len(text.pages)
1533
+ if text.show_ops == 0:
1534
+ return (
1535
+ f"its {pages} page(s) carry no text-showing operator at all and draw "
1536
+ f"{text.images} image(s): it is a scan. This reader extracts text and does no OCR, "
1537
+ "so there is nothing here it can read — the pages are pictures"
1538
+ )
1539
+ if text.vouched == 0 and text.unmapped:
1540
+ return (
1541
+ f"its {pages} page(s) show {text.unmapped} character code(s) through font(s) with "
1542
+ f"no /ToUnicode map ({', '.join(text.unmapped_fonts)}) — the codes are indices into "
1543
+ "a subset font's glyphs, not characters, and nothing in the file says which "
1544
+ "character each glyph draws. Decoding them anyway would return text that is "
1545
+ "indistinguishable from content and is not content"
1546
+ )
1547
+ if text.vouched:
1548
+ # MEASURED on the user's corpus: pages that run text-showing operators whose every
1549
+ # character is a space. Saying "no character" there would be false — the characters
1550
+ # were recovered and they carry nothing — so the count is given and named for what
1551
+ # it is.
1552
+ return (
1553
+ f"its {pages} page(s) ran {text.show_ops} text-showing operator(s) and produced "
1554
+ f"{text.vouched} character(s), every one of them whitespace — the file draws "
1555
+ f"{text.images} image(s) and no text"
1556
+ )
1557
+ return (
1558
+ f"its {pages} page(s) ran {text.show_ops} text-showing operator(s) and produced no "
1559
+ "character at all"
1560
+ )
1561
+
1562
+
1563
+ def extract_pdf(path: str | Path) -> Document:
1564
+ """One `Part` per page, and a page with no rows says why rather than rendering empty.
1565
+
1566
+ The page is the part because the disclosure has to be per page: a 40-page report with two
1567
+ scanned pages in the middle is readable, and the two pages that are pictures have to say
1568
+ so where their row count is stated. A document whose every page is like that is not a
1569
+ document with no text — it is a document this reader could not read, and it refuses.
1570
+ """
1571
+ path = Path(path)
1572
+ container = sniff(path)
1573
+ try:
1574
+ text = pdfread.read_pdf(path)
1575
+ except pdfread.PdfError as exc:
1576
+ # Through `_refuse`, so a PDF refusal carries what every other refusal carries: the
1577
+ # container, its size, and — D1's rule — the disagreement when the name lies.
1578
+ raise _refuse(path, container, str(exc)) from None
1579
+ except Exception as exc: # noqa: BLE001 - see below; the type is NAMED, not swallowed
1580
+ # A PDF is a container of arbitrary bytes and this reader parses it by hand, so a
1581
+ # malformed one can reach code that expected a different shape. The property this
1582
+ # module promises is that every path out of `extract` is rows or a refusal WITH A
1583
+ # REASON — a traceback is neither. So an unexpected failure becomes a refusal that
1584
+ # names the exception type and message verbatim: the defect stays visible (it is in
1585
+ # the sentence the caller reads) while the contract holds.
1586
+ raise _refuse(
1587
+ path,
1588
+ container,
1589
+ f"its PDF structure broke this reader — {type(exc).__name__}: {exc}. That is a "
1590
+ "defect in the reader, not a property of the file, and the type above is what to "
1591
+ "report",
1592
+ ) from None
1593
+ parts = []
1594
+ for page in text.pages:
1595
+ omissions: list[Omission] = []
1596
+ if page.images and page.rows:
1597
+ omissions.append(
1598
+ Omission(
1599
+ OMIT_MEDIA,
1600
+ page.images,
1601
+ size=page.image_bytes,
1602
+ what=", ".join(page.image_kinds) or "?",
1603
+ )
1604
+ )
1605
+ if page.unmapped:
1606
+ omissions.append(
1607
+ Omission(OMIT_UNMAPPED, page.unmapped, what=", ".join(page.unmapped_fonts))
1608
+ )
1609
+ if not page.rows:
1610
+ # Counts only. WHY a page rendered nothing is FOUR machine facts, and this record
1611
+ # used to carry three of them.
1612
+ #
1613
+ # `_pdf_refusal`, thirty lines up in this same module, has named four since J25-D3:
1614
+ # no text-showing operator at all; every character shown through a font with no
1615
+ # map; every character MAPPED AND WHITESPACE; and operators that placed no
1616
+ # character either way. The page-grain record named the first two and left the
1617
+ # third indistinguishable from the fourth — so the case the real corpus has most of
1618
+ # arrived at the model as "operators ran, nothing was dropped, no rows", which
1619
+ # reads as a defect in this reader and is not one. MEASURED 2026-08-21 over the 29
1620
+ # readable PDFs under `~/Downloads` and `~/Documents/Claude/Projects`: 10 pages
1621
+ # rendered no row, 3 of them no-operator and 7 of them mapped-and-whitespace.
1622
+ #
1623
+ # `vouched` is the discriminator, and deliberately not a new counter. On a page
1624
+ # with no rows every vouched character is by construction inside a run that
1625
+ # `pdfread.show` dropped for stripping to nothing, so `vouched > 0` IS "the text
1626
+ # was recovered and it is whitespace" — and it is the same discriminator
1627
+ # `_pdf_refusal` already uses for the same distinction one grain up. Two different
1628
+ # tests for one distinction would be the defect, not the fix.
1629
+ #
1630
+ # The numbers travel under their own names because the two slots they used to
1631
+ # travel in were misread the first time anyone read them: `count` was the image
1632
+ # count, and on a subject named `unread-page` a count reads as a number of PAGES.
1633
+ # It is one page, so `count` is 1, and the fallback renderer's "1 unread-page" is
1634
+ # now true. The sentence that turns these four into a reason stays in `contract`.
1635
+ facts = (
1636
+ ("show_ops", page.show_ops),
1637
+ ("vouched", page.vouched),
1638
+ ("unmapped", page.unmapped),
1639
+ ("images", page.images),
1640
+ ("image_bytes", page.image_bytes),
1641
+ )
1642
+ omissions.append(
1643
+ Omission(
1644
+ OMIT_UNREAD_PAGE,
1645
+ 1,
1646
+ what=" ".join(f"{name}={value}" for name, value in facts),
1647
+ facts=facts,
1648
+ )
1649
+ )
1650
+ parts.append(
1651
+ Part(
1652
+ name=f"page {page.number}",
1653
+ index=page.number - 1,
1654
+ rows=page.rows,
1655
+ omissions=tuple(omissions),
1656
+ )
1657
+ )
1658
+ doc = Document(kind="pdf", parts=tuple(parts))
1659
+ # CHARACTERS, not bytes. D2 measured a document of 28 rows and `text_bytes=27` whose
1660
+ # every character count is zero: the bytes were the newlines between empty rows, and a
1661
+ # `text_bytes == 0` test passes on exactly the file that has to be refused.
1662
+ #
1663
+ # MEASURED, and stated because it is not what it looks like: on the PDF path the two forms
1664
+ # are today EQUIVALENT — a mutation to `doc.text_bytes != 0` leaves the suite green —
1665
+ # because `pdfread._rows_from_runs` drops any row that strips to nothing. The character
1666
+ # form is kept anyway: that equivalence is a property of the renderer, one layer down,
1667
+ # and `test_a_row_is_never_whitespace_only` is what holds it rather than this line.
1668
+ if any(row.strip() for part in doc.parts for row in part.rows):
1669
+ return doc
1670
+ raise _refuse(path, container, _pdf_refusal(text))
1671
+
1672
+
1673
+ _EXTRACTORS = {
1674
+ "text": extract_text,
1675
+ "xlsx": extract_xlsx,
1676
+ "docx": extract_docx,
1677
+ "pdf": extract_pdf,
1678
+ "mhtml": extract_mhtml,
1679
+ "html": extract_html,
1680
+ "doc": lambda path: extract_textutil(path, "doc"),
1681
+ "rtf": lambda path: extract_textutil(path, "rtf"),
1682
+ }
1683
+
1684
+
1685
+ def extract(path: str | Path) -> Document:
1686
+ """Dispatch on what the file IS. Anything unreadable raises, naming the container.
1687
+
1688
+ The suffix is not consulted. J25-PREP measured two files on the user's real corpus whose
1689
+ names disagree with their bytes, and under suffix dispatch both refused for the wrong
1690
+ reason.
1691
+ """
1692
+ path = Path(path)
1693
+ container = sniff(path)
1694
+ reader = _EXTRACTORS.get(container.kind)
1695
+ if reader is not None:
1696
+ return reader(path)
1697
+ raise _refuse(path, container, _unsupported_remedy())
1698
+
1699
+
1700
+ def page(
1701
+ doc: Document,
1702
+ part: str | int = 0,
1703
+ offset: int = 0,
1704
+ limit: int = DEFAULT_ROW_LIMIT,
1705
+ max_bytes: int | None = None,
1706
+ ) -> Page:
1707
+ """Slice `limit` rows from `part` starting at `offset`, optionally under a byte ceiling.
1708
+
1709
+ `offset` indexes the RENDERING, not spreadsheet row numbers — see the module docstring.
1710
+ `max_bytes` is what actually bounds a window: a row limit alone does not, because one row
1711
+ of a wide sheet can be arbitrarily long. At least one row is always returned so that paging
1712
+ cannot stall; if that single row is over the ceiling it is cut to fit and the shortfall is
1713
+ reported in `truncated_bytes`. It is cut WITHOUT `textutil.truncate`'s in-band
1714
+ `[truncated N bytes]` marker, for two reasons: the marker contains a newline and would
1715
+ break the one-row-is-one-line invariant that row slicing depends on, and it would push the
1716
+ result back over the ceiling it was called to enforce. The count is returned out of band
1717
+ instead, which is the direction `truncate_counted` itself moved in (RB-P51).
1718
+ """
1719
+ target = doc.part(part)
1720
+ if offset < 0 or limit < 1:
1721
+ raise DocumentReadError(f"offset must be >= 0 and limit >= 1, got {offset} and {limit}")
1722
+ rows: list[str] = []
1723
+ used, dropped = 0, 0
1724
+ for row in target.rows[offset : offset + limit]:
1725
+ size = len(row.encode()) + (1 if rows else 0)
1726
+ if max_bytes is not None and rows and used + size > max_bytes:
1727
+ break
1728
+ if max_bytes is not None and not rows and size > max_bytes:
1729
+ cut = row.encode()[:max_bytes].decode(errors="ignore")
1730
+ dropped = len(row.encode()) - len(cut.encode())
1731
+ rows.append(cut)
1732
+ used = len(cut.encode())
1733
+ break
1734
+ rows.append(row)
1735
+ used += size
1736
+ nxt = offset + len(rows)
1737
+ return Page(
1738
+ part=target.name,
1739
+ offset=offset,
1740
+ rows=tuple(rows),
1741
+ total_rows=target.row_count,
1742
+ next_offset=nxt if nxt < target.row_count else None,
1743
+ truncated_bytes=dropped,
1744
+ )