Comprobación del contenido de archivos PDF utilizando Python y pdfminer. Parte 2 / Sudo Null Noticias de TI

En la parte anterior del artículo, analizamos enfoques generales para las pruebas de PDF y aprendimos cómo las bibliotecas pdfminer y PDFQuery nos ayudan a obtener información detallada sobre los objetos. ¿Es esta información suficiente para nosotros? No siempre. En este artículo hablaremos sobre cómo resolver algunos problemas técnicos interesantes.

Creando tu propio objeto de página

Al probar páginas web, un patrón arquitectónico ya se ha convertido en un enfoque estándar. Objeto de página. Le permite abstraer la búsqueda e identificación de elementos y, a veces, la interacción con ellos, del código de las pruebas mismas.

En la parte anterior vimos que pdfminer nos brinda un conjunto simple de elementos dibujados en la página. Pero realmente nos gustaría trabajar no con un conjunto aleatorio, sino poder acceder a ellos teniendo en cuenta la semántica.

Puede abordar la solución del problema de diferentes maneras, por ejemplo, puede comenzar desde la posición esperada de los elementos y buscarlos utilizando las coordenadas calculadas. Elegimos un enfoque diferente: la formalización de la lógica humana para el reconocimiento de objetos.

Expliquemos con un ejemplo. Digamos que tenemos un documento que contiene una tabla y campos adicionales:

¿Cómo determinaríamos qué son datos tabulares y cuáles no? Todo parece claro: los datos tabulares son los que hay dentro de las celdas de la tabla. Las celdas de la tabla son las que están delimitadas por las líneas o límites exteriores de la tabla (que en este tipo de documento están definidos por los extremos de las líneas). A su vez, los datos a la izquierda de la tabla son campos de datos que incluyen un encabezado y luego pares de etiquetas y valores. Las etiquetas y los valores son pares de filas que no pertenecen a la tabla y se encuentran a la misma altura.

Ahora decidamos cómo queremos acceder a estos objetos. Por ejemplo, así:

table_page.table.num_columns
table_page.table.cell(0)(4)
table_page.legend.title
table_page.legend.fields(1).label

¿Qué hay que hacer para esto? Primero, necesita crear una jerarquía de clases que describan el diseño del documento, por ejemplo, así:

from dataclasses import dataclass, field
from typing import Optional
from pdfquery.pdfquery import LayoutElement


@dataclass
class TableLegendField:
    label: Optional(LayoutElement)
    value: Optional(LayoutElement)


@dataclass
class TableReportLegend:
    title: Optional(LayoutElement) = None
    fields: list(TableLegendField) = field(default_factory=list)


@dataclass
class TableReportTable:
    table_rect: tuple(float, float, float, float) = (0.0, 0.0, 0.0, 0.0)
    vertical_lines: list(LayoutElement) = field(default_factory=list)
    horizontal_lines: list(LayoutElement) = field(default_factory=list)
    # Список ячеек: первый индекс - номер строки, второй - номер столбца
    cells: list(list(Optional(LayoutElement))) = field(default_factory=list)

    @property
    def num_rows(self) -> int:
        return len(self.cells)

    @property
    def num_cols(self) -> int:
        return len(self.cells(0)) if self.cells else 0


@dataclass
class TableReportPage:
    legend = TableReportLegend()
    table = TableReportTable()
    all_elements: list(LayoutElement) = field(default_factory=list)

El segundo es escribir código para poblar estos objetos con elementos de la página. Es decir, necesitamos traducir en código la lógica descrita anteriormente, según la cual nuestra propia conciencia asigna objetos a uno u otro campo. Por ejemplo, al inicializar un objeto de página, puede tomar un conjunto inicial de objetos y aplicar los métodos secuencialmente:

self._detect_text_elements()
self._detect_table_lines()
self._detect_table_rectangle()
self._detect_table_cell_contents()
self._detect_legend_elements()

No tiene sentido insertar la implementación de esta lógica en el texto del artículo: no contiene nada de plantilla y el reconocimiento de elementos se escribe de forma diferente para cada formato de documento. Se puede encontrar un ejemplo de código de trabajo. Aquí. A Aquí — ejemplos de pruebas escritas utilizando nuestro objeto de página.

De hecho, puede resultar bastante difícil organizar los objetos correctamente, especialmente en páginas más complejas. Pero la facilidad de uso hace que valga la pena el esfuerzo.

Recibir imágenes rasterizadas

A veces necesitamos obtener una imagen de una página para compararla con una muestra. Pdfminer nos dará un elemento de la clase LTImage, que contendrá un campo de flujo, un enlace a un determinado objeto de datos con un conjunto de atributos:

La imagen en sí, como puedes imaginar, se encuentra en el campo stream.rawdata. Puede convertirlo a un formato PIL.Image más conveniente de la siguiente manera:

image_data = image_element.layout.stream
width, height = image_data.attrs("Width"), image_data.attrs("Height")
image = Image.frombytes("RGB", (width, height), image_data.get_data())

El método Image.frombytes requiere que se seleccione un modo de codificación de color. Seleccionamos el modo “RGB”, que coincide con los atributos de la imagen en el PDF: ColorSpace = “/DeviceRGB” y BitsPerComponent = 8. Si sus atributos son diferentes, es posible que necesite un modo diferente.

Se puede encontrar un ejemplo de un script para obtener una imagen. Aquí. Para crear el archivo con el que funciona por defecto es necesario ejecutar este guion.

Recursos y metadatos

A veces nos encontramos con problemas que no se pueden resolver comprobando las propiedades de los elementos. Por ejemplo, necesita acceder a los metadatos del documento.

Recuerde que en el proceso de análisis de PDF obtenemos acceso a dos conjuntos de objetos prácticamente independientes. Uno es un conjunto de objetos en una página que pdfminer genera después de interpretar el flujo de datos de la página. El segundo es una jerarquía de objetos que definen la propia estructura del archivo. Hablamos brevemente de ello en la primera parte del artículo, cuando describimos el dispositivo de formato PDF. Por suerte, también se puede acceder a estos elementos.

El procedimiento operativo es el siguiente:

  • Obtenemos una instancia de PDFDocument. Cuando se trabaja directamente con pdfminer, se crea explícitamente. Cuando se trabaja con PDFQuery, está disponible en el campo PDFQuery.doc.

  • Los campos del documento ya contienen enlaces a algunos objetos extraídos por el analizador, por ejemplo, PDFDocument.info y PDFDocument.catalog. Algunos de ellos, a su vez, hacen referencia a objetos hijos. Tan pronto como, al recorrer la jerarquía, nos topemos con una instancia del tipo PDFObjRef, que a primera vista no contiene nada, podemos llamar al método resolve(), que devolverá un objeto completo.

  • Para buscar los caminos que necesitamos en la jerarquía, existen al menos dos formas. Puede profundizar en la especificación PDF o puede utilizar Python REPL para examinar el contenido real del documento.

Pongamos algunos ejemplos.

Recuperar un mapa de bits

En versiones anteriores de pdfminer, el objeto LTImage no tenía un campo de flujo, sino solo el nombre del objeto en el directorio de recursos de la página. Puede obtener el contenido de una imagen con este nombre de la siguiente manera:

# Получaем доступ к каталогу страниц
pages_catalog = pq.doc.catalog("Pages").resolve()
# Получаем доступ к конкретной странице
page = pages_catalog("Kids")(page_index).resolve()
# Получаем доступ к изображению в каталоге ресурсов страницы
image_data = page("Resources")("XObject")(image_element.name).resolve()
# Далее работаем с картинкой ак же, как в примере выше

Recuperando metadatos

Los metadatos son lo que vemos en el cuadro de diálogo Propiedades del documento en Acrobat (autor, fecha de creación, etc.). Se almacenan en el campo PDFDocument.info.

Ejemplo completo Aquí.

Flujo de datos de página

A veces, para analizar casos complejos conjuntamente con los desarrolladores, es necesario obtener un flujo de datos descifrados en forma de texto descomprimido. Es accesible mediante la tecla “Contenido” en el objeto de la página:

pages_catalog = pq.doc.catalog("Pages").resolve()
page = pages_catalog("Kids")(page_index).resolve()
page_contents = page("Contents").resolve()

Ejemplo completo Aquí

Otros problemas complejos y cambios en la biblioteca

Recordemos una vez más una limitación importante de pdfminer, que se analizó en la primera parte del artículo. Esta no es una herramienta para probar el diseño, es una herramienta para extraer y organizar textos. Y a veces nos encontramos con situaciones en las que las capacidades de la biblioteca no son suficientes para realizar pruebas. Pero esto no es un problema tan grande, porque está escrito en Python y podemos modificar el código para adaptarlo a nuestras necesidades.

(Aquí es necesario hacer un descargo de responsabilidad: realizamos todos los cambios en el código de la biblioteca en una bifurcación en el repositorio interno del cliente. No están disponibles públicamente y solo pueden publicarse con el permiso del cliente. Luego tendremos que limitarnos a una descripción verbal de los cambios.)

Hablemos de recorte de objetos, heurística de texto y mejora del rendimiento.

Todos los objetos en PDF se recortan hasta el borde de la página. Es decir, no hay nada en el flujo de datos que le impida especificar coordenadas para texto o elementos gráficos que se extienden más allá de los límites de la página, pero durante la renderización las partes correspondientes se recortarán.

Sin embargo, puede definir contornos de recorte dentro de la página. Por ejemplo, en la siguiente ilustración vemos el mismo gráfico, que en un caso está cortado a lo largo de los límites de la cuadrícula de coordenadas, y en el otro, no. En este caso, los operadores para dibujar la línea del gráfico serán los mismos en ambos casos.

Si lo deseas, puedes generar este ejemplo tú mismo con y sin recorte usando guiony luego analizar y comparar el contenido usando este guion. Pdfminer nos devolverá un gráfico en forma de curva con un conjunto de puntos, pero no podremos entender de ninguna manera si fue cortado o dibujado por completo.

La ruta de recorte en el contenido de la página está definida por los operadores “W” o “W*”. Veamos cómo el intérprete de pdfminer procesa estos operadores. Se puede ver el código fuente del intérprete. aquí.

def do_W(self) -> None:
    """Set clipping path using nonzero winding number rule"""
    return

def do_W_a(self) -> None:
    """Set clipping path using even-odd rule"""
    return

Y no se puede culpar a los autores del código: los trazados de recorte son un tema mucho más complejo de lo que parece a primera vista. El recorte puede realizarse a lo largo de un contorno curvo, a lo largo de una curva que se interseca o incluso a lo largo del contorno de caracteres de texto. Los contornos recortados pueden superponerse entre sí en diferentes combinaciones. En otras palabras, apoyar la poda en el caso general es una tarea difícil y probablemente no sea particularmente necesaria para los creadores de la biblioteca.

Podemos resolver un problema particular cuando tratamos sólo con áreas de recorte rectangulares simples de la siguiente manera:

  • Agregamos la capacidad de que los objetos de página en pdfminer recuerden la pila de contornos de recorte.

  • Agregamos una implementación del método do_W del intérprete.

  • Recordamos una copia de la pila de rutas de recorte en cada elemento de página devuelto. Sí, esto aumenta el consumo de memoria, pero en nuestro caso no fue crítico.

  • Ya en el lado del código de prueba, podemos obtener una pila de contornos de recorte para cada objeto, calcular su unión con los límites del objeto y así obtener los límites reales visibles del objeto. Sólo tuvimos que trabajar con contornos rectangulares y calcular su intersección fue bastante sencillo.

Heurísticas innecesarias

pdfminer trabaja con texto como este:

Paso uno. Mientras se interpreta el flujo de datos, se llama al método PDFDevice.render_string(). Un dispositivo virtual, como ya se mencionó en la sección “Comenzando con pdfminer” en la primera parte del artículo, es la entidad en pdfminer en cuyo contexto se “presentan” todos los elementos de la página, o mejor dicho, se guardan. Hay varios tipos de dispositivos y el método render_string() es polimórfico; por ejemplo, para un dispositivo de texto recordará solo el texto sin datos sobre la fuente y otros parámetros gráficos. PDFQuery utiliza inmediatamente el dispositivo de clase PDFPageAggregator, que recopila la mayor cantidad de datos posible sobre los objetos. Sin embargo, en la biblioteca pdfminer original, el método render_string() no almacena líneas enteras de texto, sino sólo caracteres individuales (LTChar).

Paso dos. Después de la interpretación, se inicia el análisis de la página. Durante el proceso de análisis, pdfminer aplica heurísticas, que lo convierten en una herramienta para extraer texto coherente. Toma todos los caracteres LTChar y utiliza heurística para combinarlos en líneas y bloques de texto (LTTextLine, LTTextBox).

En la mayoría de los casos, la heurística funciona bien.

Pero también sucede que arruinan nuestras pruebas. Nuestros documentos tienen una estructura clara; de hecho, se pueden comparar con formularios, donde cada campo o celda de la tabla puede contener un valor separado. Pero a veces sucede que los caracteres no están lo suficientemente separados entre sí, por ejemplo, cuando llenamos las celdas de la tabla con valores demasiado grandes:

Pdfminer puede decidir que los números están lo suficientemente cerca y concatenarlos en una línea separada por un espacio. Nos gustaría recibirlos en líneas separadas. En nuestros documentos, cada valor se genera en su totalidad mediante una declaración de salida de texto separada en el flujo de datos de la página. En otras palabras, no queremos utilizar heurísticas para determinar qué caracteres se pueden combinar en cadenas. El flujo de datos en sí ya tiene toda la información sobre las filas.

Para eliminar la reagrupación de símbolos se puede proponer la siguiente solución:

  • Se ha agregado una bandera a los parámetros de pdfminer que le permite deshabilitar la heurística.

  • Se crea una sobrecarga del método render_string() que, si este indicador está habilitado, genera objetos LTTextLine inmediatamente durante el proceso de interpretación de la página y agrupa los caracteres LTChar necesarios en ellos.

  • Cuando pdfminer llega a la fase de análisis, ya no tiene ningún objeto LTChar libre en la página y no se produce ninguna reagrupación.

Actuación

Se ha observado que analizar documentos con tablas a veces lleva mucho tiempo. El análisis de tablas grandes de varias páginas puede tardar hasta varios minutos.

Para encontrar cuellos de botella utilizamos el habitual incorporado cPerfil y además de eso – un visualizador serpiente.

Resultó que PDFQuery es el que más daño hace. De forma predeterminada, intenta organizar los elementos de las páginas en una estructura jerárquica, determinando mediante coordenadas cuáles están uno dentro del otro. La complejidad del algoritmo es obviamente no lineal con respecto al número de elementos de la página, y el tiempo de procesamiento aumenta mucho cuando hay muchos elementos y estos son pequeños. Esta función se desactiva configurando resort=False al crear un objeto PDFQuery. No utilizamos los resultados de la reordenación en nuestras pruebas y creamos objetos de página nosotros mismos, por lo que podemos desactivar fácilmente esta opción.

Entre otros parámetros, se lograron mejoras notables:

  • deshabilitar el redondeo automático de números en PDFQuery,

  • deshabilitar la heurística para reagrupar texto en pdfminer.

Después de optimizar los parámetros, el principal cuello de botella siguió siendo el análisis de la estructura del PDF dentro de pdfminer. Hasta ahora no hemos encontrado ninguna forma de optimizar este código.

A modo de ilustración, veamos los resultados de tiempo usando el ejemplo de un archivo con una tabla que se genera nuestro guión. Primero, no pasaremos ningún parámetro especial a la biblioteca (dejaremos todos los parámetros por defecto), luego habilitaremos todas las heurísticas y el procesamiento al máximo, y luego deshabilitaremos lo que podamos deshabilitar. Se encuentra un script de ejemplo para medir el tiempo. Aquí.

1.019 s - with default parameters
1.044 s - with full analysis
0.159 s - with reduced analysis

Eso es todo. Esperamos que nuestra experiencia sea de utilidad en otros proyectos.


El artículo fue escrito junto con Oleg Leontyev como parte de su trabajo en Auriga.

Publicaciones Similares

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *