skill
doc-extract
Agent skill for reading PDFs properly: fast local text extraction, plus vision only on what it provably missed - charts, scanned pages, and tables it failed to structure.
Set up
Skills are installed by placing their folder where your agent loads skills.
The download comes from github.com/LPSlv/doc-extract itself, not from our server, so what you get is exactly what its author published.
Install instructions come from the README
README
synced Oct 23, 2026doc-extract
Read documents properly. Fast local text extraction, plus vision only on what text extraction provably missed.
Text extraction handles most documents on its own — and silently drops every chart, pinout diagram, scanned page and merged-header table. Looking at every page instead catches all of it, and costs 3.6× what text extraction does.
doc-extract does neither. It extracts text with pdf-inspector
and anydoc, works out which pages the
extractor actually failed on, and sends only those to your agent's eyes.
Reads PDF, Word, Excel, PowerPoint and images. No API key, no per-page bill, no upload — the vision is the subscription you already pay for.
| across 2,342 PDFs, 20,375 pages | read every page | text only | doc-extract |
|---|---|---|---|
| input tokens | 48.9M | 13.6M | 19.9M |
| vision calls per page | 1.00 | 0 | 0.32 |
| figures it can see | all | none | the ones text missed |
Text alone is cheapest because it is blind — a floor, not an option. Once converted, follow-up questions read the cached text and cost 99% less again.
Quick start
npx skills add LPSlv/doc-extract@doc-extract
Then ask your agent: "read this datasheet and tell me the Q3 variance."
Or point it at a folder of mixed .pdf, .docx, .xlsx and .pptx.
Requires uv. No API key, no Rust toolchain, no
global installs — dependencies resolve on first run.
Try it without installing
git clone https://github.com/LPSlv/doc-extract && cd doc-extract
uv run skills/doc-extract/convert.py example/sample-report.pdf
{"status":"ok","artifact":"~/.cache/doc-extract/d2176c41…","cached":false,
"pending":[{"id":"p001-x38","page":1,"kind":"raster","reason":"standalone_raster",
"path":"…/images/p001-x38.png"}],
"dropped":0,"over_scale_guard":false,"scale_guard":15}
All the text is already extracted — including the budget table, as real Markdown.
Exactly one item needs eyes. The finished output is committed at
example/sample-report.expected.md, so you
can see what you get before installing anything.
What the one vision call buys
Ask "how big is the Q4 gap?" of that page:
| text extraction alone | with doc-extract |
|---|---|
***Figure 1: spend against plan*** Actual spend tracked plan closely through Q2 but diverged in Q3 as equipment procurement slipped. | **[p1] p001-x38** — Line chart, two series. X: Quarter 2026 (Q1–Q4). Y: Spend (k EUR). Planned: Q1 12, Q2 19, Q3 24, Q4 31. Actual: Q1 11, Q2 17, Q3 18, Q4 22. …the Q4 gap is about 9k EUR. |
| no answer — the chart is an embedded bitmap, so extraction returns the caption and stops | ≈ 9k EUR, with a [p1] citation |
The text path is untouched: strip the added block and you get pdf-inspector's output back byte for byte. The visual layer only ever adds.
How it works
file ──► classify ──► extract text ─────► route visuals ──► agent looks ──► answer
by content pdf-inspector/ the interesting describe.py [p12]
10-50 ms anydoc part citations
1. Convert
Everything deterministic happens in one command:
uv run skills/doc-extract/convert.py FILE [MORE ...]
Prints one JSON object per document. Exit code is non-zero if any document
failed, and a bad file never aborts the batch. Re-running returns
cached: true instantly and costs nothing.
2. Describe
For each entry in pending, read the image file and write back what you saw:
uv run skills/doc-extract/describe.py <artifact> <id> "Line chart, two series…"
uv run skills/doc-extract/describe.py <artifact> <id> - # long text from stdin
Safe to re-run — it replaces rather than duplicates, so a vision pass that dies
halfway just resumes. What to write depends on reason:
reason | Write |
|---|---|
standalone_raster, curves, diagonals | The figure: type, axes and units, notable values, all legible text |
no_text_layer | A verbatim transcription — this is the OCR path |
dense_grid, stroke_grid | The table, reproduced as Markdown |
3. Answer
Read doc.md for the whole document, or grep pages/pNNN.md and read only the
pages a question touches. Cite as [p12], or [report.pdf:p12] across documents.
Artifact layout
~/.cache/doc-extract/<sha256>-<engine+schema>/
source.json provenance and status
doc.md authoritative text, plus delimited descriptions
pages/p001.md per-page text, for citation and cheap answering
images/*.png extracted rasters and rendered pages
manifest.json every kept item, and everything dropped with its reason
[!IMPORTANT] Never edit
doc.mdby hand. Everything you add goes throughdescribe.py, which wraps it in strippable delimiters. That is what lets the benchmark strip the additions and prove the skill does not degrade text extraction.
Where descriptions land
By default every description is appended in one block at the end of doc.md,
labelled [p12], [s02] or [Sheet2]. convert.py --inline places each block
at its image's position instead — Office only; a PDF has no anchor, so
--inline changes nothing for one.
| format | --inline anchors to |
|---|---|
| PowerPoint | the picture's own line, when that line is provably the picture's; otherwise the end of the slide |
| Word | the end of the section the picture sits in — Word writes no alt text unless the author typed one, so the markdown usually holds no trace of the picture |
| Excel | the end of the sheet — images and charts come from the package, and the text engine renders neither |
| PDF, image | nothing |
The block is inserted beside the engine's line, never substituted for it.
That is what keeps the byte-identity guarantee enforceable: eval/gate.py runs
both placements over every format and fails on any in-place edit. A substitution
that recorded what it overwrote would reverse perfectly while silently eating a
prose line it mistook for a placeholder — the anchor is the text engine's alt
text, which is ordinary prose, and a slide about a file called image.png
renders the same line a picture does.
What --inline does not promise is that the position is right. Byte-identity
cannot check placement, because an insertion round-trips wherever it lands. An
image's own line is used only when the number of candidate lines in its unit
equals the number of pictures in it; otherwise the description falls back to the
unit boundary. Positions are either provably that picture's or a unit boundary,
never a guess between two lines that look alike.
Extracting the images does not give you the figures
"Extract the images from a PDF" is a well-defined operation that every tool performs correctly, and it does not do what people expect.
A PDF page is a program of drawing commands. Only image XObjects are stored bitmaps — a chart pasted from Excel is a few hundred rectangle-and-line operations with no image object to extract. One 14-page grant document yields 69 "images", of which 7 are distinct and 6 are logos and rules. A sibling document returns zero images while containing pages of drawn content.
So both mechanisms are needed, and both need filtering. Every filter exists because the obvious alternative was tested and failed on a real document:
| Filter | Why it exists |
|---|---|
| Drop images on >50% of pages, <120 px, aspect >8:1 | 69 placements collapse to 1 real graphic |
| Skip pages that already yielded a Markdown table | The extractor beats vision on tables it can parse |
| Require strokes in both orientations | Underlines are horizontal only; counting all strokes fired on bibliography pages |
| Stroke-area floor on curves and diagonals | A vendor logo is bézier artwork too — 4N25's only call was its legal disclaimer |
| Drop repeated vector signatures | ti_ucc27517 carries the same 143-curve logo on six pages |
| Drop a repeating two-edge frame | A box around a prompt listing has strokes in both orientations and reads as a ruled table; 2607.29679v1 burned five calls on them (eval/strokegrid.md) |
| Drop emblems repeated across documents | The GPO seal fired on 227 of 230 US bills — documents with no figures at all |
| Collapse pages with >6 rasters | A 48-tile inpainting figure is one figure; one TI package photo arrives as 12 strips |
| Never cost more than reading everything | On single-page documents the routed set can lose; the guard caps it |
| Twelve signals tried for branding, one kept | Mastheads and logos are separable from charts only by reading them (eval/tds-corpus.md) |
| Ink threshold on the dense-grid branch | Separates a shaded table the extractor missed from decorative banners |
Pages render at the resolution their own smallest text needs rather than a flat dpi, measured from the 5th-percentile font on each page. That alone cut image tokens 54% with identical content capture.
Reading 20,375 pages costs 2.5× less than looking at them
Twelve corpora, chosen to be unlike each other: electronics datasheets, arXiv and
PMC papers, US legislation, and six olmOCR-bench page classes. Each has a URL
manifest under eval/manifests/ and a fetch script.
[!NOTE] Pinning is uneven. The six olmOCR corpora are sha256-pinned per file, so those results reproduce exactly.
arxiv,bills,datasheetsandpmcship URLs without hashes, andpapershas no manifest — re-fetching those may not give you byte-identical inputs. Full tables:docs/benchmarks/RESULTS.md.
Reading every page of these 2,342 PDFs costs 48.9M input tokens. doc-extract reads the same 20,375 pages for 19.9M — 2.5× less — because it looks at one page in three (6,525 vision calls over 20,375 pages) instead of all of them.
Extracting text alone is cheaper still, at 13.6M, and captures no figure, scan or unparsed table whatsoever. It is the floor, not an option.
| corpus | files | pages | cheaper by | vision calls per page |
|---|---|---|---|---|
bills | 230 | 2,736 | 7.0× | 0.00 |
datasheets | 204 | 7,641 | 2.9× | 0.42 |
tds | 23 | 632 | 2.8× | 0.40 |
olmocr_arxiv_math | 522 | 522 | 2.5× | 0.12 |
olmocr_tables | 188 | 188 | 2.4× | 0.45 |
olmocr_headers_footers | 266 | 266 | 2.1× | 0.50 |
arxiv | 238 | 5,336 | 2.0× | 0.24 |
papers | 24 | 704 | 1.9× | 0.36 |
olmocr_scans | 134 | 134 | 1.8× | 1.00 |
olmocr_multi_column | 231 | 231 | 1.5× | 0.58 |
pmc | 220 | 1,923 | 1.4× | 0.48 |
olmocr_long_tiny_text | 62 | 62 | 0.9× | 1.05 |
olmocr_long_tiny_text sits last because it loses: 62 single-page documents where text plus one figure render costs more than the page itself. Single pages have nothing to amortise. It stays in the table.
On scans it recovers what text extraction cannot reach
pdf-inspector extracts zero characters from these pages. The vision pass recovers most of the content:
olmOCR-bench old_scans | pdf-inspector | doc-extract |
|---|---|---|
present — is the text there? | 0.0% | 61.5% |
order — correct reading order? | 0.0% | 59.4% |
| overall | 18.4% | 60.9% |
n=11 documents, 87 tests. The baseline's 18.4% is hollow — it passes absent tests by producing nothing at all.
On native-text PDFs it changes nothing, deliberately
Through opendataloader-bench's official evaluator it scores 0.875 overall / 0.915 NID / 0.814 TEDS — identical to the engine it delegates to, because the text path is byte-identical and every addition is strippable.
[!NOTE] That 0.875 is pdf-inspector's number, not an improvement. Against the benchmark's full engine set it ranks 5th of 15 —
opendataloader-hybrid0.907,nutrient0.885 anddocling0.882 all score higher. Those competitor figures come from opendataloader-bench's published leaderboard, not from a run in this repo; only the 0.875 was measured here. The equality is enforced, not asserted:eval/gate.pyruns the real pipeline and requires the stripped output to equal raw engine output byte for byte.
Only the first question pays
The artifact is cached, so follow-ups read text instead of pixels.
On figures, a description recovers what the page shows, minus one case
Cost is easy to measure and easy to game. The claim that matters is whether the description carries the figure's content, so it is measured on questions built so that only the visual can answer them.
| 23 visual-only questions | correct |
|---|---|
| no document at all | 0/23 |
| text extraction only | 0/23 grounded (9 lucky guesses) |
| doc-extract | 22/23 |
| read the whole page | 23/23 |
One miss. On that page the router emits a single bitmap — the upper of two drawings — and the lower figure is never seen by anything, so a question about it cannot be answered. Every other admitted question was answered and grounded in a quoted line.
Questions are admitted by a gate rather than judgement: reading the page must answer it, a closed-book agent must fail it under two option orderings, and a text-only agent must be unable to ground it in the markdown. 40 candidates in, 23 out. The oracle arm answered 40/40 with every answer grounded, so none was lost to a bad answer key.
[!NOTE] Read the two rows above doc-extract as gate calibration, not competition: admission requires the page-render arm to succeed and the other two to fail. doc-extract is the only arm free to fail, and it did.
The questions were written by two agents that saw only the rendered pages — no descriptions, no repo files. That matters: an earlier round was authored by someone who had already read what the describers produced, and one question had to be withdrawn because its answer had been volunteered to the author before it was written.
Where text extraction is worse than blind: on one paper's Fig. 4 the markdown gives 526 nm for curve a and nothing for curve b, walking a text-only reader into the wrong option, while the description carries curve b's own 530 nm.
[!NOTE] An earlier run of this same set scored 20/23, and the write-up blamed the routing. Two of those three misses were a bug in the measurement harness, which handed the arm one routed item per page when the pipeline routes several — on one page it withheld a whole page render. Corrected above. A separate round on pages the router renders whole scored 11/11, but there the describer and the page-render arm see nearly the same pixels, so that number says little. Method, per-question results and the artifacts that nearly broke both rounds:
eval/figqa.md.
On Office documents the routing barely helps, and the numbers say so
Word, Excel and PowerPoint go through anydoc
for text and through the same furniture filters, citation contract, cache and
describe rubric as PDFs. Measured on 236 government and NASA documents
(govdocs1 and NTRS, pinned in eval/manifests/office.urls.tsv):
Across 236 documents (1,335 units), the filters cut 1,647 embedded images down to 1,316 worth looking at — 0.986 vision calls per unit. 19 spreadsheet charts were recovered as exact tables at no vision cost at all.
| format | files | units | images found | sent to vision | calls per unit |
|---|---|---|---|---|---|
| Word (headings) | 162 | 484 | 415 | 331 | 0.684 |
| Excel (sheets) | 35 | 77 | 26 | 1 | 0.013 |
| PowerPoint (slides) | 39 | 774 | 1,206 | 984 | 1.271 |
Baseline is describe every extracted asset, before furniture filters and dedup — not a page render, which Office documents do not have. Residue, counted not hidden: 1 chart unreadable, 108 assets in formats no agent can view.
The 2.5× does not transfer, and it was never going to. PDF routing wins by avoiding page renders — the expensive baseline it beats is looking at every page optically. An Office document has no render to avoid, so the only lever left is filtering embedded images, and most embedded images in a real deck are content. The filters drop a fifth of the assets but under two percent of the tokens, because what they catch is small by definition: logos, icons, rules.
So a slide deck costs roughly one vision call per slide. A 79-slide NASA deck
is 86 calls, and over_scale_guard will fire and ask you first. That is the
honest shape of it.
What Office documents genuinely gain:
| Spreadsheet charts | Read from the chart definition — exact numbers, zero vision calls. 19 of 20 recovered across 35 workbooks |
| Citations and cache | [s07], [Sheet2], [Budget assumptions]; follow-up questions read text, not pixels |
| Byte-identity | Stripped output equals raw engine output, enforced by eval/gate.py on every format and both placements |
| No per-page bill | The vision is your own agent's, not a metered service |
Excel is the outlier that pays: anydoc's spreadsheet path is pure cell extraction, returning zero assets and no chart for a workbook that plainly contains both, so everything visual there is recovered here or nowhere. It reads Word and PowerPoint charts perfectly well, and those are left alone — extracting them twice was the largest error caught during design.
Not a reimplementation of Firecrawl Parse
Firecrawl's hosted Parse routes on the same open-source pdf-inspector
classifier this skill already calls, and bills 1 credit per page whether or not
a page needed OCR. Two differences matter:
- It routes on text presence alone. A chart on a page full of text passes straight through as text, and the figure is gone. This skill also routes on vector geometry, which is what catches those.
- The vision is yours. Parse's OCR layer is GLM-OCR, MIT-licensed and not Firecrawl's own model, served on their GPUs and billed per page. Here it is the agent's own eyes, inside the seat you already pay for.
Selective routing is what makes that practical rather than merely possible: at 1.00 vision calls per page a subscription-funded agent exhausts its budget on the first document; at the measured 0.32 it does not.
[!IMPORTANT] This is a billing-model comparison, not a quality one. Parse's OCR on a scanned page may well read better than a general agent's — that is unmeasured here and is not claimed either way.
Limitations
[!WARNING]
- Thresholds are fitted, not learned: ~21 numbers tuned on a handful of real documents, then exercised for regression across 2,342 more. That exercise found two failures the small set could not — see below — so treat the constants as calibrated to the corpora here, not as universal.
- Single-page documents can lose. Nothing amortises; see
olmocr_long_tiny_text.- Emblems need a batch. A publisher mark is only distinguishable from a small chart by recurring across documents — twelve signals were measured and eleven rejected (
eval/tds-corpus.md). Convert one government PDF alone and its seal still costs one call.- Branding still costs calls, and zero false positives is not reachable. Journal mastheads, society logos, conference banners and cover art fire as figures. On a 382-item labelled set (
tests/raster-labels.tsv, every raster firing across four journal corpora, classified by eye) that is 49 cases — 12.8% of raster firings, 3.4% of vision calls, 2.8% of raster tokens. Twelve signals have been measured and none removes them safely: branding is separable from a figure only by reading what it says, which is the call being avoided. The two best candidates were flawless on the set they were fitted to and then lost real content on the corpora they had not seen — a top-of-page rule dropped a tile of arXiv 2607.29107 Figure 1, and a QR detector dropped robot-manipulation photographs. Cheap handling, not detection, is the mitigation: a branding image costs a median 140 tokens against 878 for a figure, and the describe rubric dismisses one in a line.- Roughly a quarter of routed pages have nothing on them. 4,106
curvesandwhole_documentfirings were enumerated across 711 documents and 240 labelled by three independent labellers each:curveswastes 25% (95% CI 18–33),whole_document34% (26–43) — reweighted, 28% of firings and 19% of every vision call these corpora make (eval/nofigure.md). Mastheads, references pages, bibliographies, publisher title pages, plain two-column prose. Those calls are wasted, and they are inside the 0.32 calls-per-page figure above, not additional to it. An earlier 30-page sample put this at 37% overall, which was close in aggregate and wrong on the branch split.- The largest remaining block of waste is the
curvesbranch, and nothing reaches it.curvesis 45% of all vision calls; a quarter of that is one thing — a Texas Instruments datasheet page whose only vector content is the 143-curve header logo. Four signals were measured against it and all four rejected (eval/rejected-signals.md). The sharpest measure scores 0.0372 on the TI logo and 0.0372 on a real thermal chart, because TI rasterises its figures — so a prose page and a figure page have identical vector content. That is the sixteenth branding signal rejected here, and the reason has not changed: a vendor logo is separable from a figure only by reading it, which is the call being avoided.stroke_gridis the weakest branch, measured exhaustively. All 170 of its firings across 711 documents were rendered and labelled: 38% ruled tables, 6% plots, 14% other real figures, and 42% nothing at all (eval/strokegrid.md). Its second stated purpose, marker-based plots, accounts for 10 firings in 170 — do not defend the branch on that basis. Roughly half the waste is now filtered (see the next bullet); the vendor-boilerplate half — Würth title blocks, Nexperia legal pages — is untouched, because those are real multi-column grids.- The
boxed_textfilter can drop a continued table. It removes astroke_gridfiring whose page has exactly two vertical stroke positions repeating elsewhere in the document — a frame around a prompt listing, an algorithm or a proof. Validated on 348 arXiv papers fetched afterwards and labelled blind by three independent labellers: 17 drops, 17 correct, 95% over all 188 labelled firings. But a booktabs table continued across pages has two interior rules in the same place on every page and looks identical to a template. All 3 real items lost across those 188 firings are that case. The page's text still survives — routing only decides what gets a vision description — so what is lost is the table's structure, not its content. A refinement targeting it was measured and rejected as dominated (eval/rejected-signals.md).- The
textonly_pagefilter rests on a floor, not a tuned threshold. It drops a page inside a whole-document collapse when the page carries no raster and at most 2 drawing paths — a border and a header rule. Validated blind on two corpora fetched after it was written (250 journal PDFs disjoint fromcorpus/pmcby content hash, plus 348 arXiv papers): 203 drops labelled, 0 real items lost. No failure mode has been observed, which is not the same as none existing. Raising the constant to 6 cuts one more wasted call and starts losing real figures (87% precision), so 2 is deliberately conservative and moving it needs its own holdout.- The labellers behind every precision figure here are one model. "Three independent labellers" means three runs of the same model on the same prompt. They are independent of each other's answers, not of each other's blind spots, and unanimity measures determinism rather than reliability. The Wilson intervals price sampling error only — they cannot price a systematic misread all three share.
- A raster can hide the figure next to it. When a page holds both an embedded bitmap and vector artwork, firing
standalone_rasteremits the bitmap and suppresses the page render — so the vector figure is never seen. Measured once directly: a 519×457 fluorescence inset was routed while the optical-setup schematic around it was skipped entirely.- A table with no rules and no shading is invisible to every branch. If the extractor also drops it, the content is lost silently.
stroke_gridconflates marker-based plots with ruled tables — one label, two causes.- Text quality is bounded by pdf-inspector. If it misreads a page, so does this.
- A page can hold more figures than the router emits. Measured: 1 of 23 visual-only questions lost this way, on a page with two drawings where only the upper was routed. Two more questions escaped only because the text layer happened to carry the answer. Across four corpora 54% of routed rasters sit on pages with more than one raster, so the exposure is broad even though the measured loss is small.
- Figure-description accuracy rests on 23 questions from 16 pages. Enough to separate a working visual layer from an absent one, not enough to separate a good one from a better one. No public benchmark scores this task, so the set is built here and its construction is the thing to audit (
eval/figqa.md).- On Office documents the routing saves very little. 1.9% of vision tokens across the 236-document corpus, against 2.5× on PDFs. There is no page render to avoid, so the only lever is filtering embedded images, and most of those are content. Slide decks cost more than one call per slide. The cache, citations and chart extraction are the reasons to use it there — not the routing.
- Office figure counts come from one corpus. 236 government and NASA documents, skewed to what those bodies publish. A corporate deck template with a logo on every master would behave differently, and inherited furniture is invisible to the text engine entirely, so the ubiquity filter has less to catch than it does on PDFs.
- EMF, WMF and embedded OLE objects cannot be read. The text engine retains them faithfully and there is no rasterizer here, so they are dropped and counted rather than sent to an agent that cannot open them. Pasted Excel charts and clipart in older decks are frequently EMF, so on legacy documents this content is neither extracted nor viewable. Frequency unmeasured.
- Spreadsheet charts can be unreadable. Scatter and bubble series use a different XML shape than the extractor reads, and a chart referencing an external workbook resolves to nothing. An OOXML chart has no rendered image, so unlike a PDF figure there is no vision fallback — the content is simply unavailable. Counted in
droppedasnative_chart_unread.- Word citations need Word headings. A contract written as numbered prose rather than Heading styles yields one whole-document unit, so
pages/greps degrade to reading everything.
Benchmarks
| Document | What it covers |
|---|---|
docs/benchmarks/RESULTS.md | All 12 corpora, 2,342 files, 20,375 pages |
docs/benchmarks/PLAN.md | How each corpus was chosen, sourced and pinned |
eval/tds-corpus.md | 23 datasheets — the original three-way comparison |
eval/datasheets.md | Token model and per-stage wall time |
eval/resolution.md | Adaptive resolution and the controlled capture test |
eval/oldscans.md | olmOCR-bench scanned-document accuracy |
eval/opendataloader.md | Regression gate procedure |
tests/raster-labels.tsv | 382 raster firings labelled by eye — content, branding or portrait |
docs/benchmarks/results/office.json | 236 Office documents — govdocs1 and NASA NTRS, per-file rows |
uv run --with pytest --with firecrawl-anydoc==0.1.6 \
python -m pytest tests/ -q # splice/strip, cache, anydoc invariants
python3 tests/check_sync.py # verbatim block matches harvest.py
uv run eval/gate.py example/ # byte-identity, real pipeline
uv run eval/fetch.py bills && uv run eval/bench.py corpus/bills # any PDF corpus
uv run eval/fetch.py office && uv run eval/office_bench.py corpus/office
uv run eval/report.py # regenerate RESULTS.md
python3 eval/readme_tables.py --write # regenerate this README's tables
[!TIP]
harvest.pyis the single source of truth for every routing decision. The cost tables between thebenchmarks:andoffice:markers are regenerated byeval/readme_tables.py --writeand never hand-edited; the accuracy figures (olmOCR, opendataloader, figure-QA) and the branding percentages are prose, carried by hand from the documents they link to. To see routing on your own file without converting anything:uv run skills/doc-extract/harvest.py FILE.pdf