Artigo Técnico

Recursão de Form XObject: Deteção de Ciclos do PDFlibPas em Delphi

O PDFlibPas resolve chamadas recursivas de Form XObject em fluxos de conteúdo de PDF em Delphi rastreando a cadeia de chamadas ativa, não um conjunto global de visitados, pelo que o TPDFlib.EnumPageContentStatesEx consegue percorrer o mesmo Form invocado várias vezes numa única página sem confundir uma reutilização legítima com um ciclo. Um Form XObject de carimbo num modelo de fatura é o caso típico: o mesmo objeto é chamado a partir do cabeçalho, do rodapé, e de uma camada de marca de água numa única página, e só uma cadeia de chamadas que volta sobre si própria é um verdadeiro ciclo

A ISO 32000-1 §8.10 define um Form XObject como um fluxo de conteúdo autónomo que uma página, ou outro Form, invoca com o operador Do, completo com o seu próprio sistema de coordenadas em /Matrix, um limite de recorte nesse sistema de coordenadas em /BBox, e opcionalmente o seu próprio dicionário de recursos. Nada na especificação limita quantas vezes um Form pode ser invocado ou quão profundamente os Forms se podem invocar uns aos outros, pelo que um interpretador conforme tem de aceitar reutilização legítima e aninhamento legítimo, mantendo-se ao mesmo tempo defendido contra o único arranjo que a especificação de facto proíbe: um Form cujo fluxo de conteúdo, direta ou transitivamente, se invoca a si próprio. O PDFlibPas reporta essa distinção através dos valores TPDFlibContentFormTraversalStatus associados a cada captura de Do, mais notavelmente ftsEnumerated para uma descida bem-sucedida e ftsCycle para o único caso que é efetivamente um ciclo

Porque é que reutilizar o mesmo Form XObject não despoleta um ciclo falso?

Uma referência repetida a um Form XObject não é, por si só, prova de nada de errado. A ISO 32000-1 permite que o mesmo objeto Form seja invocado a partir de tantos pontos de um fluxo de conteúdo quantos o autor desejar, o que é exatamente como um carimbo de logótipo, um modelo de papel timbrado, ou um rodapé de número de página é reutilizado ao longo de uma página sem duplicar o seu fluxo de conteúdo várias vezes. A proteção ingénua contra recursão descontrolada é um único conjunto de visitados indexado pelo número de objeto: na primeira vez que um percorredor vê o objeto Form 12, marca o 12 como visto e recusa-se a voltar a entrar nele em qualquer outro ponto da árvore. Essa abordagem quebra no momento em que o mesmo carimbo aparece em dois cantos não relacionados da mesma página, porque a segunda chamada, inteiramente legítima, chega depois de o número do objeto já estar marcado como visto e acaba rejeitada como se fosse um ciclo

O PDFlibPas evita esse falso positivo delimitando a deteção de ciclos à cadeia de chamadas atual, em vez de a todo o documento. O EnumPageContentStatesEx empilha o fluxo do Form resolvido na cadeia de chamadas ativa imediatamente antes de nele descer, e depois desempilha essa mesma entrada assim que a descida regressa, com ou sem sucesso. Uma invocação irmã do mesmo fluxo idêntico só começa depois de a primeira já ter sido desempilhada, pelo que a cadeia de chamadas está livre desse fluxo no momento em que a chamada irmã o verifica, e o percorredor enumera-o exatamente como faria com qualquer outro Form. Um ciclo verdadeiro tem um aspeto diferente nessa mesma cadeia: o Form A chama o Form B, o B ainda está aberto na cadeia quando o seu próprio conteúdo chama de volta o A, e o A ainda está na cadeia por causa da chamada exterior que ainda não regressou — essa é a única forma que ftsCycle reporta, um fluxo de Form ainda aberto algures mais acima na cadeia de chamadas atual, não meramente presente noutro ponto da página

Até que profundidade pode ir a recursão de Form XObject antes de o PDFlibPas a travar?

A deteção de ciclos e a limitação de profundidade resolvem dois problemas diferentes, e o PDFlibPas mantém-nos como dois resultados diferentes de TPDFlibContentFormTraversalStatus precisamente por essa razão. Uma cadeia de vinte Forms distintos, cada um a chamar o seguinte e nenhum a repetir-se, não é um ciclo por nenhuma definição — a verificação da cadeia ativa nunca encontra um fluxo repetido — mas vinte níveis honestos de aninhamento continuam a ser vinte níveis de interpretação, concatenação de matrizes, e resolução de recursos que um PDF malformado ou adversarial poderia empurrar arbitrariamente mais alto se nada mais o travasse. O EnumPageContentStatesEx recebe um parâmetro MaxFormDepth precisamente por esta razão e limita qualquer valor passado a um máximo de 64, independentemente do que o chamador peça. Uma profundidade de zero é um caso especial que vale a pena conhecer por si só: desativa por completo a recursão de Forms e reproduz o comportamento plano, só de página, do método mais antigo EnumPageContentStates, razão pela qual cada captura de Do nesse modo reporta ftsNotRequested em vez de tentar seja o que for

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;

Um sub-rastreador por invocação: isolar o estado gráfico

Cada descida num Form XObject recebe o seu próprio rastreador de estado gráfico em vez de partilhar o que já está a percorrer a página, porque o fluxo de conteúdo de um Form é obrigado a deixar o estado gráfico exatamente como o encontrou, e o PDFlibPas não pode presumir que todos os PDFs que abre efetivamente respeitam esse requisito. O rastreador filho parte de uma captura de qualquer que seja a CTM, o estado de cor, e os parâmetros de texto ativos na instrução Do que chamou, e depois repõe a sua própria pilha de guardar-e-restaurar e o rastreio de percurso atual como vazios antes de executar uma única instrução do Form. Um q desequilibrado sem o Q correspondente dentro de um Form descuidado ou danificado, algo não raro de encontrar em PDFs produzidos por ferramentas mais antigas, fica contido dentro do rastreador dessa única invocação e nunca se propaga para o rastreador da página nem para uma invocação irmã do mesmo carimbo, uma linha mais adiante no fluxo de conteúdo

A /Matrix do Form compõe-se com a CTM em vigor no Do da mesma forma que um operador cm faz, multiplicada à esquerda contra a transformação atual em vez de a substituir, e o PDFlibPas reutiliza deliberadamente esse mesmo caminho de código em vez de manter uma segunda fórmula, já que duas implementações independentes da mesma álgebra de matrizes são exatamente o tipo de duplicação que silenciosamente diverge depois de algumas rondas de composição de escala, rotação, e distorção. O /BBox recorta depois no próprio espaço de coordenadas do Form, depois de a matriz já ter sido aplicada, e todos os quatro cantos dessa caixa são transformados individualmente em vez de apenas os cantos opostos, já que um Form rodado ou distorcido pode, de outra forma, reportar uma caixa delimitadora que falha conteúdo real situado no que era um canto extremo antes de a transformação o mover para outro lugar. Estender o ciclo do exemplo anterior sobre o mesmo array States lê esses campos diretamente

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);

Dois Forms com o mesmo nome de recurso partilham um único tipo de letra?

Não. Um nome de recurso como /F1 só significa alguma coisa relativamente ao dicionário de recursos ativo no ponto em que é usado, e dois Form XObjects diferentes têm toda a liberdade para definir dois tipos de letra completamente diferentes sob esse mesmo nome idêntico. O PDFlibPas resolve isto rastreando um âmbito de recurso a par de cada nome de recurso: quando um Form transporta o seu próprio dicionário /Resources, esse dicionário torna-se o âmbito de recurso completo para tudo o que está dentro dele, sem qualquer recurso alternativo por chave para a página ou para o dicionário do chamador em relação a qualquer coisa que o próprio dicionário do Form calhe omitir. Só um Form sem qualquer chave /Resources, um padrão ainda produzido por alguns geradores de PDF mais antigos, herda por inteiro o dicionário chamador, e essa é uma exceção de compatibilidade deliberada, não uma regra geral em que valha a pena confiar em saída nova. A identidade de tipo de letra numa captura TPDFlibContentGraphicsState é, portanto, o par de FontResource e FontResourceScope, não o nome sozinho, com FontObjectNumber disponível para confirmar exatamente a que objeto indireto um dado /F1 se resolveu nesse âmbito em particular

O mesmo âmbito aplica-se a qualquer outro recurso nomeado que um Form possa transportar, incluindo entradas ExtGState e entradas XObject aninhadas, já que o mecanismo de resolução subjacente não trata os tipos de letra como caso especial — o caso do tipo de letra apenas calha ser o que mais importa, porque uma identidade de tipo de letra incorreta produz silenciosamente os glifos errados em vez de uma falha óbvia. Código de extração que agrupe sequências de texto apenas pelo nome do tipo de letra, sem também agrupar pelo âmbito do recurso, vai fundir dois tipos de letra visualmente diferentes que calhem partilhar um nome, e o erro só virá à superfície quando alguém reparar em algarismos de um tipo de letra errado dentro do que devia ler-se como um único tipo de letra consistente

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);

Ler o FormTraversalStatus no seu próprio pipeline

O FormTraversalStatus transforma cada captura de Do num pequeno relatório de diagnóstico por si só, e um pipeline que o ignore está a deitar fora exatamente a informação que explicaria uma extração incompleta. O ftsNotApplicable significa que a instrução nunca foi, à partida, uma invocação de Form resolvida; o ftsNotRequested significa que a recursão estava desligada para esta chamada; o ftsEnumerated significa que o Form foi interpretado e percorrido com sucesso; o ftsDepthLimit e o ftsCycle assinalam as duas formas de uma descida ser propositadamente interrompida; e o ftsMalformed cobre tudo o resto que interrompeu a travessia — uma referência de fluxo irresolúvel, uma /Matrix ou /BBox que falhou a interpretação, ou uma exceção levantada ao executar o próprio conteúdo do Form. Este último caso importa operacionalmente, porque uma travessia aninhada falhada reverte qualquer saída parcial que já tivesse produzido para esse ramo, pelo que um chamador nunca precisa de adivinhar se um Form estava genuinamente vazio ou se simplesmente rebentou duas instruções dentro do seu fluxo de conteúdo

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;

Limites, custos, e onde isto se encaixa

O fluxo de conteúdo de um Form é descodificado e interpretado exatamente uma vez por chamada de enumeração, independentemente de quantas vezes o Form seja invocado, porque o PDFlibPas guarda em cache a lista de instruções interpretada contra o objeto de fluxo subjacente em vez de a reinterpretar em cada chamada irmã — o carimbo de três cantos do exemplo inicial é descodificado uma vez e percorrido três vezes, não descodificado três vezes. O que é reconstruído em cada invocação individual é tudo o que legitimamente difere de um ponto de chamada para o seguinte: o rastreador filho, a CTM concatenada, o recorte intersetado, e o âmbito de recurso. Essa contabilidade de CTM e recorte por invocação é a mesma maquinaria por trás do rastreador de estado de CTM e recorte de fluxo de conteúdo do PDFlibPas, que vale a pena ler a par deste para qualquer travessia de fluxo de conteúdo que vá além da própria recursão de Forms

Dois limites valem a pena ter em conta antes de esta API entrar num pipeline maior. O teto de profundidade de 64 níveis não é um botão de ajuste para documentos legitimamente profundos, já que faturas, extratos, e modelos de relatório reais essencialmente nunca aninham Forms mais de três ou quatro níveis de profundidade — um documento que efetivamente atinja ftsDepthLimit tem muito mais probabilidade de estar malformado ou ser adversarial do que de ser invulgarmente elaborado, e vale a pena registá-lo como um sinal de qualidade de dados em vez de silenciosamente tentar de novo com um número maior. O EnumPageContentStatesEx é também uma API de análise do lado da leitura: reporta o que um fluxo de conteúdo faz, não se um Form deveria sequer estar visível, uma questão separada respondida pelo estado de visibilidade de Grupos de Conteúdo Opcional quando um Form de carimbo ou marca de água está por trás de uma camada que um visualizador possa ter desativado. A deteção de ciclos por cadeia de chamadas, o isolamento por invocação, e o âmbito de recursos, em conjunto, compõem um canto da superfície de inspeção de fluxo de conteúdo no componente PDFlibPas para Delphi e C++Builder