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/README.md +1 -1
- package/assets/tools/memory_dream.json +20 -0
- package/assets/tools/repo_map.json +44 -0
- package/dist/docread.d.ts +82 -8
- package/dist/docread.js +348 -96
- package/dist/eventlog.js +5 -4
- package/dist/index.d.ts +5 -3
- package/dist/index.js +2 -1
- package/dist/mcp/pyargs.d.ts +20 -1
- package/dist/mcp/pyargs.js +80 -1
- package/dist/mcp/server.d.ts +36 -6
- package/dist/mcp/server.js +280 -39
- package/dist/memory/cli.js +44 -2
- package/dist/memory/component.d.ts +73 -2
- package/dist/memory/component.js +157 -5
- package/dist/memory/dream.d.ts +247 -0
- package/dist/memory/dream.js +903 -0
- package/dist/memory/factfile.d.ts +13 -1
- package/dist/memory/factfile.js +7 -17
- package/dist/memory/pyfs.d.ts +20 -12
- package/dist/memory/pyfs.js +21 -51
- package/dist/memory/store.d.ts +88 -1
- package/dist/memory/store.js +168 -5
- package/dist/pysem.d.ts +81 -0
- package/dist/pysem.js +126 -0
- package/dist/repomap.d.ts +247 -0
- package/dist/repomap.js +1040 -0
- package/dist/skillaudit.js +31 -38
- package/package.json +1 -1
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
99
|
+
// The whitespace class, `str.strip()`, `sorted()`'s order and `repr(str)` are `pysem.ts`'s
|
|
100
|
+
// (entry (n)):
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1286
|
-
if (!(err instanceof Error) || typeof e.errno !== 'number')
|
|
1372
|
+
if (err.code !== 'ERR_BUFFER_TOO_LARGE')
|
|
1287
1373
|
throw err;
|
|
1288
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2363
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
3210
|
-
|
|
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
|
-
|
|
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());
|