項目地址:https://gitee.com/Shanyalin/pdf-tranlate
關於pdf翻譯,有以下幾個需要注意的點。
1.文本提取。從pdf中提取文本用於翻譯,在我們不清楚pdf格式的情況下,我們不能想當然的認爲pdf的格式都是一樣的,頁面上一行行從上到下提取就可以。有不少的文檔分左右兩欄,甚至左中右三欄。如果按行提取,極有可能上下文語義不通,後續的翻譯就不必再提。因此文本提取的關鍵在於符合人們閱讀習慣的情況下,儘可能的按段落進行提取。
2.翻譯。翻譯不算是pdf翻譯的核心要點,現在通用的免費的翻譯接口很多,僅百度的也能滿足大多數應用場景。
3.輸出pdf。翻譯後的文本應該怎樣輸出,其實有多個方案。如不按照原來的格式,自定義規則輸出;如雙語排版,一段原文一段譯文;或者按照原文檔格式原樣輸出。自定義規則和雙語排版更適用於不分欄且沒有圖表的純文本。考慮到pdf格式不統一,按照原樣式輸出相對更簡單一些。
經過兩週左右的調研,符合閱讀習慣的文本提取更加便於文本的整合。C#和java項目或庫,或完全開源或半開源,在某些關鍵性問題上有疑難。鑑於pdf翻譯的第一個要點,最終選擇了py的pymupdf作爲操作pdf的庫。結合QPromise/EasyTrans項目的啓發,寫了這個腳本,既可以集成在EasyTrans裏替換原來翻譯,也可以單獨以腳本的形式來進行pdf翻譯。EasyTrans是基於django的網站翻譯系統,內部集成了谷歌、百度、搜狗、有道翻譯接口,主要做英譯中的翻譯;也可以上傳pdf文件翻譯。
以下簡單列表比較以下調研的庫或項目的特點,以便後期擴展。由於時間問題,調研並不充分,有些功能的使用和描述並不準確,僅就調試中出現的問題進行討論。
項目名稱/庫 | 語言 | 調研功能 | 結果分析 |
spire.pdf | C#/java |
文本提取:可以提取指定區域,可以整頁提取,無法處理表格 圖片提取:正常 轉html:標籤以字符爲單位,需自行開發重新組合的方法 |
提取結果不符合閱讀習慣。 根據圖片切分頁面,提取指定區域文本,可能會在沒有圖片時整頁提取 |
Aspose |
C#/java |
文本提取速度極慢,官方文檔提取demo多次有異常 |
文本提取出現無法識別的字符 |
Itextsharp |
C#/java |
提供了整頁提取和範圍提取的api,整頁提取時出現亂碼現象 |
亂碼現象短期不能解決,且本庫常用於寫pdf |
Npoi |
C# |
Windows office可以將pdf轉爲docx,再通過nopi來提取 |
需前置Windows office,且轉換過程中有可能丟失文件信息 |
QPromise/EasyTrans |
Py |
適用於譯文比原文短的情況,無法處理表格 |
文本提取符合閱讀習慣。 圖片提取保存時不支持某些格式。 |
Pdf_translator |
py |
先將pdf轉換爲圖片,對圖片進行ocr識別,記錄位置信息來保持原有格式 |
引入Ocr識別增加了額外的風險 |
Pdfpig |
C# |
BobLd/pdfDocumentLayoutAnalysis, BobLd/simple-docstrum。 項目提供了6種組合的算法,符合人們的閱讀習慣 |
文本識別有問題,有中文變韓語亂碼的現象 |
通過對EasyTrans項目的調試,發現直接使用pymupdf的get_text_blocks()方法提取出來的blocks還是不能滿足我們的需要,因此重新對blocks進行計算重排。
主要的思路是逐頁遍歷blocks,比較當前的block與上一個block的位置關係。Block提供了對應的座標(分別是左上角和右下角),我們也可以實例化對應的rect,根據block的x0、y1,我們可以確定文本是否有縮進及行間距大小,判斷兩個block是否是同一段落。將屬於同一段的block進行合併,把頁面的blocks重新進行組合。通過閱讀文檔和調試,發現將文本提取成字典dict,對字典中的blocks進行自定義整合更能滿足我們的需求。部分代碼如下,完整代碼請閱讀項目文件。
def dicts2blocks(extract_dict): blocks = extract_dict['blocks'] blks = [] for d in blocks: bbox, type, fz, text = d['bbox'], d['type'], 0, '' if type == 0: # 文本 lines = d['lines'] for l in lines: for s in l['spans']: fz = max(fz, s['size']) text += s['text'] pass else: # 非文本 pass blks.append((bbox[0], bbox[1], bbox[2], bbox[3], text + '\n', fz, type)) return blks
def rebulid_blocks(blks: list, is_height_in_block=False): re_blks = [] rect_curr, rect_pre, text_pre, type_pre = None, None, '', 0 width_curr, width_pre, height_curr, height_pre = 0, 0, 0, 0 for i, b in enumerate(blks): if i == 0: rect_pre, text_pre, type_pre = fitz.Rect(b[:4]), b[4], b[-1] try: index_pre = text_pre.index('\n') # 首行非文本時異常 except Exception as ex: index_pre = -1 width_pre, height_pre = b[-2] if is_height_in_block else rect_pre.width / max(index_pre + 2, len(text_pre)), \ b[-2] if is_height_in_block else rect_pre.height / ( text_pre.strip('\n').count('\n') + 1) continue rect_curr, text_curr, type_curr = fitz.Rect(b[:4]), b[4], b[-1] if type_curr != 0: # 當前非文本 re_blks.append((rect_pre, text_pre, type_pre)) rect_pre, text_pre, type_pre, width_pre, height_pre = rect_curr, text_curr, type_curr, width_curr, height_curr else: # 當前文本 try: index_curr = text_curr.index('\n') except Exception as ex: index_curr = -1 width_curr, height_curr = b[-2] if is_height_in_block else rect_curr.width / max(index_curr + 2, len(text_curr)), b[ -2] if is_height_in_block else rect_curr.height / ( text_curr.strip('\n').count('\n') + 1) if 0 <= rect_curr.y1 - rect_pre.y1 <= 1.8 * height_curr \ and -0.2 * width_curr <= rect_pre.x0 - rect_curr.x0 <= 4 * width_curr \ and rect_curr.y0 >= rect_pre.y1: # 同一段洛 # 與上一行屬於同一段 # 右下角縱座標差距爲1.8個字符寬度(段落首行字符寬度) 左上角縱座標差距-0.2-4個字符高度(段落首行字符高度) 當前左上縱座標應大於上一行右下縱座標 # 右下角縱座標差距爲1.8個字符寬度 左上角縱座標差距-0.2-4個字符高度 當前左上縱座標應大於上一行右下縱座標 rect_pre = fitz.Rect(min(rect_pre.x0, rect_curr.x0), min(rect_pre.y0, rect_curr.y0), max(rect_pre.x1, rect_curr.x1), max(rect_pre.y1, rect_curr.y1)) text_pre += text_curr type_pre = type_pre # 不修改width_pre 和 height_pre pass else: re_blks.append((rect_pre, text_pre, type_pre)) rect_pre, text_pre, type_pre, width_pre, height_pre = rect_curr, text_curr, type_curr, width_curr, height_curr re_blks.append((rect_pre, text_pre, type_pre)) return re_blks
dicts = cur_page.get_text('dict') blks = dicts2blocks(dicts) blks = rebulid_blocks(blks, True)
翻譯,可使用百度接口,具體不做贅述。
我選擇原樣輸出pdf的方式,所以在遍歷重組後的rebulid_blocks時,對每個block中的文本進行翻譯,然後將翻譯結果進行回填即可。EasyTrans項目中使用的是insert_textbox在指定區域輸出文本,需要控制字體的大小,注意此方法是有返回值的,結果大於0時表示寫入成功。
經調試發現,如果是中譯英,譯文較長的情況,指定固定的字體大小的話,大概率不能輸出成功。因此使用insert_textbox之前最好先計算出字體的大小。默認的字體不支持中文的輸出,easytrans提供了宋體的輸出方法及字體文件。通過閱讀insert_textbox源碼,發現內部已經實現了關於字體大小的計算,但是源碼中只針對指定的字體大小進行計算,對相關代碼提取改造之後,即可獲得在指定範圍內輸入指定行高指定內容的最大字號的方法。這樣做的好處是頁面看起來比較充實,不至於有大面積留白(字號偏大,輸出失敗時rect內爲空白;字號偏小,輸出成功時rect內有大量空白沒有使用);壞處在於段落間字號的大小不一致。當然也有一些方案進行改進,但結果一定是部分段落留白較多。
def calc_fontsize(rect, content, lang='zh', lineheight=1.5): rect = fitz.Rect(rect) fz = 12.0 font = fitz.Font(fontname='song', fontfile='resource/SimSun.ttf') if lang == 'zh' else fitz.Font('helv') maxwidth, maxpos = rect.width, rect.y1 for s in range(130, 10, -3): s = float(s / 10) point = rect.tl + fitz.Point(0, s * font.ascender) pos = point.y # 換行時發生變化 lbuff, rest = '', maxwidth line_t = content.expandtabs(1).split(' ') lheight = s * lineheight # text, just_tab = '', [] for word in line_t: pl_w = font.text_length(word, fontsize=s) if rest >= pl_w: # 當前行剩餘空間可以容納當前文本 lbuff += word + ' ' rest -= pl_w + s continue if len(lbuff) > 0: # 當前行剩餘空間不能容納當前字符,且當前行內已有文本,需換行 lbuff = lbuff.rstrip() + '\n' pos += lheight # text += lbuff # just_tab.append(True) lbuff = '' rest = maxwidth # 換行 重新計算剩餘空間 if pl_w <= maxwidth: # 完整空行可以容納當前字符 lbuff = word + ' ' rest = maxwidth - pl_w - s continue # 換行後完整空行不能容納當前字符串 # if len(just_tab) > 0: # just_tab[-1] = False # 標記非正常斷行 for c in word: if font.text_length(lbuff, fontsize=s) <= maxwidth - font.text_length(c, fontsize=s): # buff中可以容納當前字符 lbuff += c else: # 當前行滿了 lbuff += '\n' pos += lheight # text += lbuff # just_tab.append(False) lbuff = c lbuff += ' ' rest = maxwidth - font.text_length(lbuff, fontsize=s) if lbuff != '': # 最後一行有剩餘文本 # text += lbuff.rstrip() # just_tab.append(False) pass more = pos - maxpos if more > fitz.utils.EPSILON: # 超出範圍 continue fz = s break return fz pass
至此,pdf從提取到翻譯,再到輸出就已經初步結束了。剩下的就是根據需求細化了。