Некоторое время назад у нас появился интересный проект по созданию сервиса, генерирующего документы в формате PDF. И появилась задача — написать тесты, которые проверят документ в мельчайших деталях, включая и содержимое, и вёрстку. В данной статье мы расскажем, каким образом справились с этой задачей.
Начальные условия
Когда мы впервые показывали этот материал людям, не знакомым с проектом, часто возникали вопросы: «почему так сложно?», «зачем уходить в такие подробности?». Поэтому необходимо пояснение, ведь любое решение возникает только тогда, когда есть соответствующая задача.
- Тесты должны полностью проверять верстку документа на соответствие исходным дизайн-макетам.
- Макеты содержат подробные требования о размере и взаимном положении элементов, их цвете, применяемых шрифтах, выравнивании текста и так далее. Иногда задаются достаточно строгие ограничения на погрешность расположения элементов, вплоть до долей миллиметра.
- Наши документы — это отчёты с большим количеством элементов. Тестирование свойств и положения всех элементов на странице с достаточной точностью — работа, которая должна быть автоматизирована.
- Вдобавок к этому, существует большое количество функциональных требований на разные варианты входных данных. Получаем множество тест-кейсов с разными вариантами данных — и в каждом случае тест должен проверить, что создается правильный файл PDF с правильным отображением этих данных.
В результате, мы столкнулись с тем, что возможностей даже продвинутых библиотек нам бывает недостаточно, и нужно глубже разобраться в самом формате документа.
Готовых рецептов по созданию подробных тестов на верстку PDF в сети тоже не нашлось. Но не всё сразу, будем есть слона по частям. Для начала разберемся со стратегией тестирования и выберем подходящие инструменты.
Выбор стратегии тестирования
Выше было сказано, что необходима автоматизация проверки элементов. Однако тем, кто работает с тестированием верстки, неважно, идет ли речь о документах или веб-страницах. Кажется очевидным, что одними автотестами здесь не обойтись. Практически во всех случаях нужно вручную удостовериться, что конечный результат полностью соответствует ожиданию с точки зрения пользователя и не содержит заметных глазу отклонений. Поэтому нам нужно найти баланс между разными видами тестирования и понять, каким тестам какие задачи доверить. Давайте рассмотрим основные подходы.
Ручное тестирование
Для него видим три основных применения. Первое — ручное тестирование удобно и неизбежно, когда делаем ad-hoc тестирование новых фич. Второе — небольшое количество регрессионных тестов на основные сценарии работы нужно оставлять также ручными. Даже визуальные тесты, о которых поговорим ниже, не дадут уверенности, что мы нашли все артефакты и проблемы с документом. Третье, и самое неприятное — это те случаи, когда автоматизированные инструменты не дают возможности проверить все необходимые параметры. Об ограничениях инструментов также скажем ниже.
Недостатки ручных тестов понятны. Во-первых, это то, о чем сказано выше — невозможность вручную выверять параметры верстки, цвет и размер для каждого элемента. Во-вторых — большая вариативность входных данных и ручная проверка отображения всех комбинаций, которые мы хотим протестировать, заняла бы слишком много времени. В-третьих — невозможность использовать такие тесты в CI.
Визуальное тестирование
Речь идет о сравнении скриншотов (а в случае PDF это могут быть картинки, создаваемые, например, с помощью Ghostscript). Казалось бы, автоматизированные визуальные тесты по заранее созданным данным могут полностью заменить ручные, но это не совсем так.
Во-первых, из-за специфики продукта не получилось бы создавать гарантированно идентичные документы по одинаковым входным данным. Есть поля, которые останутся динамическими, и при сверке изображений их придется вырезать.
Вторая проблема, которую уже не представляется возможным обойти — это необходимость ручной проверки скриншотов в случае падения тестов. Понятного описания ошибки в логах мы не получим (разве только с помощью ИИ — но когда его обучать?). В любом случае, к завершению проекта предполагалось иметь небольшое количество визуальных тестов, работающих в CI.
Парсинг содержимого PDF-файлов
Главный плюс этого подхода — возможность полноценной автоматизации, проверки свойств объектов на странице в мельчайших подробностях и проверки текста. Соответственно, есть возможность реализовать любые функциональные тест-кейсы. Но есть и минусы.
Первый минус — высокая сложность тестов и большие затраты времени на создание и усовершенствование тестового фреймворка. Готовых подходов, наподобие тех, которые существуют для тестирования веба, у нас не было. Например, для PDF нет готового ответа на вопросы: «как найти определенный элемент на странице?», «можно ли создать что-то вроде локаторов для page object?». Как мы дальше увидим, документы внутри себя не имеют заранее заданной структуры или разметки. Кроме этого, надо было разобраться в исходном коде инструментальных библиотек, так как их требовалось усовершенствовать.
Второй большой недостаток — то, что такая автоматизация не позволяет на 100% проверить вид документа. К примеру, результаты парсинга дают нам набор графических объектов, но теряется информация о порядке их отрисовки. То есть, мы не можем сказать, был ли выведен текст поверх фона, или фон нарисован поверх текста.
Еще один пример — встроенные в документ шрифты. Мы получаем информацию о кодах символов, их положении и размере, но не о внешнем виде. У нас нет полной уверенности, что в сам шрифт не закралась досадная ошибка, и что по нужным кодам не записалось начертание совсем других символов, или что обычный шрифт не поменялся местами с полужирным.
Риски со шрифтами и с неправильной отрисовкой как раз и должны быть закрыты ручными и визуальными тестами.
Парсинг — самая интересная часть, о нем и пойдет речь далее. И для начала нам необходимо разобраться в том, как же устроен формат PDF.
Структура файла PDF
Несмотря на наличие готовых библиотек для разбора PDF, без знаний о его внутреннем устройстве нам не обойтись — так же, как при тестировании веб-страниц не обойтись без понимания структуры HTML. Раскрыть эту тему достаточно подробно в статье невозможно, но постараемся дать хотя бы краткий обзор, позволяющий понять, о чем говорится далее.
Более полные материалы об устройстве PDF доступны на сайте PDF Association. Еще несколько лет назад спецификация PDF была документом, который компания Adobe бесплатно предоставляла на своей странице ресурсов для разработчиков. Теперь спецификация оформлена в стандарт ISO 32000. Старую версию стандарта ISO 32000-1 можно скачать по прямой ссылке с сайта PDF Association. Более современная версия ISO 32000-2 доступна только по запросу с предоставлением всех персональных данных, как это обычно бывает с зарубежными стандартами. Для решения большинства задач достаточно старой версии.
В самом простом случае структура файла включает следующие поля:
- Заголовок — стандартная строка, например, «%PDF 1.7».
- Тело документа, которое состоит из набора объектов.
- Таблица ссылок на объекты (cross-reference table). Всем объектам в теле документа присваиваются номера, и в таблице записано смещение каждого объекта.
- Концовка (trailer), в которую записывается смещение начала таблицы ссылок, некоторая метаинформация и ключевая строка «%%EOF».
В более сложном случае, например, если документ был отредактирован, эти секции могут повторяться несколько раз, то есть изменения добавляются путем наращивания документа новыми секциями объектов и таблицами ссылок.
Таблица ссылок нужна, так как объекты в теле документа часто ссылаются друг на друга, и ссылки делаются по номеру объекта. Объекты выстроены в иерархическую структуру, но, к сожалению, это не та иерархия, которую мы хотели бы видеть на страницах. Отдельные объекты существуют только на уровне описания структуры документа, ресурсов и метаданных.
Поясним: на верхнем уровне иерархии обычно находятся каталог страниц и словарь метаданных документа (автор, заголовок и т.д.). Каталог страниц содержит ссылки на список страниц, оглавление и другие общие параметры документа. Список страниц содержит ссылки на объекты-страницы. Каждая страница — это словарь, который тоже может содержать множество ссылок и параметров, например, такие важные параметры как длина, ширина и угол поворота страницы. Из объектов, на которые ссылается страница, нам наиболее важны словарь ресурсов (картинок, шрифтов и т.д.) и потоковый объект данных. Последний в оригинале называется content stream, мы же для однообразия будем называть его потоком данных страницы.
Поток данных страницы содержит последовательность операторов отрисовки содержимого. И вот тут уже ни о какой иерархии речь не идет. Содержимое может добавляться в любом порядке. Фактически, речь идет о командах для рисования по канве, которое знакомо всем, кто когда-либо использовал графические контексты в оконных приложениях или canvas в JavaScript.
В тело PDF потоковые объекты обычно добавляются в сжатом формате. В разархивированном виде это просто текст, где каждая команда имеет вид:
[аргументы через пробел] оператор
Посмотрим на небольшой пример:
q | Положить текущие параметры графики на стек |
0 0 0 RG | Установить цвет линий и контуров RGB(0, 0, 0) |
.1 w | Установить толщину линий 0.1 pt |
n | Начать рисование контура |
100.0 500.0 m | Перейти (move to) на точку (100 pt, 500 pt) |
200.0 500.0 l | Провести линию (line to) до точки (200 pt, 500 pt) |
S | Закончить контур и обвести его без заливки. В данном примере отрисуется одна горизонтальная линия длиной 100 pt |
BT | Начать блок текста |
1 0 0 1 28.0 39.0 Tm | Установить матрицу трансформаций текста. В данном случае — параллельный сдвиг начала координат на точку (28.0 pt, 39.0 pt) с сохранением масштаба |
/F1 11 Tf | Установить шрифт размера 11 и гарнитуру, которая в словаре ресурсов страницы имеет ключ /F1 |
(Hello World) Tj | Вывести текст, указанный в скобках, с позиции, определенной матрицей трансформаций |
ET | Закончить блок текста |
Q | Восстановить предыдущие параметры графики со стека |
Можно заметить, что все координаты указаны в точках (pt = point). Это типографская единица измерения, равная 1/72 дюйма. В тех же единицах измеряется размер шрифта. Кроме того, может быть непривычным, что начало координат находится в левом нижнем углу страницы, и ось Y направлена вверх.
Отдельная особенность PDF – матрицы трансформаций. Если вы помните матрицы преобразований координат из линейной алгебры, то это они и есть. (На самом деле, матрицы квадратные, и в них 9 значений. Но 3 из них всегда одни и те же, поэтому рядом с оператором Tm мы видим 6 значений.) При этом, каждая следующая матрица накладывается на текущее преобразование, то есть, они перемножаются, и сброс к предыдущему состоянию может произойти только при закрытии блока (ET для текста или Q для графики). Из-за этого, просто читая набор команд с середины, трудно понять, в какой системе координат мы в данный момент находимся. И помочь могут только библиотеки для парсинга.
Выбор инструмента
Было рассмотрено несколько вариантов, которые удалось найти в сети по состоянию на 2020-2021 годы:
- pypdf
- pdfminer.six (будем называть его просто pdfminer)
- Camelot
- PDFUnit
- Также нужно упомянуть pdf-test, про который можно найти информацию на Хабре.
pypdf — это очень неплохой набор инструментов для работы с PDF-файлом, в том числе: создание, разбор структуры самого файла, дополнение, заполнение форм и другие операции с объектами (не с теми, которые на странице, а с теми, которые находятся в таблице ссылок). Но его возможности по извлечению содержимого страницы крайне ограничены.
pdfminer — продвинутый инструмент для извлечения текстов. Задача эта нетривиальная, ведь кусочки текста могут быть отрисованы на странице произвольным образом (например, это может быть журнальная страница с колонками, врезками и буквицами), а pdfminer должен «склеить» их и выдать в том порядке, как их читает обычный пользователь. В качестве выходных данных он может вернуть не только чистый текст, но и структуру документа в виде XML — с учетом вложенности блоков и добавлением разделительных графических элементов.
Camelot – инструмент для майнинга данных. Он очень хорошо умеет детектировать в PDF таблицы и извлекать табличные данные в виде готовых фреймов. К сожалению, он ограничен этой специализацией.
PDFUnit – инструмент для тестирования. Он включает некоторые готовые проверки для содержимого страниц. Но, к сожалению, их набор ограничен, и для решения наших задач (см. выше начальные условия) этих проверок недостаточно.
В итоге нами был выбран pdfminer. Дело в том, что для своих целей он реализует разбор потока данных страницы на объекты. Поток прогоняется через встроенный интерпретатор, и в результате все «отрисованные» на канве элементы сохраняются в виде типизированных объектов. Например, в приведенном выше примере, pdfminer поймет, что нарисованный контур — это линия, и вернет объект типа LTLine. В этом объекте будут сохранены все основные свойства: положение, цвет, толщина линии и т. д. Если же контур включает больше элементов, то получим уже объект кривой LTCurve, содержащий список точек, через которые она проходит. Все координаты, измененные матрицами преобразования, будут пересчитаны и возвращены в обычной координатной системе страницы.
Начало работы с pdfminer
Давайте, перейдем к практике. При желании, полный код примеров можно найти здесь. Если хотите проверять примеры в действии, для начала можно сгенерировать образцы PDF с помощью скриптов (1, 2).
На деле оказывается, что получать объекты с помощью pdfminer не так-то просто. Он написан прежде всего для использования в командной строке, и лаконичного внутреннего API для наших задач у него нет. Для извлечения данных нам придется создать целый набор объектов:
with open(input_file_path, 'rb') as f:
# парсер, который будет разбирать основную структуру файла
parser = PDFParser(f)
# документ, в который парсер будет складывать всю информацию
doc = PDFDocument(parser)
parser.set_document(doc)
# менеджер ресурсов
resources_mgr = PDFResourceManager()
# "устройство" для внутреннего рендеринга объектов
# (в pdfminer есть несколько типов устройств - например, есть такое,
# которое сохраняет только текст без свойств графики)
device = PDFPageAggregator(resources_mgr, laparams=LAParams())
# интерпретатор потока данных, который будет "рисовать"
# в контексте созданного выше устройства
interpreter = PDFPageInterpreter(resources_mgr, device)
# получаем первую страницу
pages_iter = PDFPage.create_pages(doc)
page0 = next(pages_iter)
# запускаем на ней интерпретатор и забираем с устройства отрендеренные объекты
interpreter.process_page(page0)
page_layout = device.get_result()
В итоге работы данного фрагмента в переменной page_layout окажется список объектов, который находится в контейнере — объекте страницы класса LTPage. Некоторые объекты, в свою очередь, являются контейнерами. Если вывести все объекты рекурсивно на консоль, мы увидим следующее:
<LTPage(1) 0.000,0.000,841.890,595.276 rotate=0>
-- "Данные наблюдений" <LTTextBoxHorizontal(0) 28.346,506.448,216.555,524.448 'Данные наблюдений\n'>
---- "Данные наблюдений" <LTTextLineHorizontal 28.346,506.448,216.555,524.448 'Данные наблюдений\n'>
------ "Д" <LTChar 28.346,506.448,41.170,524.448 matrix=[1.00,0.00,0.00,1.00, (28.35,510.24)] font='AAAAAA+LiberationSans-Bold' adv=12.823241399999999 text='Д'>
------ "а" <LTChar 41.170,506.448,51.180,524.448 matrix=[1.00,0.00,0.00,1.00, (41.17,510.24)] font='AAAAAA+LiberationSans-Bold' adv=10.010741399999999 text='а'>
------ "н" <LTChar 51.180,506.448,62.053,524.448 matrix=[1.00,0.00,0.00,1.00, (51.18,510.24)] font='AAAAAA+LiberationSans-Bold' adv=10.872070200000001 text='н'>
------ "н" <LTChar 62.053,506.448,72.925,524.448 matrix=[1.00,0.00,0.00,1.00, (62.05,510.24)] font='AAAAAA+LiberationSans-Bold' adv=10.872070200000001 text='н'>
------ "ы" <LTChar 72.925,506.448,88.297,524.448 matrix=[1.00,0.00,0.00,1.00, (72.92,510.24)] font='AAAAAA+LiberationSans-Bold' adv=15.372070200000001 text='ы'>
------ "е" <LTChar 88.297,506.448,98.307,524.448 matrix=[1.00,0.00,0.00,1.00, (88.30,510.24)] font='AAAAAA+LiberationSans-Bold' adv=10.010741399999999 text='е'>
...
В зависимости от того, как вам удобно писать тесты, можно оставить как есть, а можно преобразовать все к одноуровневому списку. Последнее может быть удобно, например, для фильтрации и сортировки.
Полный код скрипта, который получает и выводит в консоль все объекты, можно найти здесь.
Давайте подробнее посмотрим на свойства объектов:
«LTTextLineHorizontal 28.346,506.448,216.555,524.448» — здесь перечислены координаты левого нижнего и правого верхнего угла, которые в коде будут нам доступны как поля x0, y0, x1, y1.
«LTChar 88.297,506.448,98.307,524.448 matrix=[…] font=’AAAAAA+LiberationSans-Bold’ adv=10.010741399999999» – здесь мы видим, что у отдельных символов в строке доступны дополнительные свойства. Вряд ли нам вручную придётся работать с матрицей (pdfminer уже выполнил все необходимые преобразования). Элемент adv показывает относительное смещение следующего символа в строке, он, по сути, дублирует информацию о ширине элемента. А вот название шрифта может уже пригодиться.
Кроме этого, у LTChar есть дополнительные свойства, которые автоматически на печать не выводятся — это свойства графики: цвет обводки, цвет заливки и т. д.
Что такое «AAAAAA+» в названии шрифта? Спецификация PDF говорит, что это тэг подмножества шрифта (см. раздел «Font Subsets»). Тэг всегда состоит из шести заглавных букв и уникален для каждого подмножества, включенного в документ. Как правило, для нас не важно, что это за буквы, и их можно отбросить.
Аналогично выглядят и элементы графики — обычно это линии (LTLine), кривые (LTCurve) и прямоугольники (LTRect). Круги и окружности доступны нам в виде LTCurve, фрагменты которых отрисованы как кривые Безье.
Также документ может включать растровые изображения, но с ними всё сложнее. О них планируем подробнее рассказать в следующей публикации.
Добавляем PDFQuery
Еще один инструмент, который показался нам удобным и полезным — PDFQuery. Но ничто не мешает пользоваться только pdfminer.
PDFQuery выполняет для нас всю грязную работу по инициализации pdfminer’а. Можно просто написать одну строчку, и сразу получить доступ к объектам на странице:
pq = PDFQuery(file_path)
PDFQuery объединяет возможности pfdminer и пакета pyquery, позволяющего делать запросы, по виду похожие на CSS-селекторы, к структурированным документам, например, к XML. Внутри себя PDFQuery забирает результаты рендеринга страницы из pfdminer и строит из них XML-подобную структуру, прибегая к помощи пакета lxml. Более подробно о возможностях PDFQuery можно почитать на странице проекта, здесь же для наглядности приведем пару примеров:
# Получить список элементов всех классов со страницы 1
pq.pq('LTPage[page_index="0"] *')
# Получить список элементов со страницы 4, находящихся внутри заданного прямоугольника
# (помним, что все размеры измеряются в pt, а начало координат — левый нижний угол)
pq.pq('LTPage[page_index="3"] :in_bbox("0,0,10,10")')
Здесь можно посмотреть скрипт, который получает объекты со страницы уже с помощью PDFQuery и выводит список в консоль, как и в предыдущем примере.m/ru/companies/auriga/articles/843774/
Тип объектов будет немного другой. Практически всё, что возвращает pdfminer, будет обернуто в тип LayoutElement, который наследуется от Element из библиотеки lxml. Оригинальные элементы из pdfminer будут доступны в поле layout:
Можно предположить, что раз используется lxml, то объекты будут выстроены в некоторую иерархию. И это действительно так, PDFQuery пытается своими силами определить, какие объекты являются «родительскими», а какие «дочерними», исходя из их видимой вложенности, и выстраивает их в дерево.
К сожалению, здесь не будет рецептов, как использовать результаты этого автоматического упорядочивания. Мы пользовались собственными методами упорядочивания элементов, о которых планируем рассказать в следующей части статьи.
Выводы
В первой части мы успели рассказать, как в общих чертах устроен формат PDF, какие нам доступны подходы к тестированию и инструменты, и показали, как pdfminer помогает нам получить свойства отрисованных на странице объектов.
Этого уже достаточно, чтобы начать тестирование содержимого и верстки документа.
Во второй части будет предложен более структурированный подход к тестированию и описаны решения некоторых интересных технических проблем.
Источник — блог Ауриги на Хабре.