anydoc
Convert Word (.doc/.docx/.docm), PowerPoint (.ppt/.pps/.pot/.pptx/.pptm/.ppsx/.ppsm), Excel (.xls/.xlsx/.xlsm/.xlsb), OpenDocument (.odt/.ods/.odp), RTF, EPUB, CSV, and PDF documents to clean GitHub-Flavored Markdown locally with the Any Doc CLI (npx -y @firecrawl/anydoc@0.2.4):
Install
npx skills add https://github.com/magnus919/agent-skills/tree/main/anydoc
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install magnus919-agent-skills@llmmart
git clone https://github.com/magnus919/agent-skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole magnus919/agent-skills collection as a plugin from our marketplace. Git is the plain clone.
README
anydoc — office documents to GitHub-Flavored Markdown
Convert Word, PowerPoint, Excel, OpenDocument, RTF, EPUB, CSV, and PDF files into clean, LLM-friendly GitHub-Flavored Markdown. Local conversion stays on your machine; an explicitly authorized hosted OCR mode handles scanned PDFs through Firecrawl Parse when whole-document upload is acceptable.
Why Install This Skill
Office documents are opaque to agents. A .docx or .pptx is a binary zip; a .xls is an OLE container; a PDF can be anything. Reading them directly means parsing formats, handling encodings, and reconstructing structure by hand — exactly the work anydoc automates. This skill gives your agent a single, verified command that converts all 8 format families (21 extensions) into GitHub-Flavored Markdown with headings, GFM tables, slide structure, and footnotes preserved, plus the knowledge of exactly where fidelity is lost (Excel number formats, legacy PowerPoint tables, PDF tables).
The skill wraps the pinned @firecrawl/anydoc v0.2.4 CLI with a small helper script that adds input checks, friendly error hints for the known failure classes (scanned PDFs, encrypted files, malformed archives), batch conversion, dry-run planning, JSON output, and an explicit --allow-hosted-upload acknowledgement for hosted OCR.
What You Get
| Directory / file | What it provides |
|---|---|
SKILL.md + README.md |
The skill index (trigger, command map, verification steps) and this human-facing guide |
scripts/ |
anydoc — an executable Python 3 wrapper with convert (single file or stdin, -o output), batch (many files, per-file status, summary), and info (tool + pinned CLI version), plus global --json and --dry-run |
references/ |
Five focused guides: formats.md (what GFM each format produces, with fidelity caveats), cli-reference.md (verbatim --help, every flag, stdout/stderr conventions), errors.md (exit codes and the exact error messages), workflows.md (recipes: single conversion, batch, vault ingestion, piping, output verification), sources.md (upstream URLs, fixture provenance, verification procedure) |
tests/ |
Unit tests for the wrapper (argparse, pre-validation, hints, dry-run, JSON, batch) — runnable offline |
evals/ |
An eval manifest with fixture-backed cases covering docx→headings, xlsx→tables, pptx→slide structure, csv→table, legacy .doc, ODS preserved values, ODT, and the image-only-PDF OCR failure |
fixtures/ |
24 tiny sample documents (all < 5 MB): valid samples for every family plus error cases (image-only PDF, encrypted ODT, empty DOCX, unsupported extension) — used by the tests, evals, and recipes |
Quick Start
You need Node.js 20+ and npx (no other install — the CLI and its native binary are fetched on first use):
cd anydoc
npx -y @firecrawl/anydoc@0.2.4 fixtures/fixture-handmade-outline.docx
This converts the sample Word document and prints GitHub-Flavored Markdown to stdout (note the #/##/### heading lines). To write to a file instead:
npx -y @firecrawl/anydoc@0.2.4 fixtures/fixture-handmade-outline.docx -o outline.md
Or use the wrapper for the same job:
python3 scripts/anydoc convert fixtures/fixture-handmade-outline.docx -o outline.md
Triggers
Load this skill when the task involves any of these:
- "Convert this Word/Excel/PowerPoint/PDF/EPUB/CSV file to markdown"
- "Extract the headings, tables, or slide content from this document"
- "Summarize this report / spreadsheet / deck"
- "Turn this CSV into a markdown table"
- "Read this document into markdown for a knowledge base or vault"
- "Convert this PDF to markdown" — but only for text-based PDFs; scanned or image-only PDFs fail (anydoc does not OCR)
- "OCR this scanned PDF" — use local OCR by default, or explicitly authorize
--ocr hosted --allow-hosted-uploadwhen sending the whole document to Firecrawl Parse is acceptable
Do not load this skill for document generation or editing ("create a docx report", "build a PDF proposal", "validate this document") — that is the documents skill's job — or for EPUB authoring (epub skill).
Requirements
- Node.js >= 20 and
npx(the CLI is distributed via npm; the native binary ships as a platform-specific npmoptionalDependency, so there is no manual install or compilation). - Network once — the first
npxrun downloads the package and binary; later runs use the npm cache. For permanent or fully offline use, runnpm install -g @firecrawl/anydoconce. - Python 3 (standard library only) if you use the
scripts/anydocwrapper. - Local mode needs no API key or service. Hosted OCR uses Firecrawl Parse and may use
FIRECRAWL_API_KEY; it sends the whole OCR-required PDF and has no page selection.
Skill manifest
Any Doc — office documents to GitHub-Flavored Markdown
The anydoc skill converts office documents, spreadsheets, presentations,
ebooks, CSV, and text-based PDFs into GitHub-Flavored Markdown using the pinned
Any Doc CLI (@firecrawl/anydoc v0.2.4). One shared document model and one GFM
serializer produce the same logical output across formats. Local conversion runs
without a service, API key, or file upload; hosted OCR is a separate explicit route.
Overview
Load this skill when a task needs the contents of a document the agent cannot read directly: a Word report to summarize, a spreadsheet to turn into a table, a slide deck to extract, a CSV to analyze, or an ebook or PDF to quote from.
The skill ships a small Python helper (scripts/anydoc) that wraps the pinned
CLI and adds input pre-validation, friendly error hints, batch conversion, and
--dry-run/--json output. Every recipe in references/workflows.md
also shows the raw npx invocation, so the skill works with or without the
helper.
First-use decision gate
Before invoking anydoc, classify the request:
| If the user needs... | Do this |
|---|---|
| The contents of an existing supported document | Continue to Command Map. |
| Generation, editing, validation, EPUB packaging, HTML scraping, or password decryption | Stop and use the route in When not to use. |
| A format-fidelity or failure decision | Load the matching row in Reference Routing before choosing a command. |
| A conversion result | Choose stdout, -o, or batch; run it; then follow Verification. |
Hard boundary: local anydoc conversion reads existing supported documents to Markdown without uploading them. Hosted OCR is opt-in only: it sends the whole OCR-required PDF to the configured Parse service. AnyDoc does not create, edit, validate, package, decrypt, or scrape documents.
When to use
- Convert a document to markdown — Word, PowerPoint, Excel, OpenDocument, RTF, EPUB, CSV, or text-based PDF.
- Extract structure — headings, GFM tables, slide titles, speaker notes (as blockquotes), and footnotes.
- Feed documents to an LLM — one-pass conversion to clean markdown for summarization, extraction, or retrieval ingestion.
- Batch a folder — convert a directory of mixed office files for a vault or knowledge base.
- Read a document from stdin — pipe bytes into
anydoc -.
Format coverage (summary)
anydoc covers 8 format families / 21 extensions through 12 canonical
parsers. The canonical formats are doc, docx, odt, pdf, ppt, pptx, rtf, epub, xlsx, ods, odp, csv; extension aliases map through them (.docm→docx,
.xls→xlsx, .pptm→pptx, and so on).
| Family | Extensions | Expected GFM output | Decision cue |
|---|---|---|---|
| Word | .doc .docx .docm |
#–###### headings, GFM tables, [^n] footnotes |
Use when content extraction is enough; use documents when rendered layout matters. |
| PowerPoint | .ppt .pps .pot .pptx .pptm .ppsx .ppsm |
slide titles as plain paragraphs, bullet lists, speaker notes as > blockquotes, GFM tables (PPTX/ODP; legacy .ppt flattens tables to text lines) |
Need table fidelity? Prefer PPTX or ODP; legacy .ppt preserves cell text but not table structure. |
| Excel | .xls .xlsx .xlsm .xlsb |
## <sheet name> heading + one GFM table per worksheet; number formats dropped (raw cell values) |
Need displayed percentages, currency, or number formats? Prefer ODS; XLS/XLSX output is raw values. |
| OpenDocument | .odt .ods .odp |
same document/slide shapes as DOCX/PPTX; ODS keeps formatted display values | Prefer ODS when spreadsheet display formatting is part of the meaning. |
| Rich Text Format | .rtf |
same document shape as DOCX/ODT | Use for text extraction, not layout preservation. |
| EPUB | .epub |
# chapter headings, GFM tables, internal anchor links |
Use to read an existing EPUB; use epub to author or package one. |
| CSV | .csv |
one GFM table; label-like first row promoted to header; delimiter sniffing; UTF-16 with BOM | Use for delimited tabular content; inspect delimiter and encoding when output looks wrong. |
.pdf |
headings + inline emphasis, but a lower-fidelity pipeline: tables flatten to text, footnotes and links degrade. Text-based PDFs stay local; scanned/image-only PDFs require explicit hosted OCR or another OCR tool | Use local mode by default; hosted mode uploads the whole PDF and has no page selection. |
See references/formats.md for the full per-format expectations and fidelity caveats, and references/errors.md for the exact failure messages (including the no-OCR error).
Command Map
Commands are shown relative to the repository root. <file> is any document
path (for example anydoc/fixtures/fixture-handmade-outline.docx); - reads
the document from stdin.
| Need | Command | Choose it when |
|---|---|---|
| Convert one file to small markdown on stdout | anydoc/scripts/anydoc convert <file> |
The caller needs immediate content and does not need a saved artifact. |
| Convert one file to a markdown file | anydoc/scripts/anydoc convert <file> -o out.md |
The output is large, must be reviewed later, or should be preserved as an artifact. |
| Convert many files to a directory | anydoc/scripts/anydoc batch <file1> <file2> ... --out-dir out/ |
The request is a bounded batch and per-file output/status is useful. |
| Show the tool and pinned CLI version | anydoc/scripts/anydoc info |
You need to confirm the executable and version before troubleshooting or reporting an environment issue. |
| Raw pinned CLI, one document | npx -y @firecrawl/anydoc@0.2.4 <file> [-o out.md] |
The wrapper is unavailable; preserve the pinned CLI and its documented semantics. |
| Raw pinned CLI, read stdin | cat data.csv \| npx -y @firecrawl/anydoc@0.2.4 - --format csv |
Bytes already arrive on stdin and the format is known; keep the producer pipeline separate from the converter. |
For an OCR-required PDF, first use the local default so the failure is visible:
anydoc/scripts/anydoc convert scan.pdf --ocr reject
If the user explicitly authorizes sending the complete PDF to Firecrawl Parse,
use the wrapper acknowledgement and a trusted FIRECRAWL_API_KEY environment
variable when needed:
anydoc/scripts/anydoc convert scan.pdf --ocr hosted --allow-hosted-upload
The wrapper never places the key on the command line. Hosted OCR has no page selection, and a hosted failure is not permission to silently switch endpoints.
Notes:
scripts/anydocis an executable Python 3 script (shebang#!/usr/bin/env python3);python3 anydoc/scripts/anydoc ...is equivalent when the executable bit is unavailable.- The raw
npx -y @firecrawl/anydoc@0.2.4rows are the ground truth for conversion behavior; the wrapper delegates to exactly that command. - Always pin
@0.2.4for reproducible conversions.-yanswers npx's "Ok to proceed?" prompt non-interactively — the CLI itself never prompts. - Both forms share the same contract: one document per invocation, exit code
0success /1conversion or IO failure /2usage error, diagnostics as exactly oneanydoc: <message>line on stderr, and no prompts.
Hosted OCR is supported by the 0.2.4 library and CLI, but the wrapper requires
both --ocr hosted and --allow-hosted-upload so an upload cannot be selected
implicitly. The hosted route sends the complete PDF to Firecrawl Parse because
page selection is unavailable. Do not place API keys on the command line.
Reference Routing
Load only the row that answers the immediate question; the command examples and verification contract remain in this file.
| When you need to... | Load | It answers |
|---|---|---|
| Choose a format or predict fidelity | references/formats.md | Supported families, output shapes, and caveats such as raw XLSX values, ODS display values, legacy .ppt table flattening, and PDF degradation. |
| Select flags, stdin syntax, output behavior, or version details | references/cli-reference.md | Verbatim help, accepted options, stdin rules, stdout/stderr behavior, pinning, and runtime requirements. |
| Classify a failure or decide whether to retry | references/errors.md | Exit codes, exact error vocabulary, no-OCR/encryption boundaries, and the next route. |
| Choose a single-file, stdin, batch, vault, or large-output recipe | references/workflows.md | End-to-end recipes, safe output handling, per-file failure routing, and resource-limit behavior. |
| Verify a documented upstream or fixture claim | references/sources.md | Source URLs, access dates, fixture provenance, and the verification basis for documented claims. |
| Shape the final evidence report | references/report-examples.md | Complete success, expected-failure, and fidelity-boundary reports to imitate after following Verification. |
When not to use
Use this routing table before reaching for a conversion command:
| User's request | Reach for | Why |
|---|---|---|
| Generate, edit, inspect rendered layout, or validate a PDF/Word/Excel/PowerPoint artifact | documents skill |
anydoc extracts existing document contents to Markdown; it does not author, preserve rendered layout, or validate artifacts. |
| Package or author an EPUB | epub skill |
anydoc reads an existing EPUB to Markdown but never writes or validates an EPUB container. |
| OCR a scanned or image-only PDF | Local OCR tooling, or AnyDoc hosted OCR after explicit authorization | Local mode reports the OCR-required error without uploading; hosted mode sends the whole PDF to Firecrawl Parse. |
| Scrape HTML or other web content | A web-scraping skill | HTML is not a supported anydoc input. |
| Transcribe binary media such as images, video, or audio | A media or transcription tool | Embedded images become alt text; anydoc cannot transcribe media. |
| Preserve pagination, fonts, templates, or rendered layout | A document/layout tool | The only output contract is GitHub-Flavored Markdown. |
| Convert a password-protected file | An unencrypted copy from the document owner | anydoc has no password or decryption option. |
Verification
Report evidence, not just success. For every attempted conversion, return the input, exact command or wrapper path, observed exit code, output destination (stdout or file), structural markers checked, and any documented caveat or next route.
Compact report shape:
Input: <path or stdin source>
Command: <exact wrapper or pinned CLI path>
Exit: <observed code>
Output: <stdout or destination file>
Checks: <markers or fidelity facts observed>
Caveat/route: <documented limitation or next action>
Common stop conditions
| Condition | Do not | Next |
|---|---|---|
| Scanned or image-only PDF / OCR-required error | Retry unchanged or upload implicitly | Use local OCR, or explicitly authorize and run --ocr hosted --allow-hosted-upload; page selection is unavailable. |
| Encrypted or password-protected document | Guess a password or retry unchanged | Request an unencrypted copy or owner-authorized re-export. |
| Unsupported, malformed, or resource-limit error | Guess a parser or claim partial success | Match the exact error in references/errors.md and follow its bounded route. |
| Exit 0 but expected structural markers are absent | Report success from the exit code alone | Inspect the output shape and source fidelity before reporting completion. |
Confirm a conversion before reporting it as done:
- Check the exit code.
0means the CLI produced markdown.1means the document could not be read or converted — read the singleanydoc: <message>stderr line and match it against references/errors.md.2means the command itself was a usage error (bad flag, missing input, invalid--format). - Check the output shape. The markdown must contain the structural markers
your format actually produces:
- Word / ODT / RTF / text-based PDF:
#/##headings. For PDF, do not expect GFM tables or[^1]:footnote definitions — that pipeline flattens them. - Spreadsheets (xlsx/xls/ods) and CSV:
|-delimited GFM tables. xlsx/xls show raw cell values (0.155,1234.5); ODS shows formatted display values (15.5%,$1,234.50). - Presentations (pptx/odp): slide titles as plain paragraphs,
>blockquote speaker notes, GFM tables. Legacy.pptflattens tables to bare text lines. - EPUB:
#chapter headings and internal anchor links.
- Word / ODT / RTF / text-based PDF:
- Write large outputs to a file with
-o.-o out.mdkeeps stdout silent and gives a reviewable file instead of streaming the whole document into context. - Verify tables survived. If the source had tables and the output has no
|rows, consult the format caveats — PDF and legacy.pptflatten tables by design, not by error.
Stop when the conversion exits 0 and the structural markers match the source format. Do not re-run or retry on a documented failure mode (encrypted, malformed, scanned/image-only, unsupported) without changing the input; report the documented message and route as references/errors.md instructs.
Files (agent-skills)
-
evals
-
evals.json 10.7 KB
{ "schema_version": 1, "skill_name": "anydoc", "evals": [ { "id": "docx-headings", "prompt": "Convert this Word document to markdown and extract its headings.", "expected_output": "The conversion exits 0 with empty stderr and emits GitHub-Flavored Markdown whose structure is expressed as ATX headings, including the lines `## Style heading stays a heading`, `### Direct level overrides the style`, and `# Direct outline without a style`.", "assertions": [ "The output contains the heading line `## Style heading stays a heading`", "The output contains the heading line `### Direct level overrides the style`", "The output contains the heading line `# Direct outline without a style`" ], "files": ["fixtures/fixture-handmade-outline.docx"] }, { "id": "xlsx-table-cell-values", "prompt": "Convert this spreadsheet to markdown and show me the cell values as a table.", "expected_output": "The conversion exits 0 and emits a `## Values` heading followed by a GFM table whose cells carry the raw cell values: `Percent | 0.155`, `Currency | 1234.5`, `Thousands | 9876543`, `Date | 2026-03-15` — the number formats are dropped, so the output must NOT contain `15.5%` or `$1,234.50`.", "assertions": [ "The output contains a `## Values` heading", "The output contains the table row `| Percent | 0.155 | fifteen and a half |`", "The output contains the raw value `1234.5` for the Currency row", "The output does not contain the formatted values `15.5%` or `$1,234.50`" ], "files": ["fixtures/sheet.xlsx"] }, { "id": "pptx-slides-structure", "prompt": "Convert this PowerPoint deck to markdown, keeping the slide structure.", "expected_output": "The conversion exits 0 and preserves slide structure: slide titles like `Deck Title Slide` and `Numbers Slide` render as plain paragraphs (not headings), top-level bullets carry an indented nested detail, speaker notes render as blockquotes (`> Speaker note for the intro slide.`), and the slide table renders as a GFM table with the row `| North | 42 |`.", "assertions": [ "The output contains `Deck Title Slide` as a plain paragraph, not a markdown heading", "The output contains the blockquote line `> Speaker note for the intro slide.`", "The output contains the GFM table row `| North | 42 |`" ], "files": ["fixtures/pres.pptx"] }, { "id": "csv-table", "prompt": "Convert this CSV file to a markdown table.", "expected_output": "The conversion exits 0 and renders the file as a single GFM table with the first row promoted to the header: `| Kind | Value | Note |` with a separator row and body rows carrying the cell values `15.5%` and `fifteen and a half`.", "assertions": [ "The output contains the header row `| Kind | Value | Note |`", "The output contains a row carrying the cell values `15.5%` and `fifteen and a half`" ], "files": ["fixtures/fixture-sheet.csv"] }, { "id": "legacy-doc-converts", "prompt": "Convert this legacy .doc file to markdown.", "expected_output": "The conversion exits 0 with empty stderr and emits the shared document serializer's GFM shape: a `# Fixture Document` title, `##` section headings (including `## Lists` and `## Table`), a GFM table with merged cells as empty covered spans, and `[^1]: ...` footnote definitions at the end of the document.", "assertions": [ "The output contains the heading `# Fixture Document`", "The output contains at least three `##` section headings including `## Lists` and `## Table`", "The output contains a `[^1]:` footnote definition block" ], "files": ["fixtures/text.doc"] }, { "id": "image-only-pdf-no-ocr", "prompt": "Convert this scanned PDF to markdown.", "expected_output": "The conversion FAILS by design: exit code 1, empty stdout, and exactly one stderr line `anydoc: unsupported input: PDF has no extractable text (Scanned, 1 pages): OCR is required`. anydoc does not perform OCR; the correct response is to route the file to OCR tooling or the hosted Firecrawl Parse API, not to retry locally.", "assertions": [ "The conversion exits with code 1 and emits no markdown", "Stderr contains the verbatim message `anydoc: unsupported input: PDF has no extractable text (Scanned, 1 pages): OCR is required`", "The response states that OCR is required and routes to OCR tooling or Firecrawl Parse rather than retrying locally" ], "files": ["fixtures/scanned-image-only.pdf"] }, { "id": "ods-preserved-values", "prompt": "Convert this OpenDocument spreadsheet to markdown. I need the formatted display values.", "expected_output": "The conversion exits 0 and emits a `## Values` heading plus a GFM table whose cells carry the FORMATTED display values — `Percent | 15.5%`, `Currency | $1,234.50`, `Thousands | 9,876,543` — explicitly contrasting with the xlsx/xls number-format drop.", "assertions": [ "The output contains the table row `| Percent | 15.5% | fifteen and a half |`", "The output contains the formatted values `15.5%` and `$1,234.50`", "The output does not contain the raw values `0.155` or `1234.5`" ], "files": ["fixtures/sheet.ods"] }, { "id": "odt-converts", "prompt": "Convert this ODT document to markdown and show me the structure.", "expected_output": "The conversion exits 0 with empty stderr and emits the document shape shared with DOCX/DOC/RTF: a `# Fixture Document` title, `##` section headings, a GFM table, and `[^1]:` / `[^2]:` footnote definition lines at the end.", "assertions": [ "The output contains the heading `# Fixture Document`", "The output contains `##` section headings such as `## Lists` and `## Table`", "The output contains the footnote definition `[^1]: Footnote after an astral character.`" ], "files": ["fixtures/text.odt"] }, { "id": "pdf-text-lower-fidelity", "prompt": "Convert this text-based PDF to markdown. Will the table survive the conversion?", "expected_output": "The conversion exits 0 with empty stderr and preserves top-level structure via `# Fixture Document` and `##` section headings (`## Lists`, `## Table`, `## Notes and special text`), but the PDF pipeline is lower-fidelity: the table flattens into the plain paragraph `Wide head End Tall B2 C2 B3 C3` with no GFM table, footnote markers degrade to inline superscript glyphs with no `[^1]:` definition block, and links are not emitted as markdown links.", "assertions": [ "The output contains `# Fixture Document` and the `##` section headings including `## Table`", "The table region flattens to the plain paragraph `Wide head End Tall B2 C2 B3 C3` with no GFM table row", "The output contains no `[^1]:` footnote definition block" ], "files": ["fixtures/fixture-text.pdf"] }, { "id": "legacy-ppt-flattens-tables", "prompt": "Convert this legacy PowerPoint file to markdown and keep the slides' content.", "expected_output": "The conversion exits 0 and preserves slide text: plain-paragraph titles `Deck Title Slide` and `Numbers Slide` and the blockquote speaker note `> Speaker note for the intro slide.`. The Numbers Slide table flattens to bare text lines (`Region`, `Total`, `North`, `42`) rather than a GFM table, unlike PPTX and ODP.", "assertions": [ "The output contains the blockquote line `> Speaker note for the intro slide.`", "The table content renders as bare text lines including `North` and `42`", "The output does not contain a GFM table row `| North | 42 |`" ], "files": ["fixtures/pres.ppt"] }, { "id": "odp-slides-structure", "prompt": "Convert this OpenDocument presentation to markdown, preserving the slide structure.", "expected_output": "The conversion exits 0 and emits the same slide shape as PPTX: `Deck Title Slide` renders as a plain paragraph (not a heading), the speaker note renders as the blockquote `> Speaker note for the intro slide.`, and the slide table renders as a GFM table containing the row `| North | 42 |`.", "assertions": [ "The output contains `Deck Title Slide` as a plain paragraph, not a markdown heading", "The output contains the blockquote line `> Speaker note for the intro slide.`", "The output contains the GFM table row `| North | 42 |`" ], "files": ["fixtures/pres.odp"] }, { "id": "rtf-converts", "prompt": "Convert this RTF document to markdown and extract the structure.", "expected_output": "The conversion exits 0 with empty stderr and emits the shared document serializer's shape: `# Fixture Document`, `##` section headings including `## Lists` and `## Table`, a GFM table with merged cells as empty covered spans, and `[^1]:` footnote definitions at the end.", "assertions": [ "The output contains the heading `# Fixture Document`", "The output contains `##` section headings such as `## Lists` and `## Table`", "The output contains the footnote definition `[^1]: Footnote after an astral character.`" ], "files": ["fixtures/text.rtf"] }, { "id": "epub-converts", "prompt": "Convert this EPUB ebook to markdown, keeping the chapter structure.", "expected_output": "The conversion exits 0 with empty stderr and emits `# Fixture Book`, `# Chapter One` and `# Chapter Two` headings, a GFM table containing `| Bolts | 12 |`, and internal anchor links that resolve to fragments such as `[Chapter Two](#epub-text-ch002-xhtml-chapter-two)`.", "assertions": [ "The output contains the chapter heading `# Chapter One`", "The output contains the GFM table row `| Bolts | 12 |`", "The output contains the internal anchor link `[Chapter Two](#epub-text-ch002-xhtml-chapter-two)`" ], "files": ["fixtures/book.epub"] }, { "id": "csv-quoted-cells", "prompt": "Convert this CSV to a markdown table. Some cells contain commas and newlines.", "expected_output": "The conversion exits 0 and renders the file as a single GFM table with the first row promoted to the header: `| name | desc | qty |` followed by a separator row, with quoted content intact — `| padded | comma, inside | 3 |` keeps the embedded comma and `| plain | multi line | 4 |` keeps the embedded newline.", "assertions": [ "The output contains the promoted header row `| name | desc | qty |`", "The output contains the row `| padded | comma, inside | 3 |` with the embedded comma preserved" ], "files": ["fixtures/fixture-handmade-quoted.csv"] } ] }
-
-
fixtures
-
book.epub 6 KB · in bundle
-
empty--errors.docx 0 B · in bundle
-
encrypted--errors.odt 658 B · in bundle
-
fixture-handmade-numbering.docx 2.8 KB · in bundle
-
fixture-handmade-outline.docx 1.4 KB · in bundle
-
fixture-handmade-quoted.csv 66 B · in bundle
-
fixture-handmade-rich.docx 3.2 KB · in bundle
-
fixture-handmade-semicolon.csv 30 B · in bundle
-
fixture-handmade-tables.docx 1.3 KB · in bundle
-
fixture-handmade-utf16.csv 62 B · in bundle
-
fixture-sheet.csv 210 B · in bundle
-
fixture-text.pdf 87.9 KB · in bundle
-
handmade-merged.xlsx 1.6 KB · in bundle
-
pres.odp 18.6 KB · in bundle
-
pres.ppt 453.5 KB · in bundle
-
pres.pptx 15.4 KB · in bundle
-
scanned-image-only.pdf 2.1 KB · in bundle
-
sheet.ods 15.8 KB · in bundle
-
sheet.xls 7 KB · in bundle
-
sheet.xlsx 6.9 KB · in bundle
-
text.doc 20 KB · in bundle
-
text.odt 24.9 KB · in bundle
-
text.rtf 19.7 KB · in bundle
-
unsupported.xyz 12 B · in bundle
-
-
references
-
cli-reference.md 10.5 KB
# CLI reference: the Any Doc CLI (pinned @firecrawl/anydoc@0.2.4) Everything here was captured by running the pinned CLI on this machine (`npx -y @firecrawl/anydoc@0.2.4`, version 0.2.4, Node v22). The CLI is a 4.7 KB Node wrapper (`bin.anydoc = cli.js`) around a native NAPI binding that ships as an npm `optionalDependency` per platform. ## Verbatim `--help` output ``` anydoc: convert documents to GitHub-Flavored Markdown Usage: anydoc <file> [options] anydoc - [options] < file Converts one document per invocation and writes the Markdown to stdout. Pass - as the input to read the document from stdin. Never prompts; all diagnostics go to stderr. Options: -o, --output <path> Write the Markdown to <path> instead of stdout -f, --format <format> Name the input format instead of detecting it: doc, docx, odt, pdf, ppt, pptx, rtf, epub, xlsx, ods, odp, csv (extension aliases like xls, docm, ppsx resolve to these) --ocr <mode> `reject` (default) or `hosted` for OCR-required PDFs --allow-hosted-upload Acknowledges whole-document upload for hosted OCR -h, --help Print this help and exit -V, --version Print the version and exit The format is detected from the file content; the file extension is the fallback for signature-less formats (CSV). stdin has no extension, so CSV input from stdin needs --format csv. Scanned or image-only PDFs need OCR, which anydoc does not do, and error as unsupported. Exit codes: 0 success 1 the document could not be read or converted 2 usage error: unknown option, missing input, or invalid --format Examples: anydoc report.docx anydoc slides.pptx -o slides.md anydoc - --format csv < data.csv curl -s https://example.com/paper.pdf | anydoc - ``` `anydoc --version` prints exactly `0.2.4` (verified; both `--help` and `--version` exit 0 and write to stdout). ## Invocation forms ```text anydoc <file> [options] # convert a path on disk anydoc - [options] < file # read the document from stdin ``` - `-` as the input reads the document from **stdin**. - The CLI accepts **exactly one document per invocation** — there is no batch mode. Passing a second input exits 2: `anydoc: one document per invocation: unexpected second input '<path>'`. For multiple documents use a shell loop or `scripts/anydoc batch` (see [workflows.md](workflows.md)). ## Flag reference | Token | Behavior (verified) | | --- | --- | | `<file>` | Input path. Format detected from content; extension is the fallback for signature-less formats (CSV). | | `-` | Read the document from stdin. If stdin is a TTY, exits 2 with `anydoc: stdin is a terminal; pipe or redirect a document into anydoc -`. | | `-o <path>`, `--output <path>` | Write the Markdown to `<path>` instead of stdout. **Silently overwrites** an existing file (verified). Writing to a directory fails with exit 1: `anydoc: EISDIR: illegal operation on a directory, open '<path>'`. With `-o`, stdout stays silent. | | `-f <fmt>`, `--format <fmt>` | Force the input format instead of detecting it. Values: `doc, docx, odt, pdf, ppt, pptx, rtf, epub, xlsx, ods, odp, csv`. Extension aliases resolve through the parser mapping (verified: `--format xls`, `--format docm` accepted). Invalid value → exit 2: `anydoc: invalid format 'bogus'; expected one of: doc, docx, odt, pdf, ppt, pptx, rtf, epub, xlsx, ods, odp, csv`. | | `-h`, `--help` | Print help to stdout, exit 0. Works even when the native binding is unavailable. | | `-V`, `--version` | Print the version (`0.2.4`) to stdout, exit 0. Binding-independent like `--help`. | | `--format=x` | Inline `=` value syntax is supported for long options (verified: `--format=rtf` works). | | `--` | End of options: everything after `--` is treated as a positional input (a filename starting with `-`). | | Missing option value | `anydoc: <option> requires a value` → exit 2 (e.g. `anydoc: -o requires a value`). | | Unknown option | `anydoc: unknown option '--bogus' (see anydoc --help)` → exit 2. | | No input | `anydoc: missing input: pass a document path, or - for stdin (see anydoc --help)` → exit 2. | ## stdin / stdout / stderr conventions - **stdin input** via `-`. Because stdin has no file extension, **CSV from stdin requires `--format csv`** (CSV has no content signature). Without it, CSV bytes fail with exit 1: `anydoc: unsupported input: unrecognized file content: name the format explicitly`. Verified success pattern: ```bash printf 'name,role\nAlice,Engineer\n' | npx -y @firecrawl/anydoc@0.2.4 - --format csv ``` - **Markdown goes to stdout only.** With `-o`, stdout stays silent. - **All diagnostics go to stderr** as exactly one `anydoc: <message>` line per failure. Nothing is ever printed to stdout on failure. - **The CLI never prompts** — no confirmation, no interaction. (`-y` on the `npx` invocation exists only to answer *npx's* package-install prompt.) - **EPIPE is handled**: if the downstream pipe closes early (`anydoc big.xlsx | head -n 1`), the CLI exits **0** with no stderr noise (verified). Piping into `head` is not treated as a conversion failure. - **No environment variables** — the CLI uses only argv, stdin, and the filesystem (verified by reading `cli.js`). ## Running it: npx invocation ```bash npx -y @firecrawl/anydoc@0.2.4 report.docx # markdown to stdout npx -y @firecrawl/anydoc@0.2.4 slides.pptx -o slides.md # to a file npx -y @firecrawl/anydoc@0.2.4 - --format csv < data.csv # stdin (CSV needs --format) curl -s https://example.com/paper.pdf | npx -y @firecrawl/anydoc@0.2.4 - # URL → stdin ``` ### Version pinning Always pin the version: `npx -y @firecrawl/anydoc@0.2.4`. An unpinned invocation (`npx -y @firecrawl/anydoc` with no `@version` suffix) floats to the latest published tag, so conversions are not reproducible across time. All behavior in this skill is documented against **0.2.4**. The `-y` flag answers npx's "Ok to proceed?" install prompt non-interactively — omitting it means npx asks for confirmation before installing a cold-cache package. ### First run and offline behavior - The **first** `npx` invocation downloads the npm package plus the native platform binary (network required once). Verified with a fresh empty npm cache: `env npm_config_cache=$(mktemp -d) npx -y @firecrawl/anydoc@0.2.4 --version` prints `0.2.4` and exits 0. - Later runs reuse the npm cache; measured warm startup is ~0.33–0.55 s per invocation (see [workflows.md](workflows.md)). - **Cold-cache offline**: if the package is not cached and there is no network, npx itself fails with a clear fetch error before anydoc runs. The conversion itself is fully local — only package retrieval needs network. - **Permanent / offline-capable alternative**: `npm install -g @firecrawl/anydoc` once, then invoke `anydoc` directly (still pinning is up to you). This satisfies the skill's "no service dependency" claim: there is no server, no API key, and no upload — the only network use is downloading the tool. ## Distribution and system requirements - **Node.js >= 20** (package `engines`). Verified under Node v22. - The native binary ships via npm **`optionalDependencies`** — one small package per platform (`darwin-x64`, `darwin-arm64`, `linux-x64-gnu`, `linux-arm64-gnu`, `linux-x64-musl`, `linux-arm64-musl`, `win32-x64-msvc`), with **no postinstall script and no compilation**. - The npm package `@firecrawl/anydoc` 0.2.4 is ~48 KB unpacked (the binding package is a few MB per platform); published 2026-08-05T18:29:40Z. - The Rust crate `anydoc` (crates.io) and Python wheels `firecrawl-anydoc` (PyPI, imports as `anydoc`, Python >= 3.10) ship in the same release train. There is no standalone Rust CLI binary (`cargo install anydoc` is an open feature request) — the CLI exists only through the npm package. ## The wrapper: `scripts/anydoc` The skill ships a Python 3 standard-library wrapper at `scripts/anydoc` that delegates to the pinned CLI. It adds value beyond a thin npx alias: - **`convert <file|-> [-o out.md] [-f <format>] [--json] [--dry-run]`** — pre-validates the input path (missing file, directory input) and the `-o` path (existing directory) before invoking the CLI, validates `-f` against the 21 accepted format names (the 12 canonical parsers plus the 9 aliases, exit 2 on an invalid name), maps known failure classes to friendly hints (no-OCR, encrypted, malformed, unsupported), and forwards the CLI's exit code. Stdin input via `-` is passed straight through. A dash-leading filename is supported through the CLI's `--` marker with the options first: `anydoc convert -f csv -- -weird` (the wrapper emits `-o`/`-f` before `--`, since npx forwards `--` to the CLI and anything after it reads as an extra input). Absolute paths never need this. - **`batch <inputs...> [--out-dir DIR] [--json] [--dry-run]`** — converts many documents one at a time, prints per-file status, continues past failures, and exits 1 when any input failed. Output naming is deterministic: each input becomes `<stem>.md` under `--out-dir`, which is created when missing and defaults to the current working directory. Duplicate inputs convert per occurrence (a later conversion overwrites the earlier output); same-basename inputs from different directories collide on the same `<stem>.md` and the last one wins. - **`info [--version]`** — reports the tool name and the pinned CLI version (`anydoc 0.2.4 (wraps @firecrawl/anydoc@0.2.4)`) without invoking the converter; `info --version` prints exactly `0.2.4`. - Global **`--json`** (exactly one JSON document on stdout; diagnostics stay on stderr) and **`--dry-run`** (print what would run — the exact `npx` command line and output paths — and execute nothing: no CLI spawn, no output files, no directory creation). With `--json`, `convert` embeds the converted markdown in the JSON document when `-o` is not given. - Checks for Node >= 20 (missing `node`, or a version below 20, exits 1 with a clear message naming Node.js and the required version) and for `npx` (missing `npx` exits 1 naming `npx` and the pinned package `@firecrawl/anydoc@0.2.4`); always invokes npx with `-y`; never prompts; exit codes 0/1/2 mirror the CLI. Run it as `anydoc/scripts/anydoc <subcommand> ...` from the repository root, `scripts/anydoc <subcommand> ...` from the skill directory, or `python3 anydoc/scripts/anydoc <subcommand> ...` anywhere (the executable bit and `#!/usr/bin/env python3` shebang let it run directly). See [workflows.md](workflows.md) for recipes and [errors.md](errors.md) for the error vocabulary. -
errors.md 10.3 KB
# Errors, exit codes, and troubleshooting Every message below is a **verbatim real stderr capture** from the pinned CLI (`@firecrawl/anydoc@0.2.4`) run against the committed fixtures in `fixtures/` (and, for resource limits, generated oversized archives). The CLI prints exactly one line to stderr, prefixed `anydoc: `, and never prompts. ## Exit codes | Code | Meaning | Triggers | | --- | --- | --- | | `0` | Success | Normal conversion; `--help`/`--version`; also on **EPIPE** when the downstream pipe closes early (`anydoc big.xlsx \| head`). | | `1` | The document could not be read or converted | Any conversion or IO failure below: missing file, unsupported input, scanned/image-only PDF, malformed archive, encrypted document, resource limit, `-o` pointing at a directory. | | `2` | Usage error | Unknown option, missing input, invalid `--format`, more than one input, an option missing its value, stdin is a terminal. | ## Conversion / IO failures (exit code 1) ### io — the file could not be read ``` anydoc: io error: No such file or directory (os error 2) ``` This is the missing-file case (`to_markdown` path only; stdin and byte APIs have no io error). ### unsupported — unknown format or unconvertible content Unknown content **and** unknown extension (the extension is echoed as given): ``` anydoc: unsupported input: unrecognized file content and extension: unsupported.xyz ``` Verified against `fixtures/unsupported.xyz` (run from the fixture directory, the tail is `unsupported.xyz`; when you pass a longer path, that path is echoed). Recognized format but unconvertible content — a **scanned or image-only PDF** (the CLI detects the page count and that it looks scanned): ``` anydoc: unsupported input: PDF has no extractable text (Scanned, 1 pages): OCR is required ``` Verified against `fixtures/scanned-image-only.pdf`: exit 1, empty stdout. ### unsupported — stdin without a format CSV has no content signature and stdin has no extension, so CSV piped to `-` without `--format csv` fails: ``` anydoc: unsupported input: unrecognized file content: name the format explicitly ``` Fix: add `--format csv` (e.g. `cat data.csv | npx -y @firecrawl/anydoc@0.2.4 - --format csv`). ### malformed — structurally unusable archive An empty (0-byte) `.docx` and a truncated `.docx` both produce: ``` anydoc: malformed document: not a readable zip archive: invalid Zip archive: Could not find EOCD ``` Verified against `fixtures/empty--errors.docx`. Any other structurally broken package surfaces the same class. ### encrypted — password-protected document ``` anydoc: document is encrypted ``` Verified against `fixtures/encrypted--errors.odt`. There is **no password or decryption option** anywhere in the CLI or library — the only fix is an unencrypted copy of the file. ### resourceLimit — fixed safety limits (decompression / nesting / node count) Zip-bomb style DOCX (giant `word/document.xml`): ``` anydoc: resource limit exceeded (max_entry_bytes): word/document.xml declares 201326759 decompressed bytes ``` Image-bomb style DOCX (giant `word/media/image1.png`): ``` anydoc: resource limit exceeded (max_entry_bytes): word/media/image1.png declares 201326592 decompressed bytes ``` The **character-exact prefix** is: ``` anydoc: resource limit exceeded (max_entry_bytes): ``` with a tail naming the offending entry and the declared decompressed size — the tail varies by entry, so match on the prefix. Verified also with a generated 250 MB-entry zip (tail: `word/document.xml declares 250000000 decompressed bytes`). anydoc rejects zip/image bombs via `max_entry_bytes`; conversion is **not streaming**, and the whole entry is checked before use. ### output-is-directory (EISDIR) `-o` pointing at an existing directory fails with exit 1: ``` anydoc: EISDIR: illegal operation on a directory, open '<path>' ``` Verified: `npx -y @firecrawl/anydoc@0.2.4 report.rtf -o /tmp` prints `anydoc: EISDIR: illegal operation on a directory, open '/tmp'` and exits 1. Fix: pass a file path (or a path in a directory that exists); anydoc **does not create directories**. ## Usage errors (exit code 2) All verified verbatim: ``` anydoc: missing input: pass a document path, or - for stdin (see anydoc --help) anydoc: unknown option '--bogus' (see anydoc --help) anydoc: invalid format 'bogus'; expected one of: doc, docx, odt, pdf, ppt, pptx, rtf, epub, xlsx, ods, odp, csv anydoc: one document per invocation: unexpected second input '<path>' anydoc: stdin is a terminal; pipe or redirect a document into anydoc - anydoc: -o requires a value (pattern: `<option> requires a value`) ``` Notes: - `unknown option '--bogus'` echoes the offending token; `one document per invocation` echoes the second input path as given; the `-o requires a value` pattern applies to `-f` too (`-f requires a value`). - Usage errors never touch the filesystem and produce no markdown. ## The no-OCR caveat (read before converting PDFs) - anydoc converts **text-based PDFs locally** via `pdf-inspector`; hosted OCR is a separate opt-in path for OCR-required PDFs. - **Scanned / image-only PDFs fail as `unsupported`** with the exact message above (`... OCR is required`). The library's stance: "Scanned and image-only PDFs need OCR, which anydoc does not do." - Route, don't retry unchanged. Report the exact error and choose local OCR or, after explicit authorization, `--ocr hosted --allow-hosted-upload`. Hosted mode sends the whole document to Firecrawl Parse and has no page selection. Do not fabricate the document's content or silently upload it. ## Troubleshooting recipes | Symptom | Message to match | Fix | | --- | --- | --- | | File not found | `io error: No such file or directory` | Check the path; anydoc does not glob or resolve relative to the skill. | | Unknown file type | `unsupported input: unrecognized file content and extension: <path>` | Confirm the extension is one of the 21 supported; or force it with `--format <name>`. | | Scanned PDF | `PDF has no extractable text (Scanned, N pages): OCR is required` | Route to OCR tooling / Firecrawl Parse. Never retry locally. | | Encrypted file | `document is encrypted` | Ask for an unencrypted copy; there is no password option. | | Empty/truncated archive | `malformed document: not a readable zip archive` | Re-download or re-export the file. Note: some damaged files still convert partially (see below). | | Huge or malicious archive | `resource limit exceeded (max_entry_bytes):` | anydoc rejected the entry by design; do not bypass. For genuinely large real documents, use `-o out.md`. | | `-o` "failed" | `EISDIR: illegal operation on a directory, open '<path>'` | Point `-o` at a file path inside an existing directory. | | CSV from stdin failed | `unsupported input: unrecognized file content: name the format explicitly` | Add `--format csv`. | | Command rejected | any `anydoc: ...` exit-2 message | Re-read the usage: one input only, valid `--format`, options before/after correctly placed. | ## Graceful recovery — exit 0 is not byte-perfect fidelity The library skips broken parts rather than failing whenever some meaningful Markdown is still producible. The upstream test suite ships `*--recovers.*` and `*--skips.*` fixtures (e.g. `mismatched--recovers.docx`, `unbalanced--recovers.rtf`, `corrupt-styles--skips.docx`): structurally damaged documents often convert with exit 0, dropping only the broken part. So a conversion that exits 0 can still be incomplete — run the output-verification steps in [workflows.md](workflows.md) and [SKILL.md](../SKILL.md) when fidelity matters. ## Wrapper (`scripts/anydoc`) error behavior The wrapper mirrors the CLI's contract and adds pre-validation and hints: - **Pre-validation errors (exit 1)**: a missing input path, a directory-as- input, or an `-o` path that is an existing directory is caught before the CLI runs — stderr names the path and the problem (e.g. `anydoc: input file not found: <path>`, `anydoc: input path is a directory, not a file: <path>`, `anydoc: output path is a directory: <path> (pass a file path; -o does not create directories)`), with no traceback and no prompt. - **Usage errors (exit 2)**: an unknown option, a missing input, or an invalid `-f` value exits 2 with a usage message on stderr before any CLI invocation. The accepted `-f` names are the 12 canonical formats plus the 9 aliases (`anydoc: invalid format 'bogus'; expected one of: ...`). - **Friendly hints (exit 1)**: known failure classes get a hint plus a next step on stderr — no-OCR (`scanned-image-only.pdf` → "anydoc does not perform OCR. Route the file to OCR tooling or the hosted Firecrawl Parse API; do not retry it locally."), encrypted ("the document is encrypted or password-protected — supply an unencrypted copy"), malformed ("the document is malformed or corrupt (not a readable zip archive) — re-export or re-download the file and retry"), unsupported ("unsupported or unrecognized file type — check that the extension is one of the supported formats, or force it with `-f <format>`"). The raw CLI error line is always printed first, verbatim. - **Node check (exit 1)**: if `node` is missing or older than v20, stderr states that Node.js >= 20 is required (`anydoc: Node.js >= 20 is required but `node` was not found on PATH ...` / `anydoc: Node.js version v18.20.0 is too old; anydoc requires Node.js >= 20 ...`), before any CLI invocation. - **npx missing (exit 1)**: stderr names `npx` and the pinned package (`@firecrawl/anydoc@0.2.4`): `anydoc: `npx` was not found on PATH — conversion runs via `npx -y @firecrawl/anydoc@0.2.4`. Install Node.js >= 20 (which ships npx), or install the CLI permanently with `npm install -g @firecrawl/anydoc`.`. - **Batch exit policy**: `batch` exits 1 when any input failed; per-file status lines (`ok <file> -> <out.md>` / `FAIL <file>`) and a summary (`summary: N total, S succeeded, F failed`) print to stdout, failure detail to stderr. - **`--json`**: exactly one JSON document on stdout in success and failure (result, exit code, output path, optional embedded markdown for `convert`; per-file status plus summary for `batch`); human diagnostics stay on stderr. - **`--dry-run`**: prints the plan (the exact `npx` command line and output paths) and executes nothing — no CLI spawn, no output files, no directory creation. - The wrapper always passes `-y` to npx and never prompts, even on a cold cache. -
formats.md 13 KB
# Formats: what anydoc converts and what GFM you get This reference documents every input format the pinned CLI (`@firecrawl/anydoc` v0.2.4) accepts, the GitHub-Flavored Markdown each one produces, and the fidelity caveats you must know before trusting the output. Every claim below was verified by running the real CLI against the committed fixtures in `fixtures/` (see [sources.md](sources.md) for provenance and the verification procedure). ## Coverage: 8 families / 21 extensions / 12 parsers | Family | Extensions | Canonical parser | | --- | --- | --- | | Word | `.doc`, `.docx`, `.docm` | `doc` (legacy OLE) / `docx` (`.docm` aliases to `docx`) | | PowerPoint | `.ppt`, `.pps`, `.pot`, `.pptx`, `.pptm`, `.ppsx`, `.ppsm` | `ppt` (`.pps`, `.pot` alias to `ppt`) / `pptx` (`.pptm`, `.ppsx`, `.ppsm` alias to `pptx`) | | Excel | `.xls`, `.xlsx`, `.xlsm`, `.xlsb` | `xlsx` (all four; calamine reads both OLE and ZIP) | | OpenDocument | `.odt`, `.ods`, `.odp` | `odt`, `ods`, `odp` | | Rich Text Format | `.rtf` | `rtf` | | EPUB | `.epub` | `epub` | | CSV | `.csv` | `csv` | | PDF | `.pdf` | `pdf` | That is **8 families, 21 extensions, 12 canonical parsers**: `doc, docx, odt, pdf, ppt, pptx, rtf, epub, xlsx, ods, odp, csv`. These 12 names are also the values accepted by `--format`; extension aliases resolve through the same mapping (verified: `--format xls` and `--format docm` are accepted). Format detection reads the file *bytes* first (PDF header, RTF open group, OLE stream names, ZIP mimetype/content types). CSV has no content signature, so it falls back to the extension or to an explicit `--format`. ## Shared output behavior All document formats flow through one shared document model and one GFM serializer, so identical logical structure yields near-identical Markdown across formats. Behaviors you can rely on everywhere: - Headings render as `#`–`######` with anchors. - Inline runs preserve **bold**, *italic*, ~~strike~~, `` `code` ``, and lists (bullet, numbered, nested, roman). - GFM tables with header rows; merged cells render as **empty covered spans**. - Footnotes/endnotes: `[^n]` reference inline, with `[^n]: ...` definition lines at the end of the document. - Markdown specials in source text are escaped (`\*stars*`, `\| pipe`). - Embedded images render as their **alt text only** — raw image bytes never survive into Markdown. - Bookmarks/anchor targets render as raw `<a id="..."></a>` markers. ## Word (`.doc`, `.docx`, `.docm`) Expected output: `#` title, `##`/`###` section headings, inline emphasis, GFM tables, `[^n]` footnotes. DOCX, DOC, ODT, and RTF all share this document shape; the same fixture converted as `.doc`, `.odt`, and `.rtf` produced near-identical markdown. Real conversion of `fixtures/fixture-handmade-outline.docx`: ```markdown ## Style heading stays a heading ### Direct level overrides the style Direct nine turns the style heading off # Direct outline without a style Child style nine stops inheritance ``` Headings come from Word styles and direct formatting; `#`–`######` levels map onto heading levels. Real conversion of `fixtures/text.doc` shows the full document shape: ```markdown # Fixture Document Plain paragraph with **bold**, *italic*, and ~~struck~~ runs. ## Table | | | | | --- | --- | --- | | Wide head | | End | | Tall | B2 | C2 | | | B3 | C3 | ## Notes and special text Music clef 𝄞 appears before this footnote[^1] reference. [^1]: Footnote after an astral character. ``` Caveats: - **Merged cells** in Word tables render as empty covered spans (the covered cells are blank, not repeated or filled). - **Nested tables** flatten into a single cell (GFM cannot nest tables) — a known limitation of the library. - Legacy `.doc` (OLE) converts through the same document serializer with the same shape; only the relative-link target rendering differs cosmetically between sources. - Fillable-form controls (DOCX content controls) lose their field layer; labels and underline glyphs survive. ## PowerPoint (`.ppt`, `.pps`, `.pot`, `.pptx`, `.pptm`, `.ppsx`, `.ppsm`) Expected output: **slide titles as plain paragraphs** (never markdown headings), bullet lists, speaker notes as `>` blockquotes, and — for PPTX and ODP — slide tables as proper GFM tables. Legacy `.ppt` flattens tables to bare text lines (see caveat). Real conversion of `fixtures/pres.pptx`: ```markdown Deck Title Slide - Top level point - Nested detail - Second point with emphasis > Speaker note for the intro slide. Numbers Slide | Region | Total | | --- | --- | | North | 42 | Grouped shapes below. ``` Caveat — **legacy `.ppt` flattens tables to bare text lines.** The same deck converted from `fixtures/pres.ppt` renders the Numbers Slide table as plain lines with no `|` table syntax: ```markdown Numbers Slide Region Total North 42 ``` If the presentation's tables matter, use PPTX or ODP and verify the `|` rows survived (see [workflows.md](workflows.md), "Output verification"). ## Excel (`.xls`, `.xlsx`, `.xlsm`, `.xlsb`) Expected output: each worksheet becomes a `## <sheet name>` heading followed by a GFM table; the first row is used as the table header when it looks label-like. Real conversion of `fixtures/sheet.xlsx` (first table): ```markdown ## Values | Kind | Value | Note | | --- | --- | --- | | Percent | 0.155 | fifteen and a half | | Currency | 1234.5 | dollars | | Thousands | 9876543 | grouped | | Date | 2026-03-15 | ides of March | | Duration | 26:30:15 | over a day | | Tiny | 0.0000004 | four ten-millionths | | Boolean | TRUE | yes | ``` Caveats: - **XLS/XLSX drop number formats (issue #27).** Cells carry their *raw* values, not the formatted display values: `Percent → 0.155` (not `15.5%`), `Currency → 1234.5` (not `$1,234.50`), thousands `9876543`. A percentage reading as a raw fraction is wrong by 100x in meaning — warn consumers and sanity-check spreadsheets. Dates survive as ISO strings (`2026-03-15`). - **ODS is the contrast case:** it keeps the formatted display values (`15.5%`, `$1,234.50`, `9,876,543`) on the same logical content. If display values matter, prefer ODS or a CSV export. - **Merged cells render as empty covered spans** within the populated range only. Real conversion of `fixtures/handmade-merged.xlsx`: ```markdown | | | | | --- | --- | --- | | Merged across | | padded | | tall | b2 | 3.5 | | | b3 | | ``` - Hidden rows and columns are treated as visible and appear in the output (known limitation) — check for hidden template or calculation content before feeding output to an LLM. ## OpenDocument (`.odt`, `.ods`, `.odp`) - `.odt`: same document shape as DOCX/DOC/RTF — `#`/`##` headings, GFM tables, `[^n]` footnotes. Real conversion of `fixtures/text.odt` matches the `text.doc` output structure line-for-line (only relative-link targets differ in depth). - `.ods`: same spreadsheet shape as XLSX (`## Values` + GFM table) but with **formatted display values preserved** — the Excel number-format caveat does not apply. Real conversion of `fixtures/sheet.ods`: ```markdown ## Values | Kind | Value | Note | | --- | --- | --- | | Percent | 15.5% | fifteen and a half | | Currency | $1,234.50 | dollars | | Thousands | 9,876,543 | grouped | ``` - `.odp`: **same slide serializer as PPTX** — slide titles as plain paragraphs, speaker notes as blockquotes, and GFM tables **kept** (unlike legacy `.ppt`). Real conversion of `fixtures/pres.odp`: ```markdown Deck Title Slide - Top level point - - Nested detail - Second point with emphasis > Speaker note for the intro slide. Numbers Slide | Region | Total | | --- | --- | | North | 42 | ``` One cosmetic difference vs PPTX: a nested bullet renders as `- - Nested detail` on one line rather than as an indented sub-list. The table, blockquote notes, and paragraph titles are identical in shape to PPTX. ## Rich Text Format (`.rtf`) Expected output: the same document shape as DOCX/ODT (`# Fixture Document`, `##` sections, GFM tables, `[^n]` footnote definitions). Real conversion of `fixtures/text.rtf` matches `text.odt` structure; the one notable difference is that relative link targets render with a `file:///` absolute path, e.g. `[a sibling file](file:///anydoc/tests/fixture-src/sibling.odt)`, instead of a relative path — a known cosmetic quirk. ## EPUB (`.epub`) Expected output: `#` chapter headings (plus the book metadata title), GFM tables, preserved inline emphasis/code, and **internal anchor links resolved to fragments**. Real conversion of `fixtures/book.epub`: ```markdown # Fixture Book # Fixture Book anydoc tests <a id="epub-text-ch001-xhtml-chapter-one"></a> # Chapter One Opening paragraph with **bold**, *italic*, and `code` runs. See [Chapter Two](#epub-text-ch002-xhtml-chapter-two) for the table, or jump straight to [the marked paragraph](#epub-text-ch002-xhtml-markpoint). <a id="epub-text-ch002-xhtml-chapter-two"></a> # Chapter Two | Name | Qty | | --- | --- | | Bolts | 12 | | Nuts | 30 | ``` Notes: the book title may appear twice (metadata title + injected title); internal links keep working as `[text](#fragment)` links; external links stay as normal markdown links. ## CSV (`.csv`) Expected output: the file renders as **one GFM table**. The first row is **promoted to the header row** when it looks like labels (≥ 2 columns, non-empty, non-numeric, distinct fields) — this behavior ships in 0.2.4. Quoted fields with embedded commas and newlines are preserved. Real conversion of `fixtures/fixture-handmade-quoted.csv`: ```markdown | name | desc | qty | | --- | --- | --- | | padded | comma, inside | 3 | | plain | multi line | 4 | ``` Also verified: - **Delimiter sniffing** — a semicolon-delimited file with decimal commas splits on `;` and keeps `1,5` intact (real output of `fixtures/fixture-handmade-semicolon.csv`): ```markdown | a | b | c | | --- | --- | --- | | 1,5 | 2,5 | x | | 3,0 | y | z | ``` - **UTF-16 (with BOM)** decodes to correct Unicode (real output of `fixtures/fixture-handmade-utf16.csv`): ```markdown | col1 | col2 | | --- | --- | | naïve | café | | Αθήνα | 数据 | ``` CSV has no content signature, so **`--format csv` is required when reading CSV from stdin** (see [cli-reference.md](cli-reference.md)). ## PDF (`.pdf`) — the lower-fidelity pipeline Text-based PDFs convert **locally** through a separate pipeline (`pdf-inspector`) that emits Markdown directly — PDF has no document model, so only Markdown output exists. Real conversion of `fixtures/fixture-text.pdf`: ```markdown # Fixture Document Plain paragraph with **bold**, *italic*, and struck runs. **Style-bold paragraph with a** NotBold-styled span **inside.** ## Lists 1.First numbered 2.Second numbered a)Alpha sub one b)Alpha sub two i.Roman sub sub 3.Third numbered Interrupting paragraph between lists. ## Table Wide head End Tall B2 C2 B3 C3 ``` **Fidelity caveats (verified on the real output):** - **No GFM tables.** Table cell text flattens into a plain paragraph run (`Wide head End Tall B2 C2 B3 C3`) — there is no `|` table. - **No `[^n]` footnotes.** Footnote markers degrade to inline superscript glyphs (`¹`) and the note bodies drop into the flow; there is no `[^1]:` definition block. - **Links are not emitted as markdown links.** They degrade to `<u>underlined text</u>`. - Numbered/bulleted list structure compresses (markers inline), and some Unicode degrades (e.g. emoji without ZWJ). ### Scanned or image-only PDFs — explicit hosted OCR A PDF with **no extractable text layer** fails as `unsupported` with this exact message (exit code 1): ``` anydoc: unsupported input: PDF has no extractable text (Scanned, 1 pages): OCR is required ``` The local default does not perform OCR. When this message fires, report the exact error and either route the file to local OCR tooling or, only after explicit authorization, rerun with `--ocr hosted --allow-hosted-upload`. Hosted mode sends the whole document to Firecrawl Parse because page selection is unavailable. Do not select hosted mode implicitly or claim hosted accuracy from the upstream announcement. See [errors.md](errors.md) for routing guidance. ## Formats anydoc does NOT support - HTML/SingleFile (open feature request only) — not an input format. - Images (`.png`, `.jpg`, ...) — no image-to-text conversion. - Password-protected/encrypted documents — fail with `anydoc: document is encrypted` (see [errors.md](errors.md)). - Anything without a recognized signature and extension — fails as `unsupported input: unrecognized file content and extension: <path>`. ## Output-shape invariants to remember 1. One serializer: the same logical structure yields near-identical Markdown across docx/odt/rtf — do not re-test each office format for the same feature. 2. Spreadsheets: expect `## <sheet name>` + GFM tables; warn that xlsx/xls drop number formats (issue #27) while ODS keeps display values. 3. Legacy `.ppt` and all PDFs lose tabular structure — add a "verify the table survived" step or use PPTX/ODP and text PDFs. 4. Images never survive as bytes in Markdown — only alt text. -
report-examples.md 1.7 KB
# Any Doc evidence-report examples Use these as shape examples after running the command. Substitute the real input, command, exit code, output destination, checks, and caveat. Do not copy fixture values into a report for a different document. ## Successful text conversion ```text Input: /work/report.docx Command: python3 anydoc/scripts/anydoc convert /work/report.docx Exit: 0 Output: stdout (no file written) Checks: headings ## and ### present; expected table markers present Caveat/route: Markdown preserves logical structure, not Word's rendered layout ``` The report states what was actually observed, rather than claiming that the source's fonts, pagination, or visual layout survived. ## Expected failure: scanned PDF ```text Input: /work/scanned.pdf Command: python3 anydoc/scripts/anydoc convert /work/scanned.pdf Exit: 1 Output: stdout empty; stderr contained the OCR-required anydoc error Checks: no Markdown was produced Caveat/route: anydoc does not OCR; route to OCR tooling or the hosted Firecrawl Parse API; do not retry unchanged ``` An expected failure is still a completed diagnostic. Report the exact boundary and route instead of turning it into a generic conversion failure. ## Fidelity boundary: legacy presentation table ```text Input: /work/legacy.ppt Command: python3 anydoc/scripts/anydoc convert /work/legacy.ppt Exit: 0 Output: stdout Checks: slide titles and speaker notes present; table cells appeared as bare text, not GFM rows Caveat/route: legacy .ppt loses table structure; use PPTX or ODP when table fidelity matters ``` These examples calibrate reporting only. The authoritative format behavior and exact error vocabulary remain in [formats.md](formats.md) and [errors.md](errors.md). -
sources.md 5.2 KB
# Sources, provenance, and verification ## Upstream project | Resource | URL / identifier | | --- | --- | | Repository | https://github.com/firecrawl/anydoc | | npm package | `@firecrawl/anydoc` — https://www.npmjs.com/package/@firecrawl/anydoc | | PyPI package | `firecrawl-anydoc` (imports as `anydoc`) — https://pypi.org/project/firecrawl-anydoc/ | | crates.io crate | `anydoc` (same release train) | | Browser demo (WASM) | https://firecrawl.github.io/anydoc/ | | License | MIT | ## Access and verification dates - Research and empirical verification performed **2026-08-05** and **2026-08-06** on macOS (arm64) with Node v22.22.3, network access, and the pinned CLI `npx -y @firecrawl/anydoc@0.2.4`. - The pinned release **0.2.4** was published to npm at **2026-08-05T18:29:40Z**; PyPI wheels for the same version were uploaded **2026-08-05T18:29Z**. First release was 0.1.1 (2026-08-04). ## Fixture provenance The committed fixtures under `fixtures/` come from two sources, both documented here per the repository's attribution policy: 1. **The MIT-licensed upstream test suite.** Most fixtures were downloaded from `https://github.com/firecrawl/anydoc/tree/main/tests/fixtures` (raw files via `https://raw.githubusercontent.com/firecrawl/anydoc/main/tests/fixtures/...`). They retain the upstream naming and structure: - CSV: `fixture-handmade-quoted.csv`, `fixture-handmade-semicolon.csv`, `fixture-handmade-utf16.csv`, `fixture-sheet.csv` - DOCX: `fixture-handmade-numbering.docx`, `fixture-handmade-outline.docx`, `fixture-handmade-rich.docx`, `fixture-handmade-tables.docx` - Word legacy: `text.doc`; OpenDocument: `text.odt`, `sheet.ods`, `pres.odp` - RTF: `text.rtf`; EPUB: `book.epub` - PowerPoint: `pres.ppt`, `pres.pptx`; Excel: `sheet.xls`, `sheet.xlsx`, `handmade-merged.xlsx` - PDF: `fixture-text.pdf` - Error cases from the upstream `*--errors.*` corpus: `empty--errors.docx`, `encrypted--errors.odt` 2. **Generated samples** (created during research for cases the upstream suite does not cover; deterministic, reproducible): - `scanned-image-only.pdf` — a PDF with a single grayscale image and no text layer, generated with Pillow, to exercise the no-OCR error path. - `unsupported.xyz` — a small text file with an unsupported extension, to exercise the unrecognized-content error path. All fixtures are tiny (largest: `pres.ppt` at ~454 KB) and each is well under the 5 MB repository limit. All committed copies are byte-identical to the staged originals used during research (verified by sha256). MIT license notice: the upstream anydoc project is MIT-licensed (Copyright Firecrawl); the fixture files above are used under that license. The generated samples carry no upstream copyright. ## Verification procedure Every factual claim in this skill was confirmed against the **real pinned CLI** (v0.2.4), not inferred from documentation: 1. **Environment warm-up**: `node --version` (v22.22.3 ≥ 20), then `npx -y @firecrawl/anydoc@0.2.4 --version` → prints `0.2.4`; `--help` → the verbatim help block reproduced in [cli-reference.md](cli-reference.md). 2. **Positive conversions**: the pinned CLI was run on every committed fixture with stdout and stderr captured separately and the exit code recorded. All 20 positive fixtures converted with exit 0 and empty stderr; the captured markdown was compared against the output expectations documented in [formats.md](formats.md) (headings, table rows, slide structure, footnote definitions, CSV header promotion, UTF-16/delimiter handling, merged-cell covered spans). 3. **Error paths**: each error fixture and each usage error was run with stderr captured verbatim and the exit code recorded (1 for conversion/IO failures, 2 for usage errors). The exact messages appear in [errors.md](errors.md) character-for-character, including `anydoc: unsupported input: PDF has no extractable text (Scanned, 1 pages): OCR is required`. 4. **Special behaviors**: `-o` overwrite and EISDIR, stdin via `-` with and without `--format csv`, `--format=x` inline syntax, `--` end-of-options, extension aliases (`--format xls`, `--format docm`), EPIPE (`| head` exits 0 with empty stderr), the stdin-is-a-terminal usage error (via a pseudo-TTY), and resource limits (run on the upstream `zipbomb`/`imagebomb` fixtures and on a generated 250 MB-entry archive — all exit 1 with the documented `max_entry_bytes` prefix). 5. **First-run/offline**: a fresh empty npm cache was used to verify the first-run download path (`env npm_config_cache=$(mktemp -d) npx -y @firecrawl/anydoc@0.2.4 --version` → `0.2.4`, exit 0). 6. **Startup timing**: repeated warm invocations were timed (`/usr/bin/time -p npx -y @firecrawl/anydoc@0.2.4 ...`) — ~0.32–0.35 s each, consistent with the documented ~0.33–0.55 s warm-cache startup range. Repository checks applied after authoring: frontmatter and structure (`ruby scripts/validate-skills.rb`), skill quality (`ruby scripts/validate-skill-quality.rb --base origin/main`), reference caps and link resolution, eval-manifest validation (`scripts/validate-evals.py`), and no machine-specific paths or credentials in any committed file. -
workflows.md 8.8 KB
# Workflows: recipes for converting documents to markdown All recipes use the pinned CLI `npx -y @firecrawl/anydoc@0.2.4` (ground truth) and the skill's wrapper `scripts/anydoc` where it adds value. Commands are shown relative to the repository root; `anydoc/fixtures/...` paths can be replaced with any document path. The vault-ingestion recipe (section 5) is written to be run from a temp or vault directory holding *your own* documents. Each raw-CLI invocation converts **exactly one document** — there is no batch mode. ## 1. Single conversion ```bash # Markdown to stdout npx -y @firecrawl/anydoc@0.2.4 anydoc/fixtures/fixture-handmade-outline.docx # Markdown to a file (stdout stays silent; existing file is overwritten) npx -y @firecrawl/anydoc@0.2.4 anydoc/fixtures/fixture-handmade-outline.docx -o outline.md # Same jobs through the wrapper python3 anydoc/scripts/anydoc convert anydoc/fixtures/fixture-handmade-outline.docx python3 anydoc/scripts/anydoc convert anydoc/fixtures/fixture-handmade-outline.docx -o outline.md ``` Expected result: exit code 0, empty stderr, and GitHub-Flavored Markdown on stdout (or written to the `-o` output file) containing `#`/`##`/`###` heading lines. ## 2. Force the input format ```bash # Extensionless or mislabeled file: name the format explicitly npx -y @firecrawl/anydoc@0.2.4 ./data --format csv npx -y @firecrawl/anydoc@0.2.4 ./report --format docx ``` Use `--format <name>` only when detection cannot work (CSV from stdin, or a missing/wrong extension). Aliases resolve: `--format xls`, `--format docm`, `--format ppsx` are accepted. An invalid name exits 2 with `anydoc: invalid format 'bogus'; expected one of: ...`. ## 3. Read a document from stdin ```bash # CSV from stdin requires --format csv (no signature, no extension) printf 'name,role\nAlice,Engineer\n' | npx -y @firecrawl/anydoc@0.2.4 - --format csv # Any document type can come from stdin; detection reads the bytes curl -s https://example.com/paper.pdf | npx -y @firecrawl/anydoc@0.2.4 - ``` The wrapper supports the same: `cat data.csv | python3 anydoc/scripts/anydoc convert - -f csv`. Piping notes: - Markdown goes to **stdout only**; diagnostics are the single `anydoc: <message>` stderr line. - **EPIPE is handled**: if the downstream pipe closes early (`... anydoc@0.2.4 big.xlsx | head -n 1`), the CLI exits 0 with no stderr noise — piping into `head` is safe and is not a failure. ## 4. Batch conversion (raw CLI) The raw CLI takes one document per invocation, so batch with a shell loop: ```bash mkdir -p out for f in anydoc/fixtures/*.docx; do npx -y @firecrawl/anydoc@0.2.4 "$f" -o "out/$(basename "${f%.docx}").md" done ``` Each failed document (error fixtures, scanned PDFs, encrypted files) exits 1 with its `anydoc: <message>` on stderr and produces no output file; the loop continues with the next input. Handle or route those per [errors.md](errors.md). Or the wrapper, which is built for this (per-file status, continues past failures, summary, and a non-zero exit when any input failed): ```bash python3 anydoc/scripts/anydoc batch \ anydoc/fixtures/fixture-handmade-outline.docx \ anydoc/fixtures/fixture-sheet.csv \ --out-dir out/ ``` `batch --dry-run --json` prints the plan (input → output, dry-run marker) without converting or creating anything: ```bash python3 anydoc/scripts/anydoc batch anydoc/fixtures/fixture-handmade-outline.docx \ anydoc/fixtures/fixture-sheet.csv --out-dir out/ --dry-run --json ``` ## 5. Vault-ingestion pattern Convert a folder of mixed office documents to markdown for ingestion into a vault or knowledge base: 1. **Collect** the documents into a folder (mixed docx/xlsx/pptx/csv/odt/pdf is fine — text-based PDFs only; see the no-OCR caveat in [errors.md](errors.md)). 2. **Batch-convert** with the wrapper into a markdown folder: ```bash python3 anydoc/scripts/anydoc batch notes/*.docx notes/*.xlsx notes/*.csv --out-dir vault/inbox/ ``` (or the raw-CLI loop above if you are not using the wrapper). > **Run this from a temp or vault directory — never from the agent-skills > repo root.** The glob matches whatever directory you name, and the > repository tracks a top-level `docs/` directory (distinct from the > `documents/` skill): globbing `docs/*.docx` there, or deleting/cleaning > those matches, would damage tracked repository files. Keep the source > documents in their own folder (here `notes/`) and convert into a > separate `vault/inbox/` folder. 3. **Verify each output** (step 6) — at minimum confirm exit 0 and that the structural markers your formats produce are present (headings for Word/PDF, `|` tables for spreadsheets/CSV). 4. **Failures are per-file**: the batch summary names what failed; route those files per [errors.md](errors.md) (scanned PDF → OCR tooling, encrypted → unencrypted copy, unsupported → check extension) and re-run only the failures. ## 6. Output verification Before treating a conversion as done: 1. **Exit code 0** — the CLI produced markdown. Exit 1: read the `anydoc: <message>` stderr line and match it against [errors.md](errors.md). Exit 2: fix the command (usage error). 2. **Structural markers** — check the markers your format actually produces: - Word / ODT / RTF / text-based PDF: `#`/`##` heading lines (`grep -E '^#{1,6} ' out.md`). - Spreadsheets (xlsx/xls/ods) and CSV: `## <sheet>` headings and `|`-delimited rows (`grep -E '^\|' out.md`). - Presentations (pptx/odp): slide titles as plain paragraphs, `>` blockquote speaker notes, `|` table rows (legacy `.ppt` has no `|` rows — that is by design, not an error). - EPUB: `#` chapter headings and `[text](#fragment)` internal links. 3. **Tables survived?** If the source had tables and the output has no `|` rows, check the caveats: PDF and legacy `.ppt` flatten tables by design. 4. **Large outputs**: convert with `-o out.md` and inspect the file rather than streaming everything into context. Use the committed fixtures to sanity-check an environment once: ```bash npx -y @firecrawl/anydoc@0.2.4 anydoc/fixtures/fixture-handmade-outline.docx # headings npx -y @firecrawl/anydoc@0.2.4 anydoc/fixtures/sheet.xlsx # ## Values + table npx -y @firecrawl/anydoc@0.2.4 anydoc/fixtures/fixture-text.pdf # headings, no table ``` ## 7. Large files and resource limits - **Conversion is not streaming** — the document is read and processed as a whole, and safety limits protect against decompression and nesting bombs. - **Zip/image bombs are rejected via `max_entry_bytes`** with exit 1 and the prefix `anydoc: resource limit exceeded (max_entry_bytes):` (full examples in [errors.md](errors.md)). This is by design — do not try to bypass it. - **`-o out.md` is recommended for large documents** so the output is written to a reviewable file instead of filling stdout/context; you can then read the parts you need. - Genuinely large real documents (as opposed to bombs) convert normally; the per-document limit only rejects entries whose declared decompressed size exceeds the cap. - If a resource-limit error fires on a *real* file, the archive is malformed or hostile — re-export the document rather than disabling the limit. ## 8. Startup cost and performance Each `npx -y @firecrawl/anydoc@0.2.4` invocation costs roughly **0.33–0.55 s of warm-cache startup** (npm/npx process startup) on top of the conversion itself, which is a few milliseconds (measured ~5 ms for a PDF, <1 ms for a DOCX once the process is warm). There is no progress output; conversions are effectively instant. Plan for ~0.5 s per document in batch loops, and prefer a single `npx` process per document (you cannot batch inside one invocation). ## 9. Hosted OCR workflow The local default is safe for sensitive documents and never uploads them. For a scanned PDF, obtain explicit authorization for whole-document upload, then run: ```bash python3 anydoc/scripts/anydoc convert scan.pdf --ocr hosted --allow-hosted-upload ``` Set `FIRECRAWL_API_KEY` only in the trusted environment when higher hosted limits are needed. Never pass it on the command line. The hosted route uses Firecrawl Parse, has no page-selection option, and does not silently fall back to another endpoint after authentication, quota, or transport failure. Verify the output and report that the result came from hosted OCR. ## 10. Offline / cold-cache behavior - The first `npx` run downloads the package plus the native binary (network required once); later runs use the npm cache. A cold-cache offline run fails with a clear npx fetch error before anydoc executes. - For permanent or fully offline use, install once: `npm install -g @firecrawl/anydoc`, then call `anydoc <file>` directly. - The wrapper always invokes npx with `-y` (non-interactive), so it never hangs on npx's install prompt — even on a cold cache it fails fast if the package cannot be fetched.
-
-
scripts
-
anydoc 26.4 KB · in bundle
-
test_anydoc_hosted_ocr.py 6.3 KB
#!/usr/bin/env python3 """Offline regression tests for AnyDoc hosted-OCR privacy boundaries.""" import importlib.machinery import io import os import subprocess from contextlib import redirect_stderr, redirect_stdout from pathlib import Path from unittest import mock ROOT = Path(__file__).resolve().parents[1] SCRIPT = ROOT / "scripts" / "anydoc" FIXTURES = ROOT / "fixtures" SCANNED = FIXTURES / "scanned-image-only.pdf" TEXT_PDF = FIXTURES / "fixture-text.pdf" PINNED = "@firecrawl/anydoc@0.2.4" cli = importlib.machinery.SourceFileLoader( "anydoc_hosted_ocr_wrapper", str(SCRIPT) ).load_module() def run_in_process(arguments): """Run cli.main() in-process; return (code, stdout, stderr).""" stdout, stderr = io.StringIO(), io.StringIO() with redirect_stdout(stdout), redirect_stderr(stderr): try: code = cli.main(arguments) except SystemExit as exc: code = exc.code if exc.code is not None else 0 return code, stdout.getvalue(), stderr.getvalue() def test_selected_release_and_timeout_contract(): assert cli.PINNED == PINNED assert cli.LOCAL_RUN_TIMEOUT == 120 assert cli.HOSTED_RUN_TIMEOUT > 300 def test_local_command_is_the_default_and_never_enables_hosted_ocr(): command = cli.build_cli_command("report.pdf", None, None, "reject") assert command == ["npx", "-y", PINNED, "report.pdf"] assert "hosted" not in command assert "--api-key" not in command assert "--api-url" not in command def test_hosted_command_is_explicit_but_wrapper_confirmation_is_not_forwarded(): command = cli.build_cli_command("scan.pdf", "out.md", None, "hosted") assert command == [ "npx", "-y", PINNED, "scan.pdf", "-o", "out.md", "--ocr", "hosted", ] assert "--allow-hosted-upload" not in command assert "--api-key" not in command def test_hosted_mode_without_upload_authorization_stops_before_cli_spawn(): with mock.patch.object(cli, "runtime_errors", return_value=[]), mock.patch.object( cli, "run_cli" ) as run_cli: code, stdout, stderr = run_in_process( ["convert", str(SCANNED), "--ocr", "hosted"] ) assert code == 2 assert stdout == "" assert "--allow-hosted-upload" in stderr assert "whole document" in stderr run_cli.assert_not_called() def test_upload_authorization_without_hosted_mode_is_rejected(): with mock.patch.object(cli, "run_cli") as run_cli: code, stdout, stderr = run_in_process( ["convert", str(TEXT_PDF), "--allow-hosted-upload"] ) assert code == 2 assert stdout == "" assert "only valid with --ocr hosted" in stderr run_cli.assert_not_called() def test_hosted_dry_run_is_explicit_and_does_not_disclose_environment_values(): secret = "hosted-test-secret-never-print" private_endpoint = "https://private.example.invalid" with mock.patch.dict( os.environ, { "FIRECRAWL_API_KEY": secret, "FIRECRAWL_API_URL": private_endpoint, }, clear=False, ): code, stdout, stderr = run_in_process( [ "convert", str(SCANNED), "--ocr", "hosted", "--allow-hosted-upload", "--dry-run", ] ) assert code == 0 assert stderr == "" assert "--ocr hosted" in stdout assert "whole-document upload authorized" in stdout assert secret not in stdout assert private_endpoint not in stdout assert "--api-key" not in stdout def test_api_key_on_argv_is_rejected_without_echoing_the_secret(): secret = "argv-secret-never-print" code, stdout, stderr = run_in_process( [ "convert", str(SCANNED), "--ocr", "hosted", "--allow-hosted-upload", "--api-key", secret, ] ) assert code == 2 assert secret not in stdout assert secret not in stderr assert "FIRECRAWL_API_KEY" in stderr def test_local_conversion_does_not_forward_hosted_mode_even_when_credentials_exist(): completed = subprocess.CompletedProcess([], 0, stdout="# Local\n", stderr="") with mock.patch.dict( os.environ, {"FIRECRAWL_API_KEY": "ambient-secret"}, clear=False ), mock.patch.object(cli, "runtime_errors", return_value=[]), mock.patch.object( cli, "run_cli", return_value=completed ) as run_cli: code, stdout, stderr = run_in_process(["convert", str(TEXT_PDF)]) assert code == 0 assert stdout == "# Local\n" assert stderr == "" command = run_cli.call_args.args[0] assert "--ocr" not in command assert "hosted" not in command assert "ambient-secret" not in command assert run_cli.call_args.kwargs["timeout"] == cli.LOCAL_RUN_TIMEOUT def test_hosted_mode_uses_long_timeout_and_redacts_reflected_environment_secret(): secret = "reflected-secret-never-print" completed = subprocess.CompletedProcess( [], 1, stdout="", stderr=f"anydoc: Firecrawl Parse rejected the API key: {secret}", ) with mock.patch.dict( os.environ, {"FIRECRAWL_API_KEY": secret}, clear=False ), mock.patch.object(cli, "runtime_errors", return_value=[]), mock.patch.object( cli, "run_cli", return_value=completed ) as run_cli: code, stdout, stderr = run_in_process( [ "convert", str(SCANNED), "--ocr", "hosted", "--allow-hosted-upload", "--json", ] ) assert code == 1 assert secret not in stdout assert secret not in stderr assert "[REDACTED]" in stdout assert "[REDACTED]" in stderr assert run_cli.call_args.kwargs["timeout"] == cli.HOSTED_RUN_TIMEOUT def test_needs_ocr_and_hosted_failures_have_distinct_safe_routes(): error_class, hint = cli.error_class_hint("anydoc: page 1 of 1 needs OCR") assert error_class == "needs-ocr" assert "local OCR" in hint assert "explicit" in hint error_class, hint = cli.error_class_hint( "anydoc: Firecrawl Parse keyless limit reached, set FIRECRAWL_API_KEY" ) assert error_class == "hosted-rate-limit" assert "retry" in hint.lower() assert "print" in hint.lower()
-
-
tests
-
test_anydoc.py 31.7 KB
#!/usr/bin/env python3 """Unit tests for the anydoc wrapper (`anydoc/scripts/anydoc`). Offline by design: the core tests need no node, no npx, and no network. Real-CLI tests (converting the committed fixtures through the pinned CLI) are opt-in and skip gracefully when the toolchain is unavailable. """ import importlib.machinery import io import json import os import shutil import signal import stat import subprocess import sys import tempfile import unittest from contextlib import redirect_stderr, redirect_stdout from pathlib import Path from unittest import mock ROOT = Path(__file__).resolve().parents[1] # anydoc/ SCRIPT = ROOT / "scripts" / "anydoc" FIXTURES = ROOT / "fixtures" DOCX = FIXTURES / "fixture-handmade-outline.docx" CSV = FIXTURES / "fixture-sheet.csv" SCANNED = FIXTURES / "scanned-image-only.pdf" ENCRYPTED = FIXTURES / "encrypted--errors.odt" MALFORMED = FIXTURES / "empty--errors.docx" UNSUPPORTED = FIXTURES / "unsupported.xyz" TABLES = FIXTURES / "fixture-handmade-tables.docx" PINNED = "@firecrawl/anydoc@0.2.4" cli = importlib.machinery.SourceFileLoader("anydoc_wrapper", str(SCRIPT)).load_module() def run_in_process(arguments): """Run cli.main() in-process; return (code, stdout, stderr).""" stdout, stderr = io.StringIO(), io.StringIO() with redirect_stdout(stdout), redirect_stderr(stderr): try: code = cli.main(arguments) except SystemExit as exc: code = exc.code if exc.code is not None else 0 return code, stdout.getvalue(), stderr.getvalue() def run_script(arguments, env=None, cwd=None, input_bytes=None, timeout=120): """Run the wrapper as a subprocess; return CompletedProcess.""" return subprocess.run( [sys.executable, str(SCRIPT)] + arguments, capture_output=True, text=True, encoding="utf-8", errors="replace", env=env, cwd=cwd, input=input_bytes, timeout=timeout, ) def minimal_path_env(): """A PATH containing only a python3 symlink (no node, no npx).""" tmp = Path(tempfile.mkdtemp()) bindir = tmp / "bin" bindir.mkdir() os.symlink(sys.executable, bindir / "python3") env = os.environ.copy() env["PATH"] = str(bindir) return tmp, env def node_shim_env(version_line): """A PATH whose `node` is a shim printing `version_line`.""" tmp, env = minimal_path_env() bindir = tmp / "bin2" bindir.mkdir() shim = bindir / "node" shim.write_text("#!/bin/sh\n%s\n" % version_line) shim.chmod(0o755) env["PATH"] = str(bindir) + os.pathsep + env["PATH"] return tmp, env class WrapperCoreTests(unittest.TestCase): """Offline wrapper behavior: help, usage errors, pre-validation, plans.""" def test_script_is_executable_and_has_shebang(self): mode = stat.S_IMODE(SCRIPT.stat().st_mode) self.assertTrue(mode & stat.S_IXUSR, "scripts/anydoc must be executable") with SCRIPT.open("rb") as handle: first = handle.readline().decode("utf-8", "replace").strip() self.assertEqual(first, "#!/usr/bin/env python3") def test_direct_execution_via_shebang(self): result = subprocess.run( [str(SCRIPT), "info"], capture_output=True, text=True, timeout=60 ) self.assertEqual(result.returncode, 0, result.stderr) self.assertIn("anydoc", result.stdout) self.assertIn("0.2.4", result.stdout) def test_help_exits_zero_with_usage_and_examples(self): for arguments in ( ["--help"], ["convert", "--help"], ["batch", "--help"], ["info", "--help"], ): with self.subTest(arguments=arguments): code, stdout, stderr = run_in_process(arguments) self.assertEqual(code, 0, stderr) self.assertIn("usage", stdout.lower()) self.assertIn("Example", stdout) self.assertEqual(stderr, "") def test_batch_help_documents_exit_semantics(self): _, stdout, _ = run_in_process(["batch", "--help"]) self.assertIn("1 when any", stdout) self.assertIn("input failed", stdout) def test_help_works_without_node_on_path(self): tmp, env = minimal_path_env() try: for arguments in ( ["--help"], ["convert", "--help"], ["batch", "--help"], ["info", "--help"], ): with self.subTest(arguments=arguments): result = run_script(arguments, env=env) self.assertEqual(result.returncode, 0, result.stderr) self.assertIn("usage", result.stdout.lower()) self.assertIn("Example", result.stdout) self.assertEqual(result.stderr, "") finally: shutil.rmtree(tmp) def test_usage_errors_exit_2(self): # Each usage error must exit 2 on stderr and name the offending token # OR the missing input (argparse names the missing subcommand for an # unknown root option). cases = ( (["--bogus"], "command"), (["convert", str(DOCX), "--bogus"], "--bogus"), (["convert"], "required"), (["batch"], "required"), ) for arguments, needle in cases: with self.subTest(arguments=arguments): code, stdout, stderr = run_in_process(arguments) self.assertEqual(code, 2) self.assertEqual(stdout, "") self.assertIn(needle, stderr) self.assertIn("usage", stderr.lower()) self.assertNotIn("Traceback", stderr) def test_info_reports_tool_and_version(self): code, stdout, stderr = run_in_process(["info"]) self.assertEqual(code, 0, stderr) self.assertIn("anydoc", stdout) self.assertIn("0.2.4", stdout) self.assertEqual(stderr, "") def test_info_version_prints_exact_version(self): code, stdout, stderr = run_in_process(["info", "--version"]) self.assertEqual(code, 0, stderr) self.assertEqual(stdout.strip(), "0.2.4") self.assertEqual(stderr, "") def test_convert_missing_input_prevalidation(self): code, stdout, stderr = run_in_process( ["convert", "/nonexistent/anydoc-input.docx"] ) self.assertEqual(code, 1) self.assertEqual(stdout, "") self.assertIn("/nonexistent/anydoc-input.docx", stderr) self.assertNotIn("Traceback", stderr) def test_convert_directory_input_prevalidation(self): code, stdout, stderr = run_in_process(["convert", str(FIXTURES)]) self.assertEqual(code, 1) self.assertEqual(stdout, "") self.assertIn(str(FIXTURES), stderr) self.assertIn("directory", stderr) self.assertNotIn("Traceback", stderr) def test_convert_output_path_is_directory(self): code, stdout, stderr = run_in_process( ["convert", str(DOCX), "-o", str(FIXTURES)] ) self.assertEqual(code, 1) self.assertEqual(stdout, "") self.assertIn("directory", stderr) self.assertNotIn("Traceback", stderr) def test_convert_invalid_format_exit_2(self): code, stdout, stderr = run_in_process( ["convert", str(DOCX), "-f", "bogus"] ) self.assertEqual(code, 2) self.assertEqual(stdout, "") self.assertIn("invalid format 'bogus'", stderr) self.assertNotIn("Traceback", stderr) def test_convert_dry_run_plans_without_executing(self): with tempfile.TemporaryDirectory() as tmp: out = Path(tmp) / "out.md" code, stdout, stderr = run_in_process( ["convert", str(DOCX), "-o", str(out), "--dry-run"] ) self.assertEqual(code, 0, stderr) self.assertIn("npx -y " + PINNED, stdout) self.assertIn(str(DOCX), stdout) self.assertEqual(stderr, "") self.assertFalse(out.exists(), "dry-run must not create outputs") def test_convert_dry_run_json(self): code, stdout, stderr = run_in_process( ["convert", str(DOCX), "--dry-run", "--json"] ) self.assertEqual(code, 0, stderr) doc = json.loads(stdout) self.assertTrue(doc["dry_run"]) self.assertEqual(doc["command"], "convert") self.assertIn("npx -y " + PINNED, doc["command_line"]) self.assertEqual(stderr, "") def test_batch_dry_run_json_plan_and_no_output_dir(self): with tempfile.TemporaryDirectory() as tmp: out_dir = Path(tmp) / "out" code, stdout, stderr = run_in_process( [ "batch", str(DOCX), str(CSV), "--out-dir", str(out_dir), "--dry-run", "--json", ] ) self.assertEqual(code, 0, stderr) doc = json.loads(stdout) self.assertTrue(doc["dry_run"]) self.assertEqual(doc["command"], "batch") self.assertEqual(len(doc["plan"]), 2) for entry in doc["plan"]: self.assertIn("input", entry) self.assertIn("output", entry) self.assertIn("command", entry) self.assertIn("npx -y " + PINNED, entry["command"]) self.assertEqual(stderr, "") self.assertFalse(out_dir.exists(), "dry-run must not create out-dir") def test_batch_dry_run_marks_invalid_inputs(self): with tempfile.TemporaryDirectory() as tmp: missing = Path(tmp) / "missing.docx" code, stdout, stderr = run_in_process( [ "batch", str(DOCX), str(missing), "--out-dir", str(Path(tmp) / "out"), "--dry-run", "--json", ] ) self.assertEqual(code, 0, stderr) doc = json.loads(stdout) self.assertEqual(len(doc["plan"]), 2) self.assertFalse(doc["plan"][0]["would_fail"]) self.assertTrue(doc["plan"][1]["would_fail"]) self.assertIn("not found", doc["plan"][1]["error"]) def test_batch_dry_run_defaults_out_dir_to_cwd(self): with tempfile.TemporaryDirectory() as tmp: code, stdout, stderr = run_in_process( [ "batch", str(DOCX), "--dry-run", "--json", ], ) self.assertEqual(code, 0, stderr) doc = json.loads(stdout) self.assertEqual(doc["out_dir"], str(Path.cwd())) def test_batch_requires_inputs(self): code, stdout, stderr = run_in_process(["batch"]) self.assertEqual(code, 2) self.assertEqual(stdout, "") def test_convert_json_error_stays_parseable(self): code, stdout, stderr = run_in_process( ["convert", "/nonexistent/anydoc-input.docx", "--json"] ) self.assertEqual(code, 1) doc = json.loads(stdout) self.assertFalse(doc["ok"]) self.assertEqual(doc["exit_code"], 1) self.assertIn("not found", doc["error"]) self.assertIn("not found", stderr) def test_node_missing_error_via_minimal_path(self): tmp, env = minimal_path_env() try: result = run_script(["convert", str(DOCX)], env=env) self.assertEqual(result.returncode, 1) self.assertEqual(result.stdout, "") self.assertIn("node", result.stderr.lower()) self.assertIn("20", result.stderr) self.assertNotIn("Traceback", result.stderr) finally: shutil.rmtree(tmp) def test_node_too_old_error_via_shim(self): tmp, env = node_shim_env('echo "v18.20.0"') try: result = run_script(["convert", str(DOCX)], env=env) self.assertEqual(result.returncode, 1) self.assertEqual(result.stdout, "") self.assertIn("v18.20.0", result.stderr) self.assertIn("20", result.stderr) self.assertNotIn("Traceback", result.stderr) finally: shutil.rmtree(tmp) @unittest.skipUnless(shutil.which("node"), "node not on PATH") def test_npx_missing_error_via_path_with_node(self): tmp = Path(tempfile.mkdtemp()) try: bindir = tmp / "bin" bindir.mkdir() os.symlink(sys.executable, bindir / "python3") node_bin = tmp / "bin2" node_bin.mkdir() os.symlink(Path(shutil.which("node")), node_bin / "node") env = os.environ.copy() env["PATH"] = str(node_bin) + os.pathsep + str(bindir) result = run_script(["convert", str(DOCX)], env=env) self.assertEqual(result.returncode, 1) self.assertEqual(result.stdout, "") self.assertIn("npx", result.stderr) self.assertIn(PINNED, result.stderr) self.assertNotIn("Traceback", result.stderr) finally: shutil.rmtree(tmp) def test_hint_mapping_for_known_error_classes(self): cases = ( ( "anydoc: unsupported input: PDF has no extractable text " "(Scanned, 1 pages): OCR is required", "needs-ocr", ("OCR", "Firecrawl Parse", "not retry"), ), ("anydoc: document is encrypted", "encrypted", ("encrypted", "unencrypted")), ( "anydoc: malformed document: not a readable zip archive: " "invalid Zip archive: Could not find EOCD", "malformed", ("malformed", "corrupt", "zip"), ), ( "anydoc: unsupported input: unrecognized file content and " "extension: unsupported.xyz", "unsupported", ("unsupported", "-f"), ), ( "anydoc: resource limit exceeded (max_entry_bytes): " "word/document.xml declares 201326759 decompressed bytes", "resource-limit", (), ), # Wrapper pre-validation messages map to the "io" class with no hint. ("input file not found: /x/missing.docx", "io", ()), ("input path is a directory, not a file: /x/dir", "io", ()), ) for message, expected_class, keywords in cases: with self.subTest(message=message): error_class, hint = cli.error_class_hint(message) self.assertEqual(error_class, expected_class) if keywords: self.assertIsNotNone(hint) for keyword in keywords: self.assertIn(keyword, hint) def test_build_cli_command_shape(self): self.assertEqual( cli.build_cli_command("report.docx", "out.md", "csv"), ["npx", "-y", PINNED, "report.docx", "-o", "out.md", "-f", "csv"], ) self.assertEqual( cli.build_cli_command("report.docx", None, None), ["npx", "-y", PINNED, "report.docx"], ) # stdin passes through as `-` self.assertEqual( cli.build_cli_command("-", None, "csv"), ["npx", "-y", PINNED, "-", "-f", "csv"], ) # a dash-leading filename places -o/-f BEFORE the `--` separator # (npx forwards `--` to the CLI, so options after it read as inputs) self.assertEqual( cli.build_cli_command("-weird", "o.md", "csv"), ["npx", "-y", PINNED, "-o", "o.md", "-f", "csv", "--", "-weird"], ) self.assertEqual( cli.build_cli_command("-weird", None, None), ["npx", "-y", PINNED, "--", "-weird"], ) @unittest.skipUnless(shutil.which("node") and shutil.which("npx"), "toolchain missing") def test_runtime_errors_empty_when_toolchain_present(self): self.assertEqual(cli.runtime_errors(), []) def test_format_aliases_accepted(self): code, _stdout, stderr = run_in_process( ["convert", str(DOCX), "-f", "docm", "--dry-run"] ) self.assertEqual(code, 0, stderr) # --- CLI timeout: --json must still yield one parseable JSON document --- def _timeout_side_effect(self): """A subprocess.run replacement that raises a TimeoutExpired.""" def _boom(*args, **kwargs): exc = subprocess.TimeoutExpired( cmd=args[0], timeout=cli.RUN_TIMEOUT ) exc.pid = 4242 # set post-construction, as subprocess.run does raise exc return _boom def test_run_cli_timeout_kills_group_and_raises(self): with mock.patch.object( cli.subprocess, "run", side_effect=self._timeout_side_effect() ), mock.patch.object(cli.os, "killpg") as mock_kill: with self.assertRaises(cli.CliTimeoutError): cli.run_cli(["npx", "-y", cli.PINNED, "x.docx"]) mock_kill.assert_called_once_with(4242, signal.SIGKILL) def test_convert_timeout_with_json_emits_error_envelope(self): with mock.patch.object(cli, "runtime_errors", return_value=[]), mock.patch.object( cli.subprocess, "run", side_effect=self._timeout_side_effect() ), mock.patch.object(cli.os, "killpg") as mock_kill: code, stdout, stderr = run_in_process( ["convert", str(DOCX), "--json"] ) self.assertEqual(code, 1) mock_kill.assert_called_once_with(4242, signal.SIGKILL) doc = json.loads(stdout) # exactly one parseable JSON document self.assertFalse(doc["ok"]) self.assertEqual(doc["exit_code"], 1) self.assertEqual(doc["error_class"], "timeout") self.assertIn("did not complete within 120 seconds", doc["error"]) self.assertIn("did not complete within 120 seconds", stderr) self.assertNotIn("Traceback", stderr) def test_convert_timeout_without_json_uses_stderr(self): with mock.patch.object(cli, "runtime_errors", return_value=[]), mock.patch.object( cli.subprocess, "run", side_effect=self._timeout_side_effect() ), mock.patch.object(cli.os, "killpg"): code, stdout, stderr = run_in_process(["convert", str(DOCX)]) self.assertEqual(code, 1) self.assertEqual(stdout, "") self.assertIn("did not complete within 120 seconds", stderr) self.assertNotIn("Traceback", stderr) def test_batch_timeout_with_json_emits_error_envelope(self): with tempfile.TemporaryDirectory() as tmp: out_dir = Path(tmp) / "out" with mock.patch.object( cli, "runtime_errors", return_value=[] ), mock.patch.object( cli.subprocess, "run", side_effect=self._timeout_side_effect() ), mock.patch.object(cli.os, "killpg"): code, stdout, stderr = run_in_process( ["batch", str(DOCX), "--out-dir", str(out_dir), "--json"] ) self.assertEqual(code, 1) doc = json.loads(stdout) self.assertFalse(doc["ok"]) self.assertEqual(doc["command"], "batch") self.assertEqual(doc["error_class"], "timeout") self.assertIn("did not complete within 120 seconds", stderr) self.assertNotIn("Traceback", stderr) class RealCliTests(unittest.TestCase): """End-to-end conversions through the pinned CLI; skip when unavailable.""" skip_reason = None @classmethod def setUpClass(cls): if not shutil.which("npx") or not shutil.which("node"): cls.skip_reason = "npx/node not available" return try: proc = subprocess.run( ["npx", "-y", PINNED, "--version"], capture_output=True, text=True, timeout=120, ) except (OSError, subprocess.TimeoutExpired): cls.skip_reason = "pinned CLI unavailable" return if proc.returncode != 0 or "0.1.6" not in proc.stdout: cls.skip_reason = "pinned CLI unavailable" return cls.skip_reason = None def setUp(self): if self.__class__.skip_reason: self.skipTest(self.__class__.skip_reason) def _parse_ok_json(self, result): """Assert a successful --json run and return its parsed document.""" self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(result.stderr, "") doc = json.loads(result.stdout) self.assertTrue(doc["ok"]) self.assertEqual(doc["exit_code"], 0) return doc def test_convert_to_stdout(self): result = run_script(["convert", str(DOCX)]) self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(result.stderr, "") self.assertIn("# ", result.stdout) self.assertIn("## ", result.stdout) def test_convert_to_file_silent(self): with tempfile.TemporaryDirectory() as tmp: out = Path(tmp) / "out.md" result = run_script(["convert", str(DOCX), "-o", str(out)]) self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(result.stdout, "") self.assertEqual(result.stderr, "") self.assertTrue(out.exists()) content = out.read_text(encoding="utf-8") self.assertIn("## ", content) def test_convert_silently_overwrites_seeded_file(self): with tempfile.TemporaryDirectory() as tmp: out = Path(tmp) / "out.md" out.write_text("SENTINEL\n", encoding="utf-8") result = run_script(["convert", str(DOCX), "-o", str(out)]) self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(result.stdout, "") self.assertEqual(result.stderr, "") content = out.read_text(encoding="utf-8") self.assertNotIn("SENTINEL", content) self.assertIn("## ", content) def test_convert_fresh_cwd_creates_no_stray_files(self): with tempfile.TemporaryDirectory() as tmp: result = run_script(["convert", str(DOCX)], cwd=tmp) self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(result.stderr, "") self.assertFalse((Path(tmp) / "out.md").exists()) self.assertEqual(list(Path(tmp).iterdir()), []) def test_convert_stdin_csv(self): result = run_script( ["convert", "-", "-f", "csv"], input_bytes="a,b\n1,2\n" ) self.assertEqual(result.returncode, 0, result.stderr) self.assertIn("| a | b |", result.stdout) self.assertIn("| 1 | 2 |", result.stdout) def test_convert_empty_stdin_errors_without_hanging(self): result = run_script(["convert", "-"], input_bytes="") self.assertNotEqual(result.returncode, 0) self.assertIn("anydoc", result.stderr) self.assertNotIn("Traceback", result.stderr) def test_convert_extensionless_file_with_fmt_csv(self): with tempfile.TemporaryDirectory() as tmp: data = Path(tmp) / "data" data.write_bytes(CSV.read_bytes()) result = run_script(["convert", str(data), "-f", "csv"]) self.assertEqual(result.returncode, 0, result.stderr) self.assertIn("| Kind | Value | Note |", result.stdout) def test_convert_scanned_pdf_hint(self): result = run_script(["convert", str(SCANNED)]) self.assertEqual(result.returncode, 1) self.assertEqual(result.stdout, "") self.assertIn("OCR", result.stderr) self.assertIn("Firecrawl Parse", result.stderr) self.assertIn("not retry", result.stderr) self.assertNotIn("Traceback", result.stderr) def test_convert_encrypted_hint(self): result = run_script(["convert", str(ENCRYPTED)]) self.assertEqual(result.returncode, 1) self.assertEqual(result.stdout, "") self.assertIn("encrypted", result.stderr) self.assertIn("unencrypted", result.stderr) self.assertNotIn("Traceback", result.stderr) def test_convert_malformed_hint(self): result = run_script(["convert", str(MALFORMED)]) self.assertEqual(result.returncode, 1) self.assertEqual(result.stdout, "") self.assertIn("malformed", result.stderr) self.assertNotIn("Traceback", result.stderr) def test_convert_unsupported_hint(self): result = run_script(["convert", str(UNSUPPORTED)]) self.assertEqual(result.returncode, 1) self.assertEqual(result.stdout, "") self.assertIn("unsupported", result.stderr) self.assertIn("-f", result.stderr) self.assertNotIn("Traceback", result.stderr) def test_convert_json_success_to_file(self): with tempfile.TemporaryDirectory() as tmp: out = Path(tmp) / "out.md" result = run_script( ["convert", str(DOCX), "-o", str(out), "--json"] ) doc = self._parse_ok_json(result) self.assertEqual(doc["output"], str(out)) def test_convert_json_success_embeds_markdown(self): result = run_script(["convert", str(DOCX), "--json"]) doc = self._parse_ok_json(result) self.assertIn("## ", doc["markdown"]) def test_convert_json_failure(self): result = run_script(["convert", str(SCANNED), "--json"]) self.assertEqual(result.returncode, 1) doc = json.loads(result.stdout) self.assertFalse(doc["ok"]) self.assertEqual(doc["exit_code"], 1) self.assertEqual(doc["error_class"], "needs-ocr") self.assertIn("OCR", result.stderr) def test_batch_mixed_continues_past_failures(self): with tempfile.TemporaryDirectory() as tmp: out_dir = Path(tmp) / "out" result = run_script( [ "batch", str(DOCX), str(ENCRYPTED), str(TABLES), "--out-dir", str(out_dir), ] ) self.assertEqual(result.returncode, 1) stdout = result.stdout self.assertIn("ok %s" % DOCX, stdout) self.assertIn("FAIL %s" % ENCRYPTED, stdout) self.assertIn("ok %s" % TABLES, stdout) self.assertIn("summary: 3 total, 2 succeeded, 1 failed", stdout) self.assertTrue((out_dir / "fixture-handmade-outline.md").exists()) self.assertTrue((out_dir / "fixture-handmade-tables.md").exists()) self.assertFalse((out_dir / "encrypted--errors.md").exists()) self.assertIn("encrypted", result.stderr) self.assertIn("hint", result.stderr) def test_batch_all_valid_exits_zero(self): with tempfile.TemporaryDirectory() as tmp: out_dir = Path(tmp) / "out" result = run_script( [ "batch", str(DOCX), str(CSV), "--out-dir", str(out_dir), ] ) self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(result.stderr, "") self.assertIn("summary: 2 total, 2 succeeded, 0 failed", result.stdout) self.assertTrue((out_dir / "fixture-handmade-outline.md").exists()) self.assertTrue((out_dir / "fixture-sheet.md").exists()) def test_batch_json_all_valid(self): with tempfile.TemporaryDirectory() as tmp: result = run_script( [ "batch", str(DOCX), str(CSV), "--out-dir", str(Path(tmp) / "out"), "--json", ] ) doc = self._parse_ok_json(result) self.assertEqual(doc["summary"], {"total": 2, "succeeded": 2, "failed": 0}) self.assertEqual([f["status"] for f in doc["files"]], ["ok", "ok"]) def test_batch_json_mixed_keeps_stdout_parseable(self): with tempfile.TemporaryDirectory() as tmp: result = run_script( [ "batch", str(DOCX), str(ENCRYPTED), "--out-dir", str(Path(tmp) / "out"), "--json", ] ) self.assertEqual(result.returncode, 1) doc = json.loads(result.stdout) self.assertFalse(doc["ok"]) self.assertEqual(doc["exit_code"], 1) self.assertEqual(doc["summary"], {"total": 2, "succeeded": 1, "failed": 1}) self.assertIn("encrypted", result.stderr) def test_batch_json_failure_entries_share_error_class_shape(self): with tempfile.TemporaryDirectory() as tmp: missing = Path(tmp) / "missing.docx" result = run_script( [ "batch", str(ENCRYPTED), str(missing), "--out-dir", str(Path(tmp) / "out"), "--json", ] ) self.assertEqual(result.returncode, 1) doc = json.loads(result.stdout) by_input = {entry["input"]: entry for entry in doc["files"]} cli_fail = by_input[str(ENCRYPTED)] pre_fail = by_input[str(missing)] self.assertEqual(cli_fail["status"], "failed") self.assertEqual(cli_fail["error_class"], "encrypted") self.assertEqual(pre_fail["status"], "failed") self.assertEqual(pre_fail["error_class"], "io") self.assertEqual( set(cli_fail.keys()), set(pre_fail.keys()), "all batch failure entries must share the same shape", ) def test_convert_dash_leading_filename(self): with tempfile.TemporaryDirectory() as tmp: (Path(tmp) / "-weird").write_bytes(CSV.read_bytes()) result = run_script( ["convert", "-f", "csv", "--", "-weird"], cwd=tmp, ) self.assertEqual(result.returncode, 0, result.stderr) self.assertIn("| Kind | Value | Note |", result.stdout) def test_batch_duplicates_convert_per_occurrence(self): with tempfile.TemporaryDirectory() as tmp: out_dir = Path(tmp) / "out" result = run_script( ["batch", str(DOCX), str(DOCX), "--out-dir", str(out_dir)] ) self.assertEqual(result.returncode, 0, result.stderr) self.assertIn("summary: 2 total, 2 succeeded, 0 failed", result.stdout) self.assertEqual( sorted(p.name for p in out_dir.iterdir()), ["fixture-handmade-outline.md"] ) def test_batch_same_basename_collision_last_wins(self): with tempfile.TemporaryDirectory() as tmp: a_dir = Path(tmp) / "a" b_dir = Path(tmp) / "b" a_dir.mkdir() b_dir.mkdir() (a_dir / "same.docx").write_bytes(DOCX.read_bytes()) (b_dir / "same.docx").write_bytes(TABLES.read_bytes()) out_dir = Path(tmp) / "out" result = run_script( [ "batch", str(a_dir / "same.docx"), str(b_dir / "same.docx"), "--out-dir", str(out_dir), ] ) self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual( sorted(p.name for p in out_dir.iterdir()), ["same.md"] ) content = (out_dir / "same.md").read_text(encoding="utf-8") self.assertIn("| Head A | Head B | Head C |", content) if __name__ == "__main__": unittest.main()
-
-
README.md 4.7 KB
# anydoc — office documents to GitHub-Flavored Markdown Convert Word, PowerPoint, Excel, OpenDocument, RTF, EPUB, CSV, and PDF files into clean, LLM-friendly GitHub-Flavored Markdown. Local conversion stays on your machine; an explicitly authorized hosted OCR mode handles scanned PDFs through Firecrawl Parse when whole-document upload is acceptable. ## Why Install This Skill Office documents are opaque to agents. A `.docx` or `.pptx` is a binary zip; a `.xls` is an OLE container; a PDF can be anything. Reading them directly means parsing formats, handling encodings, and reconstructing structure by hand — exactly the work anydoc automates. This skill gives your agent a single, verified command that converts all 8 format families (21 extensions) into GitHub-Flavored Markdown with headings, GFM tables, slide structure, and footnotes preserved, plus the knowledge of exactly where fidelity is lost (Excel number formats, legacy PowerPoint tables, PDF tables). The skill wraps the pinned `@firecrawl/anydoc` v0.2.4 CLI with a small helper script that adds input checks, friendly error hints for the known failure classes (scanned PDFs, encrypted files, malformed archives), batch conversion, dry-run planning, JSON output, and an explicit `--allow-hosted-upload` acknowledgement for hosted OCR. ## What You Get | Directory / file | What it provides | | --- | --- | | `SKILL.md` + `README.md` | The skill index (trigger, command map, verification steps) and this human-facing guide | | `scripts/` | `anydoc` — an executable Python 3 wrapper with `convert` (single file or stdin, `-o` output), `batch` (many files, per-file status, summary), and `info` (tool + pinned CLI version), plus global `--json` and `--dry-run` | | `references/` | Five focused guides: `formats.md` (what GFM each format produces, with fidelity caveats), `cli-reference.md` (verbatim `--help`, every flag, stdout/stderr conventions), `errors.md` (exit codes and the exact error messages), `workflows.md` (recipes: single conversion, batch, vault ingestion, piping, output verification), `sources.md` (upstream URLs, fixture provenance, verification procedure) | | `tests/` | Unit tests for the wrapper (argparse, pre-validation, hints, dry-run, JSON, batch) — runnable offline | | `evals/` | An eval manifest with fixture-backed cases covering docx→headings, xlsx→tables, pptx→slide structure, csv→table, legacy `.doc`, ODS preserved values, ODT, and the image-only-PDF OCR failure | | `fixtures/` | 24 tiny sample documents (all < 5 MB): valid samples for every family plus error cases (image-only PDF, encrypted ODT, empty DOCX, unsupported extension) — used by the tests, evals, and recipes | ## Quick Start You need Node.js 20+ and `npx` (no other install — the CLI and its native binary are fetched on first use): ```bash cd anydoc npx -y @firecrawl/anydoc@0.2.4 fixtures/fixture-handmade-outline.docx ``` This converts the sample Word document and prints GitHub-Flavored Markdown to stdout (note the `#`/`##`/`###` heading lines). To write to a file instead: ```bash npx -y @firecrawl/anydoc@0.2.4 fixtures/fixture-handmade-outline.docx -o outline.md ``` Or use the wrapper for the same job: ```bash python3 scripts/anydoc convert fixtures/fixture-handmade-outline.docx -o outline.md ``` ## Triggers Load this skill when the task involves any of these: - "Convert this Word/Excel/PowerPoint/PDF/EPUB/CSV file to markdown" - "Extract the headings, tables, or slide content from this document" - "Summarize this report / spreadsheet / deck" - "Turn this CSV into a markdown table" - "Read this document into markdown for a knowledge base or vault" - "Convert this PDF to markdown" — but only for text-based PDFs; scanned or image-only PDFs fail (anydoc does not OCR) - "OCR this scanned PDF" — use local OCR by default, or explicitly authorize `--ocr hosted --allow-hosted-upload` when sending the whole document to Firecrawl Parse is acceptable Do **not** load this skill for document generation or editing ("create a docx report", "build a PDF proposal", "validate this document") — that is the `documents` skill's job — or for EPUB authoring (`epub` skill). ## Requirements - **Node.js >= 20** and `npx` (the CLI is distributed via npm; the native binary ships as a platform-specific npm `optionalDependency`, so there is no manual install or compilation). - **Network once** — the first `npx` run downloads the package and binary; later runs use the npm cache. For permanent or fully offline use, run `npm install -g @firecrawl/anydoc` once. - **Python 3** (standard library only) if you use the `scripts/anydoc` wrapper. - **Local mode needs no API key or service**. Hosted OCR uses Firecrawl Parse and may use `FIRECRAWL_API_KEY`; it sends the whole OCR-required PDF and has no page selection. -
SKILL.md 14.8 KB
--- name: anydoc description: >- Convert Word (.doc/.docx/.docm), PowerPoint (.ppt/.pps/.pot/.pptx/.pptm/.ppsx/.ppsm), Excel (.xls/.xlsx/.xlsm/.xlsb), OpenDocument (.odt/.ods/.odp), RTF, EPUB, CSV, and PDF documents to clean GitHub-Flavored Markdown locally with the Any Doc CLI (npx -y @firecrawl/anydoc@0.2.4): headings, GFM tables, slide structure, and footnotes in one pass. Use when a task needs the contents of an office document, spreadsheet, presentation, ebook, or PDF you cannot read directly. Do not use for generating, editing, or validating documents (use documents), for ebook packaging (use epub). For scanned or image-only PDFs, use hosted OCR only when the user explicitly authorizes whole-document upload; otherwise route to local OCR tooling. license: MIT compatibility: >- Node.js >= 20 and npx. The pinned CLI is @firecrawl/anydoc@0.2.4; the native binary ships via npm optionalDependencies (no install step, no postinstall, no compilation). Local conversion needs no service or API key. Hosted OCR sends the whole PDF to Firecrawl Parse and may use FIRECRAWL_API_KEY. The first npx run downloads the package once (network required); later runs use the npm cache. metadata: skills: anydoc, markdown, conversion, docx, xlsx, pptx, pdf, odt, ods, odp, rtf, epub, csv, office, documents, firecrawl tags: conversion, markdown, office, documents source: https://github.com/firecrawl/anydoc allowed-tools: Bash Read --- # Any Doc — office documents to GitHub-Flavored Markdown The `anydoc` skill converts office documents, spreadsheets, presentations, ebooks, CSV, and text-based PDFs into GitHub-Flavored Markdown using the pinned Any Doc CLI (`@firecrawl/anydoc` v0.2.4). One shared document model and one GFM serializer produce the same logical output across formats. Local conversion runs without a service, API key, or file upload; hosted OCR is a separate explicit route. ## Overview Load this skill when a task needs the *contents* of a document the agent cannot read directly: a Word report to summarize, a spreadsheet to turn into a table, a slide deck to extract, a CSV to analyze, or an ebook or PDF to quote from. The skill ships a small Python helper (`scripts/anydoc`) that wraps the pinned CLI and adds input pre-validation, friendly error hints, batch conversion, and `--dry-run`/`--json` output. Every recipe in [references/workflows.md](references/workflows.md) also shows the raw `npx` invocation, so the skill works with or without the helper. ## First-use decision gate Before invoking anydoc, classify the request: | If the user needs... | Do this | | --- | --- | | The contents of an existing supported document | Continue to [Command Map](#command-map). | | Generation, editing, validation, EPUB packaging, HTML scraping, or password decryption | Stop and use the route in [When not to use](#when-not-to-use). | | A format-fidelity or failure decision | Load the matching row in [Reference Routing](#reference-routing) before choosing a command. | | A conversion result | Choose stdout, `-o`, or batch; run it; then follow [Verification](#verification). | > **Hard boundary:** local anydoc conversion reads existing supported documents to Markdown without uploading them. Hosted OCR is opt-in only: it sends the whole OCR-required PDF to the configured Parse service. AnyDoc does not create, edit, validate, package, decrypt, or scrape documents. ## When to use - **Convert a document to markdown** — Word, PowerPoint, Excel, OpenDocument, RTF, EPUB, CSV, or text-based PDF. - **Extract structure** — headings, GFM tables, slide titles, speaker notes (as blockquotes), and footnotes. - **Feed documents to an LLM** — one-pass conversion to clean markdown for summarization, extraction, or retrieval ingestion. - **Batch a folder** — convert a directory of mixed office files for a vault or knowledge base. - **Read a document from stdin** — pipe bytes into `anydoc -`. ## Format coverage (summary) anydoc covers **8 format families / 21 extensions** through **12 canonical parsers**. The canonical formats are `doc, docx, odt, pdf, ppt, pptx, rtf, epub, xlsx, ods, odp, csv`; extension aliases map through them (`.docm`→docx, `.xls`→xlsx, `.pptm`→pptx, and so on). | Family | Extensions | Expected GFM output | Decision cue | | --- | --- | --- | --- | | Word | `.doc` `.docx` `.docm` | `#`–`######` headings, GFM tables, `[^n]` footnotes | Use when content extraction is enough; use `documents` when rendered layout matters. | | PowerPoint | `.ppt` `.pps` `.pot` `.pptx` `.pptm` `.ppsx` `.ppsm` | slide titles as plain paragraphs, bullet lists, speaker notes as `>` blockquotes, GFM tables (PPTX/ODP; legacy `.ppt` flattens tables to text lines) | Need table fidelity? Prefer PPTX or ODP; legacy `.ppt` preserves cell text but not table structure. | | Excel | `.xls` `.xlsx` `.xlsm` `.xlsb` | `## <sheet name>` heading + one GFM table per worksheet; number formats dropped (raw cell values) | Need displayed percentages, currency, or number formats? Prefer ODS; XLS/XLSX output is raw values. | | OpenDocument | `.odt` `.ods` `.odp` | same document/slide shapes as DOCX/PPTX; ODS keeps formatted display values | Prefer ODS when spreadsheet display formatting is part of the meaning. | | Rich Text Format | `.rtf` | same document shape as DOCX/ODT | Use for text extraction, not layout preservation. | | EPUB | `.epub` | `#` chapter headings, GFM tables, internal anchor links | Use to read an existing EPUB; use `epub` to author or package one. | | CSV | `.csv` | one GFM table; label-like first row promoted to header; delimiter sniffing; UTF-16 with BOM | Use for delimited tabular content; inspect delimiter and encoding when output looks wrong. | | PDF | `.pdf` | headings + inline emphasis, but a lower-fidelity pipeline: tables flatten to text, footnotes and links degrade. Text-based PDFs stay local; scanned/image-only PDFs require explicit hosted OCR or another OCR tool | Use local mode by default; hosted mode uploads the whole PDF and has no page selection. | See [references/formats.md](references/formats.md) for the full per-format expectations and fidelity caveats, and [references/errors.md](references/errors.md) for the exact failure messages (including the no-OCR error). ## Command Map Commands are shown relative to the repository root. `<file>` is any document path (for example `anydoc/fixtures/fixture-handmade-outline.docx`); `-` reads the document from stdin. | Need | Command | Choose it when | | --- | --- | --- | | Convert one file to small markdown on stdout | `anydoc/scripts/anydoc convert <file>` | The caller needs immediate content and does not need a saved artifact. | | Convert one file to a markdown file | `anydoc/scripts/anydoc convert <file> -o out.md` | The output is large, must be reviewed later, or should be preserved as an artifact. | | Convert many files to a directory | `anydoc/scripts/anydoc batch <file1> <file2> ... --out-dir out/` | The request is a bounded batch and per-file output/status is useful. | | Show the tool and pinned CLI version | `anydoc/scripts/anydoc info` | You need to confirm the executable and version before troubleshooting or reporting an environment issue. | | Raw pinned CLI, one document | `npx -y @firecrawl/anydoc@0.2.4 <file> [-o out.md]` | The wrapper is unavailable; preserve the pinned CLI and its documented semantics. | | Raw pinned CLI, read stdin | `cat data.csv \| npx -y @firecrawl/anydoc@0.2.4 - --format csv` | Bytes already arrive on stdin and the format is known; keep the producer pipeline separate from the converter. | For an OCR-required PDF, first use the local default so the failure is visible: ```bash anydoc/scripts/anydoc convert scan.pdf --ocr reject ``` If the user explicitly authorizes sending the complete PDF to Firecrawl Parse, use the wrapper acknowledgement and a trusted `FIRECRAWL_API_KEY` environment variable when needed: ```bash anydoc/scripts/anydoc convert scan.pdf --ocr hosted --allow-hosted-upload ``` The wrapper never places the key on the command line. Hosted OCR has no page selection, and a hosted failure is not permission to silently switch endpoints. Notes: - `scripts/anydoc` is an executable Python 3 script (shebang `#!/usr/bin/env python3`); `python3 anydoc/scripts/anydoc ...` is equivalent when the executable bit is unavailable. - The raw `npx -y @firecrawl/anydoc@0.2.4` rows are the ground truth for conversion behavior; the wrapper delegates to exactly that command. - Always pin `@0.2.4` for reproducible conversions. `-y` answers npx's "Ok to proceed?" prompt non-interactively — the CLI itself never prompts. - Both forms share the same contract: one document per invocation, exit code `0` success / `1` conversion or IO failure / `2` usage error, diagnostics as exactly one `anydoc: <message>` line on stderr, and no prompts. Hosted OCR is supported by the 0.2.4 library and CLI, but the wrapper requires both `--ocr hosted` and `--allow-hosted-upload` so an upload cannot be selected implicitly. The hosted route sends the complete PDF to Firecrawl Parse because page selection is unavailable. Do not place API keys on the command line. ## Reference Routing Load only the row that answers the immediate question; the command examples and verification contract remain in this file. | When you need to... | Load | It answers | | --- | --- | --- | | Choose a format or predict fidelity | [references/formats.md](references/formats.md) | Supported families, output shapes, and caveats such as raw XLSX values, ODS display values, legacy `.ppt` table flattening, and PDF degradation. | | Select flags, stdin syntax, output behavior, or version details | [references/cli-reference.md](references/cli-reference.md) | Verbatim help, accepted options, stdin rules, stdout/stderr behavior, pinning, and runtime requirements. | | Classify a failure or decide whether to retry | [references/errors.md](references/errors.md) | Exit codes, exact error vocabulary, no-OCR/encryption boundaries, and the next route. | | Choose a single-file, stdin, batch, vault, or large-output recipe | [references/workflows.md](references/workflows.md) | End-to-end recipes, safe output handling, per-file failure routing, and resource-limit behavior. | | Verify a documented upstream or fixture claim | [references/sources.md](references/sources.md) | Source URLs, access dates, fixture provenance, and the verification basis for documented claims. | | Shape the final evidence report | [references/report-examples.md](references/report-examples.md) | Complete success, expected-failure, and fidelity-boundary reports to imitate after following Verification. | ## When not to use Use this routing table before reaching for a conversion command: | User's request | Reach for | Why | | --- | --- | --- | | Generate, edit, inspect rendered layout, or validate a PDF/Word/Excel/PowerPoint artifact | `documents` skill | anydoc extracts existing document contents to Markdown; it does not author, preserve rendered layout, or validate artifacts. | | Package or author an EPUB | `epub` skill | anydoc reads an existing EPUB to Markdown but never writes or validates an EPUB container. | | OCR a scanned or image-only PDF | Local OCR tooling, or AnyDoc hosted OCR after explicit authorization | Local mode reports the OCR-required error without uploading; hosted mode sends the whole PDF to Firecrawl Parse. | | Scrape HTML or other web content | A web-scraping skill | HTML is not a supported anydoc input. | | Transcribe binary media such as images, video, or audio | A media or transcription tool | Embedded images become alt text; anydoc cannot transcribe media. | | Preserve pagination, fonts, templates, or rendered layout | A document/layout tool | The only output contract is GitHub-Flavored Markdown. | | Convert a password-protected file | An unencrypted copy from the document owner | anydoc has no password or decryption option. | ## Verification **Report evidence, not just success.** For every attempted conversion, return the input, exact command or wrapper path, observed exit code, output destination (stdout or file), structural markers checked, and any documented caveat or next route. **Compact report shape:** ```text Input: <path or stdin source> Command: <exact wrapper or pinned CLI path> Exit: <observed code> Output: <stdout or destination file> Checks: <markers or fidelity facts observed> Caveat/route: <documented limitation or next action> ``` ### Common stop conditions | Condition | Do not | Next | | --- | --- | --- | | Scanned or image-only PDF / OCR-required error | Retry unchanged or upload implicitly | Use local OCR, or explicitly authorize and run `--ocr hosted --allow-hosted-upload`; page selection is unavailable. | | Encrypted or password-protected document | Guess a password or retry unchanged | Request an unencrypted copy or owner-authorized re-export. | | Unsupported, malformed, or resource-limit error | Guess a parser or claim partial success | Match the exact error in [references/errors.md](references/errors.md) and follow its bounded route. | | Exit 0 but expected structural markers are absent | Report success from the exit code alone | Inspect the output shape and source fidelity before reporting completion. | Confirm a conversion before reporting it as done: 1. **Check the exit code.** `0` means the CLI produced markdown. `1` means the document could not be read or converted — read the single `anydoc: <message>` stderr line and match it against [references/errors.md](references/errors.md). `2` means the command itself was a usage error (bad flag, missing input, invalid `--format`). 2. **Check the output shape.** The markdown must contain the structural markers your format actually produces: - Word / ODT / RTF / text-based PDF: `#`/`##` headings. For PDF, do not expect GFM tables or `[^1]:` footnote definitions — that pipeline flattens them. - Spreadsheets (xlsx/xls/ods) and CSV: `|`-delimited GFM tables. xlsx/xls show raw cell values (`0.155`, `1234.5`); ODS shows formatted display values (`15.5%`, `$1,234.50`). - Presentations (pptx/odp): slide titles as plain paragraphs, `>` blockquote speaker notes, GFM tables. Legacy `.ppt` flattens tables to bare text lines. - EPUB: `#` chapter headings and internal anchor links. 3. **Write large outputs to a file with `-o`.** `-o out.md` keeps stdout silent and gives a reviewable file instead of streaming the whole document into context. 4. **Verify tables survived.** If the source had tables and the output has no `|` rows, consult the format caveats — PDF and legacy `.ppt` flatten tables by design, not by error. **Stop when** the conversion exits 0 and the structural markers match the source format. Do not re-run or retry on a documented failure mode (encrypted, malformed, scanned/image-only, unsupported) without changing the input; report the documented message and route as [references/errors.md](references/errors.md) instructs.
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.