文档翻译 - 如何使用PDF.js提取PDF中的文字的位置/颜色/字体信息?

1,246 阅读5分钟

一.常见的PDF文档翻译方案:

1. 以 Word 文档为中间格式的翻译方案 ( DeepL 翻译 )

该方案先将 PDF 转换为 Word 格式,然后翻译并替换 Word 中的文字,最后利用 Word 的自动排版功能,重新生成翻译后的 PDF。

缺点: 由于 PDF 格式的灵活性,将 PDF 转换为 Word 不能完美地保留所有格式。同时,翻译后的自动排版也可能导致一些表格错乱。

2. 直接翻译并在原位置替换 PDF 的文本( Google 翻译 )

该方案通过提取 PDF 文本的详细信息(文字、颜色、字体、位置、反向等),然后在原来的位置进行翻译并替换原文,以达到翻译的效果。

缺点:  当译文比原文更长时,需要将字体缩小,才能放在原位置。

然而,在英译中的情况下,这个缺点大多数情况下并不存在。例如:

原文: PDF文档因为格式过于灵活导致难以翻译.
译文: PDF documents are difficult to translate due to their highly flexible format.


OTranslator.com 使用方案2的实现, 使用ChatGPT翻译替代Google的机器翻译, 同时针对PDF格式的多种情况进行优化。

具体效果可以之前文章中的介绍。

如何使用ChatGPT翻译文档(PDF/EPUB/Word/Excel/PowerPoint)?


二.如何使用PDF.js提取PDF中的文本信息?

PDF.js 是一个JavaScript库,用于在网页上渲染和交互显示PDF文档。虽然PDF.js主要用于在浏览器中呈现PDF文档,但它也提供了一些API和功能,可以用于从PDF中提取文本信息。

1. 安装pdf.js

npm install pdfjs-dist

2. 加载PDF文档

const pdfFile = "Your PDF file path"
const pdf = await getDocument({
      url: pdfFile,
      cMapUrl: './node_modules/pdfjs-dist/cmaps/',
      cMapPacked: true
    }).promise

3. 获取文本信息

假如需要读取第一页的文本信息, 首先通过 getPage 获得第一页文档信息的引用.

const pageNumber = 1 //假定我们需要获取第一页的文本
const page = await pdf.getPage(pageNumber)

然后通过 getTextContent().items 获得页面中的文本信息.

const textContent = await page.getTextContent()
console.log(textContent.items)

每一个item中, 代表页面里的一小块文本的信息, 例如

{
 "str": "Rearranging to avoid summing the infinite tail of the distribution...",
 "dir": "ltr",
 "width": 269.9515540245999,
 "height": 10.1,
 "transform": [10.1, 0, 0, 10.1, 108.1, 375.5],
 "fontName": "g_d0_f2",
 "hasEOL": false
}

字段说明

字段名用途
str文本内容
dir文本的方向: ttb(top to bottom), ltr(left to right) , rtl(right to left)
width宽度
height高度
transform用于描述文本项的变换矩阵. 最后两个数字(108.1,375.5)分表代表字体所在位置的X,Y坐标
fontNamePDF内部的字体风格代号, 需要转换才能得到真正的字体名字

三. 文本信息的计算

从上面TextContent的item里, 已经能得到文本的坐标和文字信息.

当翻译完成后, 就可以基于这两个信息把译文输出到翻译后的文档. 但是其实可以做得更好

1. 获取文本的字体信息, 使用原字体来输出译文

const fontFace = page.commonObjs.get(item.fontName)

fontFace包含字体的名称、类型和二进制数据等字体信息。字体信息的作用主要有两个:

  • 基于字体的名称,在网上搜索下载网站的字体。为了减少PDF文件的大小,PDF文档中的字体自包含文档中用的的字符数据。
  • 判断译文的字符是否能够使用原文的字符打印。每个字体都有自己所支持的字符列表。例如,一个英文字体是不支持中文字符的。直接打印会导致PDF中显示方块。
  • 计算译文的长度。当译文过长时,需要根据长度等比压缩字体的大小。

2. 文本的打印角度

不是所有的文本都是水平的,有些可能是以45度角打印的。

根据transform字段可以计算实际的打印角度。

const angle = Math.atan2(item.transform[1], item.transform[0])

3. 获取文本的颜色, 使用原字体颜色来输出译文

计算文本颜色是一个比较复杂的问题,在网上的资料也很少。

首先需要了解PDF的指令。简单来说,PDF页面其实是执行一系列指令后生成的结果。例如:

setTextMatrix [100,100, 200,300] // 指定文字区域
setFillRGBColor [255, 255, 0 ] // 设置填充颜色为黄色 RGB(255,255,0)
moveText [125, 130] // 移动坐标到(125, 130)
drawText [{chat:xx...}...] // 在当前坐标输入文字

可以看出,文字的颜色和位置是由输出文字的那一刻的填充颜色决定。因此,要计算一个item的颜色,需要以下两步:

  1. 定位item在指令流中的位置。
  2. 解析颜色相关的执行指令,计算输入item文字那一刻的填充颜色。

首先, 获取一个页面的指令流,并打印出来用于学习和观察。(带着目的去观察, 个人觉得比较好的学习方式)

const operatorList = await page.getOperatorList()

for (let fnIndex = 0; fnIndex < operatorList.fnArray.length; fnIndex++) {
    const fn = operatorList.fnArray[fnIndex]
    const args = operatorList.argsArray[fnIndex]
    // 打印执行流的信息, 包括 fn->函数名, args->函数参数 
    console.log(Object.keys(OPS).find(key => OPS[key] == fn), args)
}

然后,根据文本位置变化和颜色变化的相关指令计算item所在位置以及当时的填充颜色。

这个问题的详细说明已经在代码中了,所以不再展开描述。

export function getItemColor(item: TextItem, operatorList: PDFOperatorList) {
  // 记录状态的堆栈
  const stack: PDFStatus[] = []
  // 当前状态记录
  let currentStatus: PDFStatus = {}
  
  // 按顺序分析页面指令
  for (let fnIndex = 0; fnIndex < operatorList.fnArray.length; fnIndex++) {
    const fn = operatorList.fnArray[fnIndex]
    const args = operatorList.argsArray[fnIndex]
    switch (fn) {
      //保存
      case OPS.save:
        stack.push(currentStatus)
        currentStatus = { ...currentStatus }
        break
      //还原
      case OPS.restore:
        currentStatus = stack.pop() ?? {}
        break
      //设置文本填充颜色
      case OPS.setFillRGBColor:
        currentStatus.currentColor = [args[0], args[1], args[2]]
        break
      //设置文本区域
      case OPS.setTextMatrix:
        currentStatus.currentMatrix = [args[4], args[5]]
        currentStatus.currentXY = [args[4], args[5]]
        break
      //设置行距
      case OPS.setLeading:
        currentStatus.leading = args[0]
        break
      //设置字体类型和大小
      case OPS.setFont:
        currentStatus.font = [args[0], args[1]]
        break
      //计算换行, 换行时当前坐标需要跳到下一行的开头
      case OPS.nextLine:
      case OPS.nextLineShowText:
      case OPS.nextLineSetSpacingShowText:
        if (currentStatus.leading && currentStatus.currentXY) {
          currentStatus.currentXY = [currentStatus.currentXY[0], currentStatus.currentXY[1] - currentStatus.leading]
        }
        break
      //移动文本坐标
      case OPS.moveText:
        if (currentStatus.currentXY) {
          currentStatus.currentXY = [currentStatus.currentXY[0] + args[0], currentStatus.currentXY[1] + args[1]]
        }
        break
      //显示文本
      case OPS.showText:
        if (currentStatus.currentXY) {
          let x = currentStatus.currentXY[0]
          let y = currentStatus.currentXY[1]
          // 判断文本是否匹配定位
          const isMatch = () =>
            Math.abs(x - item.transform[4]) < item.height / 5 && Math.abs(y - item.transform[5]) < item.height / 5
          if (isMatch()) {
            return currentStatus.currentColor
          }
          if (args[0]) {
            // 计算打印的每个字的实际坐标, 然后和item的坐标进行配对
            for (let charInfo of args[0]) {
              if (typeof charInfo?. width == 'number' && currentStatus.font) {
                if (isMatch()) {
                  return currentStatus.currentColor
                }
                x += (charInfo?. width / 1000) * currentStatus.font[1]
              } else if (typeof charInfo == 'number' && currentStatus.font) {
                if (isMatch()) {
                  return currentStatus.currentColor
                }
                x -= (charInfo / 1000) * currentStatus.font[1]
              }
            }
          }
        }
        break
    }
  }
}