Three promises that get confused
"We do not store your files" and "conversion runs inside your own network" sound alike in a pitch and are different claims. In the first case the document still leaves, once, in full. In the second it never leaves. Keep the levels apart:
| What the service says | What it means | What leaves your machine |
|---|---|---|
| No retention | parsed in memory, nothing written to disk or backup | the whole file, every time |
| Own infrastructure | one company's server instead of a resold SaaS | the whole file, to that company |
| Self-hosted or on-prem | the converter runs inside your network | nothing, if it makes no other outbound call |
Weigh the upload before you decide
The fifteen-page arXiv paper used in the other guides here is 2,215,244 bytes and converts to 39,662 characters of Markdown: 5,868 words, 9,939 tokens in o200k_base. The text you wanted is 1.8% of what you sent. The other 98% is fonts, page objects, an embedded image pipeline and link annotations, and no converter needs any of it:
| File | Size | Markdown out | Everything else in the file |
|---|---|---|---|
paper.pdf, 15 pages | 2.2 MB | 39,662 chars | fonts, page objects, 113 link annotations |
rollout.pptx, 4 slides | 30,906 B | 346 chars | 44 zip parts: theme, layouts, slide XML |
handbook.docx | 3,508 B | 666 chars | almost nothing: three parts, no metadata stream |
The metadata is the part nobody reads
Metadata is not text, so no converter needs it — which is exactly why it survives an upload unnoticed. Read from the PDF Info dictionary and the Office archive's docProps streams:
| File | Where it sits | Value found |
|---|---|---|
paper.pdf | Info / Creator | LaTeX with hyperref |
paper.pdf | Info / Producer | pdfTeX-1.40.25 |
paper.pdf | Info / PTEX.Fullbanner | the TeX Live 2023 banner, 96 characters |
paper.pdf | Info / CreationDate | D:20240410211143Z |
paper.pdf | /Annots on 15 pages | 113 annotations |
rollout.pptx | core.xml / lastModifiedBy | Steve Canny |
rollout.pptx | core.xml / created | 2013-01-27T09:14:16Z |
rollout.pptx | app.xml / Application | Microsoft Macintosh PowerPoint, TotalTime 1 |
revenue.xlsx | core.xml + app.xml | openpyxl / Microsoft Excel Compatible / Openpyxl 3.1.5 |
handbook.docx | docProps | absent: the archive has three parts |
The deck is the clearest case. Four slides written this month claim to have been last modified by Steve Canny in January 2013, because the file was built from a template that still carries its author, and the same stream reports a one-minute editing time. Any service you upload that deck to receives the claim as well, whether it wanted it or not. A file re-saved in Word or Excel usually carries more: real author names, the company, revision counters, sometimes a path on someone's disk.
It can be stripped — document inspector, exiftool, a fresh export — but only before the file leaves.
The converter used none of it
Grep the Markdown for each string above — pdfTeX, LaTeX with hyperref, PTEX.Fullbanner, Steve Canny — and every count is zero. What came out is 39,662 characters: zero headings, zero tables, zero code fences, zero links, fourteen form feeds where the pages break, 9,939 tokens. The metadata had no effect on the conversion; it travelled because the file was uploaded whole. Why that paper also arrives without a single heading is a separate problem, measured in the PDF guide and in the eight-document comparison.
Prove the conversion never calls out
Ask a service whether it forwards your file and you get a sentence. A test is better than a sentence. This guard blocks every outbound socket in the Python process and runs the converter inside it, so any attempt to reach the network fails and is recorded with the address it tried:
#!/usr/bin/env python3
"""netguard.py - run a Python program with every outbound network call denied.
PYTHONPATH=. python3 netguard.py -m app.cli convert paper.pdf -o out
One summary line is printed when the program exits. "network attempts: 0"
means the program never needed the network; every attempt is listed with the
address it tried to reach.
"""
import runpy
import socket
import sys
attempts = []
def block(what):
def inner(*args, **kwargs):
attempts.append(f"{what} {args[:2]!r}"[:120])
raise OSError(f"netguard: {what} blocked")
return inner
socket.socket.connect = block("connect")
socket.socket.connect_ex = block("connect_ex")
socket.create_connection = block("create_connection")
socket.getaddrinfo = block("getaddrinfo")
argv = sys.argv[1:]
code = 0
try:
if argv[0] == "-m":
sys.argv = [argv[1]] + argv[2:]
runpy.run_module(argv[1], run_name="__main__")
else:
sys.argv = argv
runpy.run_path(argv[0], run_name="__main__")
except SystemExit as exc:
code = int(exc.code or 0)
except OSError as exc:
print(f"netguard: stopped: {exc}", file=sys.stderr)
code = 1
print(f"netguard: exit {code}, network attempts: {len(attempts)}", file=sys.stderr)
for attempt in attempts:
print(" attempt:", attempt, file=sys.stderr)
sys.exit(code)
First the guard itself, on a script whose only job is to fetch a page. It has to fail, otherwise the guard proves nothing:
$ cat leak.py
import urllib.request
print(urllib.request.urlopen("http://example.com", timeout=5).status)
$ python3 netguard.py leak.py
netguard: stopped: <urlopen error netguard: create_connection blocked>
netguard: exit 1, network attempts: 1
attempt: create_connection (('example.com', 80), 5)
Then the real conversion, on the same fifteen-page paper:
$ PYTHONPATH=. python3 netguard.py -m app.cli convert paper.pdf -o out
OK paper.pdf (5868 words)
1 converted · 0 fallback · 0 skipped · 0 failed
netguard: exit 0, network attempts: 0
$ sha256sum out/paper.md out-plain/paper.md
64af02a0f3b9625facc8bf9d3a42313b875e0e6c1ade40b1fe9a9a57f6b5f740 out/paper.md
64af02a0f3b9625facc8bf9d3a42313b875e0e6c1ade40b1fe9a9a57f6b5f740 out-plain/paper.md
Byte-identical output, 5.7 seconds, zero attempts. The same guard around the HTTP service gives the same answer: three uploads to POST /api/convert answered 200 in 5.28 s (the paper), 0.03 s (a DOCX) and 0.03 s (an XLSX), and the process reported zero network attempts when it shut down. The conversion is library code in the same process — there is no OCR API, no document-AI endpoint and no model vendor behind it.
What still needs a network
- OCR is not built in. A scanned PDF has no text layer and the endpoint says so instead of returning bytes: HTTP 422, "This PDF has no text layer: its pages are images (a scan). Run OCR first". Run
ocrmypdf --force-ocrlocally, then convert the OCR-ed file. A half-scanned file is the common case, and it needs a different flag: the page-by-page check returns partial for a two-page file whose first page has text and whose second is a pasted scan, and suggestsocrmypdf --skip-text— OCR only the pages that are missing, not the ones that are already fine. - Speech needs a model. The first audio conversion downloads a Whisper
basemodel of about 150 MB; pre-seed that cache and the local run works offline. That is written in the deployment notes in the repository, not discovered afterwards. - Pings. A deployment makes one optional outbound call, an IndexNow ping after a content change. Nothing else phones home and no call carries a document.
- The hosted converter is still our server. It parses in memory and forwards nothing, but the file does leave your network. When that is not allowed, run the command line above on your own machine; a packaged container is on the roadmap, not shipped.
Converting with the cable pulled
The command line tool is the same converter the endpoint uses — one registry in app/convertlib.py, so a format that works on the site works from a shell, without a quota and without a network:
# one file, Markdown on stdout
python -m app.cli convert contract.pdf --stdout > contract.md
# a folder, recursively, one .md per source plus report.md
python -m app.cli convert ~/documents/q4 -o ~/markdown/q4
# a ZIP of scans already OCR-ed, with a manifest, without clobbering earlier runs
python -m app.cli convert invoices.zip -o out --no-clobber
Exit codes are 0 for a clean run, 1 when a file failed and the rest still converted, 2 for bad arguments, so a cron job can tell one unreadable scan from a wrong flag. An input that is already .md is skipped rather than converted into itself.
On-prem: the CLI today, a container on the roadmap
There is no Team edition and nothing to buy — the product is one free plan. What runs on your own machine today is the command line above: the same registry as the endpoint, no quota and no network call. A packaged container for a whole team — a /data volume, a healthcheck on /healthz, one docker compose up — is on the roadmap, not shipped, so a document that cannot leave your network is handled today by the CLI, not by a licence.
Five questions before you upload anything
- Retention: for how long, written where, and is deletion immediate?
- Subprocessors: does the extracted text go on to an OCR API, a document-AI service or a model vendor?
- Ownership: who runs the service, in which jurisdiction, and who answers for the data?
- Offline option: is there an on-prem or CLI build, or is the cloud the only path?
- Failure behaviour: what comes back on an error? A service that echoes your bytes into a message has just shown you its storage.