Claude Skill

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):

LLM Mart · 0 points · 10 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download magnus919-agent-skills-anydoc-addad86.zip · 224 KB
Part of magnus919/agent-skills — 145 skills

Install

skills CLI npx skills add https://github.com/magnus919/agent-skills/tree/main/anydoc
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install magnus919-agent-skills@llmmart
Git 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-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 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 .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/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 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:

  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. 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 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.

No comments yet.

Reviews (0)

No reviews yet.

Related