bantamkit-mcp 0.29.1 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/docread.js CHANGED
@@ -40,6 +40,7 @@ import { BantamError } from './errors.js';
40
40
  import { CODEC_ALIASES, CODEC_MODULES, MULTI_BYTE_SINGLES, SINGLE_BYTE_TABLES } from './charsets.js';
41
41
  import { HTML5_ENTITIES } from './htmlentities.js';
42
42
  import { PyOSError, pyJoin, pyName, pySuffix as pyPathSuffix } from './memory/pyfs.js';
43
+ import { cmpCodepoint, PY_WS_CLASS, pyRepr, pyStrip } from './pysem.js';
43
44
  export const NS_S = '{http://schemas.openxmlformats.org/spreadsheetml/2006/main}';
44
45
  export const NS_W = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}';
45
46
  export const NS_R = '{http://schemas.openxmlformats.org/officeDocument/2006/relationships}';
@@ -58,27 +59,50 @@ export const OMIT_NUMBER_FORMAT = 'number-format';
58
59
  export const OMIT_UNREAD_PAGE = 'unread-page';
59
60
  export const OMIT_UNMAPPED = 'unmapped-text';
60
61
  export const OMIT_UNREAD_TAIL = 'unread-tail';
62
+ // What a ceiling THIS READER imposes kept out of the rows. Not a property of the file, which
63
+ // is exactly why it is declared as a count instead of applied in silence. Three ceilings carry
64
+ // it here, and each one names its own number in `what`: bytes past `TEXT_MAX_BYTES` for a
65
+ // plain-text file and for the markup of an `.html`/`.mhtml` (the same constant, because "how
66
+ // much of a file this reader reads" is one number and not one per container), and rows past
67
+ // `XLSX_MAX_TEXT_BYTES` for a workbook, where the thing that runs away is the RENDERING rather
68
+ // than the file. The reference has a FOURTH — `textutil`'s output for a `.doc` or an `.rtf` —
69
+ // which this side cannot have and does not owe, because it does not read those two kinds at
70
+ // all (docs/porting.md, "pdf, doc and rtf on Node").
71
+ //
72
+ // The principle overclaimed until review round 5 (H3), and the correction is worth stating
73
+ // because the sentence is what stopped anyone looking: `.docx` had no ceiling of any kind, and
74
+ // on the reference `.doc` and `.rtf` had none either. MEASURED here 2026-09-06 before the
75
+ // member ceiling existed: a 41,254-byte `.docx` rendered 40,000,099 bytes of text in 306 ms —
76
+ // 2.38x `TEXT_MAX_BYTES` — with `doc.omissions` and `part.omissions` both empty.
77
+ //
78
+ // `ZIP_MEMBER_MAX_BYTES` is deliberately NOT one of these, and that is the honest amendment
79
+ // rather than a fifth token: it refuses instead of disclosing, because half an XML member is
80
+ // not a smaller XML member. So an OOXML container is bounded by what this reader will PARSE
81
+ // and says so by refusing; every other container is bounded by what it will READ and says so
82
+ // by counting. `.docx` needs no rendering budget on top of that: the text a `<w:t>` walk
83
+ // produces can never exceed the bytes of the part it walked.
61
84
  export const OMIT_SIZE_CAP = 'size-cap';
62
85
  // A worksheet cell whose own `r` reference could not place it: not letters-then-digits, or a
63
86
  // column past the last one the format has. The cell's TEXT is in the rows, at its XML
64
87
  // position; what the rows do not carry is the column the file asked for. Counted per reason,
65
88
  // with the columns it landed in, exactly as `number-format` is counted per format code.
66
89
  export const OMIT_UNPLACED_CELL = 'unplaced-cell';
90
+ // A worksheet cell a LATER cell in the same row and column replaced. Last-wins is what both
91
+ // runtimes do and what a writer's own second cell means, so the reading is kept; what was not
92
+ // kept was the disclosure. Counted with the columns it happened in, like every other cell this
93
+ // reader could not put in the rows.
94
+ export const OMIT_DUPLICATE_CELL = 'duplicate-cell';
67
95
  /** Extraction failed. The message names what was seen, never just the format's own error. */
68
96
  export class DocumentReadError extends BantamError {
69
97
  }
70
98
  // ------------------------------------------------------------------ Python string semantics
71
- /** The characters `str.isspace()` is true for — what `strip()` and `split()` consume. */
72
- const PY_WS = '\t\n\x0b\x0c\r\x1c\x1d\x1e\x1f \x85\xa0            

   ';
73
- const PY_WS_CLASS = `[${PY_WS.replace(/[\]\\^-]/g, '\\$&')}]`;
99
+ // The whitespace class, `str.strip()`, `sorted()`'s order and `repr(str)` are `pysem.ts`'s
100
+ // (entry (n)): \x85\xa0            

   ';they were ported here AND under `memory/`, and the pairs were diffed before
101
+ // they were merged. The names stay exported from here, so nothing that imports them moves.
74
102
  const PY_WS_RUN = new RegExp(`${PY_WS_CLASS}+`, 'g');
75
- const PY_STRIP = new RegExp(`^${PY_WS_CLASS}+|${PY_WS_CLASS}+$`, 'g');
76
103
  const PY_LSTRIP = new RegExp(`^${PY_WS_CLASS}+`);
77
104
  const PY_RSTRIP = new RegExp(`${PY_WS_CLASS}+$`);
78
- /** `str.strip()` with no argument. */
79
- export function pyStrip(s) {
80
- return s.replace(PY_STRIP, '');
81
- }
105
+ export { cmpCodepoint, pyRepr, pyStrip };
82
106
  function pyRstrip(s) {
83
107
  return s.replace(PY_RSTRIP, '');
84
108
  }
@@ -182,60 +206,12 @@ function pyIndex(list, index) {
182
206
  const i = index < 0 ? list.length + index : index;
183
207
  return i >= 0 && i < list.length ? list[i] : undefined;
184
208
  }
185
- /** `sorted()` on strings: by code point, which is not what `Array.sort` does above U+FFFF. */
186
- export function cmpCodepoint(a, b) {
187
- const ia = a[Symbol.iterator]();
188
- const ib = b[Symbol.iterator]();
189
- for (;;) {
190
- const x = ia.next();
191
- const y = ib.next();
192
- if (x.done && y.done)
193
- return 0;
194
- if (x.done)
195
- return -1;
196
- if (y.done)
197
- return 1;
198
- const cx = x.value.codePointAt(0);
199
- const cy = y.value.codePointAt(0);
200
- if (cx !== cy)
201
- return cx - cy;
202
- }
203
- }
204
209
  function pySorted(items) {
205
210
  return [...items].sort(cmpCodepoint);
206
211
  }
207
- // `str.isprintable()` is false for these categories (space itself excepted).
208
- const NONPRINTABLE = /[\p{Cc}\p{Cf}\p{Cs}\p{Co}\p{Cn}\p{Zl}\p{Zp}\p{Zs}]/u;
209
212
  function quoteFor(s) {
210
213
  return s.includes("'") && !s.includes('"') ? '"' : "'";
211
214
  }
212
- /** `repr(str)`. */
213
- export function pyRepr(s) {
214
- const quote = quoteFor(s);
215
- let out = quote;
216
- for (const ch of s) {
217
- const code = ch.codePointAt(0);
218
- if (ch === quote || ch === '\\')
219
- out += '\\' + ch;
220
- else if (ch === '\t')
221
- out += '\\t';
222
- else if (ch === '\n')
223
- out += '\\n';
224
- else if (ch === '\r')
225
- out += '\\r';
226
- else if (ch !== ' ' && NONPRINTABLE.test(ch)) {
227
- if (code < 0x100)
228
- out += '\\x' + code.toString(16).padStart(2, '0');
229
- else if (code < 0x10000)
230
- out += '\\u' + code.toString(16).padStart(4, '0');
231
- else
232
- out += '\\U' + code.toString(16).padStart(8, '0');
233
- }
234
- else
235
- out += ch;
236
- }
237
- return out + quote;
238
- }
239
215
  /** `repr(bytes)`. */
240
216
  export function bytesRepr(buf) {
241
217
  const text = Buffer.from(buf).toString('latin1');
@@ -841,12 +817,21 @@ function startsWith(head, magic) {
841
817
  return false;
842
818
  return true;
843
819
  }
820
+ /**
821
+ * What a `PK`-headed file IS, **and the reader it took to find out** (entries (a)/(j)).
822
+ *
823
+ * The reader is handed back rather than dropped because `extract` needs the same bytes one
824
+ * call later: before this, `sniff` read the whole archive off disk to list its members and the
825
+ * extractor read it again, two whole-file `readFileSync` per `extract`. `null` for a zip this
826
+ * could not open — the kinds that answer `null` are the ones no extractor is reached for.
827
+ */
844
828
  function zipKind(path, head) {
845
829
  const named = pyPathSuffix(path).toLowerCase().replace(/^\.+/, '');
846
830
  let names;
847
831
  let mimetype = '';
832
+ let zf;
848
833
  try {
849
- const zf = ZipReader.open(path);
834
+ zf = ZipReader.open(path);
850
835
  names = zf.namelist();
851
836
  // `readMember`, not `zf.read`: an encrypted `mimetype` is the encrypted sentence naming
852
837
  // that member, never "damaged" (review round 3 measured the latter on an .odt), and a
@@ -856,20 +841,23 @@ function zipKind(path, head) {
856
841
  }
857
842
  catch (err) {
858
843
  if (err instanceof BadZipFile || isOsError(err)) {
859
- return new Container('zip', `a truncated or damaged zip archive (it starts with ${bytesRepr(head.subarray(0, 8))})`, named);
844
+ return [
845
+ new Container('zip', `a truncated or damaged zip archive (it starts with ${bytesRepr(head.subarray(0, 8))})`, named),
846
+ null,
847
+ ];
860
848
  }
861
849
  throw err;
862
850
  }
863
851
  const members = new Set(names);
864
852
  for (const [member, kind] of ZIP_MEMBERS) {
865
853
  if (members.has(member))
866
- return new Container(kind, `an OOXML package holding ${member}`, named);
854
+ return [new Container(kind, `an OOXML package holding ${member}`, named), zf];
867
855
  }
868
856
  if (mimetype.startsWith('application/vnd.oasis.opendocument')) {
869
- return new Container('odf', `an OpenDocument package (${mimetype})`, named);
857
+ return [new Container('odf', `an OpenDocument package (${mimetype})`, named), zf];
870
858
  }
871
859
  const sample = pySorted(members).slice(0, 5).join(', ') || '(empty archive)';
872
- return new Container('zip', `a zip archive that is no OOXML package; it holds: ${sample}`, named);
860
+ return [new Container('zip', `a zip archive that is no OOXML package; it holds: ${sample}`, named), zf];
873
861
  }
874
862
  /** `dict.get(key)`: own keys only, so `constructor` is not a hit. */
875
863
  function lookup(rec, key) {
@@ -984,6 +972,22 @@ function readPrefix(path, n) {
984
972
  }
985
973
  /** What `path` IS. Magic bytes, then a zip's member list. The suffix is never consulted. */
986
974
  export function sniff(path) {
975
+ return sniffOpen(path)[0];
976
+ }
977
+ /**
978
+ * `sniff`, plus the `ZipReader` it had to open — the one caller that can reuse it is `extract`.
979
+ *
980
+ * Entries (a)/(j): deciding a `PK`-headed file is an OOXML package means reading the archive,
981
+ * and the extractor then read it again, so ONE `extract` of a `.xlsx` cost two whole-file
982
+ * `readFileSync` (MEASURED at 2 before this change, 1 after, by wrapping `node:fs` in a child
983
+ * process). Public `sniff` still drops the reader, which is what it always did.
984
+ *
985
+ * The reader is a RETURN VALUE and never module state: its lifetime is the caller's, so it
986
+ * cannot answer for a file that has changed on disk since. The single-entry document cache one
987
+ * layer up is the thing that is allowed to remember, and it is keyed on (realpath, size,
988
+ * mtime_ns) for exactly that reason.
989
+ */
990
+ function sniffOpen(path) {
987
991
  const st = statPath(path);
988
992
  if (st.isDir)
989
993
  throw new DocumentReadError(`${pyPathStr(path)} is a directory, not a document`);
@@ -992,26 +996,32 @@ export function sniff(path) {
992
996
  const named = pyPathSuffix(path).toLowerCase().replace(/^\.+/, '');
993
997
  const head = readPrefix(path, HEAD_BYTES);
994
998
  if (head.length === 0)
995
- return new Container('empty', 'an empty file (0 bytes)', named);
999
+ return [new Container('empty', 'an empty file (0 bytes)', named), null];
996
1000
  if (head[0] === 0x50 && head[1] === 0x4b)
997
1001
  return zipKind(path, head);
998
1002
  for (const [magic, kind] of MAGIC) {
999
1003
  if (startsWith(head, magic)) {
1000
1004
  if (kind === 'pdf') {
1001
1005
  const version = pyStrip(head.subarray(1, 8).toString('latin1'));
1002
- return new Container('pdf', `a PDF document (${version})`, named);
1006
+ return [new Container('pdf', `a PDF document (${version})`, named), null];
1003
1007
  }
1004
- return new Container(kind, lookup(WHAT, kind) ?? `a ${kind} file`, named);
1008
+ return [new Container(kind, lookup(WHAT, kind) ?? `a ${kind} file`, named), null];
1005
1009
  }
1006
1010
  }
1007
1011
  if (head.subarray(4, 8).toString('latin1') === 'ftyp') {
1008
1012
  const brand = pyStrip(head.subarray(8, 12).toString('latin1')) || '?';
1009
- return new Container('isobmff', `an ISO base-media container, brand ${pyRepr(brand)} (video/audio)`, named);
1013
+ return [
1014
+ new Container('isobmff', `an ISO base-media container, brand ${pyRepr(brand)} (video/audio)`, named),
1015
+ null,
1016
+ ];
1010
1017
  }
1011
1018
  const guessed = textKind(head, named);
1012
1019
  if (guessed !== null)
1013
- return guessed;
1014
- return new Container('unknown', `not a recognised container; it starts with ${bytesRepr(head.subarray(0, 12))}`, named);
1020
+ return [guessed, null];
1021
+ return [
1022
+ new Container('unknown', `not a recognised container; it starts with ${bytesRepr(head.subarray(0, 12))}`, named),
1023
+ null,
1024
+ ];
1015
1025
  }
1016
1026
  /** The one refusal sentence, and it names the CONTENT before it names anything else. */
1017
1027
  function refuse(path, container, remedy) {
@@ -1061,6 +1071,35 @@ function decodeZipName(raw, flags) {
1061
1071
  out += b < 0x80 ? String.fromCharCode(b) : CP437_HIGH[b - 0x80];
1062
1072
  return out;
1063
1073
  }
1074
+ /**
1075
+ * How much ONE MEMBER of a zip this reader will decompress and parse. The ceiling on the
1076
+ * PARSE, which is a different door from `XLSX_MAX_TEXT_BYTES`: that one bounds the text a
1077
+ * workbook renders, and it never stands in front of this, because `readMember` decompresses
1078
+ * the member whole and `parsePart` builds a tree from it before the first row is rendered.
1079
+ *
1080
+ * MEASURED 2026-09-06 on this machine, through `docread.extract` on THIS runtime at the
1081
+ * shipped constants, on a sheet of N `<row><c t="inlineStr"><is><t>x</t></is></c></row>`
1082
+ * deflated by `deflateRawSync`, peak RSS from `process.resourceUsage().maxRSS`:
1083
+ * 400,000 rows are 58,378 bytes on disk, declare 19,600,112, and took RSS from 102.2 MB to
1084
+ * 1,039.3 MB in 1,019 ms with NO omission; 1,000,000 rows are 143,939 bytes, declare
1085
+ * 49,000,112, and took it from 158.6 MB to 2,290.8 MB in 2,667 ms. Linear and unbounded —
1086
+ * the memory is the `XmlElement` tree, not the text, so a budget over the rendering could
1087
+ * not see it. The reference measured the same shape at 342.2 MB and 838.8 MB through
1088
+ * `tracemalloc`; the numbers are not comparable (one counts a Python allocator, the other a
1089
+ * whole process) and the SHAPE is what both of them state.
1090
+ *
1091
+ * 16 MiB, the number `TEXT_MAX_BYTES` states, because "how much of a file this reader reads"
1092
+ * is one number — but under its OWN NAME, because it bounds a member of a container and not a
1093
+ * file on a disk, and a bar that varies one must not be varying the other. The reference
1094
+ * measured the corpus behind the figure: 25 OOXML/ODF packages under the goal's roots, the
1095
+ * largest single XML member among them 4,283,286 bytes, 3.9x under this.
1096
+ *
1097
+ * A REFUSAL and not a truncation, which is the one place this module departs from
1098
+ * "disclose, never truncate": half an XML document is not a smaller XML document, and a tree
1099
+ * built from a severed member would carry text that is not what the file says. So the reader
1100
+ * stops and names the member, the number the file declares and its own ceiling.
1101
+ */
1102
+ export const ZIP_MEMBER_MAX_BYTES = 16 * 1024 * 1024;
1064
1103
  /** `zipfile.ZipFile` read-only: the central directory, and `read(name)` with a CRC check. */
1065
1104
  export class ZipReader {
1066
1105
  data;
@@ -1175,6 +1214,13 @@ export class ZipReader {
1175
1214
  has(name) {
1176
1215
  return this.byName.has(name);
1177
1216
  }
1217
+ /** `getinfo(name).file_size`: what the central directory DECLARES, before any inflate. */
1218
+ declaredSize(name) {
1219
+ const entry = this.byName.get(name);
1220
+ if (entry === undefined)
1221
+ throw new RangeError(`There is no item named ${pyRepr(name)} in the archive`);
1222
+ return entry.fileSize;
1223
+ }
1178
1224
  /** `ZipFile.read(name)`; a missing member is a `KeyError` — here `RangeError`, see `readMember`. */
1179
1225
  read(name) {
1180
1226
  const entry = this.byName.get(name);
@@ -1203,10 +1249,25 @@ export class ZipReader {
1203
1249
  const start = at + 30 + nameLen + extraLen;
1204
1250
  const raw = data.subarray(start, start + entry.compressedSize);
1205
1251
  let out;
1252
+ // THE CLAMP THE REFERENCE GETS FOR FREE, review round 5 (H1). `zipfile.ZipExtFile` stops
1253
+ // at `ZipInfo.file_size` and CRCs what it produced, so on the reference a member that
1254
+ // declares 10 bytes and holds 100,000 yields 10 bytes and `Bad CRC-32`. That is CPython's
1255
+ // and not the format's, and this reader is hand-written: MEASURED before this line
1256
+ // existed, a forged central directory declaring 10 bytes over a 19,600,112-byte deflate
1257
+ // stream inflated all 19,600,112, passed the CRC (which covers the real content), and
1258
+ // `extract` rendered 400,000 rows. So `readMember`'s ceiling on the DECLARED size was
1259
+ // walked past by one 4-byte edit, and the property it exists for held on the reference
1260
+ // and not here.
1261
+ //
1262
+ // `maxOutputLength` is the same clamp with a hard stop instead of a truncation: nothing
1263
+ // past `file_size` is ever allocated. A member that overruns its declaration is what
1264
+ // `ZipExtFile` would have handed to the CRC check as a short prefix, and a prefix of a
1265
+ // longer stream is not what the checksum covers, so the refusal is that check's own
1266
+ // sentence rather than a new one — the same words the reference prints for the same file.
1206
1267
  if (entry.method === 0)
1207
- out = Buffer.from(raw);
1268
+ out = Buffer.from(raw.subarray(0, entry.fileSize));
1208
1269
  else if (entry.method === 8)
1209
- out = inflateRaw(raw);
1270
+ out = entry.fileSize === 0 ? Buffer.alloc(0) : inflateRaw(raw, entry.fileSize, name);
1210
1271
  else if (entry.method === 12 || entry.method === 14) {
1211
1272
  // DELIBERATE DIVERGENCE (docs/porting.md, "bzip2 and lzma zip members on Node"): the
1212
1273
  // reference's `zipfile` decompresses methods 12 and 14 through the stdlib `bz2` and
@@ -1276,22 +1337,41 @@ export class ZipMemberUnreadable extends Error {
1276
1337
  * stream that merely ENDS early is not an error to `decompressobj` — it hands back what it
1277
1338
  * has, and `ZipExtFile` then fails the CRC — so `Z_BUF_ERROR` is retried with a sync flush
1278
1339
  * for the same partial bytes, and the CRC check in `read` refuses them the way it does there.
1340
+ *
1341
+ * `limit` is `ZipInfo.file_size` and it is `ZipExtFile`'s `_left`, not a budget of this
1342
+ * reader's own: nothing past what the archive DECLARES the member holds is allocated, so an
1343
+ * overrun costs the limit and never the stream. Node reports it as `ERR_BUFFER_TOO_LARGE`,
1344
+ * a `RangeError` with no `errno`, which the arm below rethrows untouched for the outer catch.
1345
+ *
1346
+ * The one input where this and `ZipExtFile` could answer differently is a member whose
1347
+ * declared CRC is the CRC of the first `file_size` bytes of a LONGER stream: the reference
1348
+ * would hand back that prefix and this refuses it. Forging that costs the attacker a second
1349
+ * field, it is not reachable from any file a writer produces, and closing it would mean
1350
+ * materialising the overrun to checksum a prefix of it — which is the hole this closes.
1279
1351
  */
1280
- function inflateRaw(raw) {
1352
+ function inflateRaw(raw, limit, member) {
1281
1353
  try {
1282
- return inflateRawSync(raw);
1354
+ try {
1355
+ return inflateRawSync(raw, { maxOutputLength: limit });
1356
+ }
1357
+ catch (err) {
1358
+ const e = err;
1359
+ if (!(err instanceof Error) || typeof e.errno !== 'number')
1360
+ throw err;
1361
+ if (e.code === 'Z_BUF_ERROR') {
1362
+ return inflateRawSync(raw, { maxOutputLength: limit, finishFlush: zlibConstants.Z_SYNC_FLUSH });
1363
+ }
1364
+ const out = new Error(`Error ${e.errno} while decompressing data: ${e.message}`);
1365
+ // `type(zlib.error).__name__` is `error` — the class is `zlib.error`, and `error` is what
1366
+ // the reference records for it (`tools/conformance/ref/docread_ref.py`, `_error`).
1367
+ out.name = 'error';
1368
+ throw out;
1369
+ }
1283
1370
  }
1284
1371
  catch (err) {
1285
- const e = err;
1286
- if (!(err instanceof Error) || typeof e.errno !== 'number')
1372
+ if (err.code !== 'ERR_BUFFER_TOO_LARGE')
1287
1373
  throw err;
1288
- if (e.code === 'Z_BUF_ERROR')
1289
- return inflateRawSync(raw, { finishFlush: zlibConstants.Z_SYNC_FLUSH });
1290
- const out = new Error(`Error ${e.errno} while decompressing data: ${e.message}`);
1291
- // `type(zlib.error).__name__` is `error` — the class is `zlib.error`, and `error` is what
1292
- // the reference records for it (`tools/conformance/ref/docread_ref.py`, `_error`).
1293
- out.name = 'error';
1294
- throw out;
1374
+ throw new BadZipFile(`Bad CRC-32 for file ${pyRepr(member)}`);
1295
1375
  }
1296
1376
  }
1297
1377
  function openZip(path) {
@@ -1332,6 +1412,17 @@ function readMember(zf, name, path) {
1332
1412
  const sample = pySorted(zf.namelist()).slice(0, 8).join(', ') || '(empty archive)';
1333
1413
  throw new DocumentReadError(`${pyPathName(path)} is a zip but has no ${name}; it contains: ${sample}`);
1334
1414
  }
1415
+ // The fifth arm, and it is not zipfile's: a member that decompresses past
1416
+ // `ZIP_MEMBER_MAX_BYTES`. The gate is the UNCOMPRESSED SIZE the central directory declares,
1417
+ // read before anything is decompressed, and it is deliberately the cheapest possible check —
1418
+ // a member that says it is 46 MB costs no inflate at all to refuse. Those are the attacker's
1419
+ // bytes, so declaring LOW is answered one layer down, in `ZipReader.read`: the clamp there
1420
+ // is what makes this gate a ceiling rather than a suggestion.
1421
+ const declared = zf.declaredSize(name);
1422
+ if (declared > ZIP_MEMBER_MAX_BYTES) {
1423
+ throw new DocumentReadError(`${pyPathName(path)} is a zip but its ${name} declares ${declared} bytes uncompressed, ` +
1424
+ `past the ${ZIP_MEMBER_MAX_BYTES} bytes this reader parses, so this reader cannot parse it`);
1425
+ }
1335
1426
  try {
1336
1427
  return zf.read(name);
1337
1428
  }
@@ -2168,6 +2259,35 @@ const MAX_COLUMN_LETTERS = 3;
2168
2259
  */
2169
2260
  export const UNPLACED_SHAPE = 'the column of a cell whose reference is not letters then digits';
2170
2261
  export const UNPLACED_RANGE = 'the column of a cell past XFD, the last column the format has';
2262
+ // What a cell loses when a later cell in the same row claims its column. Fixed for the same
2263
+ // reason the two above are: the reference is the file's bytes and the column letter is this
2264
+ // reader's own, so only the letter travels — in `where`, bounded by `XLSX_MAX_COLUMNS`.
2265
+ export const DUPLICATE_CELL = 'the text of a cell a later cell in the same row and column replaced';
2266
+ /**
2267
+ * How much text ONE WORKBOOK may materialise, all sheets together. `XLSX_MAX_COLUMNS` bounds a
2268
+ * ROW and nothing bounded the document, which is a ceiling with a hole in it: the width is
2269
+ * bought a row at a time. MEASURED 2026-09-06 on both runtimes: 20,000 rows each holding one
2270
+ * `XFD1` cell deflate to 53,967 bytes and materialise 327,680,000 bytes in ~9 s — 6,072x, out
2271
+ * of a file small enough to mail.
2272
+ *
2273
+ * 16 MiB, the same figure `TEXT_MAX_BYTES` carries and for the same kind of reason, but under
2274
+ * its OWN NAME because the two bound different things: bytes read off a disk there, bytes
2275
+ * rendered out of a container here, and a bar that varies one must not be varying the other.
2276
+ * MEASURED 2026-09-06 over the goal's roots: 15 `.xlsx`, the largest 8,664,227 bytes on disk,
2277
+ * and the largest RENDERING among them 754,520 bytes — 22x under this budget, so no real
2278
+ * workbook on this machine meets it.
2279
+ *
2280
+ * It bounds the RENDERING and nothing else. The sentence that stood here said it bounded the
2281
+ * rendering "because the file is already bounded", and that was FALSE for the whole life of
2282
+ * this constant: `readMember` decompressed a member whole and `parsePart` built a tree from
2283
+ * it, both before the first row was rendered and both outside this budget, so 58,378 bytes on
2284
+ * disk took RSS to 1,039.3 MB with no omission and this ceiling never saw it (review round 5,
2285
+ * H1). What bounds the file is `ZIP_MEMBER_MAX_BYTES`, one door earlier; this bounds what
2286
+ * comes out of it. The shortfall is disclosed as `OMIT_SIZE_CAP` counting the rows no sheet
2287
+ * rendered: a row is what a caller addresses, and a byte count of text that was never built
2288
+ * would be a number this reader cannot honestly produce.
2289
+ */
2290
+ export const XLSX_MAX_TEXT_BYTES = 16 * 1024 * 1024;
2171
2291
  /**
2172
2292
  * `B7` -> `[1, '']`. The cell's own reference decides its column; XML order is a fallback.
2173
2293
  *
@@ -2214,7 +2334,11 @@ function sharedStrings(zf, path) {
2214
2334
  if (!zf.has('xl/sharedStrings.xml'))
2215
2335
  return [];
2216
2336
  const out = [];
2217
- for (const si of parsePart(zf.read('xl/sharedStrings.xml'), 'xl/sharedStrings.xml', path).children) {
2337
+ // `readMember` and not `zf.read`, as `_shared_strings` calls `_read` and not `zf.read`: the
2338
+ // string table is a part this reader cannot do without, so a damaged or encrypted one owes
2339
+ // the reader's own sentence — and it is the member ceiling's door one call before any
2340
+ // worksheet reaches it, which is where a workbook's biggest part actually is.
2341
+ for (const si of parsePart(readMember(zf, 'xl/sharedStrings.xml', path), 'xl/sharedStrings.xml', path).children) {
2218
2342
  const runs = [];
2219
2343
  for (const child of si.children) {
2220
2344
  if (child.tag === NS_S + 't')
@@ -2255,12 +2379,42 @@ function cellText(cell, shared) {
2255
2379
  return raw === '1' ? 'TRUE' : 'FALSE';
2256
2380
  return raw;
2257
2381
  }
2258
- function sheetRows(root, shared, dateStyles = []) {
2382
+ /**
2383
+ * The rendered rows of one sheet, and a count of what the rendering did not carry.
2384
+ *
2385
+ * The omissions are gathered in the same pass that renders, never by a second scan: a count
2386
+ * derived from a different walk of the XML can disagree with the rows it claims to describe,
2387
+ * and a disclosure that disagrees with the thing it discloses is worse than none.
2388
+ *
2389
+ * A cell `columnIndex` cannot place is one of those omissions and NOT a refusal (review round
2390
+ * 4, M2): it keeps its text at its XML position and loses only the column the file asked for.
2391
+ * Counted per reason and rendered after the format codes, so the order of this array is blank
2392
+ * rows, then number formats by code, then unplaced cells by reason, then the cells a later
2393
+ * cell in the same row and column replaced.
2394
+ *
2395
+ * A DUPLICATE is a cell whose column already holds text from a cell earlier in the same row.
2396
+ * Last-wins is kept — it is what both runtimes do and what a writer's own second cell means —
2397
+ * and the earlier cell's text is disclosed rather than dropped in silence, which is the only
2398
+ * part of that behaviour nobody chose (entry (w)).
2399
+ *
2400
+ * `budget` is the DOCUMENT's, not this sheet's: the width of one row is already bounded by
2401
+ * `XLSX_MAX_COLUMNS` and the height of a workbook was not, so the row is where the ceiling has
2402
+ * to bite (entry (v)). A row is skipped whole rather than cut in half — half a row is a row
2403
+ * this reader cannot vouch for, which is the same rule `extractText` follows at its own cap —
2404
+ * so the overshoot is at most one row, itself bounded at `XLSX_MAX_COLUMNS` fields.
2405
+ */
2406
+ function sheetRows(root, shared, dateStyles = [], budget = { remaining: XLSX_MAX_TEXT_BYTES, total: 0, dropped: 0 }) {
2259
2407
  const rows = [];
2260
2408
  let blank = 0;
2261
2409
  const dated = new Map();
2262
2410
  const unplaced = new Map();
2411
+ const duplicated = new Map();
2263
2412
  for (const row of root.iter(NS_S + 'row')) {
2413
+ budget.total += 1;
2414
+ if (budget.remaining <= 0) {
2415
+ budget.dropped += 1;
2416
+ continue;
2417
+ }
2264
2418
  const cells = new Map();
2265
2419
  let position = 0;
2266
2420
  for (const cell of row.iter(NS_S + 'c')) {
@@ -2270,6 +2424,8 @@ function sheetRows(root, shared, dateStyles = []) {
2270
2424
  if (!text)
2271
2425
  continue;
2272
2426
  const [column, unplaceable] = columnIndex(cell.get('r'), at);
2427
+ if (cells.has(column))
2428
+ duplicated.set(column, (duplicated.get(column) ?? 0) + 1);
2273
2429
  cells.set(column, text);
2274
2430
  if (unplaceable) {
2275
2431
  let seen = unplaced.get(unplaceable);
@@ -2303,6 +2459,7 @@ function sheetRows(root, shared, dateStyles = []) {
2303
2459
  for (let k = 0; k < width; k += 1)
2304
2460
  fields.push(cells.get(k) ?? '');
2305
2461
  const line = fields.join('\t');
2462
+ budget.remaining -= utf8Length(line);
2306
2463
  if (!line)
2307
2464
  blank += 1;
2308
2465
  rows.push(line);
@@ -2324,6 +2481,12 @@ function sheetRows(root, shared, dateStyles = []) {
2324
2481
  const sortedColumns = [...columns.keys()].sort((a, b) => a - b);
2325
2482
  omissions.push(new Omission(OMIT_UNPLACED_CELL, [...columns.values()].reduce((a, b) => a + b, 0), 0, sortedColumns.map(columnLetter), reason));
2326
2483
  }
2484
+ // Then the duplicates: ONE omission for the sheet, columns in column order, the same shape
2485
+ // `number-format` and `unplaced-cell` use so that a caller rendering one renders this one.
2486
+ if (duplicated.size) {
2487
+ const columns = [...duplicated.keys()].sort((a, b) => a - b);
2488
+ omissions.push(new Omission(OMIT_DUPLICATE_CELL, [...duplicated.values()].reduce((a, b) => a + b, 0), 0, columns.map(columnLetter), DUPLICATE_CELL));
2489
+ }
2327
2490
  return [rows, omissions];
2328
2491
  }
2329
2492
  function worksheetTargets(zf, path) {
@@ -2359,8 +2522,23 @@ function mediaOmission(media) {
2359
2522
  size += bytes;
2360
2523
  return [new Omission(OMIT_MEDIA, media.size, size, [], pySorted(kinds).join(', '))];
2361
2524
  }
2362
- export function extractXlsx(path) {
2363
- const zf = openZip(path);
2525
+ /**
2526
+ * Every declared sheet, under ONE `XLSX_MAX_TEXT_BYTES` budget for the whole workbook.
2527
+ *
2528
+ * The budget is the document's, so it is made here and not in `sheetRows`, and the shortfall
2529
+ * is disclosed at the document's grain for the same reason — it is not a fact about the sheet
2530
+ * the budget happened to run out on. The cap is stated BEFORE the media tally, because the
2531
+ * media a reader met is a count of what it met underneath the cap.
2532
+ *
2533
+ * `opened` is the reader `sniff` already had to build to decide this file is an OOXML package
2534
+ * (entries (a)/(j)): before it was handed down, one `extract` read the whole archive off disk
2535
+ * TWICE. It is a parameter and never a field, so its lifetime is this call — a reader kept
2536
+ * across calls would answer from bytes no longer on disk, which is exactly what the document
2537
+ * cache one layer up is keyed on (realpath, size, mtime_ns) to avoid.
2538
+ */
2539
+ export function extractXlsx(path, opened = null) {
2540
+ const zf = opened ?? openZip(path);
2541
+ const budget = { remaining: XLSX_MAX_TEXT_BYTES, total: 0, dropped: 0 };
2364
2542
  const shared = sharedStrings(zf, path);
2365
2543
  const dateStyles = dateFormats(zf);
2366
2544
  const media = mediaIndex(zf);
@@ -2371,15 +2549,19 @@ export function extractXlsx(path) {
2371
2549
  throw new DocumentReadError(`sheet ${pyRepr(name)} has no resolvable worksheet part`);
2372
2550
  }
2373
2551
  const sheet = parsePart(readMember(zf, target, path), target, path);
2374
- const [rows, sheetOmissions] = sheetRows(sheet, shared, dateStyles);
2552
+ const [rows, sheetOmissions] = sheetRows(sheet, shared, dateStyles, budget);
2375
2553
  const omissions = [...mediaOmission(anchoredMedia(zf, target, media)), ...sheetOmissions];
2376
2554
  parts.push(new Part(name, index, rows, omissions));
2377
2555
  index += 1;
2378
2556
  }
2379
- return new Document('xlsx', parts, mediaOmission(media));
2557
+ const capped = [];
2558
+ if (budget.dropped) {
2559
+ capped.push(new Omission(OMIT_SIZE_CAP, budget.dropped, 0, [], `${budget.total} rows in this workbook; this reader renders ${XLSX_MAX_TEXT_BYTES} bytes of cell text`));
2560
+ }
2561
+ return new Document('xlsx', parts, [...capped, ...mediaOmission(media)]);
2380
2562
  }
2381
- export function extractDocx(path) {
2382
- const zf = openZip(path);
2563
+ export function extractDocx(path, opened = null) {
2564
+ const zf = opened ?? openZip(path);
2383
2565
  const root = parsePart(readMember(zf, 'word/document.xml', path), 'word/document.xml', path);
2384
2566
  const media = mediaIndex(zf);
2385
2567
  const anchored = mediaOmission(anchoredMedia(zf, 'word/document.xml', media));
@@ -3157,8 +3339,35 @@ function decodedBody(part) {
3157
3339
  return '';
3158
3340
  return decodeCharset(payload, contentCharset(part) ?? 'utf-8');
3159
3341
  }
3342
+ /**
3343
+ * A file's bytes up to `TEXT_MAX_BYTES`, and the `OMIT_SIZE_CAP` for what is past it.
3344
+ *
3345
+ * ONE ceiling and one sentence for every container that holds markup, because "how much of a
3346
+ * file this reader reads" is a fact about the reader and not about the suffix: a 1 GB `.txt`
3347
+ * stopped at `TEXT_MAX_BYTES` and counted the rest while a 1 GB `.html` was held whole
3348
+ * (`docs/roadmap-toolbox.md` row 8, entry (k)). The sentence is `extractText`'s, to the byte,
3349
+ * so a caller cannot tell from it which reader hit the cap.
3350
+ *
3351
+ * Where `extractText` also cuts back to the last line break, this does not: markup is not a
3352
+ * line-oriented format, a half-open tag is not a claim about content the way half a line is,
3353
+ * and the parsers close what the file left open without inventing text for it.
3354
+ */
3355
+ function readToCeiling(path) {
3356
+ const size = statSync(pyPathStr(path)).size;
3357
+ const raw = readPrefix(path, TEXT_MAX_BYTES + 1);
3358
+ if (raw.length <= TEXT_MAX_BYTES)
3359
+ return [raw, []];
3360
+ const dropped = size - TEXT_MAX_BYTES;
3361
+ const what = `${size} bytes on disk; this reader reads ${TEXT_MAX_BYTES}`;
3362
+ return [raw.subarray(0, TEXT_MAX_BYTES), [new Omission(OMIT_SIZE_CAP, dropped, dropped, [], what)]];
3363
+ }
3160
3364
  export function extractMhtml(path) {
3161
- const message = parseMessage(splitLines(readFileSync(pyPathStr(path)).toString('latin1')), 'text/plain');
3365
+ // BOUNDED, entry (k): the whole file went into the MIME parser, so a 1 GB `.mht` was held
3366
+ // whole — the same hole `extractHtml` had, in its own spelling. The message is parsed from
3367
+ // the bytes this reader will admit to having read; a truncated MIME message is one the
3368
+ // parser still walks, and what it could not see is COUNTED.
3369
+ const [raw, capped] = readToCeiling(path);
3370
+ const message = parseMessage(splitLines(raw.toString('latin1')), 'text/plain');
3162
3371
  const bodies = [];
3163
3372
  const skipped = new Map();
3164
3373
  let skippedBytes = 0;
@@ -3190,12 +3399,20 @@ export function extractMhtml(path) {
3190
3399
  count += c;
3191
3400
  omissions = [new Omission(OMIT_MEDIA, count, skippedBytes, [], pySorted(skipped.keys()).join(', '))];
3192
3401
  }
3193
- return nonempty(new Document('mhtml', parts, omissions), path, 'no text/html or text/plain part carried any text');
3402
+ // The cap first: the media tally counts what this reader met UNDERNEATH it, so a caller who
3403
+ // reads that number without the cap above it has read a lower bound as a total.
3404
+ return nonempty(new Document('mhtml', parts, [...capped, ...omissions]), path, 'no text/html or text/plain part carried any text');
3194
3405
  }
3406
+ /**
3407
+ * Markup to rows, up to `TEXT_MAX_BYTES` of it, saying how much it did not read.
3408
+ *
3409
+ * BOUNDED, entry (k): this did `readFileSync(path)`, so a 1 GB `.html` was materialised whole
3410
+ * where a 1 GB `.txt` had stopped at the cap and counted the rest since J10.
3411
+ */
3195
3412
  export function extractHtml(path) {
3196
- const raw = readFileSync(pyPathStr(path));
3413
+ const [raw, capped] = readToCeiling(path);
3197
3414
  const markup = new TextDecoder('utf-8', { fatal: false, ignoreBOM: true }).decode(raw);
3198
- const doc = new Document('html', [new Part('document', 0, htmlRows(markup))]);
3415
+ const doc = new Document('html', [new Part('document', 0, htmlRows(markup))], capped);
3199
3416
  return nonempty(doc, path, 'its markup carried no text outside script and style');
3200
3417
  }
3201
3418
  function plainRows(text) {
@@ -3203,11 +3420,43 @@ function plainRows(text) {
3203
3420
  .map((raw) => pyStrip(clean(raw)))
3204
3421
  .filter((line) => line);
3205
3422
  }
3423
+ /**
3424
+ * THE REFUSAL CARRIES THE OMISSIONS, review round 5 (H2). `extractHtml` and `extractMhtml`
3425
+ * build the `Document` with `capped` in `omissions` and hand it here, and here it raised: the
3426
+ * refusal kept the reader's verdict about the content and threw away the ceiling that
3427
+ * produced that verdict. MEASURED on a 16,777,291-byte `.html` whose 16 MiB `<script>` comment
3428
+ * is followed by one visible sentence — `its markup carried no text outside script and style
3429
+ * ... it is not an empty document`, about a document that carries text, from a read that
3430
+ * stopped 75 bytes short of it. The same on a `.mht` past the ceiling, where the media tally
3431
+ * went with it.
3432
+ *
3433
+ * A reader is allowed to refuse. It is not allowed to state a false fact about a file, and
3434
+ * `why` is a fact about the file only when the whole file was read. Under a cap it is scoped
3435
+ * to the part that was read and the unread bytes are named, so a caller who would have stopped
3436
+ * looking has the one number that tells it not to.
3437
+ */
3206
3438
  function nonempty(doc, path, why) {
3207
3439
  if (doc.parts.some((part) => part.rows.length))
3208
3440
  return doc;
3209
- throw new DocumentReadError(`cannot read ${pyPathName(path)}: it is a ${doc.kind} container but ${why}, so this reader has ` +
3210
- 'no text for it — it is not an empty document');
3441
+ const capped = doc.omissions.find((o) => o.subject === OMIT_SIZE_CAP);
3442
+ const media = doc.omissions.find((o) => o.subject === OMIT_MEDIA);
3443
+ let said;
3444
+ if (capped === undefined) {
3445
+ said =
3446
+ `cannot read ${pyPathName(path)}: it is a ${doc.kind} container but ${why}, so this reader has ` +
3447
+ 'no text for it — it is not an empty document';
3448
+ }
3449
+ else {
3450
+ // The cap's own `what` is quoted rather than rebuilt, so the sentence and the omission can
3451
+ // never state different numbers, and so the clause says whose bytes were counted.
3452
+ said =
3453
+ `cannot read ${pyPathName(path)}: it is a ${doc.kind} container but ${why} in the part ` +
3454
+ `this reader read (${capped.what}) — the ${capped.count} bytes it did not read may carry text`;
3455
+ }
3456
+ if (media !== undefined) {
3457
+ said += `, and it holds ${media.count} embedded part(s) (${media.what}) this reader renders no text for`;
3458
+ }
3459
+ throw new DocumentReadError(said);
3211
3460
  }
3212
3461
  // --------------------------------------------------------------- plain text, in no container
3213
3462
  function textRows(text) {
@@ -3276,10 +3525,13 @@ const EXTRACTORS = {
3276
3525
  const NODE_REFUSED = new Set(['pdf', 'doc', 'rtf']);
3277
3526
  /** Dispatch on what the file IS. Anything unreadable raises, naming the container. */
3278
3527
  export function extract(path) {
3279
- const container = sniff(path);
3528
+ // `sniffOpen` and not `sniff`: an OOXML container's reader is already built by the time the
3529
+ // kind is known, and handing it to the extractor is the whole of entries (a)/(j). It goes no
3530
+ // further than this call.
3531
+ const [container, opened] = sniffOpen(path);
3280
3532
  const reader = lookup(EXTRACTORS, container.kind);
3281
3533
  if (reader !== undefined)
3282
- return reader(path);
3534
+ return reader(path, opened);
3283
3535
  if (NODE_REFUSED.has(container.kind))
3284
3536
  throw refuse(path, container, nodeRefusal(container.kind));
3285
3537
  throw refuse(path, container, unsupportedRemedy());