Технічна стаття

Рекурсія Form XObject: виявлення циклів у PDFlibPas для Delphi

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

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

Чому повторне використання того самого Form XObject не спричиняє хибний цикл?

Повторюване посилання на Form XObject само по собі не доказ чогось неправильного. ISO 32000-1 дозволяє викликати той самий об'єкт Form із будь-якої кількості місць потоку вмісту, скільки б автор не забажав, і саме так штамп логотипа, шаблон бланка чи підвал з номером сторінки повторно використовуються на сторінці, не дублюючи свій потік вмісту кілька разів. Наївний захист проти неконтрольованої рекурсії — єдиний набір відвіданих, ключем якого є номер об'єкта: коли обхідник вперше бачить об'єкт Form 12, він позначає 12 як побачений і відмовляється знову входити в неї будь-де ще в дереві. Цей підхід ламається тієї миті, коли той самий штамп з'являється в двох непов'язаних кутках однієї сторінки, бо другий, цілком законний виклик надходить після того, як номер об'єкта вже позначений побаченим, і відхиляється, ніби це був цикл

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

Наскільки глибокою може бути рекурсія Form XObject, перш ніж PDFlibPas її зупинить?

Виявлення циклів та обмеження глибини розв'язують дві різні проблеми, і PDFlibPas тримає їх як два різні результати TPDFlibContentFormTraversalStatus саме з цієї причини. Ланцюжок із двадцяти окремих Form, кожна викликає наступну і жодна не повторюється, — не цикл за жодним визначенням, — перевірка активного ланцюжка ніколи не знаходить повторюваного потоку, — але двадцять чесних рівнів вкладеності — усе одно двадцять рівнів розбору, конкатенації матриць та розв'язання ресурсів, які спотворений чи ворожий PDF міг би підштовхнути довільно вище, якби ніщо інше цього не зупиняло. EnumPageContentStatesEx саме через це приймає параметр MaxFormDepth і обмежує будь-яке передане значення максимумом у 64, незалежно від того, що просить викликач. Глибина нуль — особливий випадок, який варто знати окремо: він повністю вимикає рекурсію Form і відтворює пласку, лише-сторінкову поведінку старішого методу 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 отримує власний трекер графічного стану замість того, щоб ділити той, що вже обходить сторінку, бо потік вмісту Form вимагається залишати графічний стан точно таким, яким він його знайшов, а PDFlibPas не може припускати, що кожен PDF, який він відкриває, справді дотримується цієї вимоги. Дочірній трекер починається зі знімка того, яким був CTM, стан кольору та параметри тексту, активні на інструкції Do, що викликала, потім скидає власний стек збереження-й-відновлення та відстеження поточного контуру до порожнього перед виконанням хоч однієї інструкції Form. Незбалансований q без відповідного Q усередині недбалої чи пошкодженої Form, не рідкість у PDF, вироблених старішим інструментарієм, залишається обмеженим усередині трекера цього одного виклику й ніколи не просочується в трекер сторінки чи в одноранговий виклик того самого штампу, що сидить рядком пізніше в потоці вмісту

/Matrix Form компонується з CTM, чинним на Do, так само, як це робить оператор cm, лівим множенням на поточне перетворення, а не заміною його, і PDFlibPas навмисно повторно використовує цей один шлях коду замість того, щоб підтримувати другу формулу, оскільки дві незалежні реалізації тієї самої матричної алгебри — саме той тип дублювання, що тихо розходиться після кількох раундів композиції масштабу, обертання та зсуву. /BBox потім обтинає у власному просторі координат Form після того, як матрицю вже застосовано, і всі чотири кути цього прямокутника перетворюються окремо, а не лише протилежні кути, оскільки повернута чи зсунута Form інакше може повідомити обмежувальний прямокутник, що пропускає реальний вміст, який сидить у тому, що колись було крайнім кутом, доки перетворення не перемістило його кудись-інде. Розширення циклу з попереднього прикладу над тим самим масивом 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);

Чи ділять дві Form з тим самим іменем ресурсу один шрифт?

Ні. Ім'я ресурсу на кшталт /F1 означає щось лише відносно словника ресурсів, чинного в точці, де воно використовується, і два різні Form XObject вільні визначити два цілком різні шрифти під тим самим ідентичним іменем. PDFlibPas розв'язує це, відстежуючи область видимості ресурсу поряд із кожним іменем ресурсу: коли Form несе власний словник /Resources, цей словник стає повною областю видимості ресурсів для всього всередині неї, без резервного повернення за ключем до сторінки чи словника викликача для будь-чого, що власний словник Form випадково опускає. Лише Form узагалі без ключа /Resources, шаблон, який досі виробляють деякі старіші генератори PDF, успадковує словник, що викликав, повністю, і це навмисний виняток сумісності, а не загальне правило, на яке варто спиратися в новому виводі. Тому ідентичність шрифту в знімку TPDFlibContentGraphicsState — це пара FontResource та FontResourceScope, не саме ім'я, з FontObjectNumber, доступним для підтвердження, у який саме непрямий об'єкт розв'язався даний /F1 у цій конкретній області видимості

Та сама область видимості застосовується до кожного іншого названого ресурсу, який може нести Form, включно з записами 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 означає, що інструкція взагалі ніколи не була розв'язаним викликом Form; ftsNotRequested означає, що рекурсію було вимкнено для цього виклику; ftsEnumerated означає, що Form успішно розібрана й обійдена; ftsDepthLimit та ftsCycle позначають два способи, якими занурення навмисно обривається; а ftsMalformed покриває все інше, що зупинило обхід — нерозв'язне посилання на потік, /Matrix чи /BBox, що не розібралися, чи виняток, піднятий під час виконання власного вмісту Form. Останній випадок важить операційно, бо провалений вкладений обхід відкочує будь-який частковий вивід, який уже виробив для цієї гілки, тож викликачу ніколи не доводиться вгадувати, чи Form справді була порожньою, чи просто вибухнула на двох інструкціях у свій потік вмісту

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;

Межі, вартість і де це вписується

Потік вмісту Form декодується й розбирається рівно один раз на виклик перелічення незалежно від того, скільки разів Form викликається, бо PDFlibPas кешує розібраний список інструкцій проти базового об'єкта потоку замість повторного розбору при кожному однорангового виклику — триторовий штамп із прикладу на початку декодується один раз і обходиться тричі, не декодується тричі. Що справді перебудовується при кожному окремому виклику, так це все, що законно розрізняється від одного місця виклику до наступного: дочірній трекер, конкатенований CTM, перетнуте обтинання та область видимості ресурсу. Цей облік CTM та обтинання на кожен виклик — той самий механізм за трекером стану CTM та обтинання потоку вмісту в PDFlibPas, вартий прочитання поряд із цим для будь-якого обходу потоку вмісту, що виходить за межі самої рекурсії Form

Дві межі варто окреслити перед тим, як цей API піде у більший конвеєр. Стеля глибини 64 рівні — не ручка налаштування для законно глибоких документів, оскільки справжні рахунки-фактури, виписки та шаблони звітів практично ніколи не вкладають Form більш ніж на три-чотири рівні — документ, що справді досягає ftsDepthLimit, набагато імовірніше спотворений чи ворожий, ніж незвично складний, і його варто логувати як сигнал якості даних, а не мовчки повторювати з більшим числом. EnumPageContentStatesEx також API аналізу на стороні читання: він повідомляє, що робить потік вмісту, не чи має Form взагалі бути видимою, — окреме питання, на яке відповідає стан видимості опційних груп вмісту (Optional Content Group), коли штамп чи Form водяного знака сидить за шаром, що переглядач міг вимкнути. Виявлення циклів за ланцюжком викликів, ізоляція на кожен виклик та область видимості ресурсів разом складають один куток поверхні перевірки потоку вмісту в компоненті PDFlibPas для Delphi та C++Builder