项目地址: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 + ' ', 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(' ') # 首行非文本时异常 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(' ').count(' ') + 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(' ') 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(' ').count(' ') + 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() + ' ' pos += lheight # text += lbuff # just_tab.append(True) lbuff = '' rest = maxwidth # 换行 重新计算剩余空间 if pl_w <= max # 完整空行可以容纳当前字符 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 += ' ' 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从提取到翻译,再到输出就已经初步结束了。剩下的就是根据需求细化了。