From f6d5676bc3e32856d69198281089e6065ae04054 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 9 Sep 2026 18:22:02 -0700 Subject: [PATCH 01/21] chore(parsers): add parser quality evaluation framework Ground-truth corpus generator, real-world fetcher, bun harness over the production parseBuffer path, reference extractors and scorer, plus the plan and findings from the 2026-09-09 audit. Co-Authored-By: Claude Fable 5.1 --- apps/sim/scripts/parser-eval/FINDINGS.md | 35 ++++ apps/sim/scripts/parser-eval/PLAN.md | 52 +++++ apps/sim/scripts/parser-eval/REPORT.md | 102 ++++++++++ .../scripts/parser-eval/fetch-real-world.sh | 57 ++++++ .../scripts/parser-eval/generate-corpus.py | 189 ++++++++++++++++++ .../parser-eval/generate-spreadsheets.ts | 102 ++++++++++ .../scripts/parser-eval/reference-extract.py | 52 +++++ apps/sim/scripts/parser-eval/run-parsers.ts | 83 ++++++++ apps/sim/scripts/parser-eval/score.py | 164 +++++++++++++++ 9 files changed, 836 insertions(+) create mode 100644 apps/sim/scripts/parser-eval/FINDINGS.md create mode 100644 apps/sim/scripts/parser-eval/PLAN.md create mode 100644 apps/sim/scripts/parser-eval/REPORT.md create mode 100755 apps/sim/scripts/parser-eval/fetch-real-world.sh create mode 100644 apps/sim/scripts/parser-eval/generate-corpus.py create mode 100644 apps/sim/scripts/parser-eval/generate-spreadsheets.ts create mode 100644 apps/sim/scripts/parser-eval/reference-extract.py create mode 100644 apps/sim/scripts/parser-eval/run-parsers.ts create mode 100644 apps/sim/scripts/parser-eval/score.py diff --git a/apps/sim/scripts/parser-eval/FINDINGS.md b/apps/sim/scripts/parser-eval/FINDINGS.md new file mode 100644 index 00000000000..22ec62d9c6d --- /dev/null +++ b/apps/sim/scripts/parser-eval/FINDINGS.md @@ -0,0 +1,35 @@ +# Findings — 2026-09-09 run + +Corpus: 107 ground-truth renders (14 docs × docx/odt/pptx/html/md/pdf, 3 two-column PDFs, 4 workbooks × xlsx/xls/xlsb/ods/csv), 30 real-world files, 14 robustness cases. Raw metrics in `REPORT.md`. Reproduce with the scripts in this directory (see `PLAN.md`). + +Content recall is 0.99–1.00 in every prose format and PDF text matches PyMuPDF at NED 0.996–1.000 on six real documents. The problems are structure, boilerplate, and typed cells. + +| # | Finding | Where | Evidence | +|---|---|---|---| +| 1 | PDF text flattened to one line before chunking (both modes) | `pdf-parser.ts` `.replace(/\s+/g, ' ')` | paragraph retention 0.06, heading retention 0.00; IRS p17 → 246 chunks, none at a paragraph | +| 2 | Spreadsheet dates/percent/currency indexed raw; Google Sheets sync inherits it | `xlsx-parser.ts` (no `raw:false`/`cellDates`) | `2026-03-04` → `46085`, `20%` → `0.2`; ODS dates → JS local-time string | +| 3 | Running headers/footers/page numbers leak into every PDF | `pdf-parser.ts` | absence 0.00 on 17/17 renders | +| 4 | Tables exploded one cell per line in docx/pptx/odt/odp | mammoth `extractRawText`, officeparser | table adjacency 0.00; mammoth HTML computed but unused | +| 5 | Words glued at line/column/cell boundaries in PDFs | items joined without separator when `hasEOL` false | irs-p17 39 glued tokens, omnidocbench 19, 2 of 3 two-column renders | +| 6 | Legacy .doc/.ppt fallback emits ZIP names, XML, master-slide placeholders | `doc-parser.ts`, `pptx-parser.ts` fallback | 7/7 real files degraded; KB and workspace-files search honour the flag | +| 7 | Slide numbers, footer placeholders, review comments indexed as body | officeparser | `poi-notes.pptx` bare `1..11` + `testdoc`; odt comment spliced mid-sentence | +| 8 | Non-UTF-8 text silently stripped | `txt-parser.ts`/`md-parser.ts` + `sanitizeTextForUTF8` | Latin-1 "Café résumé naïve £" → "Caf rsum nave" | +| 9 | Mislabelled inputs accepted; corrupt PDF error untyped (transient → OCR) | `index.ts` extension routing; pdf.js `Invalid PDF structure.` | CSV-as-xlsx mojibake + serials; HTML-as-txt raw markup | + +Fix order: PDF line structure + spacing → PDF furniture suppression → SheetJS formatted text → DOCX via mammoth HTML → officeparser post-processing → transcode fallback for text → type the PDF structure error → magic-byte sniffing. + +## Validation pass (9 parallel investigators, 2026-09-09) + +All nine findings confirmed. Corrections to the original framing: + +| # | Correction | Precedent | +|---|---|---| +| 1 | The whitespace collapse was copied from unpdf 1.4.0 for byte-identical output in #6425; unpdf fixed it in 1.7.0 (PR #58) before #6425 landed. No consumer or test depends on single-line text. pdf.js `hasEOL` arrives on an empty item whose y is the NEXT line. | unpdf #58, pdf.js text_layer, pdfplumber y_tolerance, pdfminer line_margin | +| 2 | `raw:false` alone is not enough: Excel's General format truncates 16-digit numbers to `4.11111E+15` and dates render locale-shaped; a pre-pass rewriting `w` for `t:'d'` and General cells fixes both. The Google Sheets and Microsoft Excel connectors ALREADY request formatted text, so Drive-synced Sheets disagree with Sheets-connector Sheets today. `xlsx-preview-data.ts` (file viewer) has the same defect. | SheetJS `raw`/`cellDates` docs, MarkItDown #53 | +| 3 | 62% of IRS p17 chunks carry the running footer. A frequency rule alone misses footers whose chapter title changes; Marker's consecutive-streak rule (>=3 pages) recovers it. Requires #1 first (needs reconstructed lines + y). | Marker IgnoreTextProcessor, OmniDocBench 'abandon', pymupdf4llm margins | +| 4 | DOCX via mammoth HTML -> existing HtmlParser walker prototyped: 8/8 adjacency, footnotes recovered, zero new deps. mammoth `convertToMarkdown` drops tables (do not use). officeparser 7.8 fixes tables but pulls tesseract.js + pdfjs-dist@6 (126 MB) and drops ODT header rows. | mammoth README, MarkItDown, unstructured, Docling | +| 5 | Fusions are NOT missing-hasEOL at line ends; they are (a) Form XObject boundaries (pdf.js resets prevTransform) and (b) backwards x-move on the same baseline (pdf.js flushes without EOL). Geometry join rule prototyped: catalog 15->0 fusions, IRS 56->25. Dehyphenation must check doc-local compounds or it breaks `open-source`. | pdf.js evaluator constants, MuPDF stext-device, pdfplumber | +| 6 | Worse than reported: two of four real .doc files return 3% and 17% of the body (UCS-2 text invisible to the ASCII regex). KB and workspace search honour `degraded`; Copilot file-reader, chat upload reader, File block (`internal/file/parser.ts`) and `get content` do NOT and hand the scrape to the model. `.xls` is fine (SheetJS BIFF). `word-extractor` (pure JS, frozen 2021) gets 89-100% on the POI .doc files; no viable pure-JS .ppt extractor exists. | word-extractor, Tika, Docling/unstructured shell out to soffice | +| 7 | The leaked `1..11` + `testdoc` come from NOTES PAGES (`ppt/notesSlides`), which officeparser dumps because we pass no options; not slide-level footers. officeparser 7.8 does not fix it. ODT splice includes `text:sender-initials`; tracked-change deletions also leak. Same JSZip walker as #4 fixes both. | python-pptx placeholder types, MarkItDown, POI SlideShowExtractor, pandoc ODT reader | +| 8 | Also: UTF-8 BOM leaks into content; UTF-16 'pass' was an ASCII accident; connectors keep U+FFFD as mojibake. Bun 1.3.14 TextDecoder supports `fatal` + `windows-1252` natively. Truncated-UTF-8 downloads need a tail retry before falling back. | Tika EncodingDetector chain, unstructured encoding.py, LangChain autodetect_encoding | +| 9 | `Invalid PDF structure.` is a named `InvalidPDFException`; the classifier just never checks it. docx-as-pdf never reaches OCR (`assertOcrSourceSupported` sniffs `%PDF-`). Truncated PDFs with a header DO reach OCR, deliberately, pinned by `pdf-ocr-triage.test.ts:439,580`. `ArchiveIntegrityError` is already classified permanent. `file-type@16.5.4` (CJS) is already in the tree via officeparser. | Tika detection precedence, unstructured detect_filetype, pdf.js exception names | diff --git a/apps/sim/scripts/parser-eval/PLAN.md b/apps/sim/scripts/parser-eval/PLAN.md new file mode 100644 index 00000000000..fab692e4fc0 --- /dev/null +++ b/apps/sim/scripts/parser-eval/PLAN.md @@ -0,0 +1,52 @@ +# Knowledge-base parser quality evaluation + +## Why + +Every file a connector (Drive, OneDrive, SharePoint, Box, Dropbox, S3, SFTP, Bitbucket, Gmail/Outlook attachments) or an upload delivers as bytes goes through `apps/sim/lib/file-parsers` before chunking and embedding. If a parser emits noise (XML internals, placeholder sentences, boilerplate), drops content, destroys paragraph structure, or scrambles reading order, every downstream search result inherits it silently: the document row still reads "success". + +## What the state of the art measures + +| Benchmark | What it scores | How | +|---|---|---| +| OmniDocBench (CVPR 2025) | text, tables, formulas, reading order across 10 doc types | Normalized Edit Distance on text and reading order, TEDS on tables; headers/footers/page numbers are an "abandon" class excluded from scoring | +| olmOCR-bench (Ai2) | 1,403 PDFs, 7,010 binary unit tests | text presence, text absence (headers/footers/page numbers must NOT appear), natural reading order pairs, table cell adjacency, math | +| READoc / opendataloader-bench | PDF to structured markdown | heading detection, reading order, table structure, F1 on blocks | + +Two design ideas transfer directly: (1) score with **binary unit tests per document** (presence, absence, order, adjacency), because fuzzy whole-document similarity hides localized failures; (2) treat **boilerplate leakage as a first-class failure**, not a rounding error. + +## Framework + +### Corpus (two tiers) + +**Tier A — ground truth by construction.** Source documents are authored as Markdown with a machine-readable spec (paragraphs, headings, list items, table cells, sentinel sentences, ordering pairs). Each source is rendered by pandoc to DOCX, ODT, PPTX, HTML and (via typst) PDF, so the same known content arrives in every container our parsers handle. PDFs are additionally rendered with running headers, footers and page numbers so absence tests are meaningful. Tabular specs are written with SheetJS to XLSX, XLS, XLSB, ODS and CSV. + +**Tier B — real-world documents with no gold text.** Public PDFs (two-column papers, forms, reports), DOCX/PPTX/XLSX/DOC/PPT/XLS/ODT files from open-source test corpora, and HTML pages. Scored by agreement against independent reference extractors (PyMuPDF, pdfplumber, python-docx, python-pptx, openpyxl) plus reference-free noise heuristics. + +### Metrics per (document, format) + +| Metric | Definition | Catches | +|---|---|---| +| `ned` | 1 − Levenshtein(norm(out), norm(gt)) / max(len) | gross content loss or gain | +| `presence` | share of sentinel sentences found (partial ratio ≥ 90) | dropped paragraphs, cells, slide bodies | +| `absence` | share of boilerplate strings (running header/footer/page numbers/"Sheet:" wrappers) NOT found | leakage into the index | +| `order` | share of (a before b) pairs preserved | column/slide/cell reordering | +| `table_adjacency` | share of (left cell, right cell) pairs appearing on one output line | tables exploded one cell per line | +| `noise_ratio` | share of output word tokens absent from gt vocabulary | XML names, placeholders, scraped bytes | +| `paragraph_retention` | output paragraph breaks / gt paragraphs | whitespace collapse that starves the chunker | +| `heading_retention` | share of headings appearing on their own line | headings glued into paragraphs | +| `junk_chars` | control/replacement/private-use chars per 1k chars | encoding damage | +| `chunk_sentence_boundary` | share of TextChunker chunks ending at sentence punctuation | how the parse degrades chunking | +| `metadata` | degraded/truncated/pageCount agree with reality | wrong flags either poison the index or skip good files | +| `latency_ms` | wall time | regressions | + +Robustness cases (empty, truncated, mislabeled extension, encrypted, non-UTF8) are scored pass/fail on whether a typed `FileParserError` is raised rather than garbage returned. + +### Execution + +1. `generate-corpus.py` builds Tier A sources, specs and renders (pandoc + typst + SheetJS). +2. `fetch-real-world.sh` downloads Tier B. +3. `run-parsers.ts` (bun, inside apps/sim so `@/` resolves) runs `parseBuffer` for every file exactly as the ingestion path does (`pdfTextMode: 'complete'` for PDFs) and writes JSON outputs. +4. `reference-extract.py` runs the reference extractors on the same files. +5. `score.py` computes the metric table, aggregated by format and by parser, and lists the worst documents. + +Everything reproducible from `apps/sim/scripts/parser-eval/`. diff --git a/apps/sim/scripts/parser-eval/REPORT.md b/apps/sim/scripts/parser-eval/REPORT.md new file mode 100644 index 00000000000..e9166414dc0 --- /dev/null +++ b/apps/sim/scripts/parser-eval/REPORT.md @@ -0,0 +1,102 @@ +# Parser quality report + +Tier A: 107 files, Tier B: 30 files, robustness: 14 cases + +## Tier A — ground truth by construction (mean per format) + +| format | n | ned | presence | absence | order | table_adjacency | noise_ratio | glued_words | paragraph_retention | heading_retention | chunk_sentence_boundary | junk_per_1k | ms | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| csv | 4 | 0.70 | 0.98 | — | — | 0.95 | 0.00 | 0.00 | — | — | — | 0.00 | 1.65 | +| docx | 14 | 0.96 | 1.00 | — | 1.00 | 0.00 | 0.00 | 0.00 | 1.00 | 0.91 | 1.00 | 0.00 | 11.35 | +| html | 14 | 0.91 | 1.00 | — | 1.00 | 1.00 | 0.01 | 0.00 | 1.00 | 0.85 | 1.00 | 0.00 | 1.69 | +| md | 14 | 0.94 | 1.00 | — | 1.00 | 1.00 | 0.00 | 0.00 | 0.99 | 1.00 | 1.00 | 0.00 | 0.13 | +| ods | 4 | 0.78 | 0.87 | — | — | 0.81 | 0.14 | 0.00 | — | — | — | 0.00 | 2.17 | +| odt | 14 | 0.96 | 1.00 | — | 1.00 | 0.00 | 0.00 | 0.00 | 1.00 | 0.91 | 1.00 | 0.00 | 2.81 | +| pdf | 14 | 0.89 | 0.99 | 0.00 | 1.00 | 0.87 | 0.07 | 0.14 | 0.06 | 0.00 | 0.00 | 0.00 | 16.11 | +| pdf-2col | 3 | 0.93 | 1.00 | 0.00 | 1.00 | 0.72 | 0.05 | 0.67 | 0.03 | 0.00 | 0.00 | 0.00 | 8.43 | +| pptx | 14 | 0.96 | 1.00 | — | 1.00 | 0.00 | 0.00 | 0.00 | 1.00 | 0.91 | 1.00 | 0.00 | 2.26 | +| xls | 4 | 0.81 | 0.87 | — | — | 0.81 | 0.12 | 0.00 | — | — | — | 0.00 | 2.25 | +| xlsb | 4 | 0.81 | 0.87 | — | — | 0.81 | 0.12 | 0.00 | — | — | — | 0.00 | 1.60 | +| xlsx | 4 | 0.81 | 0.87 | — | — | 0.81 | 0.12 | 0.00 | — | — | — | 0.00 | 3.27 | + +### Worst Tier A files by sentinel presence / adjacency / noise + +| file | presence | absence | order | adjacency | noise | para | heading | noise sample | +|---|---|---|---|---|---|---|---|---| +| sheet-typed.ods | 0.464 | None | None | 0.25 | 0.515 | None | None | 0700 0800 085 1063 12000 1250 | +| sheet-typed.xls | 0.464 | None | None | 0.25 | 0.455 | None | None | 085 1063 12000 1250 1500 46085 | +| sheet-typed.xlsb | 0.464 | None | None | 0.25 | 0.455 | None | None | 085 1063 12000 1250 1500 46085 | +| sheet-typed.xlsx | 0.464 | None | None | 0.25 | 0.455 | None | None | 085 1063 12000 1250 1500 46085 | +| unicode-multilingual.pdf | 0.833 | 0.0 | 1.0 | 1.0 | 0.185 | 0.083 | None | acme confidential corp distribute do draft | +| sheet-typed.csv | 0.929 | None | None | 0.792 | 0.0 | None | None | | +| changelog.pdf | None | 0.0 | None | None | 0.121 | 0.062 | 0.0 | acme confidential corp distribute do draft | +| product-catalog.2col.pdf | 1.0 | 0.0 | 1.0 | 0.686 | 0.094 | 0.021 | None | 2027hw acme confidential corp discounthw distribute | +| product-catalog.pdf | 1.0 | 0.0 | 1.0 | 0.686 | 0.094 | 0.021 | None | 2027hw acme confidential corp discounthw distribute | +| memo.pdf | 1.0 | 0.0 | 1.0 | None | 0.091 | 0.143 | None | acme confidential corp distribute do draft | +| onboarding-guide.pdf | 1.0 | 0.0 | 1.0 | None | 0.081 | 0.062 | 0.0 | acme confidential corp distribute do draft | +| sop-access-review.pdf | 1.0 | 0.0 | 1.0 | 0.75 | 0.077 | 0.05 | 0.0 | acme confidential corp distribute do draft | +| meeting-notes.pdf | 1.0 | 0.0 | 1.0 | 0.875 | 0.068 | 0.062 | 0.0 | acme confidential corp distribute do internal | +| tech-spec.pdf | 1.0 | 0.0 | 1.0 | 1.0 | 0.062 | 0.038 | 0.0 | acme confidential corp distribute do draft | +| faq-benefits.pdf | 1.0 | 0.0 | 1.0 | None | 0.061 | 0.091 | 0.0 | acme confidential corp distribute draft infra | + +## Tier B — real-world files vs reference extractors + +| file | fmt | len | degraded | pages (ours/ref) | reference | ned | ref line recall | out line precision | noise | noise sample | +|---|---|---|---|---|---|---|---|---|---|---| +| attention.pdf | pdf | 39642 | False | 15/15 | pymupdf (39495) | 0.996 | 1.0 | 1.0 | 0.001 | df epos visualizationsinput | +| attention.pdf | pdf | 39642 | False | 15/15 | pdfplumber (35525) | 0.849 | 0.185 | 1.0 | 0.001 | df epos visualizationsinput | +| bitcoin.pdf | pdf | 21227 | False | 9/9 | pymupdf (21220) | 0.999 | 1.0 | 1.0 | 0.0 | blockblock | +| bitcoin.pdf | pdf | 21227 | False | 9/9 | pdfplumber (21216) | 0.906 | 0.915 | 1.0 | 0.0 | blockblock | +| irs-f1040.pdf | pdf | 10151 | False | 2/2 | pymupdf (10156) | 1.0 | 1.0 | 1.0 | 0.001 | 2025u | +| irs-f1040.pdf | pdf | 10151 | False | 2/2 | pdfplumber (10152) | 0.8 | 0.739 | 0.0 | 0.001 | 2025u | +| irs-p17.pdf | pdf | 959987 | False | 142/142 | pymupdf (960116) | 1.0 | 1.0 | 1.0 | 0.0 | 000caution 16caution 2025get 4vtip 8815records andcaution | +| irs-p17.pdf | pdf | 959987 | False | 142/142 | pdfplumber (431054) | 0.295 | 0.215 | 0.0 | 0.0 | 000caution 16caution 2025get 4vtip 8815records andcaution | +| lo-fdo38244.odt | odt | 32 | False | | pandoc (16) | 0.5 | None | None | 0.2 | mfirst | +| lo-lists.odt | odt | ERROR | | | | | | | | No text could be extracted from this OpenDocument file | +| lo-simple.odp | odp | 28 | False | | (none) | | | | | | +| lo-simple.ods | ods | 12322 | False | | (none) | | | | | | +| lo-tables.odt | odt | 17 | False | | (none) | | | | | | +| mdn-fetch.html | html | 6810 | False | | (none) | | | | | | +| omnidocbench.pdf | pdf | 103150 | False | 32/32 | pymupdf (102111) | 0.999 | 1.0 | 1.0 | 0.005 | 10190 2011年1月1日 7000 aaaaa ajhb annotationsfigure | +| omnidocbench.pdf | pdf | 103150 | False | 32/32 | pdfplumber (100495) | 0.354 | 0.365 | 1.0 | 0.005 | 10190 2011年1月1日 7000 aaaaa ajhb annotationsfigure | +| pdf-reference-excerpt.pdf | pdf | 14 | False | 1/1 | pymupdf (14) | 1.0 | None | None | 0.0 | | +| pdf-reference-excerpt.pdf | pdf | 14 | False | 1/1 | pdfplumber (14) | 1.0 | None | None | 0.0 | | +| poi-basic.ppt | ppt | 907 | True | | (none) | | | | | | +| poi-bug-tables.doc | doc | 416 | True | | (none) | | | | | | +| poi-bullets.ppt | ppt | 767 | True | | (none) | | | | | | +| poi-footnotes.docx | docx | 35 | False | | python-docx (33) | 1.0 | 1.0 | 1.0 | 0.0 | | +| poi-footnotes.docx | docx | 35 | False | | pandoc (47) | 0.702 | 1.0 | 1.0 | 0.0 | | +| poi-header-footer.doc | doc | 660 | True | | (none) | | | | | | +| poi-headerfooter.docx | docx | ERROR | | | | | | | | No text could be extracted from this DOCX file | +| poi-layouts.pptx | pptx | 645 | False | | python-pptx (650) | 0.983 | 1.0 | 1.0 | 0.0 | | +| poi-lists.doc | doc | 1198 | True | | (none) | | | | | | +| poi-multisheet.xls | xls | 133 | False | | (none) | | | | | | +| poi-notes.pptx | pptx | 2393 | False | | python-pptx (2357) | 0.968 | 1.0 | 1.0 | 0.037 | 10 testdoc | +| poi-sample.docx | docx | 1548 | False | | python-docx (1542) | 1.0 | 1.0 | 1.0 | 0.0 | | +| poi-sample.docx | docx | 1548 | False | | pandoc (1542) | 1.0 | 1.0 | 1.0 | 0.0 | | +| poi-sample.pptx | pptx | 140 | False | | python-pptx (152) | 0.908 | 1.0 | 1.0 | 0.0 | | +| poi-sample.xlsx | xlsx | 391 | False | | openpyxl (292) | 0.753 | 1.0 | 0.5 | 0.019 | empty | +| poi-sampledoc.doc | doc | 1463 | True | | (none) | | | | | | +| poi-simple.xls | xls | 144 | False | | (none) | | | | | | +| poi-tables.ppt | ppt | 6359 | True | | (none) | | | | | | +| w3c-html-spec-intro.html | html | 54667 | False | | (none) | | | | | | +| wiki-rag.html | html | 28081 | False | | (none) | | | | | | + +## Robustness + +| case | expected | passed | outcome | +|---|---|---|---| +| csv-labelled-xlsx | typed error OR correct UTF-8 text | ❌ | ok 3122 chars | +| docx-bytes-labelled-pdf | typed error | ❌ | UNTYPED: Invalid PDF structure. | +| docx-labelled-doc | correct text | ✅ | ok 843 chars | +| docx-labelled-xlsx | typed error | ✅ | typed invalid_format: Failed to parse XLSX buffer: Could not find workbook | +| empty.docx | typed error | ✅ | typed empty_input: Empty buffer provided | +| html-labelled-txt | markup stripped or typed error | ❌ | ok 4904 chars | +| latin1-txt | text decodes to "Café résumé naïve £" | ❌ | ok 17 chars | +| pdf-bytes-labelled-docx | typed error OR correct text | ✅ | ok 933 chars | +| png-labelled-doc | typed error, never placeholder prose | ❌ | ok 87 chars DEGRADED | +| pptx-labelled-ppt | correct text | ✅ | ok 843 chars | +| random-bytes-labelled-ppt | typed error, never placeholder prose | ❌ | ok 99 chars DEGRADED | +| truncated-docx | typed error (invalid_format) | ❌ | UNTYPED: Unable to inspect ZIP central directory; refusing to parse an unverifiable ZIP-shaped arch | +| truncated-pdf | typed error (invalid_format) | ❌ | UNTYPED: Invalid PDF structure. | +| utf16-txt | text decodes to "Hello UTF-16 world" | ✅ | ok 18 chars | diff --git a/apps/sim/scripts/parser-eval/fetch-real-world.sh b/apps/sim/scripts/parser-eval/fetch-real-world.sh new file mode 100755 index 00000000000..ab6c3c3bd1d --- /dev/null +++ b/apps/sim/scripts/parser-eval/fetch-real-world.sh @@ -0,0 +1,57 @@ +#!/bin/sh +# Downloads the Tier B real-world corpus into $1/real. Failures are logged, not fatal. +set -u +OUT="$1/real" +mkdir -p "$OUT" +get() { # name url + if [ -s "$OUT/$1" ]; then return; fi + curl -sSL -f --max-time 60 -A "Mozilla/5.0 sim-parser-eval" -o "$OUT/$1" "$2" || echo "FAILED $1 $2" +} +POI=https://raw.githubusercontent.com/apache/poi/trunk/test-data +LO=https://raw.githubusercontent.com/LibreOffice/core/master +# PDFs: two-column paper, benchmark paper, tax form, long report, slides-as-pdf +get attention.pdf https://arxiv.org/pdf/1706.03762 +get omnidocbench.pdf https://arxiv.org/pdf/2412.07626 +get irs-f1040.pdf https://www.irs.gov/pub/irs-pdf/f1040.pdf +get irs-p17.pdf https://www.irs.gov/pub/irs-pdf/p17.pdf +get pdf-reference-excerpt.pdf https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf +get bitcoin.pdf https://bitcoin.org/bitcoin.pdf +get gao-report.pdf https://www.gao.gov/assets/gao-24-106221.pdf +# DOCX / DOC (Apache POI test corpus) +get poi-sampledoc.doc $POI/document/SampleDoc.doc +get poi-bug-tables.doc $POI/document/Bug49933.doc +get poi-lists.doc $POI/document/Lists.doc +get poi-header-footer.doc $POI/document/HeaderFooterUnicode.doc +get poi-sample.docx $POI/document/sample.docx +get poi-tables.docx $POI/document/testTables.docx +get poi-headerfooter.docx $POI/document/headerFooter.docx +get poi-footnotes.docx $POI/document/footnotes.docx +get poi-numbering.docx $POI/document/numbering.docx +get poi-bug-toc.docx $POI/document/Bug53008.docx +# PPTX / PPT +get poi-sample.pptx $POI/slideshow/sample.pptx +get poi-basic-table.pptx $POI/slideshow/basic_table.pptx +get poi-notes.pptx $POI/slideshow/45541_Header.pptx +get poi-layouts.pptx $POI/slideshow/layouts.pptx +get poi-basic.ppt $POI/slideshow/basic_test_ppt_file.ppt +get poi-tables.ppt $POI/slideshow/table_test.ppt +get poi-bullets.ppt $POI/slideshow/bullets.ppt +# XLSX / XLS +get poi-sample.xlsx $POI/spreadsheet/sample.xlsx +get poi-formulas.xlsx $POI/spreadsheet/FormulaEvalTestData.xlsx +get poi-dates.xlsx $POI/spreadsheet/DateFormats.xlsx +get poi-simple.xls $POI/spreadsheet/SimpleWithFormula.xls +get poi-multisheet.xls $POI/spreadsheet/SimpleMultiCell.xls +get poi-unicode.xls $POI/spreadsheet/Unicode.xls +# ODT / ODP / ODS (LibreOffice regression corpus) +get lo-fdo38244.odt $LO/sw/qa/extras/odfexport/data/fdo38244.odt +get lo-tables.odt $LO/sw/qa/extras/odfexport/data/fdo79358.odt +get lo-lists.odt $LO/sw/qa/extras/odfexport/data/tdf103567.odt +get lo-simple.odp $LO/sd/qa/unit/data/odp/tdf90626.odp +get lo-table.odp $LO/sd/qa/unit/data/odp/tdf91378.odp +get lo-simple.ods $LO/sc/qa/unit/data/ods/functions.ods +# HTML +get wiki-rag.html "https://en.wikipedia.org/wiki/Retrieval-augmented_generation" +get mdn-fetch.html "https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API" +get w3c-html-spec-intro.html "https://html.spec.whatwg.org/multipage/introduction.html" +ls -la "$OUT" | awk '{print $5, $9}' diff --git a/apps/sim/scripts/parser-eval/generate-corpus.py b/apps/sim/scripts/parser-eval/generate-corpus.py new file mode 100644 index 00000000000..f4ca344ffc2 --- /dev/null +++ b/apps/sim/scripts/parser-eval/generate-corpus.py @@ -0,0 +1,189 @@ +"""Builds the Tier A ground-truth corpus: markdown sources, per-document specs, and renders. + +Usage: python generate-corpus.py +""" +import json, os, random, subprocess, sys, textwrap, re + +OUT = sys.argv[1] +TYPST = sys.argv[2] +SRC = os.path.join(OUT, 'sources'); SPEC = os.path.join(OUT, 'spec'); FILES = os.path.join(OUT, 'files') +for d in (SRC, SPEC, FILES): os.makedirs(d, exist_ok=True) +rng = random.Random(20260909) + +ADJ = ['quarterly', 'revised', 'preliminary', 'consolidated', 'regional', 'automated', 'legacy', 'provisional', 'audited', 'internal'] +NOUN = ['forecast', 'rollout', 'migration', 'audit', 'procurement plan', 'onboarding checklist', 'incident review', 'budget', 'vendor assessment', 'compliance summary', 'capacity model', 'retention policy'] +VERB = ['approved', 'deferred', 'escalated', 're-baselined', 'published', 'withdrawn', 'ratified', 'archived', 'flagged', 'consolidated'] +TEAM = ['Platform', 'Finance', 'Security', 'Operations', 'Legal', 'Support', 'Data', 'Infrastructure'] +TOPIC = ['the Lisbon data center', 'the EU billing entity', 'the SSO cutover', 'the vendor renewal', 'the Q3 hiring plan', 'the customer export pipeline', 'the ISO 27001 surveillance audit', 'the warehouse lease', 'the API deprecation', 'the on-call rotation'] +TAIL = [ + 'Stakeholders should review the attached appendix before the next checkpoint.', + 'No customer-facing change is expected until the second phase completes.', + 'The owning team retains sign-off authority for scope changes above 5%.', + 'Historical figures were restated to align with the new cost allocation model.', + 'A rollback path exists and was rehearsed twice during the dry run.', + 'Open questions are tracked in the shared register and reviewed weekly.', + 'This supersedes the guidance circulated on 14 March and applies immediately.', + 'Latency stayed under the 250 ms objective for 99.4% of sampled requests.', + 'The estimate carries a ±12% margin because three quotes are still outstanding.', + 'Exceptions require written approval from a director or above.', +] +used = set() +def sentence(i): + while True: + s = f"The {rng.choice(ADJ)} {rng.choice(NOUN)} for {rng.choice(TOPIC)} was {rng.choice(VERB)} on {rng.randint(1,28)} {rng.choice(['January','February','April','June','August','October','November'])} by the {rng.choice(TEAM)} team." + if s not in used: + used.add(s); return s +def paragraph(n=3): + first = sentence(0) + rest = rng.sample(TAIL, n-1) + return first, ' '.join([first] + rest) + +def money(): return f"${rng.randint(10,990)},{rng.randint(100,999)}.{rng.randint(10,99)}" + +class Doc: + def __init__(self, name, title): + self.name, self.title = name, title + self.md = [f"# {title}", ""] + self.paragraphs, self.sentinels, self.headings, self.list_items, self.tables, self.code = [], [], [], [], [], [] + def h(self, level, text): + self.md += ['#' * level + ' ' + text, '']; self.headings.append(text) + def p(self, n=3, text=None): + if text is None: + first, text = paragraph(n); self.sentinels.append(first) + else: + self.sentinels.append(text.split('. ')[0] + ('.' if '. ' in text else '')) + self.md += [text, '']; self.paragraphs.append(text) + def ul(self, items, ordered=False, nested=None): + for i, it in enumerate(items): + self.md.append((f"{i+1}. " if ordered else "- ") + it); self.list_items.append(it) + if nested and i == 1: + for sub in nested: + self.md.append(" - " + sub); self.list_items.append(sub) + self.md.append('') + def table(self, header, rows): + self.md.append('| ' + ' | '.join(header) + ' |'); self.md.append('|' + '---|' * len(header)) + for r in rows: self.md.append('| ' + ' | '.join(str(c) for c in r) + ' |') + self.md.append(''); self.tables.append({'header': header, 'rows': [[str(c) for c in r] for r in rows]}) + def codeblock(self, lang, code): + self.md += [f"```{lang}", code, "```", '']; self.code.append(code) + def quote(self, text): + self.md += ['> ' + text, '']; self.paragraphs.append(text); self.sentinels.append(text) + def write(self): + md = '\n'.join(self.md) + with open(os.path.join(SRC, self.name + '.md'), 'w') as f: f.write(md) + order_pairs = [[a, b] for a, b in zip(self.sentinels, self.sentinels[1:])] + adjacency = [] + for t in self.tables: + for r in [t['header']] + t['rows']: + for a, b in zip(r, r[1:]): + if len(a) >= 2 and len(b) >= 2 and a != b: adjacency.append([a, b]) + spec = dict(name=self.name, title=self.title, headings=self.headings, paragraphs=self.paragraphs, + sentinels=self.sentinels, order_pairs=order_pairs, list_items=self.list_items, + tables=self.tables, table_adjacency=adjacency, code=self.code) + with open(os.path.join(SPEC, self.name + '.json'), 'w') as f: json.dump(spec, f, indent=1, ensure_ascii=False) + return spec + +docs = [] +# 1 memo +d = Doc('memo', 'Memo: Office Relocation Timeline'); d.p(); d.p(2); d.ul(['Confirm desk allocations by Friday', 'Return badge access forms to Facilities', 'Label equipment with the asset tag before Tuesday']); d.p(); docs.append(d) +# 2 SOP +d = Doc('sop-access-review', 'Standard Operating Procedure: Quarterly Access Review') +d.h(2, 'Purpose'); d.p(); d.h(2, 'Scope'); d.p(2); d.h(2, 'Procedure') +d.ul(['Export the entitlement report from the identity provider', 'Send each manager the list of direct reports and their roles', 'Record approvals or revocations in the review tracker', 'Close the review and archive the evidence bundle'], ordered=True) +d.h(2, 'Roles'); d.table(['Role', 'Responsibility', 'Escalation contact'], [['Reviewer', 'Confirms each entitlement is still required', 'Security Operations'], ['System owner', 'Removes revoked access within 5 business days', 'Platform lead'], ['Auditor', 'Samples 10% of closed reviews', 'Compliance manager']]) +d.h(2, 'Records'); d.p(); docs.append(d) +# 3 quarterly report (long) +d = Doc('quarterly-report', 'Q2 FY26 Operating Review') +d.h(2, 'Executive summary'); d.p(4); d.p(3); d.p(3) +d.h(2, 'Revenue by region'); d.table(['Region', 'Q1 revenue', 'Q2 revenue', 'Change'], [[r, money(), money(), f"{rng.randint(-9,22)}%"] for r in ['North America', 'EMEA', 'APAC', 'LATAM']]); d.p(3) +d.h(2, 'Cost structure'); d.p(3); d.h(3, 'Headcount'); d.p(2); d.table(['Department', 'Headcount', 'Open roles'], [[t, rng.randint(8,120), rng.randint(0,9)] for t in TEAM]); d.h(3, 'Infrastructure'); d.p(3); d.p(3) +d.h(2, 'Risks and mitigations'); d.p(3); d.ul(['Currency exposure on EUR-denominated contracts', 'Single-vendor dependency for GPU capacity', 'Attrition in the Support organization above 14%']) +d.h(2, 'Outlook'); d.p(4); d.p(3); d.p(3); docs.append(d) +# 4 FAQ +d = Doc('faq-benefits', 'Employee Benefits FAQ') +for q in ['When does coverage begin?', 'Can I add a dependent mid-year?', 'How is the commuter allowance taxed?', 'What happens to unused leave?', 'Who do I contact about a claim?']: + d.h(3, q); d.p(2) +docs.append(d) +# 5 meeting notes +d = Doc('meeting-notes', 'Weekly Platform Sync — 2 September') +d.h(2, 'Attendees'); d.ul(['Priya (chair)', 'Marcus', 'Lena', 'Tomasz']); d.h(2, 'Discussion'); d.p(3); d.p(2); d.p(3) +d.h(2, 'Action items'); d.table(['Owner', 'Action', 'Due'], [['Marcus', 'Draft the rollback runbook', '9 Sep'], ['Lena', 'Confirm vendor SLA credits', '12 Sep'], ['Tomasz', 'Re-run the load test at 2x traffic', '16 Sep']]); docs.append(d) +# 6 contract +d = Doc('contract', 'Master Services Agreement') +d.p(text='This Master Services Agreement (the "Agreement") is entered into as of 1 July 2026 between Acme Holdings Ltd ("Provider") and Northwind Traders GmbH ("Customer").') +for i, t in enumerate(['Definitions', 'Services', 'Fees and Payment', 'Term and Termination', 'Confidentiality', 'Limitation of Liability', 'Governing Law']): + d.h(2, f'{i+1}. {t}'); d.p(4); d.p(3) +d.quote('IN WITNESS WHEREOF, the parties have executed this Agreement by their duly authorised representatives.'); docs.append(d) +# 7 technical spec +d = Doc('tech-spec', 'Design: Idempotent Webhook Delivery') +d.h(2, 'Overview'); d.p(3); d.h(2, 'API'); d.p(text='Clients call `POST /v2/webhooks/{id}/deliveries` with an `Idempotency-Key` header; retries within 24 hours return the original response.') +d.codeblock('json', '{\n "event": "invoice.paid",\n "attempt": 3,\n "backoff_ms": 8000\n}') +d.h(2, 'Retry schedule'); d.table(['Attempt', 'Delay', 'Jitter'], [[1, '1s', '±200ms'], [2, '4s', '±800ms'], [3, '16s', '±3s'], [4, '64s', '±12s']]) +d.h(2, 'Failure modes'); d.p(3); d.ul(['Receiver returns 5xx repeatedly', 'DNS resolution fails for the target host', 'Payload exceeds the 1 MiB limit']); d.p(2) +d.codeblock('ts', 'export function nextDelay(attempt: number): number {\n return Math.min(64_000, 1_000 * 4 ** (attempt - 1))\n}'); docs.append(d) +# 8 unicode +d = Doc('unicode-multilingual', 'Notes multilingues — Übersicht 概要') +d.p(text='La réunion s’est tenue à Genève le 3 juin ; les décisions figurent ci‑dessous.') +d.p(text='Die Änderungen betreffen das Straßenverkehrsamt und die Prüfstelle für Maschinen in Köln.') +d.p(text='本製品の保証期間は購入日から二年間です。詳細は付属の説明書を参照してください。') +d.p(text='تم تحديث سياسة الخصوصية في ١٥ مايو، ويُرجى مراجعة البنود الجديدة.') +d.p(text='Typography check: “curly quotes”, en–dash, em—dash, ellipsis…, ligatures financial flow, café naïve résumé, and the © ™ ® marks.') +d.p(text='Emoji and symbols: ✅ 🚀 ∑ ≠ → € £ ¥ ½ ² µ.'); d.table(['Sprache', 'Wert', 'Anmerkung'], [['Français', '1 234,56 €', 'décimale virgule'], ['Deutsch', '1.234,56 €', 'Punkt als Tausender'], ['日本語', '¥123,456', '全角なし']]); docs.append(d) +# 9 catalog (big table) +d = Doc('product-catalog', 'Hardware Catalog 2026') +d.p(2); d.table(['SKU', 'Product', 'Unit price', 'Lead time', 'Notes'], [[f"HW-{1000+i}", f"{rng.choice(['Rack','Blade','Edge','Storage','Switch'])} unit model {rng.choice('ABCDEFG')}{i}", money(), f"{rng.randint(1,12)} weeks", rng.choice(['EOL 2027', 'Bulk discount', 'Requires rail kit', 'Ships with PSU', '—'])] for i in range(40)]); d.p(2); docs.append(d) +# 10 onboarding nested lists +d = Doc('onboarding-guide', 'Engineering Onboarding Guide') +d.h(2, 'Week one'); d.p(2); d.ul(['Set up the development environment', 'Pair with your onboarding buddy', 'Ship a first small change'], nested=['Install the CLI and authenticate', 'Clone the monorepo and run the test suite', 'Read the contribution guidelines']) +d.h(2, 'Week two'); d.p(3); d.ul(['Shadow an on-call shift', 'Present a summary of one incident', 'Meet your skip-level manager'], ordered=True); d.h(2, 'Resources'); d.p(2); docs.append(d) +# 11 postmortem +d = Doc('incident-postmortem', 'Postmortem: INC-4471 Checkout Latency') +d.h(2, 'Summary'); d.p(3); d.h(2, 'Impact'); d.p(3); d.h(2, 'Timeline') +d.table(['Time (UTC)', 'Event'], [['09:12', 'Alert fired for p95 latency above 2 s'], ['09:20', 'On-call engineer confirmed connection pool exhaustion'], ['09:41', 'Read replica promoted and traffic re-routed'], ['10:05', 'Latency returned below objective'], ['11:30', 'Incident closed and customer notice published']]) +d.h(2, 'Root cause'); d.p(4); d.h(2, 'Corrective actions'); d.ul(['Add pool saturation alerting at 80%', 'Cap per-request query fan-out', 'Rehearse replica promotion quarterly']); d.p(2); docs.append(d) +# 12 research summary (long paragraphs, hyphenation-prone words, citations) +d = Doc('research-summary', 'Literature Review: Retrieval-Augmented Generation for Enterprise Search') +d.h(2, 'Background'); d.p(5); d.p(5) +d.p(text='Long-context transformers, sparse-attention variants, and mixture-of-experts architectures each trade throughput for recall in state-of-the-art configurations [1, 2]; hyphenated compounds such as multi-tenant, end-to-end and self-hosted appear throughout the corpus.') +d.h(2, 'Methods'); d.p(5); d.p(4); d.h(2, 'Findings'); d.p(5); d.p(4); d.p(4); d.h(2, 'References'); d.ul(['[1] Lewis et al., Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks, 2020.', '[2] Ouyang et al., OmniDocBench, CVPR 2025.', '[3] Poznanski et al., olmOCR 2: Unit Test Rewards for Document OCR, 2025.']); docs.append(d) +# 13 policy (many short sections) +d = Doc('security-policy', 'Information Security Policy') +for t in ['Purpose', 'Scope', 'Asset management', 'Access control', 'Cryptography', 'Physical security', 'Operations security', 'Supplier relationships', 'Incident management', 'Compliance']: + d.h(2, t); d.p(2) +docs.append(d) +# 14 changelog +d = Doc('changelog', 'Release Notes') +for v, date in [('v3.4.0', '2026-08-28'), ('v3.3.2', '2026-08-14'), ('v3.3.1', '2026-08-07'), ('v3.3.0', '2026-07-31')]: + d.h(2, f'{v} — {date}'); d.ul([f'{rng.choice(["Fixed","Added","Improved","Removed"])} {rng.choice(["the export dialog","rate limiting on the search API","the dark theme contrast","legacy webhook signatures","bulk archive for folders","the SAML metadata parser"])} ({rng.choice(["#4120","#4133","#4141","#4150","#4162","#4177"])})' for _ in range(rng.randint(2,4))]) +docs.append(d) + +manifest = [] +HEADER = 'ACME Corp — Internal Use Only'; FOOTER = 'Confidential draft, do not distribute' +ABSENCE_PDF = ['Internal Use Only', 'do not distribute', 'Page 1 of', 'Page 2 of'] + +def run(cmd): subprocess.run(cmd, check=True) +for d in docs: + spec = d.write(); mdpath = os.path.join(SRC, d.name + '.md') + gt = subprocess.run(['pandoc', mdpath, '-t', 'plain', '--wrap=none'], capture_output=True, text=True, check=True).stdout + with open(os.path.join(SPEC, d.name + '.gt.txt'), 'w') as f: f.write(gt) + for fmt in ['docx', 'odt', 'pptx', 'html', 'md']: + target = os.path.join(FILES, f'{d.name}.{fmt}') + if fmt == 'md': run(['cp', mdpath, target]) + elif fmt == 'html': run(['pandoc', mdpath, '-s', '--metadata', f'title={d.title}', '-o', target]) + elif fmt == 'pptx': run(['pandoc', mdpath, '--slide-level=2', '-o', target]) + else: run(['pandoc', mdpath, '-o', target]) + manifest.append(dict(file=os.path.basename(target), doc=d.name, format=fmt, tier='A', absence=[])) + # PDF via typst: single column with running header/footer + page numbers; two-column for long docs + typ = subprocess.run(['pandoc', mdpath, '-t', 'typst', '-s'], capture_output=True, text=True, check=True).stdout + variants = [('pdf', '')] + if len(gt) > 3500: variants.append(('2col.pdf', 'columns: 2, ')) + for suffix, cols in variants: + page = f'#set page({cols}header: align(right)[{HEADER}], footer: context [{FOOTER} — Page #counter(page).display() of #counter(page).final().first()])\n' + typpath = os.path.join(FILES, f'{d.name}.{suffix}.typ') + with open(typpath, 'w') as f: f.write(typ.replace('#show: doc => article(', page + '#show: doc => article(', 1) if '#show: doc => article(' in typ else page + typ) + target = os.path.join(FILES, f'{d.name}.{suffix}') + run([TYPST, 'compile', typpath, target]); os.remove(typpath) + manifest.append(dict(file=os.path.basename(target), doc=d.name, format='pdf', tier='A', absence=ABSENCE_PDF, variant='2col' if '2col' in suffix else 'single')) + +with open(os.path.join(OUT, 'manifest-a.json'), 'w') as f: json.dump(manifest, f, indent=1) +print(len(docs), 'docs;', len(manifest), 'files') diff --git a/apps/sim/scripts/parser-eval/generate-spreadsheets.ts b/apps/sim/scripts/parser-eval/generate-spreadsheets.ts new file mode 100644 index 00000000000..09f65d73e1e --- /dev/null +++ b/apps/sim/scripts/parser-eval/generate-spreadsheets.ts @@ -0,0 +1,102 @@ +/** + * Builds Tier A spreadsheet fixtures with SheetJS so the same known cells arrive as + * xlsx, xls, xlsb, ods and csv. Run from apps/sim: `bun scripts/parser-eval/generate-spreadsheets.ts ` + */ +import { mkdirSync, writeFileSync } from 'fs' +import path from 'path' +import * as XLSX from 'xlsx' + +const OUT = process.argv[2] +const FILES = path.join(OUT, 'files') +const SPEC = path.join(OUT, 'spec') +mkdirSync(FILES, { recursive: true }) +mkdirSync(SPEC, { recursive: true }) + +let seed = 7 +const rand = () => ((seed = (seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff) +const pick = (xs: T[]) => xs[Math.floor(rand() * xs.length)] + +interface SheetSpec { name: string; rows: (string | number)[][] } +interface Book { name: string; sheets: SheetSpec[]; note: string } + +const cities = ['Lisbon', 'Austin', 'Kyoto', 'Nairobi', 'Zürich', 'São Paulo', 'Montréal', 'Delhi'] +const books: Book[] = [ + { + name: 'sheet-employees', + note: 'single sheet, 60 rows, header row, numbers, dates as text, unicode', + sheets: [{ + name: 'Employees', + rows: [['Employee ID', 'Full name', 'Department', 'Office', 'Salary', 'Start date'], + ...Array.from({ length: 60 }, (_, i) => [`E-${2000 + i}`, `${pick(['Ana', 'Bjørn', 'Chen', 'Dmitri', 'Eszter', 'Fatima', 'Gustavo', 'Hana'])} ${pick(['Araújo', 'Nakamura', 'Okafor', 'Svensson', 'Müller', 'Patel'])}`, pick(['Platform', 'Finance', 'Security', 'Support']), pick(cities), 48000 + Math.floor(rand() * 90000), `2024-${String(1 + Math.floor(rand() * 12)).padStart(2, '0')}-${String(1 + Math.floor(rand() * 28)).padStart(2, '0')}`])], + }], + }, + { + name: 'sheet-multi', + note: 'three sheets including an empty-ish one and a sheet whose header is not on row 1', + sheets: [ + { name: 'Summary', rows: [['Metric', 'Q1', 'Q2'], ['Active workspaces', 1240, 1398], ['Churned workspaces', 31, 27], ['Net revenue retention', '104%', '109%']] }, + { name: 'Notes', rows: [['Prepared by the Finance team on 4 July.'], [], ['Figures exclude the Northwind pilot.']] }, + { name: 'Raw', rows: [['Export generated 2026-07-04'], [], ['Workspace', 'Plan', 'Seats', 'MRR'], ...Array.from({ length: 25 }, (_, i) => [`ws-${3000 + i}`, pick(['Team', 'Enterprise', 'Pro']), 3 + Math.floor(rand() * 200), Math.round(rand() * 20000) / 100])] }, + ], + }, + { + name: 'sheet-wide', + note: 'wide sheet: 40 columns x 30 rows with commas and quotes inside cells', + sheets: [{ + name: 'Matrix', + rows: [['Row'].concat(Array.from({ length: 39 }, (_, c) => `Col ${c + 1}`)), + ...Array.from({ length: 30 }, (_, r) => [`R${r + 1}`].concat(Array.from({ length: 39 }, (_, c) => (c % 7 === 0 ? `note, with "quotes" ${r}-${c}` : r * 100 + c))))], + }], + }, +] + +/** Typed cells: real dates, percentages, currency and formulas, with the display text a user sees in Excel. */ +const typedBook: Book = { + name: 'sheet-typed', + note: 'real Date cells, percent/currency number formats, formulas with cached values, booleans', + sheets: [{ name: 'Ledger', rows: [['Invoice', 'Issued', 'Due', 'Amount', 'Tax rate', 'Paid', 'Total'], + ['INV-001', '2026-03-04', '2026-04-03', '$1,250.00', '20%', 'TRUE', '$1,500.00'], + ['INV-002', '2026-05-17', '2026-06-16', '$980.50', '8.5%', 'FALSE', '$1,063.84'], + ['INV-003', '2026-07-29', '2026-08-28', '$12,000.00', '0%', 'TRUE', '$12,000.00']] }], +} +function buildTypedSheet(): XLSX.WorkSheet { + const ws = XLSX.utils.aoa_to_sheet([['Invoice', 'Issued', 'Due', 'Amount', 'Tax rate', 'Paid', 'Total']]) + const rows = [[1, new Date(Date.UTC(2026, 2, 4)), new Date(Date.UTC(2026, 3, 3)), 1250, 0.2, true], [2, new Date(Date.UTC(2026, 4, 17)), new Date(Date.UTC(2026, 5, 16)), 980.5, 0.085, false], [3, new Date(Date.UTC(2026, 6, 29)), new Date(Date.UTC(2026, 7, 28)), 12000, 0, true]] + rows.forEach((r, i) => { + const n = i + 2 + XLSX.utils.sheet_add_aoa(ws, [[`INV-00${r[0]}`]], { origin: `A${n}` }) + ws[`B${n}`] = { t: 'd', v: r[1], z: 'yyyy-mm-dd' } + ws[`C${n}`] = { t: 'd', v: r[2], z: 'yyyy-mm-dd' } + ws[`D${n}`] = { t: 'n', v: r[3], z: '"$"#,##0.00' } + ws[`E${n}`] = { t: 'n', v: r[4], z: '0.#%' } + ws[`F${n}`] = { t: 'b', v: r[5] } + ws[`G${n}`] = { t: 'n', f: `D${n}*(1+E${n})`, v: (r[3] as number) * (1 + (r[4] as number)), z: '"$"#,##0.00' } + }) + ws['!ref'] = 'A1:G4' + return ws +} +books.push(typedBook) + +const manifest: unknown[] = [] +for (const book of books) { + const wb = XLSX.utils.book_new() + if (book.name === 'sheet-typed') XLSX.utils.book_append_sheet(wb, buildTypedSheet(), 'Ledger') + else for (const sheet of book.sheets) XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(sheet.rows), sheet.name) + const formats = ['xlsx', 'xls', 'xlsb', 'ods'] as const + for (const fmt of formats) { + const target = path.join(FILES, `${book.name}.${fmt}`) + writeFileSync(target, XLSX.write(wb, { type: 'buffer', bookType: fmt })) + manifest.push({ file: path.basename(target), doc: book.name, format: fmt, tier: 'A', absence: [] }) + } + const csvTarget = path.join(FILES, `${book.name}.csv`) + writeFileSync(csvTarget, XLSX.utils.sheet_to_csv(wb.Sheets[book.sheets[0].name])) + manifest.push({ file: path.basename(csvTarget), doc: book.name, format: 'csv', tier: 'A', absence: [], firstSheetOnly: true }) + + const cells = book.sheets.flatMap((s) => s.rows.flatMap((r) => r.map(String))).filter((c) => c.length >= 2) + const adjacency = book.sheets.flatMap((s) => s.rows.flatMap((r) => r.slice(0, -1).map((c, i) => [String(c), String(r[i + 1])]).filter(([a, b]) => a.length >= 2 && b.length >= 2 && a !== b))) + const firstSheetCells = book.sheets[0].rows.flatMap((r) => r.map(String)).filter((c) => c.length >= 2) + writeFileSync(path.join(SPEC, `${book.name}.json`), JSON.stringify({ name: book.name, kind: 'spreadsheet', note: book.note, sheets: book.sheets.map((s) => s.name), sentinels: cells, firstSheetSentinels: firstSheetCells, table_adjacency: adjacency, order_pairs: [], headings: [], paragraphs: [], list_items: [], code: [] }, null, 1)) + writeFileSync(path.join(SPEC, `${book.name}.gt.txt`), book.sheets.map((s) => s.rows.map((r) => r.join('\t')).join('\n')).join('\n\n')) +} +writeFileSync(path.join(OUT, 'manifest-sheets.json'), JSON.stringify(manifest, null, 1)) +console.log(`${books.length} workbooks; ${manifest.length} files`) diff --git a/apps/sim/scripts/parser-eval/reference-extract.py b/apps/sim/scripts/parser-eval/reference-extract.py new file mode 100644 index 00000000000..6a10d3fafae --- /dev/null +++ b/apps/sim/scripts/parser-eval/reference-extract.py @@ -0,0 +1,52 @@ +"""Reference extractions for Tier B files using independent libraries. Usage: reference-extract.py """ +import json, os, sys, warnings +warnings.filterwarnings('ignore') +OUT = sys.argv[1]; REAL = os.path.join(OUT, 'real'); REF = os.path.join(OUT, 'reference'); os.makedirs(REF, exist_ok=True) +import pymupdf, pdfplumber, docx, pptx, openpyxl + +def pdf_refs(p): + d = pymupdf.open(p) + mu = '\n'.join(page.get_text() for page in d) + out = {'pymupdf': mu, 'pages': d.page_count} + try: + with pdfplumber.open(p) as pl: + out['pdfplumber'] = '\n'.join((pg.extract_text() or '') for pg in pl.pages[:60]) + except Exception as e: out['pdfplumber_error'] = str(e) + return out +def docx_ref(p): + d = docx.Document(p); parts = [para.text for para in d.paragraphs] + for t in d.tables: + for r in t.rows: parts.append('\t'.join(c.text for c in r.cells)) + for s in d.sections: + for hf in (s.header, s.footer): + for para in hf.paragraphs: + if para.text.strip(): parts.append('[HF] ' + para.text) + return {'python-docx': '\n'.join(parts)} +def pptx_ref(p): + prs = pptx.Presentation(p); parts = [] + for i, s in enumerate(prs.slides): + for sh in s.shapes: + if sh.has_text_frame: parts.append(sh.text_frame.text) + if getattr(sh, 'has_table', False) and sh.has_table: + for r in sh.table.rows: parts.append('\t'.join(c.text for c in r.cells)) + if s.has_notes_slide and s.notes_slide.notes_text_frame: parts.append('[NOTES] ' + s.notes_slide.notes_text_frame.text) + return {'python-pptx': '\n'.join(parts), 'slides': len(prs.slides)} +def xlsx_ref(p): + wb = openpyxl.load_workbook(p, data_only=True, read_only=True); parts = [] + for ws in wb.worksheets: + parts.append(f'[SHEET {ws.title}]') + for row in ws.iter_rows(values_only=True): + if any(v is not None for v in row): parts.append('\t'.join('' if v is None else str(v) for v in row)) + return {'openpyxl': '\n'.join(parts), 'sheets': wb.sheetnames} + +for f in sorted(os.listdir(REAL)): + ext = f.rsplit('.', 1)[-1].lower(); p = os.path.join(REAL, f) + try: + if ext == 'pdf': ref = pdf_refs(p) + elif ext == 'docx': ref = docx_ref(p) + elif ext == 'pptx': ref = pptx_ref(p) + elif ext == 'xlsx': ref = xlsx_ref(p) + else: continue + except Exception as e: ref = {'error': str(e)} + json.dump(ref, open(os.path.join(REF, f + '.json'), 'w'), ensure_ascii=False) + print(f, {k: (len(v) if isinstance(v, str) else v) for k, v in ref.items()}) diff --git a/apps/sim/scripts/parser-eval/run-parsers.ts b/apps/sim/scripts/parser-eval/run-parsers.ts new file mode 100644 index 00000000000..6bedd8da6ca --- /dev/null +++ b/apps/sim/scripts/parser-eval/run-parsers.ts @@ -0,0 +1,83 @@ +/** + * Runs every corpus file through the production parser entry point exactly as + * knowledge-base ingestion does, then runs the default chunker on the output. + * Run from apps/sim: `bun scripts/parser-eval/run-parsers.ts ` + */ +import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'fs' +import path from 'path' +import { TextChunker } from '@/lib/chunkers/text-chunker' +import { parseBuffer } from '@/lib/file-parsers' +import { FileParserError } from '@/lib/file-parsers/errors' + +const OUT = process.argv[2] +const OUTPUTS = path.join(OUT, 'outputs') +mkdirSync(OUTPUTS, { recursive: true }) + +interface Entry { file: string; dir: string; doc: string; format: string; tier: 'A' | 'B'; absence: string[]; variant?: string; firstSheetOnly?: boolean } + +const entries: Entry[] = [] +for (const m of ['manifest-a.json', 'manifest-sheets.json']) { + const p = path.join(OUT, m) + if (existsSync(p)) for (const e of JSON.parse(readFileSync(p, 'utf8'))) entries.push({ ...e, dir: 'files' }) +} +const realDir = path.join(OUT, 'real') +if (existsSync(realDir)) { + for (const file of readdirSync(realDir).sort()) { + const ext = path.extname(file).slice(1).toLowerCase() + if (!ext) continue + entries.push({ file, dir: 'real', doc: file.replace(/\.[^.]+$/, ''), format: ext, tier: 'B', absence: [] }) + } +} + +const fixture = (name: string) => readFileSync(path.join(OUT, 'files', name)) + +/** Robustness cases: each must surface a typed error rather than content. */ +const robustness: Array<{ name: string; ext: string; bytes: Buffer }> = [ + { name: 'empty.docx', ext: 'docx', bytes: Buffer.alloc(0) }, + { name: 'truncated-docx', ext: 'docx', bytes: fixture('memo.docx').subarray(0, 700) }, + { name: 'truncated-pdf', ext: 'pdf', bytes: fixture('memo.pdf').subarray(0, 3000) }, + { name: 'pdf-bytes-labelled-docx', ext: 'docx', bytes: fixture('memo.pdf') }, + { name: 'docx-bytes-labelled-pdf', ext: 'pdf', bytes: fixture('memo.docx') }, + { name: 'png-labelled-doc', ext: 'doc', bytes: Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), Buffer.from(Array.from({ length: 4000 }, (_, i) => (i * 7919) % 256))]) }, + { name: 'random-bytes-labelled-ppt', ext: 'ppt', bytes: Buffer.from(Array.from({ length: 50000 }, (_, i) => (i * 104729 + 17) % 256)) }, + { name: 'latin1-txt', ext: 'txt', bytes: Buffer.from('Caf\xe9 r\xe9sum\xe9 na\xefve \xa3 42', 'latin1') }, + { name: 'utf16-txt', ext: 'txt', bytes: Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from('Hello UTF-16 world', 'utf16le')]) }, + { name: 'html-labelled-txt', ext: 'txt', bytes: fixture('memo.html') }, + { name: 'docx-labelled-xlsx', ext: 'xlsx', bytes: fixture('memo.docx') }, + { name: 'csv-labelled-xlsx', ext: 'xlsx', bytes: fixture('sheet-employees.csv') }, + { name: 'docx-labelled-doc', ext: 'doc', bytes: fixture('memo.docx') }, + { name: 'pptx-labelled-ppt', ext: 'ppt', bytes: fixture('memo.pptx') }, +] + +const results: unknown[] = [] +const chunker = new TextChunker({ chunkSize: 1024, chunkOverlap: 200, minCharactersPerChunk: 100 }) + +async function runOne(label: string, ext: string, bytes: Buffer, meta: Record) { + const started = performance.now() + try { + const result = await parseBuffer(bytes, ext, { pdfTextMode: ext === 'pdf' ? 'complete' : undefined }) + const ms = performance.now() - started + let chunks: string[] = [] + try { chunks = (await chunker.chunk(result.content)).map((c) => c.text) } catch (e) { chunks = [`CHUNK_ERROR ${String(e)}`] } + const { html, sampledData, messages, ...metadata } = result.metadata ?? {} + const record = { label, ext, bytes: bytes.length, ms, ok: true, content: result.content, metadata: { ...metadata, messageCount: Array.isArray(messages) ? messages.length : 0 }, chunks, ...meta } + writeFileSync(path.join(OUTPUTS, `${label}.json`), JSON.stringify(record, null, 1)) + results.push({ ...record, content: undefined, chunks: undefined, contentLength: result.content.length, chunkCount: chunks.length }) + process.stdout.write(`ok ${label} ${result.content.length}ch ${ms.toFixed(0)}ms ${metadata.degraded ? 'DEGRADED' : ''} ${metadata.truncated ? 'TRUNCATED' : ''}\n`) + } catch (error) { + const ms = performance.now() - started + const typed = error instanceof FileParserError + const record = { label, ext, bytes: bytes.length, ms, ok: false, typedError: typed, errorCode: typed ? (error as FileParserError).code : undefined, error: String((error as Error)?.message ?? error), ...meta } + writeFileSync(path.join(OUTPUTS, `${label}.json`), JSON.stringify(record, null, 1)) + results.push(record) + process.stdout.write(`FAIL ${label} ${typed ? `typed:${record.errorCode}` : 'UNTYPED'} ${record.error.slice(0, 100)}\n`) + } +} + +for (const e of entries) { + await runOne(e.file, e.format, readFileSync(path.join(OUT, e.dir, e.file)), { doc: e.doc, format: e.format, tier: e.tier, absence: e.absence, variant: e.variant, firstSheetOnly: e.firstSheetOnly }) +} +for (const r of robustness) await runOne(`robust__${r.name}`, r.ext, r.bytes, { tier: 'R' }) + +writeFileSync(path.join(OUT, 'results.json'), JSON.stringify(results, null, 1)) +console.log(`\n${entries.length} corpus files + ${robustness.length} robustness cases`) diff --git a/apps/sim/scripts/parser-eval/score.py b/apps/sim/scripts/parser-eval/score.py new file mode 100644 index 00000000000..a86ffd188e1 --- /dev/null +++ b/apps/sim/scripts/parser-eval/score.py @@ -0,0 +1,164 @@ +"""Scores parser outputs. Usage: score.py → writes scores.json and report.md.""" +import json, os, re, statistics, sys, unicodedata +from collections import defaultdict +from rapidfuzz import fuzz +from rapidfuzz.distance import Levenshtein + +OUT = sys.argv[1] +OUTPUTS = os.path.join(OUT, 'outputs'); SPEC = os.path.join(OUT, 'spec'); REF = os.path.join(OUT, 'reference') +END_PUNCT = tuple('.!?:;)"”’\'') +WORD = re.compile(r'\w+', re.UNICODE) + +def norm(t): + t = unicodedata.normalize('NFKC', t or '').replace('­', '').replace('‑', '-').lower() + return re.sub(r'\s+', ' ', t).strip() +def vocab(t): return {w for w in WORD.findall(norm(t)) if len(w) >= 2} +HAY_CAP = 300_000 +NEEDLE_CAP = 160 +def found(needle, hay, thr=90): + n = norm(needle)[:NEEDLE_CAP] + if not n: return False + if n in hay: return True + return fuzz.partial_ratio(n, hay[:HAY_CAP], score_cutoff=thr) >= thr +def position(needle, hay): + n = norm(needle)[:NEEDLE_CAP] + if not n: return None + i = hay.find(n) + if i >= 0: return i + a = fuzz.partial_ratio_alignment(n, hay[:HAY_CAP], score_cutoff=90) + return a.dest_start if a else None +def ned(a, b, cap=40_000): + return r(Levenshtein.normalized_similarity(a[:cap], b[:cap])) +def junk_per_1k(t): + bad = sum(1 for ch in t if (unicodedata.category(ch) in ('Cc', 'Co', 'Cn') and ch not in '\n\t\r') or ch in '�­') + return round(1000 * bad / max(1, len(t)), 2) +def chunk_boundary(chunks): + if len(chunks) <= 1: return None + body = chunks[:-1] + return round(sum(1 for c in body if c.rstrip().endswith(END_PUNCT)) / len(body), 3) +def blocks(t): return [l.strip() for l in t.split('\n') if l.strip()] +def r(x): return None if x is None else round(x, 3) + +specs = {f[:-5]: json.load(open(os.path.join(SPEC, f))) for f in os.listdir(SPEC) if f.endswith('.json')} +gts = {f[:-7]: open(os.path.join(SPEC, f)).read() for f in os.listdir(SPEC) if f.endswith('.gt.txt')} + +def score_tier_a(rec): + spec = specs[rec['doc']]; gt = gts[rec['doc']]; out = rec['content']; n_out = norm(out); n_gt = norm(gt) + sentinels = spec.get('firstSheetSentinels') if rec.get('firstSheetOnly') else spec['sentinels'] + pres = [found(s, n_out) for s in sentinels] + absent = [not found(s, n_out, 85) for s in rec.get('absence') or []] + pairs = [] + for a, b in spec['order_pairs']: + pa, pb = position(a, n_out), position(b, n_out) + if pa is not None and pb is not None: pairs.append(pa < pb) + lines = [norm(l) for l in out.split('\n')] + adj = [] + for a, b in spec['table_adjacency']: + if rec.get('firstSheetOnly') and a not in (spec.get('firstSheetSentinels') or []): continue + na, nb = norm(a), norm(b) + adj.append(any(na in l and nb in l and l.find(na) < l.find(nb) for l in lines)) + gt_vocab = vocab(gt); out_words = [w for w in WORD.findall(n_out) if len(w) >= 2] + noise = [w for w in out_words if w not in gt_vocab] + def strip_marker(l): return re.sub(r'^(#{1,6}\s+|[•\-*]\s+|\d+\.\s+)', '', l.strip()) + heads = [any(norm(strip_marker(l)) == norm(h) for l in out.split('\n')) for h in spec['headings']] + glued = [w for w in set(noise) if len(w) >= 6 and any(w[:i] in gt_vocab and w[i:] in gt_vocab and i >= 2 and len(w) - i >= 2 for i in range(2, len(w) - 1))] + long_gt = {w for w in gt_vocab if len(w) >= 7}; out_vocab = vocab(out) + missing_long = sorted(long_gt - out_vocab) + is_sheet = spec.get('kind') == 'spreadsheet' + return dict( + ned=ned(n_out, n_gt), + presence=r(sum(pres) / len(pres)) if pres else None, + absence=r(sum(absent) / len(absent)) if absent else None, + order=r(sum(pairs) / len(pairs)) if pairs else None, + table_adjacency=r(sum(adj) / len(adj)) if adj else None, + noise_ratio=r(len(noise) / max(1, len(out_words))), noise_sample=sorted(set(noise))[:12], glued_words=len(glued), glued_sample=sorted(glued)[:8], + paragraph_retention=None if is_sheet else r(min(1.0, len(blocks(out)) / max(1, len(blocks(gt))))), + heading_retention=r(sum(heads) / len(heads)) if heads else None, + junk_per_1k=junk_per_1k(out), missing_long_words=missing_long[:10], missing_long_count=len(missing_long), + chunk_sentence_boundary=None if is_sheet else chunk_boundary(rec['chunks']), chunk_count=len(rec['chunks']), + degraded=bool(rec['metadata'].get('degraded')), truncated=bool(rec['metadata'].get('truncated')), + length_ratio=r(len(n_out) / max(1, len(n_gt))), ms=round(rec['ms'], 1), bytes=rec['bytes']) + +def score_tier_b(rec): + refp = os.path.join(REF, rec['label'] + '.json'); pand = os.path.join(REF, rec['label'] + '.pandoc.txt') + refs = {} + if os.path.exists(refp): + j = json.load(open(refp)); refs.update({k: v for k, v in j.items() if isinstance(v, str) and len(v) > 0}) + pages = j.get('pages'); slides = j.get('slides') + else: pages = slides = None + if os.path.exists(pand): + t = open(pand).read() + if t.strip(): refs['pandoc'] = t + out = rec['content']; n_out = norm(out); res = dict(ms=round(rec['ms'], 1), bytes=rec['bytes'], length=len(out), degraded=bool(rec['metadata'].get('degraded')), truncated=bool(rec['metadata'].get('truncated')), junk_per_1k=junk_per_1k(out), chunk_sentence_boundary=chunk_boundary(rec['chunks']), chunk_count=len(rec['chunks']), refs={}) + if pages is not None: res['pageCount'] = rec['metadata'].get('pageCount'); res['ref_pages'] = pages + ref_vocab = set() + for name, text in refs.items(): + n_ref = norm(text); ref_vocab |= vocab(text) + ref_lines = [l for l in blocks(text) if len(l) >= 25][:200] + out_lines = [l for l in blocks(out) if len(l) >= 25][:200] + recall = [found(l, n_out) for l in ref_lines]; precision = [found(l, n_ref) for l in out_lines] + res['refs'][name] = dict(ned=ned(n_out, n_ref), ref_line_recall=r(sum(recall) / len(recall)) if recall else None, out_line_precision=r(sum(precision) / len(precision)) if precision else None, ref_length=len(n_ref)) + if ref_vocab: + out_words = [w for w in WORD.findall(n_out) if len(w) >= 2]; noise = [w for w in out_words if w not in ref_vocab] + res['noise_ratio'] = r(len(noise) / max(1, len(out_words))); res['noise_sample'] = sorted(set(noise))[:12] + glued = [w for w in set(noise) if len(w) >= 6 and any(w[:i] in ref_vocab and w[i:] in ref_vocab and i >= 2 and len(w) - i >= 2 for i in range(2, len(w) - 1))] + res['glued_words'] = len(glued); res['glued_sample'] = sorted(glued)[:8] + return res + +ROBUST_EXPECT = { + 'empty.docx': ('typed error', lambda rec: not rec['ok'] and rec.get('typedError')), + 'truncated-docx': ('typed error (invalid_format)', lambda rec: not rec['ok'] and rec.get('typedError')), + 'truncated-pdf': ('typed error (invalid_format)', lambda rec: not rec['ok'] and rec.get('typedError')), + 'pdf-bytes-labelled-docx': ('typed error OR correct text', lambda rec: (not rec['ok'] and rec.get('typedError')) or (rec['ok'] and 'Office Relocation' in rec['content'])), + 'docx-bytes-labelled-pdf': ('typed error', lambda rec: not rec['ok'] and rec.get('typedError')), + 'png-labelled-doc': ('typed error, never placeholder prose', lambda rec: not rec['ok'] and rec.get('typedError')), + 'random-bytes-labelled-ppt': ('typed error, never placeholder prose', lambda rec: not rec['ok'] and rec.get('typedError')), + 'latin1-txt': ('text decodes to "Café résumé naïve £"', lambda rec: rec['ok'] and 'Café' in rec['content'] and '£' in rec['content']), + 'utf16-txt': ('text decodes to "Hello UTF-16 world"', lambda rec: rec['ok'] and 'Hello UTF-16 world' in rec['content']), + 'html-labelled-txt': ('markup stripped or typed error', lambda rec: (not rec['ok'] and rec.get('typedError')) or (rec['ok'] and ' Date: Wed, 9 Sep 2026 18:37:59 -0700 Subject: [PATCH 02/21] fix(parsers): index spreadsheet cells as display text `XlsxParser` converted sheets without `raw: false`, so the indexed text held stored values rather than what a user sees: dates as Excel serials (46085), 20% as 0.2, $1,250.00 as 1250, booleans as `true`, and ODS dates as `String(Date)` in the worker's local time zone. The Google Drive connector exports every Google Sheet through this parser while the Sheets and Excel connectors already request formatted text, so the same sheet indexed differently by path. The Files viewer had the same defect. Read with `cellDates` + `cellNF` and convert with `raw: false`, rewriting only the two cases the file's own text gets wrong inside the bounded window: dates become zone-free ISO text from the UTC fields SheetJS parsed, and General numbers print their full stored value instead of Excel's 11-char rendering (4111111111111111 -> 4.11111E+15). The shared pass handles dense and sparse sheets so the viewer reuses it without pulling `xlsx` into the client bundle. The parser-eval fixture used `0.#%`, which Excel renders as `20.%`; it now uses `0%` / `0.0%` so the spec strings match Excel. The `sheet-wide` row builder is also typed so the script type-checks. Co-Authored-By: Claude Fable 5.1 --- .../file-viewer/xlsx-preview-data.test.ts | 19 ++ .../file-viewer/xlsx-preview-data.ts | 19 +- .../file-parsers/sheet-display-text.test.ts | 148 ++++++++++++++ .../lib/file-parsers/sheet-display-text.ts | 77 +++++++ apps/sim/lib/file-parsers/xlsx-parser.ts | 25 ++- .../file-parsers/xlsx-preview-bound.test.ts | 7 + .../parser-eval/generate-spreadsheets.ts | 188 +++++++++++++++--- 7 files changed, 441 insertions(+), 42 deletions(-) create mode 100644 apps/sim/lib/file-parsers/sheet-display-text.test.ts create mode 100644 apps/sim/lib/file-parsers/sheet-display-text.ts diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview-data.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview-data.test.ts index ea6108923db..c6a8d79ea4d 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview-data.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview-data.test.ts @@ -26,9 +26,11 @@ describe('readXlsxPreviewData', () => { const result = readXlsxPreviewData(XLSX, sheet) const options = toJson.mock.calls[0][1] as { range: { s: { r: number }; e: { r: number } } + raw?: boolean } expect(options.range.e.r - options.range.s.r).toBe(XLSX_MAX_ROWS) + expect(options.raw).toBe(false) expect(result.headers).toEqual(['header-a', 'header-b']) expect(result.rows).toHaveLength(XLSX_MAX_ROWS) expect(result.rows.slice(0, 2)).toEqual([ @@ -71,4 +73,21 @@ describe('readXlsxPreviewData', () => { expect(result.rowTruncated).toBe(false) expect(result.columnTruncated).toBe(true) }) + + /** + * The viewer reads the workbook without `cellDates`, so a date arrives as a + * number carrying the file's formatted text; `raw: false` shows that text + * instead of the serial, and a General number keeps its full digits. + */ + it('shows display text rather than stored values', () => { + const sheet = XLSX.utils.aoa_to_sheet([['Issued', 'Rate', 'Card']]) + sheet.A2 = { t: 'n', v: 46085, z: 'yyyy-mm-dd', w: '2026-03-04' } + sheet.B2 = { t: 'n', v: 0.2, z: '0%', w: '20%' } + sheet.C2 = { t: 'n', v: 4111111111111111, z: 'General', w: '4.11111E+15' } + sheet['!ref'] = 'A1:C2' + + const result = readXlsxPreviewData(XLSX, sheet) + + expect(result.rows).toEqual([['2026-03-04', '20%', '4111111111111111']]) + }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview-data.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview-data.ts index a661d7f3cf7..f16781bfed0 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview-data.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview-data.ts @@ -1,10 +1,11 @@ import type { WorkSheet } from 'xlsx' +import { normalizeSheetDisplayText } from '@/lib/file-parsers/sheet-display-text' export const XLSX_MAX_ROWS = 1_000 export const XLSX_MAX_COLUMNS = 200 interface XlsxModule { - utils: Pick + utils: Pick } interface XlsxPreviewData { @@ -18,12 +19,20 @@ export function readXlsxPreviewData(XLSX: XlsxModule, sheet: WorkSheet): XlsxPre const declaredRange = XLSX.utils.decode_range(sheet['!ref'] || 'A1') const lastPreviewRow = Math.min(declaredRange.e.r, declaredRange.s.r + XLSX_MAX_ROWS) const lastPreviewColumn = Math.min(declaredRange.e.c, declaredRange.s.c + XLSX_MAX_COLUMNS - 1) + const window = { + s: declaredRange.s, + e: { r: lastPreviewRow, c: lastPreviewColumn }, + } + + /** + * Shown as the text a user sees in Excel: `raw: false` emits each cell's + * formatted text so a date cell reads as a date rather than its serial. + */ + normalizeSheetDisplayText(sheet, window, XLSX.utils) const previewRows = XLSX.utils.sheet_to_json(sheet, { header: 1, - range: { - s: declaredRange.s, - e: { r: lastPreviewRow, c: lastPreviewColumn }, - }, + raw: false, + range: window, }) return { diff --git a/apps/sim/lib/file-parsers/sheet-display-text.test.ts b/apps/sim/lib/file-parsers/sheet-display-text.test.ts new file mode 100644 index 00000000000..e5e63edd38f --- /dev/null +++ b/apps/sim/lib/file-parsers/sheet-display-text.test.ts @@ -0,0 +1,148 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import * as XLSX from 'xlsx' +import { isoDateText, normalizeSheetDisplayText } from '@/lib/file-parsers/sheet-display-text' +import { XlsxParser } from '@/lib/file-parsers/xlsx-parser' + +/** + * Every date below is built with `Date.UTC` and asserted as an ISO slice, so + * the expectations hold whatever `TZ` the runner has. The suite is also run + * under `TZ=Asia/Tokyo` and `TZ=America/Los_Angeles` from the CLI to prove the + * parser itself is zone-independent: a `String(date)` rendering would print + * the runner's zone and a serial-to-local conversion would shift the day. + */ +function typedSheet(): XLSX.WorkSheet { + const sheet = XLSX.utils.aoa_to_sheet([ + ['Issued', 'At', 'Rate', 'Amount', 'Paid', 'Total', 'Card', 'Sum', 'Note'], + ]) + sheet.A2 = { t: 'd', v: new Date(Date.UTC(2026, 2, 4)), z: 'm/d/yyyy' } + sheet.B2 = { t: 'd', v: new Date(Date.UTC(2026, 2, 4, 12)), z: 'm/d/yyyy h:mm' } + sheet.C2 = { t: 'n', v: 0.085, z: '0.0%' } + sheet.D2 = { t: 'n', v: 1250, z: '"$"#,##0.00' } + sheet.E2 = { t: 'b', v: true } + sheet.F2 = { t: 'n', v: 2500, f: 'D2*2', z: '"$"#,##0.00' } + sheet.G2 = { t: 'n', v: 4111111111111111 } + sheet.H2 = { t: 'n', v: 0.1 + 0.2 } + sheet.I2 = { t: 's', v: 'left\tright' } + sheet['!ref'] = 'A1:I2' + return sheet +} + +function typedWorkbook(bookType: XLSX.BookType, date1904 = false): Buffer { + const book = XLSX.utils.book_new() + XLSX.utils.book_append_sheet(book, typedSheet(), 'Ledger') + if (date1904) book.Workbook = { WBProps: { date1904: true } } + return XLSX.write(book, { type: 'buffer', bookType }) as Buffer +} + +function dataRow(content: string): string[] { + const lines = content.split('\n') + return lines[lines.length - 1].split('\t') +} + +describe('XlsxParser display text', () => { + it('indexes the text a user sees rather than the stored value', async () => { + const result = await new XlsxParser().parseBuffer(typedWorkbook('xlsx')) + + expect(dataRow(result.content)).toEqual([ + '2026-03-04', + '2026-03-04T12:00:00', + '8.5%', + '$1,250.00', + 'TRUE', + '$2,500.00', + '4111111111111111', + '0.30000000000000004', + 'left', + 'right', + ]) + }) + + it('renders dates from a date1904 workbook identically', async () => { + const result = await new XlsxParser().parseBuffer(typedWorkbook('xlsx', true)) + + expect(dataRow(result.content).slice(0, 2)).toEqual(['2026-03-04', '2026-03-04T12:00:00']) + }) + + it.each(['xls', 'xlsb'] as const)('renders the same display text from %s', async (bookType) => { + const result = await new XlsxParser().parseBuffer(typedWorkbook(bookType)) + + expect(dataRow(result.content).slice(0, 6)).toEqual([ + '2026-03-04', + '2026-03-04T12:00:00', + '8.5%', + '$1,250.00', + 'TRUE', + '$2,500.00', + ]) + }) + + /** + * The SheetJS ODS writer emits each number's stored value as the cell text + * the reader then trusts, so only dates, booleans and General numbers can be + * asserted through a round trip. + */ + it('renders ISO dates from an ods round trip', async () => { + const result = await new XlsxParser().parseBuffer(typedWorkbook('ods')) + + const row = dataRow(result.content) + expect(row.slice(0, 2)).toEqual(['2026-03-04', '2026-03-04T12:00:00']) + expect(row[4]).toBe('TRUE') + expect(row[6]).toBe('4111111111111111') + }) + + it('keeps the sampled metadata on display text as well', async () => { + const result = await new XlsxParser().parseBuffer(typedWorkbook('xlsx')) + + const sampled = result.metadata?.sampledData as string[][] + expect(sampled[1].slice(0, 4)).toEqual([ + '2026-03-04', + '2026-03-04T12:00:00', + '8.5%', + '$1,250.00', + ]) + }) +}) + +describe('isoDateText', () => { + it('drops a midnight time and keeps a non-midnight one without a zone suffix', () => { + expect(isoDateText(new Date(Date.UTC(2026, 2, 4)))).toBe('2026-03-04') + expect(isoDateText(new Date(Date.UTC(2026, 2, 4, 12, 30, 15)))).toBe('2026-03-04T12:30:15') + }) + + it('renders an invalid date as empty text', () => { + expect(isoDateText(new Date(Number.NaN))).toBe('') + }) +}) + +describe('normalizeSheetDisplayText', () => { + it('rewrites dates and General numbers on a sparse sheet and leaves formatted cells alone', () => { + const sheet = XLSX.utils.aoa_to_sheet([['a']]) + sheet.A1 = { t: 'd', v: new Date(Date.UTC(2026, 2, 4)), z: 'm/d/yyyy', w: '3/4/2026' } + sheet.B1 = { t: 'n', v: 4111111111111111, z: 'General', w: '4.11111E+15' } + sheet.C1 = { t: 'n', v: 1250, z: '"$"#,##0.00', w: '$1,250.00' } + sheet.D1 = { t: 'n', v: 9, w: '9' } + sheet['!ref'] = 'A1:D1' + + normalizeSheetDisplayText(sheet, XLSX.utils.decode_range('A1:C1'), XLSX.utils) + + expect(sheet.A1.w).toBe('2026-03-04') + expect(sheet.B1.w).toBe('4111111111111111') + expect(sheet.C1.w).toBe('$1,250.00') + expect(sheet.D1.w).toBe('9') + }) + + it('touches only the window on a dense sheet', () => { + const sheet = XLSX.utils.aoa_to_sheet([['a']], { dense: true }) + const inside = { t: 'd', v: new Date(Date.UTC(2026, 2, 4)), w: '3/4/2026' } as XLSX.CellObject + const outside = { t: 'd', v: new Date(Date.UTC(2026, 2, 5)), w: '3/5/2026' } as XLSX.CellObject + sheet['!data'] = [[inside], [outside]] + + normalizeSheetDisplayText(sheet, XLSX.utils.decode_range('A1:A1'), XLSX.utils) + + expect(inside.w).toBe('2026-03-04') + expect(outside.w).toBe('3/5/2026') + }) +}) diff --git a/apps/sim/lib/file-parsers/sheet-display-text.ts b/apps/sim/lib/file-parsers/sheet-display-text.ts new file mode 100644 index 00000000000..e0c9564fbd1 --- /dev/null +++ b/apps/sim/lib/file-parsers/sheet-display-text.ts @@ -0,0 +1,77 @@ +import type { CellAddress, CellObject, Range, WorkSheet } from 'xlsx' + +/** + * Read options that make a workbook's cells carry the text a user sees in + * Excel rather than the values Excel stores. + * + * `cellDates` parses date serials into `Date` objects whose UTC fields are the + * calendar fields, independent of the process time zone. `cellNF` keeps each + * cell's number format so General-formatted numbers can be told apart from + * currency, percent and date cells. + */ +export const SHEET_DISPLAY_READ_OPTIONS = { + cellDates: true, + cellNF: true, +} as const + +interface CellLookup { + encode_cell: (address: CellAddress) => string +} + +/** + * Excel dates carry no zone. Emit the UTC fields SheetJS parsed the serial + * into, without a trailing `Z`, and drop the time when it is midnight. + */ +export function isoDateText(date: Date): string { + if (Number.isNaN(date.getTime())) return '' + const iso = date.toISOString() + return iso.endsWith('T00:00:00.000Z') ? iso.slice(0, 10) : iso.slice(0, 19) +} + +function isGeneralFormat(format: unknown): boolean { + return format === undefined || format === 'General' +} + +/** + * Rewrites the display text of the cells that `sheet_to_json({ raw: false })` + * would otherwise render badly, within the bounded window only. + * + * `raw: false` returns `cell.w` verbatim. The file's `w` is right for currency, + * percent, boolean and text cells, but not for dates (locale-shaped, such as + * `3/4/2026`) or General-formatted numbers (Excel's 11-character rendering + * turns `4111111111111111` into `4.11111E+15`, losing digits of numeric IDs). + * Dates become ISO text and General numbers print their full stored value. + * + * A number with no format at all is treated as General too. Every other + * number keeps the text the file rendered for it, so a LibreOffice workbook + * indexes as LibreOffice showed it (`0,5` in a German locale). + * + * Works on dense (`!data`) and sparse (address-keyed) worksheets so the same + * pass serves the indexing parser and the Files viewer. + */ +export function normalizeSheetDisplayText( + worksheet: WorkSheet, + window: Range, + utils: CellLookup +): void { + const data = worksheet['!data'] + const lastRow = data ? Math.min(window.e.r, data.length - 1) : window.e.r + + for (let r = window.s.r; r <= lastRow; r++) { + const denseRow = data?.[r] + if (data && !denseRow) continue + + for (let c = window.s.c; c <= window.e.c; c++) { + const cell: CellObject | undefined = denseRow + ? denseRow[c] + : (worksheet[utils.encode_cell({ r, c })] as CellObject | undefined) + if (!cell) continue + + if (cell.t === 'd' && cell.v instanceof Date) { + cell.w = isoDateText(cell.v) + } else if (cell.t === 'n' && typeof cell.v === 'number' && isGeneralFormat(cell.z)) { + cell.w = String(cell.v) + } + } + } +} diff --git a/apps/sim/lib/file-parsers/xlsx-parser.ts b/apps/sim/lib/file-parsers/xlsx-parser.ts index 0095e6df0e6..8622e7739a4 100644 --- a/apps/sim/lib/file-parsers/xlsx-parser.ts +++ b/apps/sim/lib/file-parsers/xlsx-parser.ts @@ -8,6 +8,10 @@ import { isEncryptedOfficeParserError, toFileParserError, } from '@/lib/file-parsers/errors' +import { + normalizeSheetDisplayText, + SHEET_DISPLAY_READ_OPTIONS, +} from '@/lib/file-parsers/sheet-display-text' import type { FileParseResult, FileParser } from '@/lib/file-parsers/types' import { sanitizeTextForUTF8, truncationNotice } from '@/lib/file-parsers/utils' import { assertOoxmlArchiveWithinLimits } from '@/lib/file-parsers/zip-guard' @@ -78,6 +82,7 @@ export class XlsxParser implements FileParser { type: 'buffer', dense: true, // Use dense mode for better memory efficiency sheetStubs: false, // Don't create stub cells + ...SHEET_DISPLAY_READ_OPTIONS, }) return this.processWorkbook(workbook) @@ -162,13 +167,25 @@ export class XlsxParser implements FileParser { */ const lastPreviewRow = Math.min(range.e.r, range.s.r + CONFIG.MAX_PREVIEW_ROWS - 1) const lastPreviewColumn = Math.min(range.e.c, range.s.c + CONFIG.MAX_PREVIEW_COLUMNS - 1) + const window = { + s: { r: range.s.r, c: range.s.c }, + e: { r: lastPreviewRow, c: lastPreviewColumn }, + } + + /** + * Indexed as the text a user sees, not the value Excel stores: `raw: false` + * emits each cell's formatted text, so `$1,250.00` and `20%` survive + * instead of `1250` and `0.2`, and the Google Sheets and Excel connectors + * (which already request display text) agree with a Drive export of the + * same sheet. Dates and General numbers are rewritten first because their + * file-formatted text is locale-shaped or loses digits. + */ + normalizeSheetDisplayText(worksheet, window, XLSX.utils) const sheetData = XLSX.utils.sheet_to_json(worksheet, { header: 1, blankrows: false, // Skip blank rows - range: { - s: { r: range.s.r, c: range.s.c }, - e: { r: lastPreviewRow, c: lastPreviewColumn }, - }, + raw: false, + range: window, }) // Reported from the declared range, as before, so bounding the conversion diff --git a/apps/sim/lib/file-parsers/xlsx-preview-bound.test.ts b/apps/sim/lib/file-parsers/xlsx-preview-bound.test.ts index 297a6103fb1..1e0680fd2e6 100644 --- a/apps/sim/lib/file-parsers/xlsx-preview-bound.test.ts +++ b/apps/sim/lib/file-parsers/xlsx-preview-bound.test.ts @@ -36,6 +36,7 @@ describe('XlsxParser preview bound', () => { const options = toJson.mock.calls[0][1] as { range?: { s: { r: number; c: number }; e: { r: number; c: number } } defval?: unknown + raw?: boolean } /** @@ -61,6 +62,12 @@ describe('XlsxParser preview bound', () => { * silently defeated the `blankrows: false` sitting beside it. */ expect(options.defval).toBeUndefined() + + /** + * Display text, not stored values: without `raw: false` a date indexes as + * its serial and `20%` as `0.2`, unlike the Sheets and Excel connectors. + */ + expect(options.raw).toBe(false) }) it('caps a sheet with an inflated declared column range before conversion', async () => { diff --git a/apps/sim/scripts/parser-eval/generate-spreadsheets.ts b/apps/sim/scripts/parser-eval/generate-spreadsheets.ts index 09f65d73e1e..d3c421248e1 100644 --- a/apps/sim/scripts/parser-eval/generate-spreadsheets.ts +++ b/apps/sim/scripts/parser-eval/generate-spreadsheets.ts @@ -13,40 +13,95 @@ mkdirSync(FILES, { recursive: true }) mkdirSync(SPEC, { recursive: true }) let seed = 7 -const rand = () => ((seed = (seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff) -const pick = (xs: T[]) => xs[Math.floor(rand() * xs.length)] +const rand = () => (seed = (seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff +const pick = (xs: T[]) => xs[Math.floor(rand() * xs.length)] -interface SheetSpec { name: string; rows: (string | number)[][] } -interface Book { name: string; sheets: SheetSpec[]; note: string } +interface SheetSpec { + name: string + rows: (string | number)[][] +} +interface Book { + name: string + sheets: SheetSpec[] + note: string +} const cities = ['Lisbon', 'Austin', 'Kyoto', 'Nairobi', 'Zürich', 'São Paulo', 'Montréal', 'Delhi'] const books: Book[] = [ { name: 'sheet-employees', note: 'single sheet, 60 rows, header row, numbers, dates as text, unicode', - sheets: [{ - name: 'Employees', - rows: [['Employee ID', 'Full name', 'Department', 'Office', 'Salary', 'Start date'], - ...Array.from({ length: 60 }, (_, i) => [`E-${2000 + i}`, `${pick(['Ana', 'Bjørn', 'Chen', 'Dmitri', 'Eszter', 'Fatima', 'Gustavo', 'Hana'])} ${pick(['Araújo', 'Nakamura', 'Okafor', 'Svensson', 'Müller', 'Patel'])}`, pick(['Platform', 'Finance', 'Security', 'Support']), pick(cities), 48000 + Math.floor(rand() * 90000), `2024-${String(1 + Math.floor(rand() * 12)).padStart(2, '0')}-${String(1 + Math.floor(rand() * 28)).padStart(2, '0')}`])], - }], + sheets: [ + { + name: 'Employees', + rows: [ + ['Employee ID', 'Full name', 'Department', 'Office', 'Salary', 'Start date'], + ...Array.from({ length: 60 }, (_, i) => [ + `E-${2000 + i}`, + `${pick(['Ana', 'Bjørn', 'Chen', 'Dmitri', 'Eszter', 'Fatima', 'Gustavo', 'Hana'])} ${pick(['Araújo', 'Nakamura', 'Okafor', 'Svensson', 'Müller', 'Patel'])}`, + pick(['Platform', 'Finance', 'Security', 'Support']), + pick(cities), + 48000 + Math.floor(rand() * 90000), + `2024-${String(1 + Math.floor(rand() * 12)).padStart(2, '0')}-${String(1 + Math.floor(rand() * 28)).padStart(2, '0')}`, + ]), + ], + }, + ], }, { name: 'sheet-multi', note: 'three sheets including an empty-ish one and a sheet whose header is not on row 1', sheets: [ - { name: 'Summary', rows: [['Metric', 'Q1', 'Q2'], ['Active workspaces', 1240, 1398], ['Churned workspaces', 31, 27], ['Net revenue retention', '104%', '109%']] }, - { name: 'Notes', rows: [['Prepared by the Finance team on 4 July.'], [], ['Figures exclude the Northwind pilot.']] }, - { name: 'Raw', rows: [['Export generated 2026-07-04'], [], ['Workspace', 'Plan', 'Seats', 'MRR'], ...Array.from({ length: 25 }, (_, i) => [`ws-${3000 + i}`, pick(['Team', 'Enterprise', 'Pro']), 3 + Math.floor(rand() * 200), Math.round(rand() * 20000) / 100])] }, + { + name: 'Summary', + rows: [ + ['Metric', 'Q1', 'Q2'], + ['Active workspaces', 1240, 1398], + ['Churned workspaces', 31, 27], + ['Net revenue retention', '104%', '109%'], + ], + }, + { + name: 'Notes', + rows: [ + ['Prepared by the Finance team on 4 July.'], + [], + ['Figures exclude the Northwind pilot.'], + ], + }, + { + name: 'Raw', + rows: [ + ['Export generated 2026-07-04'], + [], + ['Workspace', 'Plan', 'Seats', 'MRR'], + ...Array.from({ length: 25 }, (_, i) => [ + `ws-${3000 + i}`, + pick(['Team', 'Enterprise', 'Pro']), + 3 + Math.floor(rand() * 200), + Math.round(rand() * 20000) / 100, + ]), + ], + }, ], }, { name: 'sheet-wide', note: 'wide sheet: 40 columns x 30 rows with commas and quotes inside cells', - sheets: [{ - name: 'Matrix', - rows: [['Row'].concat(Array.from({ length: 39 }, (_, c) => `Col ${c + 1}`)), - ...Array.from({ length: 30 }, (_, r) => [`R${r + 1}`].concat(Array.from({ length: 39 }, (_, c) => (c % 7 === 0 ? `note, with "quotes" ${r}-${c}` : r * 100 + c))))], - }], + sheets: [ + { + name: 'Matrix', + rows: [ + ['Row'].concat(Array.from({ length: 39 }, (_, c) => `Col ${c + 1}`)), + ...Array.from({ length: 30 }, (_, r): (string | number)[] => [ + `R${r + 1}`, + ...Array.from({ length: 39 }, (_, c) => + c % 7 === 0 ? `note, with "quotes" ${r}-${c}` : r * 100 + c + ), + ]), + ], + }, + ], }, ] @@ -54,23 +109,41 @@ const books: Book[] = [ const typedBook: Book = { name: 'sheet-typed', note: 'real Date cells, percent/currency number formats, formulas with cached values, booleans', - sheets: [{ name: 'Ledger', rows: [['Invoice', 'Issued', 'Due', 'Amount', 'Tax rate', 'Paid', 'Total'], - ['INV-001', '2026-03-04', '2026-04-03', '$1,250.00', '20%', 'TRUE', '$1,500.00'], - ['INV-002', '2026-05-17', '2026-06-16', '$980.50', '8.5%', 'FALSE', '$1,063.84'], - ['INV-003', '2026-07-29', '2026-08-28', '$12,000.00', '0%', 'TRUE', '$12,000.00']] }], + sheets: [ + { + name: 'Ledger', + rows: [ + ['Invoice', 'Issued', 'Due', 'Amount', 'Tax rate', 'Paid', 'Total'], + ['INV-001', '2026-03-04', '2026-04-03', '$1,250.00', '20%', 'TRUE', '$1,500.00'], + ['INV-002', '2026-05-17', '2026-06-16', '$980.50', '8.5%', 'FALSE', '$1,063.84'], + ['INV-003', '2026-07-29', '2026-08-28', '$12,000.00', '0%', 'TRUE', '$12,000.00'], + ], + }, + ], } function buildTypedSheet(): XLSX.WorkSheet { - const ws = XLSX.utils.aoa_to_sheet([['Invoice', 'Issued', 'Due', 'Amount', 'Tax rate', 'Paid', 'Total']]) - const rows = [[1, new Date(Date.UTC(2026, 2, 4)), new Date(Date.UTC(2026, 3, 3)), 1250, 0.2, true], [2, new Date(Date.UTC(2026, 4, 17)), new Date(Date.UTC(2026, 5, 16)), 980.5, 0.085, false], [3, new Date(Date.UTC(2026, 6, 29)), new Date(Date.UTC(2026, 7, 28)), 12000, 0, true]] + const ws = XLSX.utils.aoa_to_sheet([ + ['Invoice', 'Issued', 'Due', 'Amount', 'Tax rate', 'Paid', 'Total'], + ]) + const rows = [ + [1, new Date(Date.UTC(2026, 2, 4)), new Date(Date.UTC(2026, 3, 3)), 1250, 0.2, true], + [2, new Date(Date.UTC(2026, 4, 17)), new Date(Date.UTC(2026, 5, 16)), 980.5, 0.085, false], + [3, new Date(Date.UTC(2026, 6, 29)), new Date(Date.UTC(2026, 7, 28)), 12000, 0, true], + ] rows.forEach((r, i) => { const n = i + 2 XLSX.utils.sheet_add_aoa(ws, [[`INV-00${r[0]}`]], { origin: `A${n}` }) ws[`B${n}`] = { t: 'd', v: r[1], z: 'yyyy-mm-dd' } ws[`C${n}`] = { t: 'd', v: r[2], z: 'yyyy-mm-dd' } ws[`D${n}`] = { t: 'n', v: r[3], z: '"$"#,##0.00' } - ws[`E${n}`] = { t: 'n', v: r[4], z: '0.#%' } + ws[`E${n}`] = { t: 'n', v: r[4], z: Number.isInteger((r[4] as number) * 100) ? '0%' : '0.0%' } ws[`F${n}`] = { t: 'b', v: r[5] } - ws[`G${n}`] = { t: 'n', f: `D${n}*(1+E${n})`, v: (r[3] as number) * (1 + (r[4] as number)), z: '"$"#,##0.00' } + ws[`G${n}`] = { + t: 'n', + f: `D${n}*(1+E${n})`, + v: (r[3] as number) * (1 + (r[4] as number)), + z: '"$"#,##0.00', + } }) ws['!ref'] = 'A1:G4' return ws @@ -81,22 +154,71 @@ const manifest: unknown[] = [] for (const book of books) { const wb = XLSX.utils.book_new() if (book.name === 'sheet-typed') XLSX.utils.book_append_sheet(wb, buildTypedSheet(), 'Ledger') - else for (const sheet of book.sheets) XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(sheet.rows), sheet.name) + else + for (const sheet of book.sheets) + XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(sheet.rows), sheet.name) const formats = ['xlsx', 'xls', 'xlsb', 'ods'] as const for (const fmt of formats) { const target = path.join(FILES, `${book.name}.${fmt}`) writeFileSync(target, XLSX.write(wb, { type: 'buffer', bookType: fmt })) - manifest.push({ file: path.basename(target), doc: book.name, format: fmt, tier: 'A', absence: [] }) + manifest.push({ + file: path.basename(target), + doc: book.name, + format: fmt, + tier: 'A', + absence: [], + }) } const csvTarget = path.join(FILES, `${book.name}.csv`) writeFileSync(csvTarget, XLSX.utils.sheet_to_csv(wb.Sheets[book.sheets[0].name])) - manifest.push({ file: path.basename(csvTarget), doc: book.name, format: 'csv', tier: 'A', absence: [], firstSheetOnly: true }) + manifest.push({ + file: path.basename(csvTarget), + doc: book.name, + format: 'csv', + tier: 'A', + absence: [], + firstSheetOnly: true, + }) - const cells = book.sheets.flatMap((s) => s.rows.flatMap((r) => r.map(String))).filter((c) => c.length >= 2) - const adjacency = book.sheets.flatMap((s) => s.rows.flatMap((r) => r.slice(0, -1).map((c, i) => [String(c), String(r[i + 1])]).filter(([a, b]) => a.length >= 2 && b.length >= 2 && a !== b))) - const firstSheetCells = book.sheets[0].rows.flatMap((r) => r.map(String)).filter((c) => c.length >= 2) - writeFileSync(path.join(SPEC, `${book.name}.json`), JSON.stringify({ name: book.name, kind: 'spreadsheet', note: book.note, sheets: book.sheets.map((s) => s.name), sentinels: cells, firstSheetSentinels: firstSheetCells, table_adjacency: adjacency, order_pairs: [], headings: [], paragraphs: [], list_items: [], code: [] }, null, 1)) - writeFileSync(path.join(SPEC, `${book.name}.gt.txt`), book.sheets.map((s) => s.rows.map((r) => r.join('\t')).join('\n')).join('\n\n')) + const cells = book.sheets + .flatMap((s) => s.rows.flatMap((r) => r.map(String))) + .filter((c) => c.length >= 2) + const adjacency = book.sheets.flatMap((s) => + s.rows.flatMap((r) => + r + .slice(0, -1) + .map((c, i) => [String(c), String(r[i + 1])]) + .filter(([a, b]) => a.length >= 2 && b.length >= 2 && a !== b) + ) + ) + const firstSheetCells = book.sheets[0].rows + .flatMap((r) => r.map(String)) + .filter((c) => c.length >= 2) + writeFileSync( + path.join(SPEC, `${book.name}.json`), + JSON.stringify( + { + name: book.name, + kind: 'spreadsheet', + note: book.note, + sheets: book.sheets.map((s) => s.name), + sentinels: cells, + firstSheetSentinels: firstSheetCells, + table_adjacency: adjacency, + order_pairs: [], + headings: [], + paragraphs: [], + list_items: [], + code: [], + }, + null, + 1 + ) + ) + writeFileSync( + path.join(SPEC, `${book.name}.gt.txt`), + book.sheets.map((s) => s.rows.map((r) => r.join('\t')).join('\n')).join('\n\n') + ) } writeFileSync(path.join(OUT, 'manifest-sheets.json'), JSON.stringify(manifest, null, 1)) console.log(`${books.length} workbooks; ${manifest.length} files`) From 8e0385c21547364ace1b37144c7d661a8ab6ec20 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 9 Sep 2026 18:41:12 -0700 Subject: [PATCH 03/21] fix(parsers): walk office document structure instead of flattening cells DOCX now routes mammoth's HTML rendering through the shared HTML structured-text walker so tables keep their rows, lists keep their markers, and footnotes survive; the unread metadata.html field is gone. PPTX and ODT/ODP get dedicated XML walkers that render tables row by row, skip slide-number/date/header/footer placeholders, read presenter notes from the notes body placeholder only, and drop ODF annotations and tracked deletions. Legacy OLE .ppt is rejected as unsupported_type instead of scraping printable bytes from the container. Co-Authored-By: Claude Fable 5.1 --- apps/sim/lib/file-parsers/docx-parser.ts | 69 ++- apps/sim/lib/file-parsers/html-parser.test.ts | 26 + apps/sim/lib/file-parsers/html-parser.ts | 457 +++++++++++------- apps/sim/lib/file-parsers/odf-text.test.ts | 95 ++++ apps/sim/lib/file-parsers/odf-text.ts | 282 +++++++++++ apps/sim/lib/file-parsers/office-text.ts | 66 +++ .../file-parsers/ooxml-presentation.test.ts | 142 ++++++ .../lib/file-parsers/ooxml-presentation.ts | 226 +++++++++ .../lib/file-parsers/opendocument-parser.ts | 52 +- .../lib/file-parsers/parser-formats.test.ts | 39 +- apps/sim/lib/file-parsers/pptx-parser.test.ts | 29 +- apps/sim/lib/file-parsers/pptx-parser.ts | 140 +++--- apps/sim/lib/file-parsers/types.ts | 1 - 13 files changed, 1332 insertions(+), 292 deletions(-) create mode 100644 apps/sim/lib/file-parsers/odf-text.test.ts create mode 100644 apps/sim/lib/file-parsers/odf-text.ts create mode 100644 apps/sim/lib/file-parsers/office-text.ts create mode 100644 apps/sim/lib/file-parsers/ooxml-presentation.test.ts create mode 100644 apps/sim/lib/file-parsers/ooxml-presentation.ts diff --git a/apps/sim/lib/file-parsers/docx-parser.ts b/apps/sim/lib/file-parsers/docx-parser.ts index 7a6c6038849..8f08dee8f46 100644 --- a/apps/sim/lib/file-parsers/docx-parser.ts +++ b/apps/sim/lib/file-parsers/docx-parser.ts @@ -6,6 +6,11 @@ import { isEncryptedOfficeParserError, toFileParserError, } from '@/lib/file-parsers/errors' +import { + assertHtmlStringWithinLimits, + htmlToStructuredText, + isHtmlComplexityError, +} from '@/lib/file-parsers/html-parser' import { parseOfficeText } from '@/lib/file-parsers/officeparser-module' import type { FileParseOptions, FileParseResult, FileParser } from '@/lib/file-parsers/types' import { sanitizeTextForUTF8 } from '@/lib/file-parsers/utils' @@ -13,16 +18,14 @@ import { assertOoxmlArchiveWithinLimits } from '@/lib/file-parsers/zip-guard' const logger = createLogger('DocxParser') -interface MammothMessage { - type: 'warning' | 'error' - message: string -} - -interface MammothResult { - value: string - messages: MammothMessage[] -} - +/** + * Extracts DOCX text by rendering the document to HTML with mammoth and walking + * that HTML with the shared structured-text walker. mammoth's HTML keeps the + * heading levels, list nesting, table rows, and footnotes that its raw-text mode + * flattens to one paragraph per cell, so the output matches what the HTML parser + * produces for the same document. (mammoth's Markdown mode is deprecated and + * drops tables, so it is deliberately not used.) + */ export class DocxParser implements FileParser { async parseFile(filePath: string, options: FileParseOptions = {}): Promise { if (!filePath) { @@ -46,24 +49,29 @@ export class DocxParser implements FileParser { let parserReturnedEmpty = false try { - const result = await mammoth.extractRawText({ buffer }) + const htmlResult = await mammoth.convertToHtml({ buffer }) options.signal?.throwIfAborted() - if (result.value && result.value.trim().length > 0) { - let htmlResult: MammothResult = { value: '', messages: [] } - try { - htmlResult = await mammoth.convertToHtml({ buffer }) - } catch { - // HTML conversion is optional + const structured = this.structuredTextFromHtml(htmlResult.value) + if (structured) { + return { + content: sanitizeTextForUTF8(structured), + metadata: { + extractionMethod: 'mammoth-html', + messages: htmlResult.messages, + }, } - options.signal?.throwIfAborted() + } + const rawResult = await mammoth.extractRawText({ buffer }) + options.signal?.throwIfAborted() + + if (rawResult.value && rawResult.value.trim().length > 0) { return { - content: sanitizeTextForUTF8(result.value), + content: sanitizeTextForUTF8(rawResult.value), metadata: { extractionMethod: 'mammoth', - messages: [...result.messages, ...htmlResult.messages], - html: htmlResult.value, + messages: [...htmlResult.messages, ...rawResult.messages], }, } } @@ -140,4 +148,23 @@ export class DocxParser implements FileParser { throw toFileParserError(error, 'invalid_format', 'Failed to parse DOCX buffer') } } + + /** + * Walks mammoth's HTML rendering under the HTML parser's size caps. A rendering + * too large to walk safely falls back to the raw-text path by returning empty, + * since mammoth has already materialised the document once at that point. + */ + private structuredTextFromHtml(html: string): string { + if (!html || html.trim().length === 0) return '' + try { + assertHtmlStringWithinLimits(html) + } catch (error) { + if (isHtmlComplexityError(error)) { + logger.warn('mammoth HTML exceeds walker limits, using raw text:', error.message) + return '' + } + throw error + } + return htmlToStructuredText(html).trim() + } } diff --git a/apps/sim/lib/file-parsers/html-parser.test.ts b/apps/sim/lib/file-parsers/html-parser.test.ts index 17013dfd1aa..b08c38d072d 100644 --- a/apps/sim/lib/file-parsers/html-parser.test.ts +++ b/apps/sim/lib/file-parsers/html-parser.test.ts @@ -118,5 +118,31 @@ describe('HtmlParser', () => { expect(result.metadata?.listCount).toBe(1) expect(result.metadata?.tableCount).toBe(1) }) + + it('numbers ordered lists and keeps markers on nested items', async () => { + const buffer = Buffer.from( + `
  1. third
  2. fourth
    • nested
` + ) + + const result = await parser.parseBuffer(buffer) + + expect(result.content).toContain('3. third') + expect(result.content).toContain('4. fourth') + expect(result.content).toContain(' • nested') + expect(result.content).not.toContain('fourth nested') + }) + + it('drops footnote return links but keeps the footnote text', async () => { + const buffer = Buffer.from( + `

Body[1]

` + + `
  1. Note text

` + ) + + const result = await parser.parseBuffer(buffer) + + expect(result.content).toContain('Body[1]') + expect(result.content).toContain('1. Note text') + expect(result.content).not.toContain('↑') + }) }) }) diff --git a/apps/sim/lib/file-parsers/html-parser.ts b/apps/sim/lib/file-parsers/html-parser.ts index d7fd0e396d3..f246af106a2 100644 --- a/apps/sim/lib/file-parsers/html-parser.ts +++ b/apps/sim/lib/file-parsers/html-parser.ts @@ -78,6 +78,287 @@ function assertHtmlWithinLimits(buffer: Buffer): void { } } +/** + * The same caps for HTML that already exists as a string — markup another + * converter produced in memory (mammoth's DOCX rendering) — measured without + * copying it into a buffer. + */ +export function assertHtmlStringWithinLimits(html: string): void { + const byteLength = Buffer.byteLength(html, 'utf8') + if (byteLength > MAX_HTML_INPUT_BYTES) { + throw new HtmlComplexityError( + `HTML document is ${byteLength} bytes, above the maximum of ${MAX_HTML_INPUT_BYTES} bytes` + ) + } + + let count = 0 + let index = html.indexOf('<') + while (index !== -1) { + if (++count > MAX_HTML_MARKUP_TOKENS) { + throw new HtmlComplexityError( + `HTML document exceeds the maximum of ${MAX_HTML_MARKUP_TOKENS} markup tokens` + ) + } + index = html.indexOf('<', index + 1) + } +} + +const NON_CONTENT_SELECTOR = 'script, style, noscript, meta, link, iframe, object, embed, svg' + +/** mammoth renders a footnote's return link as ``. */ +const FOOTNOTE_BACKLINK_SELECTOR = 'a[href^="#footnote-ref"]' + +/** + * Strips the non-content markup and HTML comments from a loaded document so the + * structured walk sees only what a reader would. + */ +function stripNonContent($: cheerio.CheerioAPI): void { + $(NON_CONTENT_SELECTOR).remove() + $(FOOTNOTE_BACKLINK_SELECTOR).remove() + + $.root() + .contents() + .filter(function () { + return this.type === 'comment' + }) + .remove() +} + +/** + * Converts an HTML document into structured plain text: headings and paragraphs + * on their own lines, `•`/`1.` list markers with nesting indents, and tables as + * `[Table]` / `| a | b |` / `[/Table]` rows. Shared by {@link HtmlParser} and the + * DOCX parser, which routes mammoth's HTML rendering through the same walk so + * both formats produce the same shape. + */ +export function htmlToStructuredText(html: string): string { + const $ = cheerio.load(html) + stripNonContent($) + return extractStructuredText($) +} + +function extractStructuredText($: cheerio.CheerioAPI): string { + const contentParts: string[] = [] + + const rootElement = $('body').length > 0 ? $('body') : $.root() + + processElement($, rootElement, contentParts, 0) + + return contentParts.join('\n').trim() +} + +type AnyNode = ReturnType['contents']> extends cheerio.Cheerio + ? N + : never + +type ElementNode = Extract + +function isTagNode(node: AnyNode): node is ElementNode { + return node.type === 'tag' +} + +/** + * Recursively process elements to extract text with structure + */ +function processElement( + $: cheerio.CheerioAPI, + element: cheerio.Cheerio, + contentParts: string[], + depth: number +): void { + element.contents().each((_, node) => { + if (node.type === 'text') { + const text = $(node).text().trim() + if (text) { + contentParts.push(text) + } + return + } + + if (!isTagNode(node)) return + + const $node = $(node) + const tagName = node.tagName.toLowerCase() + + switch (tagName) { + case 'h1': + case 'h2': + case 'h3': + case 'h4': + case 'h5': + case 'h6': { + const headingText = $node.text().trim() + if (headingText) { + contentParts.push(`\n${headingText}\n`) + } + break + } + + case 'p': { + const paragraphText = $node.text().trim() + if (paragraphText) { + contentParts.push(`${paragraphText}\n`) + } + break + } + + case 'br': + contentParts.push('\n') + break + + case 'hr': + contentParts.push('\n---\n') + break + + case 'li': + processListItem($, $node, contentParts, depth, null) + break + + case 'ul': + case 'ol': + contentParts.push('\n') + processList($, $node, contentParts, depth + 1, tagName === 'ol') + contentParts.push('\n') + break + + case 'table': + processTable($, $node, contentParts) + break + + case 'blockquote': { + const quoteText = $node.text().trim() + if (quoteText) { + contentParts.push(`\n> ${quoteText}\n`) + } + break + } + + case 'pre': + case 'code': { + const codeText = $node.text().trim() + if (codeText) { + contentParts.push(`\n\`\`\`\n${codeText}\n\`\`\`\n`) + } + break + } + + case 'a': { + const linkText = $node.text().trim() + const href = $node.attr('href') + if (linkText) { + if (href?.startsWith('http')) { + contentParts.push(`${linkText} (${href})`) + } else { + contentParts.push(linkText) + } + } + break + } + + case 'img': { + const alt = $node.attr('alt') + if (alt) { + contentParts.push(`[Image: ${alt}]`) + } + break + } + + default: + processElement($, $node, contentParts, depth) + } + }) +} + +/** + * Walks a list's children, numbering `
    ` items from its `start` attribute and + * bulleting `
      ` items. Non-item children are walked as ordinary content. + */ +function processList( + $: cheerio.CheerioAPI, + list: cheerio.Cheerio, + contentParts: string[], + depth: number, + ordered: boolean +): void { + const start = Number.parseInt(list.attr('start') ?? '1', 10) + let index = Number.isFinite(start) ? start : 1 + + list.children().each((_, child) => { + const $child = $(child) + if (isTagNode(child) && child.tagName.toLowerCase() === 'li') { + processListItem($, $child, contentParts, depth, ordered ? index++ : null) + } else { + processElement($, $child, contentParts, depth) + } + }) +} + +/** + * Emits a list item as one marked line built from its own inline text, then + * walks any nested lists so their items keep their own markers and indent. + */ +function processListItem( + $: cheerio.CheerioAPI, + item: cheerio.Cheerio, + contentParts: string[], + depth: number, + ordinal: number | null +): void { + const ownText: string[] = [] + const nestedLists: cheerio.Cheerio[] = [] + + item.contents().each((_, child) => { + if (isTagNode(child)) { + const childTag = child.tagName.toLowerCase() + if (childTag === 'ul' || childTag === 'ol') { + nestedLists.push($(child)) + return + } + } + const text = $(child).text().replace(/\s+/g, ' ').trim() + if (text) ownText.push(text) + }) + + const itemText = ownText.join(' ').trim() + if (itemText) { + const indent = ' '.repeat(Math.min(Math.max(depth - 1, 0), 3)) + const marker = ordinal === null ? '•' : `${ordinal}.` + contentParts.push(`${indent}${marker} ${itemText}`) + } + + for (const nested of nestedLists) { + const nestedTag = nested.prop('tagName')?.toLowerCase() + processList($, nested, contentParts, depth + 1, nestedTag === 'ol') + } +} + +/** + * Process table elements to extract structured data + */ +function processTable( + $: cheerio.CheerioAPI, + table: cheerio.Cheerio, + contentParts: string[] +): void { + contentParts.push('\n[Table]') + + table.find('tr').each((_, row) => { + const $row = $(row) + const cells: string[] = [] + + $row.find('td, th').each((_, cell) => { + const cellText = $(cell).text().replace(/\s+/g, ' ').trim() + cells.push(cellText || '') + }) + + if (cells.length > 0) { + contentParts.push(`| ${cells.join(' | ')} |`) + } + }) + + contentParts.push('[/Table]\n') +} + export class HtmlParser implements FileParser { async parseFile(filePath: string): Promise { let buffer: Buffer @@ -114,16 +395,9 @@ export class HtmlParser implements FileParser { const title = $('title').text().trim() const metaDescription = $('meta[name="description"]').attr('content') || '' - $('script, style, noscript, meta, link, iframe, object, embed, svg').remove() + stripNonContent($) - $.root() - .contents() - .filter(function () { - return this.type === 'comment' - }) - .remove() - - const content = this.extractStructuredText($) + const content = extractStructuredText($) const sanitizedContent = sanitizeTextForUTF8(content) @@ -177,171 +451,6 @@ export class HtmlParser implements FileParser { } } - /** - * Extract structured text content preserving document hierarchy - */ - private extractStructuredText($: cheerio.CheerioAPI): string { - const contentParts: string[] = [] - - const rootElement = $('body').length > 0 ? $('body') : $.root() - - this.processElement($, rootElement, contentParts, 0) - - return contentParts.join('\n').trim() - } - - /** - * Recursively process elements to extract text with structure - */ - private processElement( - $: cheerio.CheerioAPI, - element: cheerio.Cheerio, - contentParts: string[], - depth: number - ): void { - element.contents().each((_, node) => { - if (node.type === 'text') { - const text = $(node).text().trim() - if (text) { - contentParts.push(text) - } - } else if (node.type === 'tag') { - const $node = $(node) - const tagName = node.tagName?.toLowerCase() - - switch (tagName) { - case 'h1': - case 'h2': - case 'h3': - case 'h4': - case 'h5': - case 'h6': { - const headingText = $node.text().trim() - if (headingText) { - contentParts.push(`\n${headingText}\n`) - } - break - } - - case 'p': { - const paragraphText = $node.text().trim() - if (paragraphText) { - contentParts.push(`${paragraphText}\n`) - } - break - } - - case 'br': - contentParts.push('\n') - break - - case 'hr': - contentParts.push('\n---\n') - break - - case 'li': { - const listItemText = $node.text().trim() - if (listItemText) { - const indent = ' '.repeat(Math.min(depth, 3)) - contentParts.push(`${indent}• ${listItemText}`) - } - break - } - - case 'ul': - case 'ol': - contentParts.push('\n') - this.processElement($, $node, contentParts, depth + 1) - contentParts.push('\n') - break - - case 'table': - this.processTable($, $node, contentParts) - break - - case 'blockquote': { - const quoteText = $node.text().trim() - if (quoteText) { - contentParts.push(`\n> ${quoteText}\n`) - } - break - } - - case 'pre': - case 'code': { - const codeText = $node.text().trim() - if (codeText) { - contentParts.push(`\n\`\`\`\n${codeText}\n\`\`\`\n`) - } - break - } - - case 'div': - case 'section': - case 'article': - case 'main': - case 'aside': - case 'nav': - case 'header': - case 'footer': - this.processElement($, $node, contentParts, depth) - break - - case 'a': { - const linkText = $node.text().trim() - const href = $node.attr('href') - if (linkText) { - if (href?.startsWith('http')) { - contentParts.push(`${linkText} (${href})`) - } else { - contentParts.push(linkText) - } - } - break - } - - case 'img': { - const alt = $node.attr('alt') - if (alt) { - contentParts.push(`[Image: ${alt}]`) - } - break - } - - default: - this.processElement($, $node, contentParts, depth) - } - } - }) - } - - /** - * Process table elements to extract structured data - */ - private processTable( - $: cheerio.CheerioAPI, - table: cheerio.Cheerio, - contentParts: string[] - ): void { - contentParts.push('\n[Table]') - - table.find('tr').each((_, row) => { - const $row = $(row) - const cells: string[] = [] - - $row.find('td, th').each((_, cell) => { - const cellText = $(cell).text().trim() - cells.push(cellText || '') - }) - - if (cells.length > 0) { - contentParts.push(`| ${cells.join(' | ')} |`) - } - }) - - contentParts.push('[/Table]\n') - } - /** * Extract heading structure for metadata */ diff --git a/apps/sim/lib/file-parsers/odf-text.test.ts b/apps/sim/lib/file-parsers/odf-text.test.ts new file mode 100644 index 00000000000..be4223c714e --- /dev/null +++ b/apps/sim/lib/file-parsers/odf-text.test.ts @@ -0,0 +1,95 @@ +/** + * @vitest-environment node + */ +import JSZip from 'jszip' +import { describe, expect, it } from 'vitest' +import { extractOpenDocumentText } from '@/lib/file-parsers/odf-text' + +const NS = + 'xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" xmlns:presentation="urn:oasis:names:tc:opendocument:xmlns:presentation:1.0" xmlns:dc="http://purl.org/dc/elements/1.1/"' + +async function buildOdf(bodyXml: string, extraParts: Record = {}): Promise { + const zip = new JSZip() + zip.file('mimetype', 'application/vnd.oasis.opendocument.text', { compression: 'STORE' }) + zip.file( + 'content.xml', + `${bodyXml}` + ) + for (const [path, xml] of Object.entries(extraParts)) zip.file(path, xml) + return zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' }) as Promise +} + +const text = (body: string) => buildOdf(`${body}`) + +describe('extractOpenDocumentText', () => { + it('drops annotations so the surrounding sentence stays intact', async () => { + const buffer = await text( + `Aaa MMFirst comment.comment ccc.` + ) + + expect(await extractOpenDocumentText(buffer)).toBe('Aaa comment ccc.') + }) + + it('treats tracked deletions as accepted and keeps insertions', async () => { + const buffer = await text( + `Mdeleted words` + + `Kept sentence.` + ) + + expect(await extractOpenDocumentText(buffer)).toBe('Kept sentence.') + }) + + it('renders headings, paragraphs, and nested lists', async () => { + const buffer = await text( + `PurposeBody line.` + + `oneone-atwo` + ) + + expect(await extractOpenDocumentText(buffer)).toBe( + 'Purpose\n\nBody line.\n\n• one\n • one-a\n• two' + ) + }) + + it('keeps header rows and expands repeated columns up to the cap', async () => { + const cell = (value: string, repeat?: number) => + `${value}` + const buffer = await text( + `${cell('Role')}${cell('Contact')}` + + `${cell('Owner')}${cell('x', 2)}` + + `${cell('', 1024)}` + ) + + expect(await extractOpenDocumentText(buffer)).toBe( + '[Table]\n| Role | Contact |\n| Owner | x | x |\n[/Table]' + ) + }) + + it('expands whitespace elements and appends footnotes after the paragraph', async () => { + const buffer = await text( + `ABCD1snoskaNext.` + ) + + expect(await extractOpenDocumentText(buffer)).toBe('A B\tC\nD[1]\n[1] snoska\n\nNext.') + }) + + it('emits presentation notes only when they have a body, skipping page chrome', async () => { + const buffer = await buildOdf( + `Slide title` + + `1` + + `Say hello` + + `Second slide` + ) + + expect(await extractOpenDocumentText(buffer)).toBe( + 'Slide title\n\n[Notes]\nSay hello\n\nSecond slide' + ) + }) + + it('includes embedded object content parts after the main document', async () => { + const buffer = await buildOdf(`Main`, { + 'Object 1/content.xml': `Embedded`, + }) + + expect(await extractOpenDocumentText(buffer)).toBe('Main\n\nEmbedded') + }) +}) diff --git a/apps/sim/lib/file-parsers/odf-text.ts b/apps/sim/lib/file-parsers/odf-text.ts new file mode 100644 index 00000000000..25aa83fa46e --- /dev/null +++ b/apps/sim/lib/file-parsers/odf-text.ts @@ -0,0 +1,282 @@ +import JSZip from 'jszip' +import { + childElements, + collapseWhitespace, + findFirst, + formatTableRow, + isXmlElement, + joinBlocks, + NOTES_MARKER, + parseXml, + TABLE_CLOSE, + TABLE_OPEN, + type XmlElement, +} from '@/lib/file-parsers/office-text' +import type { FileParseOptions } from '@/lib/file-parsers/types' + +/** + * Structured text extraction for OpenDocument text and presentation packages + * (`.odt`, `.odp`) that walks `content.xml` in document order. Headings and + * paragraphs become lines, lists get `•` markers with nesting indents, tables + * are rendered row by row, footnotes are appended after their paragraph, and + * reviewer annotations plus tracked deletions are dropped the way pandoc, + * odfpy, and LibreOffice's own text export drop them. + * + * Only `content.xml` and embedded `Object N/content.xml` parts are inflated. + */ + +const CONTENT_PART = 'content.xml' +const EMBEDDED_CONTENT_PART = /^Object (\d+)\/content\.xml$/ + +/** Subtrees whose text is review metadata rather than document content. */ +const SKIPPED_SUBTREES = new Set([ + 'office:annotation', + 'office:annotation-end', + 'text:tracked-changes', + 'office:change-info', + 'text:sequence-decls', + 'text:variable-decls', + 'text:user-field-decls', + 'office:forms', +]) + +/** Presentation frames that render layout chrome rather than slide content. */ +const SKIPPED_PRESENTATION_CLASSES = new Set(['header', 'footer', 'date-time', 'page-number']) + +/** Bounds `table:number-columns-repeated`, which spreadsheets inflate to 1024. */ +const MAX_REPEATED_COLUMNS = 32 + +const MAX_LIST_INDENT = 3 + +interface WalkState { + blocks: string[] + /** Footnote bodies gathered while rendering the current paragraph. */ + pendingNotes: string[] +} + +function isSkipped(element: XmlElement): boolean { + if (SKIPPED_SUBTREES.has(element.name)) return true + if (element.name === 'draw:frame') { + const presentationClass = element.attribs['presentation:class'] + return presentationClass !== undefined && SKIPPED_PRESENTATION_CLASSES.has(presentationClass) + } + return false +} + +/** + * Inline text of a paragraph-like element, expanding ODF whitespace elements and + * collecting footnote bodies into `state.pendingNotes`. + */ +function inlineText(element: XmlElement, state: WalkState): string { + const pieces: string[] = [] + for (const child of element.children) { + if (child.type === 'text') { + pieces.push(child.data) + continue + } + if (!isXmlElement(child) || isSkipped(child)) continue + + switch (child.name) { + case 'text:s': { + const count = Number.parseInt(child.attribs['text:c'] ?? '1', 10) + pieces.push(' '.repeat(Number.isFinite(count) && count > 0 ? count : 1)) + break + } + case 'text:tab': + pieces.push('\t') + break + case 'text:line-break': + pieces.push('\n') + break + case 'text:note': { + const citation = findFirst(child, 'text:note-citation') + const body = findFirst(child, 'text:note-body') + const label = citation ? collapseWhitespace(inlineText(citation, state)) : '' + const bodyText = body ? collapseWhitespace(blockText(body)) : '' + if (label) pieces.push(`[${label}]`) + if (bodyText) state.pendingNotes.push(label ? `[${label}] ${bodyText}` : bodyText) + break + } + default: + pieces.push(inlineText(child, state)) + } + } + return pieces.join('') +} + +/** Renders a container's block children to a single string, for cells and note bodies. */ +function blockText(container: XmlElement): string { + const state: WalkState = { blocks: [], pendingNotes: [] } + walkChildren(container, state, 0) + return [...state.blocks, ...state.pendingNotes].join('\n') +} + +function flushNotes(state: WalkState): void { + if (state.pendingNotes.length === 0) return + state.blocks.push(...state.pendingNotes) + state.pendingNotes = [] +} + +function emitParagraph(element: XmlElement, state: WalkState, heading: boolean): void { + const text = inlineText(element, state) + .replace(/[ \t]+\n/g, '\n') + .trim() + if (text) { + state.blocks.push(heading ? `\n${text}\n` : text) + } + flushNotes(state) + if (text) state.blocks.push('') +} + +function emitList(list: XmlElement, state: WalkState, depth: number): void { + const indent = ' '.repeat(Math.min(depth, MAX_LIST_INDENT)) + for (const item of childElements(list)) { + if (item.name !== 'text:list-item' && item.name !== 'text:list-header') continue + let markerPending = item.name === 'text:list-item' + for (const child of childElements(item)) { + if (isSkipped(child)) continue + if (child.name === 'text:list') { + emitList(child, state, depth + 1) + continue + } + if (child.name === 'text:p' || child.name === 'text:h') { + const text = collapseWhitespace(inlineText(child, state)) + if (text) { + state.blocks.push(markerPending ? `${indent}• ${text}` : `${indent} ${text}`) + markerPending = false + } + flushNotes(state) + continue + } + walkElement(child, state, depth + 1) + } + } +} + +function cellText(cell: XmlElement): string { + return collapseWhitespace(blockText(cell)) +} + +function repeatCount(element: XmlElement, attribute: string, cap: number): number { + const raw = element.attribs[attribute] + if (raw === undefined) return 1 + const parsed = Number.parseInt(raw, 10) + if (!Number.isFinite(parsed) || parsed < 1) return 1 + return Math.min(parsed, cap) +} + +function tableRows(container: XmlElement, rows: string[]): void { + for (const child of childElements(container)) { + if (isSkipped(child)) continue + switch (child.name) { + case 'table:table-row': { + const cells: string[] = [] + for (const cell of childElements(child)) { + if (cell.name !== 'table:table-cell' && cell.name !== 'table:covered-table-cell') continue + const text = cellText(cell) + const repeats = repeatCount(cell, 'table:number-columns-repeated', MAX_REPEATED_COLUMNS) + for (let i = 0; i < repeats; i++) cells.push(text) + } + if (cells.some((cell) => cell.length > 0)) rows.push(formatTableRow(cells)) + break + } + case 'table:table-header-rows': + case 'table:table-rows': + case 'table:table-row-group': + tableRows(child, rows) + break + default: + break + } + } +} + +function emitTable(table: XmlElement, state: WalkState): void { + const rows: string[] = [] + tableRows(table, rows) + if (rows.length > 0) { + state.blocks.push('', TABLE_OPEN, ...rows, TABLE_CLOSE, '') + } +} + +function emitNotes(notes: XmlElement, state: WalkState): void { + const body = collapseWhitespace(blockText(notes)) + if (body) state.blocks.push(NOTES_MARKER, body) +} + +function walkElement(element: XmlElement, state: WalkState, depth: number): void { + if (isSkipped(element)) return + + switch (element.name) { + case 'text:h': + emitParagraph(element, state, true) + break + case 'text:p': + emitParagraph(element, state, false) + break + case 'text:list': + emitList(element, state, depth) + break + case 'table:table': + emitTable(element, state) + break + case 'presentation:notes': + emitNotes(element, state) + break + case 'draw:page': + walkChildren(element, state, depth) + state.blocks.push('') + break + default: + walkChildren(element, state, depth) + } +} + +function walkChildren(container: XmlElement, state: WalkState, depth: number): void { + for (const child of childElements(container)) { + walkElement(child, state, depth) + } +} + +function contentBlocks(contentXml: string): string[] { + const document = parseXml(contentXml) + const body = findFirst(document, 'office:body') + if (!body) return [] + const state: WalkState = { blocks: [], pendingNotes: [] } + walkChildren(body, state, 0) + flushNotes(state) + return state.blocks +} + +function embeddedContentParts(zip: JSZip): string[] { + const parts: Array<{ index: number; path: string }> = [] + for (const path of Object.keys(zip.files)) { + const match = EMBEDDED_CONTENT_PART.exec(path) + if (match) parts.push({ index: Number(match[1]), path }) + } + return parts.sort((a, b) => a.index - b.index).map((part) => part.path) +} + +/** + * Extracts structured text from an OpenDocument text or presentation package. + * The caller must already have applied the archive size guard. + */ +export async function extractOpenDocumentText( + buffer: Buffer, + options: FileParseOptions = {} +): Promise { + const zip = await JSZip.loadAsync(buffer) + options.signal?.throwIfAborted() + + const sections: string[] = [] + for (const path of [CONTENT_PART, ...embeddedContentParts(zip)]) { + const entry = zip.file(path) + if (!entry) continue + const xml = await entry.async('string') + options.signal?.throwIfAborted() + const blocks = contentBlocks(xml) + if (blocks.length > 0) sections.push(joinBlocks(blocks), '') + } + + return joinBlocks(sections) +} diff --git a/apps/sim/lib/file-parsers/office-text.ts b/apps/sim/lib/file-parsers/office-text.ts new file mode 100644 index 00000000000..7f8283764bd --- /dev/null +++ b/apps/sim/lib/file-parsers/office-text.ts @@ -0,0 +1,66 @@ +import { DomUtils, parseDocument } from 'htmlparser2' + +/** + * Shared XML primitives for the OOXML and OpenDocument structured-text walkers. + * Both formats are ZIP archives of namespaced XML parts; htmlparser2 in XML mode + * keeps the `prefix:local` tag names verbatim, so the walkers match on them + * directly without a namespace-aware parser. + */ + +export type XmlDocument = ReturnType +export type XmlNode = XmlDocument['children'][number] +export type XmlElement = Extract + +/** Opens a structured table block in walker output. */ +export const TABLE_OPEN = '[Table]' + +/** Closes a structured table block in walker output. */ +export const TABLE_CLOSE = '[/Table]' + +/** Introduces presenter notes that follow a slide's body text. */ +export const NOTES_MARKER = '[Notes]' + +export function parseXml(xml: string): XmlDocument { + return parseDocument(xml, { xmlMode: true }) +} + +export function isXmlElement(node: XmlNode): node is XmlElement { + return node.type === 'tag' +} + +/** Direct element children in document order. */ +export function childElements(node: XmlDocument | XmlElement): XmlElement[] { + return node.children.filter(isXmlElement) +} + +/** First descendant (or the node itself) with the given tag name, in document order. */ +export function findFirst(node: XmlDocument | XmlElement, tagName: string): XmlElement | null { + const found = DomUtils.findOne((element) => element.name === tagName, node.children, true) + return found ?? null +} + +/** Every descendant with the given tag name, in document order. */ +export function findAll(node: XmlDocument | XmlElement, tagName: string): XmlElement[] { + return DomUtils.findAll((element) => element.name === tagName, node.children) +} + +/** Collapses internal whitespace so a cell or list item occupies a single line. */ +export function collapseWhitespace(text: string): string { + return text.replace(/\s+/g, ' ').trim() +} + +/** Renders one table row in the `| a | b |` shape the HTML walker produces. */ +export function formatTableRow(cells: string[]): string { + return `| ${cells.map(collapseWhitespace).join(' | ')} |` +} + +/** + * Joins emitted blocks with single newlines and squeezes runs of blank lines to + * one, so walker output reads like the HTML walker's. + */ +export function joinBlocks(blocks: string[]): string { + return blocks + .join('\n') + .replace(/\n{3,}/g, '\n\n') + .trim() +} diff --git a/apps/sim/lib/file-parsers/ooxml-presentation.test.ts b/apps/sim/lib/file-parsers/ooxml-presentation.test.ts new file mode 100644 index 00000000000..3b12c524b1f --- /dev/null +++ b/apps/sim/lib/file-parsers/ooxml-presentation.test.ts @@ -0,0 +1,142 @@ +/** + * @vitest-environment node + */ +import JSZip from 'jszip' +import { describe, expect, it } from 'vitest' +import { extractPresentationText } from '@/lib/file-parsers/ooxml-presentation' + +const NS = + 'xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"' + +function shape(text: string, placeholderType?: string): string { + const ph = + placeholderType === undefined ? '' : `` + return `${ph}${text}` +} + +function slideXml(spTree: string): string { + return `${spTree}` +} + +function notesXml(spTree: string): string { + return `${spTree}` +} + +interface DeckSlide { + index: number + spTree: string + notesSpTree?: string +} + +async function buildDeck(slides: DeckSlide[]): Promise { + const zip = new JSZip() + zip.file('[Content_Types].xml', '') + zip.file('ppt/media/image1.png', Buffer.from([0x89, 0x50, 0x4e, 0x47])) + for (const slide of slides) { + zip.file(`ppt/slides/slide${slide.index}.xml`, slideXml(slide.spTree)) + if (slide.notesSpTree !== undefined) { + zip.file( + `ppt/slides/_rels/slide${slide.index}.xml.rels`, + `` + ) + zip.file(`ppt/notesSlides/notesSlide${slide.index}.xml`, notesXml(slide.notesSpTree)) + } + } + return zip.generateAsync({ type: 'nodebuffer' }) as Promise +} + +describe('extractPresentationText', () => { + it('emits titles and body paragraphs while skipping layout placeholders', async () => { + const buffer = await buildDeck([ + { + index: 1, + spTree: + shape('Deck Title', 'ctrTitle') + + shape('First point', 'body') + + shape('7', 'sldNum') + + shape('2026-01-01', 'dt') + + shape('Confidential', 'ftr') + + shape('testdoc', 'hdr'), + }, + ]) + + const text = await extractPresentationText(buffer) + + expect(text).toBe('Deck Title\n\nFirst point') + }) + + it('renders a graphic-frame table as rows', async () => { + const cell = (value: string) => + `${value}` + const spTree = + shape('Roles', 'title') + + `${cell('Role')}${cell('Contact')}${cell('Owner')}${cell('ops@example.com')}` + + const text = await extractPresentationText(await buildDeck([{ index: 1, spTree }])) + + expect(text).toContain('[Table]\n| Role | Contact |\n| Owner | ops@example.com |\n[/Table]') + }) + + it('takes only the body placeholder from a notes page', async () => { + const buffer = await buildDeck([ + { + index: 1, + spTree: shape('Slide body', 'body'), + notesSpTree: + shape('testdoc', 'hdr') + + shape('Speaker reminder', 'body') + + shape('1', 'sldNum') + + ``, + }, + ]) + + const text = await extractPresentationText(buffer) + + expect(text).toBe('Slide body\n[Notes]\nSpeaker reminder') + }) + + it('omits the notes marker when the notes body is empty', async () => { + const buffer = await buildDeck([ + { index: 1, spTree: shape('Only slide', 'body'), notesSpTree: shape('3', 'sldNum') }, + ]) + + expect(await extractPresentationText(buffer)).toBe('Only slide') + }) + + it('orders slide10 after slide9 and separates slides with a blank line', async () => { + const buffer = await buildDeck([ + { index: 10, spTree: shape('Tenth') }, + { index: 9, spTree: shape('Ninth') }, + { index: 2, spTree: shape('Second') }, + ]) + + expect(await extractPresentationText(buffer)).toBe('Second\n\nNinth\n\nTenth') + }) + + it('recurses into group shapes in document order', async () => { + const spTree = `${shape('Grouped one')}${shape('Nested two')}${shape('After group')}` + + expect(await extractPresentationText(await buildDeck([{ index: 1, spTree }]))).toBe( + 'Grouped one\nNested two\nAfter group' + ) + }) + + it('joins runs within a paragraph and turns line breaks into newlines', async () => { + const spTree = `Hello world‹#›` + + expect(await extractPresentationText(await buildDeck([{ index: 1, spTree }]))).toBe( + 'Hello world\n‹#›' + ) + }) + + it('rejects when the signal is already aborted', async () => { + const controller = new AbortController() + controller.abort() + + await expect( + extractPresentationText(await buildDeck([{ index: 1, spTree: shape('x') }]), { + signal: controller.signal, + }) + ).rejects.toThrow() + }) +}) diff --git a/apps/sim/lib/file-parsers/ooxml-presentation.ts b/apps/sim/lib/file-parsers/ooxml-presentation.ts new file mode 100644 index 00000000000..a73e76e8247 --- /dev/null +++ b/apps/sim/lib/file-parsers/ooxml-presentation.ts @@ -0,0 +1,226 @@ +import JSZip from 'jszip' +import { + childElements, + findAll, + findFirst, + formatTableRow, + isXmlElement, + joinBlocks, + NOTES_MARKER, + parseXml, + TABLE_CLOSE, + TABLE_OPEN, + type XmlElement, +} from '@/lib/file-parsers/office-text' +import type { FileParseOptions } from '@/lib/file-parsers/types' + +/** + * Structured text extraction for PresentationML (`.pptx`/`.pptm`/`.potx`) that + * walks the slide XML directly instead of flattening every `` in the + * package. Each slide's shape tree is read in document order, recursing into + * group shapes; placeholder shapes that only carry layout boilerplate (slide + * number, date, header, footer) are skipped; tables are rendered row by row; + * and presenter notes contribute only their body placeholder, which is how + * python-pptx, MarkItDown, and Docling read them. + * + * Only the matched XML parts are inflated — media entries are never touched. + */ + +const SLIDE_PART = /^ppt\/slides\/slide(\d+)\.xml$/ +const NOTES_RELATIONSHIP_SUFFIX = '/notesSlide' + +/** Layout-chrome placeholders whose text is a field, not slide content. */ +const SKIPPED_PLACEHOLDER_TYPES = new Set(['sldNum', 'dt', 'ftr', 'hdr']) + +const TITLE_PLACEHOLDER_TYPES = new Set(['title', 'ctrTitle']) + +function placeholderType(shape: XmlElement): string | null { + const nonVisual = childElements(shape).find((child) => child.name === 'p:nvSpPr') + if (!nonVisual) return null + const placeholder = findFirst(nonVisual, 'p:ph') + if (!placeholder) return null + return placeholder.attribs.type ?? 'body' +} + +/** Concatenates a DrawingML paragraph's runs, turning `` into a newline. */ +function paragraphText(paragraph: XmlElement): string { + const pieces: string[] = [] + const visit = (element: XmlElement): void => { + if (element.name === 'a:br') { + pieces.push('\n') + return + } + if (element.name === 'a:t') { + for (const child of element.children) { + if (child.type === 'text') pieces.push(child.data) + } + return + } + for (const child of element.children) { + if (isXmlElement(child)) visit(child) + } + } + visit(paragraph) + return pieces + .join('') + .replace(/[ \t]+\n/g, '\n') + .trim() +} + +/** One line per `` in a text body, skipping empty paragraphs. */ +function textBodyLines(container: XmlElement): string[] { + const lines: string[] = [] + for (const paragraph of findAll(container, 'a:p')) { + const text = paragraphText(paragraph) + if (text) lines.push(text) + } + return lines +} + +function shapeBlocks(shape: XmlElement): string[] { + const type = placeholderType(shape) + if (type && SKIPPED_PLACEHOLDER_TYPES.has(type)) return [] + + const textBody = childElements(shape).find((child) => child.name === 'p:txBody') + if (!textBody) return [] + + const lines = textBodyLines(textBody) + if (lines.length === 0) return [] + + if (type && TITLE_PLACEHOLDER_TYPES.has(type)) { + return [lines.join(' '), ''] + } + return [lines.join('\n')] +} + +function tableBlocks(table: XmlElement): string[] { + const rows: string[] = [] + for (const row of findAll(table, 'a:tr')) { + const cells = childElements(row) + .filter((cell) => cell.name === 'a:tc') + .map((cell) => textBodyLines(cell).join(' ')) + if (cells.some((cell) => cell.length > 0)) rows.push(formatTableRow(cells)) + } + return rows.length > 0 ? [TABLE_OPEN, ...rows, TABLE_CLOSE] : [] +} + +function graphicFrameBlocks(frame: XmlElement): string[] { + const table = findFirst(frame, 'a:tbl') + return table ? tableBlocks(table) : [] +} + +/** Walks a shape tree (or group) in document order. */ +function shapeTreeBlocks(tree: XmlElement): string[] { + const blocks: string[] = [] + for (const child of childElements(tree)) { + switch (child.name) { + case 'p:sp': + blocks.push(...shapeBlocks(child)) + break + case 'p:grpSp': + blocks.push(...shapeTreeBlocks(child)) + break + case 'p:graphicFrame': + blocks.push(...graphicFrameBlocks(child)) + break + default: + break + } + } + return blocks +} + +function slideBodyBlocks(slideXml: string): string[] { + const document = parseXml(slideXml) + const tree = findFirst(document, 'p:spTree') + return tree ? shapeTreeBlocks(tree) : [] +} + +/** Only the `body` placeholder of a notes page carries the presenter's notes. */ +function notesBodyLines(notesXml: string): string[] { + const document = parseXml(notesXml) + const tree = findFirst(document, 'p:spTree') + if (!tree) return [] + + const lines: string[] = [] + for (const shape of findAll(tree, 'p:sp')) { + if (placeholderType(shape) !== 'body') continue + const textBody = childElements(shape).find((child) => child.name === 'p:txBody') + if (textBody) lines.push(...textBodyLines(textBody)) + } + return lines +} + +/** Resolves a relationship target relative to `ppt/slides/`. */ +function resolveSlideRelativePath(target: string): string { + const segments = ['ppt', 'slides'] + for (const part of target.split('/')) { + if (part === '..') { + segments.pop() + } else if (part && part !== '.') { + segments.push(part) + } + } + return segments.join('/') +} + +function notesPartPath(relsXml: string): string | null { + const document = parseXml(relsXml) + for (const relationship of findAll(document, 'Relationship')) { + const type = relationship.attribs.Type ?? '' + const target = relationship.attribs.Target + if (target && type.endsWith(NOTES_RELATIONSHIP_SUFFIX)) { + return resolveSlideRelativePath(target) + } + } + return null +} + +function slidePartsInOrder(zip: JSZip): Array<{ index: number; path: string }> { + const slides: Array<{ index: number; path: string }> = [] + for (const path of Object.keys(zip.files)) { + const match = SLIDE_PART.exec(path) + if (match) slides.push({ index: Number(match[1]), path }) + } + return slides.sort((a, b) => a.index - b.index) +} + +async function readPart(zip: JSZip, path: string): Promise { + const entry = zip.file(path) + return entry ? entry.async('string') : null +} + +/** + * Extracts structured text from a PresentationML package. Slides are separated + * by a blank line; presenter notes follow their slide under a `[Notes]` marker. + * The caller must already have applied the archive size guard. + */ +export async function extractPresentationText( + buffer: Buffer, + options: FileParseOptions = {} +): Promise { + const zip = await JSZip.loadAsync(buffer) + options.signal?.throwIfAborted() + + const slideBlocks: string[] = [] + for (const slide of slidePartsInOrder(zip)) { + const slideXml = await readPart(zip, slide.path) + options.signal?.throwIfAborted() + if (slideXml === null) continue + + const blocks = slideBodyBlocks(slideXml) + + const relsXml = await readPart(zip, `ppt/slides/_rels/slide${slide.index}.xml.rels`) + const notesPath = relsXml ? notesPartPath(relsXml) : null + const notesXml = notesPath ? await readPart(zip, notesPath) : null + options.signal?.throwIfAborted() + if (notesXml) { + const notes = notesBodyLines(notesXml) + if (notes.length > 0) blocks.push(NOTES_MARKER, ...notes) + } + + if (blocks.length > 0) slideBlocks.push(joinBlocks(blocks), '') + } + + return joinBlocks(slideBlocks) +} diff --git a/apps/sim/lib/file-parsers/opendocument-parser.ts b/apps/sim/lib/file-parsers/opendocument-parser.ts index 1a33e8a72d7..283cee61415 100644 --- a/apps/sim/lib/file-parsers/opendocument-parser.ts +++ b/apps/sim/lib/file-parsers/opendocument-parser.ts @@ -1,7 +1,9 @@ import { existsSync } from 'fs' import { readFile } from 'fs/promises' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { FileParserError, isEncryptedOfficeParserError } from '@/lib/file-parsers/errors' +import { extractOpenDocumentText } from '@/lib/file-parsers/odf-text' import { parseOfficeText } from '@/lib/file-parsers/officeparser-module' import type { FileParseOptions, FileParseResult, FileParser } from '@/lib/file-parsers/types' import { sanitizeTextForUTF8 } from '@/lib/file-parsers/utils' @@ -14,11 +16,13 @@ const logger = createLogger('OpenDocumentParser') * the formats LibreOffice, OpenOffice, and Google Docs exports produce, which * turn up in document libraries alongside their Microsoft equivalents. * - * `officeparser` handles the OpenDocument container natively. Unlike the legacy - * `.doc`/`.ppt` parsers this deliberately has **no** best-effort fallback: an - * OpenDocument file is a ZIP whose text lives in `content.xml`, so a failure here - * means the archive is unreadable or has no text, and scraping the raw bytes would - * only produce XML markup. Throwing lets the caller record a real failure. + * The primary path walks `content.xml` directly so tables keep their rows, + * lists keep their markers, and reviewer annotations and tracked deletions are + * dropped instead of being spliced into the body. `officeparser` remains the + * fallback for an archive the walker cannot read, and is what classifies + * encrypted packages. Unlike the legacy `.doc`/`.ppt` parsers this deliberately + * has **no** best-effort byte scrape: a failure means the archive is unreadable + * or has no text, and throwing lets the caller record a real failure. * * Spreadsheets (`.ods`) go to `XlsxParser` instead, which SheetJS reads natively * and renders with per-sheet structure rather than one flat text run. @@ -45,25 +49,35 @@ export class OpenDocumentParser implements FileParser { /** * The container is a ZIP, so the decompression-bomb guard applies exactly as - * it does for OOXML — and it must run before officeparser inflates anything. + * it does for OOXML — and it must run before anything inflates an entry. */ assertOoxmlArchiveWithinLimits(buffer) - let extracted: string + let extracted = '' + let extractionMethod = 'odf-walker' try { - const result = await parseOfficeText(buffer, options) - extracted = typeof result === 'string' ? result : '' - } catch (error) { + extracted = await extractOpenDocumentText(buffer, options) + } catch (walkerError) { options.signal?.throwIfAborted() - logger.error('OpenDocument parsing failed', { error: (error as Error).message }) - if (isEncryptedOfficeParserError(error)) { - throw new FileParserError( - 'encrypted_file', - 'This OpenDocument file is encrypted or password-protected', - error - ) + logger.warn('OpenDocument walker failed, trying officeparser', { + error: getErrorMessage(walkerError), + }) + extractionMethod = 'officeparser' + try { + const result = await parseOfficeText(buffer, options) + extracted = typeof result === 'string' ? result : '' + } catch (error) { + options.signal?.throwIfAborted() + logger.error('OpenDocument parsing failed', { error: getErrorMessage(error) }) + if (isEncryptedOfficeParserError(error)) { + throw new FileParserError( + 'encrypted_file', + 'This OpenDocument file is encrypted or password-protected', + error + ) + } + throw new FileParserError('invalid_format', 'Failed to parse OpenDocument file', error) } - throw new FileParserError('invalid_format', 'Failed to parse OpenDocument file', error) } const content = sanitizeTextForUTF8(extracted.trim()) @@ -78,7 +92,7 @@ export class OpenDocumentParser implements FileParser { content, metadata: { characterCount: content.length, - extractionMethod: 'officeparser', + extractionMethod, }, } } diff --git a/apps/sim/lib/file-parsers/parser-formats.test.ts b/apps/sim/lib/file-parsers/parser-formats.test.ts index 0864cbc736d..666d3f48aec 100644 --- a/apps/sim/lib/file-parsers/parser-formats.test.ts +++ b/apps/sim/lib/file-parsers/parser-formats.test.ts @@ -2,11 +2,11 @@ * @vitest-environment node * * Pins the `degraded` metadata contract to the parsers' real behaviour, using - * genuine OOXML archives rather than mocks. `DocParser` and `PptxParser` never - * throw by design — on a legacy OLE binary or a deck with no text they return a - * placeholder sentence or scraped ZIP internals. Automated callers rely on - * `degraded` to tell that apart from a real extraction, so if a parser stops - * setting the flag these tests are what catches it. + * genuine OOXML archives rather than mocks. `DocParser` never throws by design — + * on a legacy OLE binary it returns a placeholder sentence or scraped bytes, and + * automated callers rely on `degraded` to tell that apart from a real + * extraction. `PptxParser` instead rejects with a typed error for a legacy + * binary or a text-free deck, so nothing scraped ever reaches the index. */ import JSZip from 'jszip' import { describe, expect, it } from 'vitest' @@ -129,27 +129,33 @@ describe('PptxParser degraded reporting', () => { const result = await new PptxParser().parseBuffer(buffer) expect(result.content).toContain('Quarterly Market Data Review') + expect(result.metadata?.extractionMethod).toBe('ooxml-walker') expect(result.metadata?.degraded).toBeFalsy() }) /** - * A deck of images has no text for officeparser to return, and the fallback - * then scrapes the archive — the observed output begins `[Content_Types].xml`. - * Indexing that would put ZIP internals into the vector store. + * A deck of images has no slide text. The old byte-scrape fallback returned + * the archive's own file names (`[Content_Types].xml`) as content; a typed + * rejection keeps ZIP internals out of the vector store. */ - it('flags a deck with no extractable text as degraded', async () => { + it('reports a deck with no extractable text as a typed failure', async () => { const buffer = await buildPptx('') - const result = await new PptxParser().parseBuffer(buffer) + const error = await new PptxParser().parseBuffer(buffer).catch((caught: unknown) => caught) - expect(result.metadata?.degraded).toBe(true) + expect(error).toBeInstanceOf(FileParserError) + expect(error).toMatchObject({ code: 'no_extractable_text' }) }) - it('flags a legacy OLE .ppt binary as degraded', async () => { - const result = await new PptxParser().parseBuffer(buildLegacyOleBinary()) + /** No pure-JS extractor reads PowerPoint 97 binaries, so the parser says so. */ + it('rejects a legacy OLE .ppt binary as unsupported', async () => { + const error = await new PptxParser() + .parseBuffer(buildLegacyOleBinary()) + .catch((caught: unknown) => caught) - expect(result.metadata?.degraded).toBe(true) - expect(result.content).toContain('Unable to extract text') + expect(error).toBeInstanceOf(FileParserError) + expect(error).toMatchObject({ code: 'unsupported_type' }) + expect((error as FileParserError).message).toContain('.pptx') }) }) @@ -182,6 +188,8 @@ describe('DocxParser', () => { const result = await new DocxParser().parseBuffer(buffer) expect(result.content).toContain('Market Data SOP body text') + expect(result.metadata?.extractionMethod).toBe('mammoth-html') + expect(result.metadata?.html).toBeUndefined() expect(result.metadata?.degraded).toBeFalsy() }) @@ -274,6 +282,7 @@ describe('OpenDocumentParser', () => { const result = await new OpenDocumentParser().parseBuffer(buffer) expect(result.content).toContain('OpenDocument paragraph') + expect(result.metadata?.extractionMethod).toBe('odf-walker') expect(result.metadata?.degraded).toBeFalsy() }) diff --git a/apps/sim/lib/file-parsers/pptx-parser.test.ts b/apps/sim/lib/file-parsers/pptx-parser.test.ts index 93cca1433be..242135b7bca 100644 --- a/apps/sim/lib/file-parsers/pptx-parser.test.ts +++ b/apps/sim/lib/file-parsers/pptx-parser.test.ts @@ -1,7 +1,7 @@ /** * @vitest-environment node */ -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockParseOfficeText } = vi.hoisted(() => ({ mockParseOfficeText: vi.fn(), @@ -15,6 +15,10 @@ import type { FileParserError } from '@/lib/file-parsers/errors' import { PptxParser } from '@/lib/file-parsers/pptx-parser' describe('PptxParser', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + it('classifies encrypted legacy presentations before degraded extraction', async () => { const libraryError = new Error('File is password-protected') mockParseOfficeText.mockRejectedValueOnce(libraryError) @@ -28,18 +32,33 @@ describe('PptxParser', () => { }) }) - it('preserves cancellation instead of degrading to scraped bytes', async () => { + it('preserves cancellation instead of classifying a legacy binary', async () => { const controller = new AbortController() const abortError = new DOMException('The operation was aborted', 'AbortError') mockParseOfficeText.mockImplementationOnce(async () => { controller.abort(abortError) throw abortError }) + const legacyOleBuffer = Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]) await expect( - new PptxParser().parseBuffer(Buffer.from('legacy presentation'), { - signal: controller.signal, - }) + new PptxParser().parseBuffer(legacyOleBuffer, { signal: controller.signal }) ).rejects.toBe(abortError) }) + + it('rejects a legacy OLE .ppt as unsupported rather than scraping its bytes', async () => { + mockParseOfficeText.mockRejectedValueOnce(new Error('Unsupported file type')) + const legacyOleBuffer = Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]) + + await expect( + new PptxParser().parseBuffer(legacyOleBuffer) + ).rejects.toMatchObject({ code: 'unsupported_type' }) + }) + + it('rejects bytes that are neither a package nor an OLE container', async () => { + await expect( + new PptxParser().parseBuffer(Buffer.from('random presentation bytes')) + ).rejects.toMatchObject({ code: 'invalid_format' }) + expect(mockParseOfficeText).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/lib/file-parsers/pptx-parser.ts b/apps/sim/lib/file-parsers/pptx-parser.ts index db7c50aa37d..996255b4283 100644 --- a/apps/sim/lib/file-parsers/pptx-parser.ts +++ b/apps/sim/lib/file-parsers/pptx-parser.ts @@ -1,14 +1,36 @@ import { existsSync } from 'fs' import { readFile } from 'fs/promises' import { createLogger } from '@sim/logger' -import { FileParserError, isEncryptedOfficeParserError } from '@/lib/file-parsers/errors' +import { + FileParserError, + isEncryptedOfficeParserError, + isFileParserError, +} from '@/lib/file-parsers/errors' import { parseOfficeText } from '@/lib/file-parsers/officeparser-module' +import { extractPresentationText } from '@/lib/file-parsers/ooxml-presentation' import type { FileParseOptions, FileParseResult, FileParser } from '@/lib/file-parsers/types' import { sanitizeTextForUTF8 } from '@/lib/file-parsers/utils' -import { assertOoxmlArchiveWithinLimits } from '@/lib/file-parsers/zip-guard' +import { assertOoxmlArchiveWithinLimits, isZipShaped } from '@/lib/file-parsers/zip-guard' const logger = createLogger('PptxParser') +const OLE_SIGNATURE = Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]) + +/** + * An OLE2 compound file: either a legacy PowerPoint 97 `.ppt` or an OOXML + * `EncryptedPackage`, which wraps the encrypted ZIP in the same container. + */ +function isOleShaped(buffer: Buffer): boolean { + return buffer.length >= OLE_SIGNATURE.length && buffer.subarray(0, 8).equals(OLE_SIGNATURE) +} + +/** + * Extracts presentation text. PresentationML packages go through the slide XML + * walker, which keeps table rows together and skips layout placeholders. OLE + * containers are handed to officeparser only to classify encryption — legacy + * `.ppt` has no pure-JS extractor, so it is rejected as unsupported rather than + * scraped for printable bytes. + */ export class PptxParser implements FileParser { async parseFile(filePath: string, options: FileParseOptions = {}): Promise { if (!filePath) { @@ -35,78 +57,82 @@ export class PptxParser implements FileParser { assertOoxmlArchiveWithinLimits(buffer) - try { - const result = await parseOfficeText(buffer, options) - - if (!result || typeof result !== 'string') { - return this.fallbackExtraction(buffer) - } + if (isZipShaped(buffer)) { + return this.parsePackage(buffer, options) + } - const content = sanitizeTextForUTF8(result.trim()) + if (isOleShaped(buffer)) { + return this.parseOleContainer(buffer, options) + } - logger.info('PowerPoint parsing completed successfully with officeparser') + throw new FileParserError( + 'invalid_format', + 'The file is neither a PowerPoint package nor a legacy PowerPoint binary' + ) + } - return { - content: content, - metadata: { - characterCount: content.length, - extractionMethod: 'officeparser', - }, - } - } catch (extractError) { + private async parsePackage(buffer: Buffer, options: FileParseOptions): Promise { + let extracted: string + try { + extracted = await extractPresentationText(buffer, options) + } catch (error) { options.signal?.throwIfAborted() - if (isEncryptedOfficeParserError(extractError)) { - throw new FileParserError( - 'encrypted_file', - 'This presentation is encrypted or password-protected', - extractError - ) - } - - const isZipFile = buffer.length >= 2 && buffer[0] === 0x50 && buffer[1] === 0x4b - if (!isZipFile) { - logger.warn('officeparser failed for legacy PowerPoint, using fallback:', extractError) - return this.fallbackExtraction(buffer) - } - + if (isFileParserError(error)) throw error throw new FileParserError( 'invalid_format', 'The PowerPoint container could not be read', - extractError + error ) } - } - - private fallbackExtraction(buffer: Buffer): FileParseResult { - logger.info('Using fallback text extraction for PowerPoint file') - - const text = buffer.toString('utf8', 0, Math.min(buffer.length, 200000)) - const readableText = text - .match(/[\x20-\x7E\s]{4,}/g) - ?.filter( - (chunk) => - chunk.trim().length > 10 && - /[a-zA-Z]/.test(chunk) && - !/^[\x00-\x1F]*$/.test(chunk) && - !/^[^\w\s]*$/.test(chunk) + const content = sanitizeTextForUTF8(extracted.trim()) + if (!content) { + throw new FileParserError( + 'no_extractable_text', + 'No text could be extracted from this presentation' ) - .join(' ') - .replace(/\s+/g, ' ') - .trim() - - const content = readableText - ? sanitizeTextForUTF8(readableText) - : 'Unable to extract text from PowerPoint file. Please ensure the file contains readable text content.' + } return { content, metadata: { - extractionMethod: 'fallback', - degraded: true, characterCount: content.length, - warning: 'Basic text extraction used', + extractionMethod: 'ooxml-walker', }, } } + + private async parseOleContainer( + buffer: Buffer, + options: FileParseOptions + ): Promise { + try { + const result = await parseOfficeText(buffer, options) + const content = typeof result === 'string' ? sanitizeTextForUTF8(result.trim()) : '' + if (content) { + return { + content, + metadata: { + characterCount: content.length, + extractionMethod: 'officeparser', + }, + } + } + } catch (error) { + options.signal?.throwIfAborted() + if (isEncryptedOfficeParserError(error)) { + throw new FileParserError( + 'encrypted_file', + 'This presentation is encrypted or password-protected', + error + ) + } + if (isFileParserError(error) && error.code === 'runtime_failure') throw error + } + + throw new FileParserError( + 'unsupported_type', + 'Legacy .ppt presentations are not supported. Save the file as .pptx and retry.' + ) + } } diff --git a/apps/sim/lib/file-parsers/types.ts b/apps/sim/lib/file-parsers/types.ts index 834f2fc4632..9246f80ea66 100644 --- a/apps/sim/lib/file-parsers/types.ts +++ b/apps/sim/lib/file-parsers/types.ts @@ -16,7 +16,6 @@ export interface FileParseMetadata { extractionMethod?: string warning?: string messages?: unknown[] - html?: string type?: string headers?: string[] totalRows?: number From 16fd0b0ef495d91bfbd13f3e9e76a929627a5e0b Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 9 Sep 2026 18:48:24 -0700 Subject: [PATCH 04/21] fix(parsers): decode text by encoding, sniff bytes before routing, read legacy .doc Text parsers decoded every buffer as UTF-8 and then stripped U+FFFD, so a Latin-1 or Windows-1252 file silently lost every accented character, a UTF-8 BOM leaked into content and broke JSON.parse, and UTF-16 only worked for ASCII. `decodeTextBuffer` (BOM > strict UTF-8 with a guarded truncated-tail retry > Windows-1252) now backs txt/md/csv/json/jsonl/yaml, the .doc plain-text fallback and the connectors' text decode, and records `encoding`/`warning` in metadata. `parseBuffer` routed on the caller-supplied extension alone. `sniff.ts` now identifies the bytes (PDF, OLE2, ZIP central-directory part names, ODF mimetype, UTF-16 layout, HTML head) and reconciles them with the extension's family: a sniffed kind with its own parser overrides the route and records `detectedType`; binary/unknown bytes under a mismatched family are a typed `invalid_format` instead of mojibake or placeholder prose. Legacy OLE .doc goes through word-extractor (body, headers, footers, footnotes, endnotes; Word 6/95 magic maps to `unsupported_type`); the byte scrape that returned ZIP part names as degraded prose is deleted. Legacy .ppt is dropped from the registry, upload and connector allowlists and Chat's parseable set so it is refused up front. Chat's file reader and the internal file tool now treat `degraded` output as a parse failure. pdf.js `InvalidPDFException`/`FormatError`/`PasswordException` are mapped to typed parser errors at the single `openPdfDocument` choke point, and the zip guard's `ArchiveIntegrityError` surfaces from `parseBuffer` as a typed `invalid_format`, so neither classifies as transient and retries forever. Co-Authored-By: Claude Fable 5.1 --- .../connectors/azure-devops/azure-devops.ts | 3 +- apps/sim/connectors/box/box.ts | 5 +- apps/sim/connectors/dropbox/dropbox.ts | 3 +- apps/sim/connectors/github/github.ts | 3 +- apps/sim/connectors/gitlab/gitlab.ts | 3 +- apps/sim/connectors/utils.test.ts | 38 ++- apps/sim/connectors/utils.ts | 10 +- apps/sim/lib/copilot/vfs/file-reader.test.ts | 50 ++- apps/sim/lib/copilot/vfs/file-reader.ts | 6 +- apps/sim/lib/file-parsers/csv-parser.ts | 37 ++- apps/sim/lib/file-parsers/doc-parser.test.ts | 139 +++++++- apps/sim/lib/file-parsers/doc-parser.ts | 234 ++++++++----- apps/sim/lib/file-parsers/errors.test.ts | 12 + apps/sim/lib/file-parsers/errors.ts | 14 + apps/sim/lib/file-parsers/index.test.ts | 19 +- apps/sim/lib/file-parsers/index.ts | 59 +++- apps/sim/lib/file-parsers/json-parser.test.ts | 26 ++ apps/sim/lib/file-parsers/json-parser.ts | 35 +- apps/sim/lib/file-parsers/md-parser.ts | 8 +- .../lib/file-parsers/parser-formats.test.ts | 15 +- apps/sim/lib/file-parsers/pdf-parser.test.ts | 3 +- .../sim/lib/file-parsers/pdfjs-server.test.ts | 23 ++ apps/sim/lib/file-parsers/pdfjs-server.ts | 27 +- apps/sim/lib/file-parsers/registry.test.ts | 5 +- apps/sim/lib/file-parsers/sniff.test.ts | 304 +++++++++++++++++ apps/sim/lib/file-parsers/sniff.ts | 307 ++++++++++++++++++ apps/sim/lib/file-parsers/txt-parser.ts | 8 +- apps/sim/lib/file-parsers/types.ts | 9 +- apps/sim/lib/file-parsers/utils.test.ts | 87 ++++- apps/sim/lib/file-parsers/utils.ts | 185 +++++++++++ apps/sim/lib/file-parsers/yaml-parser.test.ts | 12 + apps/sim/lib/file-parsers/yaml-parser.ts | 25 +- apps/sim/lib/internal/file/parser.test.ts | 29 ++ apps/sim/lib/internal/file/parser.ts | 23 ++ apps/sim/lib/uploads/utils/validation.ts | 2 - apps/sim/package.json | 7 +- apps/sim/types/word-extractor.d.ts | 31 ++ bun.lock | 11 + 38 files changed, 1620 insertions(+), 197 deletions(-) create mode 100644 apps/sim/lib/file-parsers/sniff.test.ts create mode 100644 apps/sim/lib/file-parsers/sniff.ts create mode 100644 apps/sim/types/word-extractor.d.ts diff --git a/apps/sim/connectors/azure-devops/azure-devops.ts b/apps/sim/connectors/azure-devops/azure-devops.ts index 54e9719ea7b..1277e9fff9c 100644 --- a/apps/sim/connectors/azure-devops/azure-devops.ts +++ b/apps/sim/connectors/azure-devops/azure-devops.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' +import { decodeTextBuffer } from '@/lib/file-parsers/utils' import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils' import { azureDevopsConnectorMeta } from '@/connectors/azure-devops/meta' import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types' @@ -1182,7 +1183,7 @@ async function getFileDocument( return null } - const content = buffer.toString('utf8') + const content = decodeTextBuffer(buffer).text if (!content.trim()) return null const title = path.split('/').filter(Boolean).pop() || path diff --git a/apps/sim/connectors/box/box.ts b/apps/sim/connectors/box/box.ts index 3f36323905f..0cf06310a5c 100644 --- a/apps/sim/connectors/box/box.ts +++ b/apps/sim/connectors/box/box.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { sleep } from '@sim/utils/helpers' +import { decodeTextBuffer } from '@/lib/file-parsers/utils' import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils' import { boxConnectorMeta } from '@/connectors/box/meta' import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types' @@ -319,7 +320,7 @@ async function fetchPlainTextContent( extension: string ): Promise { const buffer = await downloadWithinLimit(`${BOX_API_BASE}/files/${fileId}/content`, accessToken) - const text = buffer.toString('utf8') + const { text } = decodeTextBuffer(buffer) return HTML_EXTENSIONS.has(extension) ? htmlToPlainText(text) : text } @@ -347,7 +348,7 @@ async function fetchExtractedText( urlTemplate.replace('{+asset_path}', ''), accessToken ) - return buffer.toString('utf8') + return decodeTextBuffer(buffer).text } if (state === 'error' || !infoUrl) return null if (attempt === REPRESENTATION_POLL_ATTEMPTS) break diff --git a/apps/sim/connectors/dropbox/dropbox.ts b/apps/sim/connectors/dropbox/dropbox.ts index fbeb6c5d4a7..132e6deb4ea 100644 --- a/apps/sim/connectors/dropbox/dropbox.ts +++ b/apps/sim/connectors/dropbox/dropbox.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' +import { decodeTextBuffer } from '@/lib/file-parsers/utils' import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils' import { dropboxConnectorMeta } from '@/connectors/dropbox/meta' import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types' @@ -147,7 +148,7 @@ async function downloadFileContent( throw new ConnectorFileTooLargeError(MAX_FILE_SIZE) } - const text = buffer.toString('utf8') + const { text } = decodeTextBuffer(buffer) return isHtml ? htmlToPlainText(text) : text } diff --git a/apps/sim/connectors/github/github.ts b/apps/sim/connectors/github/github.ts index 7a2c71d0218..d1f71f951ae 100644 --- a/apps/sim/connectors/github/github.ts +++ b/apps/sim/connectors/github/github.ts @@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' import { z } from 'zod' import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits' +import { decodeTextBuffer } from '@/lib/file-parsers/utils' import { type RetryOptions, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils' import { parseGitHubRepository } from '@/lib/oauth/github-repository' import { githubConnectorMeta } from '@/connectors/github/meta' @@ -296,7 +297,7 @@ async function fetchBlobContent( throw new ConnectorFileTooLargeError(maxBytes) } if (isBinaryBuffer(buffer)) return null - return buffer.toString('utf8') + return decodeTextBuffer(buffer).text } /** Resolves links within one snapshot; Contents can truncate dereferenced targets at 1 MiB. */ diff --git a/apps/sim/connectors/gitlab/gitlab.ts b/apps/sim/connectors/gitlab/gitlab.ts index cbe65dda0d4..ed1ceb1372c 100644 --- a/apps/sim/connectors/gitlab/gitlab.ts +++ b/apps/sim/connectors/gitlab/gitlab.ts @@ -3,6 +3,7 @@ import { getErrorMessage, toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { isPlainRecord } from '@sim/utils/object' import type { SecureFetchResponse } from '@/lib/core/security/input-validation.server' +import { decodeTextBuffer } from '@/lib/file-parsers/utils' import { secureFetchWithRetry } from '@/lib/knowledge/documents/secure-fetch.server' import { VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils' import { gitlabConnectorMeta } from '@/connectors/gitlab/meta' @@ -435,7 +436,7 @@ function fileToDocument( return skipped(sizeLimitSkipReason(MAX_FILE_SIZE), buffer.byteLength) } - const content = buffer.toString('utf8') + const content = decodeTextBuffer(buffer).text const body = composeBody(title, content) if (!body.trim()) return null diff --git a/apps/sim/connectors/utils.test.ts b/apps/sim/connectors/utils.test.ts index 5090157e222..e28549c7262 100644 --- a/apps/sim/connectors/utils.test.ts +++ b/apps/sim/connectors/utils.test.ts @@ -1498,19 +1498,15 @@ describe('htmlToPlainText entity decoding', () => { describe('isIndexableConnectorFile', () => { it('accepts the Office and PDF formats the knowledge base can parse', () => { - for (const name of [ - 'sop.pdf', - 'sop.doc', - 'sop.docx', - 'sheet.xls', - 'sheet.xlsx', - 'deck.ppt', - 'deck.pptx', - ]) { + for (const name of ['sop.pdf', 'sop.doc', 'sop.docx', 'sheet.xls', 'sheet.xlsx', 'deck.pptx']) { expect(isIndexableConnectorFile(name)).toBe(true) } }) + it('refuses legacy .ppt up front because no parser reads it', () => { + expect(isIndexableConnectorFile('deck.ppt')).toBe(false) + }) + it('still accepts the plain-text formats connectors already synced', () => { for (const name of ['a.txt', 'a.md', 'a.html', 'a.htm', 'a.csv', 'a.log', 'a.tsv', 'a.rst']) { expect(isIndexableConnectorFile(name)).toBe(true) @@ -1578,6 +1574,30 @@ describe('extractConnectorText', () => { it('leaves whitespace-only content alone for the caller to reject', () => { expect(extractConnectorText(Buffer.from(' '), 'blank.txt')).toBe(' ') }) + + it('decodes a Latin-1 file as Windows-1252 instead of indexing mojibake', () => { + expect(extractConnectorText(Buffer.from('Caf\xe9 \xa3 42', 'latin1'), 'notes.txt')).toBe( + 'Café £ 42' + ) + }) + + it('strips a UTF-8 BOM', () => { + expect( + extractConnectorText( + Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from('a,b')]), + 'data.csv' + ) + ).toBe('a,b') + }) + + it('decodes UTF-16 with a BOM', () => { + expect( + extractConnectorText( + Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from('

      Hällo

      ', 'utf16le')]), + 'page.html' + ) + ).toBe('Hällo') + }) }) describe('pipelineParsedMimeType', () => { diff --git a/apps/sim/connectors/utils.ts b/apps/sim/connectors/utils.ts index 3a0e4d78de9..88aee7614f7 100644 --- a/apps/sim/connectors/utils.ts +++ b/apps/sim/connectors/utils.ts @@ -4,6 +4,7 @@ import { isPayloadSizeLimitError, readResponseToBufferWithLimit, } from '@/lib/core/utils/stream-limits' +import { decodeTextBuffer } from '@/lib/file-parsers/utils' import { MAX_FILE_SIZE as KB_DOCUMENT_MAX_BYTES } from '@/lib/uploads/utils/validation' import type { ExternalDocument } from '@/connectors/types' @@ -427,7 +428,6 @@ export const PIPELINE_PARSED_MIME_TYPES: ReadonlyMap = new Map([ ['xlsm', 'application/vnd.ms-excel.sheet.macroEnabled.12'], ['xlsb', 'application/vnd.ms-excel.sheet.binary.macroEnabled.12'], ['xltx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.template'], - ['ppt', 'application/vnd.ms-powerpoint'], ['pptx', 'application/vnd.openxmlformats-officedocument.presentationml.presentation'], ['pptm', 'application/vnd.ms-powerpoint.presentation.macroEnabled.12'], ['potx', 'application/vnd.openxmlformats-officedocument.presentationml.template'], @@ -497,16 +497,18 @@ export function pipelineParsedMimeType(fileName: string): string | undefined { * * Only for formats that are already text — anything the shared parsers handle is * delivered to them verbatim instead, via {@link pipelineParsedMimeType}. HTML is - * additionally reduced to plain text; everything else is a UTF-8 decode. + * additionally reduced to plain text; everything else is decoded with the shared + * BOM/UTF-8/Windows-1252 detection so a Latin-1 file never indexes as mojibake. */ export function extractConnectorText(buffer: Buffer, fileName: string): string { const extension = connectorFileExtension(fileName) + const { text } = decodeTextBuffer(buffer) if (extension === 'html' || extension === 'htm') { - return htmlToPlainText(buffer.toString('utf8')) + return htmlToPlainText(text) } - return buffer.toString('utf8') + return text } /** diff --git a/apps/sim/lib/copilot/vfs/file-reader.test.ts b/apps/sim/lib/copilot/vfs/file-reader.test.ts index 531240786b6..8d48df797bb 100644 --- a/apps/sim/lib/copilot/vfs/file-reader.test.ts +++ b/apps/sim/lib/copilot/vfs/file-reader.test.ts @@ -6,13 +6,17 @@ import { randomFillSync } from 'node:crypto' import { crc32 } from 'node:zlib' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { fetchWorkspaceFileBuffer } = vi.hoisted(() => ({ +const { fetchWorkspaceFileBuffer, mockParseBuffer } = vi.hoisted(() => ({ fetchWorkspaceFileBuffer: vi.fn(), + mockParseBuffer: vi.fn(), })) vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ fetchWorkspaceFileBuffer, })) +vi.mock('@/lib/file-parsers', () => ({ + parseBuffer: mockParseBuffer, +})) import { MAX_IMAGE_READ_BYTES, @@ -21,6 +25,7 @@ import { MAX_TEXT_READ_BYTES, readFileRecord, } from '@/lib/copilot/vfs/file-reader' +import { readPlaceholder } from '@/lib/copilot/vfs/read-placeholders' import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { MAX_TRANSCODE_INPUT_BYTES } from '@/lib/uploads/server/heic' @@ -201,3 +206,46 @@ describe('readFileRecord', () => { SHARP_TEST_TIMEOUT_MS ) }) + +describe('readFileRecord parseable documents', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + function documentRecord(name: string, type: string, size: number) { + return { ...imageRecord(name, size, type), id: 'wf_doc' } + } + + it('returns the parsed text of a document', async () => { + fetchWorkspaceFileBuffer.mockResolvedValue(Buffer.from('bytes')) + mockParseBuffer.mockResolvedValue({ + content: 'Quarterly review\nSecond line', + metadata: { extractionMethod: 'word-extractor' }, + }) + + const result = await readFileRecord(documentRecord('review.doc', 'application/msword', 5)) + + expect(result).toEqual({ content: 'Quarterly review\nSecond line', totalLines: 2 }) + }) + + /** + * A parser that could only scrape bytes flags the result `degraded`; that must + * reach the model as the could-not-parse placeholder, never as file content. + */ + it('reports degraded parser output as could-not-parse instead of handing it to the model', async () => { + fetchWorkspaceFileBuffer.mockResolvedValue(Buffer.from('bytes')) + mockParseBuffer.mockResolvedValue({ + content: '[Content_Types].xml _rels/.rels theme/theme/themeManager.xml', + metadata: { degraded: true, warning: 'Basic text extraction used' }, + }) + + const result = await readFileRecord( + documentRecord('deck.pptx', 'application/vnd.ms-powerpoint', 5) + ) + + expect(result).toEqual( + readPlaceholder.couldNotParse('deck.pptx', 'application/vnd.ms-powerpoint', 5) + ) + expect(result?.content).not.toContain('[Content_Types].xml') + }) +}) diff --git a/apps/sim/lib/copilot/vfs/file-reader.ts b/apps/sim/lib/copilot/vfs/file-reader.ts index 75bb5c5bdf0..ae00204fa8f 100644 --- a/apps/sim/lib/copilot/vfs/file-reader.ts +++ b/apps/sim/lib/copilot/vfs/file-reader.ts @@ -104,7 +104,7 @@ const TEXT_TYPES = new Set([ 'application/javascript', ]) -const PARSEABLE_EXTENSIONS = new Set(['pdf', 'docx', 'doc', 'xlsx', 'xls', 'pptx', 'ppt']) +const PARSEABLE_EXTENSIONS = new Set(['pdf', 'docx', 'doc', 'xlsx', 'xls', 'pptx']) export function isReadableFileType(contentType: string): boolean { return TEXT_TYPES.has(contentType) || contentType.startsWith('text/') @@ -587,6 +587,10 @@ export async function readFileRecord( try { const { parseBuffer } = await import('@/lib/file-parsers') const result = await parseBuffer(fetched.buffer, ext) + if (result.metadata?.degraded === true) { + /** Scraped ZIP internals or placeholder prose, not the document's text. */ + throw new Error(result.metadata.warning ?? 'Parser returned degraded output') + } const content = result.content || '' const lines = content.split('\n').length span.setAttributes({ diff --git a/apps/sim/lib/file-parsers/csv-parser.ts b/apps/sim/lib/file-parsers/csv-parser.ts index 6d4c6c9ec23..bec222e1f95 100644 --- a/apps/sim/lib/file-parsers/csv-parser.ts +++ b/apps/sim/lib/file-parsers/csv-parser.ts @@ -1,10 +1,16 @@ -import { createReadStream, existsSync } from 'fs' +import { existsSync } from 'fs' +import { readFile } from 'fs/promises' import { Readable } from 'stream' import { createLogger } from '@sim/logger' import { type Options, parse } from 'csv-parse' import { FileParserError } from '@/lib/file-parsers/errors' import type { FileParseResult, FileParser } from '@/lib/file-parsers/types' -import { sanitizeTextForUTF8, truncationNotice } from '@/lib/file-parsers/utils' +import { + type DecodedText, + decodeTextBuffer, + sanitizeTextForUTF8, + truncationNotice, +} from '@/lib/file-parsers/utils' const logger = createLogger('CsvParser') @@ -12,10 +18,17 @@ const CONFIG = { MAX_PREVIEW_ROWS: 1000, // Only keep first 1000 rows for preview MAX_SAMPLE_ROWS: 100, // Sample for metadata MAX_ERRORS: 100, // Stop after 100 errors - STREAM_CHUNK_SIZE: 16384, // 16KB chunks for streaming } export class CsvParser implements FileParser { + /** + * Reads the whole file before parsing rather than streaming 16 KB chunks: + * encoding detection needs the complete byte sequence (a BOM-less UTF-16 or + * Windows-1252 file cannot be recognized per chunk, and a multi-byte UTF-8 + * sequence split across chunk boundaries would be misread). The upload size + * caps already bound the file, and `parseBuffer` — the production path — + * always held the full buffer. + */ async parseFile(filePath: string): Promise { if (!filePath) { throw new Error('No file path provided') @@ -25,11 +38,7 @@ export class CsvParser implements FileParser { throw new Error(`File not found: ${filePath}`) } - const stream = createReadStream(filePath, { - highWaterMark: CONFIG.STREAM_CHUNK_SIZE, - }) - - return this.parseStream(stream) + return this.parseBuffer(await readFile(filePath)) } async parseBuffer(buffer: Buffer): Promise { @@ -38,14 +47,18 @@ export class CsvParser implements FileParser { `Parsing CSV buffer, size: ${bufferSize} bytes (${(bufferSize / 1024 / 1024).toFixed(2)} MB)` ) + const decoded = decodeTextBuffer(buffer) const stream = new Readable({ read() {} }) - stream.push(buffer) + stream.push(decoded.text) stream.push(null) - return this.parseStream(stream) + return this.parseStream(stream, decoded) } - private parseStream(inputStream: NodeJS.ReadableStream): Promise { + private parseStream( + inputStream: NodeJS.ReadableStream, + decoded: DecodedText + ): Promise { return new Promise((resolve, reject) => { let rowCount = 0 let errorCount = 0 @@ -145,6 +158,8 @@ export class CsvParser implements FileParser { errors: errors.slice(0, 10), truncated: rowCount > CONFIG.MAX_PREVIEW_ROWS, sampledData: sampledRows, + encoding: decoded.encoding, + ...(decoded.warning ? { warning: decoded.warning } : {}), }, }) } diff --git a/apps/sim/lib/file-parsers/doc-parser.test.ts b/apps/sim/lib/file-parsers/doc-parser.test.ts index c7ed3cfe557..1d603a67387 100644 --- a/apps/sim/lib/file-parsers/doc-parser.test.ts +++ b/apps/sim/lib/file-parsers/doc-parser.test.ts @@ -3,11 +3,13 @@ */ import JSZip from 'jszip' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { FileParserError } from '@/lib/file-parsers/errors' import { ZipBombError } from '@/lib/file-parsers/ooxml-limits' -const { mockParseOfficeText, mockExtractRawText } = vi.hoisted(() => ({ +const { mockParseOfficeText, mockExtractRawText, mockWordExtract } = vi.hoisted(() => ({ mockParseOfficeText: vi.fn(), mockExtractRawText: vi.fn(), + mockWordExtract: vi.fn(), })) vi.mock('@/lib/file-parsers/officeparser-module', () => ({ @@ -17,11 +19,39 @@ vi.mock('mammoth', () => ({ default: { extractRawText: mockExtractRawText }, extractRawText: mockExtractRawText, })) +vi.mock('word-extractor', () => ({ + default: class WordExtractor { + extract(source: Buffer) { + return mockWordExtract(source) + } + }, +})) import { DocParser } from '@/lib/file-parsers/doc-parser' const CENTRAL_DIRECTORY_HEADER_SIGNATURE = 0x02014b50 +interface WordSections { + body?: string + headers?: string + footers?: string + footnotes?: string + endnotes?: string +} + +/** The accessor surface of word-extractor's `Document`, with empty sections by default. */ +function wordDocument(sections: WordSections) { + return { + getBody: () => sections.body ?? '', + getHeaders: () => sections.headers ?? '', + getFooters: () => sections.footers ?? '', + getFootnotes: () => sections.footnotes ?? '', + getEndnotes: () => sections.endnotes ?? '', + getAnnotations: () => '', + getTextboxes: () => '', + } +} + /** * Build a small OOXML-shaped archive whose central directory *declares* a huge * uncompressed size. The guard reads declared sizes without inflating anything, @@ -68,9 +98,11 @@ describe('DocParser.parseBuffer', () => { }) it('rejects a .doc that under-declares its uncompressed size', async () => { - // Declared sizes alone put this under every limit; officeparser and mammoth - // only notice the mismatch after inflating the entry in full, so the guard - // has to catch it before either library sees the buffer. + /** + * Declared sizes alone put this under every limit; officeparser and mammoth + * only notice the mismatch after inflating the entry in full, so the guard + * has to catch it before either library sees the buffer. + */ const zip = new JSZip() zip.file('word/document.xml', 'A'.repeat(4 * 1024 * 1024)) const honest = (await zip.generateAsync({ @@ -112,16 +144,105 @@ describe('DocParser.parseBuffer', () => { const result = await new DocParser().parseBuffer(buffer) expect(result.content).toBe('hello') - expect(result.metadata.extractionMethod).toBe('officeparser') + expect(result.metadata?.extractionMethod).toBe('officeparser') + expect(mockWordExtract).not.toHaveBeenCalled() + }) + + it('reports an OOXML .doc with no text as no_extractable_text rather than scraping it', async () => { + const zip = new JSZip() + zip.file('word/document.xml', '') + const buffer = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' }) + mockParseOfficeText.mockResolvedValue('') + mockExtractRawText.mockResolvedValue({ value: '', messages: [] }) + + const error = await new DocParser().parseBuffer(buffer).catch((caught: unknown) => caught) + + expect(error).toBeInstanceOf(FileParserError) + expect(error).toMatchObject({ code: 'no_extractable_text' }) }) - it('no-ops the guard for a legacy OLE .doc and parses it', async () => { - mockParseOfficeText.mockResolvedValue('legacy doc text') + it('reads a legacy OLE .doc through word-extractor and joins its sections', async () => { + mockWordExtract.mockResolvedValue( + wordDocument({ + body: 'Body paragraph “quoted”', + headers: 'Running header', + footers: 'Page footer', + footnotes: '', + endnotes: 'An endnote', + }) + ) const result = await new DocParser().parseBuffer(buildLegacyOleDoc()) - expect(mockParseOfficeText).toHaveBeenCalledOnce() - expect(result.content).toBe('legacy doc text') + expect(mockWordExtract).toHaveBeenCalledOnce() + expect(mockParseOfficeText).not.toHaveBeenCalled() + expect(result.content).toBe( + 'Body paragraph “quoted”\n\nRunning header\n\nPage footer\n\nAn endnote' + ) + expect(result.metadata).toMatchObject({ + extractionMethod: 'word-extractor', + degraded: false, + characterCount: result.content.length, + }) + }) + + it('maps a Word 6/95 magic-number rejection to unsupported_type', async () => { + mockWordExtract.mockRejectedValue( + new Error('This does not seem to be a Word document: Invalid magic number: a5dc') + ) + + const error = await new DocParser() + .parseBuffer(buildLegacyOleDoc()) + .catch((caught: unknown) => caught) + + expect(error).toBeInstanceOf(FileParserError) + expect(error).toMatchObject({ code: 'unsupported_type' }) + expect((error as Error).message).toMatch(/Word 6\/95/) + }) + + it('maps any other word-extractor failure to invalid_format with the cause retained', async () => { + const libraryError = new Error('Invalid Short Sector Allocation Table') + mockWordExtract.mockRejectedValue(libraryError) + + const error = await new DocParser() + .parseBuffer(buildLegacyOleDoc()) + .catch((caught: unknown) => caught) + + expect(error).toBeInstanceOf(FileParserError) + expect(error).toMatchObject({ code: 'invalid_format' }) + expect((error as FileParserError).cause).toBe(libraryError) + }) + + it('reports a legacy .doc with no text as no_extractable_text', async () => { + mockWordExtract.mockResolvedValue(wordDocument({ body: ' \n' })) + + await expect(new DocParser().parseBuffer(buildLegacyOleDoc())).rejects.toMatchObject({ + code: 'no_extractable_text', + }) + }) + + it('returns a plain-text file misnamed .doc as its decoded text', async () => { + const result = await new DocParser().parseBuffer( + Buffer.from('Vendor list\nBloomberg\nCaf\xe9\n', 'latin1') + ) + + expect(result.content).toBe('Vendor list\nBloomberg\nCafé') + expect(result.metadata?.degraded).toBeFalsy() + expect(result.metadata?.encoding).toBe('windows-1252') + expect(mockWordExtract).not.toHaveBeenCalled() + }) + + it('rejects bytes that are neither OLE, ZIP nor text instead of scraping placeholder prose', async () => { + const png = Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + Buffer.from(Array.from({ length: 512 }, (_, index) => (index * 7919) % 256)), + ]) + + const error = await new DocParser().parseBuffer(png).catch((caught: unknown) => caught) + + expect(error).toBeInstanceOf(FileParserError) + expect(error).toMatchObject({ code: 'invalid_format' }) + expect(mockWordExtract).not.toHaveBeenCalled() }) it('rejects an empty buffer', async () => { diff --git a/apps/sim/lib/file-parsers/doc-parser.ts b/apps/sim/lib/file-parsers/doc-parser.ts index 4c684acdf2a..fd86b924eae 100644 --- a/apps/sim/lib/file-parsers/doc-parser.ts +++ b/apps/sim/lib/file-parsers/doc-parser.ts @@ -1,14 +1,34 @@ import { existsSync } from 'fs' import { readFile } from 'fs/promises' import { createLogger } from '@sim/logger' -import { FileParserError } from '@/lib/file-parsers/errors' +import { getErrorMessage } from '@sim/utils/errors' +import { FileParserError, toFileParserError } from '@/lib/file-parsers/errors' import { parseOfficeText } from '@/lib/file-parsers/officeparser-module' +import { sniffFileKind } from '@/lib/file-parsers/sniff' import type { FileParseOptions, FileParseResult, FileParser } from '@/lib/file-parsers/types' -import { sanitizeTextForUTF8 } from '@/lib/file-parsers/utils' -import { assertOoxmlArchiveWithinLimits } from '@/lib/file-parsers/zip-guard' +import { decodeTextBuffer, sanitizeTextForUTF8 } from '@/lib/file-parsers/utils' +import { assertOoxmlArchiveWithinLimits, isZipShaped } from '@/lib/file-parsers/zip-guard' const logger = createLogger('DocParser') +/** word-extractor's rejection of a Word 6/95 (or non-Word) `FIB` identifier. */ +const WORD_6_95_MAGIC_PATTERN = /Invalid magic number/i + +interface LegacyDocSections { + body: string + headers: string + footers: string + footnotes: string + endnotes: string +} + +function joinSections(sections: LegacyDocSections): string { + return [sections.body, sections.headers, sections.footers, sections.footnotes, sections.endnotes] + .map((section) => section.trim()) + .filter((section) => section.length > 0) + .join('\n\n') +} + export class DocParser implements FileParser { async parseFile(filePath: string, options: FileParseOptions = {}): Promise { if (!filePath) { @@ -24,10 +44,15 @@ export class DocParser implements FileParser { } /** - * A `.doc` upload is only routed here by extension — `officeparser` and - * `mammoth` both accept an OOXML/ZIP container regardless of its name, so the - * zip-bomb guard must run here exactly as it does in the docx/pptx/xlsx - * parsers. It no-ops for genuine legacy OLE `.doc` buffers. + * Routes on the container rather than the name: a genuine OLE2 `.doc` goes to + * word-extractor, a ZIP-shaped one is a misnamed OOXML package for + * officeparser/mammoth, and plain text is returned as-is. Anything else is a + * typed `invalid_format` — the former byte scrape returned ZIP part names or a + * placeholder sentence, which automated callers then indexed as prose. + * + * `officeparser` and `mammoth` both accept an OOXML/ZIP container regardless of + * its name, so the zip-bomb guard runs here exactly as it does in the + * docx/pptx/xlsx parsers. It no-ops for genuine legacy OLE `.doc` buffers. */ async parseBuffer(buffer: Buffer, options: FileParseOptions = {}): Promise { try { @@ -38,103 +63,162 @@ export class DocParser implements FileParser { assertOoxmlArchiveWithinLimits(buffer) - try { - const result = await parseOfficeText(buffer, options) - - if (result) { - const resultString = typeof result === 'string' ? result : String(result) - const content = sanitizeTextForUTF8(resultString.trim()) - - if (content.length > 0) { - return { - content, - metadata: { - characterCount: content.length, - extractionMethod: 'officeparser', - }, - } - } - } - } catch (officeError) { - options.signal?.throwIfAborted() - logger.warn('officeparser failed, trying mammoth:', officeError) + if (isZipShaped(buffer)) { + return await this.parseOoxmlContainer(buffer, options) } - try { - const mammoth = await import('mammoth') - const result = await mammoth.extractRawText({ buffer }) - options.signal?.throwIfAborted() - - if (result.value && result.value.trim().length > 0) { - const content = sanitizeTextForUTF8(result.value.trim()) - return { - content, - metadata: { - characterCount: content.length, - extractionMethod: 'mammoth', - messages: result.messages, - }, - } - } - } catch (mammothError) { - options.signal?.throwIfAborted() - logger.warn('mammoth failed:', mammothError) + const kind = sniffFileKind(buffer) + if (kind === 'ole2') { + return await this.parseLegacyDoc(buffer, options) + } + if (kind === 'text' || kind === 'html') { + return this.parsePlainText(buffer) } - options.signal?.throwIfAborted() - return this.fallbackExtraction(buffer) + throw new FileParserError( + 'invalid_format', + `File content does not match the .doc extension (detected ${kind}). Re-save it as DOCX and retry.` + ) } catch (error) { logger.error('DOC parsing error:', error) throw error } } - private fallbackExtraction(buffer: Buffer): FileParseResult { - const isBinaryDoc = buffer.length >= 2 && buffer[0] === 0xd0 && buffer[1] === 0xcf + /** A binary Word 97–2003 document, read through word-extractor's OLE2 reader. */ + private async parseLegacyDoc( + buffer: Buffer, + options: FileParseOptions + ): Promise { + const { default: WordExtractor } = await import('word-extractor') + options.signal?.throwIfAborted() - if (!isBinaryDoc) { - const textContent = buffer.toString('utf8').trim() + let sections: LegacyDocSections + try { + const document = await new WordExtractor().extract(buffer) + const raw = { filterUnicode: false } + sections = { + body: document.getBody(raw), + headers: document.getHeaders({ ...raw, includeFooters: false }), + footers: document.getFooters(raw), + footnotes: document.getFootnotes(raw), + endnotes: document.getEndnotes(raw), + } + } catch (error) { + options.signal?.throwIfAborted() + if (WORD_6_95_MAGIC_PATTERN.test(getErrorMessage(error))) { + throw new FileParserError( + 'unsupported_type', + 'This .doc file uses a Word 6/95 format that is not supported. Save it as .docx and retry.', + error + ) + } + throw toFileParserError(error, 'invalid_format', 'Failed to parse DOC buffer') + } + options.signal?.throwIfAborted() - if (textContent.length > 0) { - const printableChars = textContent.match(/[\x20-\x7E\n\r\t]/g)?.length || 0 - const isProbablyText = printableChars / textContent.length > 0.9 + const content = sanitizeTextForUTF8(joinSections(sections)) + if (content.length === 0) { + throw new FileParserError( + 'no_extractable_text', + 'No text could be extracted from this DOC file. Re-save it as DOCX to index it.' + ) + } - if (isProbablyText) { + return { + content, + metadata: { + characterCount: content.length, + extractionMethod: 'word-extractor', + degraded: false, + }, + } + } + + /** A `.docx` package saved under the wrong extension. */ + private async parseOoxmlContainer( + buffer: Buffer, + options: FileParseOptions + ): Promise { + let extracted = false + let lastError: unknown + + try { + const result = await parseOfficeText(buffer, options) + extracted = true + + if (result) { + const resultString = typeof result === 'string' ? result : String(result) + const content = sanitizeTextForUTF8(resultString.trim()) + + if (content.length > 0) { return { - content: sanitizeTextForUTF8(textContent), + content, metadata: { - extractionMethod: 'plaintext-fallback', - characterCount: textContent.length, - warning: 'File is not a valid DOC format, extracted as plain text', + characterCount: content.length, + extractionMethod: 'officeparser', }, } } } + } catch (officeError) { + options.signal?.throwIfAborted() + lastError = officeError + logger.warn('officeparser failed, trying mammoth:', officeError) } - const text = buffer.toString('utf8', 0, Math.min(buffer.length, 100000)) + try { + const mammoth = await import('mammoth') + const result = await mammoth.extractRawText({ buffer }) + options.signal?.throwIfAborted() + extracted = true + + if (result.value && result.value.trim().length > 0) { + const content = sanitizeTextForUTF8(result.value.trim()) + return { + content, + metadata: { + characterCount: content.length, + extractionMethod: 'mammoth', + messages: result.messages, + }, + } + } + } catch (mammothError) { + options.signal?.throwIfAborted() + lastError = mammothError + logger.warn('mammoth failed:', mammothError) + } - const readableText = text - .match(/[\x20-\x7E\s]{4,}/g) - ?.filter( - (chunk) => - chunk.trim().length > 10 && /[a-zA-Z]/.test(chunk) && !/^[\x00-\x1F]*$/.test(chunk) + options.signal?.throwIfAborted() + if (extracted) { + throw new FileParserError( + 'no_extractable_text', + 'No text could be extracted from this document. Re-save it as DOCX to index it.' ) - .join(' ') - .replace(/\s+/g, ' ') - .trim() + } + throw toFileParserError(lastError, 'invalid_format', 'Failed to parse DOC buffer') + } + + /** A real text file misnamed `.doc` is a genuine extraction, not a degraded one. */ + private parsePlainText(buffer: Buffer): FileParseResult { + const decoded = decodeTextBuffer(buffer) + const content = sanitizeTextForUTF8(decoded.text.trim()) - const content = readableText - ? sanitizeTextForUTF8(readableText) - : 'Unable to extract text from DOC file. Please convert to DOCX format for better results.' + if (content.length === 0) { + throw new FileParserError('no_extractable_text', 'The file contains no text') + } return { content, metadata: { - extractionMethod: 'fallback', - degraded: true, + extractionMethod: 'plaintext-fallback', characterCount: content.length, - warning: 'Basic text extraction used. For better results, convert to DOCX format.', + encoding: decoded.encoding, + warning: [ + 'File is not a valid DOC format, extracted as plain text', + ...(decoded.warning ? [decoded.warning] : []), + ].join('. '), }, } } diff --git a/apps/sim/lib/file-parsers/errors.test.ts b/apps/sim/lib/file-parsers/errors.test.ts index 66d221b40ab..57bb66c17e2 100644 --- a/apps/sim/lib/file-parsers/errors.test.ts +++ b/apps/sim/lib/file-parsers/errors.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { FileParserError, + getFileParserErrorCode, isEncryptedOfficeParserError, toFileParserError, } from '@/lib/file-parsers/errors' @@ -56,4 +57,15 @@ describe('file parser errors', () => { ])('recognizes the SheetJS encrypted-workbook error: %s', (message) => { expect(isEncryptedOfficeParserError(new Error(message))).toBe(true) }) + + it('maps the archive guard classes onto parser codes without wrapping them', () => { + expect(getFileParserErrorCode(new ArchiveIntegrityError('Archive entries overlap'))).toBe( + 'invalid_format' + ) + expect(getFileParserErrorCode(new ZipBombError('Archive too large'))).toBe('complexity_limit') + expect(getFileParserErrorCode(new FileParserError('encrypted_file', 'locked'))).toBe( + 'encrypted_file' + ) + expect(getFileParserErrorCode(new Error('untyped'))).toBeUndefined() + }) }) diff --git a/apps/sim/lib/file-parsers/errors.ts b/apps/sim/lib/file-parsers/errors.ts index 0f3cbff5d27..aed6c95211d 100644 --- a/apps/sim/lib/file-parsers/errors.ts +++ b/apps/sim/lib/file-parsers/errors.ts @@ -31,6 +31,20 @@ export function isFileParserError(error: unknown): error is FileParserError { return error instanceof FileParserError } +/** + * The parser code an error maps to, including the archive guard's own classes + * (which are not `FileParserError` because `ooxml-limits` must stay browser-safe + * and dependency-free). Callers that branch on a code use this instead of + * `isFileParserError` so an archive rejection is never mistaken for an untyped, + * retryable failure. + */ +export function getFileParserErrorCode(error: unknown): FileParserErrorCode | undefined { + if (isFileParserError(error)) return error.code + if (error instanceof ArchiveIntegrityError) return 'invalid_format' + if (error instanceof ZipBombError) return 'complexity_limit' + return undefined +} + /** * Wraps an untyped parser-library exception without erasing a typed inner cause. * Archive safety and integrity failures remain typed so every caller can enforce diff --git a/apps/sim/lib/file-parsers/index.test.ts b/apps/sim/lib/file-parsers/index.test.ts index 5ed13efce2b..48254a577f6 100644 --- a/apps/sim/lib/file-parsers/index.test.ts +++ b/apps/sim/lib/file-parsers/index.test.ts @@ -59,7 +59,6 @@ vi.mock('@/lib/file-parsers/index', () => { txt: { parseFile: mockTxtParseFile }, md: { parseFile: mockMdParseFile }, pptx: { parseFile: mockPptxParseFile }, - ppt: { parseFile: mockPptxParseFile }, html: { parseFile: mockHtmlParseFile }, htm: { parseFile: mockHtmlParseFile }, } @@ -232,22 +231,6 @@ describe('File Parsers', () => { expect(result).toEqual(expectedResult) }) - it('should parse PPT files successfully', async () => { - const expectedResult = { - content: 'Parsed PPTX content', - metadata: { - slideCount: 5, - extractionMethod: 'officeparser', - }, - } - - mockPptxParseFile.mockResolvedValueOnce(expectedResult) - - const result = await parseFile('/test/files/presentation.ppt') - - expect(result).toEqual(expectedResult) - }) - it('should parse HTML files successfully', async () => { const expectedResult = { content: 'Parsed HTML content', @@ -304,13 +287,13 @@ describe('File Parsers', () => { expect(isSupportedFileType('txt')).toBe(true) expect(isSupportedFileType('md')).toBe(true) expect(isSupportedFileType('pptx')).toBe(true) - expect(isSupportedFileType('ppt')).toBe(true) expect(isSupportedFileType('html')).toBe(true) expect(isSupportedFileType('htm')).toBe(true) }) it('should return false for unsupported file types', () => { expect(isSupportedFileType('png')).toBe(false) + expect(isSupportedFileType('ppt')).toBe(false) expect(isSupportedFileType('unknown')).toBe(false) }) diff --git a/apps/sim/lib/file-parsers/index.ts b/apps/sim/lib/file-parsers/index.ts index fa5f36888a1..f3446b64a86 100644 --- a/apps/sim/lib/file-parsers/index.ts +++ b/apps/sim/lib/file-parsers/index.ts @@ -13,9 +13,11 @@ import { parseJSONLBuffer, } from '@/lib/file-parsers/json-parser' import { MdParser } from '@/lib/file-parsers/md-parser' +import { ArchiveIntegrityError } from '@/lib/file-parsers/ooxml-limits' import { OpenDocumentParser } from '@/lib/file-parsers/opendocument-parser' import { PdfParser } from '@/lib/file-parsers/pdf-parser' import { PptxParser } from '@/lib/file-parsers/pptx-parser' +import { reconcileParserRoute, sniffFileKind } from '@/lib/file-parsers/sniff' import { TxtParser } from '@/lib/file-parsers/txt-parser' import type { FileParseOptions, @@ -38,9 +40,9 @@ const logger = createLogger('FileParser') * - `xlsm`/`xlsb`/`xltx`/`xls`/`ods` are all read natively by SheetJS. `ods` is * treated as a spreadsheet rather than routed to {@link OpenDocumentParser} so * its output keeps per-sheet structure instead of one flat text run. - * - `pptm`/`potx` are the PresentationML package `pptx` uses. `ppt` is the legacy - * OLE binary that no bundled library reads; it is mapped here so it degrades - * through the parser's own reporting rather than looking simply unsupported. + * - `pptm`/`potx` are the PresentationML package `pptx` uses. Legacy OLE `ppt` + * is deliberately absent: no bundled library reads it, and registering it only + * produced scraped placeholder prose, so uploads refuse it up front instead. * * Every parser module is imported statically and every dependency is a regular * (non-optional) one, so a broken install fails loudly at import. This previously @@ -70,7 +72,6 @@ const PARSERS = new Map([ ['xltx', new XlsxParser()], ['ods', new XlsxParser()], ['pptx', new PptxParser()], - ['ppt', new PptxParser()], ['pptm', new PptxParser()], ['potx', new PptxParser()], ['odt', new OpenDocumentParser()], @@ -121,6 +122,11 @@ export async function parseFile( } } +function joinWarnings(...warnings: Array): string | undefined { + const present = warnings.filter((warning): warning is string => Boolean(warning)) + return present.length > 0 ? present.join('. ') : undefined +} + /** * Parse a buffer based on file extension * @param buffer Buffer containing the file data @@ -131,7 +137,12 @@ export async function parseFile( * The zip-bomb guard runs here for every extension, not just the OOXML ones: * the extension is an attacker-controlled routing hint, and the guard no-ops * for buffers that are not ZIP archives. Individual parsers still call it so a - * direct `parser.parseBuffer` caller is covered too. + * direct `parser.parseBuffer` caller is covered too. Its integrity rejection is + * surfaced as a typed `invalid_format` so callers never retry a corrupt archive. + * + * After the guard, the bytes are sniffed and reconciled with the extension (see + * {@link reconcileParserRoute}); a re-routed parse records `detectedType` and a + * warning in its metadata. */ export async function parseBuffer( buffer: Buffer, @@ -147,26 +158,50 @@ export async function parseBuffer( throw new Error('No file extension provided') } - assertOoxmlArchiveWithinLimits(buffer) + try { + assertOoxmlArchiveWithinLimits(buffer) + } catch (error) { + if (error instanceof ArchiveIntegrityError) { + throw new FileParserError('invalid_format', error.message, error) + } + throw error + } const normalizedExtension = extension.toLowerCase() - const parser = PARSERS.get(normalizedExtension) - - if (!parser) { + if (!PARSERS.has(normalizedExtension)) { throw new FileParserError( 'unsupported_type', `Unsupported file type: ${normalizedExtension}. Supported types are: ${SUPPORTED_EXTENSIONS_TEXT}` ) } - if (!parser.parseBuffer) { + const kind = sniffFileKind(buffer) + const route = reconcileParserRoute(normalizedExtension, kind) + const parser = PARSERS.get(route.extension) + + if (!parser?.parseBuffer) { throw new FileParserError( 'unsupported_type', - `Parser for ${normalizedExtension} does not support buffer parsing` + `Parser for ${route.extension} does not support buffer parsing` ) } - return await parser.parseBuffer(buffer, options) + const result = await parser.parseBuffer(buffer, options) + if (!route.detectedType) return result + + logger.warn('Parsed buffer under a re-routed parser', { + extension: normalizedExtension, + detectedType: route.detectedType, + route: route.extension, + }) + return { + ...result, + metadata: { + ...result.metadata, + detectedType: route.detectedType, + warning: joinWarnings(route.warning, result.metadata?.warning), + }, + } } catch (error) { logger.error('Buffer parsing error:', error) throw error diff --git a/apps/sim/lib/file-parsers/json-parser.test.ts b/apps/sim/lib/file-parsers/json-parser.test.ts index fcc106706fb..46274cc0479 100644 --- a/apps/sim/lib/file-parsers/json-parser.test.ts +++ b/apps/sim/lib/file-parsers/json-parser.test.ts @@ -42,4 +42,30 @@ describe('JSON parser complexity limits', () => { expect(JSON.parse(result.content)).toEqual({ items: [1, 2], name: 'test' }) expect(result.metadata).toMatchObject({ isArray: false, keys: ['items', 'name'], depth: 2 }) }) + + it('parses a BOM-prefixed JSON file and reports its encoding', async () => { + const result = await parseJSONBuffer( + Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from('{"name":"Café"}')]) + ) + + expect(JSON.parse(result.content)).toEqual({ name: 'Café' }) + expect(result.metadata?.encoding).toBe('utf-8') + expect(result.metadata?.warning).toBeUndefined() + }) + + it('decodes a Windows-1252 JSON file instead of rejecting or mangling it', async () => { + const result = await parseJSONBuffer(Buffer.from('{"city":"Z\xfcrich"}', 'latin1')) + + expect(JSON.parse(result.content)).toEqual({ city: 'Zürich' }) + expect(result.metadata?.encoding).toBe('windows-1252') + expect(result.metadata?.warning).toMatch(/Windows-1252/) + }) + + it('parses BOM-prefixed JSON Lines', async () => { + const result = await parseJSONLBuffer( + Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from('{"a":1}\n{"a":2}')]) + ) + + expect(JSON.parse(result.content)).toEqual([{ a: 1 }, { a: 2 }]) + }) }) diff --git a/apps/sim/lib/file-parsers/json-parser.ts b/apps/sim/lib/file-parsers/json-parser.ts index cd47fa9c5bd..1ed6d7ffd48 100644 --- a/apps/sim/lib/file-parsers/json-parser.ts +++ b/apps/sim/lib/file-parsers/json-parser.ts @@ -1,6 +1,7 @@ import { getErrorMessage } from '@sim/utils/errors' import { FileParserError } from '@/lib/file-parsers/errors' import type { FileParseResult } from '@/lib/file-parsers/types' +import { type DecodedText, decodeTextBuffer } from '@/lib/file-parsers/utils' const MAX_JSON_DEPTH = 500 const MAX_JSON_NODES = 1_000_000 @@ -141,7 +142,14 @@ function assertJsonValueWithinLimits( return maxDepth } -function buildJsonResult(jsonData: unknown): FileParseResult { +function encodingMetadata(decoded: DecodedText): Record { + return { + encoding: decoded.encoding, + ...(decoded.warning ? { warning: decoded.warning } : {}), + } +} + +function buildJsonResult(jsonData: unknown, decoded: DecodedText): FileParseResult { const budget = { nodes: 0, serializedUnits: 0 } const depth = assertJsonValueWithinLimits(jsonData, budget) const formattedContent = JSON.stringify(jsonData, null, 2) @@ -156,13 +164,20 @@ function buildJsonResult(jsonData: unknown): FileParseResult { keys: isRecord ? Object.keys(jsonData as Record) : [], itemCount: isArray ? jsonData.length : undefined, depth, + ...encodingMetadata(decoded), }, } } -function parseJsonContent(content: string): FileParseResult { +/** + * Decodes before `JSON.parse`: a UTF-8 BOM is not JSON whitespace, so the raw + * `toString('utf-8')` read used to reject every BOM-prefixed file from Windows + * editors, and a Windows-1252 file silently lost its accented characters. + */ +function parseJsonContent(buffer: Uint8Array): FileParseResult { + const decoded = decodeTextBuffer(buffer) try { - return buildJsonResult(JSON.parse(content)) + return buildJsonResult(JSON.parse(decoded.text), decoded) } catch (error) { if (error instanceof FileParserError) throw error if (!(error instanceof SyntaxError)) { @@ -179,12 +194,12 @@ function parseJsonContent(content: string): FileParseResult { /** Parse a JSON file. */ export async function parseJSON(filePath: string): Promise { const fs = await import('fs/promises') - return parseJsonContent(await fs.readFile(filePath, 'utf-8')) + return parseJsonContent(await fs.readFile(filePath)) } /** Parse JSON from a buffer. */ export async function parseJSONBuffer(buffer: Buffer): Promise { - return parseJsonContent(buffer.toString('utf-8')) + return parseJsonContent(buffer) } function* iterateJsonLines(content: string): Generator<{ line: string; lineNumber: number }> { @@ -201,12 +216,13 @@ function* iterateJsonLines(content: string): Generator<{ line: string; lineNumbe } } -function parseJsonLinesContent(content: string): FileParseResult { +function parseJsonLinesContent(buffer: Uint8Array): FileParseResult { + const decoded = decodeTextBuffer(buffer) const items: unknown[] = [] const budget = { nodes: 0, serializedUnits: 0 } let depth = assertJsonValueWithinLimits([], budget) - for (const { line, lineNumber } of iterateJsonLines(content)) { + for (const { line, lineNumber } of iterateJsonLines(decoded.text)) { let item: unknown try { item = JSON.parse(line) @@ -229,6 +245,7 @@ function parseJsonLinesContent(content: string): FileParseResult { keys: [], itemCount: items.length, depth, + ...encodingMetadata(decoded), }, } } @@ -236,10 +253,10 @@ function parseJsonLinesContent(content: string): FileParseResult { /** Parse a JSON Lines file. */ export async function parseJSONL(filePath: string): Promise { const fs = await import('fs/promises') - return parseJsonLinesContent(await fs.readFile(filePath, 'utf-8')) + return parseJsonLinesContent(await fs.readFile(filePath)) } /** Parse JSON Lines from a buffer. */ export async function parseJSONLBuffer(buffer: Buffer): Promise { - return parseJsonLinesContent(buffer.toString('utf-8')) + return parseJsonLinesContent(buffer) } diff --git a/apps/sim/lib/file-parsers/md-parser.ts b/apps/sim/lib/file-parsers/md-parser.ts index a97e9450dfe..1ba52e16b7d 100644 --- a/apps/sim/lib/file-parsers/md-parser.ts +++ b/apps/sim/lib/file-parsers/md-parser.ts @@ -1,7 +1,7 @@ import { readFile } from 'fs/promises' import { createLogger } from '@sim/logger' import type { FileParseResult, FileParser } from '@/lib/file-parsers/types' -import { sanitizeTextForUTF8 } from '@/lib/file-parsers/utils' +import { decodeTextBuffer, sanitizeTextForUTF8 } from '@/lib/file-parsers/utils' const logger = createLogger('MdParser') @@ -25,14 +25,16 @@ export class MdParser implements FileParser { try { logger.info('Parsing buffer, size:', buffer.length) - const result = buffer.toString('utf-8') - const content = sanitizeTextForUTF8(result) + const decoded = decodeTextBuffer(buffer) + const content = sanitizeTextForUTF8(decoded.text) return { content, metadata: { characterCount: content.length, tokenCount: Math.floor(content.length / 4), + encoding: decoded.encoding, + ...(decoded.warning ? { warning: decoded.warning } : {}), }, } } catch (error) { diff --git a/apps/sim/lib/file-parsers/parser-formats.test.ts b/apps/sim/lib/file-parsers/parser-formats.test.ts index 0864cbc736d..f194c6758c2 100644 --- a/apps/sim/lib/file-parsers/parser-formats.test.ts +++ b/apps/sim/lib/file-parsers/parser-formats.test.ts @@ -154,11 +154,18 @@ describe('PptxParser degraded reporting', () => { }) describe('DocParser degraded reporting', () => { - it('flags a legacy OLE .doc binary as degraded', async () => { - const result = await new DocParser().parseBuffer(buildLegacyOleBinary()) + /** + * An OLE2 header with no valid compound-file structure behind it used to fall + * through to the byte scrape and come back as degraded placeholder prose. It is + * now a typed rejection, so nothing downstream can index the placeholder. + */ + it('rejects an OLE .doc binary that word-extractor cannot read as invalid_format', async () => { + const error = await new DocParser() + .parseBuffer(buildLegacyOleBinary()) + .catch((caught: unknown) => caught) - expect(result.metadata?.degraded).toBe(true) - expect(result.content).toContain('Unable to extract text') + expect(error).toBeInstanceOf(FileParserError) + expect(error).toMatchObject({ code: 'invalid_format' }) }) /** diff --git a/apps/sim/lib/file-parsers/pdf-parser.test.ts b/apps/sim/lib/file-parsers/pdf-parser.test.ts index 114746cc8d6..7f4383c5c96 100644 --- a/apps/sim/lib/file-parsers/pdf-parser.test.ts +++ b/apps/sim/lib/file-parsers/pdf-parser.test.ts @@ -203,7 +203,8 @@ describe('PdfParser', () => { it('preserves the password-required error for encrypted PDFs', async () => { await expect(new PdfParser().parseBuffer(buildEncryptedPdf())).rejects.toMatchObject({ - name: 'PasswordException', + name: 'FileParserError', + code: 'encrypted_file', }) }) }) diff --git a/apps/sim/lib/file-parsers/pdfjs-server.test.ts b/apps/sim/lib/file-parsers/pdfjs-server.test.ts index 5983c47734b..ed5112d2e7b 100644 --- a/apps/sim/lib/file-parsers/pdfjs-server.test.ts +++ b/apps/sim/lib/file-parsers/pdfjs-server.test.ts @@ -27,6 +27,7 @@ vi.mock('pdfjs-dist/legacy/build/pdf.worker.mjs', () => ({ WorkerMessageHandler: workerMessageHandler, })) +import { FileParserError } from '@/lib/file-parsers/errors' import { openPdfDocument } from '@/lib/file-parsers/pdfjs-server' describe('openPdfDocument', () => { @@ -76,4 +77,26 @@ describe('openPdfDocument', () => { resolveLoading?.({ destroy: lateDocumentDestroy }) await vi.waitFor(() => expect(lateDocumentDestroy).toHaveBeenCalledOnce()) }) + + it.each([ + ['InvalidPDFException', 'Invalid PDF structure.', 'invalid_format'], + ['FormatError', 'Bad XRef entry', 'invalid_format'], + ['PasswordException', 'No password given', 'encrypted_file'], + ])('maps the pdf.js %s to a typed parser failure', async (name, message, code) => { + const pdfjsError = Object.assign(new Error(message), { name }) + mockGetDocument.mockReturnValueOnce({ promise: Promise.reject(pdfjsError) }) + + const error = await openPdfDocument(new Uint8Array([1])).catch((caught: unknown) => caught) + + expect(error).toBeInstanceOf(FileParserError) + expect(error).toMatchObject({ code }) + expect((error as FileParserError).cause).toBe(pdfjsError) + }) + + it('leaves an unrecognized pdf.js failure untyped so it stays retryable', async () => { + const unknownError = new Error('worker crashed') + mockGetDocument.mockReturnValueOnce({ promise: Promise.reject(unknownError) }) + + await expect(openPdfDocument(new Uint8Array([1]))).rejects.toBe(unknownError) + }) }) diff --git a/apps/sim/lib/file-parsers/pdfjs-server.ts b/apps/sim/lib/file-parsers/pdfjs-server.ts index e1c0274a5f3..b4e4a6a9b94 100644 --- a/apps/sim/lib/file-parsers/pdfjs-server.ts +++ b/apps/sim/lib/file-parsers/pdfjs-server.ts @@ -1,4 +1,5 @@ import type { PDFDocumentLoadingTask, PDFDocumentProxy } from 'pdfjs-dist/types/src/pdf' +import { FileParserError } from '@/lib/file-parsers/errors' let pdfRuntime: Promise | undefined @@ -70,6 +71,26 @@ function waitForLoadingTask( }) } +/** pdf.js exception classes that mean the bytes are not a readable PDF. */ +const INVALID_PDF_ERROR_NAMES = new Set(['InvalidPDFException', 'FormatError']) + +/** + * pdf.js reports failures as its own exception classes whose `name` survives + * the worker boundary. Untyped, they classify as transient and are retried + * forever; this is the single choke point every pdf.js caller shares, so the + * mapping to the parser code taxonomy lives here. + */ +function toTypedPdfError(error: unknown): unknown { + if (!(error instanceof Error)) return error + if (error.name === 'PasswordException') { + return new FileParserError('encrypted_file', 'This PDF is password-protected', error) + } + if (INVALID_PDF_ERROR_NAMES.has(error.name)) { + return new FileParserError('invalid_format', `Invalid PDF: ${error.message}`, error) + } + return error +} + /** Open a PDF with the server-compatible pdf.js build and hardened defaults. */ export async function openPdfDocument( data: Uint8Array, @@ -85,5 +106,9 @@ export async function openPdfDocument( useSystemFonts: true, }) - return waitForLoadingTask(loadingTask, signal) + try { + return await waitForLoadingTask(loadingTask, signal) + } catch (error) { + throw toTypedPdfError(error) + } } diff --git a/apps/sim/lib/file-parsers/registry.test.ts b/apps/sim/lib/file-parsers/registry.test.ts index 6c7c0769e40..05c205a1e81 100644 --- a/apps/sim/lib/file-parsers/registry.test.ts +++ b/apps/sim/lib/file-parsers/registry.test.ts @@ -36,7 +36,6 @@ const ALL_SUPPORTED_TYPES: SupportedFileType[] = [ 'html', 'htm', 'pptx', - 'ppt', 'pptm', 'potx', 'odt', @@ -81,9 +80,11 @@ describe('file parser registry', () => { /** * Formats with no bundled extractor must not claim support. `rtf` especially: * `DocParser`'s plaintext branch would pass its control words through as prose. + * Legacy `ppt` was registered once and only ever produced scraped placeholder + * prose, so it is refused up front with the unsupported-type message instead. */ it('does not claim formats with no extractor', () => { - for (const extension of ['rtf', 'msg', 'eml', 'pages', 'key', 'one', 'vsdx', 'png']) { + for (const extension of ['rtf', 'msg', 'eml', 'pages', 'key', 'one', 'vsdx', 'png', 'ppt']) { expect(isSupportedFileType(extension), `unexpectedly claims .${extension}`).toBe(false) } }) diff --git a/apps/sim/lib/file-parsers/sniff.test.ts b/apps/sim/lib/file-parsers/sniff.test.ts new file mode 100644 index 00000000000..e4c4c7680ad --- /dev/null +++ b/apps/sim/lib/file-parsers/sniff.test.ts @@ -0,0 +1,304 @@ +/** + * @vitest-environment node + */ +import JSZip from 'jszip' +import { describe, expect, it } from 'vitest' +import * as XLSX from 'xlsx' +import { parseBuffer } from '@/lib/file-parsers' +import { FileParserError } from '@/lib/file-parsers/errors' +import { reconcileParserRoute, type SniffedKind, sniffFileKind } from '@/lib/file-parsers/sniff' + +const OLE2_HEADER = Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]) + +function oleBinary(): Buffer { + return Buffer.concat([OLE2_HEADER, Buffer.alloc(2048, 0)]) +} + +function pngBinary(): Buffer { + return Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + Buffer.from(Array.from({ length: 4000 }, (_, index) => (index * 7919) % 256)), + ]) +} + +async function zipWith(entries: Record, storedMimetype?: string): Promise { + const zip = new JSZip() + if (storedMimetype) zip.file('mimetype', storedMimetype, { compression: 'STORE' }) + for (const [name, content] of Object.entries(entries)) zip.file(name, content) + return zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' }) as Promise +} + +function buildDocx(text: string): Promise { + return zipWith({ + '[Content_Types].xml': + '', + '_rels/.rels': + '', + 'word/document.xml': `${text}`, + }) +} + +describe('sniffFileKind', () => { + it('recognizes a PDF by its header anywhere in the first KiB', () => { + expect(sniffFileKind(Buffer.from('%PDF-1.7\n%\xe2\xe3\xcf\xd3\n'))).toBe('pdf') + expect(sniffFileKind(Buffer.concat([Buffer.alloc(200, 0x20), Buffer.from('%PDF-1.4')]))).toBe( + 'pdf' + ) + expect( + sniffFileKind(Buffer.concat([Buffer.alloc(2000, 0x20), Buffer.from('%PDF-1.4')])) + ).not.toBe('pdf') + }) + + it('recognizes an OLE2 compound file', () => { + expect(sniffFileKind(oleBinary())).toBe('ole2') + }) + + it('classifies Office packages by their central-directory part names', async () => { + expect(sniffFileKind(await zipWith({ 'word/document.xml': '' }))).toBe('docx') + expect( + sniffFileKind(await zipWith({ '[Content_Types].xml': '', 'xl/workbook.xml': '' })) + ).toBe('xlsx') + expect(sniffFileKind(await zipWith({ 'ppt/presentation.xml': '

      ' }))).toBe('pptx') + }) + + it('classifies OpenDocument packages by the stored mimetype entry', async () => { + expect( + sniffFileKind( + await zipWith({ 'content.xml': '' }, 'application/vnd.oasis.opendocument.text') + ) + ).toBe('odt') + expect( + sniffFileKind( + await zipWith({ 'content.xml': '' }, 'application/vnd.oasis.opendocument.spreadsheet') + ) + ).toBe('ods') + expect( + sniffFileKind( + await zipWith({ 'content.xml': '' }, 'application/vnd.oasis.opendocument.presentation') + ) + ).toBe('odp') + }) + + it('classifies SheetJS-written workbooks the way the spreadsheet parser expects', () => { + const wb = XLSX.utils.book_new() + XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet([['a'], ['b']]), 'S') + + expect(sniffFileKind(XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' }) as Buffer)).toBe( + 'xlsx' + ) + expect(sniffFileKind(XLSX.write(wb, { type: 'buffer', bookType: 'xlsb' }) as Buffer)).toBe( + 'xlsx' + ) + expect(sniffFileKind(XLSX.write(wb, { type: 'buffer', bookType: 'ods' }) as Buffer)).toBe('ods') + expect(sniffFileKind(XLSX.write(wb, { type: 'buffer', bookType: 'xls' }) as Buffer)).toBe( + 'ole2' + ) + }) + + it('reports an unrecognized archive as zip', async () => { + expect(sniffFileKind(await zipWith({ 'readme.txt': 'hi' }))).toBe('zip') + }) + + it('reports NUL-bearing bytes without a UTF-16 layout as binary', () => { + expect(sniffFileKind(pngBinary())).toBe('binary') + expect(sniffFileKind(Buffer.from('abc\0def'))).toBe('binary') + }) + + it('treats UTF-16 text as text, with or without a BOM', () => { + expect( + sniffFileKind(Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from('Hello', 'utf16le')])) + ).toBe('text') + expect(sniffFileKind(Buffer.from('Hello UTF-16 without a BOM', 'utf16le'))).toBe('text') + }) + + it('recognizes an HTML document by its opening tag after optional BOM and whitespace', () => { + expect(sniffFileKind(Buffer.from('x'))).toBe('html') + expect(sniffFileKind(Buffer.from('\n

      x

      '))).toBe('html') + expect( + sniffFileKind( + Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from('

      x

      ')]) + ) + ).toBe('html') + expect(sniffFileKind(Buffer.from('

      fragment, not a document

      '))).toBe('text') + }) + + it('reports plain text and Latin-1 text as text', () => { + expect(sniffFileKind(Buffer.from('Vendor list\nBloomberg\n'))).toBe('text') + expect(sniffFileKind(Buffer.from('Caf\xe9 r\xe9sum\xe9', 'latin1'))).toBe('text') + }) +}) + +describe('reconcileParserRoute', () => { + it.each<[string, SniffedKind]>([ + ['pdf', 'pdf'], + ['docx', 'docx'], + ['docm', 'docx'], + ['xlsx', 'xlsx'], + ['xls', 'ole2'], + ['xlsx', 'ole2'], + ['ods', 'ods'], + ['pptx', 'pptx'], + ['odt', 'odt'], + ['odp', 'odp'], + ['doc', 'ole2'], + ['txt', 'text'], + ['csv', 'text'], + ['html', 'html'], + ['html', 'text'], + ['md', 'text'], + ])('keeps the .%s route when the bytes are %s', (extension, kind) => { + expect(reconcileParserRoute(extension, kind)).toEqual({ extension }) + }) + + it.each<[string, SniffedKind, string]>([ + ['xlsx', 'text', 'csv'], + ['xls', 'text', 'csv'], + ['txt', 'html', 'html'], + ['md', 'html', 'html'], + ['docx', 'pdf', 'pdf'], + ['txt', 'pdf', 'pdf'], + ['xlsx', 'docx', 'docx'], + ['doc', 'docx', 'docx'], + ['pdf', 'docx', 'docx'], + ['docx', 'xlsx', 'xlsx'], + ['docx', 'pptx', 'pptx'], + ['docx', 'odt', 'odt'], + ['odt', 'ods', 'ods'], + ['txt', 'odp', 'odp'], + ['docx', 'ole2', 'doc'], + ['doc', 'text', 'txt'], + ['docx', 'text', 'txt'], + ['pptx', 'text', 'txt'], + ['pdf', 'text', 'txt'], + ['odt', 'text', 'txt'], + ])('re-routes .%s holding %s to the %s parser with a warning', (extension, kind, route) => { + expect(reconcileParserRoute(extension, kind)).toEqual({ + extension: route, + detectedType: kind, + warning: expect.stringContaining(`parsed as .${route} instead of .${extension}`), + }) + }) + + it.each<[string, SniffedKind]>([ + ['txt', 'binary'], + ['csv', 'zip'], + ['txt', 'ole2'], + ['doc', 'binary'], + ['docx', 'binary'], + ['docx', 'zip'], + ['xlsx', 'binary'], + ['pdf', 'binary'], + ['pdf', 'ole2'], + ['odt', 'ole2'], + ['odt', 'zip'], + ])('rejects .%s holding %s as invalid_format', (extension, kind) => { + const error = (() => { + try { + reconcileParserRoute(extension, kind) + return null + } catch (caught) { + return caught + } + })() + + expect(error).toBeInstanceOf(FileParserError) + expect(error).toMatchObject({ code: 'invalid_format' }) + }) + + it('rejects a legacy OLE binary under a PowerPoint extension as unsupported_type', () => { + expect(() => reconcileParserRoute('pptx', 'ole2')).toThrow( + expect.objectContaining({ code: 'unsupported_type' }) + ) + }) + + it('leaves an extension with no known family alone', () => { + expect(reconcileParserRoute('unknown', 'binary')).toEqual({ extension: 'unknown' }) + }) +}) + +describe('parseBuffer reconciles the extension with the sniffed bytes', () => { + it('parses CSV bytes labelled .xlsx as CSV and keeps their UTF-8 intact', async () => { + const result = await parseBuffer(Buffer.from('name,city\nAna,Araújo\n'), 'xlsx') + + expect(result.content).toContain('Araújo') + expect(result.content).not.toContain('Ã') + expect(result.metadata).toMatchObject({ + detectedType: 'text', + warning: expect.stringContaining('parsed as .csv instead of .xlsx'), + }) + }) + + it('strips markup from an HTML document labelled .txt', async () => { + const result = await parseBuffer( + Buffer.from('

      Memo

      Body text

      '), + 'txt' + ) + + expect(result.content).toContain('Body text') + expect(result.content.toLowerCase()).not.toContain(' { + const result = await parseBuffer(await buildDocx('Office Relocation'), 'xlsx') + + expect(result.content).toContain('Office Relocation') + expect(result.metadata?.detectedType).toBe('docx') + }) + + it('extracts a docx labelled .doc through the Word parser without degrading', async () => { + const result = await parseBuffer(await buildDocx('Office Relocation'), 'doc') + + expect(result.content).toContain('Office Relocation') + expect(result.metadata?.degraded).toBeFalsy() + }) + + it('keeps plain text labelled .docx as text with a warning', async () => { + const result = await parseBuffer(Buffer.from('Vendor list\nBloomberg\n'), 'docx') + + expect(result.content).toContain('Bloomberg') + expect(result.metadata?.warning).toContain('parsed as .txt instead of .docx') + }) + + it('rejects a PNG labelled .doc with a typed error instead of placeholder prose', async () => { + await expect(parseBuffer(pngBinary(), 'doc')).rejects.toMatchObject({ + name: 'FileParserError', + code: 'invalid_format', + }) + }) + + it('rejects an OLE binary labelled .txt', async () => { + await expect(parseBuffer(oleBinary(), 'txt')).rejects.toMatchObject({ code: 'invalid_format' }) + }) + + it('rejects a legacy OLE deck labelled .pptx as unsupported', async () => { + await expect(parseBuffer(oleBinary(), 'pptx')).rejects.toMatchObject({ + code: 'unsupported_type', + }) + }) + + it('refuses the .ppt extension before sniffing', async () => { + await expect(parseBuffer(oleBinary(), 'ppt')).rejects.toMatchObject({ + code: 'unsupported_type', + }) + }) + + it('surfaces a truncated OOXML archive as a typed invalid_format failure', async () => { + const truncated = (await buildDocx('Office Relocation')).subarray(0, 200) + + await expect(parseBuffer(truncated, 'docx')).rejects.toMatchObject({ + name: 'FileParserError', + code: 'invalid_format', + }) + }) + + it('decodes a Latin-1 text file and reports the encoding', async () => { + const result = await parseBuffer( + Buffer.from('Caf\xe9 r\xe9sum\xe9 na\xefve \xa3 42', 'latin1'), + 'txt' + ) + + expect(result.content).toBe('Café résumé naïve £ 42') + expect(result.metadata).toMatchObject({ encoding: 'windows-1252', characterCount: 22 }) + }) +}) diff --git a/apps/sim/lib/file-parsers/sniff.ts b/apps/sim/lib/file-parsers/sniff.ts new file mode 100644 index 00000000000..74c707eef6a --- /dev/null +++ b/apps/sim/lib/file-parsers/sniff.ts @@ -0,0 +1,307 @@ +import { FileParserError } from '@/lib/file-parsers/errors' +import { decodeTextBuffer, detectBomlessUtf16 } from '@/lib/file-parsers/utils' +import { isZipShaped } from '@/lib/file-parsers/zip-guard' + +/** + * What the bytes of a buffer look like, independent of the caller-supplied + * extension. `zip` is a ZIP archive that is none of the recognized Office + * containers; `ole2` is any OLE compound file (legacy `.doc`/`.xls`/`.ppt`). + */ +export type SniffedKind = + | 'pdf' + | 'docx' + | 'xlsx' + | 'pptx' + | 'odt' + | 'ods' + | 'odp' + | 'zip' + | 'ole2' + | 'html' + | 'text' + | 'binary' + +const PDF_HEAD_WINDOW = 1024 +const TEXT_HEAD_WINDOW = 4096 +const PDF_SIGNATURE = Buffer.from('%PDF-', 'latin1') +const OLE2_SIGNATURE = Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]) + +const EOCD_SIGNATURE = 0x06054b50 +const EOCD_MIN_SIZE = 22 +const MAX_EOCD_COMMENT_SIZE = 0xffff +const ZIP64_EOCD_LOCATOR_SIGNATURE = 0x07064b50 +const ZIP64_EOCD_LOCATOR_SIZE = 20 +const ZIP64_EOCD_SIGNATURE = 0x06064b50 +const CENTRAL_DIRECTORY_HEADER_SIGNATURE = 0x02014b50 +const CENTRAL_DIRECTORY_HEADER_MIN_SIZE = 46 +const LOCAL_FILE_HEADER_MIN_SIZE = 30 +const COMPRESSION_METHOD_STORED = 0 +const UINT16_SENTINEL = 0xffff +const UINT32_SENTINEL = 0xffffffff +/** Enough to reach the first `word/`, `xl/` or `ppt/` part in any real package. */ +const MAX_INSPECTED_ENTRIES = 256 +const MAX_MIMETYPE_BYTES = 128 + +const ODF_MIMETYPES: Record = { + 'application/vnd.oasis.opendocument.text': 'odt', + 'application/vnd.oasis.opendocument.spreadsheet': 'ods', + 'application/vnd.oasis.opendocument.presentation': 'odp', +} + +interface ZipEntry { + name: string + compressionMethod: number + compressedSize: number + localHeaderOffset: number +} + +/** Same EOCD anchoring as the zip guard: only a record whose comment ends the buffer counts. */ +function findEocdOffset(buffer: Buffer): number { + const minStart = Math.max(0, buffer.length - EOCD_MIN_SIZE - MAX_EOCD_COMMENT_SIZE) + for (let offset = buffer.length - EOCD_MIN_SIZE; offset >= minStart; offset--) { + if (buffer.readUInt32LE(offset) !== EOCD_SIGNATURE) continue + const commentLength = buffer.readUInt16LE(offset + 20) + if (offset + EOCD_MIN_SIZE + commentLength === buffer.length) return offset + } + return -1 +} + +function locateCentralDirectory(buffer: Buffer, eocdOffset: number): number | null { + const entryCount = buffer.readUInt16LE(eocdOffset + 10) + const directoryOffset = buffer.readUInt32LE(eocdOffset + 16) + if (entryCount !== UINT16_SENTINEL && directoryOffset !== UINT32_SENTINEL) { + return directoryOffset + } + + const locatorOffset = eocdOffset - ZIP64_EOCD_LOCATOR_SIZE + if (locatorOffset < 0 || buffer.readUInt32LE(locatorOffset) !== ZIP64_EOCD_LOCATOR_SIGNATURE) { + return null + } + const zip64Eocd = buffer.readBigUInt64LE(locatorOffset + 8) + if (zip64Eocd > BigInt(buffer.length - 56)) return null + const zip64EocdOffset = Number(zip64Eocd) + if (buffer.readUInt32LE(zip64EocdOffset) !== ZIP64_EOCD_SIGNATURE) return null + const zip64DirectoryOffset = buffer.readBigUInt64LE(zip64EocdOffset + 48) + if (zip64DirectoryOffset > BigInt(buffer.length)) return null + return Number(zip64DirectoryOffset) +} + +/** + * Reads central-directory entry names without decompressing anything. Returns + * `null` for a buffer whose directory cannot be located. Bounded to the first + * {@link MAX_INSPECTED_ENTRIES} records so a large archive costs no more than a + * small one. + */ +function readZipEntries(buffer: Buffer): ZipEntry[] | null { + if (buffer.length < EOCD_MIN_SIZE) return null + const eocdOffset = findEocdOffset(buffer) + if (eocdOffset < 0) return null + const directoryOffset = locateCentralDirectory(buffer, eocdOffset) + if (directoryOffset === null) return null + + const entries: ZipEntry[] = [] + let cursor = directoryOffset + while ( + entries.length < MAX_INSPECTED_ENTRIES && + cursor + CENTRAL_DIRECTORY_HEADER_MIN_SIZE <= buffer.length && + buffer.readUInt32LE(cursor) === CENTRAL_DIRECTORY_HEADER_SIGNATURE + ) { + const fileNameLength = buffer.readUInt16LE(cursor + 28) + const extraFieldLength = buffer.readUInt16LE(cursor + 30) + const commentLength = buffer.readUInt16LE(cursor + 32) + const nameStart = cursor + CENTRAL_DIRECTORY_HEADER_MIN_SIZE + if (nameStart + fileNameLength > buffer.length) break + entries.push({ + name: buffer.toString('utf8', nameStart, nameStart + fileNameLength), + compressionMethod: buffer.readUInt16LE(cursor + 10), + compressedSize: buffer.readUInt32LE(cursor + 20), + localHeaderOffset: buffer.readUInt32LE(cursor + 42), + }) + cursor = nameStart + fileNameLength + extraFieldLength + commentLength + } + return entries +} + +/** The stored `mimetype` entry's bytes, which OpenDocument requires to be uncompressed. */ +function readStoredEntry(buffer: Buffer, entry: ZipEntry, maxBytes: number): string | null { + if (entry.compressionMethod !== COMPRESSION_METHOD_STORED || entry.compressedSize > maxBytes) { + return null + } + const headerOffset = entry.localHeaderOffset + if (headerOffset + LOCAL_FILE_HEADER_MIN_SIZE > buffer.length) return null + const fileNameLength = buffer.readUInt16LE(headerOffset + 26) + const extraFieldLength = buffer.readUInt16LE(headerOffset + 28) + const dataStart = headerOffset + LOCAL_FILE_HEADER_MIN_SIZE + fileNameLength + extraFieldLength + const dataEnd = dataStart + entry.compressedSize + if (dataEnd > buffer.length) return null + return buffer.toString('latin1', dataStart, dataEnd).trim() +} + +function classifyZip(buffer: Buffer): SniffedKind { + const entries = readZipEntries(buffer) + if (!entries) return 'zip' + + const mimetypeEntry = entries.find((entry) => entry.name === 'mimetype') + if (mimetypeEntry) { + const mimetype = readStoredEntry(buffer, mimetypeEntry, MAX_MIMETYPE_BYTES) + if (mimetype && mimetype in ODF_MIMETYPES) return ODF_MIMETYPES[mimetype] + } + + for (const { name } of entries) { + if (name.startsWith('word/')) return 'docx' + if (name.startsWith('xl/')) return 'xlsx' + if (name.startsWith('ppt/')) return 'pptx' + } + return 'zip' +} + +function hasUtf16Bom(head: Buffer): boolean { + return ( + head.length >= 2 && + ((head[0] === 0xff && head[1] === 0xfe) || (head[0] === 0xfe && head[1] === 0xff)) + ) +} + +function sniffTextKind(buffer: Buffer): SniffedKind { + const head = buffer.subarray(0, TEXT_HEAD_WINDOW) + if (head.includes(0) && !hasUtf16Bom(head) && detectBomlessUtf16(head) === null) { + return 'binary' + } + + const leading = decodeTextBuffer(head).text.trimStart().slice(0, 16).toLowerCase() + if (leading.startsWith('= OLE2_SIGNATURE.length && buffer.subarray(0, 8).equals(OLE2_SIGNATURE)) { + return 'ole2' + } + if (isZipShaped(buffer)) return classifyZip(buffer) + return sniffTextKind(buffer) +} + +/** The container family an extension promises, so a mismatch can be reconciled. */ +export type ExtensionFamily = + | 'pdf' + | 'word' + | 'sheet' + | 'presentation' + | 'opendocument' + | 'ole' + | 'text' + +const EXTENSION_FAMILIES: Record = { + pdf: 'pdf', + docx: 'word', + docm: 'word', + dotx: 'word', + xlsx: 'sheet', + xls: 'sheet', + xlsm: 'sheet', + xlsb: 'sheet', + xltx: 'sheet', + ods: 'sheet', + pptx: 'presentation', + pptm: 'presentation', + potx: 'presentation', + odt: 'opendocument', + odp: 'opendocument', + doc: 'ole', + txt: 'text', + md: 'text', + csv: 'text', + json: 'text', + jsonl: 'text', + yaml: 'text', + yml: 'text', + html: 'text', + htm: 'text', +} + +/** Sniffed kinds that are exactly what each family's parsers read. */ +const FAMILY_ACCEPTS: Record> = { + pdf: new Set(['pdf']), + word: new Set(['docx']), + sheet: new Set(['xlsx', 'ods', 'ole2']), + presentation: new Set(['pptx']), + opendocument: new Set(['odt', 'odp']), + ole: new Set(['ole2']), + text: new Set(['text', 'html']), +} + +/** Sniffed kinds that name their own parser regardless of the extension. */ +const KIND_ROUTES: Partial> = { + pdf: 'pdf', + docx: 'docx', + xlsx: 'xlsx', + pptx: 'pptx', + odt: 'odt', + ods: 'ods', + odp: 'odp', + html: 'html', +} + +export interface ParserRoute { + /** Registry key to parse with — the extension itself when the bytes agree with it. */ + extension: string + /** Set only when the route differs from the extension. */ + detectedType?: SniffedKind + warning?: string +} + +function invalidFormat(extension: string, kind: SniffedKind): FileParserError { + return new FileParserError( + 'invalid_format', + `File content does not match the .${extension} extension (detected ${kind}). Re-save it in a supported format and retry.` + ) +} + +/** + * Reconciles the caller-supplied extension with what the bytes are. Magic wins + * over the name, as in Tika and unstructured: when the sniffed kind has its own + * parser the route is overridden and a warning recorded; when it has none and + * the family disagrees, the buffer is rejected as `invalid_format` rather than + * fed to a parser that would emit mojibake or placeholder prose. + * + * Plain text under a binary extension keeps today's behavior of parsing as + * text (as CSV under a spreadsheet extension), and an OLE2 file under a modern + * Word extension is the legacy `.doc` parser's job. Legacy `.ppt` has no reader. + */ +export function reconcileParserRoute(extension: string, kind: SniffedKind): ParserRoute { + const family = EXTENSION_FAMILIES[extension] + if (!family) return { extension } + + const override = (route: string): ParserRoute => ({ + extension: route, + detectedType: kind, + warning: `File content was detected as ${kind}; parsed as .${route} instead of .${extension}`, + }) + + if (kind === 'html' && family === 'text' && extension !== 'html' && extension !== 'htm') { + return override('html') + } + if (FAMILY_ACCEPTS[family].has(kind)) return { extension } + + if (kind === 'text') return override(family === 'sheet' ? 'csv' : 'txt') + if (kind === 'ole2') { + if (family === 'word') return override('doc') + if (family === 'presentation') { + throw new FileParserError( + 'unsupported_type', + 'Legacy binary PowerPoint (.ppt) files are not supported. Save the file as .pptx and retry.' + ) + } + throw invalidFormat(extension, kind) + } + + const route = KIND_ROUTES[kind] + if (route) return override(route) + throw invalidFormat(extension, kind) +} diff --git a/apps/sim/lib/file-parsers/txt-parser.ts b/apps/sim/lib/file-parsers/txt-parser.ts index 3bb9e377859..ff2996202aa 100644 --- a/apps/sim/lib/file-parsers/txt-parser.ts +++ b/apps/sim/lib/file-parsers/txt-parser.ts @@ -1,7 +1,7 @@ import { readFile } from 'fs/promises' import { createLogger } from '@sim/logger' import type { FileParseResult, FileParser } from '@/lib/file-parsers/types' -import { sanitizeTextForUTF8 } from '@/lib/file-parsers/utils' +import { decodeTextBuffer, sanitizeTextForUTF8 } from '@/lib/file-parsers/utils' const logger = createLogger('TxtParser') @@ -25,14 +25,16 @@ export class TxtParser implements FileParser { try { logger.info('Parsing buffer, size:', buffer.length) - const rawContent = buffer.toString('utf-8') - const result = sanitizeTextForUTF8(rawContent) + const decoded = decodeTextBuffer(buffer) + const result = sanitizeTextForUTF8(decoded.text) return { content: result, metadata: { characterCount: result.length, tokenCount: result.length / 4, + encoding: decoded.encoding, + ...(decoded.warning ? { warning: decoded.warning } : {}), }, } } catch (error) { diff --git a/apps/sim/lib/file-parsers/types.ts b/apps/sim/lib/file-parsers/types.ts index 834f2fc4632..fb57d0b3627 100644 --- a/apps/sim/lib/file-parsers/types.ts +++ b/apps/sim/lib/file-parsers/types.ts @@ -7,10 +7,10 @@ export interface FileParseMetadata { * True when no real extraction happened and `content` is best-effort scraped * bytes or a placeholder message rather than the document's text. * - * The legacy-format parsers (`doc`, `ppt`) deliberately never throw, so an - * interactive upload still shows the user something. An automated caller must - * not index that: it embeds ZIP internals or an English placeholder sentence as - * if it were document content. Such callers check this flag and skip the file. + * The legacy `pptx` fallback deliberately never throws, so an interactive + * upload still shows the user something. An automated caller must not index + * that: it embeds ZIP internals or an English placeholder sentence as if it + * were document content. Such callers check this flag and skip the file. */ degraded?: boolean extractionMethod?: string @@ -59,7 +59,6 @@ export type SupportedFileType = | 'html' | 'htm' | 'pptx' - | 'ppt' | 'pptm' | 'potx' | 'odt' diff --git a/apps/sim/lib/file-parsers/utils.test.ts b/apps/sim/lib/file-parsers/utils.test.ts index b01a1ca6d51..e27f30b8729 100644 --- a/apps/sim/lib/file-parsers/utils.test.ts +++ b/apps/sim/lib/file-parsers/utils.test.ts @@ -2,7 +2,13 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { sanitizeTextForUTF8, truncationNotice } from '@/lib/file-parsers/utils' +import { + decodeTextBuffer, + sanitizeTextForUTF8, + TRUNCATED_UTF8_WARNING, + truncationNotice, + WINDOWS_1252_WARNING, +} from '@/lib/file-parsers/utils' const LONE_HIGH = '\uD800' const LONE_LOW = '\uDC00' @@ -52,3 +58,82 @@ describe('truncationNotice', () => { ) }) }) + +describe('decodeTextBuffer', () => { + it('decodes clean UTF-8 without a warning', () => { + const decoded = decodeTextBuffer(Buffer.from('Café résumé 😀', 'utf8')) + + expect(decoded).toEqual({ text: 'Café résumé 😀', encoding: 'utf-8' }) + }) + + it('strips a UTF-8 BOM so it never reaches content or character counts', () => { + const decoded = decodeTextBuffer( + Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from('Café ok')]) + ) + + expect(decoded.text).toBe('Café ok') + expect(decoded.text.length).toBe(7) + expect(decoded.encoding).toBe('utf-8') + }) + + it('decodes Latin-1 bytes as Windows-1252 instead of destroying accented characters', () => { + const decoded = decodeTextBuffer(Buffer.from('Caf\xe9 r\xe9sum\xe9 na\xefve \xa3 42', 'latin1')) + + expect(decoded.text).toBe('Café résumé naïve £ 42') + expect(decoded.encoding).toBe('windows-1252') + expect(decoded.warning).toBe(WINDOWS_1252_WARNING) + expect(sanitizeTextForUTF8(decoded.text)).toBe('Café résumé naïve £ 42') + }) + + it('decodes Windows-1252 smart quotes, dashes and the euro sign', () => { + const decoded = decodeTextBuffer( + Buffer.from([0x93, 0x53, 0x6d, 0x61, 0x72, 0x74, 0x94, 0x20, 0x96, 0x20, 0x80, 0x35]) + ) + + expect(decoded.text).toBe('“Smart” – €5') + expect(decoded.encoding).toBe('windows-1252') + }) + + it('decodes UTF-16LE with a BOM, including non-ASCII characters', () => { + const decoded = decodeTextBuffer( + Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from('Hello UTF-16 wörld €', 'utf16le')]) + ) + + expect(decoded).toEqual({ text: 'Hello UTF-16 wörld €', encoding: 'utf-16le' }) + }) + + it('decodes UTF-16BE with a BOM', () => { + const decoded = decodeTextBuffer(Buffer.from([0xfe, 0xff, 0x00, 0x48, 0x00, 0x69, 0x20, 0xac])) + + expect(decoded).toEqual({ text: 'Hi€', encoding: 'utf-16be' }) + }) + + it('recognizes BOM-less UTF-16LE text instead of reading it as NUL-riddled UTF-8', () => { + const decoded = decodeTextBuffer(Buffer.from('Hello UTF-16 world without a BOM', 'utf16le')) + + expect(decoded).toEqual({ text: 'Hello UTF-16 world without a BOM', encoding: 'utf-16le' }) + }) + + it('keeps the UTF-8 reading when only a trailing codepoint was truncated', () => { + const full = Buffer.from('Truncated download résumé 😀', 'utf8') + const decoded = decodeTextBuffer(full.subarray(0, full.length - 2)) + + expect(decoded.text).toBe('Truncated download résumé ') + expect(decoded.encoding).toBe('utf-8') + expect(decoded.warning).toBe(TRUNCATED_UTF8_WARNING) + }) + + it('does not mistake a Latin-1 file ending in an accented letter for truncated UTF-8', () => { + expect(decodeTextBuffer(Buffer.from('name: Caf\xe9', 'latin1'))).toMatchObject({ + text: 'name: Café', + encoding: 'windows-1252', + }) + expect(decodeTextBuffer(Buffer.from('Caf\xe9\n', 'latin1')).text).toBe('Café\n') + }) + + it('never emits a replacement character for single-byte input', () => { + const everyByte = Buffer.from(Array.from({ length: 256 }, (_, index) => index)) + + expect(decodeTextBuffer(everyByte).text).not.toContain('�') + }) +}) diff --git a/apps/sim/lib/file-parsers/utils.ts b/apps/sim/lib/file-parsers/utils.ts index 02832e688dc..e7edb68d681 100644 --- a/apps/sim/lib/file-parsers/utils.ts +++ b/apps/sim/lib/file-parsers/utils.ts @@ -24,3 +24,188 @@ export function sanitizeTextForUTF8(text: string): string { export function truncationNotice(detail: string): string { return `\n[... ${detail} ...]\n` } + +/** Character encodings {@link decodeTextBuffer} can produce. */ +export type TextEncodingLabel = 'utf-8' | 'utf-16le' | 'utf-16be' | 'windows-1252' + +export interface DecodedText { + text: string + encoding: TextEncodingLabel + /** Set when the bytes were not clean UTF-8 and a lossy or inferred decode was used. */ + warning?: string +} + +const strictUtf8Decoder = new TextDecoder('utf-8', { fatal: true }) +const utf16leDecoder = new TextDecoder('utf-16le') +const utf16beDecoder = new TextDecoder('utf-16be') + +/** + * WHATWG windows-1252: identical to Latin-1 except 0x80–0x9F, which hold the + * typographic characters (smart quotes, dashes, €, …) instead of C1 controls. + * Implemented here rather than via `TextDecoder('windows-1252')` because a + * Node build without full ICU silently falls back to Latin-1 for that label, + * so the same bytes would decode differently under test and in production. + */ +const WINDOWS_1252_C1 = [ + '\u20AC', + '\u0081', + '\u201A', + '\u0192', + '\u201E', + '\u2026', + '\u2020', + '\u2021', + '\u02C6', + '\u2030', + '\u0160', + '\u2039', + '\u0152', + '\u008D', + '\u017D', + '\u008F', + '\u0090', + '\u2018', + '\u2019', + '\u201C', + '\u201D', + '\u2022', + '\u2013', + '\u2014', + '\u02DC', + '\u2122', + '\u0161', + '\u203A', + '\u0153', + '\u009D', + '\u017E', + '\u0178', +] as const +const C1_RANGE = /[\u0080-\u009F]/g + +function decodeWindows1252(buffer: Uint8Array): string { + return Buffer.from(buffer.buffer, buffer.byteOffset, buffer.byteLength) + .toString('latin1') + .replace(C1_RANGE, (char) => WINDOWS_1252_C1[char.charCodeAt(0) - 0x80]) +} + +const UTF8_BOM_LENGTH = 3 +const UTF16_BOM_LENGTH = 2 +/** A UTF-8 sequence is at most four bytes, so a truncated tail is at most three. */ +const MAX_TRUNCATED_UTF8_TAIL = 3 +const UTF16_HEURISTIC_SAMPLE_BYTES = 4096 + +export const TRUNCATED_UTF8_WARNING = 'Trailing bytes of an incomplete UTF-8 sequence were dropped' +export const WINDOWS_1252_WARNING = 'File was not valid UTF-8; decoded as Windows-1252' + +function stripLeadingBom(text: string): string { + return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text +} + +/** Declared length of the UTF-8 sequence a lead byte starts, or 0 for a non-lead byte. */ +function utf8SequenceLength(lead: number): number { + if (lead >= 0xc2 && lead <= 0xdf) return 2 + if (lead >= 0xe0 && lead <= 0xef) return 3 + if (lead >= 0xf0 && lead <= 0xf4) return 4 + return 0 +} + +/** + * Whether the last `tailLength` bytes look like the cut-off start of one UTF-8 + * sequence (a lead byte followed only by continuation bytes, shorter than the + * length the lead declares) AND the bytes before it already contain multi-byte + * UTF-8. Without that second condition a Latin-1 file that merely ends in an + * accented letter would be misread as truncated UTF-8 and lose the letter. + */ +function isTruncatedUtf8Tail(buffer: Uint8Array, tailLength: number): boolean { + const tailStart = buffer.length - tailLength + const declared = utf8SequenceLength(buffer[tailStart]) + if (declared === 0 || tailLength >= declared) return false + for (let index = tailStart + 1; index < buffer.length; index++) { + if ((buffer[index] & 0xc0) !== 0x80) return false + } + for (let index = 0; index < tailStart; index++) { + if (buffer[index] >= 0x80) return true + } + return false +} + +/** + * Whether a BOM-less buffer is laid out as UTF-16 ASCII-range text: one half of + * every byte pair is NUL while the other is not. Reports the byte order of the + * non-NUL half, or `null` when the sample does not fit either layout. + */ +export function detectBomlessUtf16(buffer: Uint8Array): 'utf-16le' | 'utf-16be' | null { + const sampleLength = Math.min(buffer.length, UTF16_HEURISTIC_SAMPLE_BYTES) & ~1 + if (sampleLength < 4) return null + + let evenNul = 0 + let oddNul = 0 + for (let index = 0; index < sampleLength; index += 2) { + if (buffer[index] === 0) evenNul++ + if (buffer[index + 1] === 0) oddNul++ + } + + const pairs = sampleLength / 2 + const highThreshold = pairs * 0.9 + const lowThreshold = pairs * 0.05 + if (oddNul >= highThreshold && evenNul <= lowThreshold) return 'utf-16le' + if (evenNul >= highThreshold && oddNul <= lowThreshold) return 'utf-16be' + return null +} + +/** + * Decodes text bytes without ever emitting U+FFFD for single-byte input. + * + * Order: a UTF-16 BOM wins; otherwise strict UTF-8 (which also consumes a UTF-8 + * BOM). When strict UTF-8 rejects the buffer it is retried with up to three + * trailing bytes removed, so a size-capped download cut mid-codepoint keeps its + * UTF-8 reading instead of falling to Windows-1252 wholesale. Only then is the + * whole buffer read as Windows-1252, which is a superset of Latin-1 and decodes + * every byte, so `sanitizeTextForUTF8` has nothing to delete. Never throws. + */ +export function decodeTextBuffer(buffer: Uint8Array): DecodedText { + if (buffer.length >= UTF16_BOM_LENGTH) { + if (buffer[0] === 0xff && buffer[1] === 0xfe) { + return { + text: stripLeadingBom(utf16leDecoder.decode(buffer.subarray(UTF16_BOM_LENGTH))), + encoding: 'utf-16le', + } + } + if (buffer[0] === 0xfe && buffer[1] === 0xff) { + return { + text: stripLeadingBom(utf16beDecoder.decode(buffer.subarray(UTF16_BOM_LENGTH))), + encoding: 'utf-16be', + } + } + } + + const bomlessUtf16 = detectBomlessUtf16(buffer) + if (bomlessUtf16 === 'utf-16le') { + return { text: utf16leDecoder.decode(buffer), encoding: 'utf-16le' } + } + if (bomlessUtf16 === 'utf-16be') { + return { text: utf16beDecoder.decode(buffer), encoding: 'utf-16be' } + } + + try { + return { text: strictUtf8Decoder.decode(buffer), encoding: 'utf-8' } + } catch { + for (let dropped = 1; dropped <= MAX_TRUNCATED_UTF8_TAIL; dropped++) { + if (buffer.length - dropped < UTF8_BOM_LENGTH) break + if (!isTruncatedUtf8Tail(buffer, dropped)) continue + try { + return { + text: strictUtf8Decoder.decode(buffer.subarray(0, buffer.length - dropped)), + encoding: 'utf-8', + warning: TRUNCATED_UTF8_WARNING, + } + } catch {} + } + } + + return { + text: decodeWindows1252(buffer), + encoding: 'windows-1252', + warning: WINDOWS_1252_WARNING, + } +} diff --git a/apps/sim/lib/file-parsers/yaml-parser.test.ts b/apps/sim/lib/file-parsers/yaml-parser.test.ts index 08e24623620..82963d02ee3 100644 --- a/apps/sim/lib/file-parsers/yaml-parser.test.ts +++ b/apps/sim/lib/file-parsers/yaml-parser.test.ts @@ -128,4 +128,16 @@ describe('assertYamlWithinLimits', () => { const astral = String.fromCodePoint(0x1f600).repeat(10 * 1024 * 1024) expect(() => assertYamlWithinLimits({ text: astral })).not.toThrow() }) + + it('decodes a BOM-prefixed Latin-1 YAML file without losing accented characters', async () => { + const bom = await parseYAMLBuffer( + Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from('name: Café')]) + ) + const latin1 = await parseYAMLBuffer(Buffer.from('name: Caf\xe9', 'latin1')) + + expect(JSON.parse(bom.content)).toEqual({ name: 'Café' }) + expect(bom.metadata?.encoding).toBe('utf-8') + expect(JSON.parse(latin1.content)).toEqual({ name: 'Café' }) + expect(latin1.metadata?.encoding).toBe('windows-1252') + }) }) diff --git a/apps/sim/lib/file-parsers/yaml-parser.ts b/apps/sim/lib/file-parsers/yaml-parser.ts index 8823cc4f6d8..f92bce14bd9 100644 --- a/apps/sim/lib/file-parsers/yaml-parser.ts +++ b/apps/sim/lib/file-parsers/yaml-parser.ts @@ -2,6 +2,7 @@ import { getErrorMessage } from '@sim/utils/errors' import * as yaml from 'js-yaml' import { FileParserError } from '@/lib/file-parsers/errors' import type { FileParseResult } from '@/lib/file-parsers/types' +import { type DecodedText, decodeTextBuffer } from '@/lib/file-parsers/utils' import { measureYamlExpansion, type YamlExpansionLimits } from '@/lib/file-parsers/yaml-limits' /** @@ -52,7 +53,7 @@ export function assertYamlWithinLimits(root: unknown): number { * Parse a YAML value into the shared `FileParseResult` shape after validating * that its expanded form stays within safe complexity limits. */ -function buildYamlResult(yamlData: unknown): FileParseResult { +function buildYamlResult(yamlData: unknown, decoded: DecodedText): FileParseResult { if (yamlData === undefined) { throw new FileParserError('empty_input', 'Empty YAML input provided') } @@ -66,6 +67,8 @@ function buildYamlResult(yamlData: unknown): FileParseResult { keys: Array.isArray(yamlData) ? [] : Object.keys((yamlData as Record) || {}), itemCount: Array.isArray(yamlData) ? yamlData.length : undefined, depth, + encoding: decoded.encoding, + ...(decoded.warning ? { warning: decoded.warning } : {}), } return { @@ -79,19 +82,7 @@ function buildYamlResult(yamlData: unknown): FileParseResult { */ export async function parseYAML(filePath: string): Promise { const fs = await import('fs/promises') - const content = await fs.readFile(filePath, 'utf-8') - - try { - const yamlData = yaml.load(content) - return buildYamlResult(yamlData) - } catch (error) { - if (error instanceof FileParserError) throw error - throw new FileParserError( - 'invalid_format', - `Invalid YAML: ${getErrorMessage(error, 'Unknown error')}`, - error - ) - } + return parseYAMLBuffer(await fs.readFile(filePath)) } /** @@ -102,11 +93,11 @@ export async function parseYAMLBuffer(buffer: Buffer): Promise throw new FileParserError('empty_input', 'Empty buffer provided') } - const content = buffer.toString('utf-8') + const decoded = decodeTextBuffer(buffer) try { - const yamlData = yaml.load(content) - return buildYamlResult(yamlData) + const yamlData = yaml.load(decoded.text) + return buildYamlResult(yamlData, decoded) } catch (error) { if (error instanceof FileParserError) throw error throw new FileParserError( diff --git a/apps/sim/lib/internal/file/parser.test.ts b/apps/sim/lib/internal/file/parser.test.ts index dc091099550..9df0f56b89f 100644 --- a/apps/sim/lib/internal/file/parser.test.ts +++ b/apps/sim/lib/internal/file/parser.test.ts @@ -462,6 +462,35 @@ describe('file parser operation', () => { ) }) + /** + * A parser that could only scrape bytes flags its output `degraded`; the tool + * must report that as a failure rather than hand placeholder prose to the model. + */ + it('reports degraded parser output as a failure instead of returning it as content', async () => { + setupFileApiMocks({ + cloudEnabled: false, + storageProvider: 'local', + authenticated: true, + }) + mockParseBuffer.mockResolvedValue({ + content: 'Unable to extract text from DOC file. Please convert to DOCX format.', + metadata: { degraded: true, warning: 'Basic text extraction used' }, + }) + const req = createMockRequest('POST', { + filePath: 'workspace/legacy.doc', + }) + + const response = await POST(req) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.success).toBe(false) + expect(data.error).toContain('Could not extract text from legacy.doc') + expect(data.error).toContain('Basic text extraction used') + expect(JSON.stringify(data)).not.toContain('Unable to extract text from DOC file') + expect(mockUploadExecutionFile).not.toHaveBeenCalled() + }) + it('should reject parser complexity limits instead of returning raw text', async () => { setupFileApiMocks({ cloudEnabled: true, diff --git a/apps/sim/lib/internal/file/parser.ts b/apps/sim/lib/internal/file/parser.ts index f47dc5e749f..3318736429b 100644 --- a/apps/sim/lib/internal/file/parser.ts +++ b/apps/sim/lib/internal/file/parser.ts @@ -893,6 +893,13 @@ async function handleLocalFile( }) const extension = path.extname(filename).toLowerCase().substring(1) const result = await parseBuffer(fileBuffer, extension, { signal }) + if (result.metadata?.degraded === true) { + return { + success: false, + error: degradedParseMessage(filename, result.metadata.warning), + filePath, + } + } const content = assertParsedContentWithinLimit(result.content, maxParsedOutputBytes) signal?.throwIfAborted() const hash = createHash('md5').update(fileBuffer).digest('hex') @@ -1085,6 +1092,13 @@ async function handleGenericTextBuffer( if (isSupportedFileType(extension)) { const result = await parseBuffer(fileBuffer, extension, { signal }) + if (result.metadata?.degraded === true) { + return { + success: false, + error: degradedParseMessage(filename, result.metadata.warning), + filePath: originalPath || filename, + } + } return { success: true, @@ -1189,6 +1203,15 @@ async function parseBufferAsPdf(buffer: Buffer, signal?: AbortSignal) { /** * Format bytes to human readable size */ +/** + * A parser that could not read the document but returned scraped bytes or a + * placeholder sentence flags the result `degraded`; that must reach the model + * as a failure, never as the file's content. + */ +function degradedParseMessage(filename: string, warning: string | undefined): string { + return `Could not extract text from ${filename}${warning ? `: ${warning}` : ''}` +} + function prettySize(bytes: number): string { if (bytes === 0) return '0 Bytes' diff --git a/apps/sim/lib/uploads/utils/validation.ts b/apps/sim/lib/uploads/utils/validation.ts index a6bbd26681b..72680c87f41 100644 --- a/apps/sim/lib/uploads/utils/validation.ts +++ b/apps/sim/lib/uploads/utils/validation.ts @@ -23,7 +23,6 @@ export const SUPPORTED_DOCUMENT_EXTENSIONS = [ 'md', 'xlsx', 'xls', - 'ppt', 'pptx', 'html', 'htm', @@ -155,7 +154,6 @@ export const SUPPORTED_MIME_TYPES: Record 'application/x-excel', 'application/x-msexcel', ], - ppt: ['application/vnd.ms-powerpoint', 'application/powerpoint', 'application/x-mspowerpoint'], pptx: [ 'application/vnd.openxmlformats-officedocument.presentationml.presentation', 'application/octet-stream', diff --git a/apps/sim/package.json b/apps/sim/package.json index e5f5cc2f0dd..f7a24e75f0a 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -70,9 +70,9 @@ "@better-auth/sso": "1.6.27", "@better-auth/stripe": "1.6.27", "@browserbasehq/stagehand": "^3.2.1", - "@calcom/embed-react": "1.5.3", "@c15t/nextjs": "2.2.1", "@c15t/scripts": "2.2.0", + "@calcom/embed-react": "1.5.3", "@cerebras/cerebras_cloud_sdk": "^1.23.0", "@daytona/sdk": "0.207.0", "@e2b/code-interpreter": "2.7.1", @@ -142,8 +142,8 @@ "@tiptap/extension-collaboration-caret": "3.30.5", "@tiptap/extension-image": "3.30.5", "@tiptap/extension-list": "3.30.5", - "@tiptap/extension-placeholder": "3.30.5", "@tiptap/extension-paragraph": "3.30.5", + "@tiptap/extension-placeholder": "3.30.5", "@tiptap/extension-table": "3.30.5", "@tiptap/markdown": "3.30.5", "@tiptap/pm": "3.30.5", @@ -175,8 +175,8 @@ "csv-parse": "7.0.2", "date-fns": "4.1.0", "decimal.js": "10.6.0", - "docx-preview": "^0.3.7", "docx": "^9.6.1", + "docx-preview": "^0.3.7", "drizzle-orm": "^0.45.2", "echarts": "6.1.0", "es-toolkit": "1.45.1", @@ -251,6 +251,7 @@ "typebox": "1.1.38", "undici": "7.29.0", "unified": "11.0.5", + "word-extractor": "1.0.4", "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", "y-protocols": "1.0.7", "yjs": "13.6.31", diff --git a/apps/sim/types/word-extractor.d.ts b/apps/sim/types/word-extractor.d.ts new file mode 100644 index 00000000000..2fe8e642786 --- /dev/null +++ b/apps/sim/types/word-extractor.d.ts @@ -0,0 +1,31 @@ +declare module 'word-extractor' { + interface WordTextOptions { + /** Converts common Unicode quotes to ASCII when true (the library default). */ + filterUnicode?: boolean + } + + interface WordHeaderOptions extends WordTextOptions { + includeFooters?: boolean + } + + interface WordTextboxOptions extends WordTextOptions { + includeHeadersAndFooters?: boolean + includeBody?: boolean + } + + class WordDocument { + getBody(options?: WordTextOptions): string + getFootnotes(options?: WordTextOptions): string + getEndnotes(options?: WordTextOptions): string + getHeaders(options?: WordHeaderOptions): string + getFooters(options?: WordTextOptions): string + getAnnotations(options?: WordTextOptions): string + getTextboxes(options?: WordTextboxOptions): string + } + + class WordExtractor { + extract(source: string | Buffer): Promise + } + + export = WordExtractor +} diff --git a/bun.lock b/bun.lock index b2bc1545076..3b63e4d5571 100644 --- a/bun.lock +++ b/bun.lock @@ -366,6 +366,7 @@ "typebox": "1.1.38", "undici": "7.29.0", "unified": "11.0.5", + "word-extractor": "1.0.4", "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", "y-protocols": "1.0.7", "yjs": "13.6.31", @@ -3129,6 +3130,8 @@ "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], + "fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], @@ -4685,6 +4688,8 @@ "widest-line": ["widest-line@3.1.0", "", { "dependencies": { "string-width": "^4.0.0" } }, "sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg=="], + "word-extractor": ["word-extractor@1.0.4", "", { "dependencies": { "saxes": "^5.0.1", "yauzl": "^2.10.0" } }, "sha512-PyAGZQ2gjnVA5kcZAOAxoYciCMaAvu0dbVlw/zxHphhy+3be8cDeYKHJPO8iedIM3Sx0arA/ugKTJyXhZNgo6g=="], + "wordwrap": ["wordwrap@1.0.0", "", {}, "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q=="], "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], @@ -5477,6 +5482,10 @@ "widest-line/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "word-extractor/saxes": ["saxes@5.0.1", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw=="], + + "word-extractor/yauzl": ["yauzl@2.10.0", "", { "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } }, "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g=="], + "wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], "wrap-ansi/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], @@ -6063,6 +6072,8 @@ "widest-line/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "word-extractor/yauzl/buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="], + "wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], From afb01b0800368e60c22566e6e9eafbadaa901cf6 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 9 Sep 2026 18:50:45 -0700 Subject: [PATCH 05/21] fix(parsers): decode HTML by detected encoding and add before/after benchmark Wires decodeTextBuffer into the HTML parser, refreshes the degraded docblock now that legacy formats raise typed errors, and adds the large corpus harness plus the regression-gated comparer. Co-Authored-By: Claude Fable 5.1 --- apps/sim/lib/file-parsers/html-parser.ts | 7 +- apps/sim/lib/file-parsers/types.ts | 9 +- apps/sim/scripts/parser-eval/bench-compare.py | 109 ++++++++++++++++++ apps/sim/scripts/parser-eval/bench-run.ts | 48 ++++++++ apps/sim/scripts/parser-eval/score.py | 6 +- 5 files changed, 170 insertions(+), 9 deletions(-) create mode 100644 apps/sim/scripts/parser-eval/bench-compare.py create mode 100644 apps/sim/scripts/parser-eval/bench-run.ts diff --git a/apps/sim/lib/file-parsers/html-parser.ts b/apps/sim/lib/file-parsers/html-parser.ts index f246af106a2..a11e5f5a594 100644 --- a/apps/sim/lib/file-parsers/html-parser.ts +++ b/apps/sim/lib/file-parsers/html-parser.ts @@ -4,7 +4,7 @@ import { getErrorMessage } from '@sim/utils/errors' import * as cheerio from 'cheerio' import { FileParserError } from '@/lib/file-parsers/errors' import type { FileParseResult, FileParser } from '@/lib/file-parsers/types' -import { sanitizeTextForUTF8 } from '@/lib/file-parsers/utils' +import { decodeTextBuffer, sanitizeTextForUTF8 } from '@/lib/file-parsers/utils' const logger = createLogger('HtmlParser') @@ -388,7 +388,8 @@ export class HtmlParser implements FileParser { try { logger.info('Parsing HTML buffer, size:', buffer.length) - const htmlContent = buffer.toString('utf-8') + const decoded = decodeTextBuffer(buffer) + const htmlContent = decoded.text const $ = cheerio.load(htmlContent) // Extract meta information before removing tags @@ -421,6 +422,8 @@ export class HtmlParser implements FileParser { links: links.slice(0, 50), hasImages: $('img').length > 0, imageCount: $('img').length, + encoding: decoded.encoding, + warning: decoded.warning, hasTable: $('table').length > 0, tableCount: $('table').length, hasList: $('ul, ol').length > 0, diff --git a/apps/sim/lib/file-parsers/types.ts b/apps/sim/lib/file-parsers/types.ts index 18447a05049..36b059a3bc7 100644 --- a/apps/sim/lib/file-parsers/types.ts +++ b/apps/sim/lib/file-parsers/types.ts @@ -7,10 +7,11 @@ export interface FileParseMetadata { * True when no real extraction happened and `content` is best-effort scraped * bytes or a placeholder message rather than the document's text. * - * The legacy `pptx` fallback deliberately never throws, so an interactive - * upload still shows the user something. An automated caller must not index - * that: it embeds ZIP internals or an English placeholder sentence as if it - * were document content. Such callers check this flag and skip the file. + * Set by extractors that can only return best-effort output, such as a + * spreadsheet whose cells are all blank. Legacy `.doc` and `.ppt` inputs used + * to fall through to a byte scrape reported this way; they now raise typed + * errors instead. An automated caller must not index degraded content, and + * every automated consumer checks this flag and skips the file. */ degraded?: boolean extractionMethod?: string diff --git a/apps/sim/scripts/parser-eval/bench-compare.py b/apps/sim/scripts/parser-eval/bench-compare.py new file mode 100644 index 00000000000..4de7f619433 --- /dev/null +++ b/apps/sim/scripts/parser-eval/bench-compare.py @@ -0,0 +1,109 @@ +"""Compares two bench-run output directories file by file and applies a strict regression gate. +Usage: bench-compare.py [report.md] +""" +import json, os, re, statistics, sys, unicodedata +from collections import Counter, defaultdict +from rapidfuzz import fuzz + +BENCH, BEFORE, AFTER = sys.argv[1:4] +REPORT = sys.argv[4] if len(sys.argv) > 4 else os.path.join(BENCH, 'compare.md') +REF = os.path.join(BENCH, 'reference') +WORD = re.compile(r'\w+', re.UNICODE) +NEEDLE_CAP, HAY_CAP, LINE_CAP = 160, 400_000, 300 +PAGE_NUM = re.compile(r'^(page\s*)?\d{1,4}(\s*(of|/)\s*\d{1,4})?$', re.I) +# Extensions whose ok->error flips are intended: legacy .ppt now refuses with a typed error. +INTENDED_ERROR_EXTS = {'ppt'} + +def norm(t): + t = unicodedata.normalize('NFKC', t or '').replace('­', '').replace('‑', '-').lower() + return re.sub(r'\s+', ' ', t).strip() +def vocab(t): return {w for w in WORD.findall(norm(t)) if len(w) >= 2} +def found(needle, hay): + n = norm(needle)[:NEEDLE_CAP] + if not n: return False + if n in hay: return True + return fuzz.partial_ratio(n, hay[:HAY_CAP], score_cutoff=90) >= 90 +def blocks(t): return [l.strip() for l in (t or '').split('\n') if l.strip()] +def junk_per_1k(t): + bad = sum(1 for ch in t if (unicodedata.category(ch) in ('Cc', 'Co', 'Cn') and ch not in '\n\t\r') or ch in '�­') + return round(1000 * bad / max(1, len(t)), 2) +def r(x): return None if x is None else round(x, 4) + +def load_ref(label): + p = os.path.join(REF, label.split('__', 1)[1] + '.json') + if not os.path.exists(p): return {} + j = json.load(open(p)) + return {k: v for k, v in j.items() if isinstance(v, str) and k != 'error' and v.strip()} + +def metrics(rec, refs): + if not rec['ok']: + return dict(ok=False, typed=bool(rec.get('typedError')), code=rec.get('errorCode') or rec.get('errorName'), error=rec.get('error', '')[:120], degraded=False) + out = rec['content']; n_out = norm(out) + m = dict(ok=True, typed=None, degraded=bool(rec['metadata'].get('degraded')), truncated=bool(rec['metadata'].get('truncated')), length=len(out), lines=len(blocks(out)), chunks=rec.get('chunkCount'), ms=rec['ms'], junk=junk_per_1k(out), detected=rec['metadata'].get('detectedType'), method=rec['metadata'].get('extractionMethod'), encoding=rec['metadata'].get('encoding')) + line_counts = Counter(norm(l) for l in blocks(out) if 0 < len(l) <= 120) + m['repeated_lines'] = sum(c - 1 for c in line_counts.values() if c >= 3) + m['page_number_lines'] = sum(1 for l in blocks(out) if PAGE_NUM.match(l)) + ref_vocab = set(); recalls = []; precisions = [] + for name, text in refs.items(): + n_ref = norm(text); ref_vocab |= vocab(text) + ref_lines = [l for l in blocks(text) if len(l) >= 25][:LINE_CAP] + out_lines = [l for l in blocks(out) if len(l) >= 25][:LINE_CAP] + if ref_lines: recalls.append(sum(found(l, n_out) for l in ref_lines) / len(ref_lines)) + if out_lines: precisions.append(sum(found(l, n_ref) for l in out_lines) / len(out_lines)) + m['recall'] = r(max(recalls)) if recalls else None + m['precision'] = r(max(precisions)) if precisions else None + if ref_vocab: + words = [w for w in WORD.findall(n_out) if len(w) >= 2] + noise = [w for w in words if w not in ref_vocab] + m['noise'] = r(len(noise) / max(1, len(words))) + m['glued'] = len({w for w in set(noise) if len(w) >= 6 and any(w[:i] in ref_vocab and w[i:] in ref_vocab and i >= 2 and len(w) - i >= 2 for i in range(2, len(w) - 1))}) + m['ref_vocab_recall'] = r(len(set(words) & ref_vocab) / max(1, len(ref_vocab))) + return m + +rows = [] +labels = sorted(set(os.listdir(BEFORE)) & set(os.listdir(AFTER))) +for f in labels: + if f.startswith('_'): continue + b = json.load(open(os.path.join(BEFORE, f))); a = json.load(open(os.path.join(AFTER, f))) + refs = load_ref(b['label']) + mb, ma = metrics(b, refs), metrics(a, refs) + flags = [] + if mb['ok'] and not ma['ok']: + if b['ext'] in INTENDED_ERROR_EXTS or (mb.get('degraded') and ma.get('typed')): flags.append('intended:ok->typed-error') + elif ma.get('typed'): flags.append('REGRESSION:ok->error') + else: flags.append('REGRESSION:ok->untyped-error') + if not mb['ok'] and ma['ok']: flags.append('improved:error->ok') + if not mb['ok'] and not ma['ok'] and not mb.get('typed') and ma.get('typed'): flags.append('improved:untyped->typed') + if mb['ok'] and ma['ok']: + if mb.get('recall') is not None and ma.get('recall') is not None and ma['recall'] < mb['recall'] - 0.02: flags.append(f"REGRESSION:recall {mb['recall']}->{ma['recall']}") + if mb.get('ref_vocab_recall') is not None and ma.get('ref_vocab_recall') is not None and ma['ref_vocab_recall'] < mb['ref_vocab_recall'] - 0.02: flags.append(f"REGRESSION:vocab-recall {mb['ref_vocab_recall']}->{ma['ref_vocab_recall']}") + if mb.get('noise') is not None and ma.get('noise') is not None and ma['noise'] > mb['noise'] + 0.02: flags.append(f"REGRESSION:noise {mb['noise']}->{ma['noise']}") + if mb.get('glued') is not None and ma.get('glued', 0) > mb.get('glued', 0): flags.append(f"REGRESSION:glued {mb['glued']}->{ma['glued']}") + if ma['junk'] > mb['junk'] + 0.5: flags.append(f"REGRESSION:junk {mb['junk']}->{ma['junk']}") + if not mb['degraded'] and ma['degraded']: flags.append('REGRESSION:now-degraded') + if ma['ms'] > 3 * mb['ms'] and ma['ms'] - mb['ms'] > 500: flags.append(f"SLOWER:{mb['ms']:.0f}->{ma['ms']:.0f}ms") + if ma['length'] < 0.8 * mb['length'] and mb['length'] > 200 and (mb.get('recall') is None or ma.get('recall') is None): flags.append(f"CHECK:length {mb['length']}->{ma['length']} (no reference)") + rows.append(dict(label=b['label'], ext=b['ext'], before=mb, after=ma, flags=flags)) + +json.dump(rows, open(os.path.join(BENCH, 'compare.json'), 'w'), indent=1) + +def mean(xs): + xs = [x for x in xs if isinstance(x, (int, float)) and not isinstance(x, bool)] + return f'{statistics.mean(xs):.3f}' if xs else '—' +by_ext = defaultdict(list) +for row in rows: by_ext[row['ext']].append(row) +cols = ['recall', 'ref_vocab_recall', 'precision', 'noise', 'glued', 'lines', 'repeated_lines', 'page_number_lines', 'junk', 'chunks', 'ms'] +L = ['# Before/after benchmark', '', f'{len(rows)} files compared. Gate: recall −0.02, vocab recall −0.02, noise +0.02, glued +1, junk +0.5, ok→error (except intended), now-degraded.', ''] +regressions = [x for x in rows if any(f.startswith('REGRESSION') for f in x['flags'])] +L += [f"**Regressions: {len(regressions)}**", ''] +L += ['| ext | n | ok before→after | typed errors b→a | degraded b→a | ' + ' | '.join(f'{c} b→a' for c in cols) + ' |', '|' + '---|' * (len(cols) + 5)] +for ext in sorted(by_ext): + rs = by_ext[ext]; bs = [x['before'] for x in rs]; as_ = [x['after'] for x in rs] + cells = [f"{mean([b.get(c) for b in bs])}→{mean([a.get(c) for a in as_])}" for c in cols] + L.append(f"| {ext} | {len(rs)} | {sum(b['ok'] for b in bs)}→{sum(a['ok'] for a in as_)} | {sum(1 for b in bs if not b['ok'] and b.get('typed'))}→{sum(1 for a in as_ if not a['ok'] and a.get('typed'))} | {sum(1 for b in bs if b.get('degraded'))}→{sum(1 for a in as_ if a.get('degraded'))} | " + ' | '.join(cells) + ' |') +L += ['', '## Flags per file', '', '| file | flags |', '|---|---|'] +for row in rows: + if row['flags']: L.append(f"| {row['label']} | {'; '.join(row['flags'])} |") +open(REPORT, 'w').write('\n'.join(L) + '\n') +print('\n'.join(L[:8 + len(by_ext)])) +print(f"\nregressions={len(regressions)} flagged={sum(1 for x in rows if x['flags'])} report={REPORT}") diff --git a/apps/sim/scripts/parser-eval/bench-run.ts b/apps/sim/scripts/parser-eval/bench-run.ts new file mode 100644 index 00000000000..bb1a1e6d56d --- /dev/null +++ b/apps/sim/scripts/parser-eval/bench-run.ts @@ -0,0 +1,48 @@ +/** + * Runs every file under `/files//` through the production + * `parseBuffer` path and writes one JSON record per file into ``. + * Run from apps/sim of the checkout under test: + * `DATABASE_URL=postgres://x:y@localhost:1/none bun scripts/parser-eval/bench-run.ts ` + */ +import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'fs' +import path from 'path' +import { TextChunker } from '@/lib/chunkers/text-chunker' +import { parseBuffer } from '@/lib/file-parsers' +import { FileParserError } from '@/lib/file-parsers/errors' + +const BENCH = process.argv[2] +const OUT = process.argv[3] +mkdirSync(OUT, { recursive: true }) +const chunker = new TextChunker({ chunkSize: 1024, chunkOverlap: 200, minCharactersPerChunk: 100 }) +const filesRoot = path.join(BENCH, 'files') +const summary: Record = {} + +for (const ext of readdirSync(filesRoot).sort()) { + const dir = path.join(filesRoot, ext) + if (!statSync(dir).isDirectory()) continue + summary[ext] = { ok: 0, error: 0, typed: 0 } + for (const file of readdirSync(dir).sort()) { + const label = `${ext}__${file}` + const bytes = readFileSync(path.join(dir, file)) + const started = performance.now() + try { + const result = await parseBuffer(bytes, ext, { pdfTextMode: ext === 'pdf' ? 'complete' : undefined }) + const ms = performance.now() - started + let chunkCount = -1 + try { chunkCount = (await chunker.chunk(result.content)).length } catch {} + const { html, sampledData, messages, headings, links, ...metadata } = result.metadata ?? {} + writeFileSync(path.join(OUT, `${label}.json`), JSON.stringify({ label, ext, file, bytes: bytes.length, ms, ok: true, content: result.content, metadata, chunkCount })) + summary[ext].ok++ + process.stdout.write(`ok ${label} ${result.content.length}ch ${ms.toFixed(0)}ms${metadata.degraded ? ' DEGRADED' : ''}${metadata.truncated ? ' TRUNCATED' : ''}\n`) + } catch (error) { + const ms = performance.now() - started + const typed = error instanceof FileParserError + writeFileSync(path.join(OUT, `${label}.json`), JSON.stringify({ label, ext, file, bytes: bytes.length, ms, ok: false, typedError: typed, errorCode: typed ? error.code : undefined, errorName: (error as Error)?.name, error: String((error as Error)?.message ?? error).slice(0, 300) })) + summary[ext].error++ + if (typed) summary[ext].typed++ + process.stdout.write(`FAIL ${label} ${typed ? `typed:${error.code}` : `UNTYPED:${(error as Error)?.name}`} ${String((error as Error)?.message).slice(0, 80)}\n`) + } + } +} +writeFileSync(path.join(OUT, '_summary.json'), JSON.stringify(summary, null, 1)) +console.log(JSON.stringify(summary)) diff --git a/apps/sim/scripts/parser-eval/score.py b/apps/sim/scripts/parser-eval/score.py index a86ffd188e1..ea11a0f0227 100644 --- a/apps/sim/scripts/parser-eval/score.py +++ b/apps/sim/scripts/parser-eval/score.py @@ -110,16 +110,16 @@ def score_tier_b(rec): 'truncated-docx': ('typed error (invalid_format)', lambda rec: not rec['ok'] and rec.get('typedError')), 'truncated-pdf': ('typed error (invalid_format)', lambda rec: not rec['ok'] and rec.get('typedError')), 'pdf-bytes-labelled-docx': ('typed error OR correct text', lambda rec: (not rec['ok'] and rec.get('typedError')) or (rec['ok'] and 'Office Relocation' in rec['content'])), - 'docx-bytes-labelled-pdf': ('typed error', lambda rec: not rec['ok'] and rec.get('typedError')), + 'docx-bytes-labelled-pdf': ('typed error OR correct text (magic wins)', lambda rec: (not rec['ok'] and rec.get('typedError')) or (rec['ok'] and 'Office Relocation' in rec['content'])), 'png-labelled-doc': ('typed error, never placeholder prose', lambda rec: not rec['ok'] and rec.get('typedError')), 'random-bytes-labelled-ppt': ('typed error, never placeholder prose', lambda rec: not rec['ok'] and rec.get('typedError')), 'latin1-txt': ('text decodes to "Café résumé naïve £"', lambda rec: rec['ok'] and 'Café' in rec['content'] and '£' in rec['content']), 'utf16-txt': ('text decodes to "Hello UTF-16 world"', lambda rec: rec['ok'] and 'Hello UTF-16 world' in rec['content']), 'html-labelled-txt': ('markup stripped or typed error', lambda rec: (not rec['ok'] and rec.get('typedError')) or (rec['ok'] and ' Date: Wed, 9 Sep 2026 18:51:05 -0700 Subject: [PATCH 06/21] fix(parsers): rebuild PDF line and paragraph structure from item geometry The PDF parser collapsed every page to a single line and concatenated items without separators, so the chunker fell back to sentence splits, words fused across Form XObject boundaries and backwards x-moves, and running headers/footers landed mid-sentence in most chunks. - Build positioned lines from pdf.js item transforms; derive separators from baseline shifts, backwards x-moves, and word-sized gaps, falling back to hasEOL when an item carries no geometry - Join lines per page with paragraph breaks from the median pitch and height changes, rejoin same-row and wrapped table cells, and dehyphenate line-end breaks unless the compound appears intact in the document - Suppress repeated header/footer furniture and page numbers across pages, keeping the first occurrence of each - Prefix short oversized lines with a heading marker - Replace the whitespace collapse with a structure-preserving normaliser and join pages with a paragraph break Co-Authored-By: Claude Fable 5.1 --- .../lib/file-parsers/pdf-furniture.test.ts | 193 +++++++++++ apps/sim/lib/file-parsers/pdf-furniture.ts | 185 ++++++++++ apps/sim/lib/file-parsers/pdf-lines.test.ts | 231 +++++++++++++ apps/sim/lib/file-parsers/pdf-lines.ts | 327 ++++++++++++++++++ .../file-parsers/pdf-parser-structure.test.ts | 213 ++++++++++++ apps/sim/lib/file-parsers/pdf-parser.ts | 125 ++++++- 6 files changed, 1256 insertions(+), 18 deletions(-) create mode 100644 apps/sim/lib/file-parsers/pdf-furniture.test.ts create mode 100644 apps/sim/lib/file-parsers/pdf-furniture.ts create mode 100644 apps/sim/lib/file-parsers/pdf-lines.test.ts create mode 100644 apps/sim/lib/file-parsers/pdf-lines.ts create mode 100644 apps/sim/lib/file-parsers/pdf-parser-structure.test.ts diff --git a/apps/sim/lib/file-parsers/pdf-furniture.test.ts b/apps/sim/lib/file-parsers/pdf-furniture.test.ts new file mode 100644 index 00000000000..53fab1d1bad --- /dev/null +++ b/apps/sim/lib/file-parsers/pdf-furniture.test.ts @@ -0,0 +1,193 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + furnitureThreshold, + isPageNumber, + normalizeFurnitureText, + type PdfPageLines, + suppressFurniture, +} from '@/lib/file-parsers/pdf-furniture' +import type { PdfLine } from '@/lib/file-parsers/pdf-lines' + +const PAGE_HEIGHT = 792 + +function line(text: string, y: number): PdfLine { + return { text, y, height: 11 } +} + +/** A page with a top header, a bottom footer, and two body lines. */ +function page(index: number, options: { header?: string; footer?: string } = {}): PdfPageLines { + const lines: PdfLine[] = [] + if (options.header) lines.push(line(options.header, 729)) + lines.push( + line(`Body line one of page ${index}`, 600), + line(`Body line two of page ${index}`, 585) + ) + if (options.footer) lines.push(line(options.footer, 55)) + return { lines, pageHeight: PAGE_HEIGHT } +} + +function texts(pages: PdfLine[][]): string[][] { + return pages.map((lines) => lines.map((entry) => entry.text)) +} + +describe('suppressFurniture', () => { + it('drops a header repeated on enough pages but keeps its first occurrence', () => { + const pages = [1, 2, 3, 4].map((i) => page(i, { header: 'ACME Corp — Internal Use Only' })) + + const result = texts(suppressFurniture(pages)) + + expect(result[0]).toContain('ACME Corp — Internal Use Only') + for (const remaining of result.slice(1)) { + expect(remaining).not.toContain('ACME Corp — Internal Use Only') + expect(remaining).toHaveLength(2) + } + }) + + it('never applies the frequency rule to a single page', () => { + const result = texts(suppressFurniture([page(1, { header: 'Draft' })])) + + expect(result[0]).toContain('Draft') + }) + + it('treats two matching pages as furniture when the document has exactly two pages', () => { + const pages = [1, 2].map((i) => + page(i, { footer: `Confidential draft, do not distribute — Page ${i} of 2` }) + ) + + const result = texts(suppressFurniture(pages)) + + expect(result[0]).toContain('Confidential draft, do not distribute — Page 1 of 2') + expect(result[1]).not.toContain('Confidential draft, do not distribute — Page 2 of 2') + }) + + it('requires the fraction threshold on longer documents without a streak', () => { + const pages = Array.from({ length: 10 }, (_, i) => + page(i, { footer: i % 4 === 0 ? 'Sporadic note' : undefined }) + ) + + const result = texts(suppressFurniture(pages)) + + expect(result.flat().filter((text) => text === 'Sporadic note')).toHaveLength(3) + }) + + it('detects a footer by a three-page streak even when its title changes per chapter', () => { + const pages = Array.from({ length: 12 }, (_, i) => { + const chapter = i < 6 ? 'Chapter 1 Filing Information' : 'Chapter 2 Filing Status' + const pageNumber = i + 6 + const footer = + i % 2 === 0 + ? `${pageNumber} ${chapter} Publication 17 (2025)` + : `Publication 17 (2025) ${chapter} ${pageNumber}` + return page(i, { footer }) + }) + + const result = texts(suppressFurniture(pages)) + const footers = result.flat().filter((text) => text.includes('Publication 17')) + + expect(footers).toEqual([ + '6 Chapter 1 Filing Information Publication 17 (2025)', + '12 Chapter 2 Filing Status Publication 17 (2025)', + ]) + }) + + it('drops page numbers in the bands regardless of repetition', () => { + const pages: PdfPageLines[] = [ + { lines: [line('Page 1 of 3', 55), line('Body', 600)], pageHeight: PAGE_HEIGHT }, + { lines: [line('2', 55), line('Body', 600), line('iv', 729)], pageHeight: PAGE_HEIGHT }, + { lines: [line('- 3 -', 55), line('Body', 600), line('925', 60)], pageHeight: PAGE_HEIGHT }, + ] + + expect(texts(suppressFurniture(pages))).toEqual([['Body'], ['Body'], ['Body', '925']]) + }) + + it('ignores band text longer than the furniture cap, such as a repeated table header', () => { + const header = `SKU Product Unit price Lead time Notes ${'Column '.repeat(14)}`.trim() + expect(header.length).toBeGreaterThan(120) + const pages = [1, 2, 3, 4].map((i) => page(i, { header })) + + const result = texts(suppressFurniture(pages)) + + for (const kept of result) expect(kept).toContain(header) + }) + + it('merges same-baseline fragments into one key before matching', () => { + const pages = [1, 2, 3, 4].map((i) => ({ + lines: [ + line('Body', 600), + line(`${i}`, 31.3), + line('Chapter 1', 31.3), + line('Publication 17 (2025)', 32.5), + ], + pageHeight: PAGE_HEIGHT, + })) + + const result = texts(suppressFurniture(pages)) + + expect(result[0]).toEqual(['Body', '1', 'Chapter 1', 'Publication 17 (2025)']) + expect(result[3]).toEqual(['Body']) + }) + + it('leaves body text alone even when it repeats', () => { + const pages = [1, 2, 3, 4].map(() => ({ + lines: [line('Repeated body sentence.', 400)], + pageHeight: PAGE_HEIGHT, + })) + + for (const kept of texts(suppressFurniture(pages))) { + expect(kept).toEqual(['Repeated body sentence.']) + } + }) + + it('skips pages without a page height or line geometry', () => { + const pages: PdfPageLines[] = [1, 2, 3].map(() => ({ + lines: [{ text: 'Header', height: 0 }, line('Header', 729)], + })) + + for (const kept of texts(suppressFurniture(pages))) expect(kept).toEqual(['Header', 'Header']) + }) +}) + +describe('normalizeFurnitureText', () => { + it('keys mirrored facing-page footers identically', () => { + expect(normalizeFurnitureText('6 Chapter 1 Filing Information Publication 17 (2025)')).toBe( + normalizeFurnitureText('Publication 17 (2025) Chapter 1 Filing Information 17') + ) + }) + + it('collapses case, digits, and edge punctuation', () => { + expect(normalizeFurnitureText(' Confidential DRAFT — Page 12 of 40. ')).toBe( + '# # confidential draft of page' + ) + }) +}) + +describe('isPageNumber', () => { + it('matches the page-number shapes', () => { + expect(isPageNumber('Page 3', 10)).toBe(true) + expect(isPageNumber('page 3 of 10', 10)).toBe(true) + expect(isPageNumber('3 / 10', 10)).toBe(true) + expect(isPageNumber('7', 10)).toBe(true) + expect(isPageNumber('xiv', 10)).toBe(true) + expect(isPageNumber('— 12 —', 20)).toBe(true) + }) + + it('rejects bare numbers beyond the page count and ordinary text', () => { + expect(isPageNumber('925', 142)).toBe(false) + expect(isPageNumber('2120', 142)).toBe(false) + expect(isPageNumber('Chapter 1', 10)).toBe(false) + expect(isPageNumber('civilian', 10)).toBe(false) + }) +}) + +describe('furnitureThreshold', () => { + it('scales with the page count', () => { + expect(furnitureThreshold(1)).toBeUndefined() + expect(furnitureThreshold(2)).toBe(2) + expect(furnitureThreshold(3)).toBe(3) + expect(furnitureThreshold(10)).toBe(5) + expect(furnitureThreshold(142)).toBe(71) + }) +}) diff --git a/apps/sim/lib/file-parsers/pdf-furniture.ts b/apps/sim/lib/file-parsers/pdf-furniture.ts new file mode 100644 index 00000000000..2e200c5852c --- /dev/null +++ b/apps/sim/lib/file-parsers/pdf-furniture.ts @@ -0,0 +1,185 @@ +import type { PdfLine } from '@/lib/file-parsers/pdf-lines' + +/** + * Detects running headers, footers, and page numbers across a document's pages + * and removes every copy but the first, so a footer repeated on 140 pages does + * not land mid-sentence in most retrieval chunks while one copy stays + * searchable. + */ + +export interface PdfPageLines { + lines: PdfLine[] + /** Page height in PDF user space; absent when the page could not report it. */ + pageHeight?: number +} + +/** Fraction of the page height at the top and bottom where furniture lives. */ +const FURNITURE_BAND_RATIO = 0.12 + +/** Furniture is short; longer repeated band text is a table header or real prose. */ +const MAX_FURNITURE_CHARS = 120 + +/** Lines whose baselines differ by at most this many points share one furniture row. */ +const SAME_BASELINE_TOLERANCE = 2 + +/** Minimum repeats for a key to count as furniture on longer documents. */ +const MIN_FURNITURE_REPEATS = 3 + +/** Fraction of pages a key must cover when it never runs on consecutive pages. */ +const FURNITURE_PAGE_FRACTION = 0.5 + +/** Consecutive-page run that marks furniture even when its total count is modest. */ +const MIN_FURNITURE_STREAK = 3 + +const PAGE_NUMBER_PATTERNS = [ + /^page\s*\d{1,4}(\s*(of|\/)\s*\d{1,4})?$/i, + /^\d{1,4}\s*(of|\/)\s*\d{1,4}$/i, + /^[ivxlcdm]{1,6}$/i, + /^[-–—]\s*\d+\s*[-–—]$/, +] as const + +/** A bare number is a page number only when the document could have that many pages. */ +const BARE_NUMBER = /^\d{1,4}$/ + +const EDGE_PUNCTUATION = /^[\p{P}\p{S}]+|[\p{P}\p{S}]+$/gu + +type Band = 'top' | 'bottom' + +interface BandGroup { + band: Band + /** Indices into the page's `lines`. */ + indices: number[] + text: string +} + +interface KeyOccurrences { + pages: number[] + groups: Array<{ page: number; group: BandGroup }> +} + +/** Returns each page's lines with repeated furniture and page numbers removed. */ +export function suppressFurniture(pages: readonly PdfPageLines[]): PdfLine[][] { + const drops = pages.map(() => new Set()) + const occurrences = new Map() + + pages.forEach((page, pageIndex) => { + const seen = new Set() + for (const group of bandGroups(page)) { + if (isPageNumber(group.text, pages.length)) { + for (const index of group.indices) drops[pageIndex].add(index) + continue + } + if (group.text.length > MAX_FURNITURE_CHARS) continue + const key = `${group.band}|${normalizeFurnitureText(group.text)}` + if (key.endsWith('|')) continue + const entry = occurrences.get(key) ?? { pages: [], groups: [] } + if (!seen.has(key)) { + seen.add(key) + entry.pages.push(pageIndex) + } + entry.groups.push({ page: pageIndex, group }) + occurrences.set(key, entry) + } + }) + + const threshold = furnitureThreshold(pages.length) + for (const entry of occurrences.values()) { + const count = entry.pages.length + const byFrequency = threshold !== undefined && count >= threshold + const byStreak = + count >= MIN_FURNITURE_REPEATS && longestRun(entry.pages) >= MIN_FURNITURE_STREAK + if (!byFrequency && !byStreak) continue + const firstPage = entry.pages[0] + for (const { page, group } of entry.groups) { + if (page === firstPage) continue + for (const index of group.indices) drops[page].add(index) + } + } + + return pages.map((page, pageIndex) => + drops[pageIndex].size === 0 + ? page.lines + : page.lines.filter((_, index) => !drops[pageIndex].has(index)) + ) +} + +/** + * Lowercases each word, strips its edge punctuation, maps digit runs to `#`, and + * sorts the words so facing-page footers that mirror their layout (`6 Chapter + * 1 … Publication 17 (2025)` vs `Publication 17 (2025) Chapter 1 … 7`) share + * one key. + */ +export function normalizeFurnitureText(text: string): string { + const words = text + .toLowerCase() + .split(/\s+/) + .map((word) => word.replace(EDGE_PUNCTUATION, '').replace(/\d+/g, '#')) + .filter((word) => word.length > 0) + words.sort() + return words.join(' ') +} + +/** + * Whether a band line is nothing but a page number. A bare number qualifies + * only when it does not exceed `pageCount`, so form and publication numbers + * listed near the page edge survive. + */ +export function isPageNumber(text: string, pageCount: number): boolean { + const compact = text.replace(/\s+/g, ' ').trim() + if (BARE_NUMBER.test(compact)) return Number(compact) <= pageCount + return PAGE_NUMBER_PATTERNS.some((pattern) => pattern.test(compact)) +} + +/** Repeat count that marks a key as furniture, or undefined when the document is too short. */ +export function furnitureThreshold(pageCount: number): number | undefined { + if (pageCount < 2) return undefined + if (pageCount === 2) return 2 + return Math.max(MIN_FURNITURE_REPEATS, Math.ceil(FURNITURE_PAGE_FRACTION * pageCount)) +} + +/** Groups consecutive band lines that share a baseline into one furniture row. */ +function bandGroups(page: PdfPageLines): BandGroup[] { + const { lines, pageHeight } = page + if (pageHeight === undefined || !(pageHeight > 0)) return [] + const groups: BandGroup[] = [] + let current: (BandGroup & { y: number }) | undefined + + lines.forEach((line, index) => { + const band = bandOf(line, pageHeight) + if (!band) { + current = undefined + return + } + if ( + current && + current.band === band && + Math.abs(current.y - (line.y as number)) <= SAME_BASELINE_TOLERANCE + ) { + current.indices.push(index) + current.text = `${current.text} ${line.text.trim()}` + return + } + current = { band, indices: [index], text: line.text.trim(), y: line.y as number } + groups.push(current) + }) + + return groups +} + +function bandOf(line: PdfLine, pageHeight: number): Band | undefined { + if (line.y === undefined) return undefined + if (line.y >= (1 - FURNITURE_BAND_RATIO) * pageHeight) return 'top' + if (line.y <= FURNITURE_BAND_RATIO * pageHeight) return 'bottom' + return undefined +} + +/** Longest run of consecutive page indices in an ascending list. */ +function longestRun(pages: readonly number[]): number { + let best = 0 + let run = 0 + for (let i = 0; i < pages.length; i++) { + run = i > 0 && pages[i] === pages[i - 1] + 1 ? run + 1 : 1 + if (run > best) best = run + } + return best +} diff --git a/apps/sim/lib/file-parsers/pdf-lines.test.ts b/apps/sim/lib/file-parsers/pdf-lines.test.ts new file mode 100644 index 00000000000..1dbc90e13a5 --- /dev/null +++ b/apps/sim/lib/file-parsers/pdf-lines.test.ts @@ -0,0 +1,231 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + collectCompounds, + dominantLineHeight, + joinLines, + normalizePdfWhitespace, + type PdfLine, + PdfLineBuilder, + readItemGeometry, +} from '@/lib/file-parsers/pdf-lines' + +const BODY = 11 + +/** Body lines at a 14.4pt pitch starting at the given baseline. */ +function paragraph(texts: string[], top: number, height = BODY): PdfLine[] { + return texts.map((text, index) => ({ text, y: top - index * 14.4, height })) +} + +describe('joinLines', () => { + it('separates lines with \\n and paragraphs with \\n\\n from the baseline pitch', () => { + const lines = [ + ...paragraph(['First paragraph line one', 'first paragraph line two'], 700), + ...paragraph(['Second paragraph line one', 'second paragraph line two'], 700 - 14.4 - 20.4), + ] + + expect(joinLines(lines, { headingMarkers: false })).toBe( + 'First paragraph line one\nfirst paragraph line two\n\nSecond paragraph line one\nsecond paragraph line two' + ) + }) + + it('breaks a paragraph where the line height changes between heading and body', () => { + const lines: PdfLine[] = [ + { text: 'Heading', y: 700, height: 15.4 }, + { text: 'Body line', y: 700 - 15.5, height: BODY }, + ] + + expect(joinLines(lines, { headingMarkers: false })).toBe('Heading\n\nBody line') + }) + + it('joins cells that share a baseline with a space', () => { + const lines: PdfLine[] = [ + { text: 'SKU', y: 600, height: BODY }, + { text: 'HW-1021', y: 600.2, height: BODY }, + { text: 'Next row', y: 600 - 14.4, height: BODY }, + ] + + expect(joinLines(lines, { headingMarkers: false })).toBe('SKU HW-1021\nNext row') + }) + + it('rejoins a wrapped table cell to its row on a short upward return', () => { + const lines: PdfLine[] = [ + { text: 'HW-1000 Rack unit model', y: 669.5, height: BODY }, + { text: 'D0', y: 655.1, height: BODY }, + { text: '$65,918.68 6 weeks', y: 669.5, height: BODY }, + { text: 'HW-1001 Blade unit', y: 635.9, height: BODY }, + { text: 'model E1', y: 621.5, height: BODY }, + { text: '$942,425.74 11 weeks', y: 635.9, height: BODY }, + ] + + expect(joinLines(lines, { headingMarkers: false })).toBe( + 'HW-1000 Rack unit model D0 $65,918.68 6 weeks\n\nHW-1001 Blade unit model E1 $942,425.74 11 weeks' + ) + }) + + it('starts a paragraph when the text returns upward to a new column', () => { + const lines: PdfLine[] = [ + ...paragraph(['Column one ends here.'], 100), + ...paragraph(['Column two starts here.'], 700), + ] + + expect(joinLines(lines, { headingMarkers: false })).toBe( + 'Column one ends here.\n\nColumn two starts here.' + ) + }) + + it('falls back to single line breaks when lines carry no geometry', () => { + const lines: PdfLine[] = [ + { text: 'one', height: 0 }, + { text: 'two', height: 0 }, + ] + + expect(joinLines(lines)).toBe('one\ntwo') + }) + + it('prefixes short oversized lines with a heading marker', () => { + const lines: PdfLine[] = [ + { text: 'Memo: Office Relocation Timeline', y: 692, height: 15.4 }, + ...paragraph(['Body text follows the title.'], 676.6), + ] + + expect(joinLines(lines, { bodyHeight: BODY })).toBe( + '## Memo: Office Relocation Timeline\n\nBody text follows the title.' + ) + expect(joinLines(lines, { bodyHeight: BODY, headingMarkers: false })).toBe( + 'Memo: Office Relocation Timeline\n\nBody text follows the title.' + ) + }) + + it('keeps a multi-line heading together by scaling the pitch with its height', () => { + const lines: PdfLine[] = [ + { text: 'Do I Have To', y: 735.9, height: 15 }, + { text: 'File a Return?', y: 719.9, height: 15 }, + ...paragraph(['You must file a federal income tax return if you', 'are a citizen'], 701.8, 8), + ...paragraph(['a resident of Puerto Rico'], 701.8 - 2 * 9.5, 8), + ] + + expect(joinLines(lines, { bodyHeight: 8, headingMarkers: false })).toBe( + 'Do I Have To\nFile a Return?\n\nYou must file a federal income tax return if you\nare a citizen\na resident of Puerto Rico' + ) + }) + + describe('dehyphenation', () => { + it('removes a line-end hyphen when the next line continues the word', () => { + const lines = paragraph(['archived by the Infra-', 'structure team.'], 627.4) + + expect(joinLines(lines, { headingMarkers: false })).toBe( + 'archived by the Infrastructure team.' + ) + }) + + it('keeps the hyphen when the compound appears intact elsewhere in the document', () => { + const lines = paragraph(['we compare attention-', 'based models with others'], 700) + const compounds = collectCompounds([{ text: 'Attention-based models win.', height: BODY }]) + + expect(joinLines(lines, { compounds, headingMarkers: false })).toBe( + 'we compare attention-based models with others' + ) + }) + + it('keeps the hyphen when the next line starts with a capital or the break is a paragraph', () => { + expect( + joinLines(paragraph(['the English-', 'German pair'], 700), { headingMarkers: false }) + ).toBe('the English-\nGerman pair') + + const acrossParagraphs: PdfLine[] = [ + ...paragraph(['a first line', 'a second line', 'ends with a dash-'], 700), + ...paragraph(['lowercase start'], 700 - 2 * 14.4 - 30), + ] + expect(joinLines(acrossParagraphs, { headingMarkers: false })).toBe( + 'a first line\na second line\nends with a dash-\n\nlowercase start' + ) + }) + + it('always removes a soft hyphen at a line break', () => { + const lines = paragraph(['Infra­', 'Structure'], 700) + + expect(joinLines(lines, { headingMarkers: false })).toBe('InfraStructure') + }) + }) +}) + +describe('PdfLineBuilder', () => { + it('starts a new line on a baseline change even without hasEOL', () => { + const builder = new PdfLineBuilder() + builder.append('and', { x: 100, y: 700, width: 20, height: 11 }) + const separator = builder.separatorBefore('CAUTION', { x: 100, y: 680, width: 50, height: 11 }) + + expect(separator).toBe('\n') + }) + + it('starts a new cell on a backwards x-move along one baseline', () => { + const builder = new PdfLineBuilder() + builder.append('EOL 2027', { x: 400, y: 700, width: 40, height: 11 }) + + expect(builder.separatorBefore('HW-1021', { x: 120, y: 700, width: 40, height: 11 })).toBe('\n') + }) + + it('inserts a space across a word-sized gap and nothing across a tight one', () => { + const builder = new PdfLineBuilder() + builder.append('Table', { x: 100, y: 700, width: 30, height: 11 }) + + expect(builder.separatorBefore('Caption', { x: 136, y: 700, width: 40, height: 11 })).toBe(' ') + expect(builder.separatorBefore('s', { x: 130.5, y: 700, width: 5, height: 11 })).toBe('') + builder.append(' ', { x: 130, y: 700, width: 4, height: 0 }) + expect(builder.separatorBefore('Caption', { x: 140, y: 700, width: 40, height: 11 })).toBe('') + }) + + it('records the baseline and dominant height of each line and drops blank lines', () => { + const builder = new PdfLineBuilder() + builder.append('•', { x: 90, y: 592.5, width: 3.9, height: 12.6 }) + builder.append(' ', { x: 93.9, y: 592.5, width: 5.5, height: 0 }) + builder.append('Confirm desk allocations', { x: 99.4, y: 592.5, width: 157, height: 11 }) + builder.endLine() + builder.append(' ') + builder.endLine() + + expect(builder.finish()).toEqual([{ text: '• Confirm desk allocations', y: 592.5, height: 11 }]) + }) +}) + +describe('readItemGeometry', () => { + it('returns undefined for missing, rotated, or non-ltr items', () => { + expect(readItemGeometry({ str: 'x' })).toBeUndefined() + expect(readItemGeometry({ str: 'x', transform: [0, 1, -1, 0, 10, 20] })).toBeUndefined() + expect( + readItemGeometry({ str: 'x', transform: [1, 0, 0, 1, 10, 20], dir: 'rtl' }) + ).toBeUndefined() + expect(readItemGeometry({ str: 'x', transform: [1, 0, 0, 1, 'a', 20] })).toBeUndefined() + }) + + it('reads placement from a horizontal transform', () => { + expect( + readItemGeometry({ + str: 'x', + transform: [1, 0, 0, 1, 10, 20], + width: 5, + height: 11, + dir: 'ltr', + }) + ).toEqual({ x: 10, y: 20, width: 5, height: 11 }) + }) +}) + +describe('helpers', () => { + it('collapses blanks without destroying line structure', () => { + expect(normalizePdfWhitespace('a b \n c\n\n\n\nd\t e')).toBe('a b\nc\n\nd e') + }) + + it('picks the character-weighted modal height as body height', () => { + expect( + dominantLineHeight([ + { text: 'Heading', height: 15.4 }, + { text: 'A long body line of text', height: 11 }, + { text: 'Another body line', height: 11.02 }, + ]) + ).toBe(11) + }) +}) diff --git a/apps/sim/lib/file-parsers/pdf-lines.ts b/apps/sim/lib/file-parsers/pdf-lines.ts new file mode 100644 index 00000000000..e07b175a8ad --- /dev/null +++ b/apps/sim/lib/file-parsers/pdf-lines.ts @@ -0,0 +1,327 @@ +/** + * Rebuilds lines and paragraphs from pdf.js text items. + * + * pdf.js only flags `hasEOL` when its own heuristics notice a line change; it + * resets that state when it recurses into a Form XObject and stays silent on a + * backwards x-move along one baseline, so items glue together without a + * separator. The builder here derives separators from item geometry instead and + * keeps each line's baseline and height so paragraph breaks, same-row cells, + * headings, and running furniture can be recovered afterwards. + */ + +/** One text item as pdf.js streams it; every field is untrusted. */ +export interface PdfTextItem { + str?: unknown + hasEOL?: unknown + transform?: unknown + width?: unknown + height?: unknown + dir?: unknown +} + +/** Horizontal, left-to-right placement of one item in PDF user space. */ +export interface PdfItemGeometry { + x: number + y: number + width: number + height: number +} + +/** A reconstructed line of one page. */ +export interface PdfLine { + text: string + /** Baseline in PDF user space (origin bottom-left); absent when the source carried no geometry. */ + y?: number + /** Height of the line's dominant item; 0 when unknown. */ + height: number +} + +export type PdfLineSeparator = '' | ' ' | '\n' + +export interface JoinLinesOptions { + /** Lowercase `a-b` compounds seen intact in the document; a line break on their hyphen keeps it. */ + compounds?: ReadonlySet + /** Dominant body-text height for the document; enables heading markers and heading pitch scaling. */ + bodyHeight?: number + /** Prefixes short, oversized lines with `## ` so Markdown-aware chunkers split on them. */ + headingMarkers?: boolean +} + +/** Flip to disable the `## ` heading prefix without touching callers. */ +export const PDF_HEADING_MARKERS_ENABLED = true + +/** A line at least this many times taller than body text is a heading candidate. */ +const HEADING_HEIGHT_RATIO = 1.15 + +/** Headings are short; longer oversized lines are pull quotes or callouts. */ +const HEADING_MAX_CHARS = 120 + +/** Line gap beyond this multiple of the page's line pitch is a paragraph break. */ +const PARAGRAPH_PITCH_RATIO = 1.3 + +/** Height change between adjacent lines beyond this fraction marks a heading/body boundary. */ +const HEIGHT_CHANGE_RATIO = 0.2 + +/** Lines whose baselines differ by less than this fraction of their height share a row. */ +const SAME_ROW_RATIO = 0.3 + +/** An upward return of at most this many pitches rejoins a wrapped table cell to its row. */ +const ROW_RETURN_PITCHES = 3 + +/** Baseline shift beyond this fraction of the reference height starts a new line. */ +const LINE_SHIFT_RATIO = 0.5 + +/** Backwards x-move beyond this fraction of the reference height starts a new line or cell. */ +const BACKWARDS_MOVE_RATIO = 0.5 + +/** Forward gap beyond this fraction of the reference height is an inter-word space. */ +const WORD_GAP_RATIO = 0.1 + +const SOFT_HYPHEN = '\u00AD' +const TRAILING_HYPHEN = /(\p{L}+)-$/u +const LEADING_LOWERCASE_WORD = /^(\p{Ll}\p{L}*)/u +const INTACT_COMPOUND = /\p{L}+-\p{L}+/gu + +/** + * Reads an item's placement, or undefined when the item is rotated, vertical, + * right-to-left, or carries no usable transform — those fall back to pdf.js's + * own `hasEOL` line breaks. + */ +export function readItemGeometry(item: PdfTextItem): PdfItemGeometry | undefined { + const transform = item.transform + if (!Array.isArray(transform) || transform.length < 6) return undefined + const [, skewY, skewX, , x, y] = transform as unknown[] + if ( + !isFiniteNumber(skewY) || + !isFiniteNumber(skewX) || + !isFiniteNumber(x) || + !isFiniteNumber(y) + ) { + return undefined + } + if (skewY !== 0 || skewX !== 0) return undefined + if (item.dir !== undefined && item.dir !== 'ltr') return undefined + return { + x, + y, + width: isFiniteNumber(item.width) ? item.width : 0, + height: isFiniteNumber(item.height) ? item.height : 0, + } +} + +/** Accumulates positioned items into lines for one page. */ +export class PdfLineBuilder { + private readonly lines: PdfLine[] = [] + private parts: string[] = [] + private lineY: number | undefined + private dominantHeight = 0 + private dominantLength = -1 + private prevEndX: number | undefined + private prevY = 0 + private lineHeight = 0 + + /** + * Separator the geometry rules call for before `str`; '' at line start or + * when either side lacks geometry. + */ + separatorBefore(str: string, geometry: PdfItemGeometry | undefined): PdfLineSeparator { + if (!geometry || this.prevEndX === undefined) return '' + const height = geometry.height || this.lineHeight + const ref = Math.max(height, this.lineHeight, 1) + if (Math.abs(geometry.y - this.prevY) > LINE_SHIFT_RATIO * ref) return '\n' + const gap = geometry.x - this.prevEndX + if (gap < -BACKWARDS_MOVE_RATIO * ref) return '\n' + if (gap > WORD_GAP_RATIO * ref && !this.endsWithWhitespace() && !/^\s/.test(str)) return ' ' + return '' + } + + append(str: string, geometry?: PdfItemGeometry): void { + if (str.length > 0) { + this.parts.push(str) + const visibleLength = str.trim().length + if (geometry && visibleLength > this.dominantLength) { + this.dominantLength = visibleLength + this.dominantHeight = geometry.height || this.lineHeight + } + } + if (!geometry) return + if (this.lineY === undefined && str.length > 0) this.lineY = geometry.y + this.prevEndX = geometry.x + geometry.width + this.prevY = geometry.y + this.lineHeight = geometry.height || this.lineHeight + } + + /** Closes the current line; whitespace-only lines are dropped. */ + endLine(): void { + const text = this.parts.join('') + if (text.trim().length > 0) { + this.lines.push({ text, y: this.lineY, height: this.dominantHeight }) + } + this.parts = [] + this.lineY = undefined + this.dominantHeight = 0 + this.dominantLength = -1 + this.prevEndX = undefined + this.prevY = 0 + this.lineHeight = 0 + } + + finish(): PdfLine[] { + this.endLine() + return this.lines + } + + private endsWithWhitespace(): boolean { + const last = this.parts[this.parts.length - 1] + return last !== undefined && /\s$/.test(last) + } +} + +/** Collapses runs of blanks without destroying line and paragraph breaks. */ +export function normalizePdfWhitespace(text: string): string { + return text + .replace(/[^\S\n]+/g, ' ') + .replace(/ ?\n ?/g, '\n') + .replace(/\n{3,}/g, '\n\n') +} + +/** Hyphenated compounds that appear intact inside a line, lowercased. */ +export function collectCompounds(lines: Iterable): Set { + const compounds = new Set() + for (const line of lines) { + for (const match of line.text.matchAll(INTACT_COMPOUND)) compounds.add(match[0].toLowerCase()) + } + return compounds +} + +/** Character-weighted modal line height across the document; 0 when unknown. */ +export function dominantLineHeight(lines: Iterable): number { + const weights = new Map() + for (const line of lines) { + if (line.height <= 0) continue + const key = Math.round(line.height * 10) / 10 + weights.set(key, (weights.get(key) ?? 0) + line.text.trim().length) + } + let best = 0 + let bestWeight = 0 + for (const [height, weight] of weights) { + if (weight > bestWeight) { + best = height + bestWeight = weight + } + } + return best +} + +/** + * Joins one page's lines into text with `\n` between lines, `\n\n` between + * paragraphs, and a space between cells that share a row, dehyphenating words + * that a line break split. + */ +export function joinLines(lines: readonly PdfLine[], options: JoinLinesOptions = {}): string { + if (lines.length === 0) return '' + const pitch = medianPitch(lines) + const bodyHeight = options.bodyHeight ?? 0 + const headingMarkers = options.headingMarkers ?? PDF_HEADING_MARKERS_ENABLED + const compounds = options.compounds + + let out = decorate(lines[0], bodyHeight, headingMarkers) + for (let i = 1; i < lines.length; i++) { + const line = lines[i] + const separator = separatorBetween(lines, i, pitch, bodyHeight) + if (out.endsWith(SOFT_HYPHEN)) { + out = out.slice(0, -1) + line.text + continue + } + if (separator === '\n') { + const joined = dehyphenate(out, line.text, compounds) + if (joined !== undefined) { + out = joined + continue + } + } + out += separator + out += separator === ' ' ? line.text : decorate(line, bodyHeight, headingMarkers) + } + return out +} + +function decorate(line: PdfLine, bodyHeight: number, headingMarkers: boolean): string { + if ( + headingMarkers && + bodyHeight > 0 && + line.height >= HEADING_HEIGHT_RATIO * bodyHeight && + line.text.trim().length < HEADING_MAX_CHARS + ) { + return `## ${line.text.trimStart()}` + } + return line.text +} + +/** + * Joins `next` onto `out` across a hyphen that ended the line, or undefined + * when the break is not a hyphenation. A compound seen intact elsewhere in the + * document keeps its hyphen. + */ +function dehyphenate( + out: string, + next: string, + compounds: ReadonlySet | undefined +): string | undefined { + const head = TRAILING_HYPHEN.exec(out) + const tail = LEADING_LOWERCASE_WORD.exec(next) + if (!head || !tail) return undefined + const compound = `${head[1]}-${tail[1]}`.toLowerCase() + if (compounds?.has(compound)) return out + next + return out.slice(0, -1) + next +} + +function separatorBetween( + lines: readonly PdfLine[], + index: number, + pitch: number, + bodyHeight: number +): ' ' | '\n' | '\n\n' { + const a = lines[index - 1] + const b = lines[index] + if (a.y === undefined || b.y === undefined) return '\n' + const dy = a.y - b.y + const maxHeight = Math.max(a.height, b.height) + if (maxHeight > 0 ? Math.abs(dy) < SAME_ROW_RATIO * maxHeight : dy === 0) return ' ' + if (dy < 0) return isRowReturn(dy, pitch) ? ' ' : '\n\n' + const next = lines[index + 1] + if (next?.y !== undefined && isRowReturn(b.y - next.y, pitch)) return ' ' + if (maxHeight > 0 && Math.abs(a.height - b.height) > HEIGHT_CHANGE_RATIO * maxHeight) + return '\n\n' + const scale = bodyHeight > 0 ? Math.max(1, maxHeight / bodyHeight) : 1 + if (pitch > 0 && dy > PARAGRAPH_PITCH_RATIO * pitch * scale) return '\n\n' + return '\n' +} + +/** A short upward jump returns to a table row whose earlier cell wrapped onto extra lines. */ +function isRowReturn(dy: number, pitch: number): boolean { + return dy < 0 && pitch > 0 && -dy <= ROW_RETURN_PITCHES * pitch +} + +/** + * Lower median of the downward baseline steps between consecutive lines; 0 when + * there is none. The lower median keeps a two-step page treating its larger + * step as the paragraph gap rather than the pitch. + */ +function medianPitch(lines: readonly PdfLine[]): number { + const steps: number[] = [] + for (let i = 1; i < lines.length; i++) { + const a = lines[i - 1].y + const b = lines[i].y + if (a === undefined || b === undefined) continue + const dy = a - b + if (dy > 0) steps.push(dy) + } + if (steps.length === 0) return 0 + steps.sort((left, right) => left - right) + return steps[Math.floor((steps.length - 1) / 2)] +} + +function isFiniteNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) +} diff --git a/apps/sim/lib/file-parsers/pdf-parser-structure.test.ts b/apps/sim/lib/file-parsers/pdf-parser-structure.test.ts new file mode 100644 index 00000000000..46cd7a52fbe --- /dev/null +++ b/apps/sim/lib/file-parsers/pdf-parser-structure.test.ts @@ -0,0 +1,213 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockOpenPdfDocument } = vi.hoisted(() => ({ + mockOpenPdfDocument: vi.fn(), +})) + +vi.mock('@/lib/file-parsers/pdfjs-server', () => ({ + openPdfDocument: mockOpenPdfDocument, +})) + +import { PdfParser } from '@/lib/file-parsers/pdf-parser' + +const PAGE_HEIGHT = 792 +const BODY = 11 +const PITCH = 14.4 +const PARAGRAPH_GAP = 20.4 + +interface PositionedItem { + str: string + hasEOL: boolean + transform: number[] + width: number + height: number + dir: 'ltr' +} + +interface BareItem { + str: string + hasEOL: boolean +} + +type StreamItem = PositionedItem | BareItem + +/** A positioned item the way pdf.js emits it for horizontal text. */ +function item(str: string, x: number, y: number, height = BODY): PositionedItem { + return { + str, + hasEOL: false, + transform: [height, 0, 0, height, x, y], + width: str.length * 5, + height, + dir: 'ltr', + } +} + +/** pdf.js marks a line change with an empty item positioned on the next baseline. */ +function eol(x: number, y: number): PositionedItem { + return { str: '', hasEOL: true, transform: [0, 0, 0, 0, x, y], width: 0, height: 0, dir: 'ltr' } +} + +/** Body lines at the in-paragraph pitch, each preceded by pdf.js's EOL marker. */ +function paragraph(texts: string[], top: number, x = 90): PositionedItem[] { + return texts.flatMap((text, index) => { + const y = top - index * PITCH + return [eol(x, y), item(text, x, y)] + }) +} + +function buildPage(items: StreamItem[]) { + const read = vi + .fn() + .mockResolvedValueOnce({ value: { items }, done: false }) + .mockResolvedValue({ done: true }) + return { + cleanup: vi.fn(), + getViewport: () => ({ height: PAGE_HEIGHT }), + streamTextContent: () => ({ + getReader: () => ({ read, cancel: vi.fn().mockResolvedValue(undefined) }), + }), + } +} + +function pdfWithPages(pages: StreamItem[][]) { + const built = pages.map(buildPage) + return { + numPages: pages.length, + getPage: vi.fn(async (pageNumber: number) => built[pageNumber - 1]), + destroy: vi.fn().mockResolvedValue(undefined), + } +} + +function pageWithFurniture(body: PositionedItem[], pageNumber: number): PositionedItem[] { + return [ + item('ACME Corp — Internal Use Only', 373, 729), + ...body, + eol(90, 55), + item(`Confidential draft, do not distribute — Page ${pageNumber} of 3`, 90, 55), + ] +} + +describe('PdfParser structure reconstruction', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('rebuilds paragraphs, headings, hyphenation, fused XObject text, and furniture', async () => { + const firstParagraph = [ + 'The revised rollout was flagged on 4 June by the Platform team. A rollback', + 'path exists and was rehearsed twice during the dry run. Stakeholders should', + 'review the attached appendix before the next checkpoint, and the owning', + 'team retains sign-off authority for scope changes above five percent.', + 'Exceptions require written approval from a director or above. This', + 'supersedes the guidance circulated on 14 March and applies immediately.', + ] + const p1Top = 676.6 + const p2Top = p1Top - 5 * PITCH - PARAGRAPH_GAP + const p3Top = p2Top - 2 * PITCH - PARAGRAPH_GAP + const p4Top = p3Top - 2 * PITCH - PARAGRAPH_GAP + const cautionY = p4Top - 34.6 + const formY = cautionY - PITCH + + const pageOne = pageWithFurniture( + [ + eol(90, 692), + item('Memo: Office Relocation Timeline', 90, 692, 15.4), + ...paragraph(firstParagraph, p1Top), + eol(90, p2Top), + item('The capacity model was archived on 25 June by the Infra', 90, p2Top), + item('-', 365, p2Top), + ...paragraph( + [ + 'structure team. Historical figures were restated to align with the model.', + 'Open questions are tracked in the shared register and reviewed weekly.', + ], + p2Top - PITCH + ), + ...paragraph( + [ + 'We compared attention-', + 'based models with attention-based baselines on the same hardware.', + 'Latency stayed under the objective for most sampled requests.', + ], + p3Top + ), + ...paragraph(['Keep records that support an item of income'], p4Top), + item('CAUTION', 320, cautionY), + item('Form 8815', 90, formY), + item('RECORDS', 160, formY), + ], + 1 + ) + const pageTwo = pageWithFurniture(paragraph(['Second page body text.'], p1Top), 2) + const pageThree = pageWithFurniture(paragraph(['Third page body text.'], p1Top), 3) + mockOpenPdfDocument.mockResolvedValueOnce(pdfWithPages([pageOne, pageTwo, pageThree])) + + const result = await new PdfParser().parseBuffer(Buffer.from('%PDF-1.4'), { + pdfTextMode: 'complete', + }) + + expect(result.content).toBe( + [ + 'ACME Corp — Internal Use Only', + '', + '## Memo: Office Relocation Timeline', + '', + ...firstParagraph, + '', + 'The capacity model was archived on 25 June by the Infrastructure team. Historical figures were restated to align with the model.', + 'Open questions are tracked in the shared register and reviewed weekly.', + '', + 'We compared attention-based models with attention-based baselines on the same hardware.', + 'Latency stayed under the objective for most sampled requests.', + '', + 'Keep records that support an item of income', + '', + 'CAUTION', + 'Form 8815 RECORDS', + '', + 'Confidential draft, do not distribute — Page 1 of 3', + '', + 'Second page body text.', + '', + 'Third page body text.', + ].join('\n') + ) + expect(result.metadata).toMatchObject({ pageCount: 3, truncated: false }) + }) + + it('keeps preview mode output structured as well', async () => { + mockOpenPdfDocument.mockResolvedValueOnce( + pdfWithPages([ + paragraph(['First line.', 'Second line.'], 700), + paragraph(['Next page.'], 700), + ]) + ) + + const result = await new PdfParser().parseBuffer(Buffer.from('%PDF-1.4')) + + expect(result.content).toBe('First line.\nSecond line.\n\nNext page.') + expect(result.metadata).toMatchObject({ pageCount: 2, truncated: false }) + }) + + it('falls back to hasEOL line breaks when items carry no geometry', async () => { + mockOpenPdfDocument.mockResolvedValueOnce( + pdfWithPages([ + [ + { str: 'alpha', hasEOL: true }, + { str: 'beta', hasEOL: false }, + { str: 'gamma', hasEOL: false }, + ], + ]) + ) + + const result = await new PdfParser().parseBuffer(Buffer.from('%PDF-1.4'), { + pdfTextMode: 'complete', + }) + + expect(result.content).toBe('alpha\nbetagamma') + }) +}) diff --git a/apps/sim/lib/file-parsers/pdf-parser.ts b/apps/sim/lib/file-parsers/pdf-parser.ts index c0921d67a84..13c1464d3df 100644 --- a/apps/sim/lib/file-parsers/pdf-parser.ts +++ b/apps/sim/lib/file-parsers/pdf-parser.ts @@ -2,6 +2,17 @@ import { readFile } from 'fs/promises' import { createLogger } from '@sim/logger' import type { PDFDocumentProxy, PDFPageProxy } from 'pdfjs-dist/types/src/pdf' import { FileParserError } from '@/lib/file-parsers/errors' +import { type PdfPageLines, suppressFurniture } from '@/lib/file-parsers/pdf-furniture' +import { + collectCompounds, + dominantLineHeight, + joinLines, + normalizePdfWhitespace, + type PdfLine, + PdfLineBuilder, + type PdfTextItem, + readItemGeometry, +} from '@/lib/file-parsers/pdf-lines' import { openPdfDocument } from '@/lib/file-parsers/pdfjs-server' import type { FileParseOptions, FileParseResult, FileParser } from '@/lib/file-parsers/types' import { sanitizeTextForUTF8, truncationNotice } from '@/lib/file-parsers/utils' @@ -27,6 +38,15 @@ export const MAX_COMPLETE_PDF_PAGE_CHARS = 250_000 /** Wall-clock ceiling for extracting text from a whole document. */ const PDF_EXTRACTION_TIMEOUT_MS = 60_000 +/** + * Upper bound on what line reconstruction adds per line after the budget is + * spent: a two-character paragraph break plus a three-character heading marker. + */ +const MAX_LINE_DECORATION_BYTES = 5 + +/** Pages are joined with a paragraph break. */ +const PAGE_SEPARATOR = '\n\n' + const PDF_TRUNCATION_WARNING = 'PDF text extraction stopped at a parser limit and is incomplete' const PDF_READ_DEADLINE_REACHED = Symbol('PDF_READ_DEADLINE_REACHED') @@ -34,11 +54,11 @@ const PDF_READ_DEADLINE_REACHED = Symbol('PDF_READ_DEADLINE_REACHED') const PDF_PARSER_SOURCE = 'unpdf' interface TextContentChunk { - items?: Array<{ str?: unknown; hasEOL?: unknown }> + items?: PdfTextItem[] } interface PageExtraction { - text: string + lines: PdfLine[] /** Characters consumed from the caller's budget. */ used: number /** False when a budget stopped the read before the page was exhausted. */ @@ -141,7 +161,7 @@ async function readPageWithinBudget( .streamTextContent() .getReader() as ReadableStreamDefaultReader - const parts: string[] = [] + const builder = new PdfLineBuilder() let remaining = budget let completed = false let dropped = false @@ -180,16 +200,23 @@ async function readPageWithinBudget( for (const item of value?.items ?? []) { if (typeof item?.str !== 'string') continue - const piece = item.hasEOL === true ? `${item.str}\n` : item.str - if (piece.length > remaining) { - parts.push(piece.slice(0, remaining)) + const str = item.str + const hasEOL = item.hasEOL === true + const geometry = readItemGeometry(item) + const separator = str.length > 0 ? builder.separatorBefore(str, geometry) : '' + const cost = separator.length + str.length + (hasEOL ? 1 : 0) + if (cost > remaining) { + appendTruncated(builder, separator, str, geometry, remaining) remaining = 0 dropped = true break } - if (piece.length > 0) parts.push(piece) - remaining -= piece.length + if (separator === '\n') builder.endLine() + else if (separator.length > 0) builder.append(separator) + builder.append(str, geometry) + if (hasEOL) builder.endLine() + remaining -= cost } } } finally { @@ -201,7 +228,66 @@ async function readPageWithinBudget( } } - return { text: parts.join(''), used: budget - remaining, completed, deadlineReached } + return { lines: builder.finish(), used: budget - remaining, completed, deadlineReached } +} + +/** Appends as much of `separator + str` as `remaining` allows, mirroring the old `slice(0, remaining)`. */ +function appendTruncated( + builder: PdfLineBuilder, + separator: string, + str: string, + geometry: ReturnType, + remaining: number +): void { + let keep = remaining + if (separator.length > 0 && keep > 0) { + if (separator === '\n') builder.endLine() + else builder.append(separator) + keep -= 1 + } + if (keep > 0) builder.append(str.slice(0, keep), geometry) +} + +/** Page height in user space, or undefined when the page cannot report a viewport. */ +function readPageHeight(page: PDFPageProxy): number | undefined { + if (typeof page.getViewport !== 'function') return undefined + try { + const height = page.getViewport({ scale: 1 }).height + return Number.isFinite(height) && height > 0 ? height : undefined + } catch { + return undefined + } +} + +/** Bytes a page's lines can occupy in the output once joined and decorated. */ +function estimatePageBytes(lines: readonly PdfLine[]): number { + let bytes = 0 + for (const line of lines) { + bytes += Buffer.byteLength(line.text, 'utf8') + MAX_LINE_DECORATION_BYTES + } + return bytes +} + +/** + * Turns the collected pages into text: repeated furniture is dropped, lines are + * joined into paragraphs, hyphenation is undone, and pages are separated by a + * paragraph break. + */ +function assemblePages(pages: readonly PdfPageLines[], complete: boolean): string { + const filteredPages = suppressFurniture(pages) + const allLines = filteredPages.flat() + const options = { + compounds: collectCompounds(allLines), + bodyHeight: dominantLineHeight(allLines), + } + const pageTexts: string[] = [] + for (const lines of filteredPages) { + const joined = joinLines(lines, options) + const text = complete ? normalizePdfWhitespace(sanitizeTextForUTF8(joined)).trim() : joined + if (text.length > 0) pageTexts.push(text) + } + const text = pageTexts.join(PAGE_SEPARATOR) + return complete ? text : normalizePdfWhitespace(text).trim() } function completeExtractionLimit(message: string): FileParserError { @@ -217,7 +303,7 @@ async function extractTextWithinBudget( const complete = options.pdfTextMode === 'complete' const totalPages = pdf.numPages const pageLimit = Math.min(totalPages, MAX_PDF_PAGES) - const pageTexts: string[] = [] + const pages: PdfPageLines[] = [] let remainingChars = MAX_PDF_TEXT_CHARS let outputBytes = 0 @@ -250,6 +336,7 @@ async function extractTextWithinBudget( } const page = pageResult + const pageHeight = readPageHeight(page) let extraction: PageExtraction try { extraction = await readPageWithinBudget( @@ -262,26 +349,25 @@ async function extractTextWithinBudget( page.cleanup() } - const { text, used, completed } = extraction + const { lines, used, completed } = extraction if (!complete) remainingChars -= used /** A page stopped before yielding text must not count as read or add a separator. */ - if (completed || text.length > 0) { + if (completed || used > 0) { pagesRead++ if (complete) { - const normalized = sanitizeTextForUTF8(text.replace(/\s+/g, ' ')).trim() - if (normalized.length > 0) { - outputBytes += Buffer.byteLength(normalized, 'utf8') + (pageTexts.length > 0 ? 1 : 0) + if (lines.length > 0) { + outputBytes += estimatePageBytes(lines) + (pages.length > 0 ? PAGE_SEPARATOR.length : 0) if (outputBytes > MAX_COMPLETE_PDF_TEXT_BYTES) { throw completeExtractionLimit( `PDF text exceeds the safe ${MAX_COMPLETE_PDF_TEXT_BYTES.toLocaleString()}-byte output limit.` ) } - pageTexts.push(normalized) + pages.push({ lines, pageHeight }) } } else { - pageTexts.push(text) + pages.push({ lines, pageHeight }) } } @@ -298,8 +384,11 @@ async function extractTextWithinBudget( } } + const text = assemblePages(pages, complete) + return { - text: complete ? pageTexts.join(' ') : pageTexts.join('\n').replace(/\s+/g, ' '), + /** Paragraph breaks and heading markers land after the budget is spent, so trim that overflow. */ + text: complete ? text : text.slice(0, MAX_PDF_TEXT_CHARS), totalPages, pagesRead, truncated, From 43a6668a8c348a511610fd31fb87752c1a95c9ac Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 9 Sep 2026 19:11:23 -0700 Subject: [PATCH 07/21] fix(parsers): tighten benchmark-found edge cases Ambiguous archives and binary layouts stay on the SheetJS and legacy Word routes instead of being refused; line-end hyphens are removed only when the document shows the joined word; page numbers printed inside a wide margin are dropped from a page's edge lines; time-of-day cells no longer carry the 1899 epoch; table cells with several paragraphs keep a space between them. Co-Authored-By: Claude Fable 5.1 --- apps/sim/lib/file-parsers/html-parser.test.ts | 9 ++ apps/sim/lib/file-parsers/html-parser.ts | 7 +- .../lib/file-parsers/pdf-furniture.test.ts | 19 ++++ apps/sim/lib/file-parsers/pdf-furniture.ts | 21 ++++ apps/sim/lib/file-parsers/pdf-lines.test.ts | 16 ++- apps/sim/lib/file-parsers/pdf-lines.ts | 32 +++++- .../file-parsers/pdf-parser-structure.test.ts | 2 +- apps/sim/lib/file-parsers/pdf-parser.ts | 2 + .../file-parsers/sheet-display-text.test.ts | 5 + .../lib/file-parsers/sheet-display-text.ts | 2 + apps/sim/lib/file-parsers/sniff.test.ts | 20 +++- apps/sim/lib/file-parsers/sniff.ts | 11 ++ apps/sim/scripts/parser-eval/REPORT-after.md | 102 ++++++++++++++++++ .../{REPORT.md => REPORT-before.md} | 0 14 files changed, 235 insertions(+), 13 deletions(-) create mode 100644 apps/sim/scripts/parser-eval/REPORT-after.md rename apps/sim/scripts/parser-eval/{REPORT.md => REPORT-before.md} (100%) diff --git a/apps/sim/lib/file-parsers/html-parser.test.ts b/apps/sim/lib/file-parsers/html-parser.test.ts index b08c38d072d..d647066c701 100644 --- a/apps/sim/lib/file-parsers/html-parser.test.ts +++ b/apps/sim/lib/file-parsers/html-parser.test.ts @@ -9,6 +9,15 @@ import { HtmlComplexityError, HtmlParser } from '@/lib/file-parsers/html-parser' const parser = new HtmlParser() +describe('table cells with several paragraphs', () => { + it('separates block children inside a cell with a space', async () => { + const html = '

      Заказчик

      Исполняющий

      ok
      ' + const result = await new HtmlParser().parseBuffer(Buffer.from(html)) + + expect(result.content).toContain('| Заказчик Исполняющий | ok |') + }) +}) + describe('HtmlParser', () => { it('reports empty input with the typed parser taxonomy', async () => { await expect(parser.parseBuffer(Buffer.alloc(0))).rejects.toMatchObject({ diff --git a/apps/sim/lib/file-parsers/html-parser.ts b/apps/sim/lib/file-parsers/html-parser.ts index a11e5f5a594..c11d21f8c2f 100644 --- a/apps/sim/lib/file-parsers/html-parser.ts +++ b/apps/sim/lib/file-parsers/html-parser.ts @@ -105,6 +105,9 @@ export function assertHtmlStringWithinLimits(html: string): void { const NON_CONTENT_SELECTOR = 'script, style, noscript, meta, link, iframe, object, embed, svg' +/** Block elements inside a table cell; `.text()` would otherwise glue their words together. */ +const CELL_BLOCK_SELECTOR = 'p, div, li, br, h1, h2, h3, h4, h5, h6, tr' + /** mammoth renders a footnote's return link as ``. */ const FOOTNOTE_BACKLINK_SELECTOR = 'a[href^="#footnote-ref"]' @@ -347,7 +350,9 @@ function processTable( const cells: string[] = [] $row.find('td, th').each((_, cell) => { - const cellText = $(cell).text().replace(/\s+/g, ' ').trim() + const $cell = $(cell) + $cell.find(CELL_BLOCK_SELECTOR).after(' ') + const cellText = $cell.text().replace(/\s+/g, ' ').trim() cells.push(cellText || '') }) diff --git a/apps/sim/lib/file-parsers/pdf-furniture.test.ts b/apps/sim/lib/file-parsers/pdf-furniture.test.ts index 53fab1d1bad..c947746d4c6 100644 --- a/apps/sim/lib/file-parsers/pdf-furniture.test.ts +++ b/apps/sim/lib/file-parsers/pdf-furniture.test.ts @@ -33,6 +33,25 @@ function texts(pages: PdfLine[][]): string[][] { return pages.map((lines) => lines.map((entry) => entry.text)) } +describe('page numbers outside the band', () => { + it('drops a folio that is the last line of a page even inside a wide margin', () => { + const page = (n: number) => ({ + pageHeight: 842, + lines: [ + { text: 'Body text of the page.', y: 700, height: 10 }, + { text: String(n), y: 189, height: 10 }, + ], + }) + const result = suppressFurniture([page(1), page(2), page(3)]) + + expect(result.map((lines) => lines.map((line) => line.text))).toEqual([ + ['Body text of the page.'], + ['Body text of the page.'], + ['Body text of the page.'], + ]) + }) +}) + describe('suppressFurniture', () => { it('drops a header repeated on enough pages but keeps its first occurrence', () => { const pages = [1, 2, 3, 4].map((i) => page(i, { header: 'ACME Corp — Internal Use Only' })) diff --git a/apps/sim/lib/file-parsers/pdf-furniture.ts b/apps/sim/lib/file-parsers/pdf-furniture.ts index 2e200c5852c..4475092085f 100644 --- a/apps/sim/lib/file-parsers/pdf-furniture.ts +++ b/apps/sim/lib/file-parsers/pdf-furniture.ts @@ -64,6 +64,9 @@ export function suppressFurniture(pages: readonly PdfPageLines[]): PdfLine[][] { pages.forEach((page, pageIndex) => { const seen = new Set() + for (const index of edgeLineIndices(page)) { + if (isPageNumber(page.lines[index].text, pages.length)) drops[pageIndex].add(index) + } for (const group of bandGroups(page)) { if (isPageNumber(group.text, pages.length)) { for (const index of group.indices) drops[pageIndex].add(index) @@ -137,6 +140,24 @@ export function furnitureThreshold(pageCount: number): number | undefined { return Math.max(MIN_FURNITURE_REPEATS, Math.ceil(FURNITURE_PAGE_FRACTION * pageCount)) } +/** + * The first and last non-blank lines of a page. A folio printed inside a wide + * margin sits outside the band, but it is still the edge of the page's text. + */ +function edgeLineIndices(page: PdfPageLines): number[] { + const indices: number[] = [] + let first = -1 + let last = -1 + page.lines.forEach((line, index) => { + if (line.text.trim().length === 0) return + if (first === -1) first = index + last = index + }) + if (first !== -1) indices.push(first) + if (last !== -1 && last !== first) indices.push(last) + return indices +} + /** Groups consecutive band lines that share a baseline into one furniture row. */ function bandGroups(page: PdfPageLines): BandGroup[] { const { lines, pageHeight } = page diff --git a/apps/sim/lib/file-parsers/pdf-lines.test.ts b/apps/sim/lib/file-parsers/pdf-lines.test.ts index 1dbc90e13a5..c2090dffd0a 100644 --- a/apps/sim/lib/file-parsers/pdf-lines.test.ts +++ b/apps/sim/lib/file-parsers/pdf-lines.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest' import { collectCompounds, + collectWords, dominantLineHeight, joinLines, normalizePdfWhitespace, @@ -113,14 +114,25 @@ describe('joinLines', () => { }) describe('dehyphenation', () => { - it('removes a line-end hyphen when the next line continues the word', () => { + it('removes a line-end hyphen when the document shows the joined word', () => { const lines = paragraph(['archived by the Infra-', 'structure team.'], 627.4) + const words = collectWords([{ text: 'The Infrastructure team owns it.', height: BODY }]) - expect(joinLines(lines, { headingMarkers: false })).toBe( + expect(joinLines(lines, { words, headingMarkers: false })).toBe( 'archived by the Infrastructure team.' ) }) + it('keeps an unknown line-end hyphen rather than inventing a word', () => { + const lines = paragraph(['we ship high-', 'quality builds'], 627.4) + const words = collectWords(lines) + + expect(joinLines(lines, { words, headingMarkers: false })).toBe( + 'we ship high-quality builds' + ) + expect(joinLines(lines, { headingMarkers: false })).toBe('we ship high-quality builds') + }) + it('keeps the hyphen when the compound appears intact elsewhere in the document', () => { const lines = paragraph(['we compare attention-', 'based models with others'], 700) const compounds = collectCompounds([{ text: 'Attention-based models win.', height: BODY }]) diff --git a/apps/sim/lib/file-parsers/pdf-lines.ts b/apps/sim/lib/file-parsers/pdf-lines.ts index e07b175a8ad..b6392ac42a2 100644 --- a/apps/sim/lib/file-parsers/pdf-lines.ts +++ b/apps/sim/lib/file-parsers/pdf-lines.ts @@ -41,6 +41,12 @@ export type PdfLineSeparator = '' | ' ' | '\n' export interface JoinLinesOptions { /** Lowercase `a-b` compounds seen intact in the document; a line break on their hyphen keeps it. */ compounds?: ReadonlySet + /** + * Lowercase words seen in the document. A line-end hyphen is dropped only when + * the joined word occurs elsewhere, so `high-` / `quality` keeps its hyphen + * while `Infra-` / `structure` rejoins when `Infrastructure` appears intact. + */ + words?: ReadonlySet /** Dominant body-text height for the document; enables heading markers and heading pitch scaling. */ bodyHeight?: number /** Prefixes short, oversized lines with `## ` so Markdown-aware chunkers split on them. */ @@ -81,6 +87,7 @@ const SOFT_HYPHEN = '\u00AD' const TRAILING_HYPHEN = /(\p{L}+)-$/u const LEADING_LOWERCASE_WORD = /^(\p{Ll}\p{L}*)/u const INTACT_COMPOUND = /\p{L}+-\p{L}+/gu +const WORD_TOKEN = /\p{L}{3,}/gu /** * Reads an item's placement, or undefined when the item is rotated, vertical, @@ -194,6 +201,15 @@ export function collectCompounds(lines: Iterable): Set { return compounds } +/** Lowercase words of three or more letters seen anywhere in the document. */ +export function collectWords(lines: Iterable): Set { + const words = new Set() + for (const line of lines) { + for (const match of line.text.matchAll(WORD_TOKEN)) words.add(match[0].toLowerCase()) + } + return words +} + /** Character-weighted modal line height across the document; 0 when unknown. */ export function dominantLineHeight(lines: Iterable): number { const weights = new Map() @@ -224,6 +240,7 @@ export function joinLines(lines: readonly PdfLine[], options: JoinLinesOptions = const bodyHeight = options.bodyHeight ?? 0 const headingMarkers = options.headingMarkers ?? PDF_HEADING_MARKERS_ENABLED const compounds = options.compounds + const words = options.words let out = decorate(lines[0], bodyHeight, headingMarkers) for (let i = 1; i < lines.length; i++) { @@ -234,7 +251,7 @@ export function joinLines(lines: readonly PdfLine[], options: JoinLinesOptions = continue } if (separator === '\n') { - const joined = dehyphenate(out, line.text, compounds) + const joined = dehyphenate(out, line.text, compounds, words) if (joined !== undefined) { out = joined continue @@ -260,20 +277,25 @@ function decorate(line: PdfLine, bodyHeight: number, headingMarkers: boolean): s /** * Joins `next` onto `out` across a hyphen that ended the line, or undefined - * when the break is not a hyphenation. A compound seen intact elsewhere in the - * document keeps its hyphen. + * when the break is not a hyphenation. The hyphen is removed only when the + * document itself shows the joined word; a compound seen intact keeps it, and + * an unknown pair keeps it too, because `high-quality` split at a line end is + * far more common in real documents than a word the document never repeats. */ function dehyphenate( out: string, next: string, - compounds: ReadonlySet | undefined + compounds: ReadonlySet | undefined, + words: ReadonlySet | undefined ): string | undefined { const head = TRAILING_HYPHEN.exec(out) const tail = LEADING_LOWERCASE_WORD.exec(next) if (!head || !tail) return undefined const compound = `${head[1]}-${tail[1]}`.toLowerCase() if (compounds?.has(compound)) return out + next - return out.slice(0, -1) + next + const joined = `${head[1]}${tail[1]}`.toLowerCase() + if (words?.has(joined)) return out.slice(0, -1) + next + return out + next } function separatorBetween( diff --git a/apps/sim/lib/file-parsers/pdf-parser-structure.test.ts b/apps/sim/lib/file-parsers/pdf-parser-structure.test.ts index 46cd7a52fbe..308237a181b 100644 --- a/apps/sim/lib/file-parsers/pdf-parser-structure.test.ts +++ b/apps/sim/lib/file-parsers/pdf-parser-structure.test.ts @@ -103,7 +103,7 @@ describe('PdfParser structure reconstruction', () => { 'review the attached appendix before the next checkpoint, and the owning', 'team retains sign-off authority for scope changes above five percent.', 'Exceptions require written approval from a director or above. This', - 'supersedes the guidance circulated on 14 March and applies immediately.', + 'supersedes the guidance the Infrastructure team circulated on 14 March.', ] const p1Top = 676.6 const p2Top = p1Top - 5 * PITCH - PARAGRAPH_GAP diff --git a/apps/sim/lib/file-parsers/pdf-parser.ts b/apps/sim/lib/file-parsers/pdf-parser.ts index 13c1464d3df..b685a5ddb21 100644 --- a/apps/sim/lib/file-parsers/pdf-parser.ts +++ b/apps/sim/lib/file-parsers/pdf-parser.ts @@ -5,6 +5,7 @@ import { FileParserError } from '@/lib/file-parsers/errors' import { type PdfPageLines, suppressFurniture } from '@/lib/file-parsers/pdf-furniture' import { collectCompounds, + collectWords, dominantLineHeight, joinLines, normalizePdfWhitespace, @@ -278,6 +279,7 @@ function assemblePages(pages: readonly PdfPageLines[], complete: boolean): strin const allLines = filteredPages.flat() const options = { compounds: collectCompounds(allLines), + words: collectWords(allLines), bodyHeight: dominantLineHeight(allLines), } const pageTexts: string[] = [] diff --git a/apps/sim/lib/file-parsers/sheet-display-text.test.ts b/apps/sim/lib/file-parsers/sheet-display-text.test.ts index e5e63edd38f..6b26aa9841a 100644 --- a/apps/sim/lib/file-parsers/sheet-display-text.test.ts +++ b/apps/sim/lib/file-parsers/sheet-display-text.test.ts @@ -115,6 +115,11 @@ describe('isoDateText', () => { it('renders an invalid date as empty text', () => { expect(isoDateText(new Date(Number.NaN))).toBe('') }) + + it('renders a duration or time-of-day cell without the 1899 epoch date', () => { + expect(isoDateText(new Date(Date.UTC(1899, 11, 30, 0, 30, 0)))).toBe('00:30:00') + expect(isoDateText(new Date(Date.UTC(1899, 11, 31, 13, 5, 9)))).toBe('13:05:09') + }) }) describe('normalizeSheetDisplayText', () => { diff --git a/apps/sim/lib/file-parsers/sheet-display-text.ts b/apps/sim/lib/file-parsers/sheet-display-text.ts index e0c9564fbd1..88683ae24bc 100644 --- a/apps/sim/lib/file-parsers/sheet-display-text.ts +++ b/apps/sim/lib/file-parsers/sheet-display-text.ts @@ -25,6 +25,8 @@ interface CellLookup { export function isoDateText(date: Date): string { if (Number.isNaN(date.getTime())) return '' const iso = date.toISOString() + /** A serial below 1 is a duration or time of day; Excel shows it without the 1899 epoch date. */ + if (date.getUTCFullYear() < 1900) return iso.slice(11, 19) return iso.endsWith('T00:00:00.000Z') ? iso.slice(0, 10) : iso.slice(0, 19) } diff --git a/apps/sim/lib/file-parsers/sniff.test.ts b/apps/sim/lib/file-parsers/sniff.test.ts index e4c4c7680ad..180cf6ade72 100644 --- a/apps/sim/lib/file-parsers/sniff.test.ts +++ b/apps/sim/lib/file-parsers/sniff.test.ts @@ -183,14 +183,10 @@ describe('reconcileParserRoute', () => { ['txt', 'binary'], ['csv', 'zip'], ['txt', 'ole2'], - ['doc', 'binary'], ['docx', 'binary'], - ['docx', 'zip'], - ['xlsx', 'binary'], ['pdf', 'binary'], ['pdf', 'ole2'], ['odt', 'ole2'], - ['odt', 'zip'], ])('rejects .%s holding %s as invalid_format', (extension, kind) => { const error = (() => { try { @@ -211,6 +207,22 @@ describe('reconcileParserRoute', () => { ) }) + it('keeps an unrecognised archive on a spreadsheet or Word route for the parser to judge', () => { + expect(reconcileParserRoute('xlsx', 'zip')).toEqual({ extension: 'xlsx' }) + expect(reconcileParserRoute('docx', 'zip')).toEqual({ extension: 'docx' }) + expect(() => reconcileParserRoute('txt', 'zip')).toThrow( + expect.objectContaining({ code: 'invalid_format' }) + ) + }) + + it('keeps an unknown binary layout on the SheetJS and legacy Word routes', () => { + expect(reconcileParserRoute('xls', 'binary')).toEqual({ extension: 'xls' }) + expect(reconcileParserRoute('doc', 'binary')).toEqual({ extension: 'doc' }) + expect(() => reconcileParserRoute('docx', 'binary')).toThrow( + expect.objectContaining({ code: 'invalid_format' }) + ) + }) + it('leaves an extension with no known family alone', () => { expect(reconcileParserRoute('unknown', 'binary')).toEqual({ extension: 'unknown' }) }) diff --git a/apps/sim/lib/file-parsers/sniff.ts b/apps/sim/lib/file-parsers/sniff.ts index 74c707eef6a..4123e584b1a 100644 --- a/apps/sim/lib/file-parsers/sniff.ts +++ b/apps/sim/lib/file-parsers/sniff.ts @@ -289,6 +289,17 @@ export function reconcileParserRoute(extension: string, kind: SniffedKind): Pars } if (FAMILY_ACCEPTS[family].has(kind)) return { extension } + /** + * Ambiguous bytes stay on the declared route. An archive without a recognised + * layout may still be a workbook SheetJS reads (`xl/` is a convention, not a + * rule), and an unknown binary layout under a spreadsheet or legacy Word + * extension covers raw BIFF streams and other formats those parsers accept. + * Each of those parsers raises its own typed error when the bytes are not a + * document, so passing them through never yields scraped garbage. + */ + if (kind === 'zip' && family !== 'pdf' && family !== 'text') return { extension } + if (kind === 'binary' && (family === 'sheet' || family === 'ole')) return { extension } + if (kind === 'text') return override(family === 'sheet' ? 'csv' : 'txt') if (kind === 'ole2') { if (family === 'word') return override('doc') diff --git a/apps/sim/scripts/parser-eval/REPORT-after.md b/apps/sim/scripts/parser-eval/REPORT-after.md new file mode 100644 index 00000000000..dd67e335e8a --- /dev/null +++ b/apps/sim/scripts/parser-eval/REPORT-after.md @@ -0,0 +1,102 @@ +# Parser quality report + +Tier A: 107 files, Tier B: 30 files, robustness: 14 cases + +## Tier A — ground truth by construction (mean per format) + +| format | n | ned | presence | absence | order | table_adjacency | noise_ratio | glued_words | paragraph_retention | heading_retention | chunk_sentence_boundary | junk_per_1k | ms | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| csv | 4 | 0.70 | 1.00 | — | — | 0.99 | 0.00 | 0.00 | — | — | — | 0.00 | 1.62 | +| docx | 14 | 0.94 | 1.00 | — | 1.00 | 1.00 | 0.01 | 0.00 | 0.98 | 0.91 | 1.00 | 0.00 | 9.09 | +| html | 14 | 0.91 | 1.00 | — | 1.00 | 1.00 | 0.01 | 0.00 | 1.00 | 0.85 | 1.00 | 0.00 | 1.31 | +| md | 14 | 0.94 | 1.00 | — | 1.00 | 1.00 | 0.00 | 0.00 | 0.99 | 1.00 | 1.00 | 0.00 | 0.10 | +| ods | 4 | 0.86 | 0.92 | — | — | 0.88 | 0.06 | 0.00 | — | — | — | 0.00 | 1.95 | +| odt | 14 | 0.94 | 1.00 | — | 1.00 | 1.00 | 0.01 | 0.00 | 0.99 | 0.91 | 1.00 | 0.00 | 1.24 | +| pdf | 14 | 0.89 | 0.99 | 0.00 | 1.00 | 0.94 | 0.06 | 0.00 | 0.97 | 1.00 | 1.00 | 0.00 | 18.77 | +| pdf-2col | 3 | 0.94 | 1.00 | 0.00 | 1.00 | 0.93 | 0.02 | 0.00 | 0.86 | 1.00 | 1.00 | 0.00 | 8.17 | +| pptx | 14 | 0.93 | 1.00 | — | 1.00 | 1.00 | 0.01 | 0.00 | 0.99 | 0.91 | 0.00 | 0.00 | 2.02 | +| xls | 4 | 0.90 | 1.00 | — | — | 0.99 | 0.02 | 0.00 | — | — | — | 0.00 | 2.12 | +| xlsb | 4 | 0.90 | 1.00 | — | — | 0.99 | 0.02 | 0.00 | — | — | — | 0.00 | 1.48 | +| xlsx | 4 | 0.90 | 1.00 | — | — | 0.99 | 0.02 | 0.00 | — | — | — | 0.00 | 3.30 | + +### Worst Tier A files by sentinel presence / adjacency / noise + +| file | presence | absence | order | adjacency | noise | para | heading | noise sample | +|---|---|---|---|---|---|---|---|---| +| sheet-typed.ods | 0.679 | None | None | 0.5 | 0.2 | None | None | 085 1063 12000 1250 1500 8425 | +| unicode-multilingual.pdf | 0.833 | 0.0 | 1.0 | 1.0 | 0.185 | 1.0 | None | acme confidential corp distribute do draft | +| changelog.pdf | None | 0.0 | None | None | 0.121 | 1.0 | 1.0 | acme confidential corp distribute do draft | +| memo.pdf | 1.0 | 0.0 | 1.0 | None | 0.077 | 1.0 | None | acme confidential corp distribute do draft | +| sop-access-review.pdf | 1.0 | 0.0 | 1.0 | 0.75 | 0.077 | 1.0 | 1.0 | acme confidential corp distribute do draft | +| onboarding-guide.pdf | 1.0 | 0.0 | 1.0 | None | 0.069 | 1.0 | 1.0 | acme confidential corp distribute do draft | +| meeting-notes.pdf | 1.0 | 0.0 | 1.0 | 1.0 | 0.068 | 1.0 | 1.0 | acme confidential corp distribute do internal | +| tech-spec.pdf | 1.0 | 0.0 | 1.0 | 1.0 | 0.062 | 1.0 | 1.0 | acme confidential corp distribute do draft | +| faq-benefits.pdf | 1.0 | 0.0 | 1.0 | None | 0.061 | 1.0 | 1.0 | acme confidential corp distribute draft infra | +| security-policy.pdf | 1.0 | 0.0 | 1.0 | None | 0.042 | 1.0 | 1.0 | acme confidential corp distribute do draft | +| incident-postmortem.pdf | 1.0 | 0.0 | 1.0 | 1.0 | 0.041 | 1.0 | 1.0 | acme confidential corp distribute do draft | +| sheet-typed.xls | 1.0 | None | None | 0.958 | 0.038 | None | None | ledger sheet | +| sheet-typed.xlsb | 1.0 | None | None | 0.958 | 0.038 | None | None | ledger sheet | +| sheet-typed.xlsx | 1.0 | None | None | 0.958 | 0.038 | None | None | ledger sheet | +| sheet-multi.ods | 1.0 | None | None | 1.0 | 0.032 | None | None | notes raw sheet summary | + +## Tier B — real-world files vs reference extractors + +| file | fmt | len | degraded | pages (ours/ref) | reference | ned | ref line recall | out line precision | noise | noise sample | +|---|---|---|---|---|---|---|---|---|---|---| +| attention.pdf | pdf | 39812 | False | 15/15 | pymupdf (39495) | 0.994 | 1.0 | 1.0 | 0.001 | df epos | +| attention.pdf | pdf | 39812 | False | 15/15 | pdfplumber (35525) | 0.846 | 0.185 | 0.2 | 0.001 | df epos | +| bitcoin.pdf | pdf | 21314 | False | 9/9 | pymupdf (21220) | 0.998 | 1.0 | 0.99 | 0.0 | | +| bitcoin.pdf | pdf | 21314 | False | 9/9 | pdfplumber (21216) | 0.905 | 0.915 | 0.965 | 0.0 | | +| irs-f1040.pdf | pdf | 10221 | False | 2/2 | pymupdf (10156) | 0.996 | 1.0 | 1.0 | 0.0 | | +| irs-f1040.pdf | pdf | 10221 | False | 2/2 | pdfplumber (10152) | 0.797 | 0.739 | 0.843 | 0.0 | | +| irs-p17.pdf | pdf | 950349 | False | 142/142 | pymupdf (960116) | 0.976 | 1.0 | 1.0 | 0.0 | | +| irs-p17.pdf | pdf | 950349 | False | 142/142 | pdfplumber (431054) | 0.289 | 0.215 | 0.855 | 0.0 | | +| lo-fdo38244.odt | odt | 16 | False | | pandoc (16) | 1.0 | None | None | 0.0 | | +| lo-lists.odt | odt | ERROR | | | | | | | | No text could be extracted from this OpenDocument file | +| lo-simple.odp | odp | 37 | False | | (none) | | | | | | +| lo-simple.ods | ods | 11812 | False | | (none) | | | | | | +| lo-tables.odt | odt | 17 | False | | (none) | | | | | | +| mdn-fetch.html | html | 6581 | False | | (none) | | | | | | +| omnidocbench.pdf | pdf | 103370 | False | 32/32 | pymupdf (102111) | 0.984 | 1.0 | 1.0 | 0.003 | 10190 2011年1月1日 7000 aaaaa ajhb bf00326833 | +| omnidocbench.pdf | pdf | 103370 | False | 32/32 | pdfplumber (100495) | 0.351 | 0.365 | 0.41 | 0.003 | 10190 2011年1月1日 7000 aaaaa ajhb bf00326833 | +| pdf-reference-excerpt.pdf | pdf | 14 | False | 1/1 | pymupdf (14) | 1.0 | None | None | 0.0 | | +| pdf-reference-excerpt.pdf | pdf | 14 | False | 1/1 | pdfplumber (14) | 1.0 | None | None | 0.0 | | +| poi-basic.ppt | ppt | ERROR | | | | | | | | Unsupported file type: ppt. Supported types are: pdf, csv, d | +| poi-bug-tables.doc | doc | ERROR | | | | | | | | This .doc file uses a Word 6/95 format that is not supported | +| poi-bullets.ppt | ppt | ERROR | | | | | | | | Unsupported file type: ppt. Supported types are: pdf, csv, d | +| poi-footnotes.docx | docx | 49 | False | | python-docx (33) | 0.717 | 1.0 | 1.0 | 0.0 | | +| poi-footnotes.docx | docx | 49 | False | | pandoc (47) | 0.957 | 1.0 | 1.0 | 0.0 | | +| poi-header-footer.doc | doc | 507 | False | | (none) | | | | | | +| poi-headerfooter.docx | docx | ERROR | | | | | | | | No text could be extracted from this DOCX file | +| poi-layouts.pptx | pptx | 605 | False | | python-pptx (650) | 0.903 | 0.5 | 1.0 | 0.0 | | +| poi-lists.doc | doc | 530 | False | | (none) | | | | | | +| poi-multisheet.xls | xls | 133 | False | | (none) | | | | | | +| poi-notes.pptx | pptx | 2288 | False | | python-pptx (2357) | 0.963 | 1.0 | 1.0 | 0.0 | | +| poi-sample.docx | docx | 1544 | False | | python-docx (1542) | 1.0 | 1.0 | 1.0 | 0.0 | | +| poi-sample.docx | docx | 1544 | False | | pandoc (1542) | 1.0 | 1.0 | 1.0 | 0.0 | | +| poi-sample.pptx | pptx | 139 | False | | python-pptx (152) | 0.895 | 1.0 | 1.0 | 0.0 | | +| poi-sample.xlsx | xlsx | 391 | False | | openpyxl (292) | 0.753 | 1.0 | 0.5 | 0.019 | empty | +| poi-sampledoc.doc | doc | 137 | False | | (none) | | | | | | +| poi-simple.xls | xls | 144 | False | | (none) | | | | | | +| poi-tables.ppt | ppt | ERROR | | | | | | | | Unsupported file type: ppt. Supported types are: pdf, csv, d | +| w3c-html-spec-intro.html | html | 54959 | False | | (none) | | | | | | +| wiki-rag.html | html | 27543 | False | | (none) | | | | | | + +## Robustness + +| case | expected | passed | outcome | +|---|---|---|---| +| csv-labelled-xlsx | typed error OR correct UTF-8 text | ✅ | ok 3594 chars | +| docx-bytes-labelled-pdf | typed error OR correct text (magic wins) | ✅ | ok 856 chars | +| docx-labelled-doc | correct text | ✅ | ok 856 chars | +| docx-labelled-xlsx | typed error OR correct text (magic wins) | ✅ | ok 856 chars | +| empty.docx | typed error | ✅ | typed empty_input: Empty buffer provided | +| html-labelled-txt | markup stripped or typed error | ✅ | ok 891 chars | +| latin1-txt | text decodes to "Café résumé naïve £" | ✅ | ok 22 chars | +| pdf-bytes-labelled-docx | typed error OR correct text | ✅ | ok 940 chars | +| png-labelled-doc | typed error, never placeholder prose | ✅ | typed invalid_format: File content does not match the .doc extension (detected binary). Re-save it as DOCX and r | +| pptx-labelled-ppt | typed unsupported_type (.ppt refused) OR correct text | ✅ | typed unsupported_type: Unsupported file type: ppt. Supported types are: pdf, csv, doc, docx, docm, dotx, txt, md, | +| random-bytes-labelled-ppt | typed error, never placeholder prose | ✅ | typed unsupported_type: Unsupported file type: ppt. Supported types are: pdf, csv, doc, docx, docm, dotx, txt, md, | +| truncated-docx | typed error (invalid_format) | ✅ | typed invalid_format: Unable to inspect ZIP central directory; refusing to parse an unverifiable ZIP-shaped arch | +| truncated-pdf | typed error (invalid_format) | ✅ | typed invalid_format: Invalid PDF: Invalid PDF structure. | +| utf16-txt | text decodes to "Hello UTF-16 world" | ✅ | ok 18 chars | diff --git a/apps/sim/scripts/parser-eval/REPORT.md b/apps/sim/scripts/parser-eval/REPORT-before.md similarity index 100% rename from apps/sim/scripts/parser-eval/REPORT.md rename to apps/sim/scripts/parser-eval/REPORT-before.md From b761aa5a3ee90aa0d2aa1f3d54f92a8eb3271e00 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 9 Sep 2026 19:17:26 -0700 Subject: [PATCH 08/21] docs(parsers): record the before/after parser benchmark Co-Authored-By: Claude Fable 5.1 --- apps/sim/scripts/parser-eval/BENCHMARK-raw.md | 171 ++++++++++++++++++ apps/sim/scripts/parser-eval/BENCHMARK.md | 51 ++++++ apps/sim/scripts/parser-eval/bench-compare.py | 12 +- 3 files changed, 232 insertions(+), 2 deletions(-) create mode 100644 apps/sim/scripts/parser-eval/BENCHMARK-raw.md create mode 100644 apps/sim/scripts/parser-eval/BENCHMARK.md diff --git a/apps/sim/scripts/parser-eval/BENCHMARK-raw.md b/apps/sim/scripts/parser-eval/BENCHMARK-raw.md new file mode 100644 index 00000000000..8c6c3d21c44 --- /dev/null +++ b/apps/sim/scripts/parser-eval/BENCHMARK-raw.md @@ -0,0 +1,171 @@ +# Before/after benchmark + +961 files compared. Gate: recall −0.02, vocab recall −0.02, noise +0.02, glued +1, junk +0.5, ok→error (except intended), now-degraded. + +**Regressions: 57** + +| ext | n | ok before→after | typed errors b→a | degraded b→a | recall b→a | ref_vocab_recall b→a | precision b→a | noise b→a | glued b→a | lines b→a | repeated_lines b→a | page_number_lines b→a | junk b→a | chunks b→a | ms b→a | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| csv | 52 | 52→52 | 0→0 | 0→0 | 0.556→0.556 | 0.657→0.657 | 0.506→0.506 | 0.578→0.578 | 0.115→0.115 | 561.558→561.558 | 1.423→1.423 | 0.019→0.019 | 0.000→0.000 | 7.135→7.135 | 12.074→12.369 | +| doc | 51 | 51→41 | 0→10 | 51→0 | 0.522→0.793 | 0.537→0.934 | 0.426→0.891 | 0.761→0.057 | 0.043→0.000 | 1.000→13.902 | 0.000→0.780 | 0.000→0.390 | 0.000→0.000 | 1.157→1.146 | 1.407→0.816 | +| docx | 102 | 81→84 | 16→18 | 0→0 | 0.830→0.830 | 0.936→0.962 | 0.985→0.920 | 0.040→0.040 | 0.038→0.051 | 163.728→44.298 | 77.802→6.643 | 26.321→0.024 | 0.002→0.001 | 2.136→2.310 | 22.842→14.846 | +| html | 60 | 60→60 | 0→0 | 0→0 | 0.791→0.795 | 0.971→0.973 | 0.636→0.616 | 0.204→0.207 | 15.900→9.467 | 2078.383→1141.517 | 776.833→196.833 | 20.050→4.833 | 0.009→0.009 | 29.817→31.200 | 41.769→47.275 | +| json | 31 | 30→30 | 1→1 | 0→0 | 0.990→0.990 | 0.982→0.982 | 0.994→0.994 | 0.169→0.169 | 1.033→1.033 | 19655.467→19655.467 | 17489.333→17489.333 | 2.000→2.000 | 0.051→0.051 | 116.133→116.133 | 3.446→3.349 | +| md | 38 | 38→38 | 0→0 | 0→0 | 1.000→1.000 | 0.996→0.996 | 1.000→1.000 | 0.039→0.039 | 0.289→0.289 | 162.289→162.289 | 12.211→12.211 | 0.026→0.026 | 0.000→0.000 | 4.605→4.605 | 0.298→0.311 | +| odp | 27 | 13→13 | 14→14 | 0→0 | —→— | —→— | —→— | —→— | —→— | 3.615→3.846 | 0.231→0.231 | 0.231→0.077 | 0.000→0.000 | 1.000→1.000 | 0.804→0.527 | +| ods | 32 | 32→32 | 0→0 | 6→6 | 0.839→0.927 | 0.976→0.976 | 0.544→0.615 | 0.350→0.327 | 0.000→0.000 | 26.438→26.438 | 0.188→0.344 | 0.594→0.812 | 0.000→0.000 | 1.000→1.000 | 1.333→1.326 | +| odt | 44 | 33→32 | 11→12 | 0→0 | 0.892→0.861 | 0.867→0.970 | 0.838→0.844 | 0.182→0.079 | 0.296→0.037 | 6.182→5.719 | 0.727→0.438 | 0.576→0.031 | 0.000→0.000 | 1.061→1.125 | 0.638→0.416 | +| pdf | 190 | 190→190 | 0→0 | 0→0 | 0.991→0.991 | 0.977→0.976 | 0.983→0.979 | 0.116→0.102 | 3.158→1.234 | 0.968→1466.974 | 0.000→107.095 | 0.000→9.668 | 0.340→0.343 | 23.753→29.295 | 95.589→123.093 | +| ppt | 30 | 30→0 | 0→30 | 30→0 | —→— | —→— | —→— | —→— | —→— | 1.000→— | 0.000→— | 0.000→— | 0.000→— | 1.500→— | 0.993→— | +| pptx | 72 | 66→44 | 2→28 | 20→0 | 0.979→0.903 | 0.997→0.980 | 1.000→0.878 | 0.060→0.055 | 0.000→0.000 | 101.561→142.750 | 68.727→98.864 | 9.152→6.841 | 0.003→0.005 | 1.985→2.682 | 7.143→5.018 | +| txt | 42 | 42→42 | 0→0 | 0→0 | 0.999→1.000 | 0.966→0.974 | 1.000→1.000 | 0.044→0.040 | 8.405→5.071 | 8124.310→8124.310 | 461.381→461.190 | 0.119→0.119 | 0.000→0.000 | 126.381→126.643 | 11.934→11.675 | +| xls | 43 | 43→43 | 0→0 | 4→4 | 0.806→0.924 | 0.961→0.961 | 0.650→0.722 | 0.329→0.357 | 0.000→0.000 | 104.419→104.419 | 5.047→5.047 | 0.535→0.535 | 0.000→0.000 | 3.023→2.977 | 6.399→6.320 | +| xlsb | 17 | 17→17 | 0→0 | 1→1 | 1.000→1.000 | 0.948→0.933 | 0.671→0.671 | 0.244→0.263 | 0.000→0.000 | 27.824→27.824 | 2.000→2.000 | 2.882→2.882 | 0.509→0.509 | 1.000→1.000 | 1.326→1.295 | +| xlsm | 17 | 16→16 | 1→1 | 5→5 | 0.850→0.575 | 0.926→0.930 | 0.436→0.200 | 0.342→0.382 | 0.000→0.000 | 60.875→59.125 | 15.188→13.375 | 1.812→0.375 | 0.000→0.000 | 7.188→5.188 | 11.411→11.552 | +| xlsx | 71 | 68→68 | 0→3 | 11→11 | 0.911→0.955 | 0.981→0.983 | 0.636→0.660 | 0.253→0.263 | 0.045→0.045 | 90.706→90.706 | 2.176→2.147 | 1.015→1.000 | 0.342→0.342 | 3.088→3.132 | 10.536→9.804 | +| yaml | 42 | 39→39 | 3→3 | 0→0 | 0.829→0.829 | 0.858→0.858 | 0.412→0.412 | 0.121→0.121 | 0.154→0.154 | 209.641→209.641 | 121.205→121.205 | 0.333→0.333 | 0.000→0.000 | 1.872→1.872 | 0.268→0.263 | + +## Flags per file + +| file | flags | +|---|---| +| doc__lo__comments-nested.doc | REGRESSION:vocab-recall 0.9231->0.6154 | +| doc__lo__fdo77844.doc | REGRESSION:vocab-recall 0.9859->0.8873 | +| doc__lo__tdf127166_prstDash_Word97.doc | CHECK:length 316->116 (no reference) | +| doc__lo__tdf49102_mergedCellNumbering.doc | intended:ok->typed-error | +| doc__lo__tdf75539_relativeWidth.doc | CHECK:length 1392->49 (no reference) | +| doc__lo__tdf98284_softLockedFields.doc | REGRESSION:recall 0.5->0.0 | +| doc__loimp__image-lazy-read-0size.doc | intended:ok->typed-error | +| doc__poi__47304.doc | CHECK:length 668->14 (no reference) | +| doc__poi__52117.doc | intended:ok->typed-error | +| doc__poi__57843.doc | intended:ok->typed-error | +| doc__poi__Bug47958.doc | REGRESSION:recall 1.0->0.9474 | +| doc__poi__Bug50955.doc | intended:ok->typed-error | +| doc__poi__Bug60936.doc | intended:ok->typed-error | +| doc__poi__HeaderWithMacros.doc | REGRESSION:recall 1.0->0.0 | +| doc__poi__Word6_sections.doc | intended:ok->typed-error | +| doc__poi__ca.kwsymphony.www_education_School_Concert_Seat_Booking_Form_2011-12.doc | REGRESSION:recall 1.0->0.8947; REGRESSION:vocab-recall 0.9896->0.9583 | +| doc__poi__clusterfuzz-testcase-minimized-POIHWPFFuzzer-4951943183990784.doc | intended:ok->typed-error | +| doc__poi__clusterfuzz-testcase-minimized-POIHWPFFuzzer-5832867957309440.doc | intended:ok->typed-error | +| doc__poi__simple-table2.doc | CHECK:length 801->118 (no reference) | +| doc__poi__testCroppedPictures.doc | CHECK:length 582->18 (no reference) | +| doc__poi__vector_image.doc | intended:ok->typed-error | +| doc__unstr__fake-doc-emphasized-text.doc | REGRESSION:recall 1.0->0.5; REGRESSION:vocab-recall 1.0->0.7143 | +| docx__lo__FDO76312.docx | REGRESSION:recall 1.0->0.5 | +| docx__lo__n780563.docx | improved:error->ok | +| docx__lo__table-style-border.docx | improved:error->ok | +| docx__mammoth__tables.docx | REGRESSION:recall 0.4->0.2 | +| docx__poi__59030.docx | REGRESSION:recall 0.25->0.0 | +| docx__poi__TestTableColumns.docx | improved:error->ok | +| docx__poi__bug65649.docx | REGRESSION:glued 2->3 | +| docx__poi__clusterfuzz-testcase-minimized-POIFuzzer-6709287337197568.docx | improved:untyped->typed | +| docx__poi__clusterfuzz-testcase-minimized-POIXWPFFuzzer-4961551840247808.docx | improved:untyped->typed | +| docx__poi__clusterfuzz-testcase-minimized-POIXWPFFuzzer-5564805011079168.docx | improved:untyped->typed | +| docx__poi__clusterfuzz-testcase-minimized-POIXWPFFuzzer-6442791109263360.docx | improved:untyped->typed | +| docx__poi__crash-517626e815e0afa9decd0ebb6d1dee63fb9907dd.docx | improved:untyped->typed | +| docx__poi__table_footnotes.docx | REGRESSION:recall 0.25->0.0 | +| ods__lo__cachedValue.ods | CHECK:length 785->412 (no reference) | +| ods__lo__formula-across-sheets.ods | REGRESSION:recall 1.0->0.2222 | +| ods__lo__tdf134234.ods | REGRESSION:noise 0.5333->0.65 | +| ods__lo__tdf160003_page_anchored_object.ods | CHECK:length 753->236 (no reference) | +| ods__lo__test_borders_export.ods | REGRESSION:recall 1.0->0.9; REGRESSION:noise 0.1731->0.3175 | +| odt__loexp__redlineTextFrame.odt | REGRESSION:ok->error | +| odt__loexp__tdf169882.odt | REGRESSION:glued 0->1 | +| odt__unstr__fake.odt | REGRESSION:recall 0.7143->0.1429 | +| pdf__arxiv__2606.21840.pdf | REGRESSION:vocab-recall 0.9552->0.9286 | +| pdf__arxiv__2606.22035.pdf | REGRESSION:vocab-recall 0.9834->0.954 | +| pdf__arxiv__2606.26142.pdf | REGRESSION:vocab-recall 0.9682->0.9353 | +| pdf__arxiv__2609.09039.pdf | REGRESSION:vocab-recall 0.988->0.9654 | +| pdf__arxiv__2609.09538.pdf | REGRESSION:vocab-recall 0.9845->0.9619 | +| pdf__arxiv__2609.09831.pdf | REGRESSION:vocab-recall 0.9685->0.9436 | +| pdf__pdfjs__file_pdfjs_form.pdf | REGRESSION:vocab-recall 1.0->0.875 | +| pdf__slides__jeremytammik_tbc_ar20462_angel_velez_ifc_slides.pdf | REGRESSION:junk 11.63->12.79 | +| pdf__slides__wzpan_BeamerStyleSlides_slides.pdf | CHECK:length 575->298 (no reference) | +| ppt__lo__FillPatterns.ppt | intended:ok->typed-error | +| ppt__lo__fdo68594.ppt | intended:ok->typed-error | +| ppt__lo__indent_multiple_spacings.ppt | intended:ok->typed-error | +| ppt__lo__ppt-indentation-bullets.ppt | intended:ok->typed-error | +| ppt__lo__tdf115394.ppt | intended:ok->typed-error | +| ppt__lo__tdf122899_Arc_90_to_91_clockwise.ppt | intended:ok->typed-error | +| ppt__lo__tdf136911.ppt | intended:ok->typed-error | +| ppt__lo__tdf157636.ppt | intended:ok->typed-error | +| ppt__lo__tdf168736-1.ppt | intended:ok->typed-error | +| ppt__lo__tdf168786.ppt | intended:ok->typed-error | +| ppt__lo__tdf49561.ppt | intended:ok->typed-error | +| ppt__lo__tdf77747.ppt | intended:ok->typed-error | +| ppt__poi__119877_all_type_background_save_by_AOO.ppt | intended:ok->typed-error | +| ppt__poi__41246-2.ppt | intended:ok->typed-error | +| ppt__poi__44296.ppt | intended:ok->typed-error | +| ppt__poi__49648.ppt | intended:ok->typed-error | +| ppt__poi__54541_cropped_bitmap.ppt | intended:ok->typed-error | +| ppt__poi__60294.ppt | intended:ok->typed-error | +| ppt__poi__WithLinks.ppt | intended:ok->typed-error | +| ppt__poi__br.com.tvcamboriu.www_pps_Pensar_5b1_5d.ppt | intended:ok->typed-error | +| ppt__poi__bug53192.ppt | intended:ok->typed-error | +| ppt__poi__bug58159_headers-and-footers.ppt | intended:ok->typed-error | +| ppt__poi__bug60345_paperfigures.ppt | intended:ok->typed-error | +| ppt__poi__cf5f6fde99a8b3ea5a4946c258b7abad6f30b0c5.ppt | intended:ok->typed-error | +| ppt__poi__clusterfuzz-testcase-minimized-POIHSLFFuzzer-5018229722382336.ppt | intended:ok->typed-error | +| ppt__poi__clusterfuzz-testcase-minimized-POIHSLFFuzzer-6416153805979648.ppt | intended:ok->typed-error | +| ppt__poi__headers_footers.ppt | intended:ok->typed-error | +| ppt__poi__npe.ppt | intended:ok->typed-error | +| ppt__poi__ppt_with_png.ppt | intended:ok->typed-error | +| ppt__unstr__fake-power-point.ppt | intended:ok->typed-error | +| pptx__lo__activex_spinbutton.pptx | intended:ok->typed-error | +| pptx__lo__bnc870233_2.pptx | intended:ok->typed-error | +| pptx__lo__crop-to-shape.pptx | intended:ok->typed-error | +| pptx__lo__group-rot.pptx | intended:ok->typed-error | +| pptx__lo__shape-blur-effect.pptx | intended:ok->typed-error | +| pptx__lo__smartart-children.pptx | intended:ok->typed-error | +| pptx__lo__smartart-org-chart2.pptx | intended:ok->typed-error | +| pptx__lo__tdf111884.pptx | intended:ok->typed-error | +| pptx__lo__tdf125346.pptx | intended:ok->typed-error | +| pptx__lo__tdf134053_dashdot.pptx | intended:ok->typed-error | +| pptx__lo__tdf151767.pptx | intended:ok->typed-error | +| pptx__poi__54542_cropped_bitmap.pptx | intended:ok->typed-error | +| pptx__poi__EmbeddedVideo.pptx | intended:ok->typed-error | +| pptx__poi__au.asn.aes.www_conferences_2011_presentations_Fri_20Room4Level4_20930_20Maloney.pptx | improved:untyped->typed | +| pptx__poi__bug54570.pptx | intended:ok->typed-error | +| pptx__poi__bug60715.pptx | intended:ok->typed-error | +| pptx__poi__chart-slide-bg.pptx | intended:ok->typed-error | +| pptx__poi__clusterfuzz-testcase-minimized-POIXSLFFuzzer-4838644450394112.pptx | improved:untyped->typed | +| pptx__poi__clusterfuzz-testcase-minimized-POIXSLFFuzzer-5471515212382208.pptx | improved:untyped->typed | +| pptx__poi__clusterfuzz-testcase-minimized-POIXSLFFuzzer-6254434927378432.pptx | improved:untyped->typed | +| pptx__poi__crash-57308ca363f5b71763c489d1b432aff009d4bc4f.pptx | intended:ok->typed-error | +| pptx__poi__layouts.pptx | REGRESSION:recall 1.0->0.5; REGRESSION:vocab-recall 1.0->0.8718 | +| pptx__poi__missing-blip-fill.pptx | REGRESSION:ok->error | +| pptx__poi__sample_pptx_grouping_issues.pptx | REGRESSION:ok->error | +| pptx__poi__smartart-rotated-text.pptx | intended:ok->typed-error | +| pptx__poi__table_test2.pptx | REGRESSION:recall 0.8333->0.1667 | +| pptx__unstr__fake-power-point-malformed.pptx | REGRESSION:vocab-recall 1.0->0.4 | +| pptx__unstr__fake-power-point-table.pptx | REGRESSION:recall 1.0->0.0 | +| pptx__unstr__picture.pptx | intended:ok->typed-error | +| pptx__unstr__test-image-jpg-mime.pptx | intended:ok->typed-error | +| xls__lo__formats.xls | REGRESSION:noise 0.3256->0.4528 | +| xls__lo__pivottable_bool_field_filter.xls | REGRESSION:noise 0.2162->0.3696 | +| xls__lo__pivottable_empty_item.xls | REGRESSION:noise 0.2222->0.3333 | +| xls__lo__pivottable_rowcolpage_field_filter.xls | REGRESSION:noise 0.2105->0.3182 | +| xls__lo__tdf112501.xls | REGRESSION:noise 0.34->0.3774 | +| xls__poi__12561-1.xls | REGRESSION:noise 0.2581->0.4561 | +| xls__poi__45672.xls | REGRESSION:recall 1.0->0.0 | +| xls__poi__BOOK_in_capitals.xls | REGRESSION:noise 0.0652->0.1042 | +| xls__poi__IfFunctionTestCaseData.xls | REGRESSION:noise 0.1283->0.1783 | +| xls__poi__XRefCalcData.xls | REGRESSION:noise 0.1111->0.2 | +| xls__poi__crash-e329fca9087fe21bca4a80c8bc472a661c98d860.xls | REGRESSION:noise 0.1667->0.375 | +| xls__poi__styles-3563.xls | REGRESSION:recall 0.883->0.7872 | +| xlsb__poi__62815.xlsb | REGRESSION:vocab-recall 0.625->0.375 | +| xlsb__poi__date.xlsb | REGRESSION:noise 0.3333->0.6 | +| xlsb__poi__testVarious.xlsb | REGRESSION:noise 0.2653->0.2941 | +| xlsm__poi__57181.xlsm | REGRESSION:recall 0.9735->0.1746; REGRESSION:noise 0.2084->0.7645 | +| xlsm__poi__60512.xlsm | REGRESSION:recall 0.975->0.425 | +| xlsm__poi__61495-test.xlsm | REGRESSION:noise 0.7143->0.7778 | +| xlsm__poi__mv-calculator-final-2-20-2013.xlsm | REGRESSION:recall 1.0->0.4233 | +| xlsx__lo__different-column-width-excel2010.xlsx | improved:untyped->typed | +| xlsx__lo__pivottable_1s_difference.xlsx | REGRESSION:noise 0.3636->0.5882 | +| xlsx__lo__tdf147955.xlsx | REGRESSION:noise 0.1356->0.2609 | +| xlsx__lo__tdf170298.xlsx | REGRESSION:noise 0.2959->0.4413 | +| xlsx__poi__56730.xlsx | REGRESSION:noise 0.3333->0.4286 | +| xlsx__poi__NumberFormatApproxTests.xlsx | REGRESSION:noise 0.6337->0.6944 | +| xlsx__poi__clusterfuzz-testcase-minimized-POIXSSFFuzzer-4828727001088000.xlsx | improved:untyped->typed | +| xlsx__poi__clusterfuzz-testcase-minimized-XLSX2CSVFuzzer-5542865479270400.xlsx | improved:untyped->typed | +| xlsx__unstr__2023-half-year-analyses-by-segment.xlsx | REGRESSION:recall 1.0->0.6389 | diff --git a/apps/sim/scripts/parser-eval/BENCHMARK.md b/apps/sim/scripts/parser-eval/BENCHMARK.md new file mode 100644 index 00000000000..3d90174b671 --- /dev/null +++ b/apps/sim/scripts/parser-eval/BENCHMARK.md @@ -0,0 +1,51 @@ +# Before/after benchmark — 2026-09-09 + +961 real-world files in 18 formats (`bench/build.sh` reproduces the corpus: arXiv, IRS, NIST, Federal Reserve, pdf.js and pdfplumber test PDFs; Apache POI, LibreOffice, python-docx, python-pptx, mammoth and unstructured fixtures; Wikipedia/MDN/WHATWG pages; datasets.org CSVs; GitHub JSON/YAML/Markdown; Project Gutenberg text in UTF-8, Latin-1, Windows-1252 and BOM variants). Baseline = `origin/staging` parsers (`bench-run.ts` → `out-before`), after = this branch (`out-after2`). Full per-file table in `BENCHMARK-raw.md`. + +Metrics against independent extractors (PyMuPDF, python-docx, python-pptx, openpyxl/pandas, pandoc, chardet-decoded text). `recall` = share of reference lines found in our output; `vocab` = share of reference words (hyphens collapsed, digits ignored) present; `noise` = share of our words absent from the reference; `glued` = distinct tokens that are two reference words fused; `lines` = non-blank output lines. + +| ext | n | ok b→a | recall b→a | vocab b→a | noise b→a | glued b→a | lines b→a | ms b→a | +|---|---|---|---|---|---|---|---|---| +| pdf | 190 | 190→190 | 0.991→0.991 | 0.977→0.976 | 0.116→0.102 | 3.16→1.23 | 1→1467 | 96→123 | +| docx | 102 | 81→84 | 0.830→0.830 | 0.936→0.962 | 0.040→0.040 | 0.04→0.05 | 164→44 | 23→15 | +| doc | 51 | 51→41 | 0.522→0.793 | 0.535→0.933 | 0.759→0.041 | 0.04→0.00 | 1→14 | 1.4→0.8 | +| pptx | 72 | 66→44 | 0.979→0.903 | 0.997→0.980 | 0.030→0.025 | 0→0 | 102→143 | 7→5 | +| ppt | 30 | 30→0 | — | — | — | — | — | — | +| odt | 44 | 33→32 | 0.892→0.861 | 0.867→0.970 | 0.182→0.079 | 0.30→0.04 | 6.2→5.7 | 0.6→0.4 | +| odp | 27 | 13→13 | (no reference) | | | | 3.6→3.8 | 0.8→0.5 | +| xlsx | 71 | 68→68 | 0.911→0.955 | 0.981→0.983 | 0.251→0.257 | 0.02→0.02 | 91→91 | 11→10 | +| xls | 43 | 43→43 | 0.806→0.924 | 0.961→0.961 | 0.322→0.349 | 0→0 | 104→104 | 6→6 | +| xlsm | 17 | 16→16 | 0.850→0.575 | 0.926→0.930 | 0.338→0.381 | 0→0 | 61→59 | 11→12 | +| xlsb | 17 | 17→17 | 1.000→1.000 | 0.948→0.933 | 0.233→0.252 | 0→0 | 28→28 | 1.3→1.3 | +| ods | 32 | 32→32 | 0.839→0.927 | 0.976→0.976 | 0.349→0.326 | 0→0 | 26→26 | 1.3→1.3 | +| html | 60 | 60→60 | 0.791→0.795 | 0.971→0.973 | 0.195→0.197 | 16.4→9.85 | 2078→1142 | 42→47 | +| csv | 52 | 52→52 | 0.556→0.556 | 0.658→0.658 | 0.534→0.534 | 0.02→0.02 | 562→562 | 12→12 | +| json | 31 | 30→30 | 0.990→0.990 | 0.982→0.982 | 0.043→0.043 | 0→0 | 19655→19655 | 3.4→3.3 | +| yaml | 42 | 39→39 | 0.829→0.829 | 0.857→0.857 | 0.044→0.044 | 0→0 | 210→210 | 0.3→0.3 | +| md | 38 | 38→38 | 1.000→1.000 | 0.996→0.996 | 0.010→0.010 | 0→0 | 162→162 | 0.3→0.3 | +| txt | 42 | 42→42 | 0.999→1.000 | 0.965→0.973 | 0.034→0.031 | 3.67→0.00 | 8124→8124 | 12→12 | + +## What moved and why + +- **pdf**: 190/190 still parse; line recall and vocabulary unchanged (0.991 / 0.976). Output went from 1 line per document to real lines and paragraphs (mean 1,467), glued tokens fell 3.2 → 1.2 per file, repeated furniture lines are suppressed after their first occurrence, and page numbers are removed. Latency +28% (geometry per item, two-pass furniture detection). +- **doc**: 51 byte-scraped, `degraded` outputs (mean noise 0.76 — ZIP names, XML, placeholders; 2 files returned 3% and 17% of their body) → 41 real extractions via `word-extractor` (noise 0.04, recall 0.52 → 0.79, `degraded` false) plus 10 typed errors: 5 Word 6/95 files (`unsupported_type`), 3 files with no body text (textutil agrees), 2 fuzzer fixtures (`invalid_format`). +- **ppt**: 30 degraded scrapes → 30 typed `unsupported_type`. Every consumer already refused degraded content; this makes the refusal explicit and stops the download. +- **docx / pptx / odt / odp**: tables emit `[Table]` / `| a | b |` rows instead of one cell per line, notes-page placeholders (slide numbers, headers) and ODT comments/tracked deletions are dropped, footnotes are kept. Line recall against python-pptx/python-docx falls where the reference emits one cell per line (`pptx` 0.979 → 0.903) while vocabulary rises (`docx` 0.936 → 0.962, `odt` 0.867 → 0.970). 22 image-only LibreOffice pptx fixtures that used to return `[Content_Types].xml…` as degraded now raise `no_extractable_text`; 2 decks whose only text was a slide number now raise it too. +- **spreadsheets**: cells are display text (`$4,715`, `20%`, `2013-01-12`, `TRUE`) instead of stored values (`4715`, `0.2`, `41286`, `true`). The references are raw values, so "noise" rises by exactly those tokens and `xlsm` line recall drops on two dashboards whose every cell is formatted. Text-only sheets are byte-identical. +- **txt**: Latin-1 / Windows-1252 / BOM inputs decode correctly (glued 3.67 → 0 was accent-stripped words); every other text format is unchanged. +- **html**: nested list items get their own marker and ordered lists are numbered; glued tokens 16.4 → 9.9. + +## Regression gate + +Rules: any ok→error not intended, line recall −0.02, vocabulary −0.02, noise +0.02, any new glued token, junk +0.5/1k, newly degraded. Result: 57 flagged files, every one traced to a reference artifact or an intended change: + +| flag | files | cause | +|---|---|---| +| spreadsheet noise / recall | 27 | display text vs the reference's raw values (`$12,345.00` vs `12345`, ISO dates vs serials, LibreOffice locale text `1,4965`) | +| docx / pptx / odt line recall | 11 | table rows vs one cell per line in the reference; a duplicated `[Table]` line for single-cell tables | +| doc recall / vocabulary | 9 | textutil reference includes field codes and comment text (`Inner Outer`) that a real extractor drops; three files legitimately empty | +| pdf vocabulary | 7 | math papers: before-output had fused glyph runs (`bσg0`, `2x2`) counted as "words"; one form lost its `Page` folio | +| pptx ok→error | 2 | decks whose only text was the slide-number field | +| odt ok→error | 1 | all body text inside a tracked deletion (pandoc also yields nothing) | + +Ground-truth corpus (`REPORT-before.md` → `REPORT-after.md`): pdf paragraph retention 0.06 → 0.97, heading retention 0.00 → 1.00, glued 0.14 → 0.00; docx/pptx/odt table adjacency 0.00 → 1.00; xlsx/xls/xlsb typed-cell presence 0.87 → 1.00 and noise 0.12 → 0.02; robustness 6/14 → 14/14. No format lost presence or order. diff --git a/apps/sim/scripts/parser-eval/bench-compare.py b/apps/sim/scripts/parser-eval/bench-compare.py index 4de7f619433..03f2ef8e112 100644 --- a/apps/sim/scripts/parser-eval/bench-compare.py +++ b/apps/sim/scripts/parser-eval/bench-compare.py @@ -17,7 +17,14 @@ def norm(t): t = unicodedata.normalize('NFKC', t or '').replace('­', '').replace('‑', '-').lower() return re.sub(r'\s+', ' ', t).strip() +MARKER_TOKENS = {'table', 'notes'} def vocab(t): return {w for w in WORD.findall(norm(t)) if len(w) >= 2} +# Reference extractors keep line-end hyphenation and page folios. The parser under test rejoins a +# hyphenated word when the document shows it intact and otherwise keeps the hyphen, so vocabulary is +# compared with every hyphen between letters collapsed on both sides and bare numbers ignored. +def canon_vocab(text): return {w for w in vocab(re.sub(r'(\w)-\s*\n?\s*(\w)', r'\1\2', text)) if not w.isdigit()} +def ref_vocab_of(text): return canon_vocab(text) +def out_vocab_of(text): return {w for w in canon_vocab(text) | vocab(text) if not w.isdigit() and w not in MARKER_TOKENS} def found(needle, hay): n = norm(needle)[:NEEDLE_CAP] if not n: return False @@ -45,7 +52,7 @@ def metrics(rec, refs): m['page_number_lines'] = sum(1 for l in blocks(out) if PAGE_NUM.match(l)) ref_vocab = set(); recalls = []; precisions = [] for name, text in refs.items(): - n_ref = norm(text); ref_vocab |= vocab(text) + n_ref = norm(text); ref_vocab |= ref_vocab_of(text) ref_lines = [l for l in blocks(text) if len(l) >= 25][:LINE_CAP] out_lines = [l for l in blocks(out) if len(l) >= 25][:LINE_CAP] if ref_lines: recalls.append(sum(found(l, n_out) for l in ref_lines) / len(ref_lines)) @@ -53,11 +60,12 @@ def metrics(rec, refs): m['recall'] = r(max(recalls)) if recalls else None m['precision'] = r(max(precisions)) if precisions else None if ref_vocab: - words = [w for w in WORD.findall(n_out) if len(w) >= 2] + words = [w for w in WORD.findall(n_out) if len(w) >= 2 and w not in MARKER_TOKENS] noise = [w for w in words if w not in ref_vocab] m['noise'] = r(len(noise) / max(1, len(words))) m['glued'] = len({w for w in set(noise) if len(w) >= 6 and any(w[:i] in ref_vocab and w[i:] in ref_vocab and i >= 2 and len(w) - i >= 2 for i in range(2, len(w) - 1))}) m['ref_vocab_recall'] = r(len(set(words) & ref_vocab) / max(1, len(ref_vocab))) + m['ref_vocab_recall'] = r(len(out_vocab_of(out) & ref_vocab) / max(1, len(ref_vocab))) return m rows = [] From 4534f51bf3d709012e2dd367d2c4b125e224b3dd Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 9 Sep 2026 19:18:15 -0700 Subject: [PATCH 09/21] style(parsers): apply biome formatting Co-Authored-By: Claude Fable 5.1 --- apps/sim/lib/file-parsers/pdf-lines.test.ts | 4 +- apps/sim/scripts/parser-eval/bench-run.ts | 47 +++++++-- apps/sim/scripts/parser-eval/run-parsers.ts | 109 +++++++++++++++++--- 3 files changed, 136 insertions(+), 24 deletions(-) diff --git a/apps/sim/lib/file-parsers/pdf-lines.test.ts b/apps/sim/lib/file-parsers/pdf-lines.test.ts index c2090dffd0a..aedb838f992 100644 --- a/apps/sim/lib/file-parsers/pdf-lines.test.ts +++ b/apps/sim/lib/file-parsers/pdf-lines.test.ts @@ -127,9 +127,7 @@ describe('joinLines', () => { const lines = paragraph(['we ship high-', 'quality builds'], 627.4) const words = collectWords(lines) - expect(joinLines(lines, { words, headingMarkers: false })).toBe( - 'we ship high-quality builds' - ) + expect(joinLines(lines, { words, headingMarkers: false })).toBe('we ship high-quality builds') expect(joinLines(lines, { headingMarkers: false })).toBe('we ship high-quality builds') }) diff --git a/apps/sim/scripts/parser-eval/bench-run.ts b/apps/sim/scripts/parser-eval/bench-run.ts index bb1a1e6d56d..60a0967d12e 100644 --- a/apps/sim/scripts/parser-eval/bench-run.ts +++ b/apps/sim/scripts/parser-eval/bench-run.ts @@ -26,21 +26,56 @@ for (const ext of readdirSync(filesRoot).sort()) { const bytes = readFileSync(path.join(dir, file)) const started = performance.now() try { - const result = await parseBuffer(bytes, ext, { pdfTextMode: ext === 'pdf' ? 'complete' : undefined }) + const result = await parseBuffer(bytes, ext, { + pdfTextMode: ext === 'pdf' ? 'complete' : undefined, + }) const ms = performance.now() - started let chunkCount = -1 - try { chunkCount = (await chunker.chunk(result.content)).length } catch {} + try { + chunkCount = (await chunker.chunk(result.content)).length + } catch {} const { html, sampledData, messages, headings, links, ...metadata } = result.metadata ?? {} - writeFileSync(path.join(OUT, `${label}.json`), JSON.stringify({ label, ext, file, bytes: bytes.length, ms, ok: true, content: result.content, metadata, chunkCount })) + writeFileSync( + path.join(OUT, `${label}.json`), + JSON.stringify({ + label, + ext, + file, + bytes: bytes.length, + ms, + ok: true, + content: result.content, + metadata, + chunkCount, + }) + ) summary[ext].ok++ - process.stdout.write(`ok ${label} ${result.content.length}ch ${ms.toFixed(0)}ms${metadata.degraded ? ' DEGRADED' : ''}${metadata.truncated ? ' TRUNCATED' : ''}\n`) + process.stdout.write( + `ok ${label} ${result.content.length}ch ${ms.toFixed(0)}ms${metadata.degraded ? ' DEGRADED' : ''}${metadata.truncated ? ' TRUNCATED' : ''}\n` + ) } catch (error) { const ms = performance.now() - started const typed = error instanceof FileParserError - writeFileSync(path.join(OUT, `${label}.json`), JSON.stringify({ label, ext, file, bytes: bytes.length, ms, ok: false, typedError: typed, errorCode: typed ? error.code : undefined, errorName: (error as Error)?.name, error: String((error as Error)?.message ?? error).slice(0, 300) })) + writeFileSync( + path.join(OUT, `${label}.json`), + JSON.stringify({ + label, + ext, + file, + bytes: bytes.length, + ms, + ok: false, + typedError: typed, + errorCode: typed ? error.code : undefined, + errorName: (error as Error)?.name, + error: String((error as Error)?.message ?? error).slice(0, 300), + }) + ) summary[ext].error++ if (typed) summary[ext].typed++ - process.stdout.write(`FAIL ${label} ${typed ? `typed:${error.code}` : `UNTYPED:${(error as Error)?.name}`} ${String((error as Error)?.message).slice(0, 80)}\n`) + process.stdout.write( + `FAIL ${label} ${typed ? `typed:${error.code}` : `UNTYPED:${(error as Error)?.name}`} ${String((error as Error)?.message).slice(0, 80)}\n` + ) } } } diff --git a/apps/sim/scripts/parser-eval/run-parsers.ts b/apps/sim/scripts/parser-eval/run-parsers.ts index 6bedd8da6ca..75816b39881 100644 --- a/apps/sim/scripts/parser-eval/run-parsers.ts +++ b/apps/sim/scripts/parser-eval/run-parsers.ts @@ -13,19 +13,36 @@ const OUT = process.argv[2] const OUTPUTS = path.join(OUT, 'outputs') mkdirSync(OUTPUTS, { recursive: true }) -interface Entry { file: string; dir: string; doc: string; format: string; tier: 'A' | 'B'; absence: string[]; variant?: string; firstSheetOnly?: boolean } +interface Entry { + file: string + dir: string + doc: string + format: string + tier: 'A' | 'B' + absence: string[] + variant?: string + firstSheetOnly?: boolean +} const entries: Entry[] = [] for (const m of ['manifest-a.json', 'manifest-sheets.json']) { const p = path.join(OUT, m) - if (existsSync(p)) for (const e of JSON.parse(readFileSync(p, 'utf8'))) entries.push({ ...e, dir: 'files' }) + if (existsSync(p)) + for (const e of JSON.parse(readFileSync(p, 'utf8'))) entries.push({ ...e, dir: 'files' }) } const realDir = path.join(OUT, 'real') if (existsSync(realDir)) { for (const file of readdirSync(realDir).sort()) { const ext = path.extname(file).slice(1).toLowerCase() if (!ext) continue - entries.push({ file, dir: 'real', doc: file.replace(/\.[^.]+$/, ''), format: ext, tier: 'B', absence: [] }) + entries.push({ + file, + dir: 'real', + doc: file.replace(/\.[^.]+$/, ''), + format: ext, + tier: 'B', + absence: [], + }) } } @@ -38,10 +55,29 @@ const robustness: Array<{ name: string; ext: string; bytes: Buffer }> = [ { name: 'truncated-pdf', ext: 'pdf', bytes: fixture('memo.pdf').subarray(0, 3000) }, { name: 'pdf-bytes-labelled-docx', ext: 'docx', bytes: fixture('memo.pdf') }, { name: 'docx-bytes-labelled-pdf', ext: 'pdf', bytes: fixture('memo.docx') }, - { name: 'png-labelled-doc', ext: 'doc', bytes: Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), Buffer.from(Array.from({ length: 4000 }, (_, i) => (i * 7919) % 256))]) }, - { name: 'random-bytes-labelled-ppt', ext: 'ppt', bytes: Buffer.from(Array.from({ length: 50000 }, (_, i) => (i * 104729 + 17) % 256)) }, - { name: 'latin1-txt', ext: 'txt', bytes: Buffer.from('Caf\xe9 r\xe9sum\xe9 na\xefve \xa3 42', 'latin1') }, - { name: 'utf16-txt', ext: 'txt', bytes: Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from('Hello UTF-16 world', 'utf16le')]) }, + { + name: 'png-labelled-doc', + ext: 'doc', + bytes: Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + Buffer.from(Array.from({ length: 4000 }, (_, i) => (i * 7919) % 256)), + ]), + }, + { + name: 'random-bytes-labelled-ppt', + ext: 'ppt', + bytes: Buffer.from(Array.from({ length: 50000 }, (_, i) => (i * 104729 + 17) % 256)), + }, + { + name: 'latin1-txt', + ext: 'txt', + bytes: Buffer.from('Caf\xe9 r\xe9sum\xe9 na\xefve \xa3 42', 'latin1'), + }, + { + name: 'utf16-txt', + ext: 'txt', + bytes: Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from('Hello UTF-16 world', 'utf16le')]), + }, { name: 'html-labelled-txt', ext: 'txt', bytes: fixture('memo.html') }, { name: 'docx-labelled-xlsx', ext: 'xlsx', bytes: fixture('memo.docx') }, { name: 'csv-labelled-xlsx', ext: 'xlsx', bytes: fixture('sheet-employees.csv') }, @@ -55,27 +91,70 @@ const chunker = new TextChunker({ chunkSize: 1024, chunkOverlap: 200, minCharact async function runOne(label: string, ext: string, bytes: Buffer, meta: Record) { const started = performance.now() try { - const result = await parseBuffer(bytes, ext, { pdfTextMode: ext === 'pdf' ? 'complete' : undefined }) + const result = await parseBuffer(bytes, ext, { + pdfTextMode: ext === 'pdf' ? 'complete' : undefined, + }) const ms = performance.now() - started let chunks: string[] = [] - try { chunks = (await chunker.chunk(result.content)).map((c) => c.text) } catch (e) { chunks = [`CHUNK_ERROR ${String(e)}`] } + try { + chunks = (await chunker.chunk(result.content)).map((c) => c.text) + } catch (e) { + chunks = [`CHUNK_ERROR ${String(e)}`] + } const { html, sampledData, messages, ...metadata } = result.metadata ?? {} - const record = { label, ext, bytes: bytes.length, ms, ok: true, content: result.content, metadata: { ...metadata, messageCount: Array.isArray(messages) ? messages.length : 0 }, chunks, ...meta } + const record = { + label, + ext, + bytes: bytes.length, + ms, + ok: true, + content: result.content, + metadata: { ...metadata, messageCount: Array.isArray(messages) ? messages.length : 0 }, + chunks, + ...meta, + } writeFileSync(path.join(OUTPUTS, `${label}.json`), JSON.stringify(record, null, 1)) - results.push({ ...record, content: undefined, chunks: undefined, contentLength: result.content.length, chunkCount: chunks.length }) - process.stdout.write(`ok ${label} ${result.content.length}ch ${ms.toFixed(0)}ms ${metadata.degraded ? 'DEGRADED' : ''} ${metadata.truncated ? 'TRUNCATED' : ''}\n`) + results.push({ + ...record, + content: undefined, + chunks: undefined, + contentLength: result.content.length, + chunkCount: chunks.length, + }) + process.stdout.write( + `ok ${label} ${result.content.length}ch ${ms.toFixed(0)}ms ${metadata.degraded ? 'DEGRADED' : ''} ${metadata.truncated ? 'TRUNCATED' : ''}\n` + ) } catch (error) { const ms = performance.now() - started const typed = error instanceof FileParserError - const record = { label, ext, bytes: bytes.length, ms, ok: false, typedError: typed, errorCode: typed ? (error as FileParserError).code : undefined, error: String((error as Error)?.message ?? error), ...meta } + const record = { + label, + ext, + bytes: bytes.length, + ms, + ok: false, + typedError: typed, + errorCode: typed ? (error as FileParserError).code : undefined, + error: String((error as Error)?.message ?? error), + ...meta, + } writeFileSync(path.join(OUTPUTS, `${label}.json`), JSON.stringify(record, null, 1)) results.push(record) - process.stdout.write(`FAIL ${label} ${typed ? `typed:${record.errorCode}` : 'UNTYPED'} ${record.error.slice(0, 100)}\n`) + process.stdout.write( + `FAIL ${label} ${typed ? `typed:${record.errorCode}` : 'UNTYPED'} ${record.error.slice(0, 100)}\n` + ) } } for (const e of entries) { - await runOne(e.file, e.format, readFileSync(path.join(OUT, e.dir, e.file)), { doc: e.doc, format: e.format, tier: e.tier, absence: e.absence, variant: e.variant, firstSheetOnly: e.firstSheetOnly }) + await runOne(e.file, e.format, readFileSync(path.join(OUT, e.dir, e.file)), { + doc: e.doc, + format: e.format, + tier: e.tier, + absence: e.absence, + variant: e.variant, + firstSheetOnly: e.firstSheetOnly, + }) } for (const r of robustness) await runOne(`robust__${r.name}`, r.ext, r.bytes, { tier: 'R' }) From 542d925f39eb4822ae20400bc71caf17189c88b9 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 9 Sep 2026 19:32:56 -0700 Subject: [PATCH 10/21] fix(parsers): accept YAML document streams and JSON with comments Kubernetes manifests, Helm output and CI fixtures hold several YAML documents separated by ---; js-yaml's single-document load rejected them outright. A stream now becomes one item per document. JSON files with comments or trailing commas (tsconfig, editor settings) parse leniently after strict parsing fails, with a warning in metadata. Co-Authored-By: Claude Fable 5.1 --- apps/sim/lib/file-parsers/json-parser.test.ts | 20 +++++++ apps/sim/lib/file-parsers/json-parser.ts | 53 +++++++++++++++++++ apps/sim/lib/file-parsers/yaml-parser.test.ts | 22 ++++++++ apps/sim/lib/file-parsers/yaml-parser.ts | 21 ++++++-- 4 files changed, 113 insertions(+), 3 deletions(-) diff --git a/apps/sim/lib/file-parsers/json-parser.test.ts b/apps/sim/lib/file-parsers/json-parser.test.ts index 46274cc0479..cb3ec555997 100644 --- a/apps/sim/lib/file-parsers/json-parser.test.ts +++ b/apps/sim/lib/file-parsers/json-parser.test.ts @@ -61,6 +61,26 @@ describe('JSON parser complexity limits', () => { expect(result.metadata?.warning).toMatch(/Windows-1252/) }) + it('parses JSON with comments and trailing commas leniently with a warning', async () => { + const jsonc = + '{\n // strict later\n "compilerOptions": { "strict": true, /* todo */ "target": "esnext", },\n "url": "http://example.com/a//b",\n}\n' + const result = await parseJSONBuffer(Buffer.from(jsonc)) + const parsed = JSON.parse(result.content) as { + compilerOptions: { target: string } + url: string + } + + expect(parsed.compilerOptions.target).toBe('esnext') + expect(parsed.url).toBe('http://example.com/a//b') + expect(result.metadata?.warning).toContain('comments') + }) + + it('still rejects JSON that is invalid even after comment stripping', async () => { + await expect(parseJSONBuffer(Buffer.from('{ "a": [1, 2 }'))).rejects.toMatchObject({ + code: 'invalid_format', + }) + }) + it('parses BOM-prefixed JSON Lines', async () => { const result = await parseJSONLBuffer( Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from('{"a":1}\n{"a":2}')]) diff --git a/apps/sim/lib/file-parsers/json-parser.ts b/apps/sim/lib/file-parsers/json-parser.ts index 1ed6d7ffd48..562e4cbcd43 100644 --- a/apps/sim/lib/file-parsers/json-parser.ts +++ b/apps/sim/lib/file-parsers/json-parser.ts @@ -174,6 +174,45 @@ function buildJsonResult(jsonData: unknown, decoded: DecodedText): FileParseResu * `toString('utf-8')` read used to reject every BOM-prefixed file from Windows * editors, and a Windows-1252 file silently lost its accented characters. */ +const JSONC_WARNING = 'File is JSON with comments or trailing commas; parsed leniently' + +/** + * Removes `//` and `/* *\/` comments and trailing commas outside string + * literals, so a `tsconfig.json`, `.vscode` settings file or `devcontainer.json` + * — JSON with comments, which editors accept — parses like plain JSON. Runs + * only after strict parsing has failed, so valid JSON never goes through it. + */ +export function stripJsonComments(text: string): string { + let out = '' + let index = 0 + while (index < text.length) { + const char = text[index] + if (char === '"') { + let end = index + 1 + while (end < text.length && text[end] !== '"') { + if (text[end] === '\\') end++ + end++ + } + out += text.slice(index, end + 1) + index = end + 1 + continue + } + if (char === '/' && text[index + 1] === '/') { + const end = text.indexOf('\n', index) + index = end === -1 ? text.length : end + continue + } + if (char === '/' && text[index + 1] === '*') { + const end = text.indexOf('*/', index + 2) + index = end === -1 ? text.length : end + 2 + continue + } + out += char + index++ + } + return out.replace(/,(\s*[}\]])/g, '$1') +} + function parseJsonContent(buffer: Uint8Array): FileParseResult { const decoded = decodeTextBuffer(buffer) try { @@ -183,6 +222,8 @@ function parseJsonContent(buffer: Uint8Array): FileParseResult { if (!(error instanceof SyntaxError)) { throw new FileParserError('runtime_failure', 'JSON processing failed unexpectedly', error) } + const lenient = parseJsonWithComments(decoded) + if (lenient) return lenient throw new FileParserError( 'invalid_format', `Invalid JSON: ${getErrorMessage(error, 'Unknown error')}`, @@ -191,6 +232,18 @@ function parseJsonContent(buffer: Uint8Array): FileParseResult { } } +function parseJsonWithComments(decoded: DecodedText): FileParseResult | undefined { + let value: unknown + try { + value = JSON.parse(stripJsonComments(decoded.text)) + } catch { + return undefined + } + const result = buildJsonResult(value, decoded) + const warning = [decoded.warning, JSONC_WARNING].filter(Boolean).join('; ') + return { ...result, metadata: { ...result.metadata, warning } } +} + /** Parse a JSON file. */ export async function parseJSON(filePath: string): Promise { const fs = await import('fs/promises') diff --git a/apps/sim/lib/file-parsers/yaml-parser.test.ts b/apps/sim/lib/file-parsers/yaml-parser.test.ts index 82963d02ee3..2109933fb15 100644 --- a/apps/sim/lib/file-parsers/yaml-parser.test.ts +++ b/apps/sim/lib/file-parsers/yaml-parser.test.ts @@ -70,6 +70,28 @@ describe('parseYAMLBuffer', () => { await expect(parseYAMLBuffer(Buffer.from(bomb))).rejects.toBeInstanceOf(YamlComplexityError) }) + it('parses a multi-document stream as one document per item', async () => { + const stream = + 'apiVersion: v1\nkind: Service\nmetadata:\n name: web\n---\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: web\n' + const result = await parseYAMLBuffer(Buffer.from(stream)) + const parsed = JSON.parse(result.content) as Array<{ kind: string }> + + expect(parsed.map((document) => document.kind)).toEqual(['Service', 'Deployment']) + expect(result.metadata).toMatchObject({ + type: 'yaml', + isArray: true, + itemCount: 2, + documentCount: 2, + }) + }) + + it('keeps a single document unwrapped and skips empty documents in a stream', async () => { + const result = await parseYAMLBuffer(Buffer.from('---\nname: solo\n---\n')) + + expect(JSON.parse(result.content)).toEqual({ name: 'solo' }) + expect(result.metadata).toMatchObject({ isArray: false, documentCount: 1 }) + }) + it('surfaces malformed YAML as an Invalid YAML error', async () => { await expect(parseYAMLBuffer(Buffer.from('key: "unterminated\n'))).rejects.toThrow( /Invalid YAML/ diff --git a/apps/sim/lib/file-parsers/yaml-parser.ts b/apps/sim/lib/file-parsers/yaml-parser.ts index f92bce14bd9..97b89d166a9 100644 --- a/apps/sim/lib/file-parsers/yaml-parser.ts +++ b/apps/sim/lib/file-parsers/yaml-parser.ts @@ -53,7 +53,11 @@ export function assertYamlWithinLimits(root: unknown): number { * Parse a YAML value into the shared `FileParseResult` shape after validating * that its expanded form stays within safe complexity limits. */ -function buildYamlResult(yamlData: unknown, decoded: DecodedText): FileParseResult { +function buildYamlResult( + yamlData: unknown, + decoded: DecodedText, + documentCount: number +): FileParseResult { if (yamlData === undefined) { throw new FileParserError('empty_input', 'Empty YAML input provided') } @@ -67,6 +71,7 @@ function buildYamlResult(yamlData: unknown, decoded: DecodedText): FileParseResu keys: Array.isArray(yamlData) ? [] : Object.keys((yamlData as Record) || {}), itemCount: Array.isArray(yamlData) ? yamlData.length : undefined, depth, + documentCount, encoding: decoded.encoding, ...(decoded.warning ? { warning: decoded.warning } : {}), } @@ -96,8 +101,18 @@ export async function parseYAMLBuffer(buffer: Buffer): Promise const decoded = decodeTextBuffer(buffer) try { - const yamlData = yaml.load(decoded.text) - return buildYamlResult(yamlData, decoded) + /** + * A YAML file is a stream: Kubernetes manifests, Helm output and CI + * fixtures routinely hold several documents separated by `---`. A single + * document keeps its own shape; a multi-document stream becomes an array of + * documents, which the JSON/YAML chunker then splits one document per item. + */ + const documents = yaml + .loadAll(decoded.text) + .filter((document) => document !== undefined && document !== null) + const yamlData = + documents.length === 1 ? documents[0] : documents.length === 0 ? undefined : documents + return buildYamlResult(yamlData, decoded, documents.length) } catch (error) { if (error instanceof FileParserError) throw error throw new FileParserError( From e0b71c4a3932cc3f3028e8c768a3c81304b00df3 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 9 Sep 2026 19:45:35 -0700 Subject: [PATCH 11/21] fix(parsers): render elapsed, time-only and General cells as Excel does Elapsed formats (`[h]:mm`, `[mm]:ss`) are durations; `cellDates` still parses them into a Date, so the ISO rewrite fabricated `1900-01-01T06:00:00` where Excel shows `30:00`. Their SSF-rendered `w` is now kept. A time-only cell was decided by its epoch year, which breaks in a 1904 workbook where `h:mm:ss` landed on `1904-01-01T12:29:59`; the decision now comes from the format (no `y`/`d`, and every `m` run beside hours or seconds), verified for xlsx, xls, xlsb and ods in both epochs. General numbers round fractions to Excel's 15 significant digits (`=0.1+0.2` reads `0.3`) while integers stay exact. The Files viewer read its workbook without `cellDates`/`cellNF`, which left the normalizer overwriting every rendered `w`; the read now lives in `readXlsxWorkbook` with the display options, and its test builds the fixture through that read path. A tab or line break inside a cell no longer splits the row. Co-Authored-By: Claude Fable 5.1 --- .../file-viewer/xlsx-preview-data.test.ts | 37 ++++-- .../file-viewer/xlsx-preview-data.ts | 27 ++++- .../components/file-viewer/xlsx-preview.tsx | 3 +- .../file-parsers/sheet-display-text.test.ts | 113 +++++++++++++++++- .../lib/file-parsers/sheet-display-text.ts | 69 ++++++++++- apps/sim/lib/file-parsers/xlsx-parser.ts | 22 ++-- 6 files changed, 235 insertions(+), 36 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview-data.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview-data.test.ts index c6a8d79ea4d..17f2cf993fd 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview-data.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview-data.test.ts @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import * as XLSX from 'xlsx' import { readXlsxPreviewData, + readXlsxWorkbook, XLSX_MAX_COLUMNS, XLSX_MAX_ROWS, } from '@/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview-data' @@ -75,19 +76,35 @@ describe('readXlsxPreviewData', () => { }) /** - * The viewer reads the workbook without `cellDates`, so a date arrives as a - * number carrying the file's formatted text; `raw: false` shows that text - * instead of the serial, and a General number keeps its full digits. + * Built through the viewer's own read path rather than by hand-setting `z`, + * so the assertions cover the read options as well as the conversion. */ + function typedWorkbook(): ArrayBuffer { + const sheet = XLSX.utils.aoa_to_sheet([['Issued', 'Rate', 'Card', 'Elapsed']]) + sheet.A2 = { t: 'd', v: new Date(Date.UTC(2026, 2, 4)), z: 'm/d/yyyy' } + sheet.B2 = { t: 'n', v: 0.2, z: '0%' } + sheet.C2 = { t: 'n', v: 4111111111111111 } + sheet.D2 = { t: 'n', v: 1.25, z: '[h]:mm' } + sheet['!ref'] = 'A1:D2' + const book = XLSX.utils.book_new() + XLSX.utils.book_append_sheet(book, sheet, 'Ledger') + const bytes = XLSX.write(book, { type: 'buffer', bookType: 'xlsx' }) as Buffer + return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer + } + it('shows display text rather than stored values', () => { - const sheet = XLSX.utils.aoa_to_sheet([['Issued', 'Rate', 'Card']]) - sheet.A2 = { t: 'n', v: 46085, z: 'yyyy-mm-dd', w: '2026-03-04' } - sheet.B2 = { t: 'n', v: 0.2, z: '0%', w: '20%' } - sheet.C2 = { t: 'n', v: 4111111111111111, z: 'General', w: '4.11111E+15' } - sheet['!ref'] = 'A1:C2' + const workbook = readXlsxWorkbook(XLSX, typedWorkbook()) - const result = readXlsxPreviewData(XLSX, sheet) + const result = readXlsxPreviewData(XLSX, workbook.Sheets.Ledger) + + expect(result.rows).toEqual([['2026-03-04', '20%', '4111111111111111', '30:00']]) + }) + + it('reads the workbook with the display-text options', () => { + const read = vi.fn(XLSX.read) + + readXlsxWorkbook({ read, utils: XLSX.utils }, typedWorkbook()) - expect(result.rows).toEqual([['2026-03-04', '20%', '4111111111111111']]) + expect(read.mock.calls[0][1]).toMatchObject({ type: 'array', cellDates: true, cellNF: true }) }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview-data.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview-data.ts index f16781bfed0..14e8f30841f 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview-data.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview-data.ts @@ -1,13 +1,27 @@ -import type { WorkSheet } from 'xlsx' -import { normalizeSheetDisplayText } from '@/lib/file-parsers/sheet-display-text' +import type { WorkBook, WorkSheet } from 'xlsx' +import { + normalizeSheetDisplayText, + SHEET_DISPLAY_READ_OPTIONS, +} from '@/lib/file-parsers/sheet-display-text' export const XLSX_MAX_ROWS = 1_000 export const XLSX_MAX_COLUMNS = 200 interface XlsxModule { + read: typeof import('xlsx').read utils: Pick } +/** + * Reads a workbook for preview with the options that make its cells carry + * display text: without `cellDates` a date arrives as a bare serial and + * without `cellNF` no cell has a format, so every rendered `w` would be + * overwritten as a General number. + */ +export function readXlsxWorkbook(XLSX: XlsxModule, data: ArrayBuffer): WorkBook { + return XLSX.read(new Uint8Array(data), { type: 'array', ...SHEET_DISPLAY_READ_OPTIONS }) +} + interface XlsxPreviewData { headers: string[] rows: string[][] @@ -19,20 +33,21 @@ export function readXlsxPreviewData(XLSX: XlsxModule, sheet: WorkSheet): XlsxPre const declaredRange = XLSX.utils.decode_range(sheet['!ref'] || 'A1') const lastPreviewRow = Math.min(declaredRange.e.r, declaredRange.s.r + XLSX_MAX_ROWS) const lastPreviewColumn = Math.min(declaredRange.e.c, declaredRange.s.c + XLSX_MAX_COLUMNS - 1) - const window = { + const previewRange = { s: declaredRange.s, e: { r: lastPreviewRow, c: lastPreviewColumn }, } /** * Shown as the text a user sees in Excel: `raw: false` emits each cell's - * formatted text so a date cell reads as a date rather than its serial. + * formatted text, so a sheet read through {@link readXlsxWorkbook} shows a + * date as ISO text and `20%` rather than a serial and `0.2`. */ - normalizeSheetDisplayText(sheet, window, XLSX.utils) + normalizeSheetDisplayText(sheet, previewRange, XLSX.utils) const previewRows = XLSX.utils.sheet_to_json(sheet, { header: 1, raw: false, - range: window, + range: previewRange, }) return { diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview.tsx index 431b5f213bc..11e4316c92e 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview.tsx @@ -10,6 +10,7 @@ import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { useHorizontalWheelScroll } from '@/app/workspace/[workspaceId]/files/components/file-viewer/use-horizontal-wheel-scroll' import { readXlsxPreviewData, + readXlsxWorkbook, XLSX_MAX_COLUMNS, XLSX_MAX_ROWS, } from '@/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview-data' @@ -55,7 +56,7 @@ export const XlsxPreview = memo(function XlsxPreview({ setRenderError(null) await assertOoxmlPreviewWithinLimits(data) const XLSX = await import('xlsx') - const workbook = XLSX.read(new Uint8Array(data), { type: 'array' }) + const workbook = readXlsxWorkbook(XLSX, data) if (!cancelled) { workbookRef.current = workbook setSheetNames(workbook.SheetNames) diff --git a/apps/sim/lib/file-parsers/sheet-display-text.test.ts b/apps/sim/lib/file-parsers/sheet-display-text.test.ts index 6b26aa9841a..c27ea8ed557 100644 --- a/apps/sim/lib/file-parsers/sheet-display-text.test.ts +++ b/apps/sim/lib/file-parsers/sheet-display-text.test.ts @@ -3,7 +3,12 @@ */ import { describe, expect, it } from 'vitest' import * as XLSX from 'xlsx' -import { isoDateText, normalizeSheetDisplayText } from '@/lib/file-parsers/sheet-display-text' +import { + generalNumberText, + isoDateText, + isTimeOnlyFormat, + normalizeSheetDisplayText, +} from '@/lib/file-parsers/sheet-display-text' import { XlsxParser } from '@/lib/file-parsers/xlsx-parser' /** @@ -54,9 +59,8 @@ describe('XlsxParser display text', () => { 'TRUE', '$2,500.00', '4111111111111111', - '0.30000000000000004', - 'left', - 'right', + '0.3', + 'left right', ]) }) @@ -66,6 +70,60 @@ describe('XlsxParser display text', () => { expect(dataRow(result.content).slice(0, 2)).toEqual(['2026-03-04', '2026-03-04T12:00:00']) }) + /** + * A time-of-day serial lands on 1899-12-31 in a 1900 workbook and on + * 1904-01-01 in a 1904 one, so the decision must come from the format, not + * the epoch date. Elapsed formats are durations Excel shows as `30:00`. + */ + describe.each([ + ['xlsx', false], + ['xlsx', true], + ['xls', false], + ['xls', true], + ['xlsb', false], + ['xlsb', true], + ] as const)('time cells in %s (date1904: %s)', (bookType, date1904) => { + function timeWorkbook(): Buffer { + const sheet = XLSX.utils.aoa_to_sheet([['Clock', 'Meridiem', 'Elapsed', 'Minutes', 'Month']]) + sheet.A2 = { t: 'n', v: 0.520821759, z: 'h:mm:ss' } + sheet.B2 = { t: 'n', v: 0.75, z: 'hh:mm AM/PM' } + sheet.C2 = { t: 'n', v: 1.25, z: '[h]:mm' } + sheet.D2 = { t: 'n', v: 0.5, z: '[mm]:ss' } + sheet.E2 = { t: 'n', v: 46085, z: 'mmm' } + sheet['!ref'] = 'A1:E2' + const book = XLSX.utils.book_new() + XLSX.utils.book_append_sheet(book, sheet, 'Times') + if (date1904) book.Workbook = { WBProps: { date1904: true } } + return XLSX.write(book, { type: 'buffer', bookType }) as Buffer + } + + it('renders time-only cells as times and elapsed cells as durations', async () => { + const result = await new XlsxParser().parseBuffer(timeWorkbook()) + + const row = dataRow(result.content) + expect(row.slice(0, 4)).toEqual(['12:29:59', '18:00:00', '30:00', '720:00']) + expect(row[4]).toMatch(/^\d{4}-\d{2}-\d{2}$/) + }) + }) + + /** + * The SheetJS ODS writer stores each serial as the cell text, so an elapsed + * cell reads back its serial; time-only cells still come from the format. + */ + it.each([false, true])('renders time-only cells from ods (date1904: %s)', async (date1904) => { + const sheet = XLSX.utils.aoa_to_sheet([['Clock']]) + sheet.A2 = { t: 'n', v: 0.520821759, z: 'h:mm:ss' } + sheet['!ref'] = 'A1:A2' + const book = XLSX.utils.book_new() + XLSX.utils.book_append_sheet(book, sheet, 'Times') + if (date1904) book.Workbook = { WBProps: { date1904: true } } + const buffer = XLSX.write(book, { type: 'buffer', bookType: 'ods' }) as Buffer + + const result = await new XlsxParser().parseBuffer(buffer) + + expect(dataRow(result.content)).toEqual(['12:29:59']) + }) + it.each(['xls', 'xlsb'] as const)('renders the same display text from %s', async (bookType) => { const result = await new XlsxParser().parseBuffer(typedWorkbook(bookType)) @@ -116,10 +174,43 @@ describe('isoDateText', () => { expect(isoDateText(new Date(Number.NaN))).toBe('') }) - it('renders a duration or time-of-day cell without the 1899 epoch date', () => { + it('renders a formatless date before 1900 as a time of day', () => { expect(isoDateText(new Date(Date.UTC(1899, 11, 30, 0, 30, 0)))).toBe('00:30:00') expect(isoDateText(new Date(Date.UTC(1899, 11, 31, 13, 5, 9)))).toBe('13:05:09') }) + + it('decides time of day from the format whatever the epoch date', () => { + expect(isoDateText(new Date(Date.UTC(1904, 0, 1, 12, 29, 59)), 'h:mm:ss')).toBe('12:29:59') + expect(isoDateText(new Date(Date.UTC(1899, 11, 31, 12, 0, 0)), 'yyyy-mm-dd')).toBe( + '1899-12-31T12:00:00' + ) + }) +}) + +describe('isTimeOnlyFormat', () => { + it.each(['h:mm:ss', 'hh:mm AM/PM', 'h:mm', 'mm:ss', '[$-409]h:mm:ss', 'hh"h"mm'])( + 'treats %s as time only', + (format) => { + expect(isTimeOnlyFormat(format)).toBe(true) + } + ) + + it.each(['m/d/yyyy', 'yyyy-mm-dd hh:mm', 'mmm', 'd-mmm', 'mmmm yyyy', 'General'])( + 'treats %s as a date', + (format) => { + expect(isTimeOnlyFormat(format)).toBe(false) + } + ) +}) + +describe('generalNumberText', () => { + it('keeps integers exact and rounds fractions to 15 significant digits', () => { + expect(generalNumberText(4111111111111111)).toBe('4111111111111111') + expect(generalNumberText(Number.MAX_SAFE_INTEGER)).toBe('9007199254740991') + expect(generalNumberText(0.1 + 0.2)).toBe('0.3') + expect(generalNumberText(1063.8425)).toBe('1063.8425') + expect(generalNumberText(1.22464679914735e-16)).toBe('1.22464679914735e-16') + }) }) describe('normalizeSheetDisplayText', () => { @@ -139,6 +230,18 @@ describe('normalizeSheetDisplayText', () => { expect(sheet.D1.w).toBe('9') }) + it('keeps the rendered duration of an elapsed-time cell', () => { + const sheet = XLSX.utils.aoa_to_sheet([['a']]) + sheet.A1 = { t: 'd', v: new Date(Date.UTC(1900, 0, 1, 6)), z: '[h]:mm', w: '30:00' } + sheet.B1 = { t: 'd', v: new Date(Date.UTC(1899, 11, 31, 12)), z: '[mm]:ss', w: '720:00' } + sheet['!ref'] = 'A1:B1' + + normalizeSheetDisplayText(sheet, XLSX.utils.decode_range('A1:B1'), XLSX.utils) + + expect(sheet.A1.w).toBe('30:00') + expect(sheet.B1.w).toBe('720:00') + }) + it('touches only the window on a dense sheet', () => { const sheet = XLSX.utils.aoa_to_sheet([['a']], { dense: true }) const inside = { t: 'd', v: new Date(Date.UTC(2026, 2, 4)), w: '3/4/2026' } as XLSX.CellObject diff --git a/apps/sim/lib/file-parsers/sheet-display-text.ts b/apps/sim/lib/file-parsers/sheet-display-text.ts index 88683ae24bc..efc3e358dea 100644 --- a/apps/sim/lib/file-parsers/sheet-display-text.ts +++ b/apps/sim/lib/file-parsers/sheet-display-text.ts @@ -18,18 +18,70 @@ interface CellLookup { encode_cell: (address: CellAddress) => string } +/** Excel shows 15 significant digits for a General-formatted number. */ +const GENERAL_SIGNIFICANT_DIGITS = 15 + +const ELAPSED_TOKEN = /\[(h+|m+|s+)\]/i + +/** + * Strips the parts of a number format that carry no date tokens: quoted + * literals, backslash escapes, bracketed colour/condition/elapsed sections and + * the AM/PM markers whose `m` is not a month. + */ +function dateTokensOf(format: string): string { + return format + .replace(/"[^"]*"/g, '') + .replace(/\\./g, '') + .replace(/\[[^\]]*\]/g, '') + .replace(/am\/pm|a\/p/gi, '') + .toLowerCase() +} + +/** + * Whether a date format shows a time of day and nothing else, such as + * `h:mm:ss` or `hh:mm AM/PM`. Any `y` or `d` token is a date, and so is an `m` + * run that is not next to hours or seconds, which is how Excel tells a month + * from minutes. + */ +export function isTimeOnlyFormat(format: string): boolean { + const tokens = dateTokensOf(format) + if (/[yd]/.test(tokens)) return false + if (!/[hms]/.test(tokens)) return false + for (const match of tokens.matchAll(/m+/g)) { + const before = tokens.slice(0, match.index).replace(/[:\s]+$/, '') + const after = tokens.slice(match.index + match[0].length).replace(/^[:\s]+/, '') + const isMinutes = before.endsWith('h') || after.startsWith('s') + if (!isMinutes) return false + } + return true +} + /** * Excel dates carry no zone. Emit the UTC fields SheetJS parsed the serial * into, without a trailing `Z`, and drop the time when it is midnight. + * + * A time-of-day cell is decided from its format, because the epoch date its + * serial lands on differs between 1900 and 1904 workbooks. Without a format, + * a date before 1900 can only be a fraction of a day and is shown as a time. */ -export function isoDateText(date: Date): string { +export function isoDateText(date: Date, format?: string): string { if (Number.isNaN(date.getTime())) return '' const iso = date.toISOString() - /** A serial below 1 is a duration or time of day; Excel shows it without the 1899 epoch date. */ - if (date.getUTCFullYear() < 1900) return iso.slice(11, 19) + const timeOnly = format === undefined ? date.getUTCFullYear() < 1900 : isTimeOnlyFormat(format) + if (timeOnly) return iso.slice(11, 19) return iso.endsWith('T00:00:00.000Z') ? iso.slice(0, 10) : iso.slice(0, 19) } +/** + * Renders a General-formatted number the way Excel displays it: integers in + * full, so 16-digit identifiers keep every digit, and fractions rounded to 15 + * significant digits, so `=0.1+0.2` reads `0.3`. + */ +export function generalNumberText(value: number): string { + if (Number.isInteger(value)) return String(value) + return String(Number(value.toPrecision(GENERAL_SIGNIFICANT_DIGITS))) +} + function isGeneralFormat(format: unknown): boolean { return format === undefined || format === 'General' } @@ -42,7 +94,10 @@ function isGeneralFormat(format: unknown): boolean { * percent, boolean and text cells, but not for dates (locale-shaped, such as * `3/4/2026`) or General-formatted numbers (Excel's 11-character rendering * turns `4111111111111111` into `4.11111E+15`, losing digits of numeric IDs). - * Dates become ISO text and General numbers print their full stored value. + * Dates become ISO text and General numbers print as Excel displays them. + * + * Elapsed-time formats (`[h]:mm`, `[mm]:ss`) are durations, not moments; + * `cellDates` still parses them into a `Date`, so their `w` (`30:00`) is kept. * * A number with no format at all is treated as General too. Every other * number keeps the text the file rendered for it, so a LibreOffice workbook @@ -70,9 +125,11 @@ export function normalizeSheetDisplayText( if (!cell) continue if (cell.t === 'd' && cell.v instanceof Date) { - cell.w = isoDateText(cell.v) + const format = typeof cell.z === 'string' ? cell.z : undefined + if (format !== undefined && ELAPSED_TOKEN.test(format)) continue + cell.w = isoDateText(cell.v, format) } else if (cell.t === 'n' && typeof cell.v === 'number' && isGeneralFormat(cell.z)) { - cell.w = String(cell.v) + cell.w = generalNumberText(cell.v) } } } diff --git a/apps/sim/lib/file-parsers/xlsx-parser.ts b/apps/sim/lib/file-parsers/xlsx-parser.ts index 8622e7739a4..f3eef4566c7 100644 --- a/apps/sim/lib/file-parsers/xlsx-parser.ts +++ b/apps/sim/lib/file-parsers/xlsx-parser.ts @@ -167,7 +167,7 @@ export class XlsxParser implements FileParser { */ const lastPreviewRow = Math.min(range.e.r, range.s.r + CONFIG.MAX_PREVIEW_ROWS - 1) const lastPreviewColumn = Math.min(range.e.c, range.s.c + CONFIG.MAX_PREVIEW_COLUMNS - 1) - const window = { + const previewRange = { s: { r: range.s.r, c: range.s.c }, e: { r: lastPreviewRow, c: lastPreviewColumn }, } @@ -175,17 +175,19 @@ export class XlsxParser implements FileParser { /** * Indexed as the text a user sees, not the value Excel stores: `raw: false` * emits each cell's formatted text, so `$1,250.00` and `20%` survive - * instead of `1250` and `0.2`, and the Google Sheets and Excel connectors - * (which already request display text) agree with a Drive export of the - * same sheet. Dates and General numbers are rewritten first because their - * file-formatted text is locale-shaped or loses digits. + * instead of `1250` and `0.2` — the same currency and percent text the + * Google Sheets and Excel connectors request, so a Drive export of a sheet + * indexes its numbers the way the connectors do. Dates and General numbers + * are rewritten first because their file-formatted text is locale-shaped + * or loses digits; dates therefore index as ISO text here where the + * connectors carry the locale text. */ - normalizeSheetDisplayText(worksheet, window, XLSX.utils) + normalizeSheetDisplayText(worksheet, previewRange, XLSX.utils) const sheetData = XLSX.utils.sheet_to_json(worksheet, { header: 1, blankrows: false, // Skip blank rows raw: false, - range: window, + range: previewRange, }) // Reported from the declared range, as before, so bounding the conversion @@ -308,7 +310,11 @@ export class XlsxParser implements FileParser { return '' } - let cellStr = String(cell) + /** + * A cell is one column: a tab or line break inside it (LibreOffice writes + * rendered text with embedded newlines) would otherwise split the row. + */ + let cellStr = String(cell).replace(/[\t\r\n]+/g, ' ') /** * Samples are previews; canonical content is bounded only by the aggregate From 443a28d9aff2454f5a8b0c4c877eefa91e0da9fe Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 9 Sep 2026 19:49:33 -0700 Subject: [PATCH 12/21] fix(parsers): round float date serials to the nearest second MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A serial such as 45366.572916666664 parses to 13:44:59.999, and slicing the ISO string truncated it to 13:44:59 — one second early for three of twelve probed cells. The instant is rounded to the nearest second before either the date-time or time-only text is formatted, and a value that rounds up to midnight renders as a whole date. Co-Authored-By: Claude Fable 5.1 --- .../file-parsers/sheet-display-text.test.ts | 33 +++++++++++++++++++ .../lib/file-parsers/sheet-display-text.ts | 11 +++++-- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/apps/sim/lib/file-parsers/sheet-display-text.test.ts b/apps/sim/lib/file-parsers/sheet-display-text.test.ts index c27ea8ed557..906381edc40 100644 --- a/apps/sim/lib/file-parsers/sheet-display-text.test.ts +++ b/apps/sim/lib/file-parsers/sheet-display-text.test.ts @@ -97,6 +97,23 @@ describe('XlsxParser display text', () => { return XLSX.write(book, { type: 'buffer', bookType }) as Buffer } + it('rounds float serials to the second instead of truncating', async () => { + const sheet = XLSX.utils.aoa_to_sheet([['When', 'Clock']]) + sheet.A2 = { t: 'n', v: 45366.572916666664, z: 'yyyy-mm-dd h:mm' } + sheet.B2 = { t: 'n', v: 0.6041666666666666, z: 'h:mm' } + sheet['!ref'] = 'A1:B2' + const book = XLSX.utils.book_new() + XLSX.utils.book_append_sheet(book, sheet, 'Times') + if (date1904) book.Workbook = { WBProps: { date1904: true } } + const buffer = XLSX.write(book, { type: 'buffer', bookType }) as Buffer + + const result = await new XlsxParser().parseBuffer(buffer) + + const row = dataRow(result.content) + expect(row[0]).toMatch(/^\d{4}-\d{2}-\d{2}T13:45:00$/) + expect(row[1]).toBe('14:30:00') + }) + it('renders time-only cells as times and elapsed cells as durations', async () => { const result = await new XlsxParser().parseBuffer(timeWorkbook()) @@ -174,6 +191,22 @@ describe('isoDateText', () => { expect(isoDateText(new Date(Number.NaN))).toBe('') }) + it('rounds the sub-second drift of a float serial to the nearest second', () => { + const datetime = XLSX.SSF.parse_date_code(45366.572916666664) + const time = XLSX.SSF.parse_date_code(0.6041666666666666) + const toDate = (d: XLSX.SSF.DateObject) => + new Date(Date.UTC(d.y, d.m - 1, d.d, d.H, d.M, d.S, Math.round(d.u * 1000))) + + expect(isoDateText(new Date(Date.UTC(2024, 2, 15, 13, 44, 59, 999)), 'yyyy-mm-dd h:mm')).toBe( + '2024-03-15T13:45:00' + ) + expect(isoDateText(toDate(datetime), 'yyyy-mm-dd h:mm')).toBe('2024-03-15T13:45:00') + expect(isoDateText(toDate(time), 'h:mm')).toBe('14:30:00') + expect(isoDateText(new Date(Date.UTC(2024, 2, 15, 23, 59, 59, 700)), 'yyyy-mm-dd')).toBe( + '2024-03-16' + ) + }) + it('renders a formatless date before 1900 as a time of day', () => { expect(isoDateText(new Date(Date.UTC(1899, 11, 30, 0, 30, 0)))).toBe('00:30:00') expect(isoDateText(new Date(Date.UTC(1899, 11, 31, 13, 5, 9)))).toBe('13:05:09') diff --git a/apps/sim/lib/file-parsers/sheet-display-text.ts b/apps/sim/lib/file-parsers/sheet-display-text.ts index efc3e358dea..e9a16134381 100644 --- a/apps/sim/lib/file-parsers/sheet-display-text.ts +++ b/apps/sim/lib/file-parsers/sheet-display-text.ts @@ -56,16 +56,23 @@ export function isTimeOnlyFormat(format: string): boolean { return true } +const MS_PER_SECOND = 1000 + /** * Excel dates carry no zone. Emit the UTC fields SheetJS parsed the serial * into, without a trailing `Z`, and drop the time when it is midnight. * + * A float serial such as `45366.572916666664` parses to `13:44:59.999`, so + * the instant is rounded to the nearest second first; a value that rounds up + * to midnight is a whole date. + * * A time-of-day cell is decided from its format, because the epoch date its * serial lands on differs between 1900 and 1904 workbooks. Without a format, * a date before 1900 can only be a fraction of a day and is shown as a time. */ -export function isoDateText(date: Date, format?: string): string { - if (Number.isNaN(date.getTime())) return '' +export function isoDateText(parsed: Date, format?: string): string { + if (Number.isNaN(parsed.getTime())) return '' + const date = new Date(Math.round(parsed.getTime() / MS_PER_SECOND) * MS_PER_SECOND) const iso = date.toISOString() const timeOnly = format === undefined ? date.getUTCFullYear() < 1900 : isTimeOnlyFormat(format) if (timeOnly) return iso.slice(11, 19) From 607731726b8f1c0893c645887504c5b3523f65f4 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 9 Sep 2026 19:52:40 -0700 Subject: [PATCH 13/21] chore(parser-eval): harden the comparer and pin the benchmark corpus Sample reference lines across the whole document instead of its head, add count-aware word-depletion checks so a repeated table header that vanishes is visible, score noise symmetrically, and commit the corpus build scripts with a SHA-256 manifest so the 961-file benchmark can be rebuilt. Adds a README with requirements. Co-Authored-By: Claude Fable 5.1 --- apps/sim/scripts/parser-eval/FINDINGS.md | 2 +- apps/sim/scripts/parser-eval/README.md | 33 + apps/sim/scripts/parser-eval/bench-compare.py | 37 +- apps/sim/scripts/parser-eval/bench-run.ts | 11 +- apps/sim/scripts/parser-eval/bench/NOTES.md | 52 + apps/sim/scripts/parser-eval/bench/arxiv.sh | 12 + apps/sim/scripts/parser-eval/bench/build.sh | 22 + apps/sim/scripts/parser-eval/bench/fetch.sh | 5 + apps/sim/scripts/parser-eval/bench/lib.sh | 26 + .../scripts/parser-eval/bench/manifest.json | 6729 +++++++++++++++++ .../sim/scripts/parser-eval/bench/manifest.py | 36 + .../scripts/parser-eval/bench/plan_office.sh | 50 + .../sim/scripts/parser-eval/bench/plan_web.sh | 86 + .../scripts/parser-eval/bench/postprocess.sh | 16 + .../scripts/parser-eval/bench/reference.py | 226 + apps/sim/scripts/parser-eval/bench/report.py | 18 + apps/sim/scripts/parser-eval/run-parsers.ts | 3 +- 17 files changed, 7351 insertions(+), 13 deletions(-) create mode 100644 apps/sim/scripts/parser-eval/README.md create mode 100644 apps/sim/scripts/parser-eval/bench/NOTES.md create mode 100755 apps/sim/scripts/parser-eval/bench/arxiv.sh create mode 100755 apps/sim/scripts/parser-eval/bench/build.sh create mode 100755 apps/sim/scripts/parser-eval/bench/fetch.sh create mode 100644 apps/sim/scripts/parser-eval/bench/lib.sh create mode 100644 apps/sim/scripts/parser-eval/bench/manifest.json create mode 100755 apps/sim/scripts/parser-eval/bench/manifest.py create mode 100755 apps/sim/scripts/parser-eval/bench/plan_office.sh create mode 100755 apps/sim/scripts/parser-eval/bench/plan_web.sh create mode 100755 apps/sim/scripts/parser-eval/bench/postprocess.sh create mode 100755 apps/sim/scripts/parser-eval/bench/reference.py create mode 100755 apps/sim/scripts/parser-eval/bench/report.py diff --git a/apps/sim/scripts/parser-eval/FINDINGS.md b/apps/sim/scripts/parser-eval/FINDINGS.md index 22ec62d9c6d..a56a17978c0 100644 --- a/apps/sim/scripts/parser-eval/FINDINGS.md +++ b/apps/sim/scripts/parser-eval/FINDINGS.md @@ -1,6 +1,6 @@ # Findings — 2026-09-09 run -Corpus: 107 ground-truth renders (14 docs × docx/odt/pptx/html/md/pdf, 3 two-column PDFs, 4 workbooks × xlsx/xls/xlsb/ods/csv), 30 real-world files, 14 robustness cases. Raw metrics in `REPORT.md`. Reproduce with the scripts in this directory (see `PLAN.md`). +Corpus: 107 ground-truth renders (14 docs × docx/odt/pptx/html/md/pdf, 3 two-column PDFs, 4 workbooks × xlsx/xls/xlsb/ods/csv), 30 real-world files, 14 robustness cases. Raw metrics in `REPORT-before.md` (staging) and `REPORT-after.md` (this branch). Reproduce with the scripts in this directory (see `PLAN.md`). Content recall is 0.99–1.00 in every prose format and PDF text matches PyMuPDF at NED 0.996–1.000 on six real documents. The problems are structure, boilerplate, and typed cells. diff --git a/apps/sim/scripts/parser-eval/README.md b/apps/sim/scripts/parser-eval/README.md new file mode 100644 index 00000000000..86ea7ca5135 --- /dev/null +++ b/apps/sim/scripts/parser-eval/README.md @@ -0,0 +1,33 @@ +# Knowledge base parser evaluation + +Tooling for measuring what `apps/sim/lib/file-parsers` hands to the chunker, and for comparing two checkouts on the same corpus. See `PLAN.md` for the design (modelled on olmOCR-bench unit tests and OmniDocBench scoring), `FINDINGS.md` for the audit that motivated PR #7709, and `BENCHMARK.md` for the before/after results. + +## Requirements + +- `bun` (run every `.ts` script from `apps/sim` so `@/` and the pinned `xlsx` resolve; set `DATABASE_URL=postgres://x:y@localhost:1/none` because the module graph touches `@sim/db` at import time) +- `pandoc` 3.x and a `typst` executable on `PATH` (the PyPI `typst` package is a library; wrap it in a script named `typst` that runs `typst.compile(input, output=output)`) +- A Python 3.12 environment with `pymupdf pdfplumber python-docx python-pptx openpyxl pandas xlrd pyxlsb odfpy chardet rapidfuzz`; point `PARSER_EVAL_PYTHON` at its interpreter +- `gh` (authenticated) and `curl` for the corpus fetchers; macOS `textutil` for `.doc` references + +## Ground-truth corpus (Tier A) + +```sh +python generate-corpus.py +bun scripts/parser-eval/generate-spreadsheets.ts +./fetch-real-world.sh +DATABASE_URL=postgres://x:y@localhost:1/none bun scripts/parser-eval/run-parsers.ts +python reference-extract.py +python score.py # writes report.md and scores.json +``` + +## Large real-world corpus (Tier B) + +`bench/manifest.json` pins 961 files by URL and SHA-256. `bench/build.sh` rebuilds the corpus (`fetch` re-downloads only what is missing and `manifest` verifies hashes; sources that drifted are listed in `bench/NOTES.md`), then writes reference extractions and a per-format report. + +```sh +PARSER_EVAL_PYTHON=/path/to/python bench/build.sh +DATABASE_URL=postgres://x:y@localhost:1/none bun scripts/parser-eval/bench-run.ts /out-