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.
- pdfopt-0.2.0/CHANGELOG.md +133 -0
- pdfopt-0.2.0/LICENSE +21 -0
- pdfopt-0.2.0/MANIFEST.in +6 -0
- pdfopt-0.2.0/PKG-INFO +217 -0
- pdfopt-0.2.0/README.md +188 -0
- pdfopt-0.2.0/THIRD_PARTY_NOTICES +5 -0
- pdfopt-0.2.0/docs/architecture.md +204 -0
- pdfopt-0.2.0/pdfopt/__init__.py +7 -0
- pdfopt-0.2.0/pdfopt/__main__.py +5 -0
- pdfopt-0.2.0/pdfopt/_ect/License.txt +201 -0
- pdfopt-0.2.0/pdfopt/_ect/LzFind.c +415 -0
- pdfopt-0.2.0/pdfopt/_ect/LzFind.h +80 -0
- pdfopt-0.2.0/pdfopt/_ect/ectmodule.cpp +74 -0
- pdfopt-0.2.0/pdfopt/_ect/threadLocal.h +30 -0
- pdfopt-0.2.0/pdfopt/_ect/zopfli/CONTRIBUTORS +10 -0
- pdfopt-0.2.0/pdfopt/_ect/zopfli/COPYING +201 -0
- pdfopt-0.2.0/pdfopt/_ect/zopfli/blocksplitter.c +378 -0
- pdfopt-0.2.0/pdfopt/_ect/zopfli/blocksplitter.h +62 -0
- pdfopt-0.2.0/pdfopt/_ect/zopfli/deflate.cpp +1412 -0
- pdfopt-0.2.0/pdfopt/_ect/zopfli/deflate.h +80 -0
- pdfopt-0.2.0/pdfopt/_ect/zopfli/katajainen.cpp +248 -0
- pdfopt-0.2.0/pdfopt/_ect/zopfli/katajainen.h +51 -0
- pdfopt-0.2.0/pdfopt/_ect/zopfli/lz77.c +589 -0
- pdfopt-0.2.0/pdfopt/_ect/zopfli/lz77.h +95 -0
- pdfopt-0.2.0/pdfopt/_ect/zopfli/match.h +117 -0
- pdfopt-0.2.0/pdfopt/_ect/zopfli/squeeze.c +1262 -0
- pdfopt-0.2.0/pdfopt/_ect/zopfli/squeeze.h +83 -0
- pdfopt-0.2.0/pdfopt/_ect/zopfli/util.c +259 -0
- pdfopt-0.2.0/pdfopt/_ect/zopfli/util.h +136 -0
- pdfopt-0.2.0/pdfopt/_ect/zopfli/zopfli.h +115 -0
- pdfopt-0.2.0/pdfopt/cli.py +289 -0
- pdfopt-0.2.0/pdfopt/fonts.py +318 -0
- pdfopt-0.2.0/pdfopt/imagedata.py +195 -0
- pdfopt-0.2.0/pdfopt/jpegopt.py +346 -0
- pdfopt-0.2.0/pdfopt/lossless.py +450 -0
- pdfopt-0.2.0/pdfopt/mrc.py +899 -0
- pdfopt-0.2.0/pdfopt/optimize.py +433 -0
- pdfopt-0.2.0/pdfopt/pdfmodel.py +281 -0
- pdfopt-0.2.0/pdfopt/py.typed +0 -0
- pdfopt-0.2.0/pdfopt/render.py +152 -0
- pdfopt-0.2.0/pdfopt/ssim.py +72 -0
- pdfopt-0.2.0/pdfopt/tools.py +56 -0
- pdfopt-0.2.0/pdfopt.egg-info/PKG-INFO +217 -0
- pdfopt-0.2.0/pdfopt.egg-info/SOURCES.txt +60 -0
- pdfopt-0.2.0/pdfopt.egg-info/dependency_links.txt +1 -0
- pdfopt-0.2.0/pdfopt.egg-info/entry_points.txt +2 -0
- pdfopt-0.2.0/pdfopt.egg-info/requires.txt +10 -0
- pdfopt-0.2.0/pdfopt.egg-info/top_level.txt +1 -0
- pdfopt-0.2.0/pyproject.toml +65 -0
- pdfopt-0.2.0/setup.cfg +4 -0
- pdfopt-0.2.0/setup.py +21 -0
- pdfopt-0.2.0/tests/__init__.py +0 -0
- pdfopt-0.2.0/tests/helpers.py +323 -0
- pdfopt-0.2.0/tests/test_cli.py +286 -0
- pdfopt-0.2.0/tests/test_fonts.py +126 -0
- pdfopt-0.2.0/tests/test_jpegopt.py +208 -0
- pdfopt-0.2.0/tests/test_lossless.py +354 -0
- pdfopt-0.2.0/tests/test_mrc.py +512 -0
- pdfopt-0.2.0/tests/test_optimize.py +339 -0
- pdfopt-0.2.0/tests/test_pdfmodel.py +157 -0
- pdfopt-0.2.0/tests/test_render.py +24 -0
- 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.
|
pdfopt-0.2.0/MANIFEST.in
ADDED
|
@@ -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.
|