A private search engine for everything you keep. Photos, scans, video, voice notes, chat exports, email and Office files become searchable PDFs that you can search and question, offline, from one download.
$ anytopdf ~/inbox --index -o inbox.pdf
$ anytopdf search northwind
$ anytopdf ask inbox.pdf "How much was the Northwind receipt?"Every page looks like the original. Underneath, an invisible text layer carries
what anytopdf read, heard and saw: OCR from flattened phone photos, Whisper
transcripts, captions, objects, scene tags, places, dates and links. So Ctrl+F,
Spotlight, pdftotext, anytopdf search and your RAG pipeline all find it. A
manifest with SHA-256 hashes and per-page chunks is embedded in the PDF, which
makes the file its own index.
Both images come from a real run of the release binary
(python3 docs/demo/record.py); nothing is mocked.
| Reads almost anything | Photos (JPEG, PNG, TIFF, HEIC/AVIF, camera RAW) with phone-photo page flattening, scanned and digital PDFs, video, audio, SRT/VTT captions, text and Markdown, JSON and JSON Lines, HTML, web pages and YouTube links, browser bookmarks, .eml/.mbox email with attachments, WhatsApp/Telegram/Slack/iMessage chat exports, Word/Excel/PowerPoint/OpenDocument, zip and tar archives, screen recordings and print jobs |
| Reads, hears and sees | OCR through Apple Vision, docTR or Tesseract, word-aligned under the image; Whisper speech to text; CLIP scene tags so "beach" finds beach photos; YOLO objects; face presence and opt-in matching against your own local face index; laughter, applause and sirens; dominant colours, places, dates, links, sentiment and tone. Nothing estimates age, gender or emotion |
| Search and ask across everything | anytopdf search queries a local SQLite index of every converted PDF, filtered by kind or person; anytopdf ask returns ranked passages citing file, page and time, and writes a cited answer with any local OpenAI-compatible LLM |
| Writes a real archive file | Tagged PDF/A-3a with bookmarks, Arabic/Hebrew/CJK shaping, byte-reproducible output, and a provenance page |
| Proves where it came from | Embedded anytopdf-manifest.json and anytopdf-chunks.json with source hashes, page maps and entities; anytopdf extract --json reads them back |
| Plugs into agents | anytopdf mcp is a Model Context Protocol server with convert, search and ask tools: claude mcp add anytopdf -- anytopdf mcp |
| Runs unattended | Folder-backed job queue, HTTP upload intake, signed webhooks, an IMAP mailbox watcher, and an optional IPP printer helper that phones and laptops can print to |
| Extends without forks | Any anytopdf-plugin-* executable on PATH, in any language, speaks a versioned JSON protocol; an opt-in sandbox confines it |
Nothing leaves your machine: models run locally, there is no telemetry, and the network is used only when you convert a URL, download a model, point anytopdf at your own LLM endpoint, or turn on an intake channel (queue server, webhooks, IMAP or the printer).
| Platform | Command |
|---|---|
| macOS, Linux | curl -fsSL https://raw.githubusercontent.com/adeelahmad/anytopdf-rs/main/install.sh | sh |
| Homebrew | brew tap adeelahmad/anytopdf https://github.com/adeelahmad/anytopdf-rs && brew install adeelahmad/anytopdf/anytopdf |
| Windows (Scoop) | scoop bucket add anytopdf https://github.com/adeelahmad/anytopdf-rs; scoop install anytopdf/anytopdf |
| cargo-binstall | cargo binstall --git https://github.com/adeelahmad/anytopdf-rs anytopdf |
| Docker | docker run --rm -v "$PWD:/work" ghcr.io/adeelahmad/anytopdf-rs photo.jpg -o photo.pdf |
| From source | cargo install --locked --git https://github.com/adeelahmad/anytopdf-rs anytopdf |
Or download an archive for your platform from
Releases. The
installer verifies the release's SHA-256 checksum before it installs anything.
Text and image conversion need nothing else; run anytopdf doctor to see which
optional providers (FFmpeg, Tesseract, ExifTool, Poppler, LibreOffice) it found.
Every channel is described in docs/distribution.md.
Install as above, or unpack the archive for your operating system and run ./anytopdf (Windows:
anytopdf.exe). The executable needs no Rust or Python installation for text and
image conversion; provider-specific dependencies are listed below.
./anytopdf --version
./anytopdf notes.txt photo.jpg -o out.pdf
./anytopdf --no-plugins convert notes.txt --ocr off -o notes.pdf
./anytopdf doctorKeep Cargo.lock when building from source. For video, install FFmpeg; for OCR,
use native Apple Vision on macOS or install Tesseract. Audio transcription requires
a supplied transcript or a plugin.
Office documents (.docx, .xlsx, .pptx, .odt, .ods, .odp, .rtf and
their legacy formats) need LibreOffice (soffice) and Poppler's pdftoppm.
Each page is rendered as an image; with Poppler's pdftotext the document's own
text is placed invisibly over it and OCR is skipped for those pages. Without
pdftotext, pages fall back to OCR. The conversion runs in the job workspace with
a private LibreOffice profile, so the original file is never opened in place.
The release archives ship the Whisper plugin but not a Whisper engine, so transcription stays off until you install one. One command turns it on:
| System | Command |
|---|---|
| macOS | brew install whisper-cpp && anytopdf setup whisper |
| Linux, Raspberry Pi | pipx install whisper-ctranslate2 (faster-whisper; fetches its own model on first use) |
| Windows | pipx install whisper-ctranslate2, or unpack whisper-bin-x64.zip from the whisper.cpp releases, put whisper-cli.exe on PATH and run anytopdf setup whisper |
anytopdf setup whisper # downloads ggml-base.bin (142 MiB), checks its SHA-1
anytopdf convert meeting.mp4 -o meeting.pdfanytopdf doctor prints the same command for your system while anything is missing.
anytopdf-plugin-whisper transcribes audio and video sources that have no
sidecar or --transcript transcript. It extracts the audio with FFmpeg and runs
a Whisper engine, adding a visible, timed transcript page whose segments are
searchable transcript annotations.
setup whisper installs a whisper.cpp model into anytopdf's data folder
(ANYTOPDF_DATA_DIR, or ~/Library/Application Support/anytopdf on macOS,
%LOCALAPPDATA%\anytopdf on Windows, ~/.local/share/anytopdf elsewhere) and
records it. Pick another model with --model (tiny, base.en, small,
large-v3-turbo, …; .en models are English-only), install a file you already
downloaded with --from FILE, or use a mirror with --base-url URL. Every file
is checked against the checksum whisper.cpp publishes.
The plugin ships with anytopdf (in plugins/ beside the binary, or Homebrew's
libexec/plugins) and turns itself on once all three of these are present:
- a model: the one
setup whisperrecorded, orANYTOPDF_WHISPER_MODELset to a ggml model file; - an engine: whisper.cpp's
whisper-cli(brew install whisper-cpp; on Windowswhisper-cli.exefrom the whisper.cpp release zip), orwhisper-ctranslate2(faster-whisper) / OpenAIwhisperwithANYTOPDF_WHISPER_MODELnaming the model (defaultbase); - FFmpeg on
PATH.
Until then it stays off, so media conversions don't warn. anytopdf doctor (and
setup whisper itself) says exactly which piece is missing. A plugin you put on
PATH or ANYTOPDF_PLUGIN_PATH always runs and reports what it lacks as a
warning. The container image has a WHISPER=cpp build that includes whisper.cpp
(see docs/distribution.md). From source:
cargo build --release -p anytopdf -p anytopdf-plugin-whisper
mkdir -p target/release/plugins && cp target/release/anytopdf-plugin-whisper target/release/plugins/
target/release/anytopdf setup whisper
target/release/anytopdf convert meeting.mp4 --plugin-timeout 1800 -o meeting.pdfANYTOPDF_WHISPER_ENGINE (auto, whisper.cpp, openai-whisper),
ANYTOPDF_WHISPER_BIN (an engine executable not on PATH) and
ANYTOPDF_WHISPER_LANGUAGE (default: detect) override the defaults. One plugin
invocation transcribes every media source in the job, so raise
--plugin-timeout (default 60 seconds) for long recordings. A missing engine,
missing FFmpeg or a source without an audio track becomes a plugin.warning;
the PDF is still written. Under --plugin-sandbox strict, pass
--plugin-sandbox-allow-read for the data folder (or the model file) so the
plugin can load the model.
anytopdf-plugin-faces finds faces in images and video keyframes with a
compiled-in YuNet detector, so it needs no download or Python. Each face becomes
a searchable face annotation with its box, five landmarks and confidence, plus
a per-frame count (2 faces). It never estimates age, gender, emotion or other
traits. It ships beside the Whisper plugin and is enabled the same way:
cargo build --release -p anytopdf-plugin-faces
ANYTOPDF_PLUGIN_PATH="$PWD/target/release" anytopdf convert meeting.mp4 -o meeting.pdfANYTOPDF_FACES=off, ANYTOPDF_FACES_THRESHOLD, ANYTOPDF_FACES_MIN_SIZE and
the annotation format are described in docs/faces.md.
anytopdf-plugin-clip embeds every image, document page and video keyframe with
OpenAI's CLIP ViT-B/32, so they can be searched by what they show rather than by
the words on them. It runs on the CPU in pure Rust; no Python or ONNX Runtime
install is needed. It also adds zero-shot scene tags (photo, document,
screenshot, slide, chart, indoors/outdoors, beach, city street,
office, food, night and similar) to the PDF's searchable layer, so searching
the PDF for "screenshot" or "beach" finds those pages. The tags describe content
and setting only, never a person's traits.
cargo build --release -p anytopdf-plugin-clip
target/release/anytopdf-plugin-clip --fetch-model # about 600 MB, checksum-verified
export ANYTOPDF_PLUGIN_PATH="$PWD/target/release"
anytopdf convert holiday/ -o holiday.pdf--fetch-model [DIR] stores the model in anytopdf/models/clip in the user data
directory (ANYTOPDF_CLIP_MODEL_DIR overrides it; ANYTOPDF_CLIP_MODEL_MIRROR names
a mirror holding the same three files). A Hugging Face ONNX export
(onnx/vision_model.onnx, onnx/text_model.onnx, tokenizer.json) also works.
ANYTOPDF_CLIP_TAGS=off turns tags off, ANYTOPDF_CLIP_TAGS="logo=a company logo;cat"
replaces them with your own labels, and ANYTOPDF_CLIP_TAG_THRESHOLD (default 0.5)
sets how sure a tag must be. Each embedding stays in the conversion's graph
(clip.embedding) for the cross-file search index and is never written into the
PDF. anytopdf-plugin-clip --encode-text "people on a beach at night" and
--encode-image photo.jpg print a query embedding as JSON. Without a model the
plugin warns once per conversion and the PDF is still written. Under
--plugin-sandbox strict, allow the model folder with
--plugin-sandbox-allow-read "$ANYTOPDF_CLIP_MODEL_DIR" (or the default folder).
anytopdf-plugin-sentiment labels what text says as positive, negative or
neutral, and tags its tone as question, complaint or urgent (plus formal
or informal with an LLM). It reads every text source: transcript segments
(including Whisper's), caption cues, OCR blocks (words grouped into lines and
paragraphs) and the paragraphs of plain-text, email and document units. Each label
is a custom annotation with entity set to sentiment or tone, carrying the
segment's time range or OCR region; a unit with several segments also gets one
sentiment-overall line such as "overall negative", weighted by segment length.
Searching the PDF for "negative" or "complaint" finds those moments. Labels never
come from faces or voices and are never attached to a person.
It needs no model: by default it scores English text with a built-in port of the
VADER lexicon. For other languages and better accuracy, point it at a local
OpenAI-compatible endpoint (Ollama, llama.cpp llama-server, LM Studio, vLLM); if
the endpoint fails, the lexicon is used and a plugin.warning says so.
cargo build --release -p anytopdf-plugin-sentiment
export ANYTOPDF_PLUGIN_PATH="$PWD/target/release"
anytopdf convert support-call.mp4 -o call.pdf
# Optional: a local LLM instead of the lexicon
export ANYTOPDF_SENTIMENT_LLM_URL=http://127.0.0.1:11434/v1
export ANYTOPDF_SENTIMENT_LLM_MODEL=qwen2.5:3b| Variable | Default | Meaning |
|---|---|---|
ANYTOPDF_SENTIMENT_BACKEND |
auto |
vader, llm, or auto (llm when an LLM URL is set) |
ANYTOPDF_SENTIMENT_LLM_URL |
ANYTOPDF_LLM_URL |
OpenAI-compatible base URL or full /chat/completions URL |
ANYTOPDF_SENTIMENT_LLM_MODEL |
ANYTOPDF_LLM_MODEL |
Model name, required with the LLM backend |
ANYTOPDF_SENTIMENT_LLM_API_KEY |
ANYTOPDF_LLM_API_KEY |
Sent as a bearer token |
ANYTOPDF_SENTIMENT_LLM_TIMEOUT |
45 |
Seconds per request (24 segments each) |
ANYTOPDF_SENTIMENT_FROM |
all | Comma list of transcript, caption, ocr, text |
ANYTOPDF_SENTIMENT_NEUTRAL |
false |
Also annotate neutral segments |
ANYTOPDF_SENTIMENT_TONES |
true |
Add tone tags |
ANYTOPDF_SENTIMENT_THRESHOLD |
0.05 |
Lexicon score needed for positive or negative |
A request options object (how anytopdf's layered configuration passes a
[sentiment] table) takes precedence: llm_model = "qwen2.5:3b" there wins over
ANYTOPDF_SENTIMENT_LLM_MODEL, and lists such as from = ["ocr"] are accepted.
The plugin runs once per unit; with an LLM, raise --plugin-timeout for long
transcripts. --plugin-sandbox strict blocks network access, so the LLM backend
falls back to the lexicon there.
anytopdf-plugin-audio-events adds an "Audio events" page to every audio and
video source: a compact timeline (00:12 applause · 03:40 music · 05:02 laughter)
and every event with its time range. Each event is also a custom annotation
with entity = audio-event, a label, a time range and, from a model, a
confidence, so it lands in the chunks JSON. It decodes the audio with FFmpeg and
looks at it one second at a time:
- Without a model it reports
speech,music,noiseandsilencesegments from the signal itself (level, low-energy and zero-crossing patterns), andraised-voicewhere the BS.1770 loudness of speech is at leastANYTOPDF_AUDIO_EVENTS_RAISED_LU(default 8) LU above the file's median speech level. That is a measured level jump, not a guess at anyone's mood. - With an AudioSet model (YAMNet, PANNs CNN14 or any ONNX export that takes
a 16 kHz waveform) it also reports
laughter,applause,cheering,singing,crying-baby,dog,siren,alarm,gunshot,explosion,vehicle,car-horn,door,knock,keyboard-typing,telephoneandglass-breaking, and the model decides speech versus music. SetANYTOPDF_AUDIO_EVENTS_MODELto the.onnxfile andANYTOPDF_AUDIO_EVENTS_LABELSto its class map CSV (found automatically as<model>_class_map.csvorclass_labels_indices.csvbeside it). ONNX Runtime is loaded at run time fromORT_DYLIB_PATHor the system library path;ANYTOPDF_AUDIO_EVENTS_DEVICEpickscpu(default),cudaorcoreml, andANYTOPDF_AUDIO_EVENTS_THRESHOLD(default 0.3) the minimum score.
The plugin never labels how a person feels: AudioSet classes such as crying,
sobbing or screaming by adults are not reported, and there is no voice emotion,
age or gender analysis. It ships beside the Whisper plugin and is turned on the
same way (ANYTOPDF_PLUGIN_PATH). The Linux release binaries are static and
cannot load ONNX Runtime, so for a model on Linux build the plugin from source
(cargo build --release -p anytopdf-plugin-audio-events); signal analysis works
in every build. A missing or broken model becomes a plugin.warning and the
signal results are still written.
--recognize-faces names the people in photos and video keyframes from a local
face index, so searching the PDF for "Alice" finds every frame she is in, and
adds a "People in …" page per source listing each person with the times (or
pages) where they appear. Faces that match nobody are grouped as person-N
across runs; name a group once and later runs use the name. It is off by
default and needs two runtime plugins: a face detector that adds face
annotations with five-point landmarks, and anytopdf-plugin-face-id, which
embeds each face with an ArcFace-style ONNX model (for example InsightFace
w600k_mbf.onnx or w600k_r50.onnx; check the model's licence) using pure-Rust
inference, so no Python or ONNX Runtime install is needed. OpenCV SFace and the
ONNX-zoo ArcFace R100 work too: models that scale their own input get raw 0-255
pixels, others -1 to 1 (ANYTOPDF_FACE_EMBED_INPUT=raw|normalized overrides).
cargo build --release -p anytopdf-plugin-face-id
export ANYTOPDF_PLUGIN_PATH="$PWD/target/release" # plus the face detector
export ANYTOPDF_FACE_EMBED_MODEL="$HOME/models/w600k_mbf.onnx"
anytopdf faces enroll Alice alice-1.jpg alice-2.jpg
anytopdf faces import faces/ # faces/<Name>/*.jpg, one folder per person
anytopdf convert party.mp4 --recognize-faces -o party.pdf
anytopdf faces list # Alice, person-2, person-3 …
anytopdf faces name person-2 Bob # merges into Bob if Bob already exists
anytopdf faces find someone.jpg # every file and time that face appears
anytopdf faces rename|merge|forget …The index is faces.sqlite in the anytopdf data directory
($XDG_DATA_HOME/anytopdf, ~/Library/Application Support/anytopdf or
%LOCALAPPDATA%\anytopdf; override with ANYTOPDF_DATA_DIR, ANYTOPDF_FACE_INDEX or
--face-index). It holds embeddings and sightings, which never go into a PDF.
--face-threshold (default 0.40, cosine similarity) sets how close a face must
be to count as a known person; two faces in one frame are never the same
person. Only matches at 0.60 or above are remembered as more examples of that
person, so a borderline match never pulls later faces toward the wrong person. Embedding runs as one plugin call per conversion, so raise
--plugin-timeout for long videos. --profile share leaves names and the
"People in …" pages out of the PDF. Identities come only from people you enroll
or name: nothing infers age, gender, emotion or other traits.
anytopdf-plugin-vlm asks a local vision-language model about every image and
video keyframe and writes the answers as searchable caption annotations. By
default it asks for a caption, "What do you see in this image?" and the
activities shown, as the original video-analysis script did. For each video,
audio file or subtitle file it then adds a visible summary page before the
source's other pages, with a category and up to five topics (custom
annotations whose entity is category or topic). Videos also get a one- or
two-sentence summary per scene (keyframes are split at FFmpeg scene changes).
Summaries are written from the keyframe descriptions plus any transcript or
subtitles; audio needs a transcript, for example from the Whisper plugin. Prompts
never ask for anyone's age, gender, ethnicity or emotions.
The plugin does nothing until ANYTOPDF_VLM_URL is set. It talks to either:
- an OpenAI-compatible endpoint (default
ANYTOPDF_VLM_ENGINE=openai): Ollama, llama.cppllama-server, LM Studio or vLLM, withANYTOPDF_VLM_MODELnaming a vision model; or - the Moondream API (
ANYTOPDF_VLM_ENGINE=moondream): Moondream Station, or Moondream2 through transformers with the bundledhelpers/anytopdf-moondream-server.py(--device cpu|cuda). This engine also supports open-vocabulary detection:ANYTOPDF_VLM_DETECT="red car,logo"adds anobjectannotation with a box for each match.
ollama pull llava
cargo build --release -p anytopdf-plugin-vlm
export ANYTOPDF_PLUGIN_PATH="$PWD/target/release"
export ANYTOPDF_VLM_URL=http://127.0.0.1:11434/v1 ANYTOPDF_VLM_MODEL=llava
anytopdf convert meeting.mp4 --plugin-timeout 120 -o meeting.pdf| Variable | Default | Meaning |
|---|---|---|
ANYTOPDF_VLM_PROMPTS |
caption, query, activity | type=prompt entries separated by |; an entry without type= is a query |
ANYTOPDF_VLM_TIMEOUT |
50 |
seconds per keyframe, and for all summaries of a run; keep it below --plugin-timeout |
ANYTOPDF_VLM_MAX_SIDE |
768 |
images are downscaled to this many pixels before upload |
ANYTOPDF_VLM_API_KEY |
none | bearer token (OpenAI) or X-Moondream-Auth (Moondream) |
ANYTOPDF_VLM_SUMMARY |
on |
off skips summaries and categories |
ANYTOPDF_LLM_URL, ANYTOPDF_LLM_MODEL |
the VLM endpoint | OpenAI-compatible text model for summaries; required for them with the Moondream engine |
ANYTOPDF_VLM_CATEGORIES |
news, entertainment, education, sports, music, gaming, tutorial, meeting, documentary, other | comma-separated category list |
Images go to the configured endpoint, so point it at a server you trust; a
loopback URL bypasses any HTTP proxy. --plugin-sandbox strict blocks network
access and therefore this plugin. An unreachable endpoint, a slow frame or a bad
answer becomes a plugin.warning, and the PDF is still written.
anytopdf-plugin-objects runs a YOLO object detector on every image and video
keyframe, including photos attached to emails or packed in archives. Each detection becomes a searchable object annotation with its label,
confidence, normalized box and (for video) the frame's time, and each frame also
gets a count such as objects: 4 person, 1 bus, so searching the PDF for "dog"
finds the frames with a dog. Pages of PDF, Office and HTML documents are skipped.
Inference runs on tract, a pure-Rust ONNX
runtime, so the plugin needs no Python, CUDA or native library. It takes a YOLOv8
or YOLO11 ONNX export (YOLOv5 exports work too); yolo11n.onnx from the
Ultralytics assets release
is a good default and labels the 80 COCO classes. Ultralytics models are AGPL; for
an Apache-2.0 model use a YOLOX export, such as yolox_s.onnx from the
YOLOX releases or
object_detection_yolox_2022nov.onnx from the
OpenCV model zoo.
The head type is recognised from the model's output shape; a model that is none of
these, or whose class count does not match its labels, is reported as a warning
instead of silently finding nothing. From source:
cargo build --release -p anytopdf-plugin-objects
export ANYTOPDF_PLUGIN_PATH="$PWD/target/release"
export ANYTOPDF_OBJECTS_MODEL="$HOME/models/yolo11n.onnx"
anytopdf convert meeting.mp4 --plugin-timeout 600 -o meeting.pdf| Variable | Default | Meaning |
|---|---|---|
ANYTOPDF_OBJECTS_MODEL |
none (required) | YOLO .onnx file |
ANYTOPDF_OBJECTS_LABELS |
model's names, else COCO |
text file, one class name per line |
ANYTOPDF_OBJECTS_CONFIDENCE |
0.25 |
minimum score kept |
ANYTOPDF_OBJECTS_IOU |
0.45 |
overlap above which same-class boxes merge |
ANYTOPDF_OBJECTS_CLASSES |
all | comma-separated labels to keep, e.g. person,car |
ANYTOPDF_OBJECTS_INPUT_SIZE |
model's, else 640 |
input size for models with dynamic shapes |
ANYTOPDF_OBJECTS_MAX |
100 |
most detections per frame |
ANYTOPDF_OBJECTS_DEVICE |
cpu |
only cpu; other values warn and use the CPU |
The model loads once per job and yolo11n takes well under a second per frame on
a CPU, so raise --plugin-timeout (default 60 seconds) for
long videos. A missing model, an unreadable frame or a bad setting becomes a
plugin.warning; the PDF is still written.
anytopdf-plugin-tika hands files that no built-in importer claims to
Apache Tika, which reads well over a thousand
formats: EPUB, Outlook .msg, XPS, iWork, Visio, Publisher, CHM and many more.
The file's text becomes a text page, each embedded document with text (an
attachment, a chapter file) gets its own page, and Tika's metadata (title,
author, dates) is kept as tika.* source metadata in the manifest, never in the
PDF's text layer. It is a catch-all: built-in importers, including plain-text
sniffing, always win, except for a few extensions that text sniffing would
mangle (epub, msg, xps, pages, vsd and similar).
The plugin does nothing until one of these is set. It talks to a Tika server
(ANYTOPDF_TIKA_URL) or runs a tika-app jar with Java (ANYTOPDF_TIKA_JAR);
with both, the server is tried first and the jar is the fallback.
docker run -d -p 9998:9998 apache/tika:latest
export ANYTOPDF_TIKA_URL=http://127.0.0.1:9998
anytopdf convert book.epub mail.msg -o out.pdf| Variable | Default | Meaning |
|---|---|---|
ANYTOPDF_TIKA_URL |
none | Tika server base URL (the plugin calls PUT /rmeta/text) |
ANYTOPDF_TIKA_JAR |
none | tika-app jar, run as java -jar … --jsonRecursive --text |
ANYTOPDF_TIKA_JAVA |
$JAVA_HOME/bin/java, else java |
Java executable for the jar |
ANYTOPDF_TIKA_TIMEOUT |
50 |
seconds per file, below the default --plugin-timeout |
The same keys work as a [tika] table in the config file or as
--set tika.url=…; the plugin turns itself on only from the environment
variables, because the host reads its manifest before any request. A file Tika
finds no text in is skipped with import.failed, as an unsupported file was
before. Files go to the configured server, so point it at one you trust;
--plugin-sandbox strict blocks network access and therefore the server engine.
extractous (Tika compiled with GraalVM)
was considered and not used: it needs a GraalVM native-image build and a
shared library beside the plugin on every platform.
anytopdf is a pluggable media/document ingestion engine whose canonical output
is a searchable, RAG-friendly PDF.
It is deliberately not implemented as "a list of file extensions plus converters". The architecture is closer to a driver framework:
sources
-> discover/probe
-> importer
-> asset graph
-> source enrichers
-> unit enrichers
-> page planner
-> renderer
-> searchable PDF
A source may produce any number of derived units. A video can produce keyframes,
audio segments, subtitle cues and metadata. An image generally produces one visual
unit. A transcript produces text units. Future importers can produce whatever
representation makes sense for formats such as .igl, CAD, email, Office,
archives, proprietary exports, or remote sources.
Every fact becomes an Annotation with provenance:
- OCR text and bounding boxes
- captions / closed captions
- transcript text and time ranges
- file / EXIF / XMP / ffprobe metadata
- date/time and GPS/location metadata
- scene/keyframe changes
- object labels from an object-analysis plugin
- face presence, count and bounds from a face-analysis plugin, and the names of
people you enrolled (
--recognize-faces) - arbitrary future annotations
The PDF renderer paints the visual page normally and emits searchable annotations
as invisible text (fill opacity 0 in the default pdfa renderer, text rendering
mode 3 in pdf). The hidden layer carries content only
(OCR, captions, transcripts, objects, faces, barcodes, colours, time ranges, and place
names read from that text); source paths and file metadata, including GPS-derived
places, are never written into it. Text/transcript units become normal
visible text pages.
Face enrichment is limited to presence, count and bounds, plus identity only for people the user enrolls or names in the local face index. It does not infer gender identity, emotion, age or other sensitive/demographic traits from a face. The plugin model supports adding other non-sensitive semantic analyzers without changing the core.
There are two kinds of plugins.
These implement Rust traits and are compiled into the executable. This is what you use for fast, portable core functionality.
Executables named anytopdf-plugin-* are discovered from PATH and directories
in ANYTOPDF_PLUGIN_PATH (using the platform path separator). They speak the versioned JSON protocol documented in
PLUGIN_PROTOCOL.md.
This is the compatibility boundary for future/proprietary formats. A runtime plugin can be written in Rust, Go, Python, Swift, C++, etc.; there is no Rust dynamic-library ABI dependency.
A future .igl plugin can therefore be shipped independently:
anytopdf-plugin-igl
and register itself as an importer without modifying the core binary.
Importers:
- raster images
- existing PDFs: pages rendered by Poppler
pdftoppmwith their own text layer kept (OCR only for textless pages); text only without Poppler - HEIC/HEIF/AVIF photos, converted by
sips(macOS),heif-convert(libheif) or ImageMagick - camera RAW photos (CR2, CR3, NEF, ARW, DNG, RAF, ORF, RW2, PEF and more): the embedded
camera preview, found without any external tool; when it is missing or smaller than
1600 px on its long edge the RAW data is developed by
sips(macOS), LibRaw'sdcraw_emu,dcrawor ImageMagick (--raw-decode auto|preview|develop) - PWG Raster and Apple Raster (URF) print jobs
- video through FFmpeg
- audio container placeholder units
- text / Markdown
- JSON and JSON Lines (
.json,.jsonl,.ndjson, or sniffed): one searchable chunk per record with its key paths, API envelopes such as{"data": [...]}split into records, other documents as an indented outline - HTML pages (readable text, title and image alt text; no network fetches). With
--html-render([html] render = true) headless Chrome, Chromium or Edge prints the page offline (no remote images, fonts or scripts) and each printed page becomes an image page with the page's own text as its searchable layer; without a browser or Popplerpdftoppmthe file falls back to readable text with a warning - email (
.eml,.mbox): headers and body as text; attachments imported by their own importers - chat exports: WhatsApp (
_chat.txtor the exported.zip), Telegram Desktop JSON (result.json, one chat or a whole account), Slack workspace exports (the.zip, or extractedYYYY-MM-DD.jsonday files) and iMessage text fromimessage-exporter -f txt. Each conversation becomes text pages with atime sender: textline per message under date headings; photos and files shipped inside the export are imported right after the message that sent them (never from outside the export's folder, and once even when the whole export folder is converted). Slack exports only link files, so those stay named in the text.--chat-date-order auto|dmy|mdy|ymdreads ambiguous WhatsApp dates;--no-chat-attachmentsnames attachments without importing them - archives (
.zip,.tar,.tar.gz/.tgz): an index page plus every member through its own importer, with zip-bomb and path-traversal limits - SRT / VTT captions
- Office documents (Word, Excel, PowerPoint, OpenDocument, RTF) through LibreOffice and Poppler
Enrichment:
- photographed pages: the sheet is found, perspective-corrected and deskewed before OCR
(
--scan-mode auto|on|off, defaultauto) - ExifTool metadata
- ffprobe media metadata
- OCR provider chain:
- Apple Vision on macOS when built with
apple-vision - docTR through local Python when available
- Tesseract CLI
- Apple Vision on macOS when built with
- sidecar captions / transcripts
- video timestamps and scene-selection provenance
- URLs, email addresses, domains, app names, dates and times from OCR, captions and
transcripts alike (
--no-entitiesturns this off). App names come from a small gazetteer, window titles such asBudget.xlsx - Excel, and URL domains (docs.google.com/spreadsheetsis Google Sheets), never from bare capitalized words. Dates ("3 March 2024", "2024-03-03 14:05", "Tuesday at 5pm", "last Friday") are normalized to ISO 8601; relative ones resolve against the capture date from ExifTool or ffprobe when known, and each keeps the moment it was seen or spoken.--date-order dmy|mdy(defaultdmy) reads03/04/2024 - dominant colours of images and keyframes (up to five per page, named
"red", "navy blue", ... with hex and share), so searching a colour finds
the frames it dominates;
--colors offdisables it - location (
--location on|gps|off, defaulton): the source's GPS fix (EXIF, XMP, QuickTimeGPSCoordinatesor ISO 6709locationtags) becomes onelocationannotation on its first unit, reverse geocoded offline to "City, Region, Country" from an embedded GeoNamescities1000table. Place names in OCR text, captions, transcripts and text pages (cities of 100,000 people or more, countries, and a few aliases such as "USA" and "England") becomelocationannotations withattributes.source = textand are searchable in the PDF. GPS-derived locations stay out of the hidden layer and--profile sharedrops them; place names read from the text are kept.
Rendering:
- tagged PDF/A-3a with bookmarks via
krilla(default,--renderer pdfa) - plain searchable PDF via
printpdf(--renderer pdf)
Bundled runtime plugins (separate executables in this workspace):
-
anytopdf-plugin-faces: neutral face boxes, landmarks and counts for images and video keyframes with an embedded YuNet detector -
anytopdf-plugin-whisper: speech-to-text for audio and video through whisper.cpp or an OpenAI-compatible Whisper CLI -
anytopdf-plugin-clip: CLIP image embeddings for search by meaning, plus zero-shot scene tags in the searchable layer -
anytopdf-plugin-objects: YOLO object detection on images and video keyframes through a pure-Rust ONNX runtime -
anytopdf-plugin-sentiment: sentiment and tone of transcript, caption, OCR and document text, from a built-in English lexicon or a local LLM endpoint -
anytopdf-plugin-audio-events: speech, music, silence and raised-voice segments, plus laughter, applause and other sound events with an AudioSet ONNX model -
anytopdf-plugin-face-id: face embeddings for--recognize-faces, through an ArcFace-style ONNX model -
anytopdf-plugin-vlm: keyframe captions, questions, activities, video and scene summaries and a category through a local vision-language model -
anytopdf-plugin-tika: any format Apache Tika reads that no built-in importer claims, through a Tika server ortika-appjar
External plugins are the intended route for model-heavy enrichers such as:
- DETR / open-vocabulary object detection
- speech-to-text engines
- format-specific decoders
- proprietary document systems
helpers/anytopdf-printer is an optional IPP Everywhere printer built on
PAPPL for Linux and macOS. Anything that can print
(macOS, iOS, Windows, Android, CUPS) can print to it, and every job becomes a
searchable PDF in an output folder. It listens on localhost unless told otherwise:
make printer
ANYTOPDF_BIN=target/release/anytopdf \
helpers/anytopdf-printer/anytopdf-printer server -o output-directory=$HOME/PrintedThe helper only spools pages; anytopdf convert does the work, so a saved
job.pwg or job.urf print job converts the same way on any platform.
anytopdf convert . -o archive.pdf
anytopdf convert photo.jpg meeting.mp4 transcript.srt -o searchable.pdf
anytopdf convert meeting.mp4 --transcript meeting.vtt -o meeting.pdf
anytopdf convert . --filter 'invoice|receipt' -o receipts.pdf
anytopdf convert https://example.com/post -o post.pdf
anytopdf convert 'https://www.youtube.com/watch?v=…' -o talk.pdf
anytopdf doctor
anytopdf setup whisper
anytopdf plugins
anytopdf probe some.igl
anytopdf extract archive.pdf --json
anytopdf capture screen --duration 60 -o screen.pdf
anytopdf ask archive.pdf "When is the Acme invoice due?"
anytopdf mcpVideo defaults combine interval sampling and FFmpeg scene-change sampling and then perceptually deduplicate frames.
anytopdf convert meeting.mp4 \
--video-interval 5 \
--scene-threshold 0.30 \
-o meeting.pdfAny http:// or https:// argument is downloaded before discovery and then handled
by the normal importers:
- Web pages become readable text (the page's
<main>or single<article>when it marks one, else the whole body), followed by a snapshot printed by headless Chrome, Chromium or Edge.--url-snapshot auto(default) takes the snapshot when such a browser and Popplerpdftoppmare installed;onalways tries,offnever. SetANYTOPDF_CHROMEto choose the browser. - Video and podcast links on known hosts (YouTube, Vimeo, SoundCloud, Apple
Podcasts and others) are fetched with yt-dlp
together with their captions (
--url-sub-langs, defaulten.*,en), which become timed caption annotations, and the video's chapters tag the frames inside them (chapter: …scene annotations). Audio without captions needs the Whisper plugin for a transcript. SetANYTOPDF_YT_DLPto choose the yt-dlp executable. - Anything else (PDFs, images, audio, text) is saved with an extension from its
URL or
Content-Typeand probed like a local file.
--links FILE (repeatable) converts every link in a list: a text file with one URL
per line (text after the URL is its title, # starts a comment), a browser bookmark
export (Chrome, Edge, Firefox or Safari "Export bookmarks" HTML) or Chrome's profile
Bookmarks JSON. Bookmark folders become nested PDF bookmarks labelled with the
bookmark titles, the default output is named after the list (bookmarks.pdf), and an
unreachable link is skipped with input.unreadable instead of failing the run.
anytopdf convert --links reading-list.txt
anytopdf convert --links ~/Downloads/bookmarks_10_6_26.html -o bookmarks.pdf--url-mode page|media forces a plain download or yt-dlp for every URL. Each source
records url.source, url.fetched and, for redirects, url.final in the manifest
(dropped by --profile share), and the provenance page lists the URL. The default
output is named after the URL, e.g. example.com-post.pdf.
Downloads are capped by --url-max-mb (default 1024) and --url-timeout seconds
(default 600). URLs on loopback, private and link-local addresses are refused unless
--url-allow-private is given. Every URL option can also be set through its
environment variable (ANYTOPDF_URL_MODE, ANYTOPDF_URL_SNAPSHOT,
ANYTOPDF_URL_SUB_LANGS, ANYTOPDF_URL_MAX_HEIGHT, ANYTOPDF_URL_MAX_MB,
ANYTOPDF_URL_TIMEOUT, ANYTOPDF_URL_ALLOW_PRIVATE).
Every option has three equivalent spellings, rclone-style, all derived from one
definition: interval under [video] in the config file is
ANYTOPDF_VIDEO_INTERVAL in the environment and --video-interval on
anytopdf convert. anytopdf convert --help lists every option grouped by type
(Video importer, OCR enricher, …) with its environment variable and default.
Later layers win:
- built-in defaults
~/.config/anytopdf/config.toml($XDG_CONFIG_HOMEif set;%APPDATA%\anytopdf\config.tomlon Windows), or instead the files named by--config PATH(repeatable) orANYTOPDF_CONFIG;--no-configorANYTOPDF_NO_CONFIG=1skips files- environment variables:
ANYTOPDF_PROFILE=share,ANYTOPDF_VIDEO_INTERVAL=2; lists are comma-separated - flags:
--profile share,--video-interval 2 --set TABLE.KEY=VALUE(repeatable), e.g.--set whisper.model=base
# ~/.config/anytopdf/config.toml
profile = "share"
renderer = "pdfa"
plugin_timeout = 120
[video] # ANYTOPDF_VIDEO_*, --video-*
interval = 2.0
max_frames = 200
[ocr] # ANYTOPDF_OCR_*, --ocr-*
mode = "tesseract"
lang = "eng+deu"
[whisper] # a runtime plugin's table, passed to it as `options`
model = "base"Top-level keys are global settings. [image], [video], [ocr], [captions],
[entities], [colors], [location], [raw] and [scan] are the built-in tables and reject unknown keys; any other table is passed to the
runtime plugin with that manifest name (- and _ match) in the options field
of each request. A runtime plugin's keys are set with --set whisper.model=base,
or ANYTOPDF_WHISPER_MODEL once [whisper] is in a config file
(ANYTOPDF_WHISPER__MODEL always works). The older flag names (--ocr, --lang,
--scene-threshold, --dedupe-distance, --max-video-frames,
--max-image-frames, --no-embedded-subtitles, --location, --date-order,
--colors, --no-entities) still work.
anytopdf config prints the effective settings with the file, variable or flag
each came from (--json emits anytopdf.config/1); anytopdf config --defaults
prints a starter file; values under keys such as api_key, token or password
are shown as <redacted>. The file format is schemas/config-file.schema.json. An
invalid value exits 2 and names the key and where it was set. Secrets
(ANYTOPDF_WEBHOOK_SECRET, ANYTOPDF_QUEUE_TOKEN, ANYTOPDF_IMAP_*) and
ANYTOPDF_PLUGIN_PATH stay environment-only and are never read as settings, and
no file is read from the current directory, so a folder you convert cannot change
how plugins run.
anytopdf capture screen records the screen with FFmpeg and converts the
recording like any video: a frame every --interval seconds plus every scene
change, with near-duplicates dropped, then OCR and the usual pipeline.
anytopdf capture screen -o session.pdf # until Ctrl-C
anytopdf capture screen --duration 600 --interval 10 -o standup.pdf
anytopdf capture screen --display 1 --keep-recording s.mkv -- --ocr tesseractIt uses FFmpeg's platform grabber: avfoundation on macOS, gdigrab (whole
desktop) or ddagrab (--display N) on Windows, x11grab on Linux. Pass
--input-format and --input for any other FFmpeg input, such as kmsgrab on a
Wayland session. macOS needs the Screen Recording permission for the terminal app;
anytopdf doctor reports it and the grabber under "Screen capture". Options after
-- go to convert; the recording is deleted unless --keep-recording is given,
and the PDF defaults to screen-<UTC time>.pdf.
anytopdf convert --events writes one NDJSON object per line to stderr
(anytopdf.events/1, schemas/events.schema.json) instead of human progress and
error: lines. Every run ends with exactly one run.finished event whose
status (ok, partial, failed) and exit_code match the process exit code;
a failed run adds an error message. A closed stderr pipe never panics.
anytopdf convert writes tagged PDF/A-3a by default (--renderer pdfa);
--renderer pdf writes plain PDF through printpdf instead. Pages, page numbering and
the embedded manifest and chunks are the same with both. On top of that, pdfa:
- Fonts are always embedded. The first
ANYTOPDF_FONTentry is the primary font; without it the bundled DejaVu Sans (Latin, Greek, Cyrillic, Hebrew, basic Arabic) is used, so no system font is needed.ANYTOPDF_FONTmay list more fonts, separated likePATH, and they are tried next, followed by common system fonts for Arabic, Hebrew and CJK. A fallback font is embedded only when it supplies characters the earlier fonts lack. Fonts whose licence forbids embedding are skipped. Characters no font covers are left out with a render warning, because PDF/A forbids.notdefglyphs. - Each line is reordered with the Unicode bidi algorithm and shaped, so Arabic and
Hebrew read correctly. Right-to-left lines carry
ActualTextwith the logical order for copying and extraction. - The structure tree has a section per unit, a figure with alternate text per image or
frame, a paragraph per source line, and an H1 heading on the provenance page.
Bookmarks point to the first page of each source and to the provenance page. The
document language is
und(undetermined). - The document carries XMP metadata and an sRGB output intent. The manifest and
chunks are PDF/A-3 associated files (
/AF, relationshipData). - The hidden layer is written as text with a fill opacity of 0 rather than text rendering mode 3, because krilla has no mode 3. It is still searchable and extractable.
Output is reproducible under SOURCE_DATE_EPOCH. Text the bundled font covers renders
the same on every host; fallback fonts for other scripts come from the host, so such
text can embed different fonts on different hosts.
anytopdf convert DSC_0042.NEF IMG_1234.CR3 -o shoot.pdf
anytopdf convert receipt.jpg -o receipt.pdf # page found, flattened, deskewed
anytopdf convert scans/ --scan-mode off -o as-shot.pdf # keep photos untouchedRAW files use the camera's embedded JPEG preview by default. --raw-decode develop
prefers a local developer (sips, dcraw_emu, dcraw, ImageMagick) and falls back to
the preview with an input.lossy-decode warning; --raw-decode preview never runs a tool.
--scan-mode auto (the default) flattens a photo only when it clearly shows a sheet
with text on a darker background, and straightens page-filling scans whose text lines
are skewed; other photos are left as they are. on also crops sheets that touch the
frame edges and straightens any photo with text lines. Both options can also be set
with ANYTOPDF_RAW_DECODE and ANYTOPDF_SCAN_MODE.
Every converted PDF embeds two JSON attachments: anytopdf-manifest.json
(anytopdf.manifest/1: sources with SHA-256 and size, units, providers, profile)
and anytopdf-chunks.json (anytopdf.chunks/1: one chunk per unit with page
traceability, plus an optional entities list of {kind, value} such as
{"kind":"url","value":"https://example.com"} with kinds url, email, domain,
app, date, time and datetime; dates and times carry their ISO 8601 value).
Chunks of image pages also carry a words list of {text, x, y, width, height}
boxes in reading order, from OCR or the source PDF's own text, with the top-left
corner and size given as fractions of the page. Schemas live in schemas/. The share profile omits absolute
paths. If embedding fails, the same JSON is written beside the PDF as
<output>.manifest.json and <output>.chunks.json and an informational
manifest.sidecar notice is printed.
anytopdf extract <pdf> --json prints one anytopdf.extract/1 document
(schemas/extract.schema.json) with origin (embedded or sidecar), the
manifest, the chunks and warnings. A manifest or chunks schema_version
other than the supported one adds an extract.version-mismatch warning (also
on stderr) but still exits 0. A version-matched manifest or chunks file that
does not match its schema exits 3 (input); the error names the document and the
first failing JSON path. A PDF with no embedded or sidecar manifest exits
3 (input).
anytopdf convert … --index also records the output in a local SQLite search
index (FTS5, bundled; no server). anytopdf search then searches every indexed
PDF at once and prints the PDF, page, time in the source, kind, the matching text
and the source file:
anytopdf convert meeting.mp4 -o meeting.pdf --index --collection work
anytopdf index add old-archive/*.pdf # PDFs made earlier, from their embedded chunks
anytopdf search budget review # every word must match
anytopdf search '"red car"' --kind object # a phrase, only object detections
anytopdf search --person Alice --json # faces recognised as Alice
anytopdf index list
anytopdf index remove old-archive/a.pdfThe index lives at ANYTOPDF_INDEX, or index.sqlite in the per-user data
directory (~/.local/share/anytopdf on Linux, ~/Library/Application Support/anytopdf on macOS, %LOCALAPPDATA%\anytopdf on Windows); --index-db
names another. It is never written into a PDF. convert --index records one
chunk entry per unit (the unit's searchable text, as in anytopdf-chunks.json)
and one entry per annotation with its kind (ocr, caption, transcript,
face, object, scene, location, …), provider, confidence, region, time
range and attributes, so --kind and --person (faces whose person attribute
names someone) can narrow a search. It records what the PDF holds, after the
output profile is applied. index add reads existing PDFs back from their
embedded chunks, so they only have chunk entries; an unchanged PDF that
convert --index already recorded keeps its richer record. A unit's chunk
entry is left out of results when one of its annotations matched on its own,
because the annotation carries the region and time. --collection NAME tags
PDFs (on convert and index add) and filters searches. Re-indexing a PDF
replaces its entries.
Words are matched case- and accent-insensitively; "quoted words" match as a
phrase and a trailing * matches a prefix. search --json prints one
anytopdf.search/1 document (schemas/search.schema.json) and index add|list --json one anytopdf.index/1 document. Searching without an index exits 3. The
index has room for per-unit embeddings for semantic search.
anytopdf ask <pdf-or-directory> "<question>" answers a question from PDFs that
anytopdf produced. It reads each PDF's embedded chunks (every *.pdf directly in
a directory), ranks them with BM25 and keeps the best --top N (default 8) as
numbered passages. Each passage cites its PDF, pages, time range for audio and
video, and the original file name.
With no LLM configured, ask prints those passages. To get a written answer that
cites them as [n], point it at any OpenAI-compatible server such as llama.cpp,
Ollama, vLLM or LM Studio:
export ANYTOPDF_LLM_URL=http://127.0.0.1:11434/v1 # POSTs to $URL/chat/completions
export ANYTOPDF_LLM_MODEL=llama3.2 # optional: ANYTOPDF_LLM_API_KEY, ANYTOPDF_LLM_TIMEOUT
anytopdf ask meeting.pdf "What did we decide about the launch date?"The question and the retrieved passages are sent to that URL, so use a local
server for private files. If the endpoint fails, ask prints an ask.llm-failed
warning and returns the passages, still exiting 0. --no-llm skips the endpoint.
--json prints one anytopdf.ask/1 document (schemas/ask.schema.json):
mode (llm or retrieval), answer, cited passage numbers, passages and
warnings. A missing or unreadable PDF exits 3, an empty question or a
non-HTTP ANYTOPDF_LLM_URL exits 2.
anytopdf watch imap turns each new message in one mailbox into its own PDF
(release builds include it; source builds need the default imap feature):
export ANYTOPDF_IMAP_PASSWORD=... # or --password-file; never a command-line flag
anytopdf watch imap --host imap.example.com --user scans@example.com \
--allow-from example.com --output-dir ~/mail-pdfs -- --ocr auto --profile share- Each message is fetched with
BODY.PEEK[](the mailbox is not modified unless--mark-seenor--move-to <mailbox>is given), saved as a raw.eml, and converted by a childanytopdf convertinto<output-dir>/<mailbox>-<uidvalidity>-<uid>.pdf. Arguments after--go to convert. The child does not inheritANYTOPDF_IMAP_PASSWORDorANYTOPDF_IMAP_OAUTH_TOKEN. The built-in email importer renders each message (headers, body and attachments through the normal importers). --queue <QUEUE>instead of--output-dirhands each message to ananytopdf queuedirectory as a job (originimap) carrying the options after--; runanytopdf queue work <QUEUE>to convert, with webhooks if wanted.--allow-from(repeatable) accepts an address (scanner@example.com) or a whole domain (example.com, no subdomains); mail from anyone else is skipped, left in the mailbox and not retried. The From header is easy to forge, so add--require-dmarc <authserv-id>(for examplemx.google.comoroutlook.com) to also requiredmarc=passin theAuthentication-Resultsheader your own mail server added; copies of that header further down the message are ignored.--auth login(default) sends IMAPLOGINwith a password or app password.--auth xoauth2signs in to Gmail or Microsoft 365 with an OAuth2 access token fromANYTOPDF_IMAP_OAUTH_TOKENor--oauth-token-file. The file is read again on every connection, so a separate token refresher (for example a cron job using your OAuth client's refresh token) can replace it; anytopdf does not run a browser sign-in.- Progress lives in
<state-dir>/state.json(default.anytopdf-imapinside the output or queue directory), keyed by the mailbox's UIDVALIDITY. The first run only picks up mail that arrives afterwards;--backfilltakes existing mail too. Failed conversions are retried on later checks up to--max-attempts, then the message is kept in<state-dir>/failed. Messages over--max-message-bytesare skipped. Delivery is at least once: a crash mid-conversion converts that message again. --tls implicit(default, port 993) or--tls starttls(port 143) use the operating system's trusted roots plus an optional--ca-file;--tls noneis refused unless the host is loopback.- The watcher waits with IMAP IDLE when the server supports it and otherwise polls
every
--poll-intervalseconds; it reconnects with backoff after network errors.--oncechecks a single time and exits (for cron). Connection settings may also come fromANYTOPDF_IMAP_HOST,_PORT,_TLS,_USER,_MAILBOX,_AUTH,_PASSWORD_FILEand_OAUTH_TOKEN_FILE. Use one--state-dirper mailbox and one watcher per state directory.
anytopdf queue runs conversions from a plain queue directory, so it needs no
database or daemon. Only queue serve listens on a port; the worker only makes outbound
requests when --webhook is given.
anytopdf queue add ~/scans-queue invoice.jpg -- --profile share
anytopdf queue work ~/scans-queue -- --ocr auto # options for inbox files
anytopdf queue work ~/scans-queue --once # drain, then exit
anytopdf queue status ~/scans-queue- Files dropped into
QUEUE/inbox/are claimed once two scans (--poll-interval, default 2 seconds) see the same size and mtime. Dotfiles and*.part,*.tmp,*.crdownload,*.downloadand*.partialnames are ignored. - Job records (
anytopdf.job/1,schemas/job.schema.json) move betweenQUEUE/jobs/pending,running,doneandfailedby atomic rename, so several workers can share a queue. PDFs land inQUEUE/outbox/. - Each job runs
anytopdf convert --events --jsonas a child process;QUEUE/work/<id>/events.ndjsonkeeps its NDJSON events andconvert.jsonits result. Convert options after--are checked by the convert parser;-o,--output-dir,--events,--jsonand--dump-graphbelong to the worker. --job-timeout(default 3600 seconds) stops an overrunning conversion. A running job whose worker died is requeued once its lease (timeout plus 60 seconds) expires.- Runtime plugins in queued jobs run under
--plugin-sandbox containunless you pass another level (anytopdf --plugin-sandbox strict queue work QUEUE, oroffto opt out), so no process a plugin starts outlives its call.
--webhook URL (repeatable) sends Standard Webhooks
job.received, job.completed and job.failed events (anytopdf.webhook/1,
schemas/webhook.schema.json). Requests carry webhook-id, webhook-timestamp
and a webhook-signature HMAC-SHA256 over id.timestamp.body, keyed by
ANYTOPDF_WEBHOOK_SECRET (create one with anytopdf queue secret). Payloads
carry file names and queue-relative output paths, never absolute paths or error
text. Deliveries are stored in QUEUE/webhooks/pending/ before they are sent and
are retried after 5 s, 5 min, 30 min, 2 h, 5 h, 10 h and 10 h; a 410 response or
the last failure moves them to QUEUE/webhooks/failed/. Delivery is
at-least-once, so receivers should deduplicate on webhook-id.
anytopdf queue serve QUEUE is the opt-in HTTP upload intake. It listens on
127.0.0.1:8640 by default; any other address needs --tls-cert and --tls-key,
and 0.0.0.0 or :: also needs --allow-public-bind. Every request needs
Authorization: Bearer $ANYTOPDF_QUEUE_TOKEN (at least 16 characters; anytopdf queue secret makes a good one). Uploads are capped by --max-upload-mb (default
100) and must send Content-Length. Run queue work alongside it to convert them.
export ANYTOPDF_QUEUE_TOKEN=$(anytopdf queue secret)
anytopdf queue serve ~/scans-queue -- --profile share # options for uploaded files
curl -H "Authorization: Bearer $ANYTOPDF_QUEUE_TOKEN" \
--data-binary @scan.jpg 'http://127.0.0.1:8640/v1/jobs?filename=scan.jpg'| Request | Answer |
|---|---|
POST /v1/jobs?filename=NAME with the file as the body |
202 and the job (job_id, state, origin, inputs) |
GET /v1/jobs/<job_id> |
200 and the job, with output, status, exit_code and pages once finished |
GET /v1/jobs/<job_id>/output |
200 and the PDF, or 409 until the job has succeeded |
GET /v1/search?q=WORDS&kind=K&person=P&collection=C&limit=N |
with --search: 200 and an anytopdf.search/1 document (without the index path); 404 otherwise |
Uploaded names are reduced to a plain file name inside the job's work directory.
Clients cannot pass convert options; the server's options after -- apply.
Errors are JSON {"error": "..."} with 401, 411, 413, 404 or 405.
--search (with --index-db, default as for anytopdf search) opens the search
endpoint; have the worker record conversions with queue work QUEUE -- --index.
The print helper listens on localhost only. anytopdf print remote lets phones and
laptops on your Tailscale or WireGuard network print to it: it accepts TLS
connections (ipps://), admits only allowlisted peers, asks for a print user's
password, then passes the job to the helper.
tailscale cert printer.tailnet-name.ts.net
echo 'a long password' | anytopdf print passwd adeel --users ~/.anytopdf/print-users.json
anytopdf print remote --listen 100.101.102.103:8631 --allow-tailnet \
--tls-cert printer.tailnet-name.ts.net.crt --tls-key printer.tailnet-name.ts.net.key \
--users ~/.anytopdf/print-users.jsonIt refuses a non-loopback listener without users and an allowlist, and
0.0.0.0, :: or a /0 allowlist without --allow-public-bind. The signed-in
user replaces the IPP requesting-user-name, so the helper records who really
printed; --receipts receipts.jsonl also appends one anytopdf.print-receipt/1
line per job with the time, peer address, user and job name. The PDF of a print
job carries the job id, name, user and format as print.* source metadata in the
manifest and as a receipt on the provenance page (archive profile only; share
drops them). anytopdf doctor reports the print helper and Tailscale. Discovery:
anytopdf print advertise announces the printer over multicast DNS on the local
network (IPP Everywhere _ipps._tcp with the AirPrint _universal subtype);
multicast does not cross a VPN, so anytopdf print dns-sd --domain home.example --host printer.home.example prints unicast DNS-SD records to add to your own DNS
for remote Apple clients, and anytopdf print url prints the ipps:// URL to add
the printer by hand on Windows and Android. See docs/design/remote-printing.md.
anytopdf mcp runs a Model Context Protocol
server over stdio (newline-delimited JSON-RPC 2.0) so agents can call anytopdf
as tools:
| Tool | Runs | Returns |
|---|---|---|
convert |
anytopdf convert --json |
anytopdf.convert/1 report |
extract |
anytopdf extract --json |
anytopdf.extract/1 document |
ask |
anytopdf ask --json |
anytopdf.ask/1 answer and cited passages |
probe |
anytopdf probe --json |
anytopdf.probe/1 document |
search |
anytopdf search --json |
anytopdf.search/1 document |
capabilities |
anytopdf capabilities --json |
anytopdf.capabilities/1 document |
Each call re-runs the same executable, so tools keep the CLI's validation,
overwrite protection, profiles and exit codes. The JSON document is returned as
both text and structuredContent; a non-zero exit becomes a tool result with
isError: true whose text starts with the exit code and class, followed by
stderr. Global flags given before mcp (--no-plugins, --plugin-timeout,
--allow-plugin-kind, --deny-plugin-kind) apply to every call. Paths are local
to the server and relative paths resolve against its working directory, so give
agents absolute paths. The server reads and writes files with your permissions,
exactly like the CLI.
Register it with an MCP client, for example Claude Code:
claude mcp add anytopdf -- anytopdf --no-plugins mcpor in a client's JSON configuration:
{"mcpServers": {"anytopdf": {"command": "anytopdf", "args": ["mcp"]}}}anytopdf is meant to produce an evidence file: one PDF that is both the human
rendition and the machine index (embedded manifest, chunks, provenance and hashes),
works offline, and is ready for agents to read. - [x] is implemented on main
and - [ ] is planned.
Per-release detail is in ROADMAP.md.
- Content-only hidden text layer (no paths or metadata, no per-page duplication)
- Typed diagnostics with stable codes and INFO/WARNING severities
-
--strictignores mis 8 sing opt 10BC8 ional providers - Provider version detection
- Every frame of multi-frame TIFF/GIF becomes a page (
--max-image-framescaps it, warninginput.frames-not-importedwhen frames are dropped) - Content-sniffed text importer (csv, json, log, code; lossy for non-UTF-8)
-
--transcriptis never silently ignored
- Simple form
anytopdf <inputs...> -o out.pdf, no subcommand,-oanywhere - Automatic
<stem>.pdfnaming, numbered and never clobbering -
--output-dirwrites one PDF per input - Distinct exit codes
- Batch continues past failed inputs by default,
--fail-fastto stop -
--jsonfor convert, probe, doctor and plugins, with capabilities and published JSON Schemas - Help text on every flag
- NDJSON progress events
-
anytopdf askanswers questions with cited passages (MCPasktool) - One definition per option: TOML config file,
ANYTOPDF_*variable and flag (anytopdf configshows where each value came from) -
anytopdf setup whisperturns on speech to text in one step
- Content-derived source and unit IDs with SHA-256 and size
- Source anchors (time span, bounding box, byte range) and a page map
- Embedded versioned manifest and chunks (or sidecar), plus
anytopdf extract --json - Byte-reproducible output with
SOURCE_DATE_EPOCHand recorded provider versions - Archive and share privacy profiles
- Provenance page as the last page (
--no-provenance-pageto omit) - Deterministic chunk IDs and semantic page/chunk headings
- Provenance graph export
- Incremental index mode
- Cross-file search index:
convert --index,index add,search(also over MCP andqueue serve --search) - PDF/A-3a output (
--renderer pdfa) - Tagged PDF and bookmarks (
--renderer pdfa)
- Spike: layout and writer options
- krilla 0.8 writer behind
--renderer pdfa(Rust 1.92, invisible text via fill opacity) -
pdfaas the default renderer - Labelled boxes for object, face and OCR regions on visual pages (
--draw-boxes) - Rendered Markdown
- Arabic, Hebrew and CJK shaping, bidi and font fallback (
--renderer pdfa)
- PDF input: Poppler renders each page and
pdftotext -bbox-layoutlines become the hidden text layer, so only textless pages are OCR'd; without Poppler the page text is imported as text pages with aprovider.missingnotice - HTML importer:
.html/.htm/.xhtmlor a doctype becomes a text page without scripts, styles or markup - URL inputs: web pages become readable text plus a headless-Chrome snapshot, video and podcast links go through yt-dlp with their captions, other links are imported by content type
- Email importer:
.emland.mboxmessages become text pages; attachments and forwarded messages are imported through the registry (nested at most 4 deep), unimportable ones warninput.members-not-imported - Chat exports: WhatsApp, Telegram, Slack and iMessage (
imessage-exportertext) conversations with speakers, timestamps and attachments inline - Archive importer: zip and (gzipped) tar members are extracted into the job workspace under sanitized names (no traversal, links skipped) with caps of 512 MiB per member, 1 GiB per archive, 10,000 entries, a 200:1 zip compression ratio, and 2 GiB / 10,000 members per input across nesting
- HEIC/HEIF/AVIF importer: the first of
sips,heif-convert,magickorconvertthat decodes the photo produces the page; without one the input is skipped withimport.failed - Camera RAW importer: largest embedded JPEG preview (lossless sensor streams skipped, container orientation applied), developed by
sips,dcraw_emu,dcrawor ImageMagick when the preview is missing or small - Phone-photo scans: page detection, perspective correction and deskew before OCR
- Office documents through LibreOffice and Poppler
- JSON and JSON Lines: one searchable chunk per record
- Link lists and browser bookmark exports (
--links), with bookmark folders as PDF bookmarks - Any other format Apache Tika reads (EPUB, Outlook
.msg, XPS, iWork, Visio, …) throughanytopdf-plugin-tika - CAD, image stacks, IGL plugin and a generic command-adapter plugin
- Whisper transcription as a runtime plugin
- Location: offline reverse geocoding of GPS fixes and place names in text
- Face presence, count, bounds and landmarks as a runtime plugin
- Audio events: speech, music, silence, raised voices, and laughter, applause and other sounds with an AudioSet model
- Face recognition against a local, user-enrolled face index (
--recognize-faces) - Object detection (
anytopdf-plugin-objects, YOLO) and zero-shot scene tags with CLIP embeddings (anytopdf-plugin-clip) - Keyframe captions, activities and video and scene summaries from a local vision model (
anytopdf-plugin-vlm) - Sentiment and tone of transcript, caption, OCR and document text (
anytopdf-plugin-sentiment) - Dominant colours per image and keyframe
- Barcode and QR extraction
- Audio chapters and speaker turns
- OCR-text-aware video frame retention
- Webhooks (Standard Webhooks: job.received, job.completed, job.failed, HMAC signature, retries)
- Shared job queue with watched folder input
- HTTP upload input for the job queue
- IMAP watcher: IDLE and polling, sender allowlist with DMARC check, OAuth2 (XOAUTH2) tokens, job-queue hand-off
- IMAP rules beyond sender and search criteria (Paperless-ngx style), quarantine folder
- Email-to-print
- Screen capture (
anytopdf capture screen)
- Spike: PAPPL printer feasibility
- Network printer via IPP Everywhere, built on PAPPL as an optional helper process (
helpers/anytopdf-printer); PWG Raster and Apple Raster print jobs keep their paper size - AirPrint and Mopria certification
- IPP over TLS with a password, localhost by default
- Print receipts on the provenance page
- Remote printing over Tailscale or WireGuard with DNS-based discovery
- Microsoft Universal Print investigation
- Untrusted-input handling shipped with the intake channels: sandboxed conversion without network, size and page caps, zip-bomb rejection, per-sender budgets
- Opt-in OS sandbox (
--plugin-sandbox strict, Linux and macOS) and descendant process containment (contain, all platforms) for runtime plugins - Hard CPU, memory and disk quotas for runtime plugins; sandboxing for built-in providers; Windows
strict
- Release build with LTO and strip (8.58 MB to 6.25 MB on macOS arm64)
- Spike: slim and full build shapes
- Slim and full builds (full bundles LGPL decode-only ffmpeg, OCR models, Whisper base, Noto fonts)
- Homebrew formula, Scoop manifest, cargo-binstall metadata and a GHCR container image built from the release archives
- Published Homebrew tap and Scoop bucket (
Formula/andbucket/in this repository) -
curl | shinstaller (install.sh, checksum-verified) - winget and npx/uvx wrappers
- Signing and notarization
- MCP server mode (
anytopdf mcp) - Agent skill and
llms.txt
Building from source:
- GNU Make and Bash to start the bootstrap (Git Bash on Windows).
- Python 3.11+ for build verification and release tooling.
- Rust 1.92.0 (pinned in
rust-toolchain.toml); packaged binaries do not require Rust. Cargo.lockpins dependencies compatible with this toolchain.
Optional runtime providers:
ffmpeg/ffprobe: video/audio demuxing, keyframes and screen captureexiftool: rich metadatatesseract: OCR fallback- Python +
doctr: docTR OCR fallback
On macOS the apple-vision Cargo feature uses native Vision directly from Rust.
make
./target/release/anytopdf doctor
# Optional: install media/OCR/PDF inspection providers separately
make providersDefault make installs missing supported build tools, the pinned Rust toolchain,
rustfmt and Clippy, then builds the optimized CLI. Bootstrap uses Homebrew on
macOS, apt/dnf/pacman on Linux, or Chocolatey from Git Bash on Windows. Package
installation may need administrator access and network access. GNU Make and Bash
must already be available to start it. On macOS, finish the Apple Command Line
Tools installer if prompted and rerun. Windows needs Visual Studio C++ Build
Tools for MSVC; bootstrap does not install that compiler or Git Bash.
make deps prepares build tools only. make providers additionally installs
FFmpeg, ExifTool, Tesseract and Poppler (plus DejaVu fonts on Linux); it does not
install docTR models or perform audio transcription. Missing providers remain
optional for ordinary conversions. make doctor reports actual availability.
Python selection honors PYTHON=/absolute/path/to/python (quote paths containing
spaces), otherwise tries Python 3.11+ candidates. An invalid explicit override
fails instead of silently choosing another interpreter. Once tools are ready,
cargo build --release --locked remains available directly.
Linux fully-static (run on Linux with musl-tools installed):
rustup target add x86_64-unknown-linux-musl
CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER=musl-gcc \
cargo build --release --locked -p anytopdf --no-default-features --target x86_64-unknown-linux-muslWindows MSVC builds use static CRT flags from .cargo/config.toml.
macOS cannot fully statically link Apple system frameworks; the application binary itself remains a single executable and uses the system Vision framework.
- Core orchestration never switches on individual file extensions.
- Importers own format knowledge.
- Enrichers append annotations; they do not rewrite unrelated data.
- Every annotation records provider/provenance.
- Temporary derived artifacts live inside one job workspace.
- Renderer consumes only the normalized graph, never format-specific objects.
- Plugin failures are isolated and reported; one failed optional enricher does not invalidate the entire job.
- Runtime plugins use paths/JSON, not Rust ABI structs.
- The PDF is the canonical portable artifact; the graph can also be serialized as JSON for debugging or future indexing.
Running without a subcommand shows help. probe FILE reports the selected importer
without decoding media, extracting frames, or running enrichers.
convert, probe, doctor and plugins accept --json and then write exactly one versioned JSON
document to stdout (diagnostics go to stderr); contracts live in schemas/.
convert --json always emits one anytopdf.convert/1 document, including on failure
(status is ok, partial when inputs were skipped but exit is 0, or failed; exit_code
mirrors the process exit code). With --profile share, paths in it are base names.
capabilities prints a table of what this binary and environment support: built-in
importers, enrichers and renderers, OCR providers, external tools and runtime plugins, each
marked available, partial or missing, followed by how to enable what is missing and how to add a
plugin (capabilities --help explains the legend). capabilities --json lists exit codes,
diagnostic codes, profiles, OCR modes, importers and schema ids (anytopdf.capabilities/1).
anytopdf --no-plugins convert notes.txt --ocr off -o notes.pdf
anytopdf convert notes.txt -o notes.pdf --overwrite
anytopdf convert recordings/ --strict -o ../archive.pdf --dump-graph ../archive.json
anytopdf --plugin-timeout 30 --allow-plugin-kind importer plugins
anytopdf --deny-plugin-kind renderer convert document.example -o result.pdfWithout -o, output is <input-stem>.pdf for one input (else anytopdf.pdf) in the
current directory, numbered (notes-1.pdf) if that name exists; --overwrite reuses the
unnumbered name. An explicit existing -o requires --overwrite; input files and explicit transcripts are
protected even with that flag. Put outputs outside input directories so subsequent
directory scans do not ingest them. Writes are staged and atomically published.
anytopdf a.txt b.png --output-dir out/ writes one PDF per input instead of one merged
PDF. Each PDF has its own provenance page and embedded manifest listing only that source.
Names are <input-stem>.pdf in input order; a name already used in the run, by an existing
file (without --overwrite) or by an input becomes <stem>-N.pdf. All PDFs are rendered
before any is published. --dump-graph still writes one whole-run graph. It conflicts with
-o (exit 2).
PDF and JSON outputs are separate file transactions. --strict refuses to publish
when ingestion or rendering produces warnings; normal mode reports warnings and
keeps usable content. --quiet suppresses the success summary, not warnings.
| Code | Class | Meaning |
|---|---|---|
| 0 | success | The PDF was published. |
| 1 | internal | Unexpected failure. |
| 2 | usage | Invalid option or value. |
| 3 | input | Missing, unreadable or unusable input. |
| 4 | provider | An explicitly requested provider (for example --ocr tesseract) is unavailable. |
| 5 | strict | --strict stopped on warnings before publishing. |
| 6 | render | Rendering failed; nothing was published. |
| 7 | fail-fast | --fail-fast stopped on a skipped input; nothing was published. |
Batch behaviour (request Amendment 1): by default a failing input (corrupt,
unsupported or unreadable) is skipped with a warning naming the file, the rest of
the batch continues, and the run exits 0 if a PDF was published. stderr ends with
Summary: N converted, M skipped and one skipped <input>: [<code>] <reason> line
per skipped input. --fail-fast aborts without publishing and exits 7; --strict
still exits 5 on any warning; if no input is usable the run exits 3.
Each notice prints to stderr as INFO [code]: message or WARNING [code]: message.
Informational codes (provider.missing, ocr.fallback, manifest.sidecar) report
optional capabilities or fallbacks and never fail --strict. Every other code is a
warning (for example input.unsupported, import.failed, provider.failed,
render.warning) and makes --strict stop before publishing.
Plugin invocations default to a 60-second timeout. Captured stdout, stderr and
plugin response JSON each have a 16 MiB limit. Metadata providers have 30-second
timeouts, OCR subprocesses 180 seconds, subtitle extraction 120 seconds and each
video extraction pass 300 seconds. --max-video-frames also bounds extracted frames
per pass; 0 means no frame-count limit. These limits are safeguards, not an OS
sandbox: by default plugins run with your account's permissions. Install only
trusted plugins or use --no-plugins. --plugin-sandbox contain ends every process
a plugin starts with its call; --plugin-sandbox strict (Linux and macOS) also
limits plugin writes to the job workspace and blocks network access. See
PLUGIN_PROTOCOL.md for policy details.
Images are decoded by content, normalized to PNG in the temporary workspace, and
rotated according to EXIF orientation. OCR coordinates refer to that normalized
image. Every frame of a GIF or multi-page TIFF becomes a page carrying a frame anchor; --max-image-frames N caps the count (0 = unlimited). Frames are not deduplicated and each is OCRed.
Fonts are subset to the required glyphs and embedded in the PDF. The default pdfa
renderer uses its bundled DejaVu Sans and falls back to system fonts for other scripts;
--renderer pdf loads a system font. Set ANYTOPDF_FONT to a TTF file (or several,
separated like PATH) for a particular script. Missing glyphs produce warnings.
Shaping, bidirectional layout and font fallback apply to pdfa only; the printpdf
renderer draws characters in one font, left to right.
Markdown is rendered as plain text. Audio requires sidecar/explicit transcripts or
a plugin for speech recognition; docTR may download model weights on first use.
--dump-graph is a diagnostic sidecar, not a portable media bundle: visual paths
into the temporary workspace are removed. --profile archive|share (default
archive) selects metadata detail; share also strips local paths, including from stderr diagnostics, the Summary and --json messages.
--draw-boxes[=objects,faces,ocr|all] draws labelled boxes for detected regions over
image and video-frame pages (bare --draw-boxes draws objects and faces); the source
images are not modified.
--no-provenance-page omits the provenance page. SOURCE_DATE_EPOCH fixes the
creation time for reproducible output; an invalid value exits 2.
Use GNU Make, Bash, the pinned Rust toolchain, and Python 3.11+:
make help
make verify
make ci SMOKE_FLAGS=--require-poppler
make packagemake ci runs formatting, checks, Clippy, Rust tests in both feature configurations,
Python tests, a release build and PDF smoke checks. make package builds and
smoke-tests the CLI, then writes its archive and SHA-256 checksum to dist/.
Individual targets include build, build-release, fmt, check, lint, test,
test-no-default, test-python and doctor. make clean keeps release archives.
make release publishes to GitHub: it versions, verifies, commits, tags,
pushes and waits for publication. Use make build-release for a local binary,
make release-plan for a version/notes preview, and make commit-check to check
Conventional Commits. See release and recovery procedures.
Select a build target with TARGET=<triple>; Linux musl builds also use
NO_DEFAULT_FEATURES=1. Set PYTHON=python on Windows (Git Bash and GNU Make are
required), CARGO_TARGET_DIR=<path> for a separate build cache, or DIST_DIR=<path>
for a separate archive directory. Verification runs on the host; cross-compiled
smoke/package targets need an executable that can run on that host.
The smoke test verifies PDF structure, graph output and overwrite protection. With
Poppler (pdftotext, pdfinfo), it also checks extracted Unicode text and pagination.
SMOKE_FLAGS=--require-poppler requires those tools; add --strict when ExifTool
and a Unicode-capable font are installed.
GitHub CI runs make ci on Linux, macOS and Windows for branch pushes and pull
requests. Full-history push checks enforce Conventional Commits; PR checks enforce
the title for squash merging. Dispatch/reusable calls without push/PR context do
not assume event fields. Pushing a v* tag matching the Cargo version and lockfile
runs the checks, packages five native targets, verifies the exact archive/checksum
inventory and publishes a GitHub Release using that changelog section. Manual
release runs upload workflow artifacts only. See RELEASING.md for the release
procedure and validation evidence. All builds use direct shell commands.
Bug reports, importers and plugins are welcome. CONTRIBUTING.md covers the local checks and commit conventions, and PLUGIN_PROTOCOL.md is the place to start for a new format. Report security issues privately as described in SECURITY.md.
Licensed under either of Apache License, Version 2.0 or
MIT license at your option. The bundled DejaVu Sans font ships
under its own license. The embedded
gazetteer (crates/anytopdf-builtin/data/geonames-cities1000.tsv.gz) contains data
from GeoNames, licensed
CC BY 4.0; location annotations
name it in attributes.gazetteer.