Техническая статья

Рекурсия Form XObject: обнаружение циклов в PDFlibPas для Delphi

PDFlibPas разрешает рекурсивные вызовы Form XObject в потоках содержимого PDF на Delphi, отслеживая активную цепочку вызовов, а не глобальное множество посещённых объектов, так что TPDFlib.EnumPageContentStatesEx может обойти одну и ту же форму, вызванную несколько раз на одной странице, не приняв законное переиспользование за цикл. Штамповый Form XObject в шаблоне счёта-фактуры — типичный случай: один и тот же объект вызывается из заголовка, из подвала и из слоя водяного знака на одной странице, и только цепочка вызовов, замыкающаяся сама на себя, — настоящий цикл

ISO 32000-1 §8.10 определяет Form XObject как самодостаточный поток содержимого, который страница или другая форма вызывает оператором Do, с собственной системой координат в /Matrix, границей обрезки в этой системе координат в /BBox и, опционально, собственным словарём ресурсов. Ничто в спецификации не ограничивает, сколько раз можно вызвать одну форму или насколько глубоко формы могут вызывать друг друга, так что соответствующий спецификации парсер должен принимать законное переиспользование и законную вложенность, всё же защищаясь от того единственного расклада, который спецификация действительно запрещает: формы, чей поток содержимого прямо или транзитивно вызывает саму себя. PDFlibPas сообщает об этом различии через значения TPDFlibContentFormTraversalStatus, прикреплённые к каждому снимку Do, в первую очередь ftsEnumerated для успешного спуска и ftsCycle для того единственного случая, что реально является циклом

Почему переиспользование одного и того же Form XObject не вызывает ложный цикл?

Повторяющаяся ссылка на Form XObject сама по себе не является доказательством чего-либо неправильного. ISO 32000-1 позволяет вызывать один и тот же объект формы из стольких мест в потоке содержимого, сколько пожелает автор, и именно так штамп логотипа, шаблон фирменного бланка или подвал с номером страницы переиспользуются на странице без многократного дублирования их потока содержимого. Наивная защита от неконтролируемой рекурсии — единственное множество посещённых, ключом для которого служит номер объекта: как только обходчик впервые видит объект формы 12, он помечает 12 как увиденный и отказывается заходить в него снова где-либо ещё в дереве. Такой подход ломается в момент, когда один и тот же штамп появляется в двух не связанных углах одной страницы, потому что второй, совершенно законный вызов приходит уже после того, как номер объекта помечен увиденным, и отклоняется, будто это цикл

PDFlibPas избегает этого ложного срабатывания, ограничивая обнаружение циклов текущей цепочкой вызовов, а не всем документом. EnumPageContentStatesEx помещает разрешённый поток формы в активную цепочку вызовов непосредственно перед спуском в него, а затем снимает ту же запись с цепочки в момент, когда спуск возвращается, успешно или нет. Соседний вызов идентичного потока начинается только после того, как первый уже был снят, так что к моменту, когда соседний вызов его проверяет, цепочка вызовов от этого потока уже свободна, и обходчик перечисляет его точно так же, как перечислил бы любую другую форму. Настоящий цикл выглядит иначе на той же цепочке: форма A вызывает форму B, B всё ещё открыта в цепочке, когда её собственное содержимое вызывает обратно A, а A всё ещё сидит в цепочке от внешнего вызова, что ещё не вернулся, — это единственная форма, о которой сообщает ftsCycle: поток формы, всё ещё открытый где-то раньше в текущей цепочке вызовов, а не просто присутствующий где-то ещё на странице

Насколько глубоко может зайти рекурсия Form XObject прежде, чем PDFlibPas её остановит?

Обнаружение циклов и ограничение глубины решают две разные задачи, и PDFlibPas именно поэтому держит их как два разных исхода TPDFlibContentFormTraversalStatus. Цепочка из двадцати различных форм, каждая из которых вызывает следующую и ни одна не повторяется, не является циклом ни по какому определению — проверка активной цепочки никогда не находит повторяющегося потока, — но двадцать честных уровней вложенности всё же означают двадцать уровней разбора, конкатенации матриц и разрешения ресурсов, которые некорректный или враждебный PDF мог бы толкать произвольно выше, если бы ничто иное этого не остановило. Именно по этой причине EnumPageContentStatesEx принимает параметр MaxFormDepth и ограничивает любое переданное значение максимумом в 64, независимо от того, что запросил вызывающий код. Глубина, равная нулю, — особый случай, о котором стоит знать отдельно: он полностью отключает рекурсию форм и воспроизводит плоское, только-страничное поведение более старого метода EnumPageContentStates, поэтому каждый снимок Do в этом режиме сообщает ftsNotRequested вместо какой-либо попытки

var
  Lib: TPDFlib;
  States: array of TPDFlibContentGraphicsState;
  Count, I: Integer;
begin
  Lib:= TPDFlib.Create;
  try
    if Lib.LoadFromFile('invoice-batch.pdf', '')<> 1 then
      Exit;
    Lib.SelectPage(1);
    Count:= Lib.EnumPageContentStatesEx(True, 8, States);  // count only
    SetLength(States, Count);
    Lib.EnumPageContentStatesEx(True, 8, States);          // fill
    for I:= 0 to Count- 1 do
      if States[I].FormTraversalStatus= ftsCycle then
        LogSuspectForm(States[I].XObjectResource, States[I].ContentDepth);
  finally
    Lib.Free;
  end;
end;

Один субтрекер на вызов: изоляция графического состояния

Каждый спуск в Form XObject получает собственный трекер графического состояния, а не делит тот, что уже обходит страницу, потому что поток содержимого формы обязан оставить графическое состояние ровно таким, каким его нашёл, а PDFlibPas не может предполагать, что каждый открываемый PDF действительно соблюдает это требование. Дочерний трекер начинает со снимка того, какие CTM, состояние цвета и текстовые параметры были активны на вызывающей инструкции Do, а затем сбрасывает собственный стек сохранения-восстановления и отслеживание текущего пути до пустого прежде, чем выполнить хотя бы одну инструкцию формы. Несбалансированный q без соответствующего Q внутри небрежной или повреждённой формы — не редкость в PDF, произведённых старым инструментарием, — остаётся заключённым внутри трекера этого одного вызова и никогда не просачивается в трекер страницы или в соседний вызов того же штампа, стоящий строкой позже в потоке содержимого

/Matrix формы сочетается с действующим на Do CTM тем же способом, что и оператор cm, — умножается слева на текущее преобразование, а не заменяет его, — и PDFlibPas намеренно переиспользует тот же путь кода вместо поддержки второй формулы, поскольку две независимые реализации одной и той же матричной алгебры — именно тот вид дублирования, что незаметно расходится после нескольких раундов композиции масштаба, поворота и сдвига. /BBox затем обрезает в собственном координатном пространстве формы после того, как матрица уже применена, и все четыре угла этого прямоугольника преобразуются по отдельности, а не только противоположные углы, поскольку повёрнутая или скошенная форма иначе может сообщить ограничивающий прямоугольник, пропускающий реальное содержимое, оказавшееся в том, что раньше было крайним углом, до того как преобразование его сдвинуло куда-то ещё. Расширение цикла из предыдущего примера на тот же массив States читает эти поля напрямую

for I:= 0 to Count- 1 do
  if (States[I].OperatorName= 'Do')and (States[I].XObjectKind= cxkForm)and
    States[I].FormBBoxKnown then
    Writeln('Form ', States[I].XObjectResource, ' matrix ',
      States[I].FormMatrix.M11:0:3, ',', States[I].FormMatrix.M12:0:3,
      ' bbox ', States[I].FormBBoxLeft:0:1, '..', States[I].FormBBoxRight:0:1);

Разделяют ли две формы с одним именем ресурса один шрифт?

Нет. Имя ресурса вроде /F1 имеет смысл только относительно словаря ресурсов, действующего в точке его использования, и два разных Form XObject вольны определить два совершенно разных шрифта под этим идентичным именем. PDFlibPas решает это, отслеживая область видимости ресурса наряду с каждым именем ресурса: когда форма несёт собственный словарь /Resources, этот словарь становится полной областью видимости ресурсов для всего внутри неё, без поресурсного отката к словарю страницы или вызывающего кода для того, что собственный словарь формы случайно опускает. Только форма вообще без ключа /Resources — паттерн, который по-прежнему производят некоторые более старые генераторы PDF, — целиком наследует вызывающий словарь, и это намеренное исключение ради совместимости, а не общее правило, на которое стоит опираться в новом выводе. Идентичность шрифта в снимке TPDFlibContentGraphicsState, таким образом, — пара из FontResource и FontResourceScope, а не одно имя, причём FontObjectNumber доступен, чтобы подтвердить, в какой именно косвенный объект разрешился данный /F1 в этой конкретной области видимости

Та же область видимости применяется к любому другому именованному ресурсу, что может нести форма, включая записи ExtGState и вложенные записи XObject, поскольку лежащий в основе механизм разрешения не делает особого случая для шрифтов — случай со шрифтом просто оказывается самым важным, потому что несовпадающая идентичность шрифта молча производит не те глифы вместо очевидного отказа. Код извлечения, группирующий фрагменты текста только по имени шрифта, без группировки также по области видимости ресурса, объединит два визуально разных шрифта, случайно разделяющих имя, и ошибка не всплывёт, пока кто-то не заметит цифры не из той гарнитуры внутри того, что должно было читаться как один согласованный шрифт

for I:= 0 to Count- 1 do
  if (States[I].OperatorName= 'Tj')and States[I].TextAdvanceResolved then
    RecordGlyphRun(States[I].FontResource, States[I].FontResourceScope,
      States[I].FontObjectNumber, States[I].ContentDepth);

Чтение FormTraversalStatus в собственном конвейере

FormTraversalStatus сам по себе превращает каждый снимок Do в небольшой диагностический отчёт, и конвейер, игнорирующий его, выбрасывает именно ту информацию, что объяснила бы неполное извлечение. ftsNotApplicable означает, что инструкция изначально никогда не была разрешённым вызовом формы; ftsNotRequested означает, что рекурсия была отключена для этого вызова; ftsEnumerated означает, что форма была успешно разобрана и обойдена; ftsDepthLimit и ftsCycle отмечают два способа, которыми спуск намеренно обрывается; а ftsMalformed покрывает всё остальное, что остановило обход, — неразрешимую ссылку на поток, /Matrix или /BBox, что не удалось разобрать, или исключение, выброшенное при выполнении собственного содержимого формы. Последний случай имеет операционное значение, потому что неудачный вложенный обход откатывает любой частичный вывод, что уже был произведён для этой ветви, так что вызывающему коду никогда не приходится гадать, была ли форма реально пустой или просто взорвалась на второй инструкции своего потока содержимого

var
  Tally: array[TPDFlibContentFormTraversalStatus] of Integer;
  Status: TPDFlibContentFormTraversalStatus;
begin
  for Status:= Low(Tally) to High(Tally) do
    Tally[Status]:= 0;
  for I:= 0 to Count- 1 do
    Inc(Tally[States[I].FormTraversalStatus]);
  if (Tally[ftsCycle]> 0)or (Tally[ftsMalformed]> 0) then
    FlagForManualReview(SourceFileName, Tally[ftsCycle], Tally[ftsMalformed]);
end;

Границы, затраты и куда это вписывается

Поток содержимого формы декодируется и разбирается ровно один раз на вызов перечисления, сколько бы раз форма ни вызывалась, потому что PDFlibPas кэширует разобранный список инструкций относительно лежащего в основе объекта потока, а не разбирает его заново при каждом соседнем вызове, — трёхугловой штамп из вступительного примера декодируется один раз и обходится три раза, а не декодируется три раза. Что реально перестраивается при каждом отдельном вызове — это всё, что законно отличается от одного места вызова к другому: дочерний трекер, сконкатенированный CTM, пересечённая обрезка и область видимости ресурса. Этот учёт CTM и обрезки на каждый вызов — тот же механизм, что стоит за трекером состояния CTM и обрезки потока содержимого в PDFlibPas, который стоит прочитать наряду с этой статьёй для любого обхода потока содержимого, выходящего за пределы самой рекурсии форм

Два ограничения стоит учесть заранее, прежде чем этот API войдёт в более крупный конвейер. Потолок глубины в 64 уровня — не регулятор для действительно глубоких документов, поскольку реальные счета-фактуры, выписки и шаблоны отчётов практически никогда не вкладывают формы глубже трёх-четырёх уровней — документ, реально достигающий ftsDepthLimit, гораздо вероятнее некорректен или враждебен, чем необычно сложен, и его стоит логировать как сигнал качества данных, а не молча повторять с большим числом. EnumPageContentStatesEx также является API анализа только для чтения: он сообщает, что делает поток содержимого, а не должна ли форма вообще быть видимой, — это отдельный вопрос, на который отвечает состояние видимости групп опционального содержимого, когда штамп или форма водяного знака находится за слоем, который просмотрщик мог отключить. Обнаружение циклов по цепочке вызовов, изоляция по вызовам и область видимости ресурсов вместе составляют один угол поверхности инспекции потока содержимого в компоненте PDFlibPas для Delphi и C++Builder