Convilyn developers

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 guess

Or 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 |
 
![](assets/img-002.png)

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 happenedHow you see itWhat to do
A package it needs is not installedroute.missing is non-empty; convert() raises MissingDependencyErrorShow unavailable_reason — it carries the install command
Something optional is absentroute.degraded_by is non-empty; the conversion still runsNothing, or warn. A picture repeated on 40 pages is stored 40 times
This machine can read the format but not write itavailable is False with missing empty; raises UnsupportedRouteErrorOffer 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.

  1. 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.
  2. Repeated images are stored once. A logo on every page is one asset, not forty — first occurrence wins.
  3. 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

FamilyFormatsNeeds
Documents → Markdownpdf, docx, pptx, xlsx, csv, xml, txtone extra per format family
Documents → Markdowndoc, odt, rtf, xls, ods, ppt, odpLibreOffice
Documents → Markdownepub, mobi, azw3Calibre
Images ↔ images26 formats — jpg, png, webp, avif, heic, tiff, svg, RAW, and moreconvilyn[images]
PDF page operationsmerge, split, extract, rotate, compress, protectconvilyn[pdf]
uv add "convilyn[pdf]"        # just PDF
uv add "convilyn[documents]"  # every document format
uv add "convilyn[all]"        # documents + images

Plain 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_text on 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.

Where to go next