使用Python高效實現(xiàn)PDF內(nèi)容差異對比的方法詳解
1. 安裝 PyMuPDF 庫
PyMuPDF 提供了豐富的文檔操作功能,包括文本/圖像提取、頁面渲染、文檔合并拆分、注釋添加等。支持格式包括 PDF、EPUB、XPS 等。它是基于 C 語言庫 MuPDF 的 Python 綁定,MuPDF 由 Artifex 公司開發(fā),以高性能和小巧著稱。通過 pip install PyMuPDF 安裝,但在代碼中需通過 import fitz 調(diào)用其功能。fitz 是該庫的核心模塊,fitz 名稱源自 MuPDF 的原始渲染引擎 “Fitz”。為保持一致性,PyMuPDF 的 Python 接口沿用了此名稱。
pip install pymupdf
import fitz
2. 獲取 PDF 內(nèi)容
fitz.open 是 PyMuPDF(fitz 模塊)中用于打開 PDF 或其他支持的文檔格式的函數(shù)。它返回一個fitz.Document 對象。通過 fitz.Document 對象,可以:
- 訪問頁面:
使用索引訪問文檔中的頁面,例如 doc[0] 表示第一頁。
每個頁面是一個 fitz.Page 對象。 - 獲取文檔信息:
獲取文檔的元數(shù)據(jù)(如標(biāo)題、作者、創(chuàng)建時間等)。
獲取文檔的頁數(shù)。
獲取 PDF 內(nèi)容有兩種方式:
通過文件路徑獲取
def get_pdf_content_from_path(pdf_path):
"""Get PDF content from a local file path"""
pdf = fitz.open(pdf_path)
return pdf
通過 URL 獲取
注意通過接口調(diào)用獲取 repsonce.content 字節(jié)類型 content,而不是 response.text 字符串類型 content
| 屬性 | response.content | response.text |
|---|---|---|
| 返回類型 | bytes(字節(jié)) | str(字符串) |
| 解碼 | 不進(jìn)行解碼,返回原始二進(jìn)制數(shù)據(jù) | 自動根據(jù) response.encoding 解碼 |
| 適用場景 | 處理二進(jìn)制文件(如圖片、PDF 等) | 處理文本數(shù)據(jù)(如 HTML、JSON 等) |
| 手動解碼 | 需要手動解碼(如 content.decode(‘utf-8’)) | 自動解碼,無需額外操作 |
def get_pdf_content_from_datalake(content_url):
"""Get PDF content using content_url"""
content = get_content_by_content_url(content_url)
try:
pdf = fitz.open(filetype="pdf", stream=content)
except Exception as e:
raise ValueError(f"Failed to open PDF from DataLake for URL: {content_url}. Error: {str(e)}")
return pdf
3. 提取 PDF 每頁信息
PDF 通常有很多頁 content,需要比較每頁的 content,前面獲取到 fitz.Document,使用索引訪問文檔中的頁面 doc[index] 返回 fitz.Page 對象。
下面是 fitz.Page 的常用屬性,我們對比內(nèi)容只需要用到 get_text() 和 get_pixmap(),通過比較每頁的 text 和像素就能找出 PDF 任何細(xì)微的差異,包括內(nèi)容格式,e.g 字體,加粗,高亮,table 布局,圖片大小等。
| 屬性/方法 | 描述 |
|---|---|
| number | 當(dāng)前頁面的頁碼(從 0 開始)。 |
| rect | 頁面尺寸(矩形區(qū)域)。 |
| rotation | 頁面旋轉(zhuǎn)角度(0、90、180 或 270)。 |
| mediabox | 頁面媒體框的尺寸。 |
| cropbox | 頁面裁剪框的尺寸。 |
| get_text() | 提取頁面文本(支持多種格式,如 “text”、“html”、“json”)。 |
| get_pixmap() | 將頁面渲染為圖像。 |
| search_for() | 搜索頁面中的文本。 |
| get_images() | 獲取頁面中的嵌入圖像信息。 |
| add_annot() | 在頁面上添加注釋。 |
| write() | 將頁面內(nèi)容導(dǎo)出為字節(jié)流。 |
其中 get_pixmap() 用于將 PDF 頁面渲染為像素圖(圖像)。它是將 PDF 頁面轉(zhuǎn)換為圖像格式的核心方法,常用于生成頁面的可視化表示或進(jìn)行圖像比較。返回的 fitz.Pixmap 對象包含圖像的像素數(shù)據(jù)和相關(guān)信息,常用屬性如下:
| 屬性名 | 描述 |
|---|---|
| samples | 圖像的原始像素數(shù)據(jù)(字節(jié)流)。 |
| width | 圖像的寬度(像素)。 |
| height | 圖像的高度(像素)。 |
| stride | 每行像素的字節(jié)數(shù)。 |
| colorspace | 圖像的顏色空間(如 RGB、灰度等)。 |
# Determine the maximum number of pages
max_pages = max(len(pdf_base), len(pdf_target))
def extract_page_data(pdf, page_num):
"""Extract text and pixel data from a PDF page."""
page = pdf[page_num]
text = page.get_text()
pix = page.get_pixmap()
return {
"text": text,
"pix_samples": pix.samples,
"pix_width": pix.width,
"pix_height": pix.height,
}
def generate_page_data(pdf_base, pdf_target, max_pages, doc_folder):
"""Generator to yield page data for multiprocessing."""
for page_num in range(max_pages):
page_data_base = extract_page_data(pdf_base, page_num)
page_data_target = extract_page_data(pdf_target, page_num)
yield (page_data_base, page_data_target, page_num, doc_folder)
4. 內(nèi)容對比
metadata 差異
fitz.Document 對象元數(shù)據(jù) metadata 屬性,通常包括文檔的基本信息,例如標(biāo)題、作者、創(chuàng)建時間等。如果忽略 metadata 差異,可以忽略此項對比。
以下是 metadata 字典中常見的鍵及其含義:
| 鍵名 | 描述 |
|---|---|
| title | 文檔的標(biāo)題(Title)。 |
| author | 文檔的作者(Author)。 |
| subject | 文檔的主題(Subject)。 |
| keywords | 文檔的關(guān)鍵字(Keywords)。 |
| creator | 創(chuàng)建文檔的應(yīng)用程序(Creator)。 |
| producer | 生成文檔的工具或軟件(Producer)。 |
| creationDate | 文檔的創(chuàng)建日期(Creation Date)。 |
| modDate | 文檔的最后修改日期(Modification Date)。 |
| trapped | 文檔是否被標(biāo)記為“Trapped”(通常為 True 或 False,可能為空)。 |
compare_metadata(pdf_base.metadata, pdf_target.metadata, result)
def compare_metadata(metadata_base, metadata_target, result):
"""Compare PDF metadata"""
for key in set(metadata_base.keys()) | set(metadata_target.keys()):
if metadata_base.get(key) != metadata_target.get(key):
result["metadata_differences"].append(
f"Metadata '{key}' differs: pdf_base='{metadata_base.get(key)}', pdf_target='{metadata_target.get(key)}'"
)
文本對比
ndiff 是 Python 標(biāo)準(zhǔn)庫 difflib 中的一個方法,用于逐行比較兩個字符串序列,并生成一個可讀的差異列表。它特別適合用于文本比較,能夠清晰地標(biāo)記出新增、刪除和修改的部分。
difflib.ndiff 的功能
- 輸入: 兩個字符串序列(通常是通過 splitlines() 分割的多行文本)。
- 輸出: 一個迭代器,生成每一行的差異標(biāo)記。
- 差異標(biāo)記:
-:表示在第一個序列中存在,但在第二個序列中不存在的行。
+:表示在第二個序列中存在,但在第一個序列中不存在的行。
(空格):表示兩個序列中都存在的行(沒有變化)。
?:表示上一行的具體差異(通常用于標(biāo)記字符級別的變化)。
def compare_text_content(page_data_base, page_data_target, page_num, result):
"""Compare text content of two pages."""
text_base = page_data_base["text"]
text_target = page_data_target["text"]
if text_base != text_target:
result["text_differences"].append(f"Text differs on page {page_num + 1}")
diff = list(difflib.ndiff(text_base.splitlines(), text_target.splitlines()))
differences = [d for d in diff if d.startswith('+ ') or d.startswith('- ')]
if differences:
result["text_differences"].append(f"Page {page_num + 1} specific differences: {differences[:5]}...")
可視化對比
比較兩個 PDF 頁面視覺內(nèi)容,通過比較頁面的像素數(shù)據(jù)來檢測頁面之間的視覺差異。
- 頁面尺寸比較:
首先比較兩個頁面的寬度和高度,如果頁面尺寸不同,記錄差異并退出函數(shù)。 - 像素數(shù)據(jù)比較:
將頁面的像素數(shù)據(jù)轉(zhuǎn)換為圖像對象。使用 PIL.Image.frombytes 將頁面的像素數(shù)據(jù)轉(zhuǎn)換為 RGB 圖像對象。
使用 ImageChops.difference 計算兩個圖像的差異,返回一個差異圖像,其中每個像素的值表示兩個圖像對應(yīng)像素的差異程度。 - 保存差異圖像:
如果發(fā)現(xiàn)差異,保存基準(zhǔn)頁面、目標(biāo)頁面和差異圖像到指定的文件夾。
記錄差異信息到 result 字典中。
def compare_visual_content(page_data_base, page_data_target, page_num, doc_folder, result):
"""Compare visual content of two pages."""
if (page_data_base["pix_width"] != page_data_target["pix_width"] or
page_data_base["pix_height"] != page_data_target["pix_height"]):
result["format_differences"].append(
f"Page {page_num + 1} size differs: PDF_base={page_data_base['pix_width']}x{page_data_base['pix_height']}, "
f"PDF_target={page_data_target['pix_width']}x{page_data_target['pix_height']}"
)
return
img_base = Image.frombytes("RGB", [page_data_base["pix_width"], page_data_base["pix_height"]], page_data_base["pix_samples"])
img_target = Image.frombytes("RGB", [page_data_target["pix_width"], page_data_target["pix_height"]], page_data_target["pix_samples"])
diff_img = ImageChops.difference(img_base, img_target)
if np.any(np.array(diff_img)):
img_base_path = os.path.join(doc_folder, f"page_{page_num + 1}_pdf_base.png")
img_target_path = os.path.join(doc_folder, f"page_{page_num + 1}_pdf_target.png")
diff_path = os.path.join(doc_folder, f"page_{page_num + 1}_diff.png")
img_base.save(img_base_path)
img_target.save(img_target_path)
diff_img.save(diff_path)
result["format_differences"].append(f"differs on page {page_num + 1}: difference image saved at {diff_path}")
5. 提升對比效率
通過哈希值快速判斷頁面是否相同
通過比較頁面內(nèi)容的哈希值(包括文本和像素數(shù)據(jù)),如果哈希值相同,則跳過進(jìn)一步比較。
如果哈希值不同,調(diào)用 compare_text_content 和 compare_visual_content 方法分別比較文本和視覺內(nèi)容。
def hash_page_content(page_data):
"""Generate a hash for the page content."""
text_hash = hashlib.md5(page_data["text"].encode()).hexdigest()
pix_hash = hashlib.md5(page_data["pix_samples"]).hexdigest()
return text_hash, pix_hash
def compare_page(page_data_base, page_data_target, page_num, doc_folder):
"""Compare a single page for text and visual differences."""
result = {
"text_differences": [],
"format_differences": []
}
try:
# Compare hashes first
base_hash = hash_page_content(page_data_base)
target_hash = hash_page_content(page_data_target)
if base_hash == target_hash:
return result # Skip comparison if hashes are identical
# Compare text and visual content
compare_text_content(page_data_base, page_data_target, page_num, result)
compare_visual_content(page_data_base, page_data_target, page_num, doc_folder, result)
except Exception as e:
result["format_differences"].append(f"Failed to compare page {page_num + 1}: {str(e)}")
return result
早停機制
如果 PDF 差異頁面非常很多,后續(xù)的頁面差異其實是無意義的,我們可以設(shè)定一個差異頁面數(shù)量的最大值,比如 3 或 5,當(dāng)發(fā)現(xiàn)的差異頁面數(shù)量達(dá)到指定的最大值時,函數(shù)會停止進(jìn)一步的比較。
def compare_page_with_limit(args, diff_page_count, max_diff_pages, lock):
"""Compare a single page with early termination."""
page_data_base, page_data_target, page_num, doc_folder = args
with lock:
if diff_page_count.value >= max_diff_pages:
return None # Skip further processing if limit is reached
page_result = compare_page(page_data_base, page_data_target, page_num, doc_folder)
if page_result["text_differences"] or page_result["format_differences"]:
with lock:
diff_page_count.value += 1
return page_result
多進(jìn)程機制
如果需要比較的 PDF 文件比較多,我們也可以采用多進(jìn)程并發(fā)比較,提升腳本執(zhí)行時間。這里可以根據(jù)實際情況,是基于 PDF 之間并行,還是基于單個 PDF 頁面之間并行。我這邊是基于 PDF 頁面之間并發(fā)執(zhí)行的,考慮到大多數(shù) PDF 頁面達(dá)上百頁,頁面之間并發(fā)效率更高。
pool.starmap 是 Python 中 multiprocessing.Pool 提供的一種方法,用于在多進(jìn)程環(huán)境下并行執(zhí)行函數(shù)。它類似于 map 方法,但支持將多個參數(shù)傳遞給目標(biāo)函數(shù)。
這里定義了一個 diff_page_count 共享變量(通過 manager.Value 創(chuàng)建),因為是 int 型,所以在多進(jìn)程環(huán)境下需要使用 lock 來保護(hù)它。這是因為 manager.Value 本身并不能保證對其值的操作是原子的(atomic)。
共享變量的非原子操作,對共享變量的操作(如 diff_page_count.value += 1)實際上是由多個步驟組成的:
- 讀取當(dāng)前值。
- 增加值。
- 寫回新值。
在多進(jìn)程環(huán)境下,如果多個進(jìn)程同時執(zhí)行這些步驟,就可能導(dǎo)致數(shù)據(jù)競爭(race condition),從而導(dǎo)致共享變量的值不正確。假設(shè)兩個進(jìn)程同時讀取 diff_page_count.value 的值為 5,然后分別將其加 1 并寫回。最終的結(jié)果可能是 6 而不是預(yù)期的 7,因為兩個進(jìn)程的操作互相覆蓋了。使用 lock 可以確保在一個進(jìn)程修改共享變量時,其他進(jìn)程必須等待,直到當(dāng)前進(jìn)程完成操作并釋放鎖。這就避免了數(shù)據(jù)競爭,確保共享變量的值始終正確。
當(dāng)然如果換成 diff_page_count = manager.list(),它的操作(如添加或刪除元素)是線程安全的,底層已經(jīng)實現(xiàn)了同步機制。因此,多個進(jìn)程可以安全地向列表中添加元素,而無需顯式使用 lock。但是 manager.list() 的操作比直接操作 manager.Value 稍慢,因為它需要處理線程安全。如果性能是關(guān)鍵問題,仍然可以考慮使用 manager.Value 和 lock。
def prepare_output_folder(output_folder, pdf_object_id):
"""Prepare the output folder for storing comparison results."""
output_folder = os.path.join(constants.OUTPUT_DIR, output_folder)
os.makedirs(output_folder, exist_ok=True)
doc_folder = os.path.join(output_folder, pdf_object_id.replace(":", "_"))
clear_and_create_content_dir(doc_folder)
return doc_folder
def compare_pdf(pdf_base_path, pdf_target_path,
pdf_object_id, pdf_base_object_url, pdf_target_object_url,
is_from_datalake=True, output_folder="pdf_diff_results",
max_diff_pages=3):
"""Compare two PDF files for content and format differences"""
# Prepare output folder
doc_folder = prepare_output_folder(output_folder, pdf_object_id)
# Initialize result
result = {
"text_differences": [],
"format_differences": [],
"metadata_differences": [],
"page_count": {"pdf_base": 0, "pdf_target": 0}
}
# Open PDF files
pdf_base = get_pdf_content_from_datalake(pdf_base_object_url) if is_from_datalake else get_pdf_content_from_path(pdf_base_path)
pdf_target = get_pdf_content_from_datalake(pdf_target_object_url) if is_from_datalake else get_pdf_content_from_path(pdf_target_path)
# Compare page count
result["page_count"]["pdf_base"] = len(pdf_base)
result["page_count"]["pdf_target"] = len(pdf_target)
# Compare metadata, ignore differences in creation/modification dates
# compare_metadata(pdf_base.metadata, pdf_target.metadata, result)
# Determine the maximum number of pages
max_pages = max(len(pdf_base), len(pdf_target))
# Compare pages in parallel using a generator
with Manager() as manager:
# Shared counter for tracking pages with differences
diff_page_count = manager.Value('i', 0)
lock = manager.Lock()
# Create a pool of worker processes
with Pool() as pool:
page_results = pool.starmap(
compare_page_with_limit,
[(args, diff_page_count, max_diff_pages, lock) for args in generate_page_data(pdf_base, pdf_target, max_pages, doc_folder)]
)
if diff_page_count.value >= max_diff_pages:
print(f"Early termination: {diff_page_count.value} pages with differences found, stopping further processing.")
pool.terminate()
pool.join()
# Aggregate results
for page_result in page_results:
if page_result is None:
continue # Skip if terminated early
result["text_differences"].extend(page_result["text_differences"])
result["format_differences"].extend(page_result["format_differences"])
return result
6. 其他
還有一些其他細(xì)節(jié)問題,這里就不細(xì)說了,一個完整的腳本執(zhí)行是需要考慮很多因素的,目的就是為了全自動化,減少人工干預(yù)成本,提高整體效率。
這里羅列一些:
- 測試數(shù)據(jù)收集和配置,方便后期定制化執(zhí)行不同的測試數(shù)據(jù)集
- 腳本執(zhí)行過程中的 log,方便 troubleshooting
- 生成測試報告,包括細(xì)節(jié)信息,匯總信息(total,fail,pass),及其他統(tǒng)計信息,方便 triage
- 部署到 Jenkins 上日常執(zhí)行,并發(fā)送測試報告,方便 CICD
以上就是使用Python高效實現(xiàn)PDF內(nèi)容差異對比的方法詳解的詳細(xì)內(nèi)容,更多關(guān)于Python PDF內(nèi)容差異對比的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
Python棧算法的實現(xiàn)與簡單應(yīng)用示例
這篇文章主要介紹了Python棧算法的實現(xiàn)與簡單應(yīng)用,簡單講述了棧的原理并結(jié)合實例形式給出了基于棧實現(xiàn)的進(jìn)制轉(zhuǎn)換與括號匹配等相關(guān)使用技巧,需要的朋友可以參考下2017-11-11
python實現(xiàn)對doc,txt,xls文檔的讀寫操作
這篇文章主要介紹了python實現(xiàn)對doc,txt,xls文檔的讀寫操作,正如標(biāo)題所見,文章包括三個部分python實現(xiàn)對doc文檔的讀取、python實現(xiàn)對txt文檔的讀取和python實現(xiàn)對xls表格的讀取,需要的朋友可以參考一下2022-04-04
python?random模塊常用函數(shù)基礎(chǔ)教程
這篇文章主要為大家介紹了python?random模塊常用函數(shù)基礎(chǔ)教程,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2023-06-06
使用python+Pyqt5實現(xiàn)串口調(diào)試助手

