Extracción de texto y cuadros delimitadores de un PDF

Esta es la primera parte de una serie de publicaciones en el blog donde explico cómo funciona BankStatementConverter. En esta publicación, explicaré el código que determina los cuadros delimitadores y otros atributos de los caracteres en una página. Gran parte de este código se extrajo de DrawPrintTextLocations y [PDFTextStripper](https: //pdfbox.apache.org/docs/2.0.7/javadocs/org/apache/pdfbox/text/PDFTextStripper.html).

Lo primero que hacemos es cargar el archivo PDF usando PDFBox y luego procesamos el documento página por página. Los archivos PDF se procesan página por página porque si no nos quedamos sin memoria, la mayoría de los documentos tienen menos de diez páginas, pero hay documentos que tienen más de 10,000 páginas, si intentáramos cargar todos los datos de una gran documento en la memoria, rápidamente se nos llenaría y bloquearíamos nuestra aplicación. Comenzamos con el método parsePage extrayendo todos los caracteres de la página para terminar con una lista de objetos CharAndBound.

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

FastCharacterParser

El método stripPage de la clase FastCharacterParser es responsable de devolver una lista de objetos CharAndBound. Extiende la [clase PDFStreamEngine] de PDFBox (https://pdfbox.apache.org/docs/2.0.13/javadocs/org/apache/pdfbox/contentstream/PDFStreamEngine.html) y esta pendiente de los siguientes eventos:

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())
}

Cuando PDFStreamEngine encuentra un glifo, llama al método showGlyph en FastCharacterParser, aquí es donde calculamos todas las propiedades del objeto CharAndBound.

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

Podemos usar el parámetro textMatrix para determinar la rotación y la posición del glifo en la página. También queremos el ancho y el alto del glifo, usamos el cuadro delimitador de la fuente para determinar eso. Algunas fuentes no tienen información de cuadro delimitador, cuando eso sucede, recurrimos a un cuadro delimitador codificado y esperamos lo mejor.

val bbox = getBoundingBoxWithHackyFallback(font)

// advance width, bbox height (glyph space)
val xAdvance = font.getWidth(code)
val rectangle = Rectangle2D.Float(0f, bbox.lowerLeftY, xAdvance, bbox.height)

En este punto tenemos un Rectangle2D, pero está en ‘espacio de glifo’ en lugar de ‘espacio de documento’, un problema obvio que puede ver arriba es que la coordenada x está codificada en 0f. El siguiente paso es transformar este rectángulo de espacio de glifo a espacio de documento.

val affineTransform = textMatrix.createAffineTransform()

// Transformar el rectángulo
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)

// Calcular los límites del rectángulo transformado
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)

Las AffineTransformations anteriores se configuran en función de la rotación de la página y la altura de la página. Los archivos PDF definen su origen como la parte inferior izquierda de la página, prefiero un origen superior izquierdo y flipAT realiza esa conversión. Del mismo modo, rotateAT es responsable de agregar rotación de página al cuadro delimitador del carácter. La mayoría de los documentos tienen una rotación de 0, pero a menudo los documentos horizontales tienen una rotación de 90 o 270. Ahora tenemos el cuadro delimitador del glifo en la página. A continuación, debemos averiguar qué carácter(es) representa este código fuente.

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)

Primero intentamos obtener los caracteres para el fontCode de la fuente. Si eso falla, y la fuente es SimpleFont, asumimos que el código de la fuente es Unicode y simplemente lo convertimos en un carácter. El método normalizeWord es responsable de dividir los caracteres compuestos en sus partes base. El carácter ‘ff’ está codificado en UTF16 como FB00, normalizeWord lo divide en dos caracteres ‘f’ y devuelve el string “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))
}

Por lo general, el string formado a partir de characterCode tiene una longitud de uno. Si ese es el caso, podemos generar un CharAndBound y agregarlo a la lista de caracteres. Sin embargo, si nos encontramos con un carácter compuesto como ‘ff’, Unicode será “ff” y tendrá una longitud de dos. El código anterior comparte el cuadro delimitador entre los caracteres de los strings. Por ejemplo, suponga que ‘ff’ tenía un cuadro delimitador de [0,0,120,30], después de dividir la primera ‘f’ tendría un cuadro delimitador de [0,0,60,30] y la segunda ‘f’ tendría un cuadro delimitador de [60,0,60,30]. Este código no funciona para texto vertical, aunque parece seguro ignorar el texto vertical en mi ejemplo.

Ahora veámoslo en acción. Encontré este PDF en línea, extraje los cuadros delimitadores y luego los reproduje en cada página. Parecen lo suficientemente precisos para nuestros propósitos.

La próxima vez

En la segunda parte, hablaremos sobre cómo usamos la lista CharAndBound para determinar los encabezados de una tabla de transacciones.

Join The Mailing List