从PDF中提取文本和边界框

这是我解释BankStatementConverter如何工作系列博客文章的第一部分。在这篇文章中,我将解释获取边界框和其他字符属性的代码。许多代码都是基于DrawPrintTextLocationsPDFTextstripper.

我们要做的第一件事就是使用PDFBox加载PDF文件,然后我们再逐页处理文档。我们需要逐页处理PDF,避免内存耗尽,大多数文档不到十页长,但也有超过10,000页长的文档,如果我们试图将一个大文档中的所有数据加载到内存中,我们会很快耗尽内存,我们的应用程序也会奔溃。我们使用parsePage方法提取页面上的所有字符,我们会得到一个CharAndBound对象列表。

data class CharAndBound(
    val char: Char,
    val bound: Rectangle,
    val color: Int,
    val fontCode: Int,
    val rotation: Int
) : IRectangle by bound

FastCharacterParser

FastCharacterParser类的stripPage方法负责返回CharAndBound对象列表。它继承了pdfbox的PDFStreamEngine类并侦听以下事件:

private fun setOperators() {
    addOperator(ShowText())
    addOperator(BeginText())
    addOperator(Concatenate())
    addOperator(DrawObject())
    addOperator(Save())
    addOperator(Restore())
    addOperator(NextLine())
    addOperator(MoveText())
    addOperator(MoveTextSetLeading())
    addOperator(SetFontAndSize())
    addOperator(ShowTextAdjusted())
    addOperator(SetTextLeading())
    addOperator(SetMatrix())
    addOperator(ShowTextLine())
}

当PDFStreamEngine遇到字符,它将调用FastCharacterParser中的showGlyph方法,在这里我们会计算CharAndBound对象的所有属性。

override fun showGlyph(
	textMatrix: Matrix, 
	font: PDFont, 
	code: Int, 
	displacement: Vector
)

我们可以使用textMatrix参数来确定字符在页面上的角度和位置。我们使用字体的边界框来确定字符的宽度和高度。有些字体没有边界框信息,当我们遇到这种情况,我们选择回退到硬编码的边界框,并希望最终结果不会出错。

val bbox = getBoundingBoxWithHackyFallback(font)

// 设置步进宽度, bbox 高度 (字符空间)
val xAdvance = font.getWidth(code)
val rectangle = Rectangle2D.Float(0f, bbox.lowerLeftY, xAdvance, bbox.height)

到目前为止,我们有一个Rectangle2D,但它是在“字符空间”内而不是在“文档空间”,你可以看到上面有一个明显的问题,即x坐标被硬编码为了0F。下一步是将此矩形从字符空间转换为文档空间。

val affineTransform = textMatrix.createAffineTransform()

// 转换长方形
val buffer = floatArrayOf(rectangle.x, rectangle.y, rectangle.x + rectangle.width, rectangle.y + rectangle.height)
affineTransform.transform(buffer, 0, buffer, 0, 2)
flipAT.transform(buffer, 0, buffer, 0, 2)
rotateAT.transform(buffer, 0, buffer, 0, 2)

// 计算转换后的长方形边界值
val minX = min(buffer[0], buffer[2])
val maxX = max(buffer[0], buffer[2])
val minY = min(buffer[1], buffer[3])
val maxY = max(buffer[1], buffer[3])
val bound = Rectangle(minX, minY, maxX - minX, maxY - minY)

上面的AffineTransformation是根据页面的旋转角度和页面的高度配置的。PDF定义的原点为页面的左下角,而我更喜欢以左上角作为原点,因此我用flipAT执行变换。类似地,rotateAT负责向字符的边界框添加页面旋转信息。大多数文档的旋转度为0,但横向文档的旋转度通常为90或270度。现在页面上有了字符的边界框。接下来,我们需要弄清楚这个字体代码代表什么字符。

var unicode = font.toUnicode(code, glyphList)

if (unicode == null) {
    if (font is PDSimpleFont) {
        val c = code.toChar()
        unicode = String(charArrayOf(c))
    } else {
        return
    }
}

unicode = normalizeWord(unicode)

首先,我们尝试从字体中获取fontCode的字符。如果失败了,且字体是SimpleFont,我们便假设字体的代码是unicode,只需将其转换为字符即可。normalizeWord方法负责分解组合字符的基本部分。字符“ff”在UTF16中编码为FB00,normalizeWord将其分解为两个“f”字符并返回字符串“ff”。

if (unicode.length == 1) {
    characters.add(CharAndBound(unicode.first(), bound, rgb, fontCode, rotation))
    return
}

val startX = bound.left()
val width = bound.width / unicode.length

for (index in unicode.indices) {
    val x = startX + (index * width)
    characters.add(CharAndBound(unicode[index], bound.copy(x = x, width = width), rgb, fontCode, rotation))
}

通常由characterCode形成的字符串长度为1。如果是这种情况,我们可以生成一个CharAndBound,并将其添加到字符列表中。然而,如果我们遇到像“ff”这样的组合字符,unicode将是“ff”且长度为2。上面的代码在字符串中的字符之间共享同一个边界框。例如,假设“ff”有一个[0,0,120,30]的边界框,在拆分后,第一个“f”将有一个[0,0,60,30]的边界框,第二个“f”将有一个[60,0,60,30]的边界框。这段代码不适用于垂直文本,但对于我的使用范围内似乎并不会遇到这个问题

现在让我们看看它在实践中表现如何,我在网上找到了此PDF,提取边界框,然后将它们渲染到每一页上。看起来这个精度对于我们的使用范围来说没有问题。

在下一篇文章中

在系列的第二部分,我们将讨论如何使用CharAndBound列表来确定转账表的头。

Join The Mailing List