PDF:win32com與LibreOffice雙方案實(shí)戰(zhàn))
前陣子財(cái)務(wù)部扔過(guò)來(lái)一個(gè)文件夾里面躺著幾十份doc格式的通知和臺(tái)賬要求下午下班前全部轉(zhuǎn)成PDF歸檔文件名還要按新編號(hào)重排。我盯著屏幕想了三秒決定不手動(dòng)挨個(gè)“另存為”直接寫個(gè)doc2pdf小工具搞定它。今天把這套思路和坑都整理出來(lái)從技術(shù)選型、兩種主流實(shí)現(xiàn)、批量處理、踩坑實(shí)錄到如何封裝給同事用一條線講下來(lái)。先說(shuō)個(gè)可能顛覆很多初學(xué)者的認(rèn)知把doc轉(zhuǎn)PDF難度不在代碼而在保真。Word文檔里的分節(jié)符、文本框、批注、修訂、公式、頁(yè)眉頁(yè)腳這些東西想靠解析XML再畫到PDF里復(fù)雜度堪比做一整套排版軟件。真正靠譜的思路是讓現(xiàn)成的文檔程序去干活然后用Python控制它也就是腳本化“打開(kāi)-另存為PDF”這個(gè)過(guò)程。后面所有的方案本質(zhì)上都是找渲染引擎不是自己渲染。1. 先把方案盤明白doc2pdf 不是只有一條路能走很多人搜“python doc2pdf”會(huì)看到一堆庫(kù)名拿回來(lái)卻發(fā)現(xiàn)有的只支持docx不支持doc有的在Windows上正常、換Linux就歇菜。我建議動(dòng)手前先問(wèn)自己三個(gè)問(wèn)題答案直接決定選型。1.1 三個(gè)問(wèn)題決定技術(shù)路線第一個(gè)問(wèn)題目標(biāo)機(jī)器裝了什么辦公軟件。公司內(nèi)網(wǎng)且正版Office全覆蓋win32com是首選服務(wù)器、Docker或者個(gè)人電腦上不想為轉(zhuǎn)換專門裝Office就考慮LibreOffice路線。第二個(gè)問(wèn)題要轉(zhuǎn)的是.doc還是.docx。.doc是老二進(jìn)制格式格式本身就有很多歷史兼容問(wèn)題.docx本質(zhì)是zip包XML結(jié)構(gòu)相對(duì)規(guī)范。如果用LibreOffice去轉(zhuǎn)很舊的.doc容易出現(xiàn)排版錯(cuò)位這時(shí)候Word反而更有優(yōu)勢(shì)。第三個(gè)問(wèn)題對(duì)PDF保真度要求多高。合同、標(biāo)書、財(cái)務(wù)表單這種一字都不能差的場(chǎng)景直接選完整渲染引擎只是發(fā)給領(lǐng)導(dǎo)手機(jī)上看個(gè)大概那輕量方案就能湊合。三個(gè)維度拉成一張表看起來(lái)更清楚特征win32com WordLibreOffice headlesspython-docx reportlab操作系統(tǒng)WindowsWindows/macOS/Linux跨平臺(tái)依賴本機(jī)安裝Microsoft Office安裝LibreOfficepip安裝即可格式支持doc/docxdoc/docx/xls/ppt等僅簡(jiǎn)單docx保真度高和Word打開(kāi)基本一致中高復(fù)雜版式可能輕微偏移低復(fù)雜樣式直接翻車批量性能單進(jìn)程順序偏慢單進(jìn)程較快可并行快但基本不可用維護(hù)成本低但被Office綁定中注意參數(shù)和字體高需要自己處理排版1.2 為什么不建議“純Python解析”路線有段時(shí)間被“python實(shí)現(xiàn)某某轉(zhuǎn)換”這類標(biāo)題帶偏試圖用python-docx把段落讀出來(lái)再用reportlab畫成PDF覺(jué)得這樣才是純正方案。結(jié)果拿一份帶文本框、圖片環(huán)繞、頁(yè)眉頁(yè)腳的文檔測(cè)試輸出的PDF像被砸扁的紙箱子元素堆在一起。原因很簡(jiǎn)單docx的XML模型和PDF的繪制模型之間差了一個(gè)排版引擎。要把每個(gè)段落的縮進(jìn)、分頁(yè)符、浮動(dòng)圖片、域代碼更新結(jié)果全部復(fù)刻工作量足夠做一個(gè)商業(yè)軟件。所以我在這里明確一點(diǎn)doc2pdf工具的核心是調(diào)用成熟渲染引擎不是自己解析文檔。這也是為什么后文只講win32com和LibreOffice兩條主流路線而不是給你一堆看著高級(jí)、實(shí)際只適合玩具場(chǎng)景的“純Python轉(zhuǎn)換”示例。2. win32com 路線讓 Word 自己在后臺(tái)“另存為”如果Windows上已經(jīng)裝了Microsoft Office這條路最穩(wěn)。思路也最直白用win32com控制Word應(yīng)用程序打開(kāi)目標(biāo)文檔再讓它導(dǎo)出PDF。本質(zhì)和你鼠標(biāo)操作一模一樣。2.1 最小可用的轉(zhuǎn)換函數(shù)代碼核心就十幾行先看最樸素的版本import win32com.client import os def doc2pdf(input_path: str, output_path: str | None None) - str | None: word win32com.client.Dispatch(Word.Application) word.Visible False word.DisplayAlerts 0 input_path os.path.abspath(input_path) output_path os.path.abspath(output_path) if output_path else os.path.splitext(input_path)[0] .pdf try: doc word.Documents.Open(input_path, ReadOnlyTrue) doc.SaveAs(output_path, FileFormat17) # 17 wdFormatPDF doc.Close() return output_path except Exception as e: print(f轉(zhuǎn)換失敗: {e}) return None finally: word.Quit()word.Visible False表示W(wǎng)ord后臺(tái)運(yùn)行不在桌面彈出窗口。word.DisplayAlerts 0是關(guān)掉大部分提醒彈窗。FileFormat17對(duì)應(yīng)wdFormatPDF這行是關(guān)鍵中的關(guān)鍵。這里有個(gè)細(xì)節(jié)第一次跑被COM控制的Word時(shí)有些機(jī)器上窗口還是會(huì)閃一下??梢耘浜蟱ord.WindowState 0讓主窗口最小化減少視覺(jué)干擾但不用糾結(jié)因?yàn)殚W一下就過(guò)去了對(duì)轉(zhuǎn)換結(jié)果沒(méi)影響。2.2 為什么推薦 ExportAsFixedFormat 而不是 SaveAs上面的代碼能跑通但實(shí)際業(yè)務(wù)里我更推薦用ExportAsFixedFormat它對(duì)參數(shù)的控制精細(xì)得多doc.ExportAsFixedFormat( OutputFileNameoutput_path, ExportFormat17, # wdExportFormatPDF OpenAfterExportFalse, OptimizeFor0, # wdExportOptimizeForPrint Range0, # wdExportAllDocument From1, To1, Item0, # wdExportDocumentContent IncludeDocPropsTrue, KeepIRMTrue, CreateBookmarks1, # wdExportCreateHeadingBookmarks DocStructureTagsTrue, BitmapMissingFontsTrue, UseISO19005_1False )它比SaveAs多出來(lái)的價(jià)值在于可以只導(dǎo)出部分頁(yè)面通過(guò)CreateBookmarks生成目錄書簽通過(guò)OptimizeFor指定按打印優(yōu)化還是按屏幕優(yōu)化還能把缺失字體直接轉(zhuǎn)成位圖而不是悄悄跳過(guò)。標(biāo)書、合同這類對(duì)格式要求極高的場(chǎng)景這些參數(shù)非常管用。如果只是簡(jiǎn)單轉(zhuǎn)PDF用SaveAs省事但既然寫工具了我建議一上來(lái)就用ExportAsFixedFormat避免后面需要加功能時(shí)再改結(jié)構(gòu)。2.3 隱藏彈窗、禁用宏和殘留進(jìn)程清理用COM最頭疼的是彈窗。最常見(jiàn)的情況是文檔打開(kāi)時(shí)出現(xiàn)“是否恢復(fù)”“只讀建議”“是否啟用宏”等對(duì)話框一個(gè)彈窗就能讓轉(zhuǎn)換腳本卡在當(dāng)場(chǎng)既不報(bào)錯(cuò)也不繼續(xù)。處理優(yōu)先級(jí)如下先禁用Word的自動(dòng)提示word.Options.DoNotPromptForConvert True word.AutomationSecurity 3 # msoAutomationSecurityForceDisable禁用宏再在Documents.Open時(shí)顯式傳參doc word.Documents.Open( input_path, ConfirmConversionsFalse, ReadOnlyTrue, AddToRecentFilesFalse, RevertFalse )ConfirmConversionsFalse阻止格式轉(zhuǎn)換確認(rèn)框ReadOnlyTrue保證源文件不被意外改動(dòng)AddToRecentFilesFalse不會(huì)把每個(gè)文件都塞進(jìn)“最近使用的文檔”列表。即便如此偶爾還會(huì)有Word進(jìn)程卡在后臺(tái)占用文件。我的習(xí)慣是每次批量轉(zhuǎn)換結(jié)束后執(zhí)行一次word.Quit()并用try/finally保證一定執(zhí)行。如果實(shí)在出現(xiàn)僵尸進(jìn)程可以用taskkill /f /im WINWORD.EXE清理但清理前要確認(rèn)沒(méi)有別的Word文檔正開(kāi)著否則會(huì)把同事正在寫的文檔也一起殺掉。3. LibreOffice 路線沒(méi)有 Office 也能轉(zhuǎn)但暗坑不少換到Linux服務(wù)器、或者目標(biāo)機(jī)器不想裝Office時(shí)LibreOffice是主選。它自帶命令行轉(zhuǎn)換模式不需要寫任何COM代碼。3.1 soffice 一行命令打通多格式轉(zhuǎn)換命令其實(shí)很簡(jiǎn)單soffice --headless --convert-to pdf --outdir /output/dir /input/dir/xxx.docx在Python里調(diào)用時(shí)不要用os.system拼字符串用subprocess.run傳參數(shù)列表避免路徑里的空格和特殊字符被shell誤解import subprocess def doc2pdf_libreoffice(input_path: str, output_dir: str) - bool: output_dir os.path.abspath(output_dir) os.makedirs(output_dir, exist_okTrue) cmd [ soffice, --headless, --convert-to, pdf, --outdir, output_dir, os.path.abspath(input_path) ] result subprocess.run(cmd, capture_outputTrue, textTrue) if result.returncode ! 0: print(result.stderr) return False return True注意--outdir必須是已存在的目錄否則LibreOffice會(huì)直接報(bào)“Output directory does not exist”。代碼里用os.makedirs(exist_okTrue)把這層兜住。另一個(gè)跨平臺(tái)細(xì)節(jié)Linux上命令可能是libreoffice而不是soffice程序里最好先用shutil.which(soffice)探測(cè)一次找不到再試libreoffice。3.2 中文字體、profile 鎖、outdir 三個(gè)重災(zāi)區(qū)LibreOffice第一大坑是中文字體。如果系統(tǒng)里沒(méi)有中文字體轉(zhuǎn)換出來(lái)的PDF會(huì)變成方框或者缺字。Windows上一般不用管系統(tǒng)字體在那里L(fēng)inux服務(wù)器上必須裝字體。常見(jiàn)做法是把Windows字體目錄里的simsun.ttc、msyh.ttc等文件拷到/usr/share/fonts下然后執(zhí)行fc-cache -f刷新字體緩存。轉(zhuǎn)換前可以用fc-list :langzh確認(rèn)系統(tǒng)里有中文字體。第二個(gè)坑是profile鎖。LibreOffice每次運(yùn)行都要讀取用戶配置目錄如果前一次異常退出留下了鎖定文件后續(xù)轉(zhuǎn)換會(huì)報(bào)錯(cuò)。解決辦法是在命令里加獨(dú)立profilesoffice --headless -env:UserInstallationfile:///tmp/lo_profile --convert-to pdf --outdir out in.docx批量并行轉(zhuǎn)換時(shí)每個(gè)子進(jìn)程必須用不同的profile目錄否則它們會(huì)互相搶鎖甚至全部失敗。Windows下路徑要寫成file:///C:/temp/lo_profile這樣的形式這個(gè)細(xì)節(jié)經(jīng)常被忽略。第三個(gè)坑就是前面說(shuō)的--outdir必須存在。三個(gè)坑總結(jié)成一句話LibreOffice能干活但環(huán)境和參數(shù)要伺候到位。3.3 LibreOffice 轉(zhuǎn) PDF 和 Word 轉(zhuǎn) PDF 的版面差異做完對(duì)比測(cè)試后建議大家對(duì)保真度要有心理預(yù)期。同一份帶表格和頁(yè)眉頁(yè)腳的文檔Word轉(zhuǎn)PDF基本一比一還原LibreOffice大體能看但如果原文檔用了很個(gè)性化的字體、復(fù)雜的文本框分層會(huì)出現(xiàn)輕微偏移或字體替換。項(xiàng)目Word COMLibreOffice頁(yè)眉頁(yè)腳還原度高大都能還原復(fù)雜表格還原度高多數(shù)正常個(gè)別單元格錯(cuò)位字體嵌入自動(dòng)嵌入依賴系統(tǒng)字體缺失時(shí)替換書簽鏈接ExportAsFixedFormat可生成默認(rèn)保留舊版.doc兼容和已裝Word版本一致對(duì)特別老的.doc兼容一般如果你的業(yè)務(wù)對(duì)版式要求極高我建議在同一臺(tái)機(jī)器上先用兩種方式各轉(zhuǎn)一份讓最終使用者肉眼對(duì)比一次再定正式路線。別等到批量轉(zhuǎn)完發(fā)現(xiàn)頁(yè)碼標(biāo)記不對(duì)再回頭找原因。3.4 順手救活 xlsx 和 ppt從 doc2pdf 到通用轉(zhuǎn)換LibreOffice的另一個(gè)好處是順帶處理Excel和PowerPoint。把命令里的--convert-to pdf保留輸入文件換成對(duì)應(yīng)擴(kuò)展名即可。比如財(cái)務(wù)要的月度報(bào)表其實(shí)是xlsx我就把工具擴(kuò)了一個(gè)--engine libreoffice參數(shù)一個(gè)通用函數(shù)就把doc/docx/xls/xlsx/ppt/pptx全轉(zhuǎn)PDF了。這一步在“工具化”章節(jié)里會(huì)體現(xiàn)得更清楚。4. 批量目錄掃描與并發(fā)提速的取舍單人單文件場(chǎng)景不需要批量處理但現(xiàn)實(shí)需求往往是“給我把整個(gè)文件夾都轉(zhuǎn)了”。4.1 pathlib 遞歸遍歷自動(dòng)跳過(guò)已轉(zhuǎn)出的PDF批量轉(zhuǎn)換的第一步是拿到所有待轉(zhuǎn)換文件的清單。我用pathlib而不是os.walk代碼更短也更易讀from pathlib import Path def collect_docs(source_dir: str, skip_existing: bool True) - list[Path]: exts {.doc, .docx, .xls, .xlsx, .ppt, .pptx} files [] for p in Path(source_dir).rglob(*): if p.suffix.lower() in exts and not p.name.startswith(~$): if skip_existing and p.with_suffix(.pdf).exists(): continue files.append(p) return filesrglob(*)遞歸遍歷目錄~$開(kāi)頭的文件是Office臨時(shí)文件必須過(guò)濾skip_existing這個(gè)開(kāi)關(guān)很重要批量任務(wù)中斷后重新跑一遍時(shí)會(huì)自動(dòng)跳過(guò)已經(jīng)成功的文件節(jié)省大量時(shí)間。文件后綴大小寫也要處理.doc和.DOC都要認(rèn)。4.2 并發(fā)提速要分引擎來(lái)看能不能用ThreadPoolExecutor把轉(zhuǎn)換速度提上去實(shí)測(cè)下來(lái)win32com路線最好別并行。原因是Word COM是單實(shí)例、有狀態(tài)的多個(gè)線程同時(shí)控制同一個(gè)Word進(jìn)程輕則互相干擾重則讓文檔打開(kāi)出錯(cuò)、進(jìn)程崩潰。穩(wěn)妥做法是單線程循環(huán)配合每處理N個(gè)文檔重啟一次Word進(jìn)程釋放內(nèi)存保持穩(wěn)定。LibreOffice則可以試并行但每個(gè)進(jìn)程必須指定不同的UserInstallation目錄否則profile鎖會(huì)讓它們互相等待效果反而更差。我做過(guò)一次20個(gè)文檔的測(cè)試單進(jìn)程LibreOffice約40秒4進(jìn)程并行約15秒提升確實(shí)明顯。并行粒度建議按文件分配不要嘗試在單個(gè)文件上多進(jìn)程轉(zhuǎn)換LibreOffice自己也會(huì)對(duì)單文件做多線程處理?yè)尣坏教嗍找妗?. 實(shí)測(cè)踩坑清單卡死、亂碼、路徑和校驗(yàn)這段內(nèi)容是我最想寫的。網(wǎng)上教程大多只給“成功路徑”但實(shí)際跑起來(lái)坑基本都在環(huán)境交互上。5.1 Word 彈窗讓批處理停擺的完整排查鏈路批量轉(zhuǎn)第二次的時(shí)候腳本在第三個(gè)文件卡住既沒(méi)有報(bào)錯(cuò)也沒(méi)有輸出。我第一反應(yīng)是已經(jīng)設(shè)置了VisibleFalse應(yīng)該沒(méi)彈窗但仔細(xì)一想彈窗藏不住不代表它不存在。排查鏈路是這樣的先在每份文檔轉(zhuǎn)換前后打印時(shí)間戳發(fā)現(xiàn)前兩份正常第三份進(jìn)入轉(zhuǎn)換后沒(méi)有結(jié)束日志。然后把Documents.Open的參數(shù)逐一調(diào)整發(fā)現(xiàn)是“是否恢復(fù)文檔”提示在處理一份以前異常關(guān)閉的docx時(shí)出現(xiàn)。最終處理辦法是三條word.Options.DoNotPromptForConvert True word.AutomationSecurity 3 doc word.Documents.Open(path, ConfirmConversionsFalse, ReadOnlyTrue, AddToRecentFilesFalse)配置完再跑所有彈窗都消失了。這里最值得記住的是問(wèn)題不一定是代碼邏輯錯(cuò)了而是Word替你做決定但被隱藏了。排查COM類問(wèn)題建議在流程里打印異常和文檔名讓卡住的位置暴露出來(lái)。5.2 中文路徑、特殊字符與亂碼日志的處理還有一次在Windows服務(wù)器上跑路徑里帶中文轉(zhuǎn)換直接拋UnicodeDecodeError。根子不在Word轉(zhuǎn)換本身而在subprocess或控制臺(tái)編碼。解決方法是傳遞路徑時(shí)用絕對(duì)路徑對(duì)象不要用字符串拼接避免Python的str和bytes隱式轉(zhuǎn)換。subprocess.run用列表傳參會(huì)天然規(guī)避shell排除特殊字符的問(wèn)題我建議一直堅(jiān)持這個(gè)習(xí)慣。如果腳本輸出的日志中文亂碼一般不是轉(zhuǎn)換壞了而是控制臺(tái)編碼不是UTF-8。Windows下可以在腳本開(kāi)頭加import sys sys.stdout.reconfigure(encodingutf-8)或者在調(diào)用Python時(shí)設(shè)置環(huán)境變量PYTHONIOENCODINGutf-8。這不影響最終PDF但能讓你在排查時(shí)看清到底報(bào)了什么錯(cuò)。5.3 用 pypdf 校驗(yàn)頁(yè)數(shù)把問(wèn)題攔在交付之前轉(zhuǎn)換完不等于萬(wàn)事大吉。我養(yǎng)成了習(xí)慣每轉(zhuǎn)完一個(gè)文件立刻用pypdf讀一次頁(yè)數(shù)同時(shí)確認(rèn)文件頭是%PDF并把頁(yè)數(shù)記錄到日志from pypdf import PdfReader def verify_pdf(path: Path) - int: try: reader PdfReader(path) return len(reader.pages) except Exception: return -1頁(yè)數(shù)為-1說(shuō)明文件損壞或根本沒(méi)生成成功頁(yè)數(shù)為0也要警惕可能內(nèi)容異常。再配合人工抽查兩三份源文檔和PDF對(duì)比基本能把批量異常在交付前攔下來(lái)。給財(cái)務(wù)的數(shù)據(jù)一旦交付返工成本可比檢查成本高太多。6. 把工具做到可以交付給同事用一個(gè)人用的腳本可以很隨意但“給財(cái)務(wù)部用”就得有個(gè)像樣的命令行工具。6.1 argparse 命令行參數(shù)設(shè)計(jì)我用argparse做了以下參數(shù)import argparse def main(): parser argparse.ArgumentParser(description批量doc/docx/xls/ppt轉(zhuǎn)PDF) parser.add_argument(-i, --input, requiredTrue, help輸入文件或目錄) parser.add_argument(-o, --output, defaultoutput, help輸出目錄默認(rèn)./output) parser.add_argument(-e, --engine, choices[word, libreoffice], defaultword, help轉(zhuǎn)換引擎) parser.add_argument(--skip-existing, actionstore_true, help跳過(guò)已生成的PDF) parser.add_argument(--parallel, typeint, default1, helpLibreOffice并行進(jìn)程數(shù)) args parser.parse_args() run_batch(args)默認(rèn)--engine word因?yàn)檗k公場(chǎng)景WindowsOffice最常見(jiàn)Linux服務(wù)器上用--engine libreoffice指定內(nèi)部自動(dòng)探測(cè)soffice或libreoffice命令。--skip-existing支持中斷后續(xù)跑--parallel只對(duì)LibreOffice生效程序會(huì)為每個(gè)并行進(jìn)程自動(dòng)加獨(dú)立profile。6.2 日志、失敗繼續(xù)和匯總報(bào)告批量任務(wù)最怕“一個(gè)文件失敗全部白做”。所以循環(huán)里每個(gè)文件單獨(dú)try/except失敗記錄原因并繼續(xù)。日志用logging同時(shí)輸出到控制臺(tái)和文件logging.basicConfig( levellogging.INFO, format%(asctime)s [%(levelname)s] %(message)s, handlers[ logging.FileHandler(convert.log, encodingutf-8), logging.StreamHandler() ] )最后把所有失敗列表集中打印同時(shí)生成一份summary.txt寫清楚成功數(shù)量、失敗文件及原因。這份匯總在交付時(shí)非常有用同事不用打開(kāi)日志就能找到問(wèn)題文件你也能快速定位是環(huán)境問(wèn)題還是個(gè)別文件損壞。6.3 打包 exe 的注意點(diǎn)以及我的交付習(xí)慣要不要把Python腳本打包成exe我的建議是如果只在會(huì)裝Python的人手里跑腳本就夠了如果給不懂技術(shù)的同事用打包成exe更省心。PyInstaller命令大致如下pip install pyinstaller pyinstaller -F --console doc2pdf.py--console保留控制臺(tái)這樣參數(shù)錯(cuò)誤時(shí)用戶能看到提示。引擎選win32com時(shí)exe本身不包含Office運(yùn)行機(jī)器上仍要裝OfficeLibreOffice同理只是不需要裝Python了。PyInstaller打包win32com程序偶爾會(huì)遇到運(yùn)行時(shí)找不到pywintypes加上這兩個(gè)隱藏導(dǎo)入一般能解決pyinstaller -F --console --hidden-import pywintypes --hidden-import pythoncom doc2pdf.py我目前的交付習(xí)慣是代碼倉(cāng)庫(kù)里放一份源碼和requirements.txt再把打包好的exe放內(nèi)網(wǎng)共享目錄。同事直接用exe你排查問(wèn)題直接用源碼兩邊都舒服。整套工具做下來(lái)我最大的體會(huì)是先把環(huán)境問(wèn)題解決掉再談寫代碼。裝好Office、裝好字體、試通一條命令、轉(zhuǎn)一份真實(shí)文檔肉眼對(duì)比遠(yuǎn)比調(diào)了一整天參數(shù)卻因?yàn)樵贚inux上沒(méi)裝中文字體而翻車強(qiáng)。最后分享一個(gè)小習(xí)慣在批量任務(wù)里加一個(gè)--dry-run參數(shù)先掃描目錄把所有待轉(zhuǎn)換文件打印出來(lái)不真正調(diào)用Word或LibreOffice。這樣能提前發(fā)現(xiàn)哪些文件命名有問(wèn)題、哪些已經(jīng)轉(zhuǎn)換過(guò)避免辛辛苦苦跑完才發(fā)現(xiàn)選錯(cuò)了目錄。專業(yè)工具不需要多花哨的界面把幾個(gè)樸實(shí)的流程安排順就能把重復(fù)勞動(dòng)變成一條可靠的流水線。