फ़ोन की फ़ोटो से बनी PDF आमतौर पर टेक्स्ट की वजह से भारी नहीं होती। वह इसलिए भारी होती है कि हर पेज पर 3000 पिक्सल की एक JPEG लगी होती है, जबकि काग़ज़ को उसके एक छोटे हिस्से की ही ज़रूरत है। साइज़ घटाने का मतलब है उन इमेज को ढूँढना, यह पता लगाना कि वे कितनी घनी ड्रॉ हो रही हैं, उन्हें समझदार साइज़ पर रीसैंपल करना और वापस लिख देना। osFoundry डेस्कटॉप ऐप का PDF Editor यही काम दो क्लिक में कर देता है (All tools, Compress a PDF, Advanced optimization)। नीचे वही तरीक़ा PyMuPDF और Pillow के साथ क़रीब 30 लाइन के Python में है, एक टेस्ट फ़ाइल पर नापा हुआ, और उसी इनपुट पर ऐप के नतीजे से तुलना के साथ।
टेस्ट फ़ाइल इस पोस्ट के लिए बनाया गया 6 पेज का लीज़ एग्रीमेंट है: हर पेज 3024×4032 की JPEG है, जिसमें काग़ज़ का दाना और असमान रोशनी है, और वह 612×816 pt के पेज पर लगी है। फ़ाइल 32,227,412 बाइट (30.7 MiB) की है। यह दाना मायने रखता है: JPEG को बड़ा नॉइज़ बनाता है, इसलिए नीचे के सारे साइज़ इसी फ़ाइल के हैं, और ज़्यादा साफ़ स्कैन छोटे साइज़ से शुरू होगा और कम सिकुड़ेगा।
पहले पता करें कि जगह कौन घेर रहा है
कुछ भी बदलने से पहले फ़ाइल को कंटेंट की क़िस्म के हिसाब से जोड़ लें। स्कैन के लिए एक लाइन काफ़ी है:
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 फ़ाइल का 99.98% हिस्सा इमेज हैं। टेक्स्ट, पेज ट्री और PDF का बाक़ी सारा हिसाब-किताब मिलाकर सिर्फ़ 6,171 बाइट है। अगर यह संख्या 20% आती, तो इमेज रीसैंपल करना सही औज़ार नहीं होता, और किसी भी कंप्रेसर की कोई सेटिंग ज़्यादा नहीं बचा पाती। ऐप में यह जाँच पहले से है (Audit space usage); उसी फ़ाइल के लिए उसकी टेबल में Images 32,225,071 बाइट (99.99%), Content Streams 714, Document Overhead 1,627 दिखता है। वह ठीक उसी तरह नहीं गिनता जैसे ऊपर की एक लाइन गिनती है, इसलिए दोनों संख्याओं में कुछ किलोबाइट का अंतर है।
इमेज की रेज़ोल्यूशन तब तक नहीं होती जब तक वह ड्रॉ न हो
PDF के अंदर JPEG सिर्फ़ पिक्सल का एक ग्रिड है। प्रति इंच पिक्सल में उसकी घनता इससे तय होती है कि पेज उसे कितना बड़ा ड्रॉ करता है। PyMuPDF दोनों हिस्से देता है:
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 इमेज 8.5 इंच चौड़ी ड्रॉ हुई है, तो 8.5 इंच पर 3024 पिक्सल यानी 356 ppi। छपे पेज को पढ़ने के लिए क़रीब 150 चाहिए, और स्क्रीन के लिए उससे भी कम चलता है। यही इमेज आधे साइज़ में ड्रॉ होती तो 712 ppi होती, इसीलिए ppi हर इमेज और हर प्लेसमेंट के हिसाब से निकालनी पड़ती है, किसी मेटाडेटा फ़ील्ड से नहीं पढ़ी जा सकती।
रेज़ोल्यूशन और क्वालिटी की क़ीमत
संख्याएँ चुनने के लिए, पहले दो पेजों की इमेज पर एक स्वीप किया: पाँच टारगेट घनताएँ गुणा चार JPEG क्वालिटी, एक इमेज का औसत साइज़ और रेफ़रेंस के मुक़ाबले PSNR के साथ। रेफ़रेंस मूल इमेज है जिसे Lanczos से 1275×1700 (150 ppi) पर रीसाइज़ किया गया; तुलना से पहले हर वेरिएंट को उसी ग्रिड पर रीसाइज़ किया गया, इसलिए 100 और 72 ppi की पंक्तियों में वापस बड़ा करने की धुंधलाहट भी शामिल है। PSNR एक मोटा-सा पैमाना है, और यहाँ मैंने OCR नहीं चलाया। यह सिर्फ़ यह दिखाने के लिए है कि क्वालिटी तेज़ी से कहाँ गिरने लगती है।
| टारगेट ppi | q90 | q75 | q60 | q40 |
|---|---|---|---|---|
| 356 (मूल साइज़) | 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 |
दो बातें अलग से दिखती हैं। उसी क्वालिटी पर 356 से 150 ppi जाने से क़रीब 88% बाइट घट जाते हैं (q75: 1392 से 225 KiB), जबकि पूरे साइज़ पर सिर्फ़ JPEG क्वालिटी q90 से q40 तक गिराने पर भी 590 KiB बचते हैं, जो q75 पर 150 ppi के 225 KiB के दोगुने से ज़्यादा है। और 150 से नीचे डेसिबल अचानक गिर जाते हैं: q90 पर 100 ppi, q40 पर 150 ppi से भी नीचे है। छपे टेक्स्ट वाले पेज के लिए, इस डेटा में बीच की क्वालिटी पर 150 ppi वह मोड़ है जहाँ कर्व मुड़ता है।
स्क्रिप्ट
नियम वही हैं जो ऐप अपने डायलॉग में बताता है: इमेज 225 ppi से ऊपर ड्रॉ हो तो 150 ppi पर डाउनसैंपल करो, मध्यम क्वालिटी की JPEG बनाओ, और इमेज तभी बदलो जब नतीजा छोटा हो।
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])) टेस्ट फ़ाइल पर, uvx से लिए गए PyMuPDF और Pillow के साथ, इस Mac पर इसे 2.8 सेकंड लगे:
30.7 MiB -> 0.92 MiB (97% smaller), 6 images replaced एक जाल की वजह से मेरी पहली माप ग़लत आई। मेरे पहले वर्शन ने page.replace_image(xref, stream=...) इस्तेमाल किया, जो सबसे सीधा कॉल लगता था, और आउटपुट 1.83 MiB आया, यानी होना चाहिए था उसका दोगुना। उसने नई इमेज को नए ऑब्जेक्ट की तरह जोड़ दिया और पुराने ऑब्जेक्ट फ़ाइल में रह गए, इसलिए फ़ाइल में हर पेज के दोनों वर्शन थे: फ़ाइल की स्ट्रीम स्कैन करने पर 12 बड़ी स्ट्रीम मिलीं, और हर नई के साथ उतने ही साइज़ की एक जोड़ी थी। मौजूदा ऑब्जेक्ट में ही update_stream से स्ट्रीम लिखने और उसकी Width, Height, Filter और ColorSpace कुंजियाँ ठीक करने पर 0.92 MiB मिलता है, और छह स्ट्रीम 107 से 172 KB की रहती हैं।
ऐप कितना क़रीब है
ऐप ने उसी इनपुट को एक कॉपी en (optimized).pdf के रूप में 1,097,087 बाइट (1.0 MiB) पर सेव किया, 97% छोटा, छह इमेज ऑप्टिमाइज़ होकर। PyMuPDF से उसकी इमेज वापस पढ़ने पर हर एक 1275×1700 पिक्सल की निकलती है, पेज पर 150 ppi, और 130,834 से 194,130 बाइट की। ऊपर की स्क्रिप्ट 0.92 MiB देती है, थोड़ा छोटा।
फ़र्क़ एनकोडर में है। Pillow किसी JPEG की क्वांटाइज़ेशन टेबल बता सकता है, और ऐप की टेबल की पहली पंक्ति [13, 9, 8, 13, 19, 32, 41, 49] है। Pillow ठीक यही पंक्ति quality=60 पर लिखता है, यानी ऐप के डायलॉग का "Medium" यहाँ libjpeg क्वालिटी 60 है, जो स्क्रिप्ट के डिफ़ॉल्ट जैसा ही है। ऐप की इमेज में पूरे रेज़ोल्यूशन का रंग भी रहता है (Pillow उनकी chroma sampling 4:4:4 पढ़ता है, जबकि Pillow का डिफ़ॉल्ट 4:2:0 है)। पेज 1 के लिए, 4:4:4 और optimize=True के साथ Pillow 178,447 बाइट देता है और डिफ़ॉल्ट 171,678, जबकि ऐप का 194,130 है, इसलिए ऐप शायद ऑप्टिमाइज़्ड Huffman टेबल भी नहीं बनाता। यह साइज़ में कुछ प्रतिशत का फ़र्क़ है, और रंगीन फ़ॉर्म के स्कैन पर पूरे रंग का ब्योरा बचाए रखना फ़ायदे का सौदा है।
तभी बदलें जब छोटा हो
out.tell() >= len(raw) वाली जाँच सजावट नहीं है। ऐप के आउटपुट की पेज-1 वाली इमेज को क्वालिटी 90 पर दोबारा एनकोड करें: वह 284,133 बाइट की निकलती है, जबकि फ़ाइल में पहले से मौजूद इमेज 194,130 बाइट की है। जिस क्वालिटी से JPEG बनी थी उससे ऊँची क्वालिटी पर दोबारा एनकोड करने से वह बड़ी हो जाती है, और ज़्यादा शार्प नहीं होती। इस जाँच के बिना, कंप्रेसर को दो बार चलाने पर, या पहले से कंप्रेस्ड फ़ाइल पर चलाने पर, फ़ाइल बढ़ सकती है।
बाक़ी काम ppi की जाँच करती है। स्क्रिप्ट को उसके अपने आउटपुट पर और ऐप के आउटपुट पर चलाने से दोनों बार 0 इमेज बदली गईं: हर इमेज पहले से 150 ppi पर है, 225 की सीमा से नीचे, और फ़ाइल बस दोबारा सेव होती है (ऐप की 1.0 MiB की कॉपी 1.05 MiB की निकली, सिर्फ़ दोबारा लिखने की वजह से, किसी इमेज को छुए बिना)।
यह कहाँ काम नहीं करता
यह तभी मदद करता है जब ऑडिट बताए कि फ़ाइल का ज़्यादातर हिस्सा इमेज है। वर्ड प्रोसेसर से एक्सपोर्ट की गई PDF में डाउनसैंपल करने लायक़ पेज इमेज होती ही नहीं। और नतीजा lossy है: ऊपर की टेबल में दिखी क्वालिटी की कमी असली है, और मूल फ़ाइल जस की तस रखनी चाहिए। इसी वजह से ऐप फ़ाइल के बगल में नई कॉपी लिखता है, और स्क्रिप्ट भी यही करती है, जिसके लिए dst का src से अलग होना ज़रूरी है।
एक संख्या और है जो बताती है कि स्क्रीन पर दिखने वाले साइज़ से आगे यह क्यों मायने रखता है। मेल फ़ाइलें base64 में अटैच करता है, यानी फ़ाइल के हर 3 बाइट पर टेक्स्ट के 4 बाइट, ऊपर से लाइन ब्रेक। टेस्ट फ़ाइल को Python के base64.encodebytes से इस तरह एनकोड करने पर 30.7 MiB की PDF के लिए 41.5 MiB मिलता है, इसलिए 25 MB की सीमा फ़ाइल साइज़ से दिखने वाले अंदाज़े से कहीं ज़्यादा बार टूट सकती है। यही गणित भेजी जाने वाली फ़ाइल को क़रीब 18 MB से नीचे रखने की सलाह देता है।
PDF Editor osFoundry डेस्कटॉप ऐप का हिस्सा है, जो आपके कंप्यूटर पर चलता है और osFoundry डाउनलोड पेज से मुफ़्त है। यह फ़ाइलों को डिवाइस पर कैसे रखता है, यह local-first पेज पर बताया गया है।