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

Рекурсия на Form XObject: откриване на цикли в PDFlibPas за Delphi

PDFlibPas разрешава рекурсивни извиквания на Form XObject в Delphi PDF content stream-ове чрез проследяване на активната call chain, а не чрез глобален visited set, така че TPDFlib.EnumPageContentStatesEx може да обходи една и съща Form, извикана многократно на една страница, без да приема легитимното повторно използване за цикъл. Типичен случай е stamp Form XObject в шаблон за фактура: един и същ обект се извиква от header-а, footer-а и watermark слой на страницата, а истински цикъл е само call chain, която се връща към себе си

ISO 32000-1 §8.10 определя Form XObject като самостоятелен content stream, който страница или друга Form извиква чрез оператора Do, със собствена координатна система в /Matrix, граница за clipping в тази система чрез /BBox и по избор собствен resource dictionary. Спецификацията не ограничава колко пъти може да бъде извикана една Form или колко дълбоко могат да се извикват Forms една друга, затова съвместимият parser трябва да приема легитимно повторно използване и влагане, но и да се защитава от единствената забранена от спецификацията конфигурация: Form, чийто content stream пряко или транзитивно извиква самата себе си. PDFlibPas отчита това различие чрез стойностите TPDFlibContentFormTraversalStatus, прикрепени към всеки Do snapshot, най-вече ftsEnumerated за успешно спускане и ftsCycle за случая, който действително е цикъл

Защо повторното използване на същия Form XObject не създава фалшив цикъл?

Повтарящата се reference към Form XObject сама по себе си не показва проблем. ISO 32000-1 позволява един и същ Form object да бъде извикван от толкова места в content stream-а, колкото е необходимо, точно както logo stamp, letterhead template или footer с номер на страница се използват многократно, без content stream-ът да се дублира. Наивната защита срещу runaway recursion е един visited set, индексиран по object number: първия път, когато walker-ът види Form object 12, той го отбелязва като видян и отказва да влезе в него отново навсякъде в дървото. Този подход се проваля веднага щом същият stamp се появи в два несвързани ъгъла на една страница, защото второто напълно легитимно извикване идва, след като object number-ът вече е маркиран като видян, и се отхвърля като loop

PDFlibPas избягва този false positive, като ограничава откриването на цикъл до текущата call chain, а не до целия документ. EnumPageContentStatesEx добавя resolved Form stream към активната call chain непосредствено преди спускането, след което премахва същия entry веднага щом спускането завърши, успешно или не. Sibling invocation на идентичния stream започва едва след премахването на първия entry, така че call chain вече не съдържа този stream, когато sibling извикването го провери, и walker-ът го enumerates точно както всяка друга Form. Истинският цикъл изглежда различно в същата chain: Form A извиква Form B, B все още е отворена в chain-а, когато нейното съдържание извика A, а A все още стои в chain-а от външното извикване, което не е върнало управление — това е единствената форма, за която се отчита ftsCycle, Form stream, който остава отворен по-рано в текущата call chain, а не просто присъства някъде другаде на страницата

Колко дълбока може да бъде рекурсията на Form XObject, преди PDFlibPas да я спре?

Откриването на цикъл и ограничаването на дълбочината решават два различни проблема и PDFlibPas ги поддържа като два различни резултата TPDFlibContentFormTraversalStatus точно по тази причина. Верига от двадесет различни Forms, всяка от които извиква следващата без повторение, не е цикъл по никаква дефиниция — проверката на active chain никога не намира повторен stream — но двадесет честни нива на влагане пак са двадесет нива на parsing, комбиниране на матрици и разрешаване на ресурси, които malformed или adversarial PDF може да увеличи произволно, ако няма друга спирачка. EnumPageContentStatesEx приема параметър MaxFormDepth точно заради това и ограничава всяка подадена стойност до максимум 64, независимо какво поиска caller-ът. Дълбочина нула е специален случай: тя изключва Form recursion изцяло и възпроизвежда flat поведението само за страницата на стария метод EnumPageContentStates, поради което всеки Do snapshot в този режим отчита 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;

Един под-тракер за всяко извикване: изолиране на graphics state

Всяко спускане във Form XObject получава собствен graphics-state tracker, вместо да споделя tracker-а, който вече обхожда страницата, защото content stream-ът на Form трябва да остави graphics state точно както го е намерил, а PDFlibPas не може да приеме, че всеки отворен PDF спазва това изискване. Child tracker-ът започва със snapshot на активните CTM, colour state и text параметри от извикващата инструкция Do, след което нулира собствения си save-and-restore stack и current-path tracking, преди да изпълни дори една инструкция от Form. Небалансиран q без съответстващ Q в повредена или небрежно създадена Form остава ограничен в tracker-а на това извикване и не изтича към page tracker-а или към sibling invocation на същия stamp, разположен малко по-късно в content stream-а

Form /Matrix се комбинира с CTM, активен при Do, по същия начин като оператор cm, чрез left multiplication спрямо текущото преобразуване, а не чрез заместването му. PDFlibPas умишлено използва същия code path, вместо да поддържа втора формула, защото две независими реализации на една матрична алгебра лесно се разминават след няколко последователни scale, rotate и shear операции. След това /BBox изрязва в собственото coordinate space на Form, след като матрицата вече е приложена, а и четирите ъгъла на box-а се трансформират поотделно, вместо да се използват само противоположните ъгли, защото rotated или sheared Form може иначе да отчете bounding box, който пропуска реално съдържание, преместено от трансформацията. Разширяването на цикъла от предишния пример върху същия масив 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 обекти с едно и също resource име един шрифт?

Не. Име на ресурс като /F1 има значение само спрямо resource dictionary, активен в мястото на използването му, а две различни Form XObject могат свободно да дефинират напълно различни шрифтове под това едно и също име. PDFlibPas решава това, като проследява resource scope заедно с всяко име на ресурс: когато Form носи собствен /Resources dictionary, този dictionary става пълният resource scope за всичко вътре в нея, без per-key fallback към dictionary-то на страницата или caller-а за ключ, който Form-ата не е включила. Само Form без никакъв ключ /Resources, модел, който все още се среща при някои по-стари PDF генератори, наследява целия caller dictionary, и това е умишлено compatibility изключение, а не общо правило за нов output. Следователно font identity в snapshot на TPDFlibContentGraphicsState е двойката FontResource и FontResourceScope, а не само името, като FontObjectNumber позволява да потвърдите кой точно indirect object е разрешен за конкретния /F1 в този scope

Същото scope правило важи за всеки друг named resource, който Form може да носи, включително записи в ExtGState и вложени записи в XObject, защото механизмът за разрешаване не прави специален случай за шрифтове. Шрифтовете просто са най-опасният пример, понеже несъответстващата font identity тихо дава грешни glyph-ове, вместо очевиден failure. Extraction code, което групира text runs само по font name и не групира едновременно по resource scope, ще слее два визуално различни шрифта с еднакво име, а грешката може да се види едва когато някой забележи цифри от неправилния typeface в текст, който е трябвало да изглежда с единен шрифт

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 в собствения pipeline

FormTraversalStatus превръща всеки Do snapshot в малък диагностичен отчет, а pipeline, който го игнорира, губи точно информацията, обясняваща непълно извличане. ftsNotApplicable означава, че инструкцията изобщо не е била разрешено извикване на Form; ftsNotRequested означава, че recursion е била изключена за това извикване; ftsEnumerated означава, че Form е била parse-ната и обходена успешно; ftsDepthLimit и ftsCycle отбелязват двата начина, по които спускането умишлено приключва; а ftsMalformed обхваща всичко останало, което е спряло обхода — неразрешима reference към stream, /Matrix или /BBox, които не могат да бъдат parse-нати, или exception при изпълнение на собствения content на Form. Последният случай е важен за работата на pipeline-а, защото неуспешен вложен обход връща назад частичния output, който вече е създал за този branch, така че caller-ът не трябва да гадае дали Form е била празна или просто е прекъснала две инструкции навътре в content stream-а

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;

Граници, разходи и мястото на този API

Content stream-ът на Form се decode-ва и parse-ва точно веднъж за всяко enumeration извикване, независимо колко пъти е извикана Form-ата, защото PDFlibPas кешира parsed instruction list спрямо основния stream object, вместо да го parse-ва наново при всяко sibling извикване — stamp-ът от три части в началния пример се decode-ва веднъж и се обхожда три пъти, а не се decode-ва три пъти. Това, което се изгражда наново при всяко извикване, е всичко, което действително се различава между отделните места на извикване: child tracker-ът, комбинираният CTM, пресеченият clip и resource scope. Това per-invocation CTM и clip bookkeeping е същият механизъм зад tracker-а за CTM и clipping state в content stream-а на PDFlibPas, който е полезен заедно с тази статия при всеки обход на content stream, надхвърлящ самата Form recursion

Преди този API да стане част от по-голям pipeline, е полезно да поставите две ограничения в правилния контекст. Таванът от 64 нива не е настройка за легитимно дълбоки документи, защото реалните фактури, извлечения и report template-и почти никога не влагат Forms на повече от три или четири нива — документ, който действително достига ftsDepthLimit, много по-вероятно е malformed или adversarial, отколкото необичайно сложен, и си струва да бъде записан като сигнал за качеството на данните, а не да бъде повторен мълчаливо с по-голяма стойност. EnumPageContentStatesEx също е read-side analysis API: той отчита какво прави content stream-ът, но не дали Form трябва да бъде видима, което е отделен въпрос, отговорен от състоянието на видимост на Optional Content Group, когато stamp или watermark Form се намира зад слой, който viewer-ът може да е изключил. Откриването на цикли по call chain, изолирането на всяко извикване и resource scoping заедно образуват един от ъглите на повърхността за инспекция на content stream в компонента PDFlibPas за Delphi и C++Builder