bantamkit-mcp 0.26.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.
Files changed (148) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +398 -0
  3. package/assets/contracts/default.yaml +90 -0
  4. package/assets/evals/devteam/manifest.yaml +351 -0
  5. package/assets/evals/devteam/repo/HISTORY.md +18 -0
  6. package/assets/evals/devteam/repo/README.md +12 -0
  7. package/assets/evals/devteam/repo/docs/architecture.md +17 -0
  8. package/assets/evals/devteam/repo/docs/runbook.md +10 -0
  9. package/assets/evals/devteam/repo/issues/142-settlement-timeout.md +23 -0
  10. package/assets/evals/devteam/repo/patches/0009-retry-budget.patch +38 -0
  11. package/assets/evals/devteam/repo/src/ledger/__init__.py +3 -0
  12. package/assets/evals/devteam/repo/src/ledger/config.py +35 -0
  13. package/assets/evals/devteam/repo/src/ledger/errors.py +13 -0
  14. package/assets/evals/devteam/repo/src/ledger/posting.py +12 -0
  15. package/assets/evals/devteam/repo/src/ledger/registry.py +7 -0
  16. package/assets/evals/devteam/repo/src/ledger/report.py +9 -0
  17. package/assets/evals/devteam/repo/src/ledger/retry.py +17 -0
  18. package/assets/evals/devteam/repo/src/ledger/settle.py +16 -0
  19. package/assets/evals/devteam/repo/src/ledger/validate.py +14 -0
  20. package/assets/evals/devteam/repo/tests/test_posting.py +13 -0
  21. package/assets/evals/devteam/repo/tests/test_settle.py +9 -0
  22. package/assets/evals/devteam/tasks/dt-error-contract.yaml +186 -0
  23. package/assets/evals/devteam/tasks/dt-handler-map.yaml +183 -0
  24. package/assets/evals/devteam/tasks/dt-patch-before-after.yaml +182 -0
  25. package/assets/evals/devteam/tasks/dt-retry-attempts.yaml +181 -0
  26. package/assets/evals/devteam/tasks/dt-settlement-config.yaml +185 -0
  27. package/assets/evals/devteam/tasks/dt-symbol-home.yaml +181 -0
  28. package/assets/evals/devteam/tasks/dt-trace-blame.yaml +182 -0
  29. package/assets/evals/devteam/tasks/dt-unread-key.yaml +180 -0
  30. package/assets/evals/document/tasks/doc-large-in-137.yaml +38 -0
  31. package/assets/evals/document/tasks/doc-large-in-359.yaml +44 -0
  32. package/assets/evals/document/tasks/doc-large-in-372.yaml +38 -0
  33. package/assets/evals/document/tasks/doc-large-out-11764.yaml +37 -0
  34. package/assets/evals/document/tasks/doc-large-out-4137.yaml +37 -0
  35. package/assets/evals/document/tasks/doc-large-out-8022.yaml +37 -0
  36. package/assets/evals/document/tasks/doc-small-137.yaml +37 -0
  37. package/assets/evals/document/tasks/doc-small-261.yaml +37 -0
  38. package/assets/evals/document/tasks/doc-small-388.yaml +37 -0
  39. package/assets/evals/fixtures/.gitkeep +0 -0
  40. package/assets/evals/fixtures/catalog.json +6 -0
  41. package/assets/evals/perturbations/task-completion.yaml +576 -0
  42. package/assets/evals/tasks/.gitkeep +0 -0
  43. package/assets/evals/tasks/extract-contact.yaml +14 -0
  44. package/assets/evals/tasks/extract-invoice.yaml +14 -0
  45. package/assets/evals/tasks/extract-order.yaml +15 -0
  46. package/assets/evals/tasks/extract-schedule.yaml +14 -0
  47. package/assets/evals/tasks/extract-versions.yaml +17 -0
  48. package/assets/evals/tasks/nav-prod-port.yaml +84 -0
  49. package/assets/evals/tasks/nav-release-bundle.yaml +87 -0
  50. package/assets/evals/tasks/recall-audit-retention.yaml +17 -0
  51. package/assets/evals/tasks/recall-cache-ttl.yaml +13 -0
  52. package/assets/evals/tasks/recall-db-port.yaml +17 -0
  53. package/assets/evals/tasks/recall-deploy.yaml +13 -0
  54. package/assets/evals/tasks/recall-env-endpoint.yaml +18 -0
  55. package/assets/evals/tasks/recall-oncall-rotation.yaml +21 -0
  56. package/assets/evals/tasks/recall-oncall.yaml +13 -0
  57. package/assets/evals/tasks/recall-org-quota.yaml +18 -0
  58. package/assets/evals/tasks/recall-owner.yaml +13 -0
  59. package/assets/evals/tasks/shop-basket-total.yaml +10 -0
  60. package/assets/evals/tasks/shop-cheapest.yaml +9 -0
  61. package/assets/evals/tasks/shop-compare.yaml +9 -0
  62. package/assets/evals/tasks/shop-gadget-value.yaml +9 -0
  63. package/assets/evals/tasks/shop-stock-total.yaml +9 -0
  64. package/assets/evals/tasks/shop-total.yaml +9 -0
  65. package/assets/profiles/default.yaml +31 -0
  66. package/assets/profiles/patient.yaml +31 -0
  67. package/assets/rubrics/.gitkeep +0 -0
  68. package/assets/rubrics/code-quality.yaml +20 -0
  69. package/assets/rubrics/grounded-completion.yaml +37 -0
  70. package/assets/rubrics/task-completion.yaml +28 -0
  71. package/assets/schemas/shiftwork-checkpoint.json +188 -0
  72. package/assets/skills/.gitkeep +0 -0
  73. package/assets/skills/file-graph.md +7 -0
  74. package/assets/skills/memory.md +35 -0
  75. package/assets/tools/.gitkeep +0 -0
  76. package/assets/tools/bantamkit_read.json +48 -0
  77. package/assets/tools/bantamkit_status.json +25 -0
  78. package/assets/tools/build_identity.json +17 -0
  79. package/assets/tools/document_list.json +12 -0
  80. package/assets/tools/document_read.json +31 -0
  81. package/assets/tools/file_graph.json +12 -0
  82. package/assets/tools/memory_compact.json +31 -0
  83. package/assets/tools/memory_recall.json +38 -0
  84. package/assets/tools/memory_save.json +61 -0
  85. package/assets/tools/shiftwork_clock_in.json +25 -0
  86. package/assets/tools/shiftwork_clock_out.json +60 -0
  87. package/assets/tools/shiftwork_status.json +25 -0
  88. package/assets/tools/skill_audit.json +70 -0
  89. package/assets/tools/validate_json.json +31 -0
  90. package/dist/assets.d.ts +48 -0
  91. package/dist/assets.js +109 -0
  92. package/dist/charsets.d.ts +17 -0
  93. package/dist/charsets.js +580 -0
  94. package/dist/cli.d.ts +12 -0
  95. package/dist/cli.js +280 -0
  96. package/dist/contract.d.ts +145 -0
  97. package/dist/contract.js +608 -0
  98. package/dist/docread.d.ts +357 -0
  99. package/dist/docread.js +3315 -0
  100. package/dist/errors.d.ts +11 -0
  101. package/dist/errors.js +14 -0
  102. package/dist/eventlog.d.ts +137 -0
  103. package/dist/eventlog.js +294 -0
  104. package/dist/htmlentities.d.ts +2 -0
  105. package/dist/htmlentities.js +16 -0
  106. package/dist/index.d.ts +23 -0
  107. package/dist/index.js +13 -0
  108. package/dist/mcp/identity.d.ts +25 -0
  109. package/dist/mcp/identity.js +275 -0
  110. package/dist/mcp/pyargs.d.ts +80 -0
  111. package/dist/mcp/pyargs.js +362 -0
  112. package/dist/mcp/sdkjson.d.ts +56 -0
  113. package/dist/mcp/sdkjson.js +91 -0
  114. package/dist/mcp/server.d.ts +45 -0
  115. package/dist/mcp/server.js +740 -0
  116. package/dist/mcp/status.d.ts +210 -0
  117. package/dist/mcp/status.js +357 -0
  118. package/dist/mcp/transport.d.ts +68 -0
  119. package/dist/mcp/transport.js +111 -0
  120. package/dist/mcpreport.d.ts +214 -0
  121. package/dist/mcpreport.js +758 -0
  122. package/dist/memory/cli.d.ts +2 -0
  123. package/dist/memory/cli.js +396 -0
  124. package/dist/memory/component.d.ts +256 -0
  125. package/dist/memory/component.js +497 -0
  126. package/dist/memory/factfile.d.ts +120 -0
  127. package/dist/memory/factfile.js +406 -0
  128. package/dist/memory/layers.d.ts +92 -0
  129. package/dist/memory/layers.js +410 -0
  130. package/dist/memory/pyfs.d.ts +521 -0
  131. package/dist/memory/pyfs.js +1608 -0
  132. package/dist/memory/pyyaml.d.ts +155 -0
  133. package/dist/memory/pyyaml.js +868 -0
  134. package/dist/memory/store.d.ts +478 -0
  135. package/dist/memory/store.js +1034 -0
  136. package/dist/pyargparse.d.ts +141 -0
  137. package/dist/pyargparse.js +813 -0
  138. package/dist/pyjson.d.ts +110 -0
  139. package/dist/pyjson.js +524 -0
  140. package/dist/pyjsonschema.d.ts +50 -0
  141. package/dist/pyjsonschema.js +951 -0
  142. package/dist/shiftwork.d.ts +60 -0
  143. package/dist/shiftwork.js +324 -0
  144. package/dist/skillaudit.d.ts +172 -0
  145. package/dist/skillaudit.js +914 -0
  146. package/dist/statusline.d.ts +106 -0
  147. package/dist/statusline.js +281 -0
  148. package/package.json +45 -0
@@ -0,0 +1,3315 @@
1
+ /**
2
+ * The Node half of `runtime-py/src/bantamkit/docread.py`: deterministic text extraction,
3
+ * addressable by part and row, dispatched on what a file IS.
4
+ *
5
+ * The Python module is the reference and this file COPIES it — its rows, its omission
6
+ * records and its sentences, byte for byte, for the kinds it reads: `text`, `xlsx`,
7
+ * `docx`, `html` and `mhtml`. Every constant, token and sentence below is the Python
8
+ * one; where the reference reads something this half does not (`pdf` through
9
+ * `pdfread`, `doc`/`rtf` through `/usr/bin/textutil`), `sniff` still IDENTIFIES the
10
+ * container so the refusal names it, and `extract` refuses with a sentence that is a
11
+ * DELIBERATE divergence recorded in `docs/porting.md`.
12
+ *
13
+ * No third-party dependency. The reference leans on four standard-library modules that
14
+ * Node does not ship, and each is replaced by the smallest hand-written reader that
15
+ * reproduces the reference's answers on the reference's own fixtures:
16
+ *
17
+ * - `zipfile` -> `ZipReader`: central-directory walk, stored + deflate (`node:zlib`
18
+ * `inflateRawSync`), CRC-32 checked the way `ZipFile.read` checks it.
19
+ * - `xml.etree` -> `parseXml`: a namespace-expanding element tree with `.text`,
20
+ * `iter(tag)`, `find(tag)` and direct-child iteration — the four ET
21
+ * operations `docread.py` actually calls.
22
+ * - `html.parser` + `html.unescape` -> `HtmlText` over a line-for-line port of
23
+ * `HTMLParser.goahead` (CPython 3.12.13, `convert_charrefs=True`), so
24
+ * the DATA CHUNKS arrive in the same pieces — the chunking is visible in
25
+ * the rows because `_HtmlText` joins chunks with a space.
26
+ * - `email` -> `parseMime`: multipart boundaries, header unfolding, Content-Type
27
+ * parameters, quoted-printable (`binascii.a2b_qp`) and base64.
28
+ *
29
+ * Python string semantics that JavaScript does not share are ported explicitly rather
30
+ * than approximated: `str.strip()`/`split()`/`splitlines()` use Python's whitespace and
31
+ * line-boundary sets (`PY_WS`, `pySplitlines`), `repr()` of a `str` and of `bytes` is
32
+ * `pyRepr`/`bytesRepr`, `sorted()` compares by code point (`cmpCodepoint`), and a UTF-8
33
+ * decode error reports the same `start` offset and `reason` CPython's decoder does
34
+ * (`utf8Scan`).
35
+ */
36
+ import { closeSync, openSync, readFileSync, readSync, statSync } from 'node:fs';
37
+ import { posix } from 'node:path';
38
+ import { constants as zlibConstants, inflateRawSync } from 'node:zlib';
39
+ import { BantamError } from './errors.js';
40
+ import { CODEC_ALIASES, CODEC_MODULES, MULTI_BYTE_SINGLES, SINGLE_BYTE_TABLES } from './charsets.js';
41
+ import { HTML5_ENTITIES } from './htmlentities.js';
42
+ import { PyOSError, pyJoin, pyName, pySuffix as pyPathSuffix } from './memory/pyfs.js';
43
+ export const NS_S = '{http://schemas.openxmlformats.org/spreadsheetml/2006/main}';
44
+ export const NS_W = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}';
45
+ export const NS_R = '{http://schemas.openxmlformats.org/officeDocument/2006/relationships}';
46
+ // Read directly, by this module, with nothing outside the standard library — the Python
47
+ // tuple verbatim, because `_unsupported_remedy` prints it and the sentence must match.
48
+ export const SUPPORTED = ['text', 'xlsx', 'docx', 'pdf', 'html', 'mhtml'];
49
+ export const TEXTUTIL_SUPPORTED = ['doc', 'rtf'];
50
+ export const TEXTUTIL = '/usr/bin/textutil';
51
+ export const DEFAULT_ROW_LIMIT = 50;
52
+ export const PAGE_MAX_ROWS = 200;
53
+ export const PAGE_MAX_BYTES = 3072;
54
+ export const TEXT_MAX_BYTES = 16 * 1024 * 1024;
55
+ export const OMIT_MEDIA = 'media';
56
+ export const OMIT_BLANK_ROWS = 'blank-rows';
57
+ export const OMIT_NUMBER_FORMAT = 'number-format';
58
+ export const OMIT_UNREAD_PAGE = 'unread-page';
59
+ export const OMIT_UNMAPPED = 'unmapped-text';
60
+ export const OMIT_UNREAD_TAIL = 'unread-tail';
61
+ export const OMIT_SIZE_CAP = 'size-cap';
62
+ // A worksheet cell whose own `r` reference could not place it: not letters-then-digits, or a
63
+ // column past the last one the format has. The cell's TEXT is in the rows, at its XML
64
+ // position; what the rows do not carry is the column the file asked for. Counted per reason,
65
+ // with the columns it landed in, exactly as `number-format` is counted per format code.
66
+ export const OMIT_UNPLACED_CELL = 'unplaced-cell';
67
+ /** Extraction failed. The message names what was seen, never just the format's own error. */
68
+ export class DocumentReadError extends BantamError {
69
+ }
70
+ // ------------------------------------------------------------------ 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, '\\$&')}]`;
74
+ const PY_WS_RUN = new RegExp(`${PY_WS_CLASS}+`, 'g');
75
+ const PY_STRIP = new RegExp(`^${PY_WS_CLASS}+|${PY_WS_CLASS}+$`, 'g');
76
+ const PY_LSTRIP = new RegExp(`^${PY_WS_CLASS}+`);
77
+ 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
+ }
82
+ function pyRstrip(s) {
83
+ return s.replace(PY_RSTRIP, '');
84
+ }
85
+ /** `str.strip(chars)`. */
86
+ function pyStripChars(s, chars) {
87
+ let start = 0;
88
+ let end = s.length;
89
+ while (start < end && chars.includes(s[start]))
90
+ start += 1;
91
+ while (end > start && chars.includes(s[end - 1]))
92
+ end -= 1;
93
+ return s.slice(start, end);
94
+ }
95
+ function pyLstripChars(s, chars) {
96
+ let start = 0;
97
+ while (start < s.length && chars.includes(s[start]))
98
+ start += 1;
99
+ return s.slice(start);
100
+ }
101
+ /** `str.split()` with no argument: runs of whitespace, no empty fields. */
102
+ export function pySplit(s) {
103
+ const trimmed = s.replace(PY_LSTRIP, '');
104
+ if (!trimmed)
105
+ return [];
106
+ return pyRstrip(trimmed).split(PY_WS_RUN);
107
+ }
108
+ /** `str.splitlines()`: Python's line boundaries, terminators dropped. */
109
+ export function pySplitlines(s) {
110
+ const out = [];
111
+ let start = 0;
112
+ let i = 0;
113
+ while (i < s.length) {
114
+ const ch = s[i];
115
+ let eol = 0;
116
+ if (ch === '\r')
117
+ eol = s[i + 1] === '\n' ? 2 : 1;
118
+ else if ('\n\x0b\x0c\x1c\x1d\x1e\x85

'.includes(ch))
119
+ eol = 1;
120
+ if (eol) {
121
+ out.push(s.slice(start, i));
122
+ i += eol;
123
+ start = i;
124
+ }
125
+ else {
126
+ i += 1;
127
+ }
128
+ }
129
+ if (start < s.length)
130
+ out.push(s.slice(start));
131
+ return out;
132
+ }
133
+ /** `int(text)` for a base-10 literal; `null` where Python raises `ValueError`. */
134
+ // `int()` strips `Py_UNICODE_ISSPACE` characters, which is neither JavaScript's `\s` (that
135
+ // has U+FEFF, this does not) nor `str.isspace()` (U+001C–U+001F are ASCII-whitespace to
136
+ // `isspace` and refused by `int()`). Measured on CPython 3.12 (job43 G2), one character at a
137
+ // time: accepted U+0085, U+00A0, U+1680, U+2000–U+200A, U+2028, U+2029, U+202F, U+205F,
138
+ // U+3000; refused U+001C–U+001F, U+180E, U+200B, U+FEFF.
139
+ const PY_INT_SPACE = '[\\t\\n\\x0b\\x0c\\r \\x85\\xa0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000]*';
140
+ const PY_INT = new RegExp(`^${PY_INT_SPACE}([+-]?)(\\p{Nd}+(?:_\\p{Nd}+)*)${PY_INT_SPACE}$`, 'u');
141
+ const IS_ND = /\p{Nd}/u;
142
+ /**
143
+ * The value of one `Nd` character. Unicode encodes every decimal-digit set as a contiguous
144
+ * run 0..9, so the value is the distance to the run's start — walked, because the runs abut
145
+ * (the five mathematical sets U+1D7CE.. are fifty consecutive code points) and the walk is
146
+ * taken modulo ten.
147
+ */
148
+ function digitValue(ch) {
149
+ let cp = ch.codePointAt(0);
150
+ let steps = 0;
151
+ while (IS_ND.test(String.fromCodePoint(cp - 1))) {
152
+ cp -= 1;
153
+ steps += 1;
154
+ }
155
+ return steps % 10;
156
+ }
157
+ /**
158
+ * `int(text)`, or `null` where it raises `ValueError`.
159
+ *
160
+ * `int()` accepts ANY Unicode decimal digit — `<v>١٢</v>` (Arabic-Indic) indexes shared
161
+ * string 12 on the reference (measured, job43 G1: `tests/data/docread/unicode-digit-shared-
162
+ * string.xlsx` reads `str12`), and until G2 this port refused the cell as "indexes shared
163
+ * string '١٢'". `_` between digit groups, a sign, and Unicode whitespace around are `int()`'s
164
+ * too. A decimal string past 4300 digits is `ValueError` since CPython 3.11 (the cap
165
+ * `mcpserver._ArgMetadata` documents), and is `null` here rather than `Infinity`.
166
+ */
167
+ function pyInt(text) {
168
+ const m = PY_INT.exec(text);
169
+ if (!m)
170
+ return null;
171
+ const digits = m[2].replace(/_/g, '');
172
+ if (digits.length > 4300)
173
+ return null;
174
+ let ascii = '';
175
+ for (const ch of digits)
176
+ ascii += digitValue(ch);
177
+ const n = Number(ascii);
178
+ return m[1] === '-' ? -n : n;
179
+ }
180
+ /** Python list indexing: negative counts from the end, out of range is `undefined`. */
181
+ function pyIndex(list, index) {
182
+ const i = index < 0 ? list.length + index : index;
183
+ return i >= 0 && i < list.length ? list[i] : undefined;
184
+ }
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
+ function pySorted(items) {
205
+ return [...items].sort(cmpCodepoint);
206
+ }
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
+ function quoteFor(s) {
210
+ return s.includes("'") && !s.includes('"') ? '"' : "'";
211
+ }
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
+ /** `repr(bytes)`. */
240
+ export function bytesRepr(buf) {
241
+ const text = Buffer.from(buf).toString('latin1');
242
+ const quote = quoteFor(text);
243
+ let out = 'b' + quote;
244
+ for (const b of buf) {
245
+ const ch = String.fromCharCode(b);
246
+ if (ch === quote || ch === '\\')
247
+ out += '\\' + ch;
248
+ else if (b === 0x09)
249
+ out += '\\t';
250
+ else if (b === 0x0a)
251
+ out += '\\n';
252
+ else if (b === 0x0d)
253
+ out += '\\r';
254
+ else if (b >= 0x20 && b < 0x7f)
255
+ out += ch;
256
+ else
257
+ out += '\\x' + b.toString(16).padStart(2, '0');
258
+ }
259
+ return out + quote;
260
+ }
261
+ /**
262
+ * `str(Path(p))`: `//` and `.` segments collapsed, a trailing slash dropped, nothing resolved.
263
+ *
264
+ * This is ALSO the string every `fs` call in this module receives, because it is the string
265
+ * the reference hands the kernel: `Path('')` is `.` (the cwd — a directory, on the
266
+ * reference), and `Path('a/b/.')` is `a/b`, so `stat('README.md/.')` on the reference is a
267
+ * stat of the FILE, where a raw `statSync('README.md/.')` is ENOTDIR. Measured (job43 F3):
268
+ * `''` read as `no such file: .` here and `. is a directory, not a document` there.
269
+ *
270
+ * The parsing is `pyfs`'s — `PurePath`'s own, with the Windows flavour on Windows — so a
271
+ * sentence printed under `win32` carries `C:\\docs\\a.docx` where the reference prints it,
272
+ * and `Path('C:/docs/a.docx').name` is `a.docx` on both. Until job43 G2 this module had its
273
+ * own POSIX-only copy of the three helpers, and every sentence on Windows would have named
274
+ * the path with the separators the CALLER wrote.
275
+ */
276
+ function pyPathStr(path) {
277
+ return pyJoin(path);
278
+ }
279
+ /** `Path(p).name`: the last component of the collapsed path — `''` for `''`, `.` and `/`. */
280
+ function pyPathName(path) {
281
+ return pyName(path);
282
+ }
283
+ /** `posixpath.splitext(name)[1]`. Leading dots of the basename never begin an extension. */
284
+ function posixSplitextExt(name) {
285
+ const slash = name.lastIndexOf('/');
286
+ const dot = name.lastIndexOf('.');
287
+ if (dot <= slash)
288
+ return '';
289
+ let first = slash + 1;
290
+ while (first < dot && name[first] === '.')
291
+ first += 1;
292
+ return first < dot ? name.slice(dot) : '';
293
+ }
294
+ function utf8Length(s) {
295
+ return Buffer.byteLength(s, 'utf8');
296
+ }
297
+ /**
298
+ * CPython's UTF-8 decoder, as a validator: the same `start` and `reason` its
299
+ * `UnicodeDecodeError` carries, and the same tolerance an incremental decoder has for a
300
+ * final character the read cut in half when `final` is false.
301
+ */
302
+ export function utf8Scan(buf, final) {
303
+ const n = buf.length;
304
+ let i = 0;
305
+ while (i < n) {
306
+ const b = buf[i];
307
+ if (b < 0x80) {
308
+ i += 1;
309
+ continue;
310
+ }
311
+ let need;
312
+ let lo = 0x80;
313
+ let hi = 0xbf;
314
+ if (b >= 0xc2 && b <= 0xdf)
315
+ need = 1;
316
+ else if (b >= 0xe0 && b <= 0xef) {
317
+ need = 2;
318
+ if (b === 0xe0)
319
+ lo = 0xa0;
320
+ if (b === 0xed)
321
+ hi = 0x9f;
322
+ }
323
+ else if (b >= 0xf0 && b <= 0xf4) {
324
+ need = 3;
325
+ if (b === 0xf0)
326
+ lo = 0x90;
327
+ if (b === 0xf4)
328
+ hi = 0x8f;
329
+ }
330
+ else {
331
+ return { end: i, invalidAt: i, reason: 'invalid start byte', incomplete: false };
332
+ }
333
+ for (let k = 1; k <= need; k += 1) {
334
+ if (i + k >= n) {
335
+ if (final) {
336
+ return { end: i, invalidAt: i, reason: 'unexpected end of data', incomplete: false };
337
+ }
338
+ return { end: i, invalidAt: -1, reason: '', incomplete: true };
339
+ }
340
+ const c = buf[i + k];
341
+ const min = k === 1 ? lo : 0x80;
342
+ const max = k === 1 ? hi : 0xbf;
343
+ if (c < min || c > max) {
344
+ return { end: i, invalidAt: i, reason: 'invalid continuation byte', incomplete: false };
345
+ }
346
+ }
347
+ i += need + 1;
348
+ }
349
+ return { end: n, invalidAt: -1, reason: '', incomplete: false };
350
+ }
351
+ function decodeUtf8(buf) {
352
+ return Buffer.from(buf.buffer, buf.byteOffset, buf.length).toString('utf8');
353
+ }
354
+ /**
355
+ * `encodings.normalize_encoding` after `codecs.lookup`'s own lower-casing: runs of anything
356
+ * that is not an ASCII letter, digit or `.` collapse to one `_`, and none leads or trails.
357
+ * `ISO 8859-1`, `ISO--8859--1` and `iso-8859-1;` are all `iso_8859_1`.
358
+ */
359
+ export function pyNormalizeEncoding(encoding) {
360
+ let out = '';
361
+ let punct = false;
362
+ for (const c of encoding.toLowerCase()) {
363
+ if (/[a-z0-9.]/.test(c)) {
364
+ if (punct && out)
365
+ out += '_';
366
+ out += c;
367
+ punct = false;
368
+ }
369
+ else
370
+ punct = true;
371
+ }
372
+ return out;
373
+ }
374
+ /**
375
+ * `encodings.search_function`: the module `codecs.lookup(name)` would import, or `null`
376
+ * where it raises `LookupError`. The alias table is consulted on the normalized name and on
377
+ * the name with `.` as `_`; the module name itself is the fallback, and a name that is not
378
+ * a module — `cp-437` (`cp_437`), `x-mac-roman`, `latin.1` — is the `LookupError`.
379
+ */
380
+ export function pyCodecModule(name) {
381
+ const norm = pyNormalizeEncoding(name);
382
+ const aliased = lookup(CODEC_ALIASES, norm) ?? lookup(CODEC_ALIASES, norm.replaceAll('.', '_'));
383
+ for (const modname of [aliased, norm]) {
384
+ if (modname !== undefined && CODEC_MODULES.has(modname))
385
+ return modname;
386
+ }
387
+ return null;
388
+ }
389
+ /**
390
+ * The multi-byte codecs this port decodes through `TextDecoder`, module -> WHATWG label.
391
+ * The label's table is not the codec's in every cell (WHATWG `shift_jis` is cp932,
392
+ * `gb2312` is gbk, `big5` carries HKSCS, `euc-kr` is cp949), so a byte pair only one of the
393
+ * two knows differs; the ERROR policy is CPython's, see `decodeMultiByte`.
394
+ */
395
+ /**
396
+ * The STATEFUL codecs — the ones whose decoder carries state set by earlier bytes, so that no
397
+ * byte-to-character map can describe them at all — module -> WHATWG label. Review round 4
398
+ * (H1).
399
+ *
400
+ * `charsets-table.py` judged a module a stateless single-byte codec with a 2-byte probe from
401
+ * six hand-picked lead bytes, and neither ESC nor `~` was among them, so seven of this family
402
+ * were written into `SINGLE_BYTE_TABLES` with a 256-character table they cannot have and
403
+ * `decodeSingleByte` then decoded them ONE BYTE AT A TIME. Measured: `iso2022_jp` bytes for
404
+ * こんにちは came back as "\ufffd$B$3$s$K$A$O\ufffd(B", the ESCs eaten by the table, where the
405
+ * reference answers こんにちは. That was a regression — before round 3 the port called
406
+ * `TextDecoder(charset)`, and ICU does have an `iso-2022-jp` decoder. The generator refuses
407
+ * them now (it sweeps all 65,536 pairs), and this map is where the six that HAVE a decoder go.
408
+ *
409
+ * SIX MODULES AND NOT NINE, by measurement. Over the shared JIS X 0208 range every
410
+ * `iso2022_jp*` variant scores 99.5–99.7 % against CPython through ICU's `iso-2022-jp`,
411
+ * against 1.4 % through the UTF-8 fallback. `hz`, `iso2022_kr` and `utf_7` score 0 % — WHATWG
412
+ * maps all three to the "replacement" encoding, which answers one U+FFFD for any non-empty
413
+ * input — so they keep the no-decoder path this module already documents. The residual for
414
+ * the six is ICU's table against CPython's, the divergence roadmap row 8 (o) registers: over
415
+ * 8,829 inputs ICU agrees on 5,664 to the byte table's 5,021 and the UTF-8 fallback's 4,339,
416
+ * and 888 of 900 valid-text inputs to their 73 and 45. The 12 are the wave dash, U+301C
417
+ * against U+FF5E.
418
+ *
419
+ * A PLAIN `TextDecoder` and never `decodeMultiByte`: that walk assumes a byte under 0x80 is
420
+ * its own ASCII character, which is exactly what an escape sequence is not.
421
+ */
422
+ const ESCAPE_LABELS = {
423
+ iso2022_jp: 'iso-2022-jp',
424
+ iso2022_jp_1: 'iso-2022-jp',
425
+ iso2022_jp_2: 'iso-2022-jp',
426
+ iso2022_jp_2004: 'iso-2022-jp',
427
+ iso2022_jp_3: 'iso-2022-jp',
428
+ iso2022_jp_ext: 'iso-2022-jp',
429
+ };
430
+ const MULTI_BYTE_LABELS = {
431
+ shift_jis: 'shift_jis',
432
+ cp932: 'shift_jis',
433
+ euc_jp: 'euc-jp',
434
+ gb2312: 'gb2312',
435
+ gbk: 'gbk',
436
+ gb18030: 'gb18030',
437
+ big5: 'big5',
438
+ big5hkscs: 'big5',
439
+ euc_kr: 'euc-kr',
440
+ cp949: 'euc-kr',
441
+ };
442
+ /**
443
+ * `bytes.decode(charset, errors="replace")`, falling back to UTF-8 on `LookupError`
444
+ * (`docread.py`, `_decoded_body`).
445
+ *
446
+ * Until H2 this was `TextDecoder(charset)` with the WHATWG label table, and review round 3
447
+ * measured that table against the codec registry on bytes `80 D0 E9 A4 FF`
448
+ * (`runtime-py/tests/data/docread/charset-table.json`): cp437, mac_roman and iso-8859-16
449
+ * are labels WHATWG does not have; iso-8859-9's 0x80–0x9F are C1 controls to Python and
450
+ * windows-1254's punctuation to WHATWG; Node 25.2.1's ICU decoder maps windows-1252's
451
+ * 0x80 to U+0080 and prints nothing for a trailing 0xFF under latin-1; us-ascii is
452
+ * windows-1252 there. The single-byte codecs are now `charsets.ts`, 256 characters per
453
+ * module written by the registry itself (79 codecs, 20 of them measured over all 256 bytes
454
+ * against the old path: 13 differed); the CJK codecs go through `TextDecoder` with
455
+ * CPython's replacement policy (`decodeMultiByte`, where the residual is quantified); a
456
+ * module this port has no decoder for (`utf_7`, `hz`, `iso2022_kr`, `johab`, the JIS X
457
+ * 0213 variants) decodes as UTF-8, which is the other remaining divergence.
458
+ *
459
+ * AMENDED at review round 4 (H1). This paragraph used to name `iso2022_*` among the modules
460
+ * with no decoder, and for seven of that family the code did something else and worse: the
461
+ * generator had written them a single-byte table, so they were decoded one byte at a time and
462
+ * their escape sequences became U+FFFD. The six `iso2022_jp*` modules now go through ICU's
463
+ * real decoder (`ESCAPE_LABELS`, where the three arms are scored); `hz`, `iso2022_kr` and
464
+ * `utf_7` are what is left of the no-decoder set, and for them this paragraph is now true.
465
+ */
466
+ export function decodeCharset(buf, charset) {
467
+ const module = pyCodecModule(charset);
468
+ if (module === null)
469
+ return decodeUtf8(buf);
470
+ // BEFORE the table lookup, deliberately. A stateful codec must never be read as a byte
471
+ // map, and putting this first means a table that reappears cannot silently win again.
472
+ const escape = lookup(ESCAPE_LABELS, module);
473
+ if (escape !== undefined)
474
+ return new TextDecoder(escape).decode(buf);
475
+ const table = lookup(SINGLE_BYTE_TABLES, module);
476
+ if (table !== undefined)
477
+ return decodeSingleByte(buf, table);
478
+ if (module === 'utf_8')
479
+ return decodeUtf8(buf);
480
+ if (module === 'utf_8_sig') {
481
+ const bom = buf.length >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf;
482
+ return decodeUtf8(bom ? buf.subarray(3) : buf);
483
+ }
484
+ if (module === 'utf_16' || module === 'utf_16_le' || module === 'utf_16_be')
485
+ return decodeUtf16(buf, module);
486
+ if (module === 'utf_32' || module === 'utf_32_le' || module === 'utf_32_be')
487
+ return decodeUtf32(buf, module);
488
+ if (lookup(MULTI_BYTE_LABELS, module) !== undefined)
489
+ return decodeMultiByte(buf, module);
490
+ return decodeUtf8(buf);
491
+ }
492
+ const ICU_ALONE = new Map();
493
+ /** Which of bytes 0x80..0xFF ICU's decoder for `label` accepts on their own, computed once. */
494
+ function acceptedAlone(label, fatal) {
495
+ let mask = ICU_ALONE.get(label);
496
+ if (mask === undefined) {
497
+ mask = new Uint8Array(128);
498
+ for (let b = 0x80; b <= 0xff; b += 1) {
499
+ try {
500
+ fatal.decode(new Uint8Array([b]));
501
+ mask[b - 0x80] = 1;
502
+ }
503
+ catch {
504
+ // a lead byte, or a byte the codec has no character for
505
+ }
506
+ }
507
+ ICU_ALONE.set(label, mask);
508
+ }
509
+ return mask;
510
+ }
511
+ function decodeSingleByte(buf, table) {
512
+ const low = table.low;
513
+ let out = '';
514
+ for (const b of buf) {
515
+ if (b < 0x80)
516
+ out += low === undefined ? String.fromCharCode(b) : low[b];
517
+ else
518
+ out += table.high[b - 0x80];
519
+ }
520
+ return out;
521
+ }
522
+ /**
523
+ * `utf_16` consumes a BOM of either order and defaults to little-endian without one;
524
+ * `utf_16_le`/`utf_16_be` keep a BOM as U+FEFF. A lone surrogate is one U+FFFD per unit and
525
+ * an odd trailing byte one U+FFFD, which is the WHATWG policy too (measured on the table).
526
+ */
527
+ function decodeUtf16(buf, module) {
528
+ let label = module === 'utf_16_be' ? 'utf-16be' : 'utf-16le';
529
+ if (module === 'utf_16' && buf.length >= 2) {
530
+ if (buf[0] === 0xff && buf[1] === 0xfe)
531
+ buf = buf.subarray(2);
532
+ else if (buf[0] === 0xfe && buf[1] === 0xff) {
533
+ label = 'utf-16be';
534
+ buf = buf.subarray(2);
535
+ }
536
+ }
537
+ return new TextDecoder(label, { fatal: false, ignoreBOM: true }).decode(buf);
538
+ }
539
+ /** `utf_32` as CPython decodes it: BOM as for utf_16, a bad unit or a short tail one U+FFFD. */
540
+ function decodeUtf32(buf, module) {
541
+ let big = module === 'utf_32_be';
542
+ if (module === 'utf_32' && buf.length >= 4) {
543
+ if (buf[0] === 0xff && buf[1] === 0xfe && buf[2] === 0 && buf[3] === 0)
544
+ buf = buf.subarray(4);
545
+ else if (buf[0] === 0 && buf[1] === 0 && buf[2] === 0xfe && buf[3] === 0xff) {
546
+ big = true;
547
+ buf = buf.subarray(4);
548
+ }
549
+ }
550
+ let out = '';
551
+ let i = 0;
552
+ for (; i + 4 <= buf.length; i += 4) {
553
+ const cp = big
554
+ ? (buf[i] << 24) + (buf[i + 1] << 16) + (buf[i + 2] << 8) + buf[i + 3]
555
+ : (buf[i + 3] << 24) + (buf[i + 2] << 16) + (buf[i + 1] << 8) + buf[i];
556
+ out += cp > 0x10ffff || cp < 0 || (cp >= 0xd800 && cp <= 0xdfff) ? '�' : String.fromCodePoint(cp);
557
+ }
558
+ if (i < buf.length)
559
+ out += '�';
560
+ return out;
561
+ }
562
+ /**
563
+ * A CJK codec with CPython's `errors="replace"`: a byte that starts no character the codec
564
+ * knows is ONE U+FFFD and decoding resumes at the next byte, so `A4 FF` in euc-jp is two
565
+ * replacement characters where ICU's own lenient mode prints one. The whole buffer is tried
566
+ * fatally first — the common case decodes in one call — and only a buffer that fails is
567
+ * walked byte by byte, each position taking the shortest slice the codec accepts.
568
+ *
569
+ * What this does NOT make identical is the two tables: 300 random 0–9 byte strings per
570
+ * codec against `bytes.decode(codec, "replace")` (H2, seed 7) agree 297/300 for euc_jp,
571
+ * 296 euc_kr, 295 cp932, 292 gb18030, 278 gbk, 268 shift_jis, 208 big5, 205 big5hkscs,
572
+ * 192 cp949, 141 gb2312 — every remaining case a pair one table knows and the other does
573
+ * not (gb2312 is decoded by ICU's gbk, big5 by its HKSCS table). The utf_16/utf_32 family
574
+ * and utf_8_sig are 300/300.
575
+ */
576
+ function decodeMultiByte(buf, module) {
577
+ const label = MULTI_BYTE_LABELS[module];
578
+ const fatal = new TextDecoder(label, { fatal: true, ignoreBOM: true });
579
+ const singles = lookup(MULTI_BYTE_SINGLES, module);
580
+ const icuAlone = acceptedAlone(label, fatal);
581
+ // A byte ICU decodes on its own that CPython does not (0x80 is U+0080 to ICU's euc-jp and
582
+ // U+20AC to its gbk, U+FFFD to both of CPython's) is the one case the fast path cannot
583
+ // take: ICU would print the byte, and in a longer slice would consume it as a prefix.
584
+ const suspect = (b) => b >= 0x80 && singles[b - 0x80] === '\ufffd' && icuAlone[b - 0x80] === 1;
585
+ if (!buf.some(suspect)) {
586
+ try {
587
+ return fatal.decode(buf);
588
+ }
589
+ catch {
590
+ // fall through to the walk
591
+ }
592
+ }
593
+ let out = '';
594
+ let i = 0;
595
+ const n = buf.length;
596
+ while (i < n) {
597
+ let j = i;
598
+ while (j < n && buf[j] < 0x80)
599
+ j += 1;
600
+ if (j > i) {
601
+ out += fatal.decode(buf.subarray(i, j));
602
+ i = j;
603
+ continue;
604
+ }
605
+ // A byte that is a character on its own to CPython (`MULTI_BYTE_SINGLES`, written by the
606
+ // registry) is that character; one that is not is never a one-byte slice here, whatever
607
+ // ICU would make of it alone — it accepts 0x80 as U+0080 where CPython refuses it.
608
+ const single = singles[buf[i] - 0x80];
609
+ let taken = 0;
610
+ if (single !== '\ufffd') {
611
+ out += single;
612
+ i += 1;
613
+ continue;
614
+ }
615
+ if (icuAlone[buf[i] - 0x80] === 1) {
616
+ out += '\ufffd';
617
+ i += 1;
618
+ continue;
619
+ }
620
+ for (let len = 2; len <= 4 && i + len <= n; len += 1) {
621
+ try {
622
+ out += fatal.decode(buf.subarray(i, i + len));
623
+ taken = len;
624
+ break;
625
+ }
626
+ catch {
627
+ // a longer slice may be the character
628
+ }
629
+ }
630
+ if (taken === 0) {
631
+ out += '�';
632
+ taken = 1;
633
+ }
634
+ i += taken;
635
+ }
636
+ return out;
637
+ }
638
+ /** Something the file holds that the rows do not carry, as a COUNT and the fact behind it. */
639
+ export class Omission {
640
+ subject;
641
+ count;
642
+ size;
643
+ where;
644
+ what;
645
+ facts;
646
+ constructor(subject, count, size = 0, where = [], what = '', facts = []) {
647
+ this.subject = subject;
648
+ this.count = count;
649
+ this.size = size;
650
+ this.where = where;
651
+ this.what = what;
652
+ this.facts = facts;
653
+ }
654
+ /** The primitive form the contract layer takes, keys in the reference's order. */
655
+ asDict() {
656
+ const facts = {};
657
+ for (const [name, value] of this.facts)
658
+ facts[name] = value;
659
+ return {
660
+ subject: this.subject,
661
+ count: this.count,
662
+ size: this.size,
663
+ where: [...this.where],
664
+ what: this.what,
665
+ facts,
666
+ };
667
+ }
668
+ }
669
+ /** One addressable unit: a worksheet, or a `.docx` body. `rows` are rendered lines. */
670
+ export class Part {
671
+ name;
672
+ index;
673
+ rows;
674
+ omissions;
675
+ constructor(name, index, rows, omissions = []) {
676
+ this.name = name;
677
+ this.index = index;
678
+ this.rows = rows;
679
+ this.omissions = omissions;
680
+ }
681
+ get rowCount() {
682
+ return this.rows.length;
683
+ }
684
+ /** UTF-8 bytes of this part's full rendering. EXTRACTED size, not file size. */
685
+ get textBytes() {
686
+ return utf8Length(this.rows.join('\n'));
687
+ }
688
+ }
689
+ export class Document {
690
+ kind;
691
+ parts;
692
+ omissions;
693
+ constructor(kind, parts, omissions = []) {
694
+ this.kind = kind;
695
+ this.parts = parts;
696
+ this.omissions = omissions;
697
+ }
698
+ get textBytes() {
699
+ return this.parts.reduce((sum, p) => sum + p.textBytes, 0);
700
+ }
701
+ /** Resolve by exact sheet name, else by 0-based index. Names win over numeric keys. */
702
+ part(key) {
703
+ for (const p of this.parts) {
704
+ if (p.name === key)
705
+ return p;
706
+ }
707
+ const numeric = typeof key === 'number' ? key : /^[0-9]+$/.test(key.replace(/^-+/, '')) ? pyInt(key) : null;
708
+ if (numeric !== null) {
709
+ if (numeric >= 0 && numeric < this.parts.length)
710
+ return this.parts[numeric];
711
+ }
712
+ const names = this.parts.map((p) => pyRepr(p.name)).join(', ');
713
+ const shown = typeof key === 'number' ? String(key) : pyRepr(key);
714
+ throw new DocumentReadError(`no part ${shown}; this document has ${this.parts.length}: ${names}`);
715
+ }
716
+ }
717
+ /** A row slice of one part. `nextOffset` is null exactly when the part is exhausted. */
718
+ export class Page {
719
+ part;
720
+ offset;
721
+ rows;
722
+ totalRows;
723
+ nextOffset;
724
+ truncatedBytes;
725
+ constructor(part, offset, rows, totalRows, nextOffset, truncatedBytes = 0) {
726
+ this.part = part;
727
+ this.offset = offset;
728
+ this.rows = rows;
729
+ this.totalRows = totalRows;
730
+ this.nextOffset = nextOffset;
731
+ this.truncatedBytes = truncatedBytes;
732
+ }
733
+ get text() {
734
+ return this.rows.join('\n');
735
+ }
736
+ }
737
+ /** One rendered row is one line: no embedded newline or tab may survive a cell value. */
738
+ function clean(text) {
739
+ return text.replace(/\t/g, ' ').replace(/\r/g, ' ').replace(/\n/g, ' ');
740
+ }
741
+ // --------------------------------------------------------------- what a file IS, from bytes
742
+ /** The container a file's own bytes declare it to be. `kind` decides; `what` explains. */
743
+ export class Container {
744
+ kind;
745
+ what;
746
+ named;
747
+ constructor(kind, what, named) {
748
+ this.kind = kind;
749
+ this.what = what;
750
+ this.named = named;
751
+ }
752
+ /** True when the name promises a container the bytes do not deliver. */
753
+ get suffixLies() {
754
+ const expected = lookup(SUFFIX_KINDS, this.named);
755
+ return expected !== undefined && expected !== this.kind;
756
+ }
757
+ }
758
+ export const SUFFIX_KINDS = {
759
+ xlsx: 'xlsx',
760
+ docx: 'docx',
761
+ pptx: 'pptx',
762
+ pdf: 'pdf',
763
+ doc: 'doc',
764
+ xls: 'doc',
765
+ ppt: 'doc',
766
+ rtf: 'rtf',
767
+ htm: 'html',
768
+ html: 'html',
769
+ mht: 'mhtml',
770
+ mhtml: 'mhtml',
771
+ eml: 'mhtml',
772
+ mov: 'isobmff',
773
+ mp4: 'isobmff',
774
+ m4a: 'isobmff',
775
+ m4v: 'isobmff',
776
+ png: 'png',
777
+ jpg: 'jpeg',
778
+ jpeg: 'jpeg',
779
+ gif: 'gif',
780
+ gz: 'gzip',
781
+ zip: 'zip',
782
+ };
783
+ const WHAT = {
784
+ doc: 'an OLE2 compound file (the pre-2007 Office binary)',
785
+ rtf: 'an RTF document',
786
+ woff2: 'a WOFF2 web font',
787
+ woff: 'a WOFF web font',
788
+ zstd: 'a zstd-compressed stream',
789
+ xz: 'an xz-compressed stream',
790
+ sqlite: 'a SQLite 3 database',
791
+ wasm: 'a WebAssembly module',
792
+ };
793
+ const MAGIC = [
794
+ [Buffer.from('%PDF-', 'latin1'), 'pdf'],
795
+ [Buffer.from('\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1', 'latin1'), 'doc'],
796
+ [Buffer.from('{\\rtf', 'latin1'), 'rtf'],
797
+ [Buffer.from('\x89PNG\r\n\x1a\n', 'latin1'), 'png'],
798
+ [Buffer.from('\xff\xd8\xff', 'latin1'), 'jpeg'],
799
+ [Buffer.from('GIF8', 'latin1'), 'gif'],
800
+ [Buffer.from('\x1f\x8b', 'latin1'), 'gzip'],
801
+ [Buffer.from('Rar!\x1a\x07', 'latin1'), 'rar'],
802
+ [Buffer.from("7z\xbc\xaf'\x1c", 'latin1'), '7z'],
803
+ [Buffer.from('%!PS', 'latin1'), 'postscript'],
804
+ [Buffer.from('\x7fELF', 'latin1'), 'elf'],
805
+ [Buffer.from('BZh', 'latin1'), 'bzip2'],
806
+ [Buffer.from('SQLite format 3\x00', 'latin1'), 'sqlite'],
807
+ [Buffer.from('\xfd7zXZ\x00', 'latin1'), 'xz'],
808
+ [Buffer.from('wOF2\x00\x01\x00\x00', 'latin1'), 'woff2'],
809
+ [Buffer.from('wOF2OTTO', 'latin1'), 'woff2'],
810
+ [Buffer.from('wOFF\x00\x01\x00\x00', 'latin1'), 'woff'],
811
+ [Buffer.from('wOFFOTTO', 'latin1'), 'woff'],
812
+ [Buffer.from('\x28\xb5\x2f\xfd', 'latin1'), 'zstd'],
813
+ [Buffer.from('\x00asm', 'latin1'), 'wasm'],
814
+ ];
815
+ const ZIP_MEMBERS = [
816
+ ['xl/workbook.xml', 'xlsx'],
817
+ ['word/document.xml', 'docx'],
818
+ ['ppt/presentation.xml', 'pptx'],
819
+ ];
820
+ export const HEAD_BYTES = 4096;
821
+ // Python's `\s` and `\b` are Unicode-aware in a `str` pattern; the classes below say so.
822
+ const HTML_HEAD = new RegExp(`<(?:!doctype${PY_WS_CLASS}+html|html(?![\\p{L}\\p{N}_])|head(?![\\p{L}\\p{N}_])|body(?![\\p{L}\\p{N}_]))`, 'iu');
823
+ const MIME_HEADER = /^[A-Za-z][A-Za-z0-9-]*:[ \t]/;
824
+ // The C0 codes a plain-text document legitimately carries: tab, the newline family, and ESC.
825
+ const TEXT_CONTROLS = new Set([0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x1b]);
826
+ function isBinaryControl(code) {
827
+ return code < 0x20 && !TEXT_CONTROLS.has(code);
828
+ }
829
+ /** The Python regex character class for the same set, over a whole string. */
830
+ const BINARY_CONTROL = new RegExp('[' +
831
+ Array.from({ length: 0x20 }, (_, c) => c)
832
+ .filter((c) => !TEXT_CONTROLS.has(c))
833
+ .map((c) => '\\x' + c.toString(16).padStart(2, '0'))
834
+ .join('') +
835
+ ']');
836
+ function startsWith(head, magic) {
837
+ if (head.length < magic.length)
838
+ return false;
839
+ for (let i = 0; i < magic.length; i += 1)
840
+ if (head[i] !== magic[i])
841
+ return false;
842
+ return true;
843
+ }
844
+ function zipKind(path, head) {
845
+ const named = pyPathSuffix(path).toLowerCase().replace(/^\.+/, '');
846
+ let names;
847
+ let mimetype = '';
848
+ try {
849
+ const zf = ZipReader.open(path);
850
+ names = zf.namelist();
851
+ // `readMember`, not `zf.read`: an encrypted `mimetype` is the encrypted sentence naming
852
+ // that member, never "damaged" (review round 3 measured the latter on an .odt), and a
853
+ // `DocumentReadError` it raises leaves this function the way `_zip_kind` lets `_read`'s.
854
+ if (names.includes('mimetype'))
855
+ mimetype = decodeUtf8(readMember(zf, 'mimetype', path));
856
+ }
857
+ catch (err) {
858
+ 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);
860
+ }
861
+ throw err;
862
+ }
863
+ const members = new Set(names);
864
+ for (const [member, kind] of ZIP_MEMBERS) {
865
+ if (members.has(member))
866
+ return new Container(kind, `an OOXML package holding ${member}`, named);
867
+ }
868
+ if (mimetype.startsWith('application/vnd.oasis.opendocument')) {
869
+ return new Container('odf', `an OpenDocument package (${mimetype})`, named);
870
+ }
871
+ 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);
873
+ }
874
+ /** `dict.get(key)`: own keys only, so `constructor` is not a hit. */
875
+ function lookup(rec, key) {
876
+ return Object.hasOwn(rec, key) ? rec[key] : undefined;
877
+ }
878
+ /**
879
+ * `except OSError`: a rejection the FILESYSTEM produced, or the `OSError` this reader raised
880
+ * itself. A Node `fs` error carries a numeric `errno` and the `syscall` it came from; a
881
+ * `zlib` error carries an `errno` (-3, `Z_DATA_ERROR`) but no syscall, and Node's own
882
+ * `ERR_INVALID_ARG_VALUE` / `ERR_STRING_TOO_LONG` carry a string `code` and nothing else.
883
+ * Until job43 G2 any Error with a string `code` passed here, so a corrupt deflate stream in a
884
+ * rels part was swallowed as "no relationships" where the reference lets `zlib.error` escape.
885
+ */
886
+ function isOsError(err) {
887
+ if (err instanceof PyOSError)
888
+ return true;
889
+ const e = err;
890
+ return err instanceof Error && typeof e.errno === 'number' && typeof e.syscall === 'string';
891
+ }
892
+ /** Decode a TRUNCATED sample, tolerating a character the sample cut in half. */
893
+ function decodeHead(head) {
894
+ const scan = utf8Scan(head, head.length < HEAD_BYTES);
895
+ if (scan.invalidAt >= 0)
896
+ return null;
897
+ return decodeUtf8(head.subarray(0, scan.end));
898
+ }
899
+ function textKind(head, named) {
900
+ const text = decodeHead(head);
901
+ if (text === null)
902
+ return null;
903
+ const stripped = pyLstripChars(text, ' \t\r\n');
904
+ const lines = pySplitlines(stripped).filter((line) => pyStrip(line));
905
+ if (lines.length && MIME_HEADER.test(lines[0])) {
906
+ const block = lines.slice(0, 20).join('\n').toLowerCase();
907
+ if (block.includes('mime-version:') || block.includes('content-type:')) {
908
+ return new Container('mhtml', 'a MIME message / MHTML web archive', named);
909
+ }
910
+ }
911
+ if (HTML_HEAD.test(sliceChars(stripped, 2048))) {
912
+ return new Container('html', 'an HTML document', named);
913
+ }
914
+ let binary = false;
915
+ for (const ch of text) {
916
+ if (isBinaryControl(ch.codePointAt(0))) {
917
+ binary = true;
918
+ break;
919
+ }
920
+ }
921
+ if (!binary)
922
+ return new Container('text', 'plain text in no document container', named);
923
+ return null;
924
+ }
925
+ /** `text[:n]` counts CODE POINTS, which `String.prototype.slice` does not. */
926
+ function sliceChars(text, n) {
927
+ let out = '';
928
+ let count = 0;
929
+ for (const ch of text) {
930
+ if (count >= n)
931
+ break;
932
+ out += ch;
933
+ count += 1;
934
+ }
935
+ return out;
936
+ }
937
+ /** `Path.is_dir()`/`exists()`/`stat().st_size`, with pathlib's tolerance for a dead link. */
938
+ function statPath(path) {
939
+ try {
940
+ const st = statSync(pyPathStr(path));
941
+ return { exists: true, isDir: st.isDirectory(), size: st.size };
942
+ }
943
+ catch (err) {
944
+ if (isOsError(err) && ['ENOENT', 'ENOTDIR', 'EBADF', 'ELOOP'].includes(err.code ?? '')) {
945
+ return { exists: false, isDir: false, size: 0 };
946
+ }
947
+ // `Path('a\x00b').exists()` is False: `os.stat` raises `ValueError("embedded null
948
+ // byte")` and pathlib's `exists`/`is_dir` catch it. Node refuses the same string with
949
+ // `ERR_INVALID_ARG_VALUE` before any syscall, and until job43 G2 that reached the wire as
950
+ // a fabricated `[Errno 0] ERR_INVALID_ARG_VALUE` where the reference says `no such file`.
951
+ if (err instanceof Error && err.code === 'ERR_INVALID_ARG_VALUE') {
952
+ return { exists: false, isDir: false, size: 0 };
953
+ }
954
+ throw err;
955
+ }
956
+ }
957
+ /**
958
+ * `handle.read(n)`: at most `n` bytes from the front of the file, never the whole file.
959
+ *
960
+ * The count is what `read(2)` RETURNS, never `st_size`: a device or procfs file reports
961
+ * 0 bytes and still answers a read. Measured (job43 F3): `/dev/zero` was `an empty file
962
+ * (0 bytes)` here while the reference read 4096 zero bytes and named `not a recognised
963
+ * container`. The buffer grows a chunk at a time so a 12-byte file does not allocate the
964
+ * 16 MiB `extract_text` may ask for.
965
+ */
966
+ function readPrefix(path, n) {
967
+ const fd = openSync(pyPathStr(path), 'r');
968
+ try {
969
+ const chunks = [];
970
+ let total = 0;
971
+ while (total < n) {
972
+ const chunk = Buffer.allocUnsafe(Math.min(n - total, 1 << 20));
973
+ const got = readSync(fd, chunk, 0, chunk.length, total);
974
+ if (got === 0)
975
+ break;
976
+ chunks.push(chunk.subarray(0, got));
977
+ total += got;
978
+ }
979
+ return chunks.length === 1 ? chunks[0] : Buffer.concat(chunks, total);
980
+ }
981
+ finally {
982
+ closeSync(fd);
983
+ }
984
+ }
985
+ /** What `path` IS. Magic bytes, then a zip's member list. The suffix is never consulted. */
986
+ export function sniff(path) {
987
+ const st = statPath(path);
988
+ if (st.isDir)
989
+ throw new DocumentReadError(`${pyPathStr(path)} is a directory, not a document`);
990
+ if (!st.exists)
991
+ throw new DocumentReadError(`no such file: ${pyPathStr(path)}`);
992
+ const named = pyPathSuffix(path).toLowerCase().replace(/^\.+/, '');
993
+ const head = readPrefix(path, HEAD_BYTES);
994
+ if (head.length === 0)
995
+ return new Container('empty', 'an empty file (0 bytes)', named);
996
+ if (head[0] === 0x50 && head[1] === 0x4b)
997
+ return zipKind(path, head);
998
+ for (const [magic, kind] of MAGIC) {
999
+ if (startsWith(head, magic)) {
1000
+ if (kind === 'pdf') {
1001
+ const version = pyStrip(head.subarray(1, 8).toString('latin1'));
1002
+ return new Container('pdf', `a PDF document (${version})`, named);
1003
+ }
1004
+ return new Container(kind, lookup(WHAT, kind) ?? `a ${kind} file`, named);
1005
+ }
1006
+ }
1007
+ if (head.subarray(4, 8).toString('latin1') === 'ftyp') {
1008
+ 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);
1010
+ }
1011
+ const guessed = textKind(head, named);
1012
+ if (guessed !== null)
1013
+ return guessed;
1014
+ return new Container('unknown', `not a recognised container; it starts with ${bytesRepr(head.subarray(0, 12))}`, named);
1015
+ }
1016
+ /** The one refusal sentence, and it names the CONTENT before it names anything else. */
1017
+ function refuse(path, container, remedy) {
1018
+ const size = statSync(pyPathStr(path)).size;
1019
+ let lie = '';
1020
+ if (container.suffixLies)
1021
+ lie = `; its name says .${container.named}, which its bytes do not`;
1022
+ return new DocumentReadError(`cannot read ${pyPathName(path)}: it is ${container.what}, ${size} bytes on disk${lie}. ${remedy}`);
1023
+ }
1024
+ function unsupportedRemedy() {
1025
+ const direct = SUPPORTED.slice(0, -1).join(', ') + ` and ${SUPPORTED[SUPPORTED.length - 1]}`;
1026
+ const extra = TEXTUTIL_SUPPORTED.slice(0, -1).join(', ') +
1027
+ ` and ${TEXTUTIL_SUPPORTED[TEXTUTIL_SUPPORTED.length - 1]}`;
1028
+ return `this reader reads ${direct} directly, and ${extra} through ${TEXTUTIL}`;
1029
+ }
1030
+ // -------------------------------------------------------------------------------- zip
1031
+ /** `zipfile.BadZipFile`. Not a `DocumentReadError`: the reference lets it through too. */
1032
+ export class BadZipFile extends Error {
1033
+ constructor(message) {
1034
+ super(message);
1035
+ this.name = 'BadZipFile';
1036
+ }
1037
+ }
1038
+ const CRC_TABLE = (() => {
1039
+ const table = new Int32Array(256);
1040
+ for (let n = 0; n < 256; n += 1) {
1041
+ let c = n;
1042
+ for (let k = 0; k < 8; k += 1)
1043
+ c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
1044
+ table[n] = c;
1045
+ }
1046
+ return table;
1047
+ })();
1048
+ export function crc32(buf) {
1049
+ let crc = -1;
1050
+ for (const b of buf)
1051
+ crc = CRC_TABLE[(crc ^ b) & 0xff] ^ (crc >>> 8);
1052
+ return (crc ^ -1) >>> 0;
1053
+ }
1054
+ // IBM code page 437, the name encoding a zip uses when its UTF-8 flag (bit 11) is clear.
1055
+ const CP437_HIGH = 'ÇüéâäàåçêëèïîìÄÅÉæÆôöòûùÿÖÜ¢£¥₧ƒáíóúñѪº¿⌐¬½¼¡«»░▒▓│┤╡╢╖╕╣║╗╝╜╛┐└┴┬├─┼╞╟╚╔╩╦╠═╬╧╨╤╥╙╘╒╓╫╪┘┌█▄▌▐▀αßΓπΣσµτΦΘΩδ∞φε∩≡±≥≤⌠⌡÷≈°∙·√ⁿ²■\xa0';
1056
+ function decodeZipName(raw, flags) {
1057
+ if (flags & 0x800)
1058
+ return decodeUtf8(raw);
1059
+ let out = '';
1060
+ for (const b of raw)
1061
+ out += b < 0x80 ? String.fromCharCode(b) : CP437_HIGH[b - 0x80];
1062
+ return out;
1063
+ }
1064
+ /** `zipfile.ZipFile` read-only: the central directory, and `read(name)` with a CRC check. */
1065
+ export class ZipReader {
1066
+ data;
1067
+ entries;
1068
+ concat;
1069
+ pathName;
1070
+ byName = new Map();
1071
+ constructor(data, entries, concat,
1072
+ /** `Path(p).name`, for the one sentence `read` composes itself (the bzip2/lzma ruling). */
1073
+ pathName) {
1074
+ this.data = data;
1075
+ this.entries = entries;
1076
+ this.concat = concat;
1077
+ this.pathName = pathName;
1078
+ for (const entry of entries)
1079
+ this.byName.set(entry.filename, entry);
1080
+ }
1081
+ static open(path) {
1082
+ return ZipReader.from(readFileSync(pyPathStr(path)), pyPathName(path));
1083
+ }
1084
+ static from(data, pathName = '') {
1085
+ // `_EndRecData`: the last 22 bytes are an EOCD with no comment, or the record is
1086
+ // searched for in the last 64 KiB + 22.
1087
+ const n = data.length;
1088
+ let eocd = -1;
1089
+ if (n >= 22 && data.readUInt32LE(n - 22) === 0x06054b50 && data.readUInt16LE(n - 2) === 0) {
1090
+ eocd = n - 22;
1091
+ }
1092
+ else {
1093
+ const start = Math.max(0, n - (1 << 16) - 22);
1094
+ for (let i = n - 22; i >= start; i -= 1) {
1095
+ if (data.readUInt32LE(i) === 0x06054b50) {
1096
+ eocd = i;
1097
+ break;
1098
+ }
1099
+ }
1100
+ }
1101
+ if (eocd < 0)
1102
+ throw new BadZipFile('File is not a zip file');
1103
+ let count = data.readUInt16LE(eocd + 10);
1104
+ let sizeCd = data.readUInt32LE(eocd + 12);
1105
+ let offsetCd = data.readUInt32LE(eocd + 16);
1106
+ let location = eocd;
1107
+ if (eocd >= 20 && data.readUInt32LE(eocd - 20) === 0x07064b50) {
1108
+ const zip64Offset = Number(data.readBigUInt64LE(eocd - 20 + 8));
1109
+ if (zip64Offset + 56 <= n && data.readUInt32LE(zip64Offset) === 0x06064b50) {
1110
+ count = Number(data.readBigUInt64LE(zip64Offset + 32));
1111
+ sizeCd = Number(data.readBigUInt64LE(zip64Offset + 40));
1112
+ offsetCd = Number(data.readBigUInt64LE(zip64Offset + 48));
1113
+ location = zip64Offset;
1114
+ }
1115
+ }
1116
+ const concat = location - sizeCd - offsetCd;
1117
+ const entries = [];
1118
+ let pos = offsetCd + concat;
1119
+ // `_RealGetContents`: `if self.start_dir < 0: raise BadZipFile(...)`. Without it the
1120
+ // first `readUInt32LE` below throws a `RangeError` carrying `ERR_OUT_OF_RANGE`.
1121
+ if (pos < 0)
1122
+ throw new BadZipFile('Bad offset for central directory');
1123
+ for (let k = 0; k < count; k += 1) {
1124
+ if (pos + 46 > n || data.readUInt32LE(pos) !== 0x02014b50) {
1125
+ throw new BadZipFile('Bad magic number for central directory');
1126
+ }
1127
+ const flags = data.readUInt16LE(pos + 8);
1128
+ const method = data.readUInt16LE(pos + 10);
1129
+ const crc = data.readUInt32LE(pos + 16);
1130
+ let compressedSize = data.readUInt32LE(pos + 20);
1131
+ let fileSize = data.readUInt32LE(pos + 24);
1132
+ const nameLen = data.readUInt16LE(pos + 28);
1133
+ const extraLen = data.readUInt16LE(pos + 30);
1134
+ const commentLen = data.readUInt16LE(pos + 32);
1135
+ let headerOffset = data.readUInt32LE(pos + 42);
1136
+ const filename = decodeZipName(data.subarray(pos + 46, pos + 46 + nameLen), flags);
1137
+ // zip64 extra field: the 0xFFFFFFFF sentinels are replaced in this order.
1138
+ let extra = pos + 46 + nameLen;
1139
+ const extraEnd = extra + extraLen;
1140
+ while (extra + 4 <= extraEnd) {
1141
+ const id = data.readUInt16LE(extra);
1142
+ const len = data.readUInt16LE(extra + 2);
1143
+ if (id === 0x0001) {
1144
+ let at = extra + 4;
1145
+ if (fileSize === 0xffffffff && at + 8 <= extraEnd) {
1146
+ fileSize = Number(data.readBigUInt64LE(at));
1147
+ at += 8;
1148
+ }
1149
+ if (compressedSize === 0xffffffff && at + 8 <= extraEnd) {
1150
+ compressedSize = Number(data.readBigUInt64LE(at));
1151
+ at += 8;
1152
+ }
1153
+ if (headerOffset === 0xffffffff && at + 8 <= extraEnd) {
1154
+ headerOffset = Number(data.readBigUInt64LE(at));
1155
+ }
1156
+ }
1157
+ extra += 4 + len;
1158
+ }
1159
+ entries.push({ filename, method, flags, crc, compressedSize, fileSize, headerOffset });
1160
+ pos += 46 + nameLen + extraLen + commentLen;
1161
+ }
1162
+ return new ZipReader(data, entries, concat, pathName);
1163
+ }
1164
+ namelist() {
1165
+ return this.entries.map((e) => e.filename);
1166
+ }
1167
+ /** `infolist()` reduced to what `_media_index` reads: name, size, and whether it is a directory. */
1168
+ infolist() {
1169
+ return this.entries.map((e) => ({
1170
+ filename: e.filename,
1171
+ fileSize: e.fileSize,
1172
+ isDir: e.filename.endsWith('/'),
1173
+ }));
1174
+ }
1175
+ has(name) {
1176
+ return this.byName.has(name);
1177
+ }
1178
+ /** `ZipFile.read(name)`; a missing member is a `KeyError` — here `RangeError`, see `readMember`. */
1179
+ read(name) {
1180
+ const entry = this.byName.get(name);
1181
+ if (entry === undefined)
1182
+ throw new RangeError(`There is no item named ${pyRepr(name)} in the archive`);
1183
+ const at = entry.headerOffset + this.concat;
1184
+ const data = this.data;
1185
+ // `ZipFile.open` seeks to `header_offset`, and a NEGATIVE offset — an EOCD whose
1186
+ // central-directory offset overshoots, so `concat` is a large negative number — is
1187
+ // `fp.seek(-n)`, which the C library refuses as `OSError(EINVAL)`. The reference lets
1188
+ // that reach the model as `[Errno 22] Invalid argument`, no filename (measured, job43
1189
+ // F3, EOCD offset 0x7FFFFFF0). This port read from a `Buffer`, so the same archive
1190
+ // threw `RangeError: The value of "offset" is out of range ...` and the handler printed
1191
+ // `[Errno 0] ERR_OUT_OF_RANGE`.
1192
+ if (at < 0)
1193
+ throw new PyOSError(22, 'EINVAL', 'Invalid argument', null);
1194
+ if (at + 30 > data.length || data.readUInt32LE(at) !== 0x04034b50) {
1195
+ throw new BadZipFile('Bad magic number for file header');
1196
+ }
1197
+ // `RuntimeError("File 'x' is encrypted, password required for extraction")`, raised
1198
+ // off the central directory's flag before the data is touched. `readMember` words it.
1199
+ if (entry.flags & 0x1)
1200
+ throw new ZipMemberUnreadable(name);
1201
+ const nameLen = data.readUInt16LE(at + 26);
1202
+ const extraLen = data.readUInt16LE(at + 28);
1203
+ const start = at + 30 + nameLen + extraLen;
1204
+ const raw = data.subarray(start, start + entry.compressedSize);
1205
+ let out;
1206
+ if (entry.method === 0)
1207
+ out = Buffer.from(raw);
1208
+ else if (entry.method === 8)
1209
+ out = inflateRaw(raw);
1210
+ else if (entry.method === 12 || entry.method === 14) {
1211
+ // DELIBERATE DIVERGENCE (docs/porting.md, "bzip2 and lzma zip members on Node"): the
1212
+ // reference's `zipfile` decompresses methods 12 and 14 through the stdlib `bz2` and
1213
+ // `lzma` modules; Node core has neither, and a decompressor is not a dependency this
1214
+ // package takes. The sentence is in the pdf/doc/rtf ruling family: what the member is,
1215
+ // which server reads it, where the ruling is written down.
1216
+ throw new ZipMethodUnsupported(`${this.pathName} is a zip but its ${name} uses compression method ${entry.method} ` +
1217
+ `(${entry.method === 12 ? 'bzip2' : 'lzma'}), which the Node server cannot decompress ` +
1218
+ '(the Python server reads it); see docs/porting.md');
1219
+ }
1220
+ else {
1221
+ // `NotImplementedError("That compression method is not supported")` — a
1222
+ // `RuntimeError` subclass on the reference, which until review round 3 made it the
1223
+ // ENCRYPTED sentence there; `_read` now catches it first and names the method number.
1224
+ throw new ZipMemberUnreadable(name, entry.method);
1225
+ }
1226
+ if (crc32(out) !== entry.crc)
1227
+ throw new BadZipFile(`Bad CRC-32 for file ${pyRepr(name)}`);
1228
+ return out;
1229
+ }
1230
+ }
1231
+ /**
1232
+ * The ruled bzip2/lzma refusal, as its own class — review round 4 (M1).
1233
+ *
1234
+ * It IS a `DocumentReadError` and its sentence is unchanged, so `docs/porting.md`'s ruling
1235
+ * and every case that pins the wording still hold. What the subclass buys is that
1236
+ * `isUnreadableOptional` can name exactly this refusal: on the reference the same members are
1237
+ * a `NotImplementedError`, which `_UNREADABLE_OPTIONAL` catches through `RuntimeError`, so a
1238
+ * TOLERANT read swallows it there and must swallow it here. Without the class the only way to
1239
+ * recognise it would be `instanceof DocumentReadError`, which would also swallow the
1240
+ * encrypted-member and shared-string refusals that the same call can raise.
1241
+ */
1242
+ export class ZipMethodUnsupported extends DocumentReadError {
1243
+ constructor(message) {
1244
+ super(message);
1245
+ // `BantamError` stamps `new.target.name`, and `name` is OBSERVABLE — `dump_py.py` prints
1246
+ // `type(e).__name__` for the reference, where the same refusal is one class. The subclass
1247
+ // is an internal distinction and must not become a second class name on the wire.
1248
+ this.name = 'DocumentReadError';
1249
+ }
1250
+ }
1251
+ /**
1252
+ * `ZipFile.read`'s `RuntimeError` family: the member is encrypted (`method` undefined), or
1253
+ * (its subclass `NotImplementedError`) compressed by a method zipfile has no decompressor
1254
+ * for (`method` is the number). Not a `DocumentReadError` itself, because the reference's
1255
+ * `_read` is what words it, and the parts `_rel_targets`/`_date_formats` read swallow it
1256
+ * silently instead.
1257
+ */
1258
+ export class ZipMemberUnreadable extends Error {
1259
+ member;
1260
+ method;
1261
+ constructor(member, method) {
1262
+ super(method === undefined
1263
+ ? `File ${pyRepr(member)} is encrypted, password required for extraction`
1264
+ : 'That compression method is not supported');
1265
+ this.member = member;
1266
+ this.method = method;
1267
+ this.name = 'ZipMemberUnreadable';
1268
+ }
1269
+ }
1270
+ /**
1271
+ * `zlib.decompressobj(-15).decompress(raw)` as `zipfile` calls it, with CPython's errors.
1272
+ *
1273
+ * A stream zlib rejects is `zlib.error("Error -3 while decompressing data: invalid block
1274
+ * type")` — the number is zlib's return code and the phrase is zlib's own `msg`, which Node
1275
+ * exposes as `errno` and `message`, so the sentence is rebuilt rather than translated. A
1276
+ * stream that merely ENDS early is not an error to `decompressobj` — it hands back what it
1277
+ * has, and `ZipExtFile` then fails the CRC — so `Z_BUF_ERROR` is retried with a sync flush
1278
+ * for the same partial bytes, and the CRC check in `read` refuses them the way it does there.
1279
+ */
1280
+ function inflateRaw(raw) {
1281
+ try {
1282
+ return inflateRawSync(raw);
1283
+ }
1284
+ catch (err) {
1285
+ const e = err;
1286
+ if (!(err instanceof Error) || typeof e.errno !== 'number')
1287
+ 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;
1295
+ }
1296
+ }
1297
+ function openZip(path) {
1298
+ if (!statPath(path).exists)
1299
+ throw new DocumentReadError(`no such file: ${pyPathStr(path)}`);
1300
+ try {
1301
+ return ZipReader.open(path);
1302
+ }
1303
+ catch (err) {
1304
+ if (!(err instanceof BadZipFile))
1305
+ throw err;
1306
+ const head = readPrefix(path, 8);
1307
+ throw new DocumentReadError(`${pyPathName(path)} is not a zip archive, so it is not an OOXML document; ` +
1308
+ `it starts with ${bytesRepr(head)} (${statSync(pyPathStr(path)).size} bytes on disk)`);
1309
+ }
1310
+ }
1311
+ /**
1312
+ * `_parse`: one part this reader cannot do without, or the refusal in the reader's words.
1313
+ *
1314
+ * The sentence carries NO line, column or parser phrase, on purpose: the reference's expat
1315
+ * and this hand-written walk would never agree on one, and the sentence is what the model
1316
+ * reads. `relTargets` and `dateFormats` are not routed through here, as `_rel_targets` and
1317
+ * `_date_formats` are not: a broken rels or styles part costs an omission or a date format,
1318
+ * never the rows.
1319
+ */
1320
+ function parsePart(data, member, path) {
1321
+ try {
1322
+ return parseXml(data);
1323
+ }
1324
+ catch (err) {
1325
+ if (!(err instanceof XmlParseError))
1326
+ throw err;
1327
+ throw new DocumentReadError(`${pyPathName(path)} is a zip but its ${member} is not well-formed XML, so this reader cannot parse it`);
1328
+ }
1329
+ }
1330
+ function readMember(zf, name, path) {
1331
+ if (!zf.has(name)) {
1332
+ const sample = pySorted(zf.namelist()).slice(0, 8).join(', ') || '(empty archive)';
1333
+ throw new DocumentReadError(`${pyPathName(path)} is a zip but has no ${name}; it contains: ${sample}`);
1334
+ }
1335
+ try {
1336
+ return zf.read(name);
1337
+ }
1338
+ catch (err) {
1339
+ // `_read`'s four arms, each the member and the fact. Encrypted and unsupported-method
1340
+ // say nothing zipfile said; damaged carries zipfile's/zlib's phrase in parentheses
1341
+ // (`Bad CRC-32 for file 'word/document.xml'`, `Error -3 while decompressing data:
1342
+ // invalid block type`) because a lying checksum and a broken stream are different facts
1343
+ // about the file, and `ZipReader`/`inflateRaw` rebuild both phrases from the same words.
1344
+ // Until review round 3 the method arm printed the encrypted sentence and the damaged
1345
+ // pair crossed the wire as `isError` frames, on both sides.
1346
+ if (err instanceof ZipMemberUnreadable) {
1347
+ if (err.method !== undefined) {
1348
+ throw new DocumentReadError(`${pyPathName(path)} is a zip but its ${name} uses compression method ${err.method}, which this reader cannot decompress`);
1349
+ }
1350
+ throw new DocumentReadError(`${pyPathName(path)} is a zip but its ${name} is encrypted, so this reader cannot read it without a password`);
1351
+ }
1352
+ if (isDamagedMember(err)) {
1353
+ throw new DocumentReadError(`${pyPathName(path)} is a zip but its ${name} is damaged (${err.message}), so this reader cannot read it`);
1354
+ }
1355
+ throw err;
1356
+ }
1357
+ }
1358
+ /** `zipfile.BadZipFile` off the CRC, or `zlib.error` off the deflate stream. */
1359
+ function isDamagedMember(err) {
1360
+ return err instanceof BadZipFile || (err instanceof Error && err.name === 'error');
1361
+ }
1362
+ /**
1363
+ * `_UNREADABLE_OPTIONAL`: why a tolerant read of `.rels` or `styles.xml` yields nothing
1364
+ * rather than raising — malformed, absent, unreadable, encrypted or unsupported-method
1365
+ * (`RuntimeError`), a lying checksum (`BadZipFile`) or a broken deflate stream (`zlib.error`).
1366
+ *
1367
+ * `ZipMethodUnsupported` is here, and that is review round 4 (M1). The reference's tuple
1368
+ * catches `RuntimeError`, which covers the `NotImplementedError` its `zipfile` raises for a
1369
+ * method it has no decompressor for; this list covered method 9 (`ZipMemberUnreadable`) but
1370
+ * not 12 and 14, which this port answers with the ruled bzip2/lzma refusal instead. MEASURED
1371
+ * on an `.xlsx` whose OPTIONAL `xl/styles.xml` is bzip2-compressed: the reference read the
1372
+ * whole workbook and this port refused it — no manifest, no parts. `docs/porting.md`'s bzip2
1373
+ * row rules the REQUIRED member and has no optional-member fixture, so that difference was
1374
+ * unruled and unpinned; `bzip2-optional-styles.xlsx` is the fixture it lacked, and the two
1375
+ * runtimes now answer the same bytes for it.
1376
+ *
1377
+ * The list is a CLASS list and the comment above it names the reference's classes; the two
1378
+ * are the same SET, which is the claim the paragraph above used to make while the code
1379
+ * quietly did not deliver it.
1380
+ */
1381
+ function isUnreadableOptional(err) {
1382
+ return (err instanceof XmlParseError ||
1383
+ err instanceof RangeError ||
1384
+ isOsError(err) ||
1385
+ err instanceof ZipMemberUnreadable ||
1386
+ err instanceof ZipMethodUnsupported ||
1387
+ isDamagedMember(err));
1388
+ }
1389
+ // -------------------------------------------------------------------------------- xml
1390
+ /** `xml.etree.ElementTree.ParseError`. Not a `DocumentReadError`: the reference lets it through. */
1391
+ export class XmlParseError extends Error {
1392
+ constructor(message) {
1393
+ super(message);
1394
+ this.name = 'XmlParseError';
1395
+ }
1396
+ }
1397
+ /** An element the way ET exposes one: expanded `{ns}tag`, attributes, `.text`, children. */
1398
+ export class XmlElement {
1399
+ tag;
1400
+ attrs;
1401
+ text = '';
1402
+ children = [];
1403
+ constructor(tag, attrs) {
1404
+ this.tag = tag;
1405
+ this.attrs = attrs;
1406
+ }
1407
+ get(name) {
1408
+ return this.attrs.get(name);
1409
+ }
1410
+ /** `Element.find(tag)`: the first DIRECT child with that tag. */
1411
+ find(tag) {
1412
+ return this.children.find((c) => c.tag === tag);
1413
+ }
1414
+ /** `Element.iter(tag)`: this element and every descendant, document order. */
1415
+ *iter(tag) {
1416
+ if (tag === undefined || this.tag === tag)
1417
+ yield this;
1418
+ for (const child of this.children)
1419
+ yield* child.iter(tag);
1420
+ }
1421
+ }
1422
+ const XML_NS = 'http://www.w3.org/XML/1998/namespace';
1423
+ const XML_NAME = /[:A-Za-z_\p{L}\p{Nl}][-.:A-Za-z0-9_\u00b7\p{L}\p{Nl}\p{Mn}\p{Mc}\p{Nd}\p{Pc}]*/uy;
1424
+ const XML_ENTITIES = new Map([
1425
+ ['amp', '&'],
1426
+ ['lt', '<'],
1427
+ ['gt', '>'],
1428
+ ['quot', '"'],
1429
+ ['apos', "'"],
1430
+ ]);
1431
+ /** XML 1.0 `Char`: what a character reference may name. `&#0;` and a surrogate may not. */
1432
+ function isXmlChar(cp) {
1433
+ return (cp === 0x9 ||
1434
+ cp === 0xa ||
1435
+ cp === 0xd ||
1436
+ (cp >= 0x20 && cp <= 0xd7ff) ||
1437
+ (cp >= 0xe000 && cp <= 0xfffd) ||
1438
+ (cp >= 0x10000 && cp <= 0x10ffff));
1439
+ }
1440
+ /**
1441
+ * A declared encoding expat has no decoder for. `LookupError("unknown encoding: x")` on the
1442
+ * reference — NOT an `ET.ParseError`, so `_parse` does not word it and it escapes to the
1443
+ * wire as a raised exception; the same class of thing here, deliberately not `XmlParseError`.
1444
+ */
1445
+ export class XmlEncodingError extends Error {
1446
+ constructor(encoding) {
1447
+ super(`unknown encoding: ${encoding}`);
1448
+ this.name = 'LookupError';
1449
+ }
1450
+ }
1451
+ // `cp1252` bytes 0x80–0x9F, 0 where the codec has no character (0x81, 0x8D, 0x8F, 0x90, 0x9D).
1452
+ const CP1252_HIGH = [
1453
+ 0x20ac, 0, 0x201a, 0x0192, 0x201e, 0x2026, 0x2020, 0x2021, 0x02c6, 0x2030, 0x0160, 0x2039, 0x0152, 0, 0x017d, 0,
1454
+ 0, 0x2018, 0x2019, 0x201c, 0x201d, 0x2022, 0x2013, 0x2014, 0x02dc, 0x2122, 0x0161, 0x203a, 0x0153, 0, 0x017e, 0x0178,
1455
+ ];
1456
+ // `<?xml version="1.0" encoding="..." standalone="..."?>` — the three in that order, or
1457
+ // expat's "XML declaration not well-formed".
1458
+ const XML_DECL = /^<\?xml[ \t\r\n]+version[ \t\r\n]*=[ \t\r\n]*(["'])1\.[0-9]+\1(?:[ \t\r\n]+encoding[ \t\r\n]*=[ \t\r\n]*(["'])([A-Za-z][A-Za-z0-9._-]*)\2)?(?:[ \t\r\n]+standalone[ \t\r\n]*=[ \t\r\n]*(["'])(?:yes|no)\4)?[ \t\r\n]*\?>/;
1459
+ /**
1460
+ * The bytes of an XML part as the characters expat hands ET.
1461
+ *
1462
+ * A byte-order mark decides first (UTF-8's is dropped, UTF-16's picks the byte order); then
1463
+ * the declaration's `encoding`; then UTF-8. The property, measured against `ET.fromstring`
1464
+ * (job43 G2): UTF-8 is STRICT — `<a>caf\xe9</a>` with no declaration is "not well-formed
1465
+ * (invalid token)", where until G2 this port decoded it lossily to `caf�` and read a
1466
+ * row the reference refuses; `encoding="ISO-8859-1"` over the same byte reads `café`, and
1467
+ * over `\xc3\xa9` reads `é`, because a declaration is obeyed and not sniffed past;
1468
+ * `US-ASCII` refuses any byte over 0x7f; a name no codec answers to is `LookupError`.
1469
+ * Every other name goes to `TextDecoder`, which has the single-byte codecs CPython has
1470
+ * (windows-125x, iso-8859-x, koi8) but not utf-7 — the docs/porting.md utf-7 row.
1471
+ */
1472
+ function decodeXmlSource(data) {
1473
+ let bytes = data;
1474
+ if (bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf)
1475
+ bytes = bytes.subarray(3);
1476
+ // A UTF-16 byte-order mark, or expat's BOM-less detection off the first `<`: `3C 00` is
1477
+ // little-endian, `00 3C` big-endian.
1478
+ const bom = (bytes[0] === 0xff && bytes[1] === 0xfe) || (bytes[0] === 0xfe && bytes[1] === 0xff);
1479
+ const bare16 = (bytes[0] === 0x3c && bytes[1] === 0x00) || (bytes[0] === 0x00 && bytes[1] === 0x3c);
1480
+ if (bom || bare16) {
1481
+ const order = bytes[0] === 0xff || bytes[0] === 0x3c ? 'utf-16le' : 'utf-16be';
1482
+ try {
1483
+ return new TextDecoder(order, { fatal: true, ignoreBOM: !bom }).decode(bytes);
1484
+ }
1485
+ catch {
1486
+ throw new XmlParseError('not well-formed (invalid token)');
1487
+ }
1488
+ }
1489
+ const head = Buffer.from(bytes.buffer, bytes.byteOffset, Math.min(bytes.length, 1024)).toString('latin1');
1490
+ let encoding = null;
1491
+ if (/^<\?xml[ \t\r\n?]/.test(head)) {
1492
+ const m = XML_DECL.exec(head);
1493
+ if (!m)
1494
+ throw new XmlParseError('XML declaration not well-formed');
1495
+ encoding = m[3] === undefined ? null : m[3].toLowerCase();
1496
+ }
1497
+ const name = (encoding ?? 'utf-8').replace(/_/g, '-');
1498
+ if (['utf-8', 'utf8'].includes(name)) {
1499
+ const scan = utf8Scan(bytes, true);
1500
+ if (scan.invalidAt >= 0)
1501
+ throw new XmlParseError('not well-formed (invalid token)');
1502
+ return decodeUtf8(bytes);
1503
+ }
1504
+ if (['iso-8859-1', 'iso8859-1', 'latin-1', 'latin1', 'l1', '8859', 'cp819'].includes(name)) {
1505
+ return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.length).toString('latin1');
1506
+ }
1507
+ if (['us-ascii', 'ascii', 'us', '646', 'ansi-x3.4-1968'].includes(name)) {
1508
+ for (const b of bytes)
1509
+ if (b >= 0x80)
1510
+ throw new XmlParseError('not well-formed (invalid token)');
1511
+ return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.length).toString('latin1');
1512
+ }
1513
+ if (['utf-16', 'utf16'].includes(name)) {
1514
+ // No BOM: expat assumes the little-endian order (measured on the reference).
1515
+ return new TextDecoder('utf-16le', { fatal: true, ignoreBOM: true }).decode(bytes);
1516
+ }
1517
+ if (['windows-1252', 'cp1252', '1252'].includes(name)) {
1518
+ // Node's `TextDecoder('windows-1252')` takes a latin1 fast path and answers U+0093 for
1519
+ // 0x93 (measured, job43 G2); CPython's `cp1252` answers U+201C, and expat sees the codec's
1520
+ // answer. The 0x80–0x9F row is spelled out; a byte the codec has no character for is
1521
+ // what expat refuses (measured: 0x81, 0x8D, 0x8F, 0x90, 0x9D are "not well-formed").
1522
+ let out = '';
1523
+ for (const b of bytes) {
1524
+ if (b < 0x80 || b >= 0xa0)
1525
+ out += String.fromCharCode(b);
1526
+ else {
1527
+ const ch = CP1252_HIGH[b - 0x80];
1528
+ if (ch === 0)
1529
+ throw new XmlParseError('not well-formed (invalid token)');
1530
+ out += String.fromCharCode(ch);
1531
+ }
1532
+ }
1533
+ return out;
1534
+ }
1535
+ let decoder;
1536
+ try {
1537
+ decoder = new TextDecoder(name, { fatal: true, ignoreBOM: true });
1538
+ }
1539
+ catch {
1540
+ throw new XmlEncodingError(encoding);
1541
+ }
1542
+ try {
1543
+ return decoder.decode(bytes);
1544
+ }
1545
+ catch {
1546
+ throw new XmlParseError('not well-formed (invalid token)');
1547
+ }
1548
+ }
1549
+ // Every character of the decoded document must be an XML 1.0 `Char`: `\x01`, `\x0c` and
1550
+ // NUL in text, in an attribute, anywhere, are "not well-formed (invalid token)" to expat
1551
+ // (measured, job43 G2); `\x7f` and the C1 range are allowed. A lone surrogate cannot come
1552
+ // out of the decoders above, so the class is the BMP's non-characters and C0.
1553
+ // eslint-disable-next-line no-control-regex
1554
+ const NOT_XML_CHAR = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\ufffe\uffff]/;
1555
+ /**
1556
+ * A reference's body — `#123`, `#x1F`, `lt`, or a declared name — resolved through the
1557
+ * predefined five and `entities`, or `XmlParseError`; `resolve` is how the caller expands a
1558
+ * declared entity in ITS context (an attribute value cannot hold markup, a text run can).
1559
+ */
1560
+ function refValue(body, entities, resolve) {
1561
+ let cp;
1562
+ if (/^#x[0-9a-fA-F]+$/.test(body))
1563
+ cp = parseInt(body.slice(2), 16);
1564
+ else if (/^#[0-9]+$/.test(body))
1565
+ cp = parseInt(body.slice(1), 10);
1566
+ else {
1567
+ const known = XML_ENTITIES.get(body);
1568
+ if (known !== undefined)
1569
+ return known;
1570
+ if (!/^[A-Za-z_:][-.:A-Za-z0-9_]*$/.test(body))
1571
+ throw new XmlParseError('not well-formed (invalid token)');
1572
+ if (!entities.has(body))
1573
+ throw new XmlParseError(`undefined entity: &${body};`);
1574
+ return resolve(body);
1575
+ }
1576
+ if (!isXmlChar(cp))
1577
+ throw new XmlParseError('reference to invalid character number');
1578
+ return String.fromCodePoint(cp);
1579
+ }
1580
+ /**
1581
+ * Text and attribute values with their references expanded, or `XmlParseError`.
1582
+ *
1583
+ * Every `&` MUST begin a well-formed reference — expat says `not well-formed (invalid
1584
+ * token)` for a bare one, `&amp b` without its semicolon, `&#0;`, `&#xD800;` (a surrogate)
1585
+ * and `&#x110000;` (past Unicode). Until job43 F3 this port accepted all of them: `a & b`
1586
+ * came back as a row where the reference refused the whole part, and `&#x110000;` threw a
1587
+ * `RangeError` out of `String.fromCodePoint`. The five predefined names are the only named
1588
+ * entities an OOXML part may use without a DTD; a name the internal subset declared is
1589
+ * expanded through `resolve` (job43 G2), and any other name is "undefined entity".
1590
+ */
1591
+ function xmlUnescape(text, entities = NO_ENTITIES, resolve = undefinedEntity) {
1592
+ if (!text.includes('&'))
1593
+ return text;
1594
+ let out = '';
1595
+ let i = 0;
1596
+ for (;;) {
1597
+ const amp = text.indexOf('&', i);
1598
+ if (amp < 0)
1599
+ return out + text.slice(i);
1600
+ out += text.slice(i, amp);
1601
+ const semi = text.indexOf(';', amp + 1);
1602
+ if (semi < 0)
1603
+ throw new XmlParseError('not well-formed (invalid token)');
1604
+ out += refValue(text.slice(amp + 1, semi), entities, resolve);
1605
+ i = semi + 1;
1606
+ }
1607
+ }
1608
+ const NO_ENTITIES = new Map();
1609
+ function undefinedEntity(name) {
1610
+ throw new XmlParseError(`undefined entity: &${name};`);
1611
+ }
1612
+ /**
1613
+ * The internal subset of a `<!DOCTYPE ... [ ... ]>`: its `<!ENTITY name "value">`
1614
+ * declarations, and where the declaration ends.
1615
+ *
1616
+ * What expat does with the subset, reduced to what an OOXML part could carry: a general
1617
+ * entity with a literal value is declared (the first declaration of a name wins; a
1618
+ * character reference in the value is expanded at declaration time, a general-entity
1619
+ * reference is kept for use time, a bare `&` is refused); a parameter entity (`%`), an
1620
+ * external entity (`SYSTEM`/`PUBLIC`) and every other declaration (`ELEMENT`, `ATTLIST`,
1621
+ * `NOTATION`) are skipped over their quoted strings; comments and processing instructions
1622
+ * inside the subset are skipped, so an `<!ENTITY>` inside a comment declares nothing.
1623
+ * Measured against `ET.fromstring` on fourteen subset shapes (job43 G2).
1624
+ */
1625
+ function parseDoctype(src, at) {
1626
+ const entities = new Map();
1627
+ const n = src.length;
1628
+ let i = at + 2;
1629
+ const skipQuoted = () => {
1630
+ const q = src[i];
1631
+ const close = src.indexOf(q, i + 1);
1632
+ if (close < 0)
1633
+ throw new XmlParseError('unterminated declaration');
1634
+ i = close + 1;
1635
+ };
1636
+ // The prolog of the declaration: up to `[` or `>`, honouring quoted external identifiers.
1637
+ for (; i < n; i += 1) {
1638
+ const ch = src[i];
1639
+ if (ch === '"' || ch === "'") {
1640
+ skipQuoted();
1641
+ i -= 1;
1642
+ }
1643
+ else if (ch === '[' || ch === '>')
1644
+ break;
1645
+ }
1646
+ if (i >= n)
1647
+ throw new XmlParseError('unterminated declaration');
1648
+ if (src[i] === '>')
1649
+ return { end: i + 1, entities };
1650
+ i += 1;
1651
+ for (;;) {
1652
+ while (i < n && ' \t\n'.includes(src[i]))
1653
+ i += 1;
1654
+ if (i >= n)
1655
+ throw new XmlParseError('unterminated declaration');
1656
+ if (src[i] === ']')
1657
+ break;
1658
+ if (src.startsWith('<!--', i)) {
1659
+ const j = src.indexOf('-->', i + 4);
1660
+ if (j < 0)
1661
+ throw new XmlParseError('unterminated comment');
1662
+ i = j + 3;
1663
+ continue;
1664
+ }
1665
+ if (src.startsWith('<?', i)) {
1666
+ const j = src.indexOf('?>', i + 2);
1667
+ if (j < 0)
1668
+ throw new XmlParseError('unterminated processing instruction');
1669
+ i = j + 2;
1670
+ continue;
1671
+ }
1672
+ if (src[i] === '%') {
1673
+ // A parameter-entity reference in the subset: `%name;` — nothing to expand here.
1674
+ const j = src.indexOf(';', i + 1);
1675
+ if (j < 0)
1676
+ throw new XmlParseError('not well-formed (invalid token)');
1677
+ i = j + 1;
1678
+ continue;
1679
+ }
1680
+ if (!src.startsWith('<!', i))
1681
+ throw new XmlParseError('not well-formed (invalid token)');
1682
+ const isEntity = src.startsWith('<!ENTITY', i) && ' \t\n'.includes(src[i + 8] ?? '');
1683
+ i += 2;
1684
+ let name = null;
1685
+ let value = null;
1686
+ if (isEntity) {
1687
+ i += 6;
1688
+ while (i < n && ' \t\n'.includes(src[i]))
1689
+ i += 1;
1690
+ let parameter = false;
1691
+ if (src[i] === '%') {
1692
+ parameter = true;
1693
+ i += 1;
1694
+ while (i < n && ' \t\n'.includes(src[i]))
1695
+ i += 1;
1696
+ }
1697
+ XML_NAME.lastIndex = i;
1698
+ const m = XML_NAME.exec(src);
1699
+ if (!m || m.index !== i)
1700
+ throw new XmlParseError('not well-formed (invalid token)');
1701
+ i += m[0].length;
1702
+ while (i < n && ' \t\n'.includes(src[i]))
1703
+ i += 1;
1704
+ if (src[i] === '"' || src[i] === "'") {
1705
+ const q = src[i];
1706
+ const close = src.indexOf(q, i + 1);
1707
+ if (close < 0)
1708
+ throw new XmlParseError('unterminated declaration');
1709
+ if (!parameter) {
1710
+ name = m[0];
1711
+ value = src.slice(i + 1, close);
1712
+ }
1713
+ i = close + 1;
1714
+ }
1715
+ }
1716
+ // The rest of this declaration, to its `>`, over any quoted string.
1717
+ for (; i < n; i += 1) {
1718
+ const ch = src[i];
1719
+ if (ch === '"' || ch === "'") {
1720
+ skipQuoted();
1721
+ i -= 1;
1722
+ }
1723
+ else if (ch === '>')
1724
+ break;
1725
+ }
1726
+ if (i >= n)
1727
+ throw new XmlParseError('unterminated declaration');
1728
+ i += 1;
1729
+ if (name !== null && value !== null && !entities.has(name)) {
1730
+ // Character references are expanded now; `&name;` stays for use time; `&` bare or
1731
+ // `%` in a literal are refused as expat refuses them.
1732
+ let text = '';
1733
+ let k = 0;
1734
+ for (;;) {
1735
+ const amp = value.indexOf('&', k);
1736
+ if (amp < 0) {
1737
+ text += value.slice(k);
1738
+ break;
1739
+ }
1740
+ text += value.slice(k, amp);
1741
+ const semi = value.indexOf(';', amp + 1);
1742
+ if (semi < 0)
1743
+ throw new XmlParseError('not well-formed (invalid token)');
1744
+ const body = value.slice(amp + 1, semi);
1745
+ if (body.startsWith('#'))
1746
+ text += refValue(body, NO_ENTITIES, undefinedEntity);
1747
+ else if (/^[A-Za-z_:][-.:A-Za-z0-9_]*$/.test(body))
1748
+ text += `&${body};`;
1749
+ else
1750
+ throw new XmlParseError('not well-formed (invalid token)');
1751
+ k = semi + 1;
1752
+ }
1753
+ if (text.includes('%'))
1754
+ throw new XmlParseError('not well-formed (invalid token)');
1755
+ entities.set(name, text);
1756
+ }
1757
+ }
1758
+ i += 1;
1759
+ while (i < n && ' \t\n'.includes(src[i]))
1760
+ i += 1;
1761
+ if (src[i] !== '>')
1762
+ throw new XmlParseError('not well-formed (invalid token)');
1763
+ return { end: i + 1, entities };
1764
+ }
1765
+ /**
1766
+ * A strict-enough XML reader: one root, matched tags, declared prefixes, the five
1767
+ * predefined entities and the internal subset's own, comments and PIs dropped, CDATA kept,
1768
+ * line ends normalised, and attribute values whitespace-normalised — what expat gives ET
1769
+ * for an OOXML part.
1770
+ *
1771
+ * A declared entity is expanded the way expat expands it: in a text run its replacement
1772
+ * text is PARSED in place, so `<!ENTITY e "<x>in</x>y">` yields an element, and a
1773
+ * replacement that opens what it does not close is "asynchronous entity"; in an attribute
1774
+ * value it is expanded as text and may not contain `<`; a reference to the entity being
1775
+ * expanded is "recursive entity reference". Measured against `ET.fromstring` (job43 G2).
1776
+ */
1777
+ export function parseXml(data) {
1778
+ const src = decodeXmlSource(data).replace(/\r\n?/g, '\n');
1779
+ if (NOT_XML_CHAR.test(src))
1780
+ throw new XmlParseError('not well-formed (invalid token)');
1781
+ const stack = [];
1782
+ let root;
1783
+ let entities = NO_ENTITIES;
1784
+ let doctypeSeen = false;
1785
+ const expanding = new Set();
1786
+ const appendText = (text) => {
1787
+ const top = stack[stack.length - 1];
1788
+ if (top === undefined) {
1789
+ if (pyStrip(text))
1790
+ throw new XmlParseError('text outside the root element');
1791
+ return;
1792
+ }
1793
+ if (top.el.children.length === 0)
1794
+ top.el.text += text;
1795
+ };
1796
+ /** A declared entity in an attribute value: text only, no markup, no cycles. */
1797
+ const attrEntity = (name) => {
1798
+ if (expanding.has(name))
1799
+ throw new XmlParseError('recursive entity reference');
1800
+ const replacement = entities.get(name);
1801
+ if (replacement.includes('<'))
1802
+ throw new XmlParseError('not well-formed (invalid token)');
1803
+ expanding.add(name);
1804
+ try {
1805
+ // Attribute-value normalisation reaches into the replacement text: a literal tab or
1806
+ // newline there (a character reference's included) becomes a space.
1807
+ return xmlUnescape(replacement.replace(/[\t\n]/g, ' '), entities, attrEntity);
1808
+ }
1809
+ finally {
1810
+ expanding.delete(name);
1811
+ }
1812
+ };
1813
+ /** A declared entity in a text run: its replacement text parsed here, at this depth. */
1814
+ const textEntity = (name) => {
1815
+ if (expanding.has(name))
1816
+ throw new XmlParseError('recursive entity reference');
1817
+ const depth = stack.length;
1818
+ expanding.add(name);
1819
+ try {
1820
+ scan(entities.get(name), true);
1821
+ }
1822
+ finally {
1823
+ expanding.delete(name);
1824
+ }
1825
+ if (stack.length !== depth)
1826
+ throw new XmlParseError('asynchronous entity');
1827
+ };
1828
+ /** A run of character data: references expanded, declared entities parsed in place. */
1829
+ const emitText = (run) => {
1830
+ if (run.includes(']]>'))
1831
+ throw new XmlParseError('not well-formed (invalid token)');
1832
+ let i = 0;
1833
+ for (;;) {
1834
+ const amp = run.indexOf('&', i);
1835
+ if (amp < 0) {
1836
+ appendText(xmlUnescape(run.slice(i)));
1837
+ return;
1838
+ }
1839
+ const semi = run.indexOf(';', amp + 1);
1840
+ if (semi < 0)
1841
+ throw new XmlParseError('not well-formed (invalid token)');
1842
+ const body = run.slice(amp + 1, semi);
1843
+ if (body.startsWith('#') || !entities.has(body)) {
1844
+ appendText(xmlUnescape(run.slice(i, semi + 1), entities, undefinedEntity));
1845
+ }
1846
+ else {
1847
+ appendText(xmlUnescape(run.slice(i, amp)));
1848
+ textEntity(body);
1849
+ }
1850
+ i = semi + 1;
1851
+ }
1852
+ };
1853
+ const scan = (text, inEntity) => {
1854
+ let i = 0;
1855
+ const n = text.length;
1856
+ const fail = (what) => {
1857
+ throw new XmlParseError(`${what} at offset ${i}`);
1858
+ };
1859
+ const expand = (qname, ns, isAttr) => {
1860
+ const colon = qname.indexOf(':');
1861
+ if (colon < 0) {
1862
+ if (isAttr)
1863
+ return qname;
1864
+ const def = ns.get('');
1865
+ return def ? `{${def}}${qname}` : qname;
1866
+ }
1867
+ const prefix = qname.slice(0, colon);
1868
+ const local = qname.slice(colon + 1);
1869
+ if (prefix === 'xml')
1870
+ return `{${XML_NS}}${local}`;
1871
+ const uri = ns.get(prefix);
1872
+ if (uri === undefined)
1873
+ return fail(`unbound prefix ${prefix}`);
1874
+ return `{${uri}}${local}`;
1875
+ };
1876
+ while (i < n) {
1877
+ if (text[i] !== '<') {
1878
+ const j = text.indexOf('<', i);
1879
+ const end = j < 0 ? n : j;
1880
+ emitText(text.slice(i, end));
1881
+ i = end;
1882
+ continue;
1883
+ }
1884
+ if (text.startsWith('<!--', i)) {
1885
+ const j = text.indexOf('-->', i + 4);
1886
+ if (j < 0)
1887
+ fail('unterminated comment');
1888
+ i = j + 3;
1889
+ continue;
1890
+ }
1891
+ if (text.startsWith('<![CDATA[', i)) {
1892
+ const j = text.indexOf(']]>', i + 9);
1893
+ if (j < 0)
1894
+ fail('unterminated CDATA section');
1895
+ appendText(text.slice(i + 9, j));
1896
+ i = j + 3;
1897
+ continue;
1898
+ }
1899
+ if (text.startsWith('<?', i)) {
1900
+ const j = text.indexOf('?>', i + 2);
1901
+ if (j < 0)
1902
+ fail('unterminated processing instruction');
1903
+ // `<?xml ...?>` anywhere but the very start is "XML or text declaration not at
1904
+ // start of entity"; at the start it was read by `decodeXmlSource` already.
1905
+ if (i !== 0 && /^<\?xml(?![-.:A-Za-z0-9_])/i.test(text.slice(i, i + 6))) {
1906
+ fail('XML or text declaration not at start of entity');
1907
+ }
1908
+ i = j + 2;
1909
+ continue;
1910
+ }
1911
+ if (text.startsWith('<!', i)) {
1912
+ if (!text.startsWith('<!DOCTYPE', i) || inEntity || root !== undefined || stack.length || doctypeSeen) {
1913
+ fail('not well-formed (invalid token)');
1914
+ }
1915
+ doctypeSeen = true;
1916
+ const doctype = parseDoctype(text, i);
1917
+ entities = doctype.entities;
1918
+ i = doctype.end;
1919
+ continue;
1920
+ }
1921
+ if (text.startsWith('</', i)) {
1922
+ XML_NAME.lastIndex = i + 2;
1923
+ const m = XML_NAME.exec(text);
1924
+ if (!m)
1925
+ return fail('malformed end tag');
1926
+ let j = i + 2 + m[0].length;
1927
+ while (j < n && ' \t\n'.includes(text[j]))
1928
+ j += 1;
1929
+ if (text[j] !== '>')
1930
+ return fail('malformed end tag');
1931
+ const top = stack.pop();
1932
+ if (top === undefined)
1933
+ return fail('end tag with no open element');
1934
+ const expected = expand(m[0], top.ns, false);
1935
+ if (expected !== top.el.tag)
1936
+ fail('mismatched tag');
1937
+ i = j + 1;
1938
+ continue;
1939
+ }
1940
+ // A start tag.
1941
+ XML_NAME.lastIndex = i + 1;
1942
+ const m = XML_NAME.exec(text);
1943
+ if (!m)
1944
+ return fail('not well-formed');
1945
+ const qname = m[0];
1946
+ let j = i + 1 + qname.length;
1947
+ const rawAttrs = [];
1948
+ let selfClosing = false;
1949
+ for (;;) {
1950
+ while (j < n && ' \t\n'.includes(text[j]))
1951
+ j += 1;
1952
+ if (j >= n)
1953
+ fail('unterminated start tag');
1954
+ if (text[j] === '>') {
1955
+ j += 1;
1956
+ break;
1957
+ }
1958
+ if (text[j] === '/' && text[j + 1] === '>') {
1959
+ selfClosing = true;
1960
+ j += 2;
1961
+ break;
1962
+ }
1963
+ XML_NAME.lastIndex = j;
1964
+ const am = XML_NAME.exec(text);
1965
+ if (!am)
1966
+ return fail('malformed attribute');
1967
+ j += am[0].length;
1968
+ while (j < n && ' \t\n'.includes(text[j]))
1969
+ j += 1;
1970
+ if (text[j] !== '=')
1971
+ fail('attribute without value');
1972
+ j += 1;
1973
+ while (j < n && ' \t\n'.includes(text[j]))
1974
+ j += 1;
1975
+ const quote = text[j];
1976
+ if (quote !== '"' && quote !== "'")
1977
+ return fail('unquoted attribute value');
1978
+ const close = text.indexOf(quote, j + 1);
1979
+ if (close < 0)
1980
+ fail('unterminated attribute value');
1981
+ const value = text.slice(j + 1, close);
1982
+ if (value.includes('<'))
1983
+ fail("'<' in attribute value");
1984
+ rawAttrs.push([am[0], xmlUnescape(value.replace(/[\t\n]/g, ' '), entities, attrEntity)]);
1985
+ j = close + 1;
1986
+ }
1987
+ const parentNs = stack[stack.length - 1]?.ns ?? new Map();
1988
+ let ns = parentNs;
1989
+ for (const [name, value] of rawAttrs) {
1990
+ if (name === 'xmlns' || name.startsWith('xmlns:')) {
1991
+ if (ns === parentNs)
1992
+ ns = new Map(parentNs);
1993
+ ns.set(name === 'xmlns' ? '' : name.slice(6), value);
1994
+ }
1995
+ }
1996
+ const attrs = new Map();
1997
+ for (const [name, value] of rawAttrs) {
1998
+ if (name === 'xmlns' || name.startsWith('xmlns:'))
1999
+ continue;
2000
+ const key = expand(name, ns, true);
2001
+ if (attrs.has(key))
2002
+ fail('duplicate attribute');
2003
+ attrs.set(key, value);
2004
+ }
2005
+ const el = new XmlElement(expand(qname, ns, false), attrs);
2006
+ const parent = stack[stack.length - 1];
2007
+ if (parent === undefined) {
2008
+ if (root !== undefined)
2009
+ fail('junk after document element');
2010
+ root = el;
2011
+ }
2012
+ else {
2013
+ parent.el.children.push(el);
2014
+ }
2015
+ if (!selfClosing)
2016
+ stack.push({ el, ns });
2017
+ i = j;
2018
+ }
2019
+ };
2020
+ scan(src, false);
2021
+ if (stack.length)
2022
+ throw new XmlParseError('unclosed element');
2023
+ if (root === undefined)
2024
+ throw new XmlParseError('no element found');
2025
+ return root;
2026
+ }
2027
+ // ------------------------------------------- what an OOXML package holds that no row carries
2028
+ const MEDIA_MEMBER = /(?:^|\/)(?:media|embeddings)\/[^/]+$/;
2029
+ function mediaIndex(zf) {
2030
+ const out = new Map();
2031
+ for (const info of zf.infolist()) {
2032
+ if (!info.isDir && MEDIA_MEMBER.test(info.filename))
2033
+ out.set(info.filename, info.fileSize);
2034
+ }
2035
+ return out;
2036
+ }
2037
+ function relTargets(zf, member, members) {
2038
+ const rels = posix.join(posix.dirname(member), '_rels', posix.basename(member) + '.rels');
2039
+ if (!members.has(rels))
2040
+ return [];
2041
+ let tree;
2042
+ try {
2043
+ tree = parseXml(zf.read(rels));
2044
+ }
2045
+ catch (err) {
2046
+ if (isUnreadableOptional(err))
2047
+ return [];
2048
+ throw err;
2049
+ }
2050
+ const out = [];
2051
+ for (const rel of tree.children) {
2052
+ const target = rel.get('Target') ?? '';
2053
+ if (!target || rel.get('TargetMode') === 'External' || target.includes('://'))
2054
+ continue;
2055
+ if (target.startsWith('/'))
2056
+ out.push(target.replace(/^\/+/, ''));
2057
+ else
2058
+ out.push(posix.normalize(posix.join(posix.dirname(member), target)));
2059
+ }
2060
+ return out;
2061
+ }
2062
+ function anchoredMedia(zf, member, media) {
2063
+ const members = new Set(zf.namelist());
2064
+ const reached = new Map();
2065
+ for (const first of relTargets(zf, member, members)) {
2066
+ const direct = media.get(first);
2067
+ if (direct !== undefined) {
2068
+ reached.set(first, direct);
2069
+ continue;
2070
+ }
2071
+ for (const second of relTargets(zf, first, members)) {
2072
+ const size = media.get(second);
2073
+ if (size !== undefined)
2074
+ reached.set(second, size);
2075
+ }
2076
+ }
2077
+ return reached;
2078
+ }
2079
+ const BUILTIN_DATE_FORMATS = {
2080
+ 14: 'mm-dd-yy',
2081
+ 15: 'd-mmm-yy',
2082
+ 16: 'd-mmm',
2083
+ 17: 'mmm-yy',
2084
+ 18: 'h:mm AM/PM',
2085
+ 19: 'h:mm:ss AM/PM',
2086
+ 20: 'h:mm',
2087
+ 21: 'h:mm:ss',
2088
+ 22: 'm/d/yy h:mm',
2089
+ 45: 'mm:ss',
2090
+ 46: '[h]:mm:ss',
2091
+ 47: 'mmss.0',
2092
+ };
2093
+ function isLocaleDateId(id) {
2094
+ return (id >= 27 && id <= 36) || (id >= 50 && id <= 58);
2095
+ }
2096
+ const FORMAT_LITERAL = /\[[^\]]*\]|"[^"]*"|\\[^\n]/g;
2097
+ export function isDateFormat(code) {
2098
+ const bare = code.replace(FORMAT_LITERAL, '').split(';')[0].toLowerCase();
2099
+ return [...'ymdhs'].some((ch) => bare.includes(ch));
2100
+ }
2101
+ function dateFormats(zf) {
2102
+ if (!zf.has('xl/styles.xml'))
2103
+ return [];
2104
+ let root;
2105
+ try {
2106
+ root = parseXml(zf.read('xl/styles.xml'));
2107
+ }
2108
+ catch (err) {
2109
+ if (isUnreadableOptional(err))
2110
+ return [];
2111
+ throw err;
2112
+ }
2113
+ const custom = new Map();
2114
+ for (const node of root.iter(NS_S + 'numFmt')) {
2115
+ const id = pyInt(node.get('numFmtId') || '-1');
2116
+ if (id === null)
2117
+ continue;
2118
+ custom.set(id, node.get('formatCode') || '');
2119
+ }
2120
+ const cellXfs = root.find(NS_S + 'cellXfs');
2121
+ const out = [];
2122
+ for (const xf of cellXfs ? cellXfs.children : []) {
2123
+ const fmtId = pyInt(xf.get('numFmtId') || '0');
2124
+ if (fmtId === null) {
2125
+ out.push('');
2126
+ continue;
2127
+ }
2128
+ const code = custom.get(fmtId);
2129
+ if (code !== undefined)
2130
+ out.push(isDateFormat(code) ? code : '');
2131
+ else if (BUILTIN_DATE_FORMATS[fmtId] !== undefined)
2132
+ out.push(BUILTIN_DATE_FORMATS[fmtId]);
2133
+ else if (isLocaleDateId(fmtId))
2134
+ out.push(`built-in numFmtId ${fmtId} (locale-dependent date)`);
2135
+ else
2136
+ out.push('');
2137
+ }
2138
+ return out;
2139
+ }
2140
+ /** 0 -> `A`. */
2141
+ export function columnLetter(index) {
2142
+ let letters = '';
2143
+ let i = index + 1;
2144
+ while (i) {
2145
+ const rem = (i - 1) % 26;
2146
+ i = Math.floor((i - 1) / 26);
2147
+ letters = String.fromCharCode(65 + rem) + letters;
2148
+ }
2149
+ return letters;
2150
+ }
2151
+ /**
2152
+ * ECMA-376 gives a worksheet 16,384 columns, the last of them `XFD`. A reference past that
2153
+ * does not name a column of any workbook, so this reader will not build a row wide enough to
2154
+ * reach one. THE FORMAT'S NUMBER, not a limit invented here, which is why it is spelled as
2155
+ * the last column's name rather than as a round figure someone liked.
2156
+ */
2157
+ export const XLSX_MAX_COLUMNS = 16384;
2158
+ /**
2159
+ * Four letters is already 18,278 (`AAAA`), past `XFD` whatever the letters are. Checking the
2160
+ * LENGTH before the arithmetic is what keeps a megabyte of letters from being turned into a
2161
+ * megabyte-long number on the way to being refused.
2162
+ */
2163
+ const MAX_COLUMN_LETTERS = 3;
2164
+ /**
2165
+ * Why a cell's own reference could not place it. Fixed strings, never the reference itself:
2166
+ * `r` is whatever the file says, and an omission that echoed it would carry the file's bytes
2167
+ * into the manifest with no bound at all.
2168
+ */
2169
+ export const UNPLACED_SHAPE = 'the column of a cell whose reference is not letters then digits';
2170
+ export const UNPLACED_RANGE = 'the column of a cell past XFD, the last column the format has';
2171
+ /**
2172
+ * `B7` -> `[1, '']`. The cell's own reference decides its column; XML order is a fallback.
2173
+ *
2174
+ * A reference is ASCII letters then ASCII digits naming a column the format has, or this
2175
+ * reader cannot place it and says WHY. The match is on the reference AS WRITTEN and never on
2176
+ * its uppercase (`_column`, review round 3): `r="ß1"` was a `TypeError` on the Python side
2177
+ * and column 486 (`SS1`, after `toUpperCase`) here, and a column number that depends on a
2178
+ * Unicode case table is not a fact about the workbook.
2179
+ *
2180
+ * AN ANSWER AND NOT A THROW, which is review round 4 (M2), mirroring `runtime-py` `a1acfa7`.
2181
+ * Round 3 spelled the refusal as a `DocumentReadError` out of a function `sheetRows` does not
2182
+ * catch, so ONE cell the reader could not place refused the entire workbook — measured on a
2183
+ * two-sheet fixture where sheet `Good` is clean and sheet `Bad` holds one cell `r="1"`: no
2184
+ * manifest, no parts, the clean sheet unreachable. `r="1"`, `r="A"` and `r="B7 "` all read
2185
+ * before round 3. The strictness was right and its price was not: the cell keeps its text and
2186
+ * takes its XML position, which is where it sat before round 3 and is the same position on
2187
+ * both runtimes, and the column it did not get is disclosed as an `Omission` rather than
2188
+ * charged to the whole document. The sentence "cell reference {r!r} is not a column-and-row
2189
+ * reference like B7, so this reader cannot place it" NO LONGER EXISTS on either runtime.
2190
+ *
2191
+ * THE CEILING is review round 4 (M5). Round 3 made this function stricter about the SHAPE of
2192
+ * a reference and left the resulting index unbounded: measured on the reference, a 1,755-byte
2193
+ * xlsx whose one cell is `r="ZZZZZ1"` produced a 12,356,630-byte row, 7,041x the file. Row
2194
+ * width is now bounded by the format at `XLSX_MAX_COLUMNS` fields however many bytes the file
2195
+ * spends asking for more.
2196
+ */
2197
+ export function columnIndex(ref, fallback) {
2198
+ if (!ref)
2199
+ return [fallback, ''];
2200
+ const match = /^([A-Za-z]+)[0-9]+$/.exec(ref);
2201
+ if (match === null)
2202
+ return [fallback, UNPLACED_SHAPE];
2203
+ const letters = match[1];
2204
+ if (letters.length > MAX_COLUMN_LETTERS)
2205
+ return [fallback, UNPLACED_RANGE];
2206
+ let index = 0;
2207
+ for (const ch of letters.toUpperCase())
2208
+ index = index * 26 + (ch.charCodeAt(0) - 64);
2209
+ if (index > XLSX_MAX_COLUMNS)
2210
+ return [fallback, UNPLACED_RANGE];
2211
+ return [index - 1, ''];
2212
+ }
2213
+ function sharedStrings(zf, path) {
2214
+ if (!zf.has('xl/sharedStrings.xml'))
2215
+ return [];
2216
+ const out = [];
2217
+ for (const si of parsePart(zf.read('xl/sharedStrings.xml'), 'xl/sharedStrings.xml', path).children) {
2218
+ const runs = [];
2219
+ for (const child of si.children) {
2220
+ if (child.tag === NS_S + 't')
2221
+ runs.push(child.text);
2222
+ else if (child.tag === NS_S + 'r') {
2223
+ for (const sub of child.children)
2224
+ if (sub.tag === NS_S + 't')
2225
+ runs.push(sub.text);
2226
+ }
2227
+ }
2228
+ out.push(runs.join(''));
2229
+ }
2230
+ return out;
2231
+ }
2232
+ function cellText(cell, shared) {
2233
+ const kind = cell.get('t');
2234
+ if (kind === 'inlineStr') {
2235
+ const node = cell.find(NS_S + 'is');
2236
+ if (node === undefined)
2237
+ return '';
2238
+ let out = '';
2239
+ for (const t of node.iter(NS_S + 't'))
2240
+ out += t.text;
2241
+ return out;
2242
+ }
2243
+ const value = cell.find(NS_S + 'v');
2244
+ const raw = value === undefined ? '' : value.text;
2245
+ if (kind === 's') {
2246
+ const index = pyInt(raw);
2247
+ const hit = index === null ? undefined : pyIndex(shared, index);
2248
+ if (hit === undefined) {
2249
+ throw new DocumentReadError(`cell ${cell.get('r') ?? 'None'} indexes shared string ${pyRepr(raw)}, ` +
2250
+ `but the table has ${shared.length} entries`);
2251
+ }
2252
+ return hit;
2253
+ }
2254
+ if (kind === 'b')
2255
+ return raw === '1' ? 'TRUE' : 'FALSE';
2256
+ return raw;
2257
+ }
2258
+ function sheetRows(root, shared, dateStyles = []) {
2259
+ const rows = [];
2260
+ let blank = 0;
2261
+ const dated = new Map();
2262
+ const unplaced = new Map();
2263
+ for (const row of root.iter(NS_S + 'row')) {
2264
+ const cells = new Map();
2265
+ let position = 0;
2266
+ for (const cell of row.iter(NS_S + 'c')) {
2267
+ const text = clean(cellText(cell, shared));
2268
+ const at = position;
2269
+ position += 1;
2270
+ if (!text)
2271
+ continue;
2272
+ const [column, unplaceable] = columnIndex(cell.get('r'), at);
2273
+ cells.set(column, text);
2274
+ if (unplaceable) {
2275
+ let seen = unplaced.get(unplaceable);
2276
+ if (seen === undefined) {
2277
+ seen = new Map();
2278
+ unplaced.set(unplaceable, seen);
2279
+ }
2280
+ seen.set(column, (seen.get(column) ?? 0) + 1);
2281
+ }
2282
+ const t = cell.get('t');
2283
+ if (t === undefined || t === 'n') {
2284
+ const style = pyInt(cell.get('s') || '0');
2285
+ const code = style === null ? '' : (pyIndex(dateStyles, style) ?? '');
2286
+ if (code) {
2287
+ let columns = dated.get(code);
2288
+ if (columns === undefined) {
2289
+ columns = new Map();
2290
+ dated.set(code, columns);
2291
+ }
2292
+ columns.set(column, (columns.get(column) ?? 0) + 1);
2293
+ }
2294
+ }
2295
+ }
2296
+ // A loop, not `Math.max(...cells.keys())`: spreading 300k keys into one call is a
2297
+ // `RangeError: Maximum call stack size exceeded` (measured, review round 3).
2298
+ let width = 0;
2299
+ for (const column of cells.keys())
2300
+ if (column + 1 > width)
2301
+ width = column + 1;
2302
+ const fields = [];
2303
+ for (let k = 0; k < width; k += 1)
2304
+ fields.push(cells.get(k) ?? '');
2305
+ const line = fields.join('\t');
2306
+ if (!line)
2307
+ blank += 1;
2308
+ rows.push(line);
2309
+ }
2310
+ const omissions = [];
2311
+ if (blank)
2312
+ omissions.push(new Omission(OMIT_BLANK_ROWS, blank));
2313
+ for (const code of pySorted(dated.keys())) {
2314
+ const columns = dated.get(code);
2315
+ const sortedColumns = [...columns.keys()].sort((a, b) => a - b);
2316
+ omissions.push(new Omission(OMIT_NUMBER_FORMAT, [...columns.values()].reduce((a, b) => a + b, 0), 0, sortedColumns.map(columnLetter), code));
2317
+ }
2318
+ // Then the unplaced cells, one omission per REASON, `pySorted` so the order is by
2319
+ // codepoint the way `sorted(unplaced)` is on the reference — which puts `UNPLACED_RANGE`
2320
+ // ("...past XFD...") before `UNPLACED_SHAPE` ("...whose reference is not..."), `p` before
2321
+ // `w`. Same shape as `number-format`: a count, and the columns it landed in.
2322
+ for (const reason of pySorted(unplaced.keys())) {
2323
+ const columns = unplaced.get(reason);
2324
+ const sortedColumns = [...columns.keys()].sort((a, b) => a - b);
2325
+ omissions.push(new Omission(OMIT_UNPLACED_CELL, [...columns.values()].reduce((a, b) => a + b, 0), 0, sortedColumns.map(columnLetter), reason));
2326
+ }
2327
+ return [rows, omissions];
2328
+ }
2329
+ function worksheetTargets(zf, path) {
2330
+ const workbook = parsePart(readMember(zf, 'xl/workbook.xml', path), 'xl/workbook.xml', path);
2331
+ const rels = new Map();
2332
+ const member = 'xl/_rels/workbook.xml.rels';
2333
+ for (const rel of parsePart(readMember(zf, member, path), member, path).children) {
2334
+ const target = rel.get('Target') ?? '';
2335
+ if (target.startsWith('/'))
2336
+ rels.set(rel.get('Id'), target.replace(/^\/+/, ''));
2337
+ else
2338
+ rels.set(rel.get('Id'), posix.normalize(posix.join('xl', target)));
2339
+ }
2340
+ const out = [];
2341
+ let order = 0;
2342
+ for (const sheet of workbook.iter(NS_S + 'sheet')) {
2343
+ const rid = sheet.get(NS_R + 'id');
2344
+ const target = rid ? rels.get(rid) : `xl/worksheets/sheet${order + 1}.xml`;
2345
+ out.push([sheet.get('name') || `sheet${order + 1}`, target]);
2346
+ order += 1;
2347
+ }
2348
+ return out;
2349
+ }
2350
+ function mediaOmission(media) {
2351
+ if (media.size === 0)
2352
+ return [];
2353
+ const kinds = new Set();
2354
+ for (const name of media.keys()) {
2355
+ kinds.add(posixSplitextExt(name).replace(/^\.+/, '').toLowerCase() || '?');
2356
+ }
2357
+ let size = 0;
2358
+ for (const bytes of media.values())
2359
+ size += bytes;
2360
+ return [new Omission(OMIT_MEDIA, media.size, size, [], pySorted(kinds).join(', '))];
2361
+ }
2362
+ export function extractXlsx(path) {
2363
+ const zf = openZip(path);
2364
+ const shared = sharedStrings(zf, path);
2365
+ const dateStyles = dateFormats(zf);
2366
+ const media = mediaIndex(zf);
2367
+ const parts = [];
2368
+ let index = 0;
2369
+ for (const [name, target] of worksheetTargets(zf, path)) {
2370
+ if (target === undefined) {
2371
+ throw new DocumentReadError(`sheet ${pyRepr(name)} has no resolvable worksheet part`);
2372
+ }
2373
+ const sheet = parsePart(readMember(zf, target, path), target, path);
2374
+ const [rows, sheetOmissions] = sheetRows(sheet, shared, dateStyles);
2375
+ const omissions = [...mediaOmission(anchoredMedia(zf, target, media)), ...sheetOmissions];
2376
+ parts.push(new Part(name, index, rows, omissions));
2377
+ index += 1;
2378
+ }
2379
+ return new Document('xlsx', parts, mediaOmission(media));
2380
+ }
2381
+ export function extractDocx(path) {
2382
+ const zf = openZip(path);
2383
+ const root = parsePart(readMember(zf, 'word/document.xml', path), 'word/document.xml', path);
2384
+ const media = mediaIndex(zf);
2385
+ const anchored = mediaOmission(anchoredMedia(zf, 'word/document.xml', media));
2386
+ const rows = [];
2387
+ for (const para of root.iter(NS_W + 'p')) {
2388
+ const runs = [];
2389
+ for (const node of para.iter()) {
2390
+ if (node.tag === NS_W + 't')
2391
+ runs.push(node.text);
2392
+ else if (node.tag === NS_W + 'tab' || node.tag === NS_W + 'br' || node.tag === NS_W + 'cr')
2393
+ runs.push(' ');
2394
+ }
2395
+ const text = pyStrip(clean(runs.join('')));
2396
+ if (text)
2397
+ rows.push(text);
2398
+ }
2399
+ const body = new Part('document', 0, rows, anchored);
2400
+ return new Document('docx', [body], mediaOmission(media));
2401
+ }
2402
+ // ------------------------------------------------------------------- HTML (html.parser)
2403
+ const BLOCK_TAGS = new Set('p div br hr li tr h1 h2 h3 h4 h5 h6 table tbody thead blockquote pre section article header footer nav aside figure figcaption dt dd ul ol form fieldset title'.split(' '));
2404
+ const FIELD_TAGS = new Set(['td', 'th']);
2405
+ const SILENT_TAGS = new Set(['script', 'style', 'template', 'noscript']);
2406
+ const INVALID_CHARREFS = {
2407
+ 0x00: '�',
2408
+ 0x0d: '\r',
2409
+ 0x80: '€',
2410
+ 0x81: '\x81',
2411
+ 0x82: '‚',
2412
+ 0x83: 'ƒ',
2413
+ 0x84: '„',
2414
+ 0x85: '…',
2415
+ 0x86: '†',
2416
+ 0x87: '‡',
2417
+ 0x88: 'ˆ',
2418
+ 0x89: '‰',
2419
+ 0x8a: 'Š',
2420
+ 0x8b: '‹',
2421
+ 0x8c: 'Œ',
2422
+ 0x8d: '\x8d',
2423
+ 0x8e: 'Ž',
2424
+ 0x8f: '\x8f',
2425
+ 0x90: '\x90',
2426
+ 0x91: '‘',
2427
+ 0x92: '’',
2428
+ 0x93: '“',
2429
+ 0x94: '”',
2430
+ 0x95: '•',
2431
+ 0x96: '–',
2432
+ 0x97: '—',
2433
+ 0x98: '˜',
2434
+ 0x99: '™',
2435
+ 0x9a: 'š',
2436
+ 0x9b: '›',
2437
+ 0x9c: 'œ',
2438
+ 0x9d: '\x9d',
2439
+ 0x9e: 'ž',
2440
+ 0x9f: 'Ÿ',
2441
+ };
2442
+ function isInvalidCodepoint(num) {
2443
+ if ((num >= 0x1 && num <= 0x8) || (num >= 0xe && num <= 0x1f) || (num >= 0x7f && num <= 0x9f))
2444
+ return true;
2445
+ if (num >= 0xfdd0 && num <= 0xfdef)
2446
+ return true;
2447
+ if (num === 0xb)
2448
+ return true;
2449
+ return (num & 0xfffe) === 0xfffe && num <= 0x10ffff;
2450
+ }
2451
+ const CHARREF = /&(#[0-9]+;?|#[xX][0-9a-fA-F]+;?|[^\t\n\f <&#;]{1,32};?)/g;
2452
+ function replaceCharref(s) {
2453
+ if (s[0] === '#') {
2454
+ const num = s[1] === 'x' || s[1] === 'X' ? parseInt(s.slice(2).replace(/;$/, ''), 16) : parseInt(s.slice(1).replace(/;$/, ''), 10);
2455
+ const invalid = INVALID_CHARREFS[num];
2456
+ if (invalid !== undefined)
2457
+ return invalid;
2458
+ if ((num >= 0xd800 && num <= 0xdfff) || num > 0x10ffff)
2459
+ return '�';
2460
+ if (isInvalidCodepoint(num))
2461
+ return '';
2462
+ return String.fromCodePoint(num);
2463
+ }
2464
+ const whole = HTML5_ENTITIES.get(s);
2465
+ if (whole !== undefined)
2466
+ return whole;
2467
+ for (let x = s.length - 1; x > 1; x -= 1) {
2468
+ const hit = HTML5_ENTITIES.get(s.slice(0, x));
2469
+ if (hit !== undefined)
2470
+ return hit + s.slice(x);
2471
+ }
2472
+ return '&' + s;
2473
+ }
2474
+ /** `html.unescape`. */
2475
+ export function unescape(s) {
2476
+ if (!s.includes('&'))
2477
+ return s;
2478
+ return s.replace(CHARREF, (_, body) => replaceCharref(body));
2479
+ }
2480
+ const TAGFIND_TOLERANT = /([a-zA-Z][^\t\n\r\f />]*)(?:[\t\n\r\f ]|\/(?!>))*/y;
2481
+ const ATTRFIND_TOLERANT = /((?<=['"\t\n\r\f /])[^\t\n\r\f />][^\t\n\r\f /=>]*)([\t\n\r\f ]*=[\t\n\r\f ]*('[^']*'|"[^"]*"|(?!['"])[^>\t\n\r\f ]*))?(?:[\t\n\r\f ]|\/(?!>))*/y;
2482
+ const LOCATETAGEND = /[a-zA-Z][^\t\n\r\f />]*[\t\n\r\f /]*(?:(?<=['"\t\n\r\f /])[^\t\n\r\f />][^\t\n\r\f /=>]*(?:[\t\n\r\f ]*=[\t\n\r\f ]*(?:'[^']*'|"[^"]*"|(?!['"])[^>\t\n\r\f ]*))?[\t\n\r\f /]*)*>?/y;
2483
+ const STARTTAGOPEN = /<[a-zA-Z]/y;
2484
+ const ENDTAGOPEN = /<\/[a-zA-Z]/y;
2485
+ const COMMENTCLOSE = /--!?>/g;
2486
+ const COMMENTABRUPTCLOSE = /-?>/y;
2487
+ const CHARREF_TOKEN = /&#(?:[0-9]+|[xX][0-9a-fA-F]+)[^0-9a-fA-F]/y;
2488
+ const ENTITYREF_TOKEN = /&([a-zA-Z][-.a-zA-Z0-9]*)[^a-zA-Z0-9]/y;
2489
+ const INCOMPLETE_TOKEN = /&[a-zA-Z#]/y;
2490
+ const CDATA_CONTENT_ELEMENTS = new Set(['script', 'style', 'xmp', 'iframe', 'noembed', 'noframes']);
2491
+ const RCDATA_CONTENT_ELEMENTS = new Set(['textarea', 'title']);
2492
+ function stickyMatch(re, s, at) {
2493
+ re.lastIndex = at;
2494
+ return re.exec(s);
2495
+ }
2496
+ /**
2497
+ * `html.parser.HTMLParser`, `convert_charrefs=True`, ported statement for statement from
2498
+ * CPython 3.12.13 — including the RAWTEXT handling of `<script>`/`<style>` and the RCDATA
2499
+ * handling of `<title>`/`<textarea>` that release carries. Only the three handlers
2500
+ * `_HtmlText` overrides do anything; comments, declarations and PIs are consumed and
2501
+ * dropped exactly where the reference drops them.
2502
+ */
2503
+ class HtmlParserBase {
2504
+ rawdata = '';
2505
+ cdataElem = null;
2506
+ escapable = true;
2507
+ interesting = null;
2508
+ feed(data) {
2509
+ this.rawdata += data;
2510
+ this.goahead(false);
2511
+ }
2512
+ close() {
2513
+ this.goahead(true);
2514
+ }
2515
+ setCdataMode(elem, escapable) {
2516
+ this.cdataElem = elem.toLowerCase();
2517
+ this.escapable = escapable;
2518
+ if (this.cdataElem === 'plaintext')
2519
+ this.interesting = /$(?![\s\S])/g;
2520
+ else
2521
+ this.interesting = new RegExp(`</${this.cdataElem}(?=[\\t\\n\\r\\f />])`, 'ig');
2522
+ }
2523
+ clearCdataMode() {
2524
+ this.interesting = null;
2525
+ this.cdataElem = null;
2526
+ this.escapable = true;
2527
+ }
2528
+ goahead(end) {
2529
+ const rawdata = this.rawdata;
2530
+ let i = 0;
2531
+ const n = rawdata.length;
2532
+ while (i < n) {
2533
+ let j;
2534
+ if (!this.cdataElem) {
2535
+ j = rawdata.indexOf('<', i);
2536
+ if (j < 0) {
2537
+ const amppos = rawdata.lastIndexOf('&', n - 1);
2538
+ const from = Math.max(i, n - 34);
2539
+ const amp = amppos >= from ? amppos : -1;
2540
+ if (amp >= 0 && !/[\t\n\r\f ;]/.test(rawdata.slice(amp)))
2541
+ break;
2542
+ j = n;
2543
+ }
2544
+ }
2545
+ else {
2546
+ const re = this.interesting;
2547
+ re.lastIndex = i;
2548
+ const match = re.exec(rawdata);
2549
+ if (match)
2550
+ j = match.index;
2551
+ else
2552
+ break;
2553
+ }
2554
+ if (i < j) {
2555
+ if (this.escapable)
2556
+ this.handleData(unescape(rawdata.slice(i, j)));
2557
+ else
2558
+ this.handleData(rawdata.slice(i, j));
2559
+ }
2560
+ i = j;
2561
+ if (i === n)
2562
+ break;
2563
+ if (rawdata.startsWith('<', i)) {
2564
+ let k;
2565
+ if (stickyMatch(STARTTAGOPEN, rawdata, i))
2566
+ k = this.parseStarttag(i);
2567
+ else if (rawdata.startsWith('</', i))
2568
+ k = this.parseEndtag(i);
2569
+ else if (rawdata.startsWith('<!--', i))
2570
+ k = this.parseComment(i);
2571
+ else if (rawdata.startsWith('<?', i))
2572
+ k = this.parsePi(i);
2573
+ else if (rawdata.startsWith('<!', i))
2574
+ k = this.parseHtmlDeclaration(i);
2575
+ else if (i + 1 < n || end) {
2576
+ this.handleData('<');
2577
+ k = i + 1;
2578
+ }
2579
+ else
2580
+ break;
2581
+ if (k < 0) {
2582
+ if (!end)
2583
+ break;
2584
+ if (rawdata.startsWith('</', i) && i + 2 === n)
2585
+ this.handleData('</');
2586
+ k = n;
2587
+ }
2588
+ i = k;
2589
+ }
2590
+ else if (rawdata.startsWith('&#', i)) {
2591
+ // Unreachable with convert_charrefs=True outside cdata mode; kept for shape.
2592
+ const match = stickyMatch(CHARREF_TOKEN, rawdata, i);
2593
+ if (match) {
2594
+ let k = i + match[0].length;
2595
+ if (rawdata[k - 1] !== ';')
2596
+ k -= 1;
2597
+ i = k;
2598
+ continue;
2599
+ }
2600
+ if (rawdata.slice(i).includes(';')) {
2601
+ this.handleData(rawdata.slice(i, i + 2));
2602
+ i += 2;
2603
+ }
2604
+ break;
2605
+ }
2606
+ else if (rawdata.startsWith('&', i)) {
2607
+ const match = stickyMatch(ENTITYREF_TOKEN, rawdata, i);
2608
+ if (match) {
2609
+ let k = i + match[0].length;
2610
+ if (rawdata[k - 1] !== ';')
2611
+ k -= 1;
2612
+ i = k;
2613
+ continue;
2614
+ }
2615
+ if (stickyMatch(INCOMPLETE_TOKEN, rawdata, i))
2616
+ break;
2617
+ if (i + 1 < n) {
2618
+ this.handleData('&');
2619
+ i += 1;
2620
+ }
2621
+ else
2622
+ break;
2623
+ }
2624
+ else {
2625
+ throw new Error('interesting.search() lied');
2626
+ }
2627
+ }
2628
+ if (end && i < n) {
2629
+ if (this.escapable)
2630
+ this.handleData(unescape(rawdata.slice(i, n)));
2631
+ else
2632
+ this.handleData(rawdata.slice(i, n));
2633
+ i = n;
2634
+ }
2635
+ this.rawdata = rawdata.slice(i);
2636
+ }
2637
+ parseHtmlDeclaration(i) {
2638
+ const rawdata = this.rawdata;
2639
+ if (rawdata.startsWith('<!--', i))
2640
+ return this.parseComment(i);
2641
+ if (rawdata.startsWith('<![CDATA[', i)) {
2642
+ const j = rawdata.indexOf(']]>', i + 9);
2643
+ return j < 0 ? -1 : j + 3;
2644
+ }
2645
+ if (rawdata.slice(i, i + 9).toLowerCase() === '<!doctype') {
2646
+ const gtpos = rawdata.indexOf('>', i + 9);
2647
+ return gtpos === -1 ? -1 : gtpos + 1;
2648
+ }
2649
+ if (rawdata.startsWith('<![', i)) {
2650
+ const j = rawdata.indexOf('>', i + 3);
2651
+ return j < 0 ? -1 : j + 1;
2652
+ }
2653
+ return this.parseBogusComment(i);
2654
+ }
2655
+ parseComment(i) {
2656
+ const rawdata = this.rawdata;
2657
+ COMMENTCLOSE.lastIndex = i + 4;
2658
+ let match = COMMENTCLOSE.exec(rawdata);
2659
+ if (!match) {
2660
+ match = stickyMatch(COMMENTABRUPTCLOSE, rawdata, i + 4);
2661
+ if (!match)
2662
+ return -1;
2663
+ }
2664
+ return match.index + match[0].length;
2665
+ }
2666
+ parseBogusComment(i) {
2667
+ const pos = this.rawdata.indexOf('>', i + 2);
2668
+ return pos === -1 ? -1 : pos + 1;
2669
+ }
2670
+ parsePi(i) {
2671
+ const pos = this.rawdata.indexOf('>', i + 2);
2672
+ return pos === -1 ? -1 : pos + 1;
2673
+ }
2674
+ parseStarttag(i) {
2675
+ const rawdata = this.rawdata;
2676
+ const endpos = this.checkForWholeStartTag(i);
2677
+ if (endpos < 0)
2678
+ return endpos;
2679
+ const match = stickyMatch(TAGFIND_TOLERANT, rawdata, i + 1);
2680
+ let k = i + 1 + match[0].length;
2681
+ const tag = match[1].toLowerCase();
2682
+ while (k < endpos) {
2683
+ const m = stickyMatch(ATTRFIND_TOLERANT, rawdata, k);
2684
+ if (!m || m[0].length === 0)
2685
+ break;
2686
+ k += m[0].length;
2687
+ }
2688
+ const endText = pyStrip(rawdata.slice(k, endpos));
2689
+ if (endText !== '>' && endText !== '/>') {
2690
+ this.handleData(rawdata.slice(i, endpos));
2691
+ return endpos;
2692
+ }
2693
+ if (endText.endsWith('/>')) {
2694
+ this.handleStarttag(tag);
2695
+ this.handleEndtag(tag);
2696
+ }
2697
+ else {
2698
+ this.handleStarttag(tag);
2699
+ if (CDATA_CONTENT_ELEMENTS.has(tag) || tag === 'plaintext')
2700
+ this.setCdataMode(tag, false);
2701
+ else if (RCDATA_CONTENT_ELEMENTS.has(tag))
2702
+ this.setCdataMode(tag, true);
2703
+ }
2704
+ return endpos;
2705
+ }
2706
+ checkForWholeStartTag(i) {
2707
+ const match = stickyMatch(LOCATETAGEND, this.rawdata, i + 1);
2708
+ const j = i + 1 + match[0].length;
2709
+ return this.rawdata[j - 1] !== '>' ? -1 : j;
2710
+ }
2711
+ parseEndtag(i) {
2712
+ const rawdata = this.rawdata;
2713
+ if (rawdata.indexOf('>', i + 2) < 0)
2714
+ return -1;
2715
+ if (!stickyMatch(ENDTAGOPEN, rawdata, i)) {
2716
+ if (rawdata[i + 2] === '>')
2717
+ return i + 3;
2718
+ return this.parseBogusComment(i);
2719
+ }
2720
+ const match = stickyMatch(LOCATETAGEND, rawdata, i + 2);
2721
+ const j = i + 2 + match[0].length;
2722
+ if (rawdata[j - 1] !== '>')
2723
+ return -1;
2724
+ const name = stickyMatch(TAGFIND_TOLERANT, rawdata, i + 2);
2725
+ this.handleEndtag(name[1].toLowerCase());
2726
+ this.clearCdataMode();
2727
+ return j;
2728
+ }
2729
+ }
2730
+ /** HTML to rows. Block markup ends a row, `<td>`/`<th>` separate fields with a tab. */
2731
+ class HtmlText extends HtmlParserBase {
2732
+ rows = [];
2733
+ fields = [];
2734
+ silent = 0;
2735
+ handleStarttag(tag) {
2736
+ if (SILENT_TAGS.has(tag))
2737
+ this.silent += 1;
2738
+ else if (FIELD_TAGS.has(tag))
2739
+ this.fields.push('');
2740
+ else if (BLOCK_TAGS.has(tag))
2741
+ this.flush();
2742
+ }
2743
+ handleEndtag(tag) {
2744
+ if (SILENT_TAGS.has(tag))
2745
+ this.silent = Math.max(0, this.silent - 1);
2746
+ else if (BLOCK_TAGS.has(tag))
2747
+ this.flush();
2748
+ }
2749
+ handleData(data) {
2750
+ if (this.silent)
2751
+ return;
2752
+ const text = pySplit(data).join(' ');
2753
+ if (!text)
2754
+ return;
2755
+ if (this.fields.length) {
2756
+ const last = this.fields.length - 1;
2757
+ this.fields[last] = pyStrip(`${this.fields[last]} ${text}`);
2758
+ }
2759
+ else {
2760
+ this.fields.push(text);
2761
+ }
2762
+ }
2763
+ flush() {
2764
+ const line = pyStrip(pyStripChars(this.fields.map((field) => pyStrip(clean(field))).join('\t'), '\t'));
2765
+ this.fields = [];
2766
+ if (line)
2767
+ this.rows.push(line);
2768
+ }
2769
+ close() {
2770
+ super.close();
2771
+ this.flush();
2772
+ }
2773
+ }
2774
+ /** Rendered rows of one HTML fragment. A pure function of the string: no I/O, no host. */
2775
+ export function htmlRows(markup) {
2776
+ const parser = new HtmlText();
2777
+ parser.feed(markup);
2778
+ parser.close();
2779
+ return parser.rows;
2780
+ }
2781
+ /** `Message.get(name)`: the first header of that name, unfolded the way the policy does. */
2782
+ function header(part, name) {
2783
+ const want = name.toLowerCase();
2784
+ for (const [key, value] of part.headers) {
2785
+ if (key.toLowerCase() === want)
2786
+ return value.replace(/[\r\n]/g, '');
2787
+ }
2788
+ return undefined;
2789
+ }
2790
+ /** `email.message._parseparam`. */
2791
+ function parseParam(s) {
2792
+ s = ';' + s;
2793
+ const plist = [];
2794
+ while (s.startsWith(';')) {
2795
+ s = s.slice(1);
2796
+ let end = s.indexOf(';');
2797
+ const count = (text, needle) => text.split(needle).length - 1;
2798
+ while (end > 0 && (count(s.slice(0, end), '"') - count(s.slice(0, end), '\\"')) % 2) {
2799
+ end = s.indexOf(';', end + 1);
2800
+ }
2801
+ if (end < 0)
2802
+ end = s.length;
2803
+ let f = s.slice(0, end);
2804
+ if (f.includes('=')) {
2805
+ const i = f.indexOf('=');
2806
+ f = pyStrip(f.slice(0, i)).toLowerCase() + '=' + pyStrip(f.slice(i + 1));
2807
+ }
2808
+ plist.push(pyStrip(f));
2809
+ s = s.slice(end);
2810
+ }
2811
+ return plist;
2812
+ }
2813
+ function unquoteValue(value) {
2814
+ if (value.length > 1) {
2815
+ if (value.startsWith('"') && value.endsWith('"')) {
2816
+ return value.slice(1, -1).replace(/\\\\/g, '\\').replace(/\\"/g, '"');
2817
+ }
2818
+ if (value.startsWith('<') && value.endsWith('>'))
2819
+ return value.slice(1, -1);
2820
+ }
2821
+ return value;
2822
+ }
2823
+ function getParam(part, name) {
2824
+ const value = header(part, 'content-type');
2825
+ if (value === undefined)
2826
+ return undefined;
2827
+ for (const p of parseParam(value)) {
2828
+ let key;
2829
+ let val;
2830
+ const eq = p.indexOf('=');
2831
+ if (eq >= 0) {
2832
+ key = pyStrip(p.slice(0, eq));
2833
+ val = pyStrip(p.slice(eq + 1));
2834
+ }
2835
+ else {
2836
+ key = pyStrip(p);
2837
+ val = '';
2838
+ }
2839
+ if (key.toLowerCase() === name.toLowerCase())
2840
+ return unquoteValue(val);
2841
+ }
2842
+ return undefined;
2843
+ }
2844
+ function contentType(part) {
2845
+ const value = header(part, 'content-type');
2846
+ if (value === undefined)
2847
+ return part.defaultType;
2848
+ const ctype = pyStrip(value.split(';')[0]).toLowerCase();
2849
+ if (ctype.split('/').length - 1 !== 1)
2850
+ return 'text/plain';
2851
+ return ctype;
2852
+ }
2853
+ function contentMaintype(part) {
2854
+ return contentType(part).split('/')[0];
2855
+ }
2856
+ function contentSubtype(part) {
2857
+ return contentType(part).split('/')[1];
2858
+ }
2859
+ function contentCharset(part) {
2860
+ const charset = getParam(part, 'charset');
2861
+ if (charset === undefined)
2862
+ return undefined;
2863
+ // eslint-disable-next-line no-control-regex
2864
+ if (/[^\x00-\x7f]/.test(charset))
2865
+ return undefined;
2866
+ return charset.toLowerCase();
2867
+ }
2868
+ /** `binascii.a2b_qp(data, header=False)`, i.e. `quopri.decodestring`. */
2869
+ export function decodeQuotedPrintable(data) {
2870
+ const out = [];
2871
+ const n = data.length;
2872
+ let i = 0;
2873
+ const isHex = (b) => (b >= 0x30 && b <= 0x39) || (b >= 0x41 && b <= 0x46) || (b >= 0x61 && b <= 0x66);
2874
+ while (i < n) {
2875
+ const b = data[i];
2876
+ if (b === 0x3d) {
2877
+ i += 1;
2878
+ if (i >= n)
2879
+ break;
2880
+ const c = data[i];
2881
+ if (c === 0x0a || c === 0x0d) {
2882
+ if (c !== 0x0a)
2883
+ while (i < n && data[i] !== 0x0a)
2884
+ i += 1;
2885
+ if (i < n)
2886
+ i += 1;
2887
+ }
2888
+ else if (c === 0x3d) {
2889
+ out.push(0x3d);
2890
+ i += 1;
2891
+ }
2892
+ else if (i + 1 < n && isHex(c) && isHex(data[i + 1])) {
2893
+ out.push(parseInt(String.fromCharCode(c, data[i + 1]), 16));
2894
+ i += 2;
2895
+ }
2896
+ else {
2897
+ out.push(0x3d);
2898
+ }
2899
+ }
2900
+ else {
2901
+ out.push(b);
2902
+ i += 1;
2903
+ }
2904
+ }
2905
+ return Buffer.from(out);
2906
+ }
2907
+ /** `binascii.Error`, which is a `ValueError`. */
2908
+ class BinasciiError extends Error {
2909
+ }
2910
+ /**
2911
+ * `binascii.a2b_uu(line)`: one uuencoded line to its bytes, in the C module's own steps.
2912
+ *
2913
+ * The first character is the byte count; each following character carries six bits, a line
2914
+ * end or a line that ran out counts as zero ("some spaces got eaten at end-of-line"); a
2915
+ * character outside `' '..'\x60'` is "Illegal char"; and whatever is left after the count
2916
+ * is satisfied must be space, backtick or a line end, or it is "Trailing garbage".
2917
+ */
2918
+ export function a2bUu(line) {
2919
+ // An empty line is 32 zero bytes on the reference (measured: `a2b_uu(b'')`), the count a
2920
+ // missing first character implies — the same answer as a NUL first character.
2921
+ let binLen = line.length === 0 ? 32 : (line[0] - 0x20) & 0x3f;
2922
+ const out = [];
2923
+ let i = 1;
2924
+ let leftchar = 0;
2925
+ let leftbits = 0;
2926
+ for (; binLen > 0; i += 1) {
2927
+ let ch;
2928
+ if (i >= line.length)
2929
+ ch = 0;
2930
+ else {
2931
+ ch = line[i];
2932
+ if (ch === 0x0a || ch === 0x0d)
2933
+ ch = 0;
2934
+ else {
2935
+ if (ch < 0x20 || ch > 0x20 + 64)
2936
+ throw new BinasciiError('Illegal char');
2937
+ ch = (ch - 0x20) & 0x3f;
2938
+ }
2939
+ }
2940
+ leftchar = (leftchar << 6) | ch;
2941
+ leftbits += 6;
2942
+ if (leftbits >= 8) {
2943
+ leftbits -= 8;
2944
+ out.push((leftchar >> leftbits) & 0xff);
2945
+ leftchar &= (1 << leftbits) - 1;
2946
+ binLen -= 1;
2947
+ }
2948
+ }
2949
+ for (; i < line.length; i += 1) {
2950
+ const ch = line[i];
2951
+ if (ch !== 0x20 && ch !== 0x20 + 64 && ch !== 0x0a && ch !== 0x0d)
2952
+ throw new BinasciiError('Trailing garbage');
2953
+ }
2954
+ return Buffer.from(out);
2955
+ }
2956
+ /** `bytes.splitlines()`: `\n`, `\r` and `\r\n` split, no trailing empty line. */
2957
+ function bytesSplitlines(data) {
2958
+ const out = [];
2959
+ let start = 0;
2960
+ for (let i = 0; i < data.length; i += 1) {
2961
+ const b = data[i];
2962
+ if (b === 0x0a || b === 0x0d) {
2963
+ out.push(data.subarray(start, i));
2964
+ if (b === 0x0d && data[i + 1] === 0x0a)
2965
+ i += 1;
2966
+ start = i + 1;
2967
+ }
2968
+ }
2969
+ if (start < data.length)
2970
+ out.push(data.subarray(start));
2971
+ return out;
2972
+ }
2973
+ /**
2974
+ * `email.message._decode_uu`: the lines between `begin <octal mode> ...` and `end`, or
2975
+ * `ValueError` — which `get_payload` answers with the payload UNDECODED.
2976
+ *
2977
+ * `begin` must start the line and its mode must parse in base 8; a blank line before `end`
2978
+ * is "Truncated input"; `end` is recognised stripped of ` \t\r\n\f`; and a line
2979
+ * `a2b_uu` rejects is retried cut to the length its count character implies (the workaround
2980
+ * for broken encoders that the stdlib carries by name). Measured against the reference
2981
+ * (job43 G2) on seventeen payload shapes, including the Truncated and no-`begin` ones.
2982
+ */
2983
+ export function decodeUu(encoded) {
2984
+ const lines = bytesSplitlines(encoded);
2985
+ let at = 0;
2986
+ for (;;) {
2987
+ if (at >= lines.length)
2988
+ throw new BinasciiError('`begin` line not found');
2989
+ const line = lines[at];
2990
+ at += 1;
2991
+ if (line.subarray(0, 6).toString('latin1') === 'begin ') {
2992
+ const mode = line.subarray(6).toString('latin1').split(' ')[0];
2993
+ // `int(mode, base=8)`: a sign, optional `0o`, octal digits with single underscores.
2994
+ if (/^[ \t\n\x0b\x0c\r]*[+-]?(?:0[oO]_?)?[0-7]+(?:_[0-7]+)*[ \t\n\x0b\x0c\r]*$/.test(mode))
2995
+ break;
2996
+ }
2997
+ }
2998
+ const out = [];
2999
+ for (; at < lines.length; at += 1) {
3000
+ const line = lines[at];
3001
+ if (line.length === 0)
3002
+ throw new BinasciiError('Truncated input');
3003
+ if (line.toString('latin1').replace(/^[ \t\r\n\f]+|[ \t\r\n\f]+$/g, '') === 'end')
3004
+ break;
3005
+ try {
3006
+ out.push(a2bUu(line));
3007
+ }
3008
+ catch (err) {
3009
+ if (!(err instanceof BinasciiError))
3010
+ throw err;
3011
+ const nbytes = Math.floor((((line[0] - 32) & 63) * 4 + 5) / 3);
3012
+ out.push(a2bUu(line.subarray(0, nbytes)));
3013
+ }
3014
+ }
3015
+ return Buffer.concat(out);
3016
+ }
3017
+ /** `Message.get_payload(decode=True)` for a leaf; `null` for a multipart the way it is there. */
3018
+ function decodedPayload(part) {
3019
+ if (part.body === null)
3020
+ return null;
3021
+ const raw = Buffer.from(part.body, 'latin1');
3022
+ const cte = (header(part, 'content-transfer-encoding') ?? '').toLowerCase();
3023
+ if (cte === 'quoted-printable')
3024
+ return decodeQuotedPrintable(raw);
3025
+ if (['x-uuencode', 'uuencode', 'uue', 'x-uue'].includes(cte)) {
3026
+ // `except ValueError: return bpayload` — a payload that will not decode is handed back
3027
+ // as it is, `begin` line and all, and read as text.
3028
+ try {
3029
+ return decodeUu(raw);
3030
+ }
3031
+ catch (err) {
3032
+ if (err instanceof BinasciiError)
3033
+ return raw;
3034
+ throw err;
3035
+ }
3036
+ }
3037
+ if (cte === 'base64') {
3038
+ const joined = part.body.replace(/\r\n|\r|\n/g, '');
3039
+ const pad = joined.length % 4;
3040
+ return Buffer.from(pad ? joined + '='.repeat(4 - pad) : joined, 'base64');
3041
+ }
3042
+ return raw;
3043
+ }
3044
+ const HEADER_LINE = /^(From |[\x21-\x39\x3b-\x7e]*:|[\t ])/;
3045
+ function splitLines(text) {
3046
+ // `NLCRE_crack`: every line keeps its own terminator.
3047
+ const out = [];
3048
+ let start = 0;
3049
+ for (let i = 0; i < text.length; i += 1) {
3050
+ const ch = text[i];
3051
+ if (ch === '\n') {
3052
+ out.push(text.slice(start, i + 1));
3053
+ start = i + 1;
3054
+ }
3055
+ else if (ch === '\r') {
3056
+ const end = text[i + 1] === '\n' ? i + 2 : i + 1;
3057
+ out.push(text.slice(start, end));
3058
+ start = end;
3059
+ i = end - 1;
3060
+ }
3061
+ }
3062
+ if (start < text.length)
3063
+ out.push(text.slice(start));
3064
+ return out;
3065
+ }
3066
+ function stripEol(line) {
3067
+ return line.replace(/\r\n$|\r$|\n$/, '');
3068
+ }
3069
+ function escapeRegex(s) {
3070
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
3071
+ }
3072
+ /** `email.feedparser` for what `extract_mhtml` walks: headers, multipart, message/*. */
3073
+ function parseMessage(lines, defaultType) {
3074
+ const part = { headers: [], body: null, children: [], defaultType };
3075
+ // `_parse_headers`: up to the first line that is not a header; a blank line is consumed.
3076
+ let at = 0;
3077
+ const raw = [];
3078
+ while (at < lines.length) {
3079
+ const line = lines[at];
3080
+ if (!HEADER_LINE.test(line)) {
3081
+ if (/^(\r\n|\r|\n)$/.test(line))
3082
+ at += 1;
3083
+ break;
3084
+ }
3085
+ if (/^[ \t]/.test(line) && raw.length)
3086
+ raw[raw.length - 1].push(line);
3087
+ else
3088
+ raw.push([line]);
3089
+ at += 1;
3090
+ }
3091
+ for (const source of raw) {
3092
+ const first = source[0];
3093
+ if (first.startsWith('From '))
3094
+ continue;
3095
+ const colon = first.indexOf(':');
3096
+ if (colon < 0)
3097
+ continue;
3098
+ const name = first.slice(0, colon);
3099
+ const value = (first.slice(colon + 1).replace(/^[ \t]+/, '') + source.slice(1).join('')).replace(/[\r\n]+$/, '');
3100
+ part.headers.push([name, value]);
3101
+ }
3102
+ const body = lines.slice(at);
3103
+ const maintype = contentMaintype(part);
3104
+ if (maintype === 'message') {
3105
+ part.children.push(parseMessage(body, 'text/plain'));
3106
+ return part;
3107
+ }
3108
+ if (maintype === 'multipart') {
3109
+ const boundary = getParam(part, 'boundary');
3110
+ if (boundary === undefined) {
3111
+ part.body = body.join('');
3112
+ return part;
3113
+ }
3114
+ const childDefault = contentType(part) === 'multipart/digest' ? 'message/rfc822' : 'text/plain';
3115
+ const boundaryRe = new RegExp(`^(--${escapeRegex(pyRstrip(boundary))})(--)?([ \\t]*)(\\r\\n|\\r|\\n)?$`);
3116
+ let k = 0;
3117
+ let sawBoundary = false;
3118
+ while (k < body.length) {
3119
+ const line = body[k];
3120
+ const mo = boundaryRe.exec(line);
3121
+ k += 1;
3122
+ if (!mo)
3123
+ continue;
3124
+ if (mo[2])
3125
+ break;
3126
+ // Consume any multiple boundary lines that may be following.
3127
+ while (k < body.length && boundaryRe.test(body[k]))
3128
+ k += 1;
3129
+ const sub = [];
3130
+ while (k < body.length && !boundaryRe.test(body[k])) {
3131
+ sub.push(body[k]);
3132
+ k += 1;
3133
+ }
3134
+ const child = parseMessage(sub, childDefault);
3135
+ // The newline preceding the boundary belongs to the boundary, not to the part — and
3136
+ // the reference strips it after every subpart, closing boundary seen or not.
3137
+ if (child.body !== null)
3138
+ child.body = stripEol(child.body);
3139
+ part.children.push(child);
3140
+ sawBoundary = true;
3141
+ }
3142
+ if (!sawBoundary)
3143
+ part.body = body.join('');
3144
+ return part;
3145
+ }
3146
+ part.body = body.join('');
3147
+ return part;
3148
+ }
3149
+ function* walk(part) {
3150
+ yield part;
3151
+ for (const child of part.children)
3152
+ yield* walk(child);
3153
+ }
3154
+ function decodedBody(part) {
3155
+ const payload = decodedPayload(part);
3156
+ if (payload === null)
3157
+ return '';
3158
+ return decodeCharset(payload, contentCharset(part) ?? 'utf-8');
3159
+ }
3160
+ export function extractMhtml(path) {
3161
+ const message = parseMessage(splitLines(readFileSync(pyPathStr(path)).toString('latin1')), 'text/plain');
3162
+ const bodies = [];
3163
+ const skipped = new Map();
3164
+ let skippedBytes = 0;
3165
+ for (const part of walk(message)) {
3166
+ if (contentMaintype(part) === 'multipart')
3167
+ continue;
3168
+ const subtype = contentSubtype(part);
3169
+ if (contentMaintype(part) !== 'text' || (subtype !== 'html' && subtype !== 'plain')) {
3170
+ const payload = decodedPayload(part);
3171
+ const type = contentType(part);
3172
+ skipped.set(type, (skipped.get(type) ?? 0) + 1);
3173
+ skippedBytes += payload ? payload.length : 0;
3174
+ continue;
3175
+ }
3176
+ const body = decodedBody(part);
3177
+ if (pyStrip(body))
3178
+ bodies.push([subtype, body]);
3179
+ }
3180
+ const parts = [];
3181
+ bodies.forEach(([subtype, body], index) => {
3182
+ const rows = subtype === 'html' ? htmlRows(body) : plainRows(body);
3183
+ const name = bodies.length === 1 ? 'document' : `part${index}`;
3184
+ parts.push(new Part(name, index, rows));
3185
+ });
3186
+ let omissions = [];
3187
+ if (skipped.size) {
3188
+ let count = 0;
3189
+ for (const c of skipped.values())
3190
+ count += c;
3191
+ omissions = [new Omission(OMIT_MEDIA, count, skippedBytes, [], pySorted(skipped.keys()).join(', '))];
3192
+ }
3193
+ return nonempty(new Document('mhtml', parts, omissions), path, 'no text/html or text/plain part carried any text');
3194
+ }
3195
+ export function extractHtml(path) {
3196
+ const raw = readFileSync(pyPathStr(path));
3197
+ const markup = new TextDecoder('utf-8', { fatal: false, ignoreBOM: true }).decode(raw);
3198
+ const doc = new Document('html', [new Part('document', 0, htmlRows(markup))]);
3199
+ return nonempty(doc, path, 'its markup carried no text outside script and style');
3200
+ }
3201
+ function plainRows(text) {
3202
+ return pySplitlines(text)
3203
+ .map((raw) => pyStrip(clean(raw)))
3204
+ .filter((line) => line);
3205
+ }
3206
+ function nonempty(doc, path, why) {
3207
+ if (doc.parts.some((part) => part.rows.length))
3208
+ 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');
3211
+ }
3212
+ // --------------------------------------------------------------- plain text, in no container
3213
+ function textRows(text) {
3214
+ if (!text)
3215
+ return [];
3216
+ if (text.endsWith('\n'))
3217
+ text = text.slice(0, -1);
3218
+ return text.split('\n').map((line) => (line.endsWith('\r') ? line.slice(0, -1) : line));
3219
+ }
3220
+ export function extractText(path) {
3221
+ const size = statSync(pyPathStr(path)).size;
3222
+ let raw = readPrefix(path, TEXT_MAX_BYTES + 1);
3223
+ const capped = raw.length > TEXT_MAX_BYTES;
3224
+ if (capped)
3225
+ raw = raw.subarray(0, TEXT_MAX_BYTES);
3226
+ const scan = utf8Scan(raw, !capped);
3227
+ let why = '';
3228
+ let text;
3229
+ if (scan.invalidAt >= 0) {
3230
+ text = decodeUtf8(raw.subarray(0, scan.invalidAt));
3231
+ why = `byte ${scan.invalidAt} begins a sequence that is not UTF-8 (${scan.reason})`;
3232
+ }
3233
+ else {
3234
+ text = decodeUtf8(raw.subarray(0, scan.end));
3235
+ }
3236
+ const control = BINARY_CONTROL.exec(text);
3237
+ if (control !== null) {
3238
+ const at = utf8Length(text.slice(0, control.index));
3239
+ why = `byte ${at} is control code 0x${control[0].charCodeAt(0).toString(16).padStart(2, '0')}, which is binary framing`;
3240
+ text = text.slice(0, control.index);
3241
+ }
3242
+ if (why || capped)
3243
+ text = text.slice(0, text.lastIndexOf('\n') + 1);
3244
+ const dropped = size - utf8Length(text);
3245
+ let omissions = [];
3246
+ if (dropped > 0) {
3247
+ const subject = why ? OMIT_UNREAD_TAIL : OMIT_SIZE_CAP;
3248
+ const what = why || `${size} bytes on disk; this reader reads ${TEXT_MAX_BYTES}`;
3249
+ omissions = [new Omission(subject, dropped, dropped, [], what)];
3250
+ }
3251
+ const doc = new Document('text', [new Part('document', 0, textRows(text))], omissions);
3252
+ if (doc.parts.some((part) => part.rows.some((row) => pyStrip(row))))
3253
+ return doc;
3254
+ throw refuse(path, sniff(path), why || 'no line in it carries a character, so this reader has no text for it');
3255
+ }
3256
+ // ------------------------------------------------------- pdf, doc, rtf: identified, refused
3257
+ /**
3258
+ * DELIBERATE DIVERGENCE (docs/porting.md). The reference reads a PDF through `pdfread`
3259
+ * and `.doc`/`.rtf` through `/usr/bin/textutil`; this half has neither, so the three
3260
+ * kinds are identified by `sniff` and refused by name. Same `_refuse` shape as every
3261
+ * other refusal — container, size on disk, the suffix disagreement — with a remedy that
3262
+ * says which server can read it.
3263
+ */
3264
+ function nodeRefusal(kind) {
3265
+ if (kind === 'pdf')
3266
+ return 'pdf is not readable by the Node server yet (the Python server reads it); see docs/porting.md';
3267
+ return `${kind} is read through ${TEXTUTIL} by the Python server and not by the Node server; see docs/porting.md`;
3268
+ }
3269
+ const EXTRACTORS = {
3270
+ text: extractText,
3271
+ xlsx: extractXlsx,
3272
+ docx: extractDocx,
3273
+ mhtml: extractMhtml,
3274
+ html: extractHtml,
3275
+ };
3276
+ const NODE_REFUSED = new Set(['pdf', 'doc', 'rtf']);
3277
+ /** Dispatch on what the file IS. Anything unreadable raises, naming the container. */
3278
+ export function extract(path) {
3279
+ const container = sniff(path);
3280
+ const reader = lookup(EXTRACTORS, container.kind);
3281
+ if (reader !== undefined)
3282
+ return reader(path);
3283
+ if (NODE_REFUSED.has(container.kind))
3284
+ throw refuse(path, container, nodeRefusal(container.kind));
3285
+ throw refuse(path, container, unsupportedRemedy());
3286
+ }
3287
+ /** Slice `limit` rows from `part` starting at `offset`, optionally under a byte ceiling. */
3288
+ export function page(doc, part = 0, offset = 0, limit = DEFAULT_ROW_LIMIT, maxBytes = null) {
3289
+ const target = doc.part(part);
3290
+ if (offset < 0 || limit < 1) {
3291
+ throw new DocumentReadError(`offset must be >= 0 and limit >= 1, got ${offset} and ${limit}`);
3292
+ }
3293
+ const rows = [];
3294
+ let used = 0;
3295
+ let dropped = 0;
3296
+ for (const row of target.rows.slice(offset, offset + limit)) {
3297
+ const bytes = utf8Length(row);
3298
+ const size = bytes + (rows.length ? 1 : 0);
3299
+ if (maxBytes !== null && rows.length && used + size > maxBytes)
3300
+ break;
3301
+ if (maxBytes !== null && !rows.length && size > maxBytes) {
3302
+ const encoded = Buffer.from(row, 'utf8').subarray(0, maxBytes);
3303
+ const scan = utf8Scan(encoded, false);
3304
+ const cut = decodeUtf8(encoded.subarray(0, scan.end));
3305
+ dropped = bytes - utf8Length(cut);
3306
+ rows.push(cut);
3307
+ used = utf8Length(cut);
3308
+ break;
3309
+ }
3310
+ rows.push(row);
3311
+ used += size;
3312
+ }
3313
+ const nxt = offset + rows.length;
3314
+ return new Page(target.name, offset, rows, target.rowCount, nxt < target.rowCount ? nxt : null, dropped);
3315
+ }