A fast, desktop-environment-neutral PDF toolkit for Linux.
Leht (Estonian: sheet, page, leaf — pronounced "leht") is a viewer, editor and toolbox in one: view, merge, split, compress, annotate, redact, fill forms and sign — locally, with no account and no upload. It looks correct on GNOME, KDE, XFCE and everything else, because it favours no desktop.
Status: pre-alpha. The engine, the
lehtcommand-line tool and a Qt6 viewer with editing (annotations, forms, true redaction) work. The viewer parses every document in a sandboxed worker process, so a malicious PDF costs a respawn, not the application.LEHT_BUILD_UIis stillOFFby default. Expect the CLI surface to shift before 1.0.
Linux has no fast, complete, DE-neutral PDF tool. Okular is KDE-flavoured and heavy; Evince/Papers is GNOME-flavoured and read-only in practice; anything involving real editing means Acrobat under Wine or handing your document to a website. Leht is one native toolkit that covers the operations people actually need.
Design commitments:
- Local by default. Documents never leave the machine. No account, no telemetry.
- Structure-preserving. Editing a page must not rewrite the parts you didn't touch. Merging images must not re-encode JPEGs that were already fine.
- DE-neutral. Qt6 Widgets, XDG portals for file dialogs and dark mode. No desktop's house style imposed on another's.
- Core before pixels. The engine is a plain C++20 library with zero Qt. The GUI is one consumer of it; the CLI is another; bindings are cheap later.
- Fast on real files. The benchmark corpus is the 300-page scanned contract, not a three-page invoice.
One honest limitation while this is pre-alpha: the parser is MuPDF. The viewer never runs
it — a sandboxed leht-worker per document does, and a crash there is contained — but
the CLI parses in-process, so a parser crash takes that one command with it. See
docs/robustness.md.
leht info FILE... pages, size, metadata, encryption
leht render FILE -o OUT.png [-p N] [-z Z] render one page to PNG
leht merge FILE... -o OUT.pdf merge PDFs and images, in order
leht split FILE -o 'part-%03d.pdf' [-n N] split into chunks
leht extract FILE -p RANGES -o OUT.pdf keep only these pages
leht remove FILE -p RANGES -o OUT.pdf drop these pages
leht rotate FILE -p RANGES -d DEG -o OUT.pdf rotate in place
leht compress FILE -o OUT.pdf [--preset P] [-q N] [--linearize]
leht encrypt FILE -o OUT.pdf [--user-pw PW] [--owner-pw PW] [--method M]
leht decrypt FILE -o OUT.pdf [--password PW]
leht redact FILE -o OUT.pdf [--text TERM] [--rect P:X0,Y0,X1,Y1]... [-p RANGES]
leht crop FILE -o OUT.pdf (--box X0,Y0,X1,Y1 | --margins N[,T,R,B]) [-p RANGES]
leht watermark FILE -o OUT.pdf --text TEXT [--opacity F] [--angle DEG] [--under]
leht annots FILE list annotations, with ids
leht annotate FILE -o OUT.pdf [--highlight TEXT] [--note P:X,Y:TEXT] [--freetext P:BOX:TEXT]
[--stamp P:NAME]... [--move ID:BOX]... [--set-text ID:TEXT]...
leht form FILE list form fields
leht fill FILE -o OUT.pdf NAME=VALUE... [--flatten]
leht sign FILE -o OUT.pdf (--p12 ID.p12 | --pkcs11 URI|auto) [--box P:BOX] [--tsa URL]
[--certify no-changes|forms|comments] [--ltv | --lta]
leht ltv FILE -o OUT.pdf [--tsa URL] embed validation data (B-LT, B-LTA)
leht keys [--pkcs11-module LIB] signing keys on ID cards and tokens
leht ocr FILE -o OUT.pdf [-p RANGES] [--lang est+eng] [--dpi N] [--force]
make scanned pages searchable
leht verify FILE [--trust CA.pem]... [--online] [--require-qualified] [--json]
check every signature
leht trusted-list update|status the EU trusted lists: qualified or not
-o output · -p pages · -z zoom (1.0 = 72 DPI) · -n pages per file · -d degrees ·
-q JPEG quality · --preset lossless|print|ebook|screen (default ebook) ·
--method aes256|aes128|rc4 (default aes256) · --linearize
Redaction removes; it does not cover. Text, image pixels and drawing under the box
leave the file, along with everything else that repeats them: overlapping annotations and
fields, thumbnails, marked-content /ActualText, the structure tree and earlier
revisions. Anywhere the term still appears (metadata, bookmarks) is listed, and redact
exits 3. Cropping only hides. fill never runs a document's JavaScript. Details and
how each claim is tested: docs/editing.md.
A signature signs a file, not a document. Signing appends a revision and leaves the
original bytes untouched, so signatures already in the file stay valid — and so saving a
signed document now appends rather than rewrites. verify keeps four questions apart: are
these the signed bytes, whose key was it, is that name trustworthy, and was anything added
afterwards. Exit codes say the same: 4 broken, 5 untrusted, 6 changed after signing, 7
changed in a way a certification or field lock forbids, and with --require-qualified 8
not a qualified electronic signature. The private key never enters the
sandboxed worker that parses the PDF, and the signature blobs coming out of a document are
only ever parsed inside it: docs/signing.md.
Page ranges are 1-based and inclusive: 1-5,8,12-. A descending range reverses those
pages — -p 5-1 flips them. That is deliberate, not a parsing accident.
compress and encrypt refuse to write over their input. You always get both files,
so you can compare sizes and keep whichever you want. Destroying the original to find out
whether the result was worth it is not a workflow.
Presets target an effective DPI: screen 72, ebook 150, print 300, plus lossless,
which only does object GC, stream de-duplication and recompression. A re-encoded image is
kept only if it actually came out smaller, and images carrying transparency (/SMask
or /Mask) are skipped entirely, since JPEG has no alpha channel.
The DPI estimate is a conservative heuristic: effective DPI is inferred from an image's
pixel dimensions against a nominal 11-inch page, not from its measured placement in the
content stream. It only ever shrinks toward the budget and never upsamples. One measured
example: a 466 KB merge of a 150 DPI scan plus text went to 106 KB at --preset screen,
77% smaller. Your files will differ.
Done
-
M1 — core + CLI: context, document model, renderer, page cache, and the ops layer behind the commands above; fuzz targets and sanitizer runs.
-
M2 — Qt6 viewer: scroll, zoom, rotate, search, text selection, outline, thumbnails, encrypted documents, printing.
-
M3 — process isolation: the viewer never parses a PDF. A fresh
leht-workerper document does, under seccomp-bpf, rlimits and namespaces, and a crash in it is contained and reported. Design, threat model and measured cost: docs/robustness.md. -
M4 — editing: true redaction (content removed, not covered), crop, watermarks, annotations and form filling, in the CLI and in the viewer. The viewer edits through the sandboxed worker, with undo and redo, and a crash loses no edits. See docs/editing.md.
-
M5 — signatures: PAdES signing from a PKCS#12 key (B-B, or B-T with a timestamp authority), verification that keeps integrity, identity, trust and later changes apart, incremental saving so signatures survive, and a visible signature mark that says it is only a picture. The key stays out of the sandbox; the hostile DER stays inside it. See docs/signing.md.
-
ID-card signing: a key that stays on an Estonian ID card, or any PKCS#11 smartcard or token, in the CLI (
--pkcs11,leht keys) and the viewer's Sign dialog. Tested against SoftHSM2 only; not yet tried with a real card and reader. -
Release readiness:
cmake --installwith desktop integration, CI on every push, and the whole suite, sanitizers and a fuzz pass run against the pinned MuPDF 1.28.4. -
Viewer editing: move and resize annotations, free text typed in place, crop to a box, and every watermark and crop option the CLI has. See docs/editing.md.
-
Certification, field locks, encrypted signing: certify a document for no changes, form filling, or comments; a form's field locks enacted; every later change judged against them (
verifyexits 7 on a forbidden one); encrypted PDFs signed as they are. See docs/signing.md. -
Long-term validation: PAdES B-LT and B-LTA — the certificates, OCSP responses and CRLs a signature needs embedded in the document, and a document timestamp over them, so it can be checked after its certificate expires; revocation checked against them, or online on request. Only certificate identifiers ever go over the network. See docs/signing.md.
-
The EU trusted lists: fetched and verified on request from the Official Journal's anchor down, they make qualified CAs and timestamp authorities trusted — an Estonian ID-card signature verifies as trusted with nothing added — and say whether a signature is a qualified electronic signature. See docs/signing.md.
-
OCR: scanned pages made searchable with an invisible text layer — Estonian and English by default — read by Tesseract in its own sandboxed worker. See docs/ocr.md.
Later — packaging as Flatpak, RPM and DEB, once Leht has been used day to day.
Explicitly out of scope for v1: in-place text editing. It requires font matching against subsetted embedded fonts plus line reflow, works only on simple documents, and fails visibly on complex ones. If it ever ships it will be a labelled experimental mode, never a headline promise.
Windows and macOS are not v1 targets. Qt6 keeps that door open at no extra cost today.
| Path | What it is |
|---|---|
core/ |
The engine: context, document, renderer, page cache, ops. Never links Qt. |
cli/ |
leht, the headless command-line tool over the full core surface. |
ui/ |
The Qt6 Widgets viewer. Parses nothing itself. Off by default at build time. |
worker/ |
leht-worker: the sandboxed process that parses documents for the viewer. |
ipc/ |
The viewer ⇄ worker wire format. Decodes as if the worker were hostile. |
crypto/ |
Signing and verification: PKCS#12, CMS/PAdES, RFC 3161. Links OpenSSL only, never MuPDF, and parses no PDF. |
bench/ |
Benchmark harness — timings against a real-world corpus. |
tests/corpus/ |
Generated test PDFs. Unit tests live in core/tests/. |
core/ links MuPDF only — MuPDF 1.28 covers merging (pdf_graft_page) and
compression, linearisation and AES-256 encryption (pdf_write_options), so there is no
second PDF library. MuPDF is linked PRIVATE and no public header names a MuPDF type,
only forward-declared opaque pointers. That keeps the render backend swappable and stops
consumers inheriting MuPDF's include path. Keep it that way.
Rendering is one independent leht::Context per thread, never cloned contexts — a
measured constraint, not a style choice. See docs/threading.md.
Requires CMake 3.28+, Ninja, a C++20 compiler, MuPDF, OpenSSL 3.2+ and libseccomp (for
leht-worker). Qt6 only for the viewer.
MuPDF 1.28.4 or newer is strongly recommended. Older versions — including the 1.28.2
that Fedora 44 ships — abort the process on some malformed files. Leht still builds and
works against them, and the build warns; don't open untrusted PDFs on one. See
docs/robustness.md. tools/build-mupdf.sh downloads, checks and
builds the pinned 1.28.4 into ~/.cache/leht and prints its path:
cmake -S . -B build -G Ninja -DLEHT_MUPDF_ROOT="$(tools/build-mupdf.sh)"# Fedora
sudo dnf install gcc-c++ cmake ninja-build mupdf-devel openssl-devel libseccomp-devel \
p11-kit-devel tesseract-devel leptonica-devel \
libxml2-devel xmlsec1-devel xmlsec1-openssl-devel libtool-ltdl-devel
sudo dnf install tesseract-langpack-est tesseract-langpack-eng # OCR languages
sudo dnf install qt6-qtbase-devel qt6-qtsvg # only for the viewer
# Debian / Ubuntu
sudo apt install g++ cmake ninja-build libmupdf-dev libssl-dev libseccomp-dev libp11-kit-dev \
libtesseract-dev libleptonica-dev tesseract-ocr-est tesseract-ocr-eng \
libxml2-dev libxmlsec1-dev libltdl-dev
sudo apt install qt6-base-dev qt6-svg-plugins # only for the viewer (Debian 13+)cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo
cmake --build build
ctest --test-dir buildRunning the full test suite wants a few extra command-line tools that Leht itself never needs at runtime — see CONTRIBUTING.md.
CI (.github/workflows/ci.yml) builds and tests three ways on Fedora 44: against the
system MuPDF, against the pinned 1.28.4, and against 1.28.4 under ASan and UBSan with a
mutation-fuzz pass. tools/ci-local.sh system|pinned|asan runs the same job in a podman
container, from a read-only copy of the tree.
cmake -S . -B build-release -G Ninja -DCMAKE_BUILD_TYPE=Release -DLEHT_BUILD_UI=ON \
-DCMAKE_INSTALL_PREFIX="$HOME/.local" -DLEHT_MUPDF_ROOT="$(tools/build-mupdf.sh)"
cmake --build build-release
cmake --install build-releaseThat installs leht and leht-viewer into bin/, leht-worker into libexec/leht/, and
a desktop entry and icon, so Leht appears in the application menu and in "Open with" for
PDFs. The viewer finds its worker relative to its own binary, so the tree can be moved.
To remove it: xargs rm -v < build-release/install_manifest.txt.
Build options — all default as shown:
| Option | Default | Effect |
|---|---|---|
LEHT_BUILD_CLI |
ON |
Build the leht command-line tool |
LEHT_BUILD_UI |
OFF |
Build the Qt6 viewer (requires LEHT_BUILD_WORKER) |
LEHT_BUILD_WORKER |
ON |
Build leht-worker, the sandboxed parser process (needs libseccomp) |
LEHT_BUILD_TESTS |
ON |
Build the test suite |
LEHT_BUILD_BENCH |
ON |
Build the benchmark harness |
LEHT_SANITIZE |
OFF |
AddressSanitizer + UBSan |
AGPL-3.0-or-later — see LICENSE. Leht is open source and free forever. That choice is deliberate and it is what unlocks MuPDF as the engine; see docs/licensing.md for the dependency chain and what the licence obliges.
Leht is Estonian for a sheet of paper, a page, and a leaf — all three at once, which is about as close to "a PDF" as a natural word gets. See docs/branding.md.