Проверка содержимого PDF-файлов средствами Python и pdfminer. Часть 2 |
17.12.2024 00:00 |
Автор: Наталья Баранова, Аурига В предыдущей части статьи мы рассмотрели общие подходы к тестированию PDF и познакомились с тем, как библиотеки pdfminer и PDFQuery помогают нам получать детальную информацию об объектах. Достаточно ли нам этой информации? Далеко не всегда. В этой статье мы расскажем о решении некоторых интересных технических проблем. Делаем свой Page ObjectВ тестировании веб-страниц уже стал стандартным подход, когда используется архитектурный паттерн Page Object. Он позволяет абстрагировать поиск и идентификацию элементов, а иногда и взаимодействие с ними, от кода самих тестов. В предыдущей части мы видели, что pdfminer дает нам простой набор элементов, отрисованных на странице. Но нам бы очень хотелось работать не с беспорядочным набором, а уметь обращаться к ним с учетом семантики. Подходить к решению проблемы можно по-разному, например, можно исходить из ожидаемого положения элементов и искать их по расчетным координатам. Нами был выбран другой подход — с формализацией человеческой логики распознавания объектов. Поясним на примере. Допустим, у нас есть документ, который содержит таблицу и дополнительные поля: Как бы мы определили, что является табличными данными, а что нет? Кажется, все понятно: табличные данные — это то, что находится внутри ячеек таблицы. Ячейки таблицы — это то, что ограничено линиями или внешними границами таблицы (которые в данном типе документа определяются концами линий). В свою очередь, данные слева от таблицы — это поля с данными, которые включают заголовок и далее пары из меток и значений. Метки и значения — это пары строк, которые не относятся к таблице и расположены на одной высоте. Теперь давайте решим, как мы хотим обращаться к этим объектам. Например, так:
Что нужно для этого сделать? Первое — нужно создать иерархию классов, описывающих макет документа, например, так:
Второе — написать код для заполнения этих объектов элементами со страницы. То есть, надо перенести в код логику, описанную выше, по которой наше собственное сознание относит объекты к тому или иному полю. Например, при инициализации Page Object можно взять первоначальный набор объектов и последовательно применить методы:
Реализацию этой логики в текст статьи вставлять нет смысла: в ней нет ничего шаблонного, и для каждого формата документа распознавание элементов пишется по-своему. Пример рабочего кода можно найти здесь. А здесь — примеры тестов, написанных с помощью нашего Page Object. На самом деле, правильно разложить объекты бывает совсем непросто, особенно для более сложно устроенных страниц. Но удобство использования оправдывает затраченные усилия. Получение растровых картинокИногда нам нужно получить со страницы изображение, чтобы сравнить его с образцом. Pdfminer отдаст нам элемент класса LTImage, в котором будет поле stream – ссылка на некий объект данных с набором атрибутов: Сама картинка, как можно догадаться, находится в поле stream.rawdata. Преобразовать ее в более удобный формат PIL.Image можно следующим образом:
Метод Image.frombytes требует выбора режима кодировки цвета. Мы выбираем режим "RGB", который соответствует атрибутам изображения в PDF: ColorSpace = "/DeviceRGB" и BitsPerComponent = 8. Если ваши атрибуты отличаются, возможно, потребуется другой режим. Пример скрипта с получением изображения можно найти здесь. Чтобы создать файл, с которым он по умолчанию работает, надо запустить этот скрипт. Ресурсы и метаданныеИногда мы сталкиваемся с задачами, которые не решаются проверкой свойств элементов. Например, нужно получить доступ к метаданным документа. Вспомним, что в процессе парсинга PDF мы получаем доступ к двум практически независимым наборам объектов. Один — это набор объектов на странице, который pdfminer формирует после интерпретации потока данных страницы. Второй — это иерархия объектов, которые задают саму структуру файла. О ней мы кратко говорили в первой части статьи, когда описывали устройство формата PDF. К счастью, к этим элементам тоже можно получить доступ. Порядок работы следующий:
Приведем несколько примеров. Извлечение растрового изображенияВ более старых версиях pdfminer у объекта LTImage не было поля stream, а было только имя объекта в каталоге ресурсов страницы. Получить содержимое картинки по этому имени можно следующим образом:
Получение метаданныхМетаданные — это то, что мы видим в диалоге "Document Properties" в Acrobat (автор, дата создания и т.д.). Они хранятся в поле PDFDocument.info. Полный пример здесь. Поток данных страницыИногда для совместного разбора сложных случаев с разработчиками нужно получить расшифрованный поток данных в виде разархивированного текста. Он доступен по ключу "Contents" в объекте страницы:
Полный пример здесь Другие сложные проблемы и изменение библиотекНапомним еще раз одно важное ограничение pdfminer, о котором говорили в первой части статьи. Это не инструмент для тестирования верстки, это инструмент для извлечения и упорядочивания текстов. И иногда мы сталкиваемся с ситуациями, когда возможностей библиотеки не хватает для тестирования. Но это не такая большая проблема, ведь она написана на Python, и мы можем переделать код для своих нужд. (Здесь необходимо дать дисклеймер: все изменения в коде библиотеки мы делали в форке во внутреннем репозитории заказчика. В общем доступе их нет, и опубликованы они могут быть только с разрешения заказчика. Нам же далее придется ограничиваться словесным описанием изменений.) Поговорим про обрезку объектов, текстовые эвристики и повышение производительности. Все объекты в PDF обрезаются по контуру страницы. То есть в потоке данных ничто не мешает указать координаты для текста или графических элементов, которые выходят за границы страницы, но при рендеринге соответствующие части будут обрезаны (clipped). Однако можно задать контуры обрезки и внутри страницы. Например, ниже на иллюстрации мы видим один и тот же график, который в одном случае обрезан по границам координатной сетки, а в другом — нет. При этом операторы для отрисовки самой линии графика в обоих случаях будут одинаковые. При желании вы можете самостоятельно сгенерировать этот пример с обрезкой и без нее с помощью скрипта, а потом распарсить и сравнить содержимое с помощью этого скрипта. Pdfminer вернет нам график как кривую с набором точек, но мы никак не сможем понять, был он обрезан или был отрисован полностью. Контур обрезки в содержимом страницы определяется операторами «W» или «W*». Давайте посмотрим, как интерпретатор pdfminer'а обрабатывает эти операторы. Исходный код интерпретатора можно увидеть тут.
И авторов кода нельзя упрекнуть: контуры обрезки — гораздо более сложная тема, чем кажется на первый взгляд. Обрезка может идти по криволинейному контуру, по самопересекающейся кривой или даже по контуру символов текста. Контуры обрезки могут накладываться друг на друга в разных сочетаниях. Иначе говоря, поддержать обрезку в общем случае — задача сложная и, вероятно, не особо нужная создателям библиотеки. Решить частную задачу, когда мы имеем дело только с простыми прямоугольными областями обрезки, можно следующим образом:
Ненужные эвристикиС текстом pdfminer работает так: Шаг первый. В процессе интерпретации потока данных вызывается метод PDFDevice.render_string(). Виртуальное устройство, как уже упоминалось в разделе «Начало работы с pdfminer» в первой части статьи — это та сущность в pdfminer, в контексте которой происходит «отрисовка», а точнее, сохранение всех элементов страницы. Устройств несколько видов, и метод render_string() полиморфен, например, для текстового устройства он будет запоминать только текст без данных о шрифте и других параметрах графики. PDFQuery сразу использует устройство класса PDFPageAggregator, которое собирает максимум данных об объектах. Однако в оригинальной библиотеке pdfminer метод render_string() сохраняет не целые строки текста, а только отдельные символы (LTChar). Шаг второй. После интерпретации запускается анализ страницы. В процессе анализа pdfminer применяет эвристики, которые и делают его инструментом для извлечения связного текста. Он берет все символы LTChar и с помощью эвристик объединяет их в строки и блоки текста (LTTextLine, LTTextBox). В большинстве случаев, эвристики работают просто отлично. Но бывает и так, что они портят нам тесты. Наши документы имеют четкую структуру — по сути, их можно сравнить с формами, где в каждом поле или ячейке таблицы может находиться отдельное значение. Но иногда происходит так, что символы недостаточно далеко отстоят друг от друга — например, когда мы заполняем ячейки таблицы слишком большими значениями: Pdfminer может решить, что числа расположены достаточно близко, и объединить их в одну строку через пробел. Нам же хотелось бы получать их в виде отдельных строк. В наших документах каждое значение целиком выводится отдельным оператором вывода текста в потоке данных страницы. Иначе говоря, мы не хотим применять эвристики, чтобы узнать, какие символы можно объединить в строки. В самом потоке данных уже есть вся информация о строках. Для того, чтобы исключить перегруппировку символов, можно предложить такое решение:
ПроизводительностьБыло замечено, что на парсинг документов с таблицами иногда тратится заметное время. На разбор больших многостраничных таблиц могло уходить до нескольких минут. Для поиска узких мест использовали обычный встроенный cProfile и дополнительно к нему — визуализатор snakeviz. Оказалось, что больше всего вредит PDFQuery. По умолчанию он пытается выстроить элементы на страницах в иерархическую структуру, определяя по координатам, какие из них находятся внутри друг друга. Сложность алгоритма, очевидно, нелинейная относительно количества элементов на странице, и время обработки сильно растет, когда элементов много, и они мелкие. Эта функция отключается параметром resort=False при создании объекта PDFQuery. Мы в тестах никак не пользуемся результатами пересортировки и самостоятельно формируем page objects, поэтому можем легко выключить этот параметр. Из других параметров заметный выигрыш дали:
После оптимизации параметров основным узким местом остался сам парсинг структуры PDF внутри pdfminer. Пока способов оптимизировать этот код мы не нашли. В качестве иллюстрации посмотрим на результаты тайминга на примере файла с таблицей, который генерируется нашим скриптом. Сначала не будем передавать библиотеке никаких специальных параметров (оставим все параметры по умолчанию), потом включим все эвристики и обработки по максимуму, а потом отключим то, что можем отключить. Пример скрипта для замера времени находится здесь.
Вот и все. Надеемся, что наш опыт окажется полезным в других проектах. Статья была написана совместно с Олегом Леонтьевым в рамках работы в компании «Аурига». |