Consumer SDK
Convert files without an account.
convilyn.local runs on your machine. No API key, no upload, no network call, no credits — and no request to us that could fail.
Convert a file with no account
from convilyn import local
result = local.convert("quarterly-report.docx", to="md")
print(result.output) # quarterly-report.md
print(result.warnings) # anything the extractor had to guessOr from the shell:
$ convilyn local convert quarterly-report.docx --to md
✓ quarterly-report.md (18.2 KiB, 0.4s)Structure survives the trip. Headings stay headings, lists stay lists, tables
become GitHub-Flavoured Markdown tables, and embedded images are written to an
assets/ directory beside the Markdown so their links resolve.
A 14-page report with two tables comes out as quarterly-report.md plus
assets/:
## Revenue by region
| Region | Q1 | Q2 |
| ------ | ----- | ----- |
| EMEA | 1,204 | 1,388 |
| APAC | 903 | 1,051 |
That is the same table a naive text extraction returns as
Region Q1 Q2 EMEA 1,204 1,388 APAC 903 1,051 — one run-on line with the
column structure gone.
Ask before you convert
Whether a conversion is possible depends on what is installed on this
machine, so the SDK lets you ask rather than guess. capabilities() and
plan() never raise; an unavailable route is an answer, not an exception.
route = local.capabilities().can("odt", "md")
if route and not route.available:
print(route.unavailable_reason)
# Converting odt needs LibreOffice, which was not found on PATH or in the
# standard install location for this platform. Install LibreOffice from
# https://www.libreoffice.org/download/ (provides `soffice`). Formats that
# need no external program: csv, docx, pdf, pptx, txt, xlsx, xml.The route set is the same on every machine — only the availability flags move. That is deliberate: it means two machines' capability tables can be diffed, and a support question is answered by pasting one command's output rather than by interviewing the user about their environment.
Best practice. Gate your UI on route.available, and print
route.unavailable_reason verbatim rather than composing your own message. It
already names what is missing and the exact command that fixes it; a message you
write yourself will drift from the one the SDK would have given.
Three ways a conversion can be unavailable
They look alike and they are not. Telling a user to install something that cannot help is worse than telling them the limit.
| What happened | How you see it | What to do |
|---|---|---|
| A package it needs is not installed | route.missing is non-empty; convert() raises MissingDependencyError | Show unavailable_reason — it carries the install command |
| Something optional is absent | route.degraded_by is non-empty; the conversion still runs | Nothing, or warn. A picture repeated on 40 pages is stored 40 times |
| This machine can read the format but not write it | available is False with missing empty; raises UnsupportedRouteError | Offer a different target — no install changes this |
The third row is why convert() checks the route before it opens your file:
failing on the plan is cheap and legible, failing mid-decode is neither.
Feed a model fewer tokens for the same document
This is the practical reason to convert locally before an AI step, and it costs nothing to try.
- Convert to Markdown first, on your machine. What reaches the model is headings, lists and real tables — not a raw binary and not HTML-derived text with the structure flattened out. Zero API calls, zero credits.
- Repeated images are stored once. A logo on every page is one asset, not forty — first occurrence wins.
- Then constrain the output shape with structured understanding, so you are not paying for retry loops to get parseable output.
How much this saves depends entirely on your document. Convert one of yours and count — that number is worth more than any figure published here.
One file, or five hundred
The two entry points differ in how they report failure, and the difference is intentional.
results = local.convert_many(
Path("invoices").glob("*.pdf"),
to="md",
out_dir="build/",
on_progress=lambda e: print(f"{e.index}/{e.total} {e.source.name}"),
)
failed = [r for r in results if not r.ok]
print(f"{len(results) - len(failed)} converted, {len(failed)} failed")
for r in failed:
print(r.source, r.error.message)convert() raises, because a one-shot call that quietly returns a failure
is easy to ignore. convert_many() returns failures as results, because a
500-file batch that stops on file three is not a batch. Pass
raise_on_error=True if you want the strict behaviour.
Best practice. Do not loop over convert() to build a batch — you lose
progress reporting and you stop on the first bad file. A batch whose inputs
would collide on one output name is refused before anything is written.
Why these functions are synchronous
The rest of the SDK is async because it is IO-bound over HTTP. Conversion is CPU- and subprocess-bound, which inverts the argument: threads do not make a LibreOffice subprocess finish sooner.
aconvert() and aconvert_many() exist so a call does not block your event
loop. They run the same synchronous implementation off the loop and do nothing
else — do not gather five hundred of them expecting parallelism the thread
pool will not give you.
What runs here, and what it needs
| Family | Formats | Needs |
|---|---|---|
| Documents → Markdown | pdf, docx, pptx, xlsx, csv, xml, txt | one extra per format family |
| Documents → Markdown | doc, odt, rtf, xls, ods, ppt, odp | LibreOffice |
| Documents → Markdown | epub, mobi, azw3 | Calibre |
| Images ↔ images | 26 formats — jpg, png, webp, avif, heic, tiff, svg, RAW, and more | convilyn[images] |
| PDF page operations | merge, split, extract, rotate, compress, protect | convilyn[pdf] |
uv add "convilyn[pdf]" # just PDF
uv add "convilyn[documents]" # every document format
uv add "convilyn[all]" # documents + imagesPlain text, CSV and Markdown need nothing at all — they work on the bare install.
The legacy, OpenDocument and ebook formats take one hop first: .odt → .docx,
.ods → .xlsx, and so on. They are then read as that modern sibling, so they
inherit headings, tables and embedded images exactly as it does.
The result still reports the format you gave it, and its warnings say which route it took.
Honest limits
- Images are refused above 40 megapixels, checked against the header before the file is decoded. That is a decompression-bomb guard sized for a laptop, not a quality limit.
extract_texton a scanned PDF returns an empty string. That is the honest answer, not a failure: recovering glyphs from a picture is a different operation, and one that is metered.- Image codecs vary by platform. Some formats a given install reads but cannot write; the route says so, and names a package only when a package would actually help.
From the shell
$ convilyn local formats --from heic
! heic → unavailable. Reading heic needs pillow-heif, a Pillow plugin this
package does not install: a HEIF/HEIC codec. Add it with
`pip install pillow-heif` and the format becomes available with no further
configuration.
$ convilyn local doctor
✓ csv, txt, md no extra needed
✓ pdf convilyn[pdf]
✗ odt, rtf, ods LibreOffice not found — https://www.libreoffice.org/download/Every command takes --json for machine-readable output and --dry-run to show
the route it would take without writing anything.