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.
- bantamkit/__init__.py +32 -0
- bantamkit/agent.py +458 -0
- bantamkit/assets/contracts/default.yaml +90 -0
- bantamkit/assets/evals/devteam/manifest.yaml +351 -0
- bantamkit/assets/evals/devteam/repo/HISTORY.md +18 -0
- bantamkit/assets/evals/devteam/repo/README.md +12 -0
- bantamkit/assets/evals/devteam/repo/docs/architecture.md +17 -0
- bantamkit/assets/evals/devteam/repo/docs/runbook.md +10 -0
- bantamkit/assets/evals/devteam/repo/issues/142-settlement-timeout.md +23 -0
- bantamkit/assets/evals/devteam/repo/patches/0009-retry-budget.patch +38 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/__init__.py +3 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/config.py +35 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/errors.py +13 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/posting.py +12 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/registry.py +7 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/report.py +9 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/retry.py +17 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/settle.py +16 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/validate.py +14 -0
- bantamkit/assets/evals/devteam/repo/tests/test_posting.py +13 -0
- bantamkit/assets/evals/devteam/repo/tests/test_settle.py +9 -0
- bantamkit/assets/evals/devteam/tasks/dt-error-contract.yaml +186 -0
- bantamkit/assets/evals/devteam/tasks/dt-handler-map.yaml +183 -0
- bantamkit/assets/evals/devteam/tasks/dt-patch-before-after.yaml +182 -0
- bantamkit/assets/evals/devteam/tasks/dt-retry-attempts.yaml +181 -0
- bantamkit/assets/evals/devteam/tasks/dt-settlement-config.yaml +185 -0
- bantamkit/assets/evals/devteam/tasks/dt-symbol-home.yaml +181 -0
- bantamkit/assets/evals/devteam/tasks/dt-trace-blame.yaml +182 -0
- bantamkit/assets/evals/devteam/tasks/dt-unread-key.yaml +180 -0
- bantamkit/assets/evals/document/tasks/doc-large-in-137.yaml +38 -0
- bantamkit/assets/evals/document/tasks/doc-large-in-359.yaml +44 -0
- bantamkit/assets/evals/document/tasks/doc-large-in-372.yaml +38 -0
- bantamkit/assets/evals/document/tasks/doc-large-out-11764.yaml +37 -0
- bantamkit/assets/evals/document/tasks/doc-large-out-4137.yaml +37 -0
- bantamkit/assets/evals/document/tasks/doc-large-out-8022.yaml +37 -0
- bantamkit/assets/evals/document/tasks/doc-small-137.yaml +37 -0
- bantamkit/assets/evals/document/tasks/doc-small-261.yaml +37 -0
- bantamkit/assets/evals/document/tasks/doc-small-388.yaml +37 -0
- bantamkit/assets/evals/fixtures/.gitkeep +0 -0
- bantamkit/assets/evals/fixtures/catalog.json +6 -0
- bantamkit/assets/evals/perturbations/task-completion.yaml +576 -0
- bantamkit/assets/evals/tasks/.gitkeep +0 -0
- bantamkit/assets/evals/tasks/extract-contact.yaml +14 -0
- bantamkit/assets/evals/tasks/extract-invoice.yaml +14 -0
- bantamkit/assets/evals/tasks/extract-order.yaml +15 -0
- bantamkit/assets/evals/tasks/extract-schedule.yaml +14 -0
- bantamkit/assets/evals/tasks/extract-versions.yaml +17 -0
- bantamkit/assets/evals/tasks/nav-prod-port.yaml +84 -0
- bantamkit/assets/evals/tasks/nav-release-bundle.yaml +87 -0
- bantamkit/assets/evals/tasks/recall-audit-retention.yaml +17 -0
- bantamkit/assets/evals/tasks/recall-cache-ttl.yaml +13 -0
- bantamkit/assets/evals/tasks/recall-db-port.yaml +17 -0
- bantamkit/assets/evals/tasks/recall-deploy.yaml +13 -0
- bantamkit/assets/evals/tasks/recall-env-endpoint.yaml +18 -0
- bantamkit/assets/evals/tasks/recall-oncall-rotation.yaml +21 -0
- bantamkit/assets/evals/tasks/recall-oncall.yaml +13 -0
- bantamkit/assets/evals/tasks/recall-org-quota.yaml +18 -0
- bantamkit/assets/evals/tasks/recall-owner.yaml +13 -0
- bantamkit/assets/evals/tasks/shop-basket-total.yaml +10 -0
- bantamkit/assets/evals/tasks/shop-cheapest.yaml +9 -0
- bantamkit/assets/evals/tasks/shop-compare.yaml +9 -0
- bantamkit/assets/evals/tasks/shop-gadget-value.yaml +9 -0
- bantamkit/assets/evals/tasks/shop-stock-total.yaml +9 -0
- bantamkit/assets/evals/tasks/shop-total.yaml +9 -0
- bantamkit/assets/profiles/default.yaml +31 -0
- bantamkit/assets/profiles/patient.yaml +31 -0
- bantamkit/assets/rubrics/.gitkeep +0 -0
- bantamkit/assets/rubrics/code-quality.yaml +20 -0
- bantamkit/assets/rubrics/grounded-completion.yaml +37 -0
- bantamkit/assets/rubrics/task-completion.yaml +28 -0
- bantamkit/assets/schemas/shiftwork-checkpoint.json +188 -0
- bantamkit/assets/skills/.gitkeep +0 -0
- bantamkit/assets/skills/file-graph.md +7 -0
- bantamkit/assets/skills/memory.md +35 -0
- bantamkit/assets/tools/.gitkeep +0 -0
- bantamkit/assets/tools/bantamkit_read.json +48 -0
- bantamkit/assets/tools/bantamkit_status.json +25 -0
- bantamkit/assets/tools/build_identity.json +17 -0
- bantamkit/assets/tools/document_list.json +12 -0
- bantamkit/assets/tools/document_read.json +31 -0
- bantamkit/assets/tools/file_graph.json +12 -0
- bantamkit/assets/tools/memory_compact.json +31 -0
- bantamkit/assets/tools/memory_recall.json +38 -0
- bantamkit/assets/tools/memory_save.json +61 -0
- bantamkit/assets/tools/shiftwork_clock_in.json +25 -0
- bantamkit/assets/tools/shiftwork_clock_out.json +60 -0
- bantamkit/assets/tools/shiftwork_status.json +25 -0
- bantamkit/assets/tools/skill_audit.json +70 -0
- bantamkit/assets/tools/validate_json.json +31 -0
- bantamkit/assets.py +67 -0
- bantamkit/budget.py +114 -0
- bantamkit/client.py +329 -0
- bantamkit/contract.py +522 -0
- bantamkit/criticreplay.py +3241 -0
- bantamkit/critique.py +301 -0
- bantamkit/docread.py +1744 -0
- bantamkit/evalrun.py +2003 -0
- bantamkit/eventlog.py +282 -0
- bantamkit/filegraph.py +218 -0
- bantamkit/loopguard.py +101 -0
- bantamkit/mcpreport.py +763 -0
- bantamkit/mcpserver.py +1334 -0
- bantamkit/memory/__init__.py +28 -0
- bantamkit/memory/__main__.py +291 -0
- bantamkit/memory/component.py +569 -0
- bantamkit/memory/divergence.py +744 -0
- bantamkit/memory/layers.py +257 -0
- bantamkit/memory/store.py +940 -0
- bantamkit/pdfread.py +1402 -0
- bantamkit/profile.py +46 -0
- bantamkit/shiftwork.py +212 -0
- bantamkit/skillaudit.py +853 -0
- bantamkit/statusline.py +313 -0
- bantamkit/structured.py +125 -0
- bantamkit/textutil.py +30 -0
- bantamkit-0.27.0.dist-info/METADATA +207 -0
- bantamkit-0.27.0.dist-info/RECORD +119 -0
- bantamkit-0.27.0.dist-info/WHEEL +4 -0
- 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 `�…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 — `Ab` 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
|
+
)
|