PDF yang dibuat dari foto ponsel biasanya besar bukan karena teksnya. Ukurannya membengkak karena tiap halaman berisi JPEG 3000 piksel yang dipasang di selembar kertas yang hanya butuh sebagian kecil dari resolusi itu. Memperkecilnya berarti menemukan gambar-gambar tersebut, menghitung seberapa rapat gambar itu digambar, melakukan resampling ke ukuran yang masuk akal, lalu menulisnya kembali. PDF Editor di aplikasi desktop osFoundry melakukannya dengan dua klik (All tools, Compress a PDF, Advanced optimization). Di bawah ini ide yang sama dalam sekitar 30 baris Python dengan PyMuPDF dan Pillow, diukur pada sebuah file uji, lalu dibandingkan dengan hasil aplikasi dari input yang sama.
File ujinya adalah surat sewa 6 halaman yang dibuat khusus untuk tulisan ini: tiap halaman berupa JPEG 3024×4032 dengan tekstur butiran kertas dan pencahayaan tidak merata, ditempatkan pada halaman 612×816 pt. Ukurannya 32,227,412 byte (30.7 MiB). Butiran ini penting: noise-lah yang membuat JPEG besar, jadi ukuran absolut di bawah hanya berlaku untuk file ini, dan hasil scan yang lebih bersih akan mulai dari ukuran lebih kecil dan menyusut lebih sedikit.
Cari tahu apa yang memakan ruang
Sebelum mengubah apa pun, jumlahkan isi file berdasarkan jenis kontennya. Untuk dokumen scan, satu baris sudah cukup:
import os, pymupdf doc = pymupdf.open("scan.pdf") img = sum(len(doc.xref_stream_raw(i[0])) for p in doc for i in p.get_images(full=True)) print(img, "of", os.path.getsize("scan.pdf")) 32221241 of 32227412 Gambar menyumbang 99.98% dari isi file. Teks, struktur halaman, dan seluruh pembukuan PDF jika digabung hanya 6,171 byte. Seandainya angkanya 20%, resampling gambar bukan alat yang tepat, dan pengaturan apa pun di kompresor mana pun tidak akan banyak menghemat. Aplikasi ini punya pemeriksaan tersebut (Audit space usage); tabelnya untuk file yang sama menunjukkan Images 32,225,071 byte (99.99%), Content Streams 714, Document Overhead 1,627. Cara hitungnya tidak persis sama dengan one-liner di atas, sehingga kedua angka berbeda beberapa kilobyte.
Gambar tidak punya resolusi sampai ia digambar
JPEG di dalam PDF hanyalah kisi piksel. Kerapatannya dalam piksel per inci ditentukan oleh seberapa besar halaman menggambarnya. PyMuPDF menyediakan kedua bagiannya:
page = doc[0] xref, w, h = page.get_images(full=True)[0][0], *page.get_images(full=True)[0][2:4] rect = page.get_image_rects(xref)[0] # where the page draws it, in points ppi = w / (rect.width / 72) print(w, h, round(rect.width / 72, 2), round(ppi)) 3024 4032 8.5 356 Gambar ini digambar selebar 8.5 inci, jadi 3024 piksel di atas 8.5 inci berarti 356 ppi. Membaca halaman cetak butuh sekitar 150, dan layar cukup dengan lebih sedikit. Gambar yang sama jika digambar setengah ukurannya akan menjadi 712 ppi, itulah sebabnya ppi harus dihitung per gambar per penempatan, bukan dibaca dari field metadata.
Berapa harga resolusi dan kualitas
Untuk memilih angka, berikut hasil uji pada gambar di dua halaman pertama: lima kerapatan target dikali empat kualitas JPEG, dengan rata-rata ukuran satu gambar dan PSNR terhadap sebuah referensi. Referensinya adalah gambar asli yang di-resize ke 1275×1700 (150 ppi) dengan Lanczos; setiap varian di-resize ke grid itu sebelum dibandingkan, jadi baris 100 dan 72 ppi sudah termasuk keburaman akibat diperbesar kembali. PSNR hanyalah angka kasar, dan saya tidak menjalankan OCR di sini. Angka ini hanya untuk menunjukkan di mana kualitas mulai turun dengan cepat.
| target ppi | q90 | q75 | q60 | q40 |
|---|---|---|---|---|
| 356 (ukuran asli) | 3713 KiB / 44.0 dB | 1392 KiB / 41.7 dB | 941 KiB / 40.3 dB | 590 KiB / 39.5 dB |
| 225 | 985 KiB / 41.5 dB | 491 KiB / 39.6 dB | 333 KiB / 38.5 dB | 244 KiB / 37.7 dB |
| 150 | 431 KiB / 38.8 dB | 225 KiB / 36.6 dB | 167 KiB / 35.1 dB | 135 KiB / 33.8 dB |
| 100 | 210 KiB / 33.1 dB | 119 KiB / 31.7 dB | 94 KiB / 30.6 dB | 76 KiB / 29.4 dB |
| 72 | 118 KiB / 28.3 dB | 72 KiB / 27.6 dB | 57 KiB / 26.9 dB | 45 KiB / 26.0 dB |
Ada dua hal yang menonjol. Turun dari 356 ke 150 ppi pada kualitas yang sama membuang sekitar 88% byte (q75: 1392 menjadi 225 KiB), sedangkan hanya menurunkan kualitas JPEG pada ukuran penuh, dari q90 ke q40, masih menyisakan 590 KiB, lebih dari dua kali 225 KiB milik 150 ppi pada q75. Dan di bawah 150, nilai desibel anjlok: 100 ppi pada q90 lebih rendah daripada 150 ppi pada q40. Untuk halaman berisi teks cetak, 150 ppi dengan kualitas menengah adalah titik tekuk kurva pada data ini.
Skripnya
Aturannya sama dengan yang tertulis di dialog aplikasi: downsample ke 150 ppi jika gambar digambar di atas 225 ppi, JPEG dengan kualitas menengah, dan ganti gambar hanya jika hasilnya lebih kecil.
import io, os, sys, pymupdf from PIL import Image def shrink(src, dst, target=150, above=225, quality=60): doc = pymupdf.open(src) seen = set() for page in doc: for info in page.get_images(full=True): xref, w, h = info[0], info[2], info[3] if xref in seen: continue seen.add(xref) rect = page.get_image_rects(xref)[0] ppi = w / (rect.width / 72) if ppi <= above: continue raw = doc.xref_stream_raw(xref) im = Image.open(io.BytesIO(raw)).convert("RGB") im = im.resize((round(w * target / ppi), round(h * target / ppi)), Image.LANCZOS) out = io.BytesIO() im.save(out, "JPEG", quality=quality) if out.tell() >= len(raw): continue # not smaller: keep the original doc.update_stream(xref, out.getvalue(), compress=False) doc.xref_set_key(xref, "Width", str(im.width)) doc.xref_set_key(xref, "Height", str(im.height)) doc.xref_set_key(xref, "Filter", "/DCTDecode") doc.xref_set_key(xref, "ColorSpace", "/DeviceRGB") doc.save(dst, garbage=4, deflate=True) shrink(sys.argv[1], sys.argv[2]) print(os.path.getsize(sys.argv[1]), "->", os.path.getsize(sys.argv[2])) Pada file uji, dengan PyMuPDF dan Pillow dari uvx, prosesnya memakan 2.8 detik di Mac ini:
30.7 MiB -> 0.92 MiB (97% smaller), 6 images replaced Ada satu jebakan yang membuat pengukuran pertama saya salah. Versi pertama saya memakai page.replace_image(xref, stream=...), yang tampak seperti pilihan paling jelas, dan hasilnya 1.83 MiB, dua kali lipat dari seharusnya. Fungsi itu menambahkan gambar baru sebagai objek baru sementara objek lama tetap tinggal di file, sehingga file menyimpan kedua versi tiap halaman: pemindaian stream di file itu menunjukkan 12 stream besar, dan tiap yang baru punya kembaran berukuran sama. Menulis stream ke objek yang sudah ada dengan update_stream lalu memperbaiki key Width, Height, Filter, dan ColorSpace-nya menghasilkan 0.92 MiB, dengan enam stream berukuran 107 sampai 172 KB.
Seberapa dekat dengan aplikasinya
Aplikasi menyimpan input yang sama sebagai salinan, en (optimized).pdf, sebesar 1,097,087 byte (1.0 MiB), 97% lebih kecil, dengan enam gambar dioptimalkan. Membaca balik gambarnya dengan PyMuPDF menunjukkan masing-masing 1275×1700 piksel, 150 ppi di halaman, 130,834 sampai 194,130 byte. Skrip di atas menghasilkan 0.92 MiB, sedikit lebih kecil.
Perbedaannya ada di encoder. Pillow bisa melaporkan tabel kuantisasi sebuah JPEG, dan baris pertama tabel milik aplikasi adalah [13, 9, 8, 13, 19, 32, 41, 49]. Pillow menulis persis baris itu pada quality=60, jadi "Medium" di dialog aplikasi di sini setara kualitas libjpeg 60, sama dengan default di skrip. Gambar dari aplikasi juga mempertahankan warna beresolusi penuh (Pillow membaca chroma sampling-nya sebagai 4:4:4, sedangkan default Pillow adalah 4:2:0). Untuk halaman 1, Pillow dengan 4:4:4 dan optimize=True menghasilkan 178,447 byte dan default-nya 171,678, dibandingkan 194,130 dari aplikasi, jadi aplikasi kemungkinan juga tidak memakai tabel Huffman yang dioptimalkan. Selisihnya hanya beberapa persen ukuran, dan detail warna penuh layak dipertahankan pada scan formulir berwarna.
Ganti hanya jika hasilnya lebih kecil
Pengecekan out.tell() >= len(raw) bukan hiasan. Ambil keluaran aplikasi lalu encode ulang gambar halaman 1 pada kualitas 90: hasilnya 284,133 byte, sedangkan gambar yang sudah ada di file 194,130 byte. Meng-encode ulang JPEG pada kualitas lebih tinggi daripada kualitas yang menghasilkannya membuatnya lebih besar tanpa lebih tajam. Tanpa pengecekan ini, menjalankan kompresor dua kali, atau pada file yang sudah terkompresi, bisa membuat file membesar.
Pengecekan ppi mengerjakan sisanya. Menjalankan skrip pada keluarannya sendiri, dan pada keluaran aplikasi, sama-sama mengganti 0 gambar: semua gambar sudah 150 ppi, di bawah ambang 225, dan file hanya disimpan ulang (salinan 1.0 MiB dari aplikasi menjadi 1.05 MiB, murni akibat penulisan ulang, bukan karena menyentuh gambar).
Di mana cara ini berhenti bekerja
Cara ini hanya membantu jika hasil audit menunjukkan gambar mendominasi isi file. PDF hasil ekspor dari word processor tidak punya gambar halaman untuk di-downsample. Hasilnya juga lossy: penurunan kualitas pada tabel di atas nyata, dan file aslinya harus tetap disimpan apa adanya. Karena itu aplikasi menulis salinan baru di samping file, dan skrip ini pun begitu, sehingga dst harus berbeda dari src.
Ada juga satu angka yang menjelaskan mengapa ini penting di luar ukuran yang tampil di layar. Email melampirkan file dalam base64, yaitu 4 byte teks untuk setiap 3 byte file, ditambah pemisah baris. Meng-encode file uji dengan cara itu memakai base64.encodebytes Python menghasilkan 41.5 MiB untuk PDF 30.7 MiB, sehingga batas 25 MB bisa terlampaui lebih jauh daripada yang ditunjukkan ukuran file. Perhitungan yang sama menempatkan file yang akan dikirim di bawah sekitar 18 MB.
PDF Editor adalah bagian dari aplikasi desktop osFoundry, yang berjalan di komputer Anda dan gratis dari halaman unduhan osFoundry. Cara aplikasi menyimpan file tetap di perangkat dijelaskan di halaman local-first.
