Sprag

Convert pptx, xlsx, pdf, docx and fig to Markdown before Claude Code reads them

sprag doc2md converts PowerPoint, Excel, PDF, Word and Figma files to Markdown so Claude Code reads a compact text version instead of a raw attachment. One 510k-token deck became a few thousand tokens.

πŸ“„ doc2md β€” documents become Markdown before the model reads them

Planning documents mostly arrive as pptx, xlsx, pdf, docx or fig, and handled as they are, one of two things happens. Claude Code refuses the file as binary and the model reads nothing, or the model unpacks the archive and burns tokens on the body XML. One 30MB deck is 540,000 tokens of XML, which does not even fit in a 200k context window. A PDF attached whole costs 2,500–2,900 tokens a page, and Read takes a .fig as raw bytes that fill the context with noise. The savings section below measures each case.

doc2md converts the file once and hands over the Markdown instead. That same deck becomes 22,610 tokens.

It turns on at install. sprag install registers the hooks (since v3.32.0; set SPRAG_NO_DOC2MD=1 to skip it, or run sprag doc2md off later). The converter is not in the install: it installs itself the first time a document shows up, as described below. Until it is there, the hooks have nothing to convert with and do nothing.

Three situations, three different interception points:

SituationWhere it is caught
A document path typed in the prompt (@path, quoted, or relative)UserPromptSubmit: converted, and the conversion's path is handed back as context
A document opened with Read mid-taskPDFs and .fig files are caught by PreToolUse(Read). pptx/xlsx/docx are not: Claude Code refuses them as binary before any hook runs, so the session-start note tells the model to run doc2md <path> instead
A document attached to the messageNot catchable. No hook event receives attachment content. The session-start note has the model ask for a path next time

That second row is measured, not assumed: a .pdf Read fires the hook, and a .pptx Read in the same session leaves no hook log entry at all. A .fig is not refused by Read (see the Figma section below), so it reaches the hook the way a PDF does.

sprag doc2md on                  # register the hooks again after `doc2md off` (the converter installs itself)
sprag doc2md                     # check converter + hook registration
sprag doc2md report.pptx         # convert by hand and see the result
sprag doc2md install-converter   # only to get the install out of the way early

The converter installs itself. Any rollout step a person has to be told about is a step some of them skip, so the converter installs in the background the moment a document first shows up, and converts as soon as it is ready. Measured: about 30s for the first document (15s install plus markitdown's first import), then 3.7s for a new document and 0.1s on a cache hit. The .fig parser installs in half a second on the first Figma file.

It installs on first use rather than at install time: the venv is 47MB, and someone who never opens a document should not pay for it. Set SPRAG_DOC2MD_NO_AUTOINSTALL=1 to turn the automatic install off.

Python 3.10+ is required β€” markitdown's own floor, and macOS still ships 3.9 as /usr/bin/python3. The venv is built on an interpreter chosen by version rather than by PATH order. Built on 3.9, pip resolves markitdown to a 2019 placeholder release (0.0.1a1): the install looks like it worked and every conversion then dies at import. This was found by walking into it. When nothing on the machine is new enough, the message points at brew install python instead of at an install command that cannot succeed.

The converter goes into a venv this tool owns (<state dir>/doc2md-venv): no system interpreter is touched, and uninstalling the CLI takes it along. An existing markitdown on uv tool or PATH is preferred over building a new one.

Conversion is markitdown. Slide numbers, heading levels, tables, speaker notes and per-sheet headings all survive, and non-Latin text comes through intact.

Several things it deliberately does not do:

What a conversion saves

Every conversion is stamped with a provenance header: which original, when, how many tokens. Savings show up on the statusline's own πŸ“„ Doc2md saved line.

The baseline is what you would have done without a converter, and that differs by format. Both were measured on 2026-09-06.

PDF is priced against attaching it. The same one-line prompt was sent through claude --print --input-format stream-json with and without the file as a document block. The control turn cost 42,204 tokens, twice, to the token.

Attached fileSizeExtra tokensPer page
Report PDF7 pages+20,5372,934
Report PDF5 pages+12,7092,542

An attached PDF is read whole, but every page costs 2,500–2,900 tokens against 5,531 for the conversion. The coefficient used is 2,500 per page β€” below both measurements, so the figure understates rather than flatters.

pptx/xlsx/docx are priced against unpacking the container. These never reach the model as attachments at all: the same probe on a docx added 78 tokens and the model replied that it had no file, and Read refuses the format outright. What you actually do without a converter is unzip the archive and read its XML, where tags and style attributes outweigh the words.

OriginalBody XMLConversionRatio
Deck, pptx (31.8MB)~540,429 tokens~22,610 tokens23.8Γ—
Business plan, docx (189KB)~79,621 tokens~1,684 tokens47.3Γ—

A single 30MB deck is 540,000 tokens as XML, which does not fit in a 200k context window. This baseline is measured per file from the real XML size, not applied as a per-format ratio. .xls is not a zip container and has no markup to measure, so it claims nothing.

If client behaviour changes, re-measure the attachment side with scripts/doc2md-baseline.mjs and swap only the values in the ATTACHMENT_BASELINE table in src/doc2md-ledger.cjs.

Figma .fig converts too

Planning documents are moving from PowerPoint to Figma, so the same hook catches .fig. A .fig is a zip, but the canvas.fig inside it is Figma's private binary (kiwi format), which markitdown cannot open β€” so this one format is converted in Node with openfig-core (MIT). doc2md install-converter places it beside markitdown in the tool's state directory; the package itself still ships zero dependencies.

The result is an outline: pages and frames become headings, text nodes become body lines, and shapes are counted rather than listed β€” in a planning document the words are the content, and two hundred Rectangle 173 lines would drown them. A file with no text at all is refused rather than dressed up as an empty document.

Verified against real files: a community Bootstrap UI kit (8.1MB, 4,155 nodes, 1,312 of them text) and a 52MB Tailwind kit, each converting in 0.2s into a 71.9KB and a 44KB outline, with Korean text surviving the round trip losslessly. Both .fig vintages parse β€” the current zip container and the older bare fig-kiwi stream.

.fig saves the most of any format. Unlike the Office containers, Read does not refuse a .fig: the extension means nothing to it, so it pulls the binary in as text and the context window fills with tokenised noise. Measured against the same 42,760-token control:

FileSizeExtra tokens for a ReadConversion
plan.fig26KB+44,195100 tokens
bootstrap-kit.fig8.1MB+43,99418,397 tokens

Two files three hundred times apart in size cost the same, because Read truncates long before the file ends β€” you pay for a whole document and receive a fraction of one. The baseline is therefore a flat 44,000 tokens. For comparison, the same probe on a pptx cost +317 tokens and on a docx +185: a refusal message, and nothing else.

Why the baseline does not scale with file size

A baseline has to be what would actually have been spent without the converter. Intuition says a bigger file burns more, but the Read tool has a cap (2,000 lines by default, plus a per-line character limit), and a binary file hits it almost immediately: even the 26KB file was already truncated, which is why two files 300Γ— apart came out 201 tokens apart. Had the 8.1MB file gone in whole it would have been millions of tokens β€” money nobody could have spent, since it does not fit in a 200k context window. Claiming to have saved unspendable money is flattery, not measurement.

The same principle runs through every baseline here:

The common rule: wherever an estimate and a measurement diverge, the lower number wins. A figure the user can trust is worth more than one that flatters the tool.

Editing a document: copy, then script

Conversion is one-way β€” editing the cached .md changes nothing in the source. The hook refuses Edit/Write on both the cache and the original binary, and points at the right path instead: copy the original, edit the copy with a script, re-convert the copy to verify.

install-converter puts the editing libraries (python-pptx, python-docx, openpyxl) in the same venv, so a structural request like "swap the chart on slide 23 for a line chart" is a short script the agent writes on the spot. .fig edits go through openfig-core, which encodes as well as parses.

All four formats were exercised end to end on 2026-09-06: 10 docx run replacements plus three consecutive re-saves, a pptx bar-to-line chart swap with an added data point, xlsx value edits and a new row, and a fig text edit with re-encode and re-parse. In every case the original was byte-identical afterwards and the re-converted copy showed the change. One caveat: removing a chart shape from a pptx leaves the old chart XML part orphaned β€” PowerPoint ignores it, but delete the part and its rels for a clean file. Charts and images never appear in a conversion, so visual edits must be confirmed in the application itself.

DRM-wrapped documents

Encryption and DRM are detected differently but handled the same way. Enterprise DRM (Fasoo, MarkAny, SoftCamp and the like) does not password a document β€” it wraps the whole file, and only processes the vendor's agent has whitelisted ever see plaintext. Python is not one of them, so what sits on disk is ciphertext behind a vendor header, and no password will open it.

The first bytes decide which story to tell: a zip header means a truncated download, an OLE container means a password, and neither means the file is not that format at all.

βœ— bad-archive: File is not a zip file            β†’ download it again
βœ— encrypted: password-protected Office file      β†’ tell the user it cannot be read, and stop
βœ— drm-protected: DRM-wrapped file (FASOO)        β†’ tell the user it cannot be read, and stop

Vendor names are matched only as a hint in the diagnostic output; the classification stands without recognising the vendor. PDFs are judged the same way through their public DRM security-handler names (FOPN_foweb, EBX_HANDLER, Adobe.APS).

Locked documents, and Windows

A password-protected document is a state, not an error. Office encrypts by wrapping the package in an OLE compound file rather than a zip, so opening one as a zip used to report "not a zip file" β€” which reads as a broken download and sends the user after the wrong problem. It is now identified before conversion:

βœ— encrypted: password-protected Office file (OLE-wrapped)
βœ— encrypted: password-protected PDF

The model is told to report that the contents cannot be read and not to work around it, and the original Read is refused. Company encryption is policy, so a document that will not open is the right outcome. This tool never asks for or stores a password. Claude Code refuses pptx, xlsx and docx as binary files before any settings hook runs, so the hook's notice cannot reach the model for those; the mod delivers it instead. A PDF that merely restricts printing still opens and still converts β€” checked against a false positive β€” and a legacy .xls, which is an OLE file by design, is not mistaken for an encrypted one.

Windows is supported, and verified on a real Windows runner. For teams with Windows machines:

sprag doc2md --clean empties the conversion cache; doc2md off removes the hook. Removal filters for this tool's own entry, so anything else you registered under PreToolUse stays.

More docs