/** * Turning a PDF / DOCX / XLSX into plain text — the PURE half (BOFF-6291). * * Everything in this file is a string-in/string-out function with no imports, * no DOM and no parser dependency, which is what makes it testable under the * repo's `environment: 'node'` vitest config. The browser-only half — reading * the File, lazily importing pdf.js / fflate, unzipping — lives in * `documentExtract.ts` and calls into here. * * ## Why regex and not DOMParser * * DOCX and XLSX are ZIPs of XML, and the obvious tool is `DOMParser`. It is * browser-only, so every one of these functions would then be untestable here * and the extraction logic would ship unverified. We are pulling *text* out of * a machine-generated, schema-fixed document part — not rendering untrusted * markup — so a targeted scan over the tags Word/Excel actually emit is both * sufficient and checkable. The output is inlined into a prompt as text; it is * never interpreted as markup, so a mis-parse costs fidelity, not safety. */ /** Upper bound on characters pulled out of one document before we stop. */ export declare const MAX_EXTRACTED_CHARS = 200000; /** Upper bound on PDF pages we will even look at, however long the document. */ export declare const MAX_PDF_PAGES_SCANNED = 100; /** * How many characters of PAGE-SPECIFIC text a page must yield before we are * willing to say we read it. * * A scanned page is not empty — its scanner stamps a footer on every page, and * `page.trim() !== ''` is therefore true for a document containing no readable * content whatsoever. 24 characters is below any real page of a soil report or * an audit (those run to thousands) and above the watermarks that defeat an * all-or-nothing check. */ export declare const MIN_PAGE_TEXT_CHARS = 24; /** * A line repeated on at least this share of pages is a running head/footer or a * scanner watermark, not content. * * Used ONLY to judge whether a page carries its own text; the line is never * removed from the text handed to the model. So a false positive here costs * nothing but a slightly more cautious disclosure, while a false negative is the * failure this whole notion exists to stop. */ export declare const BOILERPLATE_PAGE_SHARE = 0.6; /** * Boilerplate detection only ever applies to short lines, never to content. * * Named trade-off: a running footer LONGER than this — a full confidentiality * paragraph stamped on every page of a scan — is not recognised, and the scan * is then treated as readable. That is the safe direction to be wrong in only * because the model still sees exactly the text the file contains; it is not a * claim the heuristic is exact. */ export declare const MAX_BOILERPLATE_LINE_CHARS = 80; /** * Below this share of pages carrying a text layer, a document is "mostly * images" and must be presented as unread rather than as extracted. */ export declare const MIN_TEXT_PAGE_SHARE = 0.5; /** * What happened when we tried to read a document. * * Every value other than `ok` MUST reach the model as a sentence — a document * we could not read has to look different from a document that was empty, and * both have to look different from one we never tried. Silence here is what * makes an assistant answer confidently about a file it never saw. */ export type DocumentExtractionStatus = "ok" | "no-text" | "encrypted" | "corrupt" /** No extractor for this type — we did not even try. */ | "unsupported"; /** Document formats we can turn into text in the browser. */ export type DocumentFormat = "pdf" | "docx" | "xlsx"; export interface DocumentExtraction { status: DocumentExtractionStatus; /** * Which extractor produced this. Carried so the failure wording can be * true rather than merely plausible: "appears to be scanned images" is the * right sentence for a PDF and a wrong one for an empty .docx. */ format?: DocumentFormat; /** Total pages in a PDF (not how many we read — see `pagesScanned`). */ pageCount?: number; /** How many pages we actually looked at; capped by MAX_PDF_PAGES_SCANNED. */ pagesScanned?: number; /** How far into the document `textContent` reaches: pages 1..N were processed. */ pagesIncluded?: number; /** * The 1-indexed pages whose text is ACTUALLY in `textContent`. * * Distinct from `pagesIncluded` on purpose: a 40-page certificate with 3 text * pages and 37 image pages processes all 40 and reads 3, and saying "40 pages" * there is the exact falsehood this feature must not tell. */ pageNumbers?: number[]; /** * The 1-indexed pages within `pagesScanned` that yielded no page-specific * text — image or blank pages. Counted so they can be DISCLOSED rather than * silently folded into a page total. */ imagePages?: number[]; /** How many scanned pages carried a text layer at all. */ textPageCount?: number; /** Characters of text the document yielded before the inline budget cut it. */ sourceChars?: number; /** Worksheet names, in workbook order. XLSX only. */ sheetNames?: string[]; /** Parser detail for the `corrupt` case — shown to the model verbatim. */ detail?: string; } /** Resolve the five XML entities plus numeric character references. */ export declare function decodeXmlEntities(value: string): string; /** * `word/document.xml` → plain text. * * Word's body is a flat list of `` paragraphs holding `` runs, with * `` and `` as explicit whitespace. Table cells are paragraphs * too, so a table comes out one cell per line — lossy for layout, faithful for * content, and the model reads it fine. * * `` (field codes such as a HYPERLINK target or a MERGEFIELD name) * is deliberately skipped: it is markup the reader never sees, and inlining it * puts strings in front of the model that are not in the document. */ export declare function docxXmlToText(xml: string): string; /** `xl/sharedStrings.xml` → the string table, indexed as the sheets index it. */ export declare function parseSharedStrings(xml: string): string[]; /** `A` → 0, `Z` → 25, `AA` → 26. Returns -1 for anything that is not a column. */ export declare function columnLetterToIndex(reference: string): number; /** * One worksheet's XML → CSV-ish rows. * * Sparse cells are padded from their `r="C7"` reference so column alignment * survives, which is the whole reason a spreadsheet is worth inlining at all. * Numbers come out exactly as stored — an Excel date is a serial number, and * guessing at a display format would put dates in front of the model that the * file does not contain. */ export declare function sheetXmlToRows(xml: string, sharedStrings: string[]): string[]; /** * `xl/workbook.xml` (+ its rels) → the sheets in workbook order. * * The rels file is what maps `r:id="rId3"` to `worksheets/sheet1.xml`; the * common assumption that `sheetN.xml` matches tab order is simply false in any * workbook whose sheets have been reordered or deleted. */ export declare function parseWorkbookSheets(workbookXml: string, relsXml: string): { name: string; path: string | null; }[]; /** Stitch the parsed sheets into one document, headed by the sheet name. */ export declare function joinSheets(sheets: { name: string; rows: string[]; }[]): string; /** * pdf.js text items → one page of text. * * `hasEOL` is pdf.js' own end-of-line marker, so line structure comes from the * library rather than from us guessing at coordinates. Marked-content items * carry no `str` and are skipped. */ export declare function pdfTextItemsToPage(items: { str?: string; hasEOL?: boolean; }[]): string; /** Header a page gets when several of them are inlined together. */ export declare function pageMarker(pageNumber: number): string; /** * Which pages of a PDF actually carry text, and which are images. * * ## Why an all-or-nothing check is not enough * * The obvious test — "did ANY page yield a non-empty string" — is defeated by * every scanner on the market, because scanners stamp a text footer onto each * page image. A 12-page scanned soil report whose only text layer is * `Scanned with CamScanner 1` passes that test, and the document is then handed * to the model as `Extracted text (12 pages):` with no hint that not one word of * the report is in it. The model answers confidently about a file nobody read. * * So the unit of judgement is the PAGE, and the question per page is whether it * holds text SPECIFIC TO IT. Two signals, deliberately both cheap and pure: * * 1. **Repetition.** A line that appears on most pages, once digits are * normalised away so page numbers compare equal, is a running head/footer or * a scan watermark. Only short lines are eligible, so this can never strike * out a paragraph of content. * 2. **Volume.** What is left after (1) must reach `MIN_PAGE_TEXT_CHARS`. * * Boilerplate is subtracted only for this JUDGEMENT — the text handed to the * model is untouched, so mis-classifying a sparse page costs a more cautious * sentence rather than lost content. */ export interface PageTextSummary { /** 1-indexed pages carrying page-specific text. */ textPages: number[]; /** 1-indexed pages carrying nothing but blanks or repeated boilerplate. */ imagePages: number[]; /** The normalised lines judged to be running heads/footers or watermarks. */ boilerplate: string[]; } export declare function summarisePageText(pages: string[]): PageTextSummary; /** * Is this document mostly images — i.e. must it be presented as UNREAD? * * Takes the counts rather than the extraction so it stays pure and is the same * function whether it is asked at extraction time or at prompt-building time. */ export declare function isMostlyImagePages(textPageCount: number, pagesScanned: number): boolean; /** * Everything `extractPdf` does once pdf.js has handed over the page strings. * * Pulled out of the browser half deliberately: pdf.js needs a DOM and this * repo's vitest is `environment: 'node'`, so leaving the scanned-vs-read * decision inside `extractPdf` left the most dangerous logic in the feature — * "may the model treat this document as read" — with no test that could reach * it. Here it is a string-array-in, extraction-out function. */ export interface PdfPageFit { extraction: DocumentExtraction; textContent?: string; documentPages?: string[]; textTruncated?: boolean; } export declare function fitPdfPages(pages: string[], pageCount: number, pagesScanned: number, limit: number): PdfPageFit; /** `[1,2,3,7,9,10]` → `"1-3, 7, 9-10"`. Empty in, empty out. */ export declare function formatPageRanges(pages: number[]): string; export interface FittedPages { text: string; truncated: boolean; /** How far into the document `text` reaches: pages 1..N were processed. */ pagesIncluded: number; /** The 1-indexed pages whose text is actually in `text`. */ pageNumbers: number[]; /** Rendered length of every page that HAS text, before the cut. */ sourceChars: number; /** * `pages` trimmed to exactly what `text` holds, indices preserved. * * This is what makes the per-file cap an INVARIANT rather than a hope: the * caller stores this array, so a later re-fit against the shared budget can * only ever shrink it. Storing the untruncated pages instead let the shared * pass re-expand a document to 20,000 characters — 2.5x the per-file cap — * and starve the file attached beside it. */ pages: string[]; } /** * Fit whole PDF pages into a character budget, preferring a PAGE boundary. * * A page is the unit a reader — and a citation — thinks in, so cutting mid-page * is a worse lie than dropping the tail entirely. Falls back to a hard character * cut only when the first page with text will not fit on its own, because * "0 pages included" is useless to the model. * * Blank entries are pages the caller has judged to carry no text (see * `summarisePageText`). They are skipped rather than rendered as an empty * `[page N]`, which would both waste budget and read to the model as a page it * had seen and found empty. */ export declare function truncatePagesForInline(pages: string[], limit: number): FittedPages; //# sourceMappingURL=documentText.d.ts.map