1// @effect-diagnostics nodeBuiltinImport:off -- isolated CLI worker; no server runtime is loaded here.
2import * as NodeFSP from "node:fs/promises";
3import * as NodePath from "node:path";
4import * as NodeUtil from "node:util";
5import { characterEntities } from "character-entities";
6import { SaxesParser } from "saxes";
7import * as Yauzl from "yauzl";
8
9const MAX_INPUT = 16 * 1024 * 1024;
10const MAX_EXPANDED = 32 * 1024 * 1024;
11const MAX_OUTPUT = 1_000_000;
12const CELL_START = Symbol("cell-start");
13const CELL_END = Symbol("cell-end");
14type TextPart = string | typeof CELL_START | typeof CELL_END | { fallback: string };
15
16const TRUNCATED = "\n\n[Extracted text truncated at document extraction limits.]";
17// Parts the document never displays: tracked deletions and moves, field codes, phonetic guides,
18// paragraph properties such as tab-stop definitions.
19const HIDDEN = new Set([
20 "script",
21 "style",
22 "binary-data",
23 "rPh",
24 "delText",
25 "moveFrom",
26 "instrText",
27 "delInstrText",
28 "tracked-changes",
29 "pPr",
30]);
31
32// @effect-diagnostics-next-line extendsNativeError:off - this worker loads no Effect runtime; Truncation is a local throw sentinel.
33class Truncation extends Error {}
34
35interface XmlNode {
36 name: string;
37 attrs: Record<string, string>;
38 children: Array<XmlNode | string>;
39 spaceCount?: number;
40 hasText?: boolean;
41 hasTextChoice?: boolean;
42}
43
44function xml(source: string, odfSpaces = false, htmlEntities = false): XmlNode {
45 const root: XmlNode = { name: "root", attrs: {}, children: [] };
46 const stack = [root];
47 let nodes = 0;
48 const parser = new SaxesParser();
49 if (htmlEntities) Object.assign(parser.ENTITIES, characterEntities);
50 parser.on("opentag", (tag) => {
51 if (++nodes > 200_000 || stack.length > 128) throw new Truncation("XML structure limit");
52 const node: XmlNode = {
53 name: tag.name.split(":").at(-1)!,
54 attrs: Object.fromEntries(
55 Object.entries(tag.attributes).flatMap(([key, value]) => [
56 [key, String(value)],
57 [key.split(":").at(-1)!, String(value)],
58 ]),
59 ),
60 children: [],
61 };
62 if (odfSpaces && node.name === "s") node.spaceCount = repeatCount(node.attrs.c);
63 stack.at(-1)!.children.push(node);
64 stack.push(node);
65 });
66 parser.on("closetag", () => {
67 const node = stack.pop()!;
68 const parent = stack.at(-1)!;
69 // Markup-compatibility fallbacks repeat their mc:Choice content, e.g. Word text boxes.
70 // Keep one when no Choice carries text, such as a chart with a VML caption.
71 if (node.name === "Fallback" && parent.hasTextChoice) {
72 parent.children.pop();
73 return;
74 }
75 if (!node.hasText) return;
76 parent.hasText = true;
77 if (node.name === "Choice") parent.hasTextChoice = true;
78 });
79 const text = (value: string) => {
80 const node = stack.at(-1)!;
81 node.children.push(value);
82 if (value.trim() !== "") node.hasText = true;
83 };
84 parser.on("text", text);
85 parser.on("cdata", text);
86 parser.write(source).close();
87 return root;
88}
89
90const children = (node: XmlNode) =>
91 node.children.filter((child): child is XmlNode => typeof child !== "string");
92function* all(node: XmlNode, name: string | readonly string[]): Generator<XmlNode, void> {
93 for (const child of children(node)) {
94 if (typeof name === "string" ? child.name === name : name.includes(child.name)) yield child;
95 else yield* all(child, name);
96 }
97}
98function* text(node: XmlNode): Generator<string, void> {
99 if (HIDDEN.has(node.name)) return;
100 if (["tab", "cell"].includes(node.name)) yield "\t";
101 else if (["br", "line-break", "cr"].includes(node.name)) yield "\n";
102 else if (node.spaceCount !== undefined) {
103 yield " ".repeat(Math.min(MAX_OUTPUT, node.spaceCount));
104 if (node.spaceCount > MAX_OUTPUT) throw new Truncation("Space repeat limit");
105 } else
106 for (const child of node.children) {
107 if (typeof child === "string") yield child;
108 else yield* text(child);
109 }
110}
111function* paragraphs(node: XmlNode): Generator<TextPart, void> {
112 if (HIDDEN.has(node.name)) return;
113 for (const child of node.children) {
114 if (typeof child === "string") yield child;
115 else if (["br", "line-break", "cr"].includes(child.name)) yield "\n";
116 else if (["tbl", "table"].includes(child.name)) yield* table(child);
117 else if (["p", "h"].includes(child.name) || /^h[1-6]$/.test(child.name)) {
118 const style = all(child, "pStyle").next().value?.attrs.val ?? "";
119 const level =
120 child.name === "h"
121 ? Number(child.attrs["outline-level"]) || 1
122 : /^h[1-6]$/.test(child.name)
123 ? Number(child.name[1])
124 : Number(style.match(/^Heading([1-6])$/i)?.[1]) || 0;
125 if (level) yield `${"#".repeat(Math.min(6, level))} `;
126 yield* text(child);
127 yield "\n";
128 } else {
129 yield* paragraphs(child);
130 if (["li", "div", "section"].includes(child.name)) yield "\n";
131 }
132 }
133}
134function* inCell(parts: Iterable<TextPart>): Generator<TextPart, void> {
135 yield CELL_START;
136 yield* parts;
137 yield CELL_END;
138}
139function repeatCount(value: string | undefined): number {
140 if (value === undefined) return 1;
141 if (!/^[0-9]+$/.test(value) || Number(value) < 1) throw new Error("Invalid repeat count");
142 return Number(value);
143}
144function odfValue(cell: XmlNode): string {
145 const type = cell.attrs["office:value-type"] ?? cell.attrs["value-type"];
146 const attr = (name: string) => cell.attrs[`office:${name}`] ?? cell.attrs[name] ?? "";
147 switch (type) {
148 case "void":
149 return "";
150 case "string":
151 return attr("string-value");
152 case "boolean":
153 return attr("boolean-value");
154 case "date":
155 return attr("date-value");
156 case "time":
157 return attr("time-value");
158 case "currency":
159 return attr("value").trim() ? `${attr("value")} ${attr("currency")}`.trim() : "";
160 case "percentage":
161 return attr("value").trim() ? `${attr("value")} [ODF value-type=percentage]` : "";
162 default:
163 return attr("value");
164 }
165}
166function hasCellText(cell: XmlNode): boolean {
167 if (odfValue(cell).trim()) return true;
168 // Space markers can have enormous repeat counts; emptiness never requires expanding them.
169 if (HIDDEN.has(cell.name) || cell.spaceCount !== undefined) return false;
170 return cell.children.some((child) =>
171 typeof child === "string" ? !!child.trim() : hasCellText(child),
172 );
173}
174function* table(node: XmlNode): Generator<TextPart, void> {
175 let rowCount = 0;
176 for (const row of all(node, ["tr", "table-row"])) {
177 const cells = children(row).filter((cell) =>
178 ["tc", "table-cell", "covered-table-cell", "td", "th"].includes(cell.name),
179 );
180 // ODS writers pad sheets with repeated empty rows and trailing cells.
181 while (cells.length && !hasCellText(cells.at(-1)!)) cells.pop();
182 if (!cells.length) continue;
183 const repeat = repeatCount(row.attrs["number-rows-repeated"]);
184 for (let index = 0; index < repeat; index++) {
185 if (++rowCount > 10_000) throw new Truncation("Row limit");
186 let column = 0;
187 for (const cell of cells) {
188 const count = repeatCount(cell.attrs["number-columns-repeated"]);
189 for (let i = 0; i < count; i++) {
190 if (column >= 512) throw new Truncation("Column limit");
191 if (column++) yield "\t";
192 yield CELL_START;
193 yield* paragraphs(cell);
194 yield { fallback: odfValue(cell) };
195 yield CELL_END;
196 }
197 }
198 yield "\n";
199 }
200 }
201}
202
203async function archive(path: string): Promise<Map<string, string>> {
204 const zip = await new Promise<Yauzl.ZipFile>((resolve, reject) =>
205 Yauzl.open(
206 path,
207 { lazyEntries: true, validateEntrySizes: true, strictFileNames: true },
208 (error, result) => (error || !result ? reject(error) : resolve(result)),
209 ),
210 );
211 const files = new Map<string, string>();
212 let expanded = 0;
213 let count = 0;
214 try {
215 await new Promise<void>((resolve, reject) => {
216 zip.on("error", reject);
217 zip.on("end", resolve);
218 zip.on("entry", (entry: Yauzl.Entry) => {
219 if (++count > 2048 || zip.entryCount > 2048) {
220 reject(new Truncation("Archive limit"));
221 return;
222 }
223 if (entry.generalPurposeBitFlag & 1) {
224 reject(new Error("Encrypted archive"));
225 return;
226 }
227 if (!/\.(xml|rels|xhtml|html|htm|opf|svg)$/i.test(entry.fileName)) {
228 zip.readEntry();
229 return;
230 }
231 if (
232 entry.uncompressedSize > MAX_EXPANDED ||
233 entry.uncompressedSize > Math.max(1024, entry.compressedSize * 200)
234 ) {
235 reject(new Truncation("Archive limit"));
236 return;
237 }
238 expanded += entry.uncompressedSize;
239 if (expanded > MAX_EXPANDED) {
240 reject(new Truncation("Expanded byte limit"));
241 return;
242 }
243 zip.openReadStream(entry, (error, stream) => {
244 if (error || !stream) {
245 reject(error);
246 return;
247 }
248 const buffers: Buffer[] = [];
249 let size = 0;
250 stream.on("error", reject);
251 stream.on("data", (chunk: Buffer) => {
252 size += chunk.length;
253 if (size > entry.uncompressedSize || size > MAX_EXPANDED) {
254 stream.destroy(new Truncation("Entry byte limit"));
255 return;
256 }
257 buffers.push(chunk);
258 });
259 stream.on("end", () => {
260 const data = Buffer.concat(buffers);
261 // XML's BOM and initial '<' bytes distinguish both UTF-16 byte orders.
262 const encoding =
263 (data[0] === 0xff && data[1] === 0xfe) || (data[0] === 0x3c && data[1] === 0)
264 ? "utf-16le"
265 : (data[0] === 0xfe && data[1] === 0xff) || (data[0] === 0 && data[1] === 0x3c)
266 ? "utf-16be"
267 : "utf-8";
268 files.set(entry.fileName, new TextDecoder(encoding).decode(data));
269 zip.readEntry();
270 });
271 });
272 });
273 zip.readEntry();
274 });
275 } finally {
276 zip.close();
277 }
278 return files;
279}
280
281function* notebook(source: string): Generator<string, void> {
282 const book: unknown = JSON.parse(source);
283 if (!book || typeof book !== "object" || !("cells" in book) || !Array.isArray(book.cells))
284 throw new Error("Invalid notebook");
285 function* lines(value: unknown): Generator<string, void> {
286 if (typeof value === "string") yield value;
287 else if (Array.isArray(value))
288 for (const line of value) {
289 if (typeof line === "string") yield line;
290 }
291 }
292 for (const [index, cell] of book.cells.entries()) {
293 if (index >= 10_000) throw new Truncation("Notebook cell limit");
294 if (!cell || typeof cell !== "object") continue;
295 const kind = "cell_type" in cell && cell.cell_type === "code" ? "code" : "markdown";
296 yield `## Cell ${index + 1} (${kind})\n`;
297 if ("source" in cell) yield* lines(cell.source);
298 if ("outputs" in cell && Array.isArray(cell.outputs)) {
299 yield "\nOutput:\n";
300 for (const output of cell.outputs) {
301 if (!output || typeof output !== "object") continue;
302 if ("text" in output) yield* lines(output.text);
303 else if (
304 "data" in output &&
305 output.data &&
306 typeof output.data === "object" &&
307 "text/plain" in output.data
308 )
309 yield* lines(output.data["text/plain"]);
310 else if ("output_type" in output && output.output_type === "error") {
311 if ("ename" in output) yield* lines(output.ename);
312 if ("evalue" in output) {
313 yield ": ";
314 yield* lines(output.evalue);
315 }
316 yield "\n";
317 if ("traceback" in output)
318 for (const line of lines(output.traceback)) {
319 yield NodeUtil.stripVTControlCharacters(line);
320 yield "\n";
321 }
322 }
323 yield "\n";
324 }
325 }
326 yield "\n\n";
327 }
328}
329
330function* rtf(source: string): Generator<string, void> {
331 if (!source.startsWith("{\\rtf")) throw new Error("Invalid RTF document");
332 let state = { skip: false, uc: 1, decoder: new TextDecoder("windows-1252") };
333 const stack: Array<typeof state> = [];
334 const symbols = new Map([
335 ["endash", "–"],
336 ["emdash", characterEntities.mdash],
337 ["enspace", "\u2002"],
338 ["emspace", "\u2003"],
339 ["qmspace", "\u2005"],
340 ["lquote", "‘"],
341 ["rquote", "’"],
342 ["ldblquote", "“"],
343 ["rdblquote", "”"],
344 ["bullet", "•"],
345 ["~", "\u00a0"],
346 ["_", "\u2011"],
347 ["-", ""],
348 ]);
349 let fallback = 0;
350 for (let i = 0; i < source.length; i++) {
351 const char = source[i]!;
352 if (char === "{" || char === "}") fallback = 0;
353 if (char === "{") {
354 stack.push({ ...state });
355 if (stack.length > 128) throw new Truncation("RTF depth limit");
356 } else if (char === "}") state = stack.pop() ?? state;
357 else if (char === "\\") {
358 const rest = source.slice(i + 1, i + 80);
359 const hex = /^'([0-9a-f]{2})/i.exec(rest);
360 const word = /^([a-z]+)(-?\d+)? ?/i.exec(rest);
361 if (hex) {
362 if (fallback) fallback--;
363 else if (!state.skip)
364 yield state.decoder.decode(Uint8Array.of(Number.parseInt(hex[1]!, 16)));
365 i += 3;
366 } else if (word) {
367 i += word[0].length;
368 const control = word[1]!;
369 const value = Number(word[2]);
370 if (control === "bin") {
371 if (!Number.isSafeInteger(value) || value < 0)
372 throw new Error("Invalid RTF binary length");
373 i += value;
374 }
375 if (fallback) {
376 fallback--;
377 continue;
378 }
379 if (
380 [
381 "fonttbl",
382 "colortbl",
383 "stylesheet",
384 "info",
385 "pict",
386 "object",
387 "fldinst",
388 "header",
389 "footer",
390 ].includes(control)
391 )
392 state.skip = true;
393 if (control === "ansicpg") {
394 // Per-byte decoding only supports single-byte Windows code pages.
395 if (value !== 874 && (value < 1250 || value > 1258))
396 throw new Error("Unsupported RTF code page");
397 state.decoder = new TextDecoder(`windows-${value}`, { fatal: true });
398 } else if (control === "uc") {
399 if (!Number.isSafeInteger(value) || value < 0)
400 throw new Error("Invalid RTF fallback count");
401 state.uc = value;
402 } else if (control === "u" && !state.skip) {
403 yield String.fromCharCode(value < 0 ? value + 65536 : value);
404 fallback = state.uc;
405 } else if (!state.skip && ["par", "line", "row"].includes(control)) yield "\n";
406 else if (!state.skip && ["tab", "cell"].includes(control)) yield "\t";
407 else if (!state.skip && symbols.has(control)) yield symbols.get(control)!;
408 } else {
409 i++;
410 if (fallback) fallback--;
411 else if (rest[0] === "*") state.skip = true;
412 // A backslash before a line break is \par; TextEdit ends paragraphs this way.
413 else if (!state.skip && (rest[0] === "\n" || rest[0] === "\r")) yield "\n";
414 else if (!state.skip && ["\\", "{", "}"].includes(rest[0]!)) yield rest[0]!;
415 else if (!state.skip && symbols.has(rest[0]!)) yield symbols.get(rest[0]!)!;
416 }
417 } else if (!state.skip && char !== "\n" && char !== "\r") {
418 if (fallback) fallback--;
419 else
420 yield char.charCodeAt(0) < 128
421 ? char
422 : state.decoder.decode(Uint8Array.of(char.charCodeAt(0)));
423 }
424 }
425}
426
427async function* document(path: string, extension: string): AsyncGenerator<TextPart, void> {
428 if ((await NodeFSP.stat(path)).size > MAX_INPUT) throw new Truncation("Input byte limit");
429 if (extension === ".ipynb") yield* notebook(await NodeFSP.readFile(path, "utf8"));
430 else if (extension === ".rtf") yield* rtf((await NodeFSP.readFile(path)).toString("latin1"));
431 else {
432 const files = await archive(path);
433 const notes: string[] = [];
434 const skip = (name: string) =>
435 notes.push(`\n[Skipped document part: ${name} (missing or invalid XML).]\n`);
436 const read = (name: string): XmlNode => {
437 try {
438 const data = files.get(name);
439 if (!data) throw new Error("Missing document part");
440 return xml(data, [".odt", ".ods", ".odp"].includes(extension), extension === ".epub");
441 } catch (error) {
442 if (error instanceof Truncation) throw error;
443 skip(name);
444 return { name: "root", attrs: {}, children: [] };
445 }
446 };
447 const relations = (name: string) =>
448 new Map(
449 [...all(read(name), "Relationship")]
450 .filter((rel) => rel.attrs.TargetMode !== "External")
451 .map((rel) => [rel.attrs.Id, rel.attrs.Target]),
452 );
453 if (extension === ".docx") yield* paragraphs(read("word/document.xml"));
454 else if ([".odt", ".ods", ".odp"].includes(extension)) {
455 const content = read("content.xml");
456 if (extension === ".odt") yield* paragraphs(content);
457 else {
458 let index = 0;
459 for (const part of all(content, extension === ".ods" ? "table" : "page")) {
460 yield extension === ".ods"
461 ? `\n\n## Sheet: ${part.attrs.name ?? "Untitled"}\n`
462 : `\n\n## Slide ${++index}\n`;
463 if (extension === ".ods") yield* table(part);
464 else yield* paragraphs(part);
465 }
466 }
467 } else if (extension === ".xlsx") {
468 const workbook = read("xl/workbook.xml");
469 const dateSystem = ["1", "true"].includes(
470 all(workbook, "workbookPr").next().value?.attrs.date1904 ?? "",
471 )
472 ? "1904"
473 : "1900";
474 const styles = files.has("xl/styles.xml") ? read("xl/styles.xml") : undefined;
475 const customFormats = new Map(
476 styles
477 ? [...all(styles, "numFmt")].map((node) => [node.attrs.numFmtId, node.attrs.formatCode])
478 : [],
479 );
480 const cellFormats = styles ? all(styles, "cellXfs").next().value : undefined;
481 const formats = cellFormats ? children(cellFormats) : [];
482 const strings = files.has("xl/sharedStrings.xml")
483 ? [...all(read("xl/sharedStrings.xml"), "si")]
484 : [];
485 const populatedStrings = strings.map(hasCellText);
486 const rels = relations("xl/_rels/workbook.xml.rels");
487 for (const sheet of all(workbook, "sheet")) {
488 yield `\n## Sheet: ${sheet.attrs.name}\n`;
489 const target = rels.get(sheet.attrs["r:id"] ?? sheet.attrs.id);
490 if (!target) {
491 skip(`sheet relationship ${sheet.attrs.name}`);
492 continue;
493 }
494 const name = target.startsWith("/")
495 ? target.slice(1)
496 : NodePath.posix.normalize(`xl/${target}`);
497 const populated = (cell: XmlNode) => {
498 const value = all(cell, "v").next().value;
499 if (cell.attrs.t === "s") {
500 const raw = value?.children.find((child) => typeof child === "string")?.trim();
501 return !!raw && !!populatedStrings[Number(raw)];
502 }
503 if (cell.attrs.t === "inlineStr") return hasCellText(cell);
504 if (value && hasCellText(value)) return true;
505 for (const formula of all(cell, "f")) if (hasCellText(formula)) return true;
506 return false;
507 };
508 let rowCount = 0;
509 for (const row of all(read(name), "row")) {
510 const cells: Array<XmlNode | undefined> = [];
511 let nextColumn = 0;
512 let columnsTruncated = false;
513 for (const cell of children(row).filter((node) => node.name === "c")) {
514 const letters = cell.attrs.r?.match(/^[A-Z]+/)?.[0];
515 const column = letters
516 ? [...letters].reduce((n, char) => n * 26 + char.charCodeAt(0) - 64, 0) - 1
517 : nextColumn;
518 nextColumn = column + 1;
519 if (!populated(cell)) continue;
520 if (column >= 512) {
521 columnsTruncated = true;
522 continue;
523 }
524 cells[column] = cell;
525 }
526 if (!cells.length && !columnsTruncated) continue;
527 if (++rowCount > 10_000) throw new Truncation("Row limit");
528 for (const [column, cell] of cells.entries()) {
529 if (column) yield "\t";
530 if (!cell) continue;
531 const value = all(cell, "v").next().value;
532 const raw = value?.children.find((child) => typeof child === "string") ?? "";
533 if (cell.attrs.t === "s") {
534 const shared = strings[Number(raw)];
535 if (shared) yield* inCell(text(shared));
536 } else if (cell.attrs.t === "inlineStr") yield* inCell(text(cell));
537 else {
538 if (value && hasCellText(value)) yield* inCell(text(value));
539 else for (const formula of all(cell, "f")) yield* inCell(text(formula));
540 const formatId = formats[Number(cell.attrs.s ?? 0)]?.attrs.numFmtId;
541 const format = formatId ? customFormats.get(formatId) : undefined;
542 if (
543 (cell.attrs.t === undefined || cell.attrs.t === "n") &&
544 raw.trim() &&
545 formatId &&
546 formatId !== "0"
547 )
548 yield ` [Excel numFmtId=${formatId}${format ? `; format=${format}` : ""}; date system ${dateSystem}]`;
549 }
550 }
551 yield "\n";
552 if (columnsTruncated) throw new Truncation("Column limit");
553 }
554 }
555 } else if (extension === ".pptx") {
556 const rels = relations("ppt/_rels/presentation.xml.rels");
557 let index = 0;
558 for (const slide of all(read("ppt/presentation.xml"), "sldId")) {
559 yield `\n\n## Slide ${++index}\n`;
560 const target = rels.get(slide.attrs["r:id"] ?? slide.attrs.id);
561 if (!target) {
562 skip(`slide relationship ${index}`);
563 continue;
564 }
565 const name = target.startsWith("/")
566 ? target.slice(1)
567 : NodePath.posix.normalize(`ppt/${target}`);
568 yield* paragraphs(read(name));
569 const slideRels = NodePath.posix.join(
570 NodePath.posix.dirname(name),
571 "_rels",
572 `${NodePath.posix.basename(name)}.rels`,
573 );
574 if (!files.has(slideRels)) continue;
575 for (const rel of all(read(slideRels), "Relationship")) {
576 const target = rel.attrs.Target;
577 if (
578 !rel.attrs.Type?.endsWith("/notesSlide") ||
579 rel.attrs.TargetMode === "External" ||
580 !target
581 )
582 continue;
583 yield "Notes:\n";
584 yield* paragraphs(
585 read(
586 target.startsWith("/")
587 ? target.slice(1)
588 : NodePath.posix.join(NodePath.posix.dirname(name), target),
589 ),
590 );
591 }
592 }
593 } else if (extension === ".epub") {
594 const rootfile = all(read("META-INF/container.xml"), "rootfile").next().value?.attrs[
595 "full-path"
596 ];
597 if (!rootfile) {
598 if (!notes.length) skip("EPUB package");
599 yield* notes;
600 return;
601 }
602 const packageXml = read(rootfile);
603 const items = new Map(
604 [...all(packageXml, "item")].map((item) => [item.attrs.id, item.attrs.href]),
605 );
606 let index = 0;
607 for (const item of all(packageXml, "itemref")) {
608 yield `\n\n## Chapter ${++index}\n`;
609 const href = items.get(item.attrs.idref);
610 if (!href) {
611 skip(`chapter ${index}`);
612 continue;
613 }
614 let decoded: string;
615 try {
616 decoded = decodeURIComponent(href.split("#")[0]!);
617 } catch {
618 skip(`chapter ${index}`);
619 continue;
620 }
621 const name = NodePath.posix.normalize(
622 NodePath.posix.join(NodePath.posix.dirname(rootfile), decoded),
623 );
624 yield* paragraphs(read(name));
625 }
626 } else throw new Error("Unsupported document");
627 yield* notes;
628 }
629}
630
631export async function extractDocumentText(path: string, extension: string): Promise<string> {
632 let result = "";
633 let remaining = MAX_OUTPUT;
634 let cellDepth = 0;
635 let started = false;
636 let space = false;
637 let truncated = false;
638 try {
639 // Charge raw text before normalization so repeated whitespace also exhausts the budget.
640 for await (const part of document(path, extension)) {
641 if (part === CELL_START) {
642 if (cellDepth++ === 0) {
643 started = false;
644 space = false;
645 } else if (started) space = true;
646 continue;
647 }
648 if (part === CELL_END) {
649 cellDepth--;
650 if (cellDepth > 0 && started) space = true;
651 continue;
652 }
653 if (typeof part !== "string" && started) continue;
654 const raw = typeof part === "string" ? part : part.fallback;
655 let value = raw.slice(0, remaining).replaceAll("\0", "");
656 remaining -= Math.min(raw.length, remaining);
657 if (cellDepth) {
658 value = value.replaceAll(/\s+/g, " ");
659 const trimmed = value.trim();
660 if (trimmed) {
661 result += (started && (space || value.startsWith(" ")) ? " " : "") + trimmed;
662 started = true;
663 }
664 space = value.endsWith(" ") || (!value && space);
665 } else result += value;
666 if (remaining === 0) throw new Truncation("Output budget");
667 }
668 } catch (error) {
669 if (!(error instanceof Truncation)) throw error;
670 truncated = true;
671 }
672 result = result.replaceAll(/\n(?:[ \t]*\n){2,}/g, "\n\n").trim();
673 if (!result && !truncated) throw new Error("No text extracted");
674 return result + (truncated ? TRUNCATED : "");
675}