pdfopt 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. pdfopt-0.2.0/CHANGELOG.md +133 -0
  2. pdfopt-0.2.0/LICENSE +21 -0
  3. pdfopt-0.2.0/MANIFEST.in +6 -0
  4. pdfopt-0.2.0/PKG-INFO +217 -0
  5. pdfopt-0.2.0/README.md +188 -0
  6. pdfopt-0.2.0/THIRD_PARTY_NOTICES +5 -0
  7. pdfopt-0.2.0/docs/architecture.md +204 -0
  8. pdfopt-0.2.0/pdfopt/__init__.py +7 -0
  9. pdfopt-0.2.0/pdfopt/__main__.py +5 -0
  10. pdfopt-0.2.0/pdfopt/_ect/License.txt +201 -0
  11. pdfopt-0.2.0/pdfopt/_ect/LzFind.c +415 -0
  12. pdfopt-0.2.0/pdfopt/_ect/LzFind.h +80 -0
  13. pdfopt-0.2.0/pdfopt/_ect/ectmodule.cpp +74 -0
  14. pdfopt-0.2.0/pdfopt/_ect/threadLocal.h +30 -0
  15. pdfopt-0.2.0/pdfopt/_ect/zopfli/CONTRIBUTORS +10 -0
  16. pdfopt-0.2.0/pdfopt/_ect/zopfli/COPYING +201 -0
  17. pdfopt-0.2.0/pdfopt/_ect/zopfli/blocksplitter.c +378 -0
  18. pdfopt-0.2.0/pdfopt/_ect/zopfli/blocksplitter.h +62 -0
  19. pdfopt-0.2.0/pdfopt/_ect/zopfli/deflate.cpp +1412 -0
  20. pdfopt-0.2.0/pdfopt/_ect/zopfli/deflate.h +80 -0
  21. pdfopt-0.2.0/pdfopt/_ect/zopfli/katajainen.cpp +248 -0
  22. pdfopt-0.2.0/pdfopt/_ect/zopfli/katajainen.h +51 -0
  23. pdfopt-0.2.0/pdfopt/_ect/zopfli/lz77.c +589 -0
  24. pdfopt-0.2.0/pdfopt/_ect/zopfli/lz77.h +95 -0
  25. pdfopt-0.2.0/pdfopt/_ect/zopfli/match.h +117 -0
  26. pdfopt-0.2.0/pdfopt/_ect/zopfli/squeeze.c +1262 -0
  27. pdfopt-0.2.0/pdfopt/_ect/zopfli/squeeze.h +83 -0
  28. pdfopt-0.2.0/pdfopt/_ect/zopfli/util.c +259 -0
  29. pdfopt-0.2.0/pdfopt/_ect/zopfli/util.h +136 -0
  30. pdfopt-0.2.0/pdfopt/_ect/zopfli/zopfli.h +115 -0
  31. pdfopt-0.2.0/pdfopt/cli.py +289 -0
  32. pdfopt-0.2.0/pdfopt/fonts.py +318 -0
  33. pdfopt-0.2.0/pdfopt/imagedata.py +195 -0
  34. pdfopt-0.2.0/pdfopt/jpegopt.py +346 -0
  35. pdfopt-0.2.0/pdfopt/lossless.py +450 -0
  36. pdfopt-0.2.0/pdfopt/mrc.py +899 -0
  37. pdfopt-0.2.0/pdfopt/optimize.py +433 -0
  38. pdfopt-0.2.0/pdfopt/pdfmodel.py +281 -0
  39. pdfopt-0.2.0/pdfopt/py.typed +0 -0
  40. pdfopt-0.2.0/pdfopt/render.py +152 -0
  41. pdfopt-0.2.0/pdfopt/ssim.py +72 -0
  42. pdfopt-0.2.0/pdfopt/tools.py +56 -0
  43. pdfopt-0.2.0/pdfopt.egg-info/PKG-INFO +217 -0
  44. pdfopt-0.2.0/pdfopt.egg-info/SOURCES.txt +60 -0
  45. pdfopt-0.2.0/pdfopt.egg-info/dependency_links.txt +1 -0
  46. pdfopt-0.2.0/pdfopt.egg-info/entry_points.txt +2 -0
  47. pdfopt-0.2.0/pdfopt.egg-info/requires.txt +10 -0
  48. pdfopt-0.2.0/pdfopt.egg-info/top_level.txt +1 -0
  49. pdfopt-0.2.0/pyproject.toml +65 -0
  50. pdfopt-0.2.0/setup.cfg +4 -0
  51. pdfopt-0.2.0/setup.py +21 -0
  52. pdfopt-0.2.0/tests/__init__.py +0 -0
  53. pdfopt-0.2.0/tests/helpers.py +323 -0
  54. pdfopt-0.2.0/tests/test_cli.py +286 -0
  55. pdfopt-0.2.0/tests/test_fonts.py +126 -0
  56. pdfopt-0.2.0/tests/test_jpegopt.py +208 -0
  57. pdfopt-0.2.0/tests/test_lossless.py +354 -0
  58. pdfopt-0.2.0/tests/test_mrc.py +512 -0
  59. pdfopt-0.2.0/tests/test_optimize.py +339 -0
  60. pdfopt-0.2.0/tests/test_pdfmodel.py +157 -0
  61. pdfopt-0.2.0/tests/test_render.py +24 -0
  62. pdfopt-0.2.0/tests/test_ssim.py +72 -0
@@ -0,0 +1,133 @@
1
+ # Changelog
2
+
3
+ ## Unreleased
4
+
5
+ **Audit fixes**
6
+ - Font cutting: the cmap tables are rebuilt after the subset. Fonts without
7
+ `/Encoding` and with a single (1, 0) table (LibreOffice export) lost the way
8
+ from code to glyph, the rendering check rejected the result, and the image
9
+ changes were switched off together with the fonts. This works now, and when a
10
+ font cut fails, only the font cutting is dropped (`lossless-nofonts`).
11
+ - `-o`/`--output` (a file or a folder; the sources are not touched),
12
+ `-j`/`--jobs`, `--version`. A symlink or a non-PDF named on the command line
13
+ is explained (exit code 2 when there is nothing to do); the total shows
14
+ "already optimized: N".
15
+ - The number of files processed at once is bounded by free memory (about 700 MB
16
+ per file with `-q`, 300 MB without).
17
+ - Dockerfile with all external programs; English README and a results table;
18
+ release workflow (cibuildwheel wheels + sdist → PyPI on a `v*` tag);
19
+ `py.typed`.
20
+ - The "pixel-identical" wording is exact now: the check runs at 100 dpi.
21
+ - All messages, `--help`, docs and tests are in plain English.
22
+
23
+ **Speed**
24
+ - The render check splits pages into chunks by core count instead of 8 pages:
25
+ with 9 pages only 2 of 12 threads were busy. Layered (MRC) mode: a 9-page
26
+ colour scan 55 → 22 s, a grayscale one 42 → 11 s (same result).
27
+ - Compositing while the background is searched is about 15 % faster.
28
+
29
+ **Interface**
30
+ - Without `-v` the output is minimal: a progress line with the share saved (on a
31
+ terminal) and a total line. Warnings about `hpdf-worker` and ECT appear only
32
+ with `-v`; fontTools warnings about slightly damaged fonts are silenced.
33
+ - `--decrypt`: files with encryption that open without a password are decrypted
34
+ and optimized (the result has no encryption). Files that need a password are
35
+ still skipped.
36
+
37
+ **Lossless**
38
+ - TrueType fonts are cut to the glyphs in use (fontTools, glyph ids kept): a Word
39
+ document 1414 → 1073 KB (−24 %), pdfTeX with images from Office −16 %.
40
+ - JPEG is repacked with mozjpeg's `jpegtran` instead of libjpeg: −0.7 % on photo
41
+ scans, −12…28 % on JPEG in papers (files −6…8 %); the same for the JPEG that
42
+ pdfopt creates itself.
43
+ - ECT level 6 for streams above 4 MB: a paper with a 25 MB image 38 s → 11 s at
44
+ a cost of about 0.1 %; Flate is not unpacked beyond the ECT budget.
45
+ - The ECT module rejects levels outside 2–9 (level 1 did not finish).
46
+ - New required dependencies: `fonttools`, `mozjpeg-lossless-optimization`;
47
+ `jpegtran` is no longer needed.
48
+
49
+ **Lossy**
50
+ - MRC: size on par with DjVu at the same quality (9-page scans, `-q 90` and
51
+ `-q 96`: grayscale 298 → 104 KB, colour 390 → 112 KB; DjVu 108 and 111 KB).
52
+ - JBIG2 with a symbol match threshold chosen by letter size, per band of
53
+ lines: 0.80 for normal text, 0.85/0.90 for small text, very small text
54
+ lossless; footnotes get their own mask (protects against 6↔8 and 9↔8 swaps).
55
+ - Thresholds stay at 0.9 or below: jbig2enc 0.28 (Ubuntu 24.04) rejected
56
+ 0.92, and the symbol mode silently did not work.
57
+ - The mask is smoothed by a 3×3 vote.
58
+ - Background at 100/50 dpi instead of 150/100; JPEG 2000 with ratios up to
59
+ 2000:1 (before, at most 50:1, which almost never beat JPEG).
60
+ - Coloured text: one mask per ink instead of a colour JPEG image.
61
+ - A separate threshold for text pages, `--mrc-quality` (default `-q` − 6):
62
+ with `-q 96` MRC used to fail on real scans (a 10.9 MB grayscale scan →
63
+ 104 KB).
64
+ - Scans with an ICC profile (ICCBased, CalRGB) keep their colour space in the
65
+ layers; before, the background was written as DeviceRGB/DeviceGray.
66
+ - Ghostscript is really offered to documents without text: the text-layer test
67
+ fired on any page with content, so the engine never ran.
68
+
69
+ **Reliability**
70
+ - A failure on one MRC page no longer cancels MRC and JPEG for the whole
71
+ document.
72
+ - Image decoding is bounded (512 MB; Flate only to the needed size): a
73
+ decompression bomb cannot exhaust memory. Pillow's `DecompressionBombError`
74
+ is caught everywhere.
75
+ - Lossless RGB→Gray leaves images with `/Decode` alone (the array would become
76
+ invalid).
77
+ - When root replaces a file, the owner is kept.
78
+
79
+ **Build**
80
+ - MIT license (`LICENSE`); the package as a whole is `MIT AND Apache-2.0`
81
+ because of the bundled ECT and Zopfli.
82
+ - The sdist builds: `MANIFEST.in` with the ECT headers; the ECT/Zopfli licenses
83
+ (Apache 2.0) go into the sdist and the wheel (`license-files`); the C++
84
+ sources are no longer put in the wheel. CI builds the sdist and a wheel from it.
85
+ - CI compares SSIM with ffmpeg again and runs the Ghostscript tests.
86
+ - CI runs on Linux, macOS and Windows; `setup.py` knows MSVC and the ECT
87
+ extension is optional (zlib is used if it does not build).
88
+
89
+ **API**
90
+ - `optimize_pdf`: everything after `mode` is keyword-only (a positional
91
+ `dry_run` was easily mixed up with the threshold).
92
+
93
+ ## TODO
94
+
95
+ - **Smoother `-q` scale on scans (decision postponed).** `-q 90`, `-q 85` and
96
+ `-q 80` give byte-identical files on scans, while `-q 96` gives a file four
97
+ times larger. The search reaches its floor (background at the lowest dpi and
98
+ JPEG quality). Options: lower the floors (more dpi steps, a wider JPEG quality
99
+ range) and measure the quality cost, or print the threshold that was really
100
+ reached.
101
+
102
+ ## 0.2.0
103
+
104
+ **Lossless**
105
+ - One parse of the file instead of five independent engines; the passes add up.
106
+ - pikepdf instead of the external `qpdf` (object/xref streams are always
107
+ available), ECT -9 (sources from katzip, `pdfopt/_ect`) for Deflate.
108
+ - Image re-encoding: RGB→Gray, 8→1 bit, PNG predictors, G4/Flate→JBIG2 generic.
109
+ - Stream deduplication, thumbnail removal.
110
+ - Pixel check of the changed pages.
111
+
112
+ **Lossy**
113
+ - MRC: adaptive binarization, normalized background filling, a colour text
114
+ layer, empty pages, JBIG2 in chunks, scans in Flate/JPX, shared layers.
115
+ - JPEG: dpi from the page geometry, quality bisection, Flate photos → JPEG.
116
+
117
+ **Speed**
118
+ - SSIM in-process (ffmpeg's formula), no `ffmpeg` process per page.
119
+ - Candidates are checked from the smallest; the fingerprint uses content hashes
120
+ instead of text extraction.
121
+ - Rendering in chunks, a shared reference, early exit; parallelism across files.
122
+
123
+ **Reliability**
124
+ - The `/PdfOpt` marker: no second compression; `--force`.
125
+ - Signed or encrypted files are "skipped", exit code 0.
126
+ - The signature is searched in objects, not in the file text.
127
+
128
+ **CLI:** `-v`, `--force`, `--safe-text`. Removed: `-j` (always all cores; it
129
+ came back later), `--fast` (always ECT -9), `--version` (it came back later),
130
+ `--pdf-worker` (`hpdf-worker` is taken from `PATH`, a warning if it is missing),
131
+ `--mode`, `--lossless`, `--no-dry-run`, `--no-recursive`, the variables
132
+ `*_HYPER_WORKER`/`HYPER_DRV`, the `third_party` folder. Fixed a crash of
133
+ `pdfopt -h` (a `%` sign in the help).
pdfopt-0.2.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 bortoq
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,6 @@
1
+ # The ECT extension needs its headers to build from the sdist.
2
+ recursive-include pdfopt/_ect *.c *.cpp *.h License.txt
3
+ recursive-include pdfopt/_ect/zopfli COPYING CONTRIBUTORS
4
+ include THIRD_PARTY_NOTICES CHANGELOG.md
5
+ recursive-include docs *.md
6
+ recursive-include tests *.py
pdfopt-0.2.0/PKG-INFO ADDED
@@ -0,0 +1,217 @@
1
+ Metadata-Version: 2.4
2
+ Name: pdfopt
3
+ Version: 0.2.0
4
+ Summary: PDF optimizer: lossless recompression, JPEG re-encoding and layered MRC for scans
5
+ License-Expression: MIT AND Apache-2.0
6
+ Project-URL: Homepage, https://github.com/bortoq/pdf-optimizer
7
+ Project-URL: Issues, https://github.com/bortoq/pdf-optimizer/issues
8
+ Keywords: pdf,compress,optimize,scan,mrc,jbig2
9
+ Classifier: Environment :: Console
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Topic :: Multimedia :: Graphics :: Graphics Conversion
12
+ Classifier: Topic :: Utilities
13
+ Requires-Python: >=3.11
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ License-File: THIRD_PARTY_NOTICES
17
+ License-File: pdfopt/_ect/License.txt
18
+ License-File: pdfopt/_ect/zopfli/COPYING
19
+ Requires-Dist: pikepdf<11,>=9
20
+ Requires-Dist: pypdf<7,>=5
21
+ Requires-Dist: Pillow<13,>=10
22
+ Requires-Dist: numpy<3,>=1.26
23
+ Requires-Dist: fonttools<5,>=4.50
24
+ Requires-Dist: mozjpeg-lossless-optimization<2,>=1.3
25
+ Provides-Extra: dev
26
+ Requires-Dist: pytest>=8; extra == "dev"
27
+ Requires-Dist: ruff>=0.5; extra == "dev"
28
+ Dynamic: license-file
29
+
30
+ # pdfopt
31
+
32
+ A PDF optimizer. It tries several encoders, picks the smallest **verified**
33
+ result and replaces the source file atomically, only if the file got smaller.
34
+
35
+ - **Lossless (default)**: text and images stay as they are; every page whose
36
+ images or fonts changed is compared pixel for pixel at 100 dpi.
37
+ - **Lossy (`-q N`)**: recompresses photos and scans for as long as the SSIM of
38
+ every page stays at or above `N/100`.
39
+
40
+ Everything runs locally; no file leaves your machine. Design notes are in
41
+ [docs/architecture.md](docs/architecture.md).
42
+
43
+ ## Results
44
+
45
+ Scans at 300 dpi, 9 pages, page SSIM ≥ 0.90 (`-q 90`), against Ghostscript:
46
+
47
+ | File | Source | pdfopt `-q 90` | Ghostscript `/ebook` | Ghostscript `/screen` |
48
+ | --- | --- | --- | --- | --- |
49
+ | grayscale scan | 14,939 KB | **104 KB**, SSIM 0.965, 11 s | 1,719 KB, SSIM 0.958 | 685 KB, SSIM 0.889 |
50
+ | colour scan | 12,916 KB | **112 KB**, SSIM 0.970, 22 s | 11,200 KB | 1,936 KB, SSIM 0.896 |
51
+
52
+ Lossless (KB, source → pdfopt; `pikepdf`/qpdf in brackets):
53
+
54
+ | Document | Result |
55
+ | --- | --- |
56
+ | dvips paper | 146 → **124** (130) |
57
+ | pdfTeX paper | 776 → **570** (747) |
58
+ | Word document | 1595 → **1073** (1532) |
59
+ | CCITT scan | 553 → **396** (539) |
60
+
61
+ An independent audit measured a 6-page 300 dpi scan (7.8 MB) at 111 KB with
62
+ SSIM 0.962, against 655 KB / 0.880 for DjVu (`pdf2djvu --lossy`).
63
+
64
+ ## Install
65
+
66
+ ```sh
67
+ pip install -e . # pikepdf, pypdf, Pillow, numpy, fontTools, mozjpeg + builds ECT
68
+ pip install -e '.[dev]' # + pytest, ruff
69
+ ```
70
+
71
+ Python ≥ 3.11 and C/C++ compilers are needed (the ECT module is built on
72
+ install; if the build fails, installation still works and streams are packed
73
+ with zlib). Running straight from a source checkout: `python3 setup.py build_ext --inplace`.
74
+ External programs are listed [below](#external-programs).
75
+
76
+ **Docker** (all programs included):
77
+
78
+ ```sh
79
+ docker build -t pdfopt .
80
+ docker run --rm -v "$PWD":/data pdfopt -q 96 book.pdf
81
+ ```
82
+
83
+ ## Usage
84
+
85
+ ```sh
86
+ pdfopt book.pdf # lossless
87
+ pdfopt -q 96 book.pdf # lossy, page SSIM >= 0.96
88
+ pdfopt --dry-run -r /path/to/books # estimate the saving for a tree
89
+ pdfopt -v *.pdf # verbose log
90
+ pdfopt --decrypt locked.pdf # encrypted, but opens without a password
91
+ pdfopt -o small.pdf book.pdf # write a new file, keep the source
92
+ pdfopt -o out/ -j 4 *.pdf # into out/, at most 4 cores
93
+ python3 -m pdfopt --help
94
+ ```
95
+
96
+ | Option | Meaning |
97
+ | --- | --- |
98
+ | `-q`, `--quality N` | minimum page SSIM `N/100` (1–100); turns on lossy mode |
99
+ | `--dry-run` | compute the result without replacing the source |
100
+ | `-r`, `--recursive` | descend into subfolders |
101
+ | `-v`, `--verbose` | per-file results; on stderr the size and rejection reason of each engine, timings, warnings about missing programs |
102
+ | `--decrypt` | decrypt files that open without a password and optimize them (the owner's restrictions, such as printing or copying, are dropped) |
103
+ | `-o`, `--output PATH` | leave sources alone: write the result to a file (one input) or a directory (several); unchanged and skipped files are copied as they are |
104
+ | `-j`, `--jobs N` | cores to use (default: all) |
105
+ | `--version` | print the version |
106
+ | `--force` | process a file even if it is already optimized |
107
+ | `--safe-text` | with `-q`, code text masks without JBIG2 symbol mode (larger, but similar letters can never be swapped) |
108
+ | `--mrc-quality N` | minimum SSIM of MRC text pages (default 6 below `-q`; 100 with `-q 100`) |
109
+
110
+ **Exit codes:** `0` success (including "no change" and skipped files), `1` at
111
+ least one file failed, `2` bad arguments, or nothing to do because every path
112
+ named was rejected (a symlink, not a `.pdf`).
113
+
114
+ Without `-v` a terminal shows one progress line with the share saved so far,
115
+ and a single total at the end. Signed PDFs and password-protected files are
116
+ **skipped** (`skipped: ...`), which is not an error; files encrypted without a
117
+ password are processed only with `--decrypt`. Already optimized files are
118
+ counted in the total. When several files run at once, their number is bounded
119
+ by free memory (about 700 MB per file with `-q`).
120
+
121
+ ## What it does
122
+
123
+ ### Lossless
124
+
125
+ One parse, several passes, one save:
126
+
127
+ 1. drops page thumbnails, merges identical streams;
128
+ 2. images: RGB with R=G=B becomes DeviceGray, pure black-and-white 8-bit images
129
+ become 1-bit, Flate images get the best PNG predictor, 1-bit Flate and CCITT
130
+ Group 4 are rewritten as **JBIG2 generic** (lossless);
131
+ 3. JPEG: mozjpeg's `jpegtran` (optimized Huffman tables, tuned progressive
132
+ scans) with a pixel-identity check, 0.7–28 % smaller than libjpeg's;
133
+ 4. embedded TrueType fonts are cut to the glyphs the document shows (fontTools,
134
+ glyph ids kept, content streams unchanged); the cmap tables are rebuilt, so
135
+ fonts without `/Encoding` (LibreOffice) work too. Pages with changed fonts
136
+ are compared pixel for pixel; with an AcroForm or any unclear font use the
137
+ font is left alone, and if a cut ever changes the rendering only the font
138
+ step is dropped, not the image steps;
139
+ 5. streams are recompressed by the bundled ECT (like `ect -9`; streams above
140
+ 4 MB like `ect -6`) and saved as object/xref streams.
141
+
142
+ Metadata, text layer, geometry and annotations do not change.
143
+
144
+ ### Lossy (`-q`)
145
+
146
+ On top of the lossless result:
147
+
148
+ - **MRC** for scans (page = one picture): a text-free background at 50–100 dpi
149
+ (JPEG or JPEG 2000, quality searched by SSIM), a full-resolution text mask
150
+ (JBIG2 with shared dictionaries, Group 4 or Flate), one ink colour per mask.
151
+ The JBIG2 symbol threshold depends on letter size, and very small text is
152
+ coded losslessly, so 6 and 8 are never merged the way Xerox scanners did in 2013.
153
+ - **JPEG** for other photos: the smallest JPEG (and dpi, from the content-stream
154
+ geometry) that passes SSIM; Flate photos become JPEG.
155
+ - **Ghostscript** profiles (300/240/200 dpi) for PDFs without text or forms.
156
+ - **Hyper Compress**, an external `hpdf-worker`, if found in `PATH`.
157
+
158
+ ## How a result is verified
159
+
160
+ Candidates are sorted by size and checked from the smallest; the first that
161
+ passes wins.
162
+
163
+ | Check | When |
164
+ | --- | --- |
165
+ | page count, MediaBox/CropBox, rotation, annotation count | always |
166
+ | content stream of every page byte for byte (else text compare) | lossless |
167
+ | metadata (Info and XMP) | lossless |
168
+ | rendering identical at 100 dpi on changed pages | lossless, when images or fonts changed |
169
+ | rendering SSIM (200 dpi, `pdftoppm`) ≥ threshold | lossy: every MRC page and re-encoded page (failing pages revert), all pages for Ghostscript/Hyper |
170
+
171
+ SSIM is computed in-process (numpy) with FFmpeg's `ssim` formula.
172
+
173
+ A marker (`/Root /PdfOpt`) keeps a second run from recompressing a file; `--force`
174
+ overrides it.
175
+
176
+ ## External programs
177
+
178
+ | Program | Used for | Without it |
179
+ | --- | --- | --- |
180
+ | `pdftoppm` (poppler) | render checks, **required for `-q`** | lossless drops image transforms; `-q` unavailable |
181
+ | `jbig2` (jbig2enc) | JBIG2 generic and symbol dictionaries | Group 4/Flate |
182
+ | `opj_compress` (OpenJPEG) | JPEG 2000 backgrounds | JPEG only |
183
+ | `cjpeg` from mozjpeg | denser JPEG | Pillow/libjpeg |
184
+ | `gs` (Ghostscript) | Ghostscript profiles | skipped |
185
+ | `hpdf-worker` (in `PATH`) | Hyper Compress | skipped with a warning |
186
+
187
+ ## Layout
188
+
189
+ ```
190
+ pdfopt/
191
+ cli.py arguments, file discovery, parallelism across files
192
+ optimize.py orchestrator: stages, candidates, verification, marker, atomic replace
193
+ lossless.py lossless passes (structure, images, JPEG, ECT, save)
194
+ fonts.py TrueType subsetting
195
+ jpegopt.py lossy photo recompression
196
+ mrc.py layered scan compression
197
+ pdfmodel.py reading: signature, page fingerprint, marker, image walk
198
+ imagedata.py image decoding, PNG predictors, Group 4
199
+ render.py pdftoppm, VisualGate (SSIM), identity check
200
+ ssim.py SSIM matching ffmpeg
201
+ tools.py external program lookup
202
+ ```
203
+
204
+ ## Tests
205
+
206
+ ```sh
207
+ pytest
208
+ ruff check .
209
+ ```
210
+
211
+ CI runs the tests on Linux, macOS and Windows. Tests that need `pdftoppm`,
212
+ `jbig2`, `ffmpeg`, `gs` or OpenJPEG are skipped when the program is missing.
213
+
214
+ ## License
215
+
216
+ MIT, see [LICENSE](LICENSE). The bundled ECT and Zopfli (`pdfopt/_ect`) are under
217
+ the Apache License 2.0, see [THIRD_PARTY_NOTICES](THIRD_PARTY_NOTICES).
pdfopt-0.2.0/README.md ADDED
@@ -0,0 +1,188 @@
1
+ # pdfopt
2
+
3
+ A PDF optimizer. It tries several encoders, picks the smallest **verified**
4
+ result and replaces the source file atomically, only if the file got smaller.
5
+
6
+ - **Lossless (default)**: text and images stay as they are; every page whose
7
+ images or fonts changed is compared pixel for pixel at 100 dpi.
8
+ - **Lossy (`-q N`)**: recompresses photos and scans for as long as the SSIM of
9
+ every page stays at or above `N/100`.
10
+
11
+ Everything runs locally; no file leaves your machine. Design notes are in
12
+ [docs/architecture.md](docs/architecture.md).
13
+
14
+ ## Results
15
+
16
+ Scans at 300 dpi, 9 pages, page SSIM ≥ 0.90 (`-q 90`), against Ghostscript:
17
+
18
+ | File | Source | pdfopt `-q 90` | Ghostscript `/ebook` | Ghostscript `/screen` |
19
+ | --- | --- | --- | --- | --- |
20
+ | grayscale scan | 14,939 KB | **104 KB**, SSIM 0.965, 11 s | 1,719 KB, SSIM 0.958 | 685 KB, SSIM 0.889 |
21
+ | colour scan | 12,916 KB | **112 KB**, SSIM 0.970, 22 s | 11,200 KB | 1,936 KB, SSIM 0.896 |
22
+
23
+ Lossless (KB, source → pdfopt; `pikepdf`/qpdf in brackets):
24
+
25
+ | Document | Result |
26
+ | --- | --- |
27
+ | dvips paper | 146 → **124** (130) |
28
+ | pdfTeX paper | 776 → **570** (747) |
29
+ | Word document | 1595 → **1073** (1532) |
30
+ | CCITT scan | 553 → **396** (539) |
31
+
32
+ An independent audit measured a 6-page 300 dpi scan (7.8 MB) at 111 KB with
33
+ SSIM 0.962, against 655 KB / 0.880 for DjVu (`pdf2djvu --lossy`).
34
+
35
+ ## Install
36
+
37
+ ```sh
38
+ pip install -e . # pikepdf, pypdf, Pillow, numpy, fontTools, mozjpeg + builds ECT
39
+ pip install -e '.[dev]' # + pytest, ruff
40
+ ```
41
+
42
+ Python ≥ 3.11 and C/C++ compilers are needed (the ECT module is built on
43
+ install; if the build fails, installation still works and streams are packed
44
+ with zlib). Running straight from a source checkout: `python3 setup.py build_ext --inplace`.
45
+ External programs are listed [below](#external-programs).
46
+
47
+ **Docker** (all programs included):
48
+
49
+ ```sh
50
+ docker build -t pdfopt .
51
+ docker run --rm -v "$PWD":/data pdfopt -q 96 book.pdf
52
+ ```
53
+
54
+ ## Usage
55
+
56
+ ```sh
57
+ pdfopt book.pdf # lossless
58
+ pdfopt -q 96 book.pdf # lossy, page SSIM >= 0.96
59
+ pdfopt --dry-run -r /path/to/books # estimate the saving for a tree
60
+ pdfopt -v *.pdf # verbose log
61
+ pdfopt --decrypt locked.pdf # encrypted, but opens without a password
62
+ pdfopt -o small.pdf book.pdf # write a new file, keep the source
63
+ pdfopt -o out/ -j 4 *.pdf # into out/, at most 4 cores
64
+ python3 -m pdfopt --help
65
+ ```
66
+
67
+ | Option | Meaning |
68
+ | --- | --- |
69
+ | `-q`, `--quality N` | minimum page SSIM `N/100` (1–100); turns on lossy mode |
70
+ | `--dry-run` | compute the result without replacing the source |
71
+ | `-r`, `--recursive` | descend into subfolders |
72
+ | `-v`, `--verbose` | per-file results; on stderr the size and rejection reason of each engine, timings, warnings about missing programs |
73
+ | `--decrypt` | decrypt files that open without a password and optimize them (the owner's restrictions, such as printing or copying, are dropped) |
74
+ | `-o`, `--output PATH` | leave sources alone: write the result to a file (one input) or a directory (several); unchanged and skipped files are copied as they are |
75
+ | `-j`, `--jobs N` | cores to use (default: all) |
76
+ | `--version` | print the version |
77
+ | `--force` | process a file even if it is already optimized |
78
+ | `--safe-text` | with `-q`, code text masks without JBIG2 symbol mode (larger, but similar letters can never be swapped) |
79
+ | `--mrc-quality N` | minimum SSIM of MRC text pages (default 6 below `-q`; 100 with `-q 100`) |
80
+
81
+ **Exit codes:** `0` success (including "no change" and skipped files), `1` at
82
+ least one file failed, `2` bad arguments, or nothing to do because every path
83
+ named was rejected (a symlink, not a `.pdf`).
84
+
85
+ Without `-v` a terminal shows one progress line with the share saved so far,
86
+ and a single total at the end. Signed PDFs and password-protected files are
87
+ **skipped** (`skipped: ...`), which is not an error; files encrypted without a
88
+ password are processed only with `--decrypt`. Already optimized files are
89
+ counted in the total. When several files run at once, their number is bounded
90
+ by free memory (about 700 MB per file with `-q`).
91
+
92
+ ## What it does
93
+
94
+ ### Lossless
95
+
96
+ One parse, several passes, one save:
97
+
98
+ 1. drops page thumbnails, merges identical streams;
99
+ 2. images: RGB with R=G=B becomes DeviceGray, pure black-and-white 8-bit images
100
+ become 1-bit, Flate images get the best PNG predictor, 1-bit Flate and CCITT
101
+ Group 4 are rewritten as **JBIG2 generic** (lossless);
102
+ 3. JPEG: mozjpeg's `jpegtran` (optimized Huffman tables, tuned progressive
103
+ scans) with a pixel-identity check, 0.7–28 % smaller than libjpeg's;
104
+ 4. embedded TrueType fonts are cut to the glyphs the document shows (fontTools,
105
+ glyph ids kept, content streams unchanged); the cmap tables are rebuilt, so
106
+ fonts without `/Encoding` (LibreOffice) work too. Pages with changed fonts
107
+ are compared pixel for pixel; with an AcroForm or any unclear font use the
108
+ font is left alone, and if a cut ever changes the rendering only the font
109
+ step is dropped, not the image steps;
110
+ 5. streams are recompressed by the bundled ECT (like `ect -9`; streams above
111
+ 4 MB like `ect -6`) and saved as object/xref streams.
112
+
113
+ Metadata, text layer, geometry and annotations do not change.
114
+
115
+ ### Lossy (`-q`)
116
+
117
+ On top of the lossless result:
118
+
119
+ - **MRC** for scans (page = one picture): a text-free background at 50–100 dpi
120
+ (JPEG or JPEG 2000, quality searched by SSIM), a full-resolution text mask
121
+ (JBIG2 with shared dictionaries, Group 4 or Flate), one ink colour per mask.
122
+ The JBIG2 symbol threshold depends on letter size, and very small text is
123
+ coded losslessly, so 6 and 8 are never merged the way Xerox scanners did in 2013.
124
+ - **JPEG** for other photos: the smallest JPEG (and dpi, from the content-stream
125
+ geometry) that passes SSIM; Flate photos become JPEG.
126
+ - **Ghostscript** profiles (300/240/200 dpi) for PDFs without text or forms.
127
+ - **Hyper Compress**, an external `hpdf-worker`, if found in `PATH`.
128
+
129
+ ## How a result is verified
130
+
131
+ Candidates are sorted by size and checked from the smallest; the first that
132
+ passes wins.
133
+
134
+ | Check | When |
135
+ | --- | --- |
136
+ | page count, MediaBox/CropBox, rotation, annotation count | always |
137
+ | content stream of every page byte for byte (else text compare) | lossless |
138
+ | metadata (Info and XMP) | lossless |
139
+ | rendering identical at 100 dpi on changed pages | lossless, when images or fonts changed |
140
+ | rendering SSIM (200 dpi, `pdftoppm`) ≥ threshold | lossy: every MRC page and re-encoded page (failing pages revert), all pages for Ghostscript/Hyper |
141
+
142
+ SSIM is computed in-process (numpy) with FFmpeg's `ssim` formula.
143
+
144
+ A marker (`/Root /PdfOpt`) keeps a second run from recompressing a file; `--force`
145
+ overrides it.
146
+
147
+ ## External programs
148
+
149
+ | Program | Used for | Without it |
150
+ | --- | --- | --- |
151
+ | `pdftoppm` (poppler) | render checks, **required for `-q`** | lossless drops image transforms; `-q` unavailable |
152
+ | `jbig2` (jbig2enc) | JBIG2 generic and symbol dictionaries | Group 4/Flate |
153
+ | `opj_compress` (OpenJPEG) | JPEG 2000 backgrounds | JPEG only |
154
+ | `cjpeg` from mozjpeg | denser JPEG | Pillow/libjpeg |
155
+ | `gs` (Ghostscript) | Ghostscript profiles | skipped |
156
+ | `hpdf-worker` (in `PATH`) | Hyper Compress | skipped with a warning |
157
+
158
+ ## Layout
159
+
160
+ ```
161
+ pdfopt/
162
+ cli.py arguments, file discovery, parallelism across files
163
+ optimize.py orchestrator: stages, candidates, verification, marker, atomic replace
164
+ lossless.py lossless passes (structure, images, JPEG, ECT, save)
165
+ fonts.py TrueType subsetting
166
+ jpegopt.py lossy photo recompression
167
+ mrc.py layered scan compression
168
+ pdfmodel.py reading: signature, page fingerprint, marker, image walk
169
+ imagedata.py image decoding, PNG predictors, Group 4
170
+ render.py pdftoppm, VisualGate (SSIM), identity check
171
+ ssim.py SSIM matching ffmpeg
172
+ tools.py external program lookup
173
+ ```
174
+
175
+ ## Tests
176
+
177
+ ```sh
178
+ pytest
179
+ ruff check .
180
+ ```
181
+
182
+ CI runs the tests on Linux, macOS and Windows. Tests that need `pdftoppm`,
183
+ `jbig2`, `ffmpeg`, `gs` or OpenJPEG are skipped when the program is missing.
184
+
185
+ ## License
186
+
187
+ MIT, see [LICENSE](LICENSE). The bundled ECT and Zopfli (`pdfopt/_ect`) are under
188
+ the Apache License 2.0, see [THIRD_PARTY_NOTICES](THIRD_PARTY_NOTICES).
@@ -0,0 +1,5 @@
1
+ pdfopt/_ect — Efficient Compression Tool (ECT) Deflate compressor,
2
+ (c) 2014-2026 Felix Hanau, with Zopfli by Google Inc. (Apache License 2.0).
3
+ Sources were taken from the katzip project with its patches
4
+ (ect.patch, ect_alignment.patch) applied. See pdfopt/_ect/License.txt and
5
+ pdfopt/_ect/zopfli/COPYING.