pdfdistill qpdf
Description
Reduce bloated PDFs (e.g. Microsoft Print to PDF) — C++/qpdf implementation
README
pdfdistill-qpdf
Shrinks bloated PDFs — in particular the ones produced by Microsoft Print to PDF, which
embeds complete TrueType fonts and stores every image as lossless Flate — by deduplicating
objects, subsetting fonts, recompressing/downsampling images and rewriting the file structure.
This repository contains:
- a 4D component (
pdfdistill-qpdf/) that lets 4D applications distill
PDFs through the classcs.qpdf.distill; - the command-line tool it wraps: a small, self-contained C++17
program built on qpdf, HarfBuzz and libjpeg-turbo, linked
statically, with only permissively licensed dependencies.
4D component
The component bundles the pdfdistill-qpdf executable — a signed and notarized universal
binary for macOS, and static builds for Windows x64 and ARM64 — in
Resources/bin/{macOS,Windows,WindowsARM} (see CI / releases) and runs it with
4D.SystemWorker. If the executable for the current platform is missing, pdfdistill-qpdf
is looked up on the PATH. No plug-in is involved. It targets 4D 20 and later.
Installation
Project Dependencies (4D 20 R6 and later): add the component from the Project
Dependencies panel (GitHub repository miyako/pdfdistill-qpdf), or declare it in your
project's Project/Sources/dependencies.json:
{
"dependencies": {
"pdfdistill-qpdf": {
"github": "miyako/pdfdistill-qpdf",
"version": "^20.0.0"
}
}
}Manual: download pdfdistill-qpdf.zip from the
latest release and place the
compiled component in your project's Components folder.
The component's classes are exposed in the cs.qpdf class store.
Quick start
var $distill : cs.qpdf.distill
$distill:=cs.qpdf.distill.new()
var $version : Text
$version:=$distill.version() // e.g. "0.1.0"
var $in; $out : 4D.File
$in:=Folder(fk desktop folder).file("report.pdf")
$out:=Folder(fk desktop folder).file("report.distilled.pdf")
// file to file, synchronous; each task is a collection of CLI arguments
$distill.execute([$in; "-o"; $out; "--quality"; 60; "--max-dpi"; 150])
// several tasks in one call, e.g. lossless only
$distill.execute([\
[$in; "-o"; $out; "--lossless"]; \
[Folder(fk desktop folder).file("other.pdf"); "--no-images"]]) // -> other.distilled.pdf
// Blob in (via stdin), Blob out (via stdout)
var $pdf : 4D.Blob
$pdf:=$in.getContent()
var $result : Object
$result:=$distill.execute(["-"; "--quality"; 60; {file: $pdf}])[0]
// $result.response: 4D.Blob (the PDF), $result.responseError: Text, $result.exitCode: IntegerTask elements are mapped to the command line by type: 4D.File → its path, Text → an argument
(flags such as "--lossless"), Number → its value, and an object {data: …; file: …} supplies
a callback context (data) and/or the stdin payload (file, used when the input is "-").
Synchronous calls return one object per task: {response: 4D.Blob; responseError: Text; exitCode: Integer} (stdout, stderr, and 0 success / 1 runtime error / 2 usage error).
For Blob in / Blob out, use ["-"; …options; {file: $blob}]; the PDF is returned in response.
To run asynchronously, pass an onResponse callback; it receives the 4D.SystemWorker
(exitCode, and the CLI's summary line in responseError) and the event object, whose
context is the task's data. The calling process must stay alive until the callback fires:
#DECLARE($params : Object)
If ($params=Null)
CALL WORKER(1; Current method name; {})
Else
var $in; $out : 4D.File
$in:=Folder(fk desktop folder).file("report.pdf")
$out:=Folder(fk desktop folder).file("report.distilled.pdf")
cs.qpdf.distill.new().execute([[$in; "-o"; $out; {data: $out}]]; \
{onResponse: Formula(ALERT(String($1.exitCode)+": "+$1.responseError))})
End if Class documentation
distill— public API:version(),
execute(),terminate(), options, callbacks and examples- Internal:
_CLI,
_CLI_Controller,
_distill_Controller
Command-line tool
The CLI is written in C++17 on top of qpdf and is one
of three sibling implementations (Rust/lopdf, C++/qpdf, Go/pdfcpu) that share
the same command-line interface so they can be benchmarked against each other.
Sample (tests/fixtures/ms-print-to-pdf-1.pdf, 1 page) |
Size | Saved |
|---|---|---|
| Original | 720,651 B | — |
--no-images --no-subset --no-dedup (structure only) |
711,053 B | 1.3 % |
--lossless (dedup + font subsetting + structure) |
577,618 B | 19.8 % |
default (--quality 75 --max-dpi 150) |
115,056 B | 84.0 % |
--max-dpi 30 --quality 60 |
42,323 B | 94.1 % |
Rendered output is pixel-identical outside the two photos (JPEG artifacts only);
text extraction and every used glyph's outline/metrics are preserved (checked by
the test suite).
Usage
pdfdistill-qpdf <INPUT|-> [-o <OUTPUT|->] [options]
INPUT path or '-' for stdin
-o output path or '-' for stdout (default: stdout if input is '-', else <input>.distilled.pdf)
--quality <1-100> JPEG quality for recompressed images (default 75)
--max-dpi <n> downsample images whose effective resolution exceeds n (default 150; 0 = never)
--no-images skip image recompression
--no-subset skip font subsetting
--no-dedup skip duplicate object/image/font removal
--lossless only lossless optimizations (implies no JPEG recompression / downsampling)
-v, --verbose print per-step size statistics to stderr
-q, --quiet
--version, --help
- The default output name replaces a trailing
.pdf(case-insensitive) with
.distilled.pdf(report.pdf→report.distilled.pdf); other names get
.distilled.pdfappended. - Exit code
0on success,1on processing/I/O errors,2on usage errors. - All diagnostics go to stderr; stdout carries only PDF bytes when
-o -is
used (stdin/stdout are switched to binary mode on Windows). - The output is never larger than the input: if the optimized file would be
bigger (or equal), the original bytes are written unchanged. - Encrypted inputs (with or without a user password) are passed through
byte-for-byte unchanged, matching the sibling implementations. - Output is deterministic (same input + options → identical bytes).
Examples:
pdfdistill-qpdf scan.pdf # writes scan.distilled.pdf
pdfdistill-qpdf in.pdf -o out.pdf -v # per-step statistics
cat in.pdf | pdfdistill-qpdf - > out.pdf # filter mode
pdfdistill-qpdf in.pdf -o out.pdf --lossless # no lossy image changes-v output for the sample:
[dedup] merged 0 stream(s), 0 dict/array object(s); size 720651 -> 711053 bytes (1.3%)
font 8 0: kept 15 glyph(s), program 446508 -> 247312 bytes (uncompressed)
[fonts] subset 1 font(s), ...; size 711053 -> 577618 bytes (18.8%)
image 15 0 512x512 -> 512x512 JPEG: 227054 -> 23356 bytes
image 13 0 512x512 -> 512x512 JPEG: 296825 -> 35549 bytes
[images] 2 image(s) JPEG-encoded, 0 downsampled, ...; size 577618 -> 115056 bytes (80.1%)
[structure] object streams + max Flate; size 115056 -> 115056 bytes (0.0%)
(Each step's size is measured by writing the document with the final writer
settings, so object streams/max Flate are already included in the first line.)
What it does
The passes run in this order on an in-memory QPDF document:
- Dedup (
src/dedup.cpp). Every reachable stream is hashed on its
decoded data (qpdf "generalized" decode level; data that can't be decoded
losslessly is hashed raw together with its filter) plus its dictionary
without/Length,/Filter,/DecodeParms. Value-like objects are merged
too: indirect arrays/strings/numbers (e.g./W,/FontBBox), font,
font-descriptor, ExtGState, encoding, pattern and shading dictionaries, and
untyped dictionaries that have no identity keys (/Parent,/P,/Kids,
/Rect, …). Candidates are byte-compared before merging, all references are
rewritten, and the process repeats until a fixed point — so once SMasks or
FontFiles merge, the images/fonts that point to them merge in the next round.
Pages, annotations, outline items, fields and structure elements are never
merged. - Font subsetting (
src/content.cpp,src/fonts.cpp). All content is
walked with qpdf's content-stream parser: page contents, Form XObjects
(recursively), annotation appearance streams, tiling patterns, soft-mask
groups and Type 3 glyph procedures, trackingq/Q,cm,Tf,gs /Font
andTj/TJ/'/". Form XObjects inherit the caller's current font; text
shown with no known font protects every font in that resource scope. ForType0fonts withIdentity-H/Identity-V
encoding and aCIDFontType2descendant withFontFile2, the used CIDs are
mapped through/CIDToGIDMap(Identity or stream) to glyph IDs. The font
program is then subset with HarfBuzzhb-subsetusing
HB_SUBSET_FLAGS_RETAIN_GIDS, so glyph IDs — and thereforeCIDToGIDMap
and the content streams — stay valid; nothing in the content is rewritten.
Composite-glyph components are kept automatically,cmapentries for the
kept glyphs are retained, and layout tables (GSUB,GPOS,GDEF,kern,
…) are dropped because text in a PDF is already shaped.BaseFont/FontName
get a 6-letter subset tag (ABCDEF+Name),/Length1is updated and the
now-inaccurate optional/CIDSetis removed.
A font program is left untouched when it is shared with a simple
(non-CID) font, uses a non-Identity CMap, appears in/AcroForm /DR(form
fields may need more glyphs later), belongs to a font that is not reachable
through any walked resource dictionary, or when content using it fails to
parse. - Images (
src/images.cpp). For 8-bitDeviceGray/DeviceRGB/CalGray/
CalRGB/ICCBased(N=1|3) images with a default/Decodearray and lossless
filters (Flate/LZW/RunLength/ASCII*), the pixels are re-encoded as baseline
JPEG (DCTDecode, libjpeg-turbo, optimized Huffman tables) at--quality
and only kept if smaller. The effective resolution is computed from the CTM
at eachDo; if the lowest resolution at which an image is drawn exceeds
--max-dpi(with 5 % tolerance), it is downsampled with an area-averaging
filter to exactly--max-dpi. Images whose placement is unknown (patterns,
annotation appearances, soft-mask groups, Type 3 glyphs, unparsable
content) are never downsampled. Existing JPEGs are only touched when they
need downsampling./SMaskimages stay lossless (Flate, level 9) and are
resampled to the parent's new size when the parent was downsampled.
Skipped:/ImageMask,/Maskcolor-key arrays, Indexed/Separation/DeviceN/
Lab/CMYK, 1/2/4/16-bit, JPX/JBIG2/CCITT, non-default/Decode. - Structure (
QPDFWriter): object streams + cross-reference streams
(qpdf_o_generate), generalized decode + Flate recompression at level 9
(setRecompressFlate,Pl_Flate::setCompressionLevel(9)), deterministic
/ID, and only objects reachable from the trailer are written (unreferenced
and merged duplicates disappear). Streams that use PNG/TIFF predictors are
kept as-is unless plain Flate is actually smaller, because the writer would
otherwise drop the predictor.
--lossless runs passes 1, 2 and 4. With all three --no-* flags only the
structural rewrite remains.
Building
Dependencies are provided by vcpkg in manifest mode (vcpkg.json, pinned
builtin-baseline) and linked statically. Custom overlay triplets in
cmake/triplets/ build release-only static libraries:
| Triplet | Use |
|---|---|
arm64-osx-pdfdistill / x64-osx-pdfdistill |
macOS 11+, static libs (system libc++/libSystem dynamic) |
x64-windows-static-pdfdistill |
Windows x64, static libs, static CRT (/MT) |
arm64-windows-static-pdfdistill |
Windows ARM64, static libs, static CRT (/MT) |
CMakeLists.txt points VCPKG_OVERLAY_TRIPLETS at that directory and sets
CMAKE_MSVC_RUNTIME_LIBRARY to MultiThreaded, so the whole executable uses
/MT. qpdf is built by its vcpkg port with the native crypto provider only
(REQUIRE_CRYPTO_NATIVE=ON, no OpenSSL/GnuTLS); HarfBuzz is built without
default features (no FreeType/ICU/GLib).
Prerequisites: CMake ≥ 3.21, a C++17 compiler, Git, and a vcpkg checkout:
git clone https://github.com/microsoft/vcpkg.git
./vcpkg/bootstrap-vcpkg.sh -disableMetrics # bootstrap-vcpkg.bat on Windows
export VCPKG_ROOT=$PWD/vcpkgmacOS (universal arm64 + x86_64)
vcpkg builds one architecture per triplet, so the script configures both
architectures separately (each with its own static dependency tree) and merges
the executables with lipo:
brew install ninja
./scripts/build-macos-universal.sh # -> build/universal/pdfdistill-qpdfSingle-architecture build:
cmake -S . -B build/arm64 -G Ninja \
-DCMAKE_TOOLCHAIN_FILE=$VCPKG_ROOT/scripts/buildsystems/vcpkg.cmake \
-DVCPKG_TARGET_TRIPLET=arm64-osx-pdfdistill -DCMAKE_OSX_ARCHITECTURES=arm64
cmake --build build/arm64
ctest --test-dir build/arm64 --output-on-failureotool -L on the result only lists libc++, libSystem and the
ApplicationServices framework.
Note: recent Apple clang searches
/usr/local/includebefore-isystem
directories. The build therefore uses-Ifor dependency headers
(CMAKE_NO_SYSTEM_FROM_IMPORTED) and the macOS triplets inject vcpkg's
include directory into port builds, so stray Homebrew/MacPorts headers (for
example a libjpeg 9jpeglib.h) cannot be mixed with the vcpkg libraries.
Windows (x64 / ARM64, static, /MT)
From a "x64 Native Tools" prompt or any shell with Visual Studio 2022:
cmake -S . -B build/x64 -G "Visual Studio 17 2022" -A x64 ^
-DCMAKE_TOOLCHAIN_FILE=%VCPKG_ROOT%/scripts/buildsystems/vcpkg.cmake ^
-DVCPKG_TARGET_TRIPLET=x64-windows-static-pdfdistill
cmake --build build/x64 --config Release
ctest --test-dir build/x64 -C Release --output-on-failureThe result build/x64/Release/pdfdistill-qpdf.exe depends only on system DLLs
(KERNEL32, …).
For Windows on ARM, build on an ARM64 machine (Visual Studio 2022 or later with
the ARM64 build tools; omit -G to use the newest installed Visual Studio) with
-A ARM64 and the arm64-windows-static-pdfdistill triplet:
cmake -S . -B build/arm64 -A ARM64 ^
-DCMAKE_TOOLCHAIN_FILE=%VCPKG_ROOT%/scripts/buildsystems/vcpkg.cmake ^
-DVCPKG_TARGET_TRIPLET=arm64-windows-static-pdfdistill
cmake --build build/arm64 --config Release
ctest --test-dir build/arm64 -C Release --output-on-failureCI / releases
.github/workflows/build.yml (build) builds and tests on GitHub-hosted runners and uploads
pdfdistill-qpdf-macos-universal.tar.gz (macOS 15, arm64 + x86_64 via lipo),
pdfdistill-qpdf-windows-x64.zip (Windows Server 2022) and
pdfdistill-qpdf-windows-arm64.zip (native windows-11-arm runner) as artifacts. Both
Windows builds are checked for a static CRT with dumpbin /DEPENDENTS. vcpkg binary
packages are cached with actions/cache, keyed by the vcpkg commit, vcpkg.json and the
triplets. Each archive contains the binary, this README, LICENSE,
THIRD_PARTY_LICENSES.md and the dependency license texts. Pull requests only build and
test.
On push to main (changes outside pdfdistill-qpdf/) or a manual run on main, build also:
- code signs the macOS binary (Developer ID, hardened runtime, secure timestamp) and
notarizes it withnotarytool; the job fails unless notarization isAccepted(a bare
Mach-O can't be stapled, so the ticket is fetched online by Gatekeeper); - copies the binaries into the 4D component —
pdfdistill-qpdf/Resources/bin/macOS/pdfdistill-qpdf,
pdfdistill-qpdf/Resources/bin/Windows/pdfdistill-qpdf.exe,
pdfdistill-qpdf/Resources/bin/WindowsARM/pdfdistill-qpdf.exe— and commits them tomain
asgithub-actions[bot]if they changed; - dispatches the Publish workflow (
publish.yml, patch bump), which builds, signs and
releases the 4D component from that commit.
Signing uses the same repository secrets as Publish (APPLE_DEVELOPER_ID_CERTIFICATE,
APPLE_DEVELOPER_ID_CERTIFICATE_PASSWORD, KEYCHAIN_PASSWORD, CODESIGN_APPLE_ID,
NOTARYTOOL_APPLE_ID, NOTARYTOOL_TEAM_ID, NOTARYTOOL_PASSWORD).
Pushing a v* tag creates a GitHub release with the three CLI archives.
Tests
ctest runs 36 tests against tests/fixtures/ms-print-to-pdf-1.pdf using a
helper, pdfcheck (tests/pdfcheck.cpp, built with qpdf + HarfBuzz):
pdfcheck check–qpdf --check-equivalent: loads the file, decodes every
stream (including JPEG), parses all page content, fails on any qpdf warning.pdfcheck compare– same page count; identical sequence of text-showing
operations (font name without subset tag + string bytes); for every used CID
the glyph's extents and advance in the subset font equal the original; same
number of drawn images, SMasks preserved, image data decodable.pdfcheck maxsize– size regression guard (default limit 150,000 bytes,
configurable with-DPDFDISTILL_SIZE_LIMIT=).- CLI behaviour:
--help,--version, usage errors (exit 2), missing input
(exit 1); stdin→stdout round trip (also with explicit-o -) must be
byte-identical to file output;--lossless,--max-dpi 30, structure-only;
re-distilling an already optimized file never grows it; a document made of
three independently imported copies of the fixture (pdfcheck concat) must
dedup back to roughly the size of one; a Form XObject that shows a glyph with
the font inherited from the page (pdfcheck inherit-font) keeps that glyph;
AES-256 encrypted copies (empty and non-empty user password) (pdfcheck encrypt) are passed through unchanged.
Licenses
pdfdistill-qpdf is MIT licensed (see LICENSE). All dependencies are
permissively licensed; see THIRD_PARTY_LICENSES.md.