別)
有一次我從客戶發(fā)來的 PDF 里整理產(chǎn)品圖頁面上圖片清清楚楚右鍵還能“另存為”可真正批量導(dǎo)出以后導(dǎo)出的圖片不是尺寸不對就是數(shù)量少了很多。后來換了個思路用 Python 提取 PDF 中的圖片幾行代碼跑通可一放到真實文件上圖片少、文件亂、原圖變樣、程序中途崩潰這些問題又全冒出來了。那時候我就意識到問題通常不出在代碼本身而在于很多人沒有先把需求想清楚你要的是 PDF 里的“原始圖片素材”還是頁面上肉眼看到的那張圖。這兩類需求看著像一回事實際完全對應(yīng)著不同的技術(shù)路線也會得到完全不同的結(jié)果質(zhì)量。這篇文章不是只給你一段能跑的代碼而是想把“用 Python 提取 PDF 圖片”這件事拆成需求判斷、最小實現(xiàn)、批量工程化、兜底渲染和排查鏈路幾個層次來講。你跟著這套思路走一遍以后再拿到奇怪 PDF就不至于每次都靠試。1. 先想清楚你要的是 PDF 里的“原始素材”還是頁面上的那張圖1.1 兩種需求看起來一樣結(jié)果邏輯完全不同第一種需求是把 PDF 當(dāng)作一個容器。你希望把里面嵌入的圖片對象完整取出來保留原始編碼、原始尺寸、原始色彩信息。比如一個排版文件里包含了高質(zhì)量的商品圖你要把它們還原成獨立圖片這屬于“提取素材”。第二種需求是把 PDF 的頁面當(dāng)作一張畫布。你想把頁面某一塊區(qū)域的視覺效果保存成圖片比如從一張數(shù)據(jù)報表里截取一個圖表區(qū)域或者把掃描件里某個圖形塊裁出來這屬于“提取畫面”。這兩種需求和最終輸出并不完全等價。PDF 里的圖片對象不等于閱讀器上顯示給你看的那張圖。一張大圖可以被轉(zhuǎn) 90 度引用可以被局部裁剪可以被半透明遮罩疊加也可以被內(nèi)容流里的矢量圖形蓋住一部分。如果你直接導(dǎo)出圖片對象得到的很可能是原始未旋轉(zhuǎn)、未裁切、未修飾的圖片。而如果你用頁面渲染工具去截取某個區(qū)域得到的又可能是重采樣之后的結(jié)果不是原圖質(zhì)量。所以第一步不是寫代碼而是問自己到底要原素材還是要視覺結(jié)果。1.2 為什么不能靠“找文件頭、文件尾”的方式硬摳圖片很多人一上手會想到用 Python 讀取二進(jìn)制文件然后通過 JPG 的文件頭FF D8 FF或者 PNG 的文件頭去掃描數(shù)據(jù)。這個思路對某些簡單 PDF 也許能跑通但非常脆弱。原因有兩個。第一PDF 內(nèi)部不是一張張圖片順序排列的圖片數(shù)據(jù)可能經(jīng)過壓縮、編碼、拆包也可能被對象間接引用文件里的字節(jié)順序和你讀到的視覺順序沒有直接關(guān)系。第二PDF 既有掃描文本也有翻錄還有被壓縮進(jìn)對象流里的小對象。直接通過二進(jìn)制掃描很難準(zhǔn)確判斷一個圖片數(shù)據(jù)的起止位置很容易把圖片截斷或者誤識別成亂碼。正確的方式是先理解 PDF 的底層結(jié)構(gòu)圖片通常是頁面內(nèi)容流里引用的一種 Image XObject。它帶有寬度、高度、濾鏡、色彩空間等參數(shù)圖片數(shù)據(jù)本身按對應(yīng)編碼存放在 PDF 對象中。PyMuPDF 這類庫做的事情就是把這些細(xì)節(jié)封裝成高層接口讓你不需要手動拼裝對象引用關(guān)系。1.3 理解一點對象概念比多抄幾段代碼更有用你不需要成為 PDF 規(guī)范專家但至少應(yīng)該知道幾個關(guān)鍵詞。PDF 文件的基本單位是“對象”每個對象有編號。頁面對象通過資源字典去引用圖片對象圖片對象在 PDF 里通常以/Subtype /Image的形式存在。真正要導(dǎo)出圖片時腳本要做的事可以簡化成先找到頁面里引用了哪些圖片對象再按對象編號去 PDF 頂層對象里把圖片數(shù)據(jù)取出來。PyMuPDF 的page.get_images(fullTrue)就是負(fù)責(zé)找引用doc.extract_image(xref)就是負(fù)責(zé)按對象編號取數(shù)據(jù)。你只要把這兩個接口組合起來就能寫出一個最小可用版本。先掌握這個對象思維后面遇到問題時排查方向就會清晰很多。你遇到的很多“圖片提取不出來”的奇怪現(xiàn)象本質(zhì)上不是 Python 語法問題而是“圖片所在的位置”和“提取工具尋找的位置”不匹配。2. 最小閉環(huán)用 PyMuPDF 把嵌入的圖片完整導(dǎo)出2.1 環(huán)境準(zhǔn)備為什么安裝 PyMuPDF 之后導(dǎo)入的模塊叫 fitz先說安裝最簡單的命令是python -m pip install PyMuPDF這里有一個很常見的困惑裝的是 PyMuPDF代碼里寫的卻是import fitz。這是歷史遺留命名不是要故意繞暈?zāi)?。PyMuPDF 在 Python 環(huán)境里對外的導(dǎo)入模塊一直使用fitz后續(xù)文檔也在延續(xù)這個習(xí)慣。如果你遇到ModuleNotFoundError: No module named fitz不要改成安裝一個叫fitz的別的包優(yōu)先檢查當(dāng)前命令行使用的 Python 環(huán)境和 pip 安裝環(huán)境是不是同一個。在 Windows 上最容易出現(xiàn)這種問題因為系統(tǒng)里可能同時存在多個 Python。最穩(wěn)妥的方式不是直接執(zhí)行pip install而是用當(dāng)前解釋器對應(yīng)的 pip 命令來安裝python -m pip install PyMuPDF這樣保證包被你當(dāng)前正在用的 Python 環(huán)境接收。如果還要對圖片做校驗、裁剪、格式轉(zhuǎn)換建議順手安裝 Pillowpython -m pip install Pillow2.2 一個能真正把原圖落盤的最小腳本下面這個腳本結(jié)構(gòu)比較保守用 try/finally 保證 PDF 文件最終會被關(guān)閉。你不要小看這個細(xì)節(jié)批量跑幾百個 PDF 時文件句柄不釋放會帶來很隱蔽的資源占用問題。import os import fitz def extract_images_from_pdf(pdf_path, out_dirimages_out): os.makedirs(out_dir, exist_okTrue) doc fitz.open(pdf_path) saved_count 0 used_xrefs set() try: for page_index in range(len(doc)): page doc.load_page(page_index) page_images page.get_images(fullTrue) if not page_images: print(f第 {page_index 1} 頁沒有找到圖片對象) continue for xref in {item[0] for item in page_images}: if xref in used_xrefs: continue try: info doc.extract_image(xref) except Exception as exc: print(f第 {page_index 1} 頁提取圖片失敗xref{xref}原因{exc}) continue image_data info.get(image) image_ext info.get(ext, bin) if not image_data: continue out_path os.path.join(out_dir, fimg_{xref:05d}.{image_ext}) with open(out_path, wb) as f: f.write(image_data) used_xrefs.add(xref) saved_count 1 finally: doc.close() print(f完成共保存 {saved_count} 張圖片) return saved_count if __name__ __main__: extract_images_from_pdf(input.pdf)這段代碼只做了一件事遍歷每一頁找到頁面引用的圖片對象按對象編號去 PDF 里取原始圖片數(shù)據(jù)然后直接寫成文件。這里有一個非常關(guān)鍵的選擇保存圖片數(shù)據(jù)時直接用extract_image返回的image字段和ext擴(kuò)展名中間沒有經(jīng)過任何圖片解碼。這意味著你要保存到磁盤的是 PDF 里嵌入的原始二維碼和原始數(shù)據(jù)而不是重新編碼后的數(shù)據(jù)。對于 PDF 內(nèi)部的 JPEG 圖片直接保存成.jpg通常是安全的。對于 PNG、JPX 等你沒見過的擴(kuò)展名也不要急著改后綴先用文件頭校驗工具確認(rèn)格式再用圖片瀏覽器打開看看。2.3 extract_image 返回了什么腳本才能判斷怎么處理很多人把get_images的結(jié)果直接當(dāng)成圖片內(nèi)容來寫文件結(jié)果寫出來的東西打不開。實際上get_images(fullTrue)返回的是一批元組第一個元素才是圖片對象的編號xref。真正要拿到圖片數(shù)據(jù)需要進(jìn)一步調(diào)用doc.extract_image(xref)。extract_image的返回結(jié)果通常是一個字典里面有這些信息需要注意image圖片數(shù)據(jù)本體可能已經(jīng)是 JPEG 或 PNG 編碼后的字節(jié)直接用二進(jìn)制方式寫入文件即可。ext推薦使用的擴(kuò)展名常見可能是jpeg、png、jpx、jb2這類值。width和height圖片的原始像素尺寸注意不一定是頁面上顯示的尺寸。colorspace顏色空間比如 RGB、GRAY、CMYK。不要在extract_image返回的字典里做太強(qiáng)的主觀判斷因為不同 PDF 結(jié)構(gòu)會有差異。比較穩(wěn)妥的做法是先獲取image和ext保存完用 Pillow 或文件類型工具驗證生產(chǎn)級腳本里再考慮用哈希去重。注意如果只執(zhí)行page.get_images(fullTrue)你拿到的只是圖片對象的信息不是圖片內(nèi)容。你必須把xref傳給doc.extract_image(xref)之后才能拿到可寫入文件的字節(jié)流。2.4 第一次跑完以后別急著歡呼單頁 PDF 跑通只能說明路徑?jīng)]有斷。這時候你應(yīng)該先做一個校驗而不是立刻鋪到 1000 個 PDF 上。我建議的校驗順序是用普通圖片查看器打開導(dǎo)出文件確認(rèn)不是亂碼。對比 PDF 頁面里的圖片數(shù)量和實際導(dǎo)出數(shù)量。檢查尺寸特別小、特別大的圖片確認(rèn)是否合理。用 Pillow 打開幾張有代表性的圖片讀取size和mode確認(rèn)沒有在解碼層出問題。如果這一步出現(xiàn)“數(shù)量對不上”先不要急大概率不是 Python 代碼的 bug而是 PDF 本身對同一張圖片的引用方式和視覺呈現(xiàn)方式不同。3. 從“跑通一次”到“批量處理”該補腳本能力而不是循環(huán)3.1 去重同一個對象出現(xiàn)在多個頁面不應(yīng)該重復(fù)保存一份真實的 PDF尤其是那種從辦公軟件導(dǎo)出的文件經(jīng)常會出現(xiàn)同一個 Logo、同一個背景素材在多頁里反復(fù)出現(xiàn)的情況。如果簡單按“頁碼 圖片序號”來命名會產(chǎn)生大量重復(fù)文件。這里可以通過xref去重因為同一張嵌入圖片在 PDF 里往往會共享同一個對象編號。上面的最小腳本里已經(jīng)加入了used_xrefs集合核心邏輯就是用xref判斷這張圖是不是已經(jīng)處理過了。但這里還有一個更隱蔽的情況有時候同一個視覺圖片會被保存成兩個不同的對象編號比如一個文件被重復(fù)嵌入兩次。如果你確定要去重到“內(nèi)容級別”可以用內(nèi)容哈希來處理。import hashlib def image_key(data: bytes) - str: return hashlib.sha256(data).hexdigest()在保存前先計算image_key(image_data)如果這個 key 已經(jīng)出現(xiàn)過了就跳過。哈希去重的價格是額外的 CPU 和內(nèi)存開銷。如果 PDF 本身非常大、圖片數(shù)量很多建議先用xref集合去重把大部分明顯重復(fù)擋掉再決定是否做內(nèi)容哈希。3.2 過濾干擾對象不要把所有東西都當(dāng)作有效圖片保存真實 PDF 里的圖片對象千奇百怪常見會干擾你判斷的對象包括只有幾個像素寬的小圖標(biāo)或紋理。用于透明效果的 SMask 遮罩對象。用于頁面平鋪背景的 pattern。表單字段的外觀流。被隱藏圖層里的圖片。如果你直接把所有圖片都導(dǎo)出會得到一堆看起來沒用的文件。比較好的過濾策略是先看尺寸和內(nèi)容類型。一個通用做法是把width或height小于一定閾值的圖片標(biāo)記為低價值。不過閾值不能一刀切有些 PDF 里的商品縮略圖可能本來就只有 200 像素寬。建議第一次先用“不刪文件只打印統(tǒng)計信息”的方式運行把尺寸分布打出來再決定要不要真正過濾。還可以在 Pillow 層驗證如果 Pillow 打開圖片后能確認(rèn)它不是空圖、不是純色馬賽克通常說明這張圖的二進(jìn)制流是完整的。注意過濾邏輯寧可保守不要激進(jìn)。直接把小于 100 像素的圖片全刪掉容易誤傷重要素材。更安全的做法是先加一個“低價值目錄”把疑似噪聲單獨放不污染主輸出目錄。3.3 日志、寫入校驗和異?;謴?fù)少一個都很難收場批量處理時最常見的失敗不是提取邏輯本身而是文件系統(tǒng)問題、PDF 損壞、權(quán)限受限和路徑?jīng)_突。建議給腳本補充幾件事每一條失敗記錄都要打印頁碼或 xref不要只打印一句“提取失敗”。圖片寫入以后最好校驗一次文件大小不是 0。不要因為一張圖失敗就中斷整個 PDF。對多 PDF 批量任務(wù)建議給每個 PDF 單獨建立輸出目錄。這樣即使跑到第 500 個 PDF 時出了問題你也能快速定位到是哪個輸入文件、哪個 xref 出錯而不是盯著控制臺看只有一行FileNotFoundError。3.4 一個更實用的批量腳本骨架在進(jìn)入真實項目前我會把腳本組織成三層結(jié)構(gòu)單個 PDF 處理函數(shù)、批量遍歷邏輯、主入口參數(shù)解析。import os import glob import hashlib import fitz def extract_single_pdf(pdf_path, out_dir): os.makedirs(out_dir, exist_okTrue) doc fitz.open(pdf_path) used_xrefs set() stats {total: 0, saved: 0, skipped: 0, failed: 0} try: for page_index in range(len(doc)): page doc.load_page(page_index) images page.get_images(fullTrue) stats[total] len(images) for xref in {item[0] for item in images}: if xref in used_xrefs: stats[skipped] 1 continue try: info doc.extract_image(xref) except Exception as exc: stats[failed] 1 print(fextract failed: {pdf_path} page {page_index 1}, xref {xref}, {exc}) continue data info.get(image) ext info.get(ext, bin) if not data: stats[failed] 1 continue digest hashlib.sha256(data).hexdigest()[:16] out_path os.path.join(out_dir, f{digest}.{ext}) if os.path.exists(out_path): stats[skipped] 1 continue with open(out_path, wb) as f: f.write(data) stats[saved] 1 used_xrefs.add(xref) print(f{pdf_path}: saved{stats[saved]}, skipped{stats[skipped]}, failed{stats[failed]}) return stats finally: doc.close() def process_many(pdf_dir, out_root): for pdf_path in glob.glob(os.path.join(pdf_dir, *.pdf)): basename os.path.splitext(os.path.basename(pdf_path))[0] target_dir os.path.join(out_root, basename) extract_single_pdf(pdf_path, target_dir)這個骨架并不是最精簡的但它能覆蓋大多數(shù)批量場景去重、容錯、獨立目錄、統(tǒng)計信息都齊了。如果你只需要臨時處理一份 PDF可以去掉批量遍歷層。4. 頁面里明明看得到圖對象列表里卻拿不到怎么辦4.1 你看到的不一定是嵌入圖片對象有幾種情況會讓get_images返回空列表但頁面仍然有圖形內(nèi)容。第一種圖片是矢量圖。頁面里看到的是一個用路徑、曲線和顏色填充組成的矢量圖形而不是嵌入的位圖對象。此時不存在“圖片 XObject”自然也沒有原圖可提取。你要么接受矢量結(jié)果要么把頁面渲染成圖片。第二種圖片被嵌在復(fù)雜的表單 XObject 或內(nèi)容流里。PyMuPDF 雖然已經(jīng)會遞歸搜索頁面資源但遇到非常規(guī)的 PDF 結(jié)構(gòu)時你仍然可能拿不到想要的對象編號。第三種頁面顯示的是圖片但整個圖被放在一個容器或裁剪區(qū)域里。你從對象層面拿出來的圖片是完整的視覺上卻被裁剪了。遇到這種情況不要反復(fù)折騰get_images。你應(yīng)該把策略切換成“渲染頁面區(qū)域”直接按視覺看到的內(nèi)容來生成圖片。4.2 兜底策略按位置渲染頁面局部區(qū)域PyMuPDF 的頁面對象渲染能力此時更有用。你可以先通過page.get_image_info(xrefsTrue)獲取圖片在頁面上的位置信息再用渲染功能把對應(yīng)區(qū)域畫成一張新的 PNG。import fitz def render_region(pdf_path, page_index, bbox, output_path, zoom2): doc fitz.open(pdf_path) try: page doc.load_page(page_index) clip fitz.Rect(bbox) matrix fitz.Matrix(zoom, zoom) pix page.get_pixmap(matrixmatrix, clipclip) pix.save(output_path) finally: doc.close()這里的bbox一般是一個四元組或 Rect表示頁面坐標(biāo)系里的左上角 x、左上角 y、右下角 x、右下角 y。直接打印image_info[bbox]把它作為clip傳入即可。zoom參數(shù)影響導(dǎo)出的清晰度。90 是常見的 PDF 頁面基礎(chǔ)分辨率zoom2意味著按 2 倍去渲染。如果你需要放大原區(qū)域的清晰度可以繼續(xù)提高 zoom但渲染耗時和內(nèi)存占用也會增加。4.3 “原圖”和“視覺圖”的差異在導(dǎo)出素材場景下不能混用做素材導(dǎo)出時我用一個很樸素的判斷標(biāo)準(zhǔn)如果對方要的是“可以直接放進(jìn)設(shè)計稿的高清原圖”優(yōu)先用extract_image拿原對象數(shù)據(jù)。如果圖片在 PDF 里已經(jīng)被裁剪過對方要的是“頁面里那個可見區(qū)域”那么原對象反而不能直接交付因為裁剪、旋轉(zhuǎn)、縮放都沒有體現(xiàn)在導(dǎo)出對象上。這兩種結(jié)果沒有絕對的好壞只是適用場景不同。最穩(wěn)妥的方式是同一個 PDF 可以同時生成兩個文件夾originals存extract_image拿到的原始素材rendered存按頁面視覺裁剪出的區(qū)域。先讓使用者自己看哪個符合需求再根據(jù)結(jié)果固定代碼邏輯。注意如果要處理的是加密 PDF 或設(shè)置了使用權(quán)限的文檔請先確認(rèn)自己有權(quán)訪問和提取內(nèi)容并且使用合法獲取的密碼進(jìn)行解密后再操作。不要嘗試?yán)@過文檔訪問控制。5. 別急著選庫先了解邊界再決定用哪條路線5.1 幾個常見 Python 庫的大致定位不同庫處理 PDF 圖片的側(cè)重點不同下面是一個粗粒度的對照庫名適合任務(wù)主要限制PyMuPDF遍歷頁面圖片對象、提取原圖、渲染頁面區(qū)域不是純 Python依賴底層二進(jìn)制庫pypdf / PyPDF2處理頁面結(jié)構(gòu)、合并拆分、基礎(chǔ)圖片文件對象提取對圖片濾鏡、復(fù)雜 XObject 的覆蓋力不如 PyMuPDFpdfplumber解析文本、表格、頁面坐標(biāo)信息不是為了提取圖片而設(shè)計圖片處理不是最佳選擇PyPdfium2頁面渲染場景主要用于把 PDF 畫成圖像不是素材提取首選pdfimages命令行工具用于對照結(jié)果不是 Python 庫但在批量驗證時很好用我的建議是優(yōu)先用 PyMuPDF 跑通最小流程因為它在“找圖片對象”和“渲染頁面”兩方面都覆蓋得比較完整。pypdf 在純粹做 PDF 元數(shù)據(jù)操作時也非常方便但用它提取復(fù)雜 PDF 里的圖片你需要處理更多底層細(xì)節(jié)。5.2 一個通用排查鏈路當(dāng)你發(fā)現(xiàn)提取結(jié)果不對時按下面這個順序排查會比亂猜高效很多先看現(xiàn)象是根本沒有輸出還是圖片數(shù)量不匹配還是輸出文件打不開還是程序直接崩潰。再看輸入PDF 是否加密是否損壞文件擴(kuò)展名是否只是前綴內(nèi)容是否為空。再確認(rèn)圖片類型頁面上到底是不是位圖是矢量繪圖還是嵌入的圖片對象。檢查 xref用get_images是否有返回返回的xref是否能被extract_image正常解析。檢查格式輸出文件的擴(kuò)展名是否正確文件大小是否為 0。最后用渲染兜底如果對象層拿不到就用get_pixmap渲染頁面區(qū)域。這套鏈路看起來簡單卻能覆蓋絕大多數(shù)“圖片為什么提取不出來”的疑問。5.3 工程化建議把腳本當(dāng)成可以被下班后重新跑的東西來寫一旦處理量到了幾十份 PDF 以上腳本就要往“可重復(fù)、可審計、可恢復(fù)”的方向設(shè)計。我會把輸出的每一步都記錄下來。不是讓你上一套復(fù)雜日志框架而是在關(guān)鍵節(jié)點打印文件名 頁碼 結(jié)果。最少要能回答三個問題哪一份文件跑過了成功多少張失敗在哪里。如果同一個任務(wù)需要反復(fù)修改參數(shù)建議使用命令行參數(shù)而不是改代碼python extract_pdf_images.py --pdf_dir ./input --out_dir ./output --min_width 50同時把輸出目錄按輸入文件名分開避免多份 PDF 的圖片互相覆蓋。注意如果只是臨時跑一次默認(rèn)配置通常夠用。如果要長期維護(hù)還需要考慮磁盤空間、重復(fù)文件清理、任務(wù)斷點續(xù)跑和關(guān)鍵路徑是否存在這些工程問題。最后說一個實際經(jīng)驗我見過很多同學(xué)照著一篇教程復(fù)制代碼把圖片提取出來以后存到自己都分不清的地方。下一次遇到新 PDF又把代碼翻出來改兩行再次碰運氣。真正高效的做法其實是在動手前先明確兩個定義第一輸出物是“原始素材”還是“視覺區(qū)域”第二腳本需要滿足“單次可用”還是“批量反復(fù)使用”。只要這兩個問題回答清楚了技術(shù)路線基本就定了需要原始素材就去頁面資源里找圖片對象需要視覺區(qū)域就做頁面渲染要反復(fù)使用就補上日志、去重、異常處理、目錄規(guī)劃和結(jié)果校驗。PDF 格式本身并不復(fù)雜但它隱藏的邊界情況非常多。圖片提取這件事真正難的不是不會寫 Python而是沒有意識到 PDF 里“圖片數(shù)據(jù)和圖片顯示”是兩套邏輯。理解了這個你提取圖片的準(zhǔn)確率和穩(wěn)定性都會比那些只會復(fù)制代碼的人高一個檔次。