Artigo Técnico

Recursão de Form XObject: Detecção de Ciclo no PDFlibPas em Delphi

O PDFlibPas resolve chamadas recursivas de Form XObject em content streams de PDF em Delphi rastreando a cadeia de chamadas ativa, não um conjunto global de visitados, de modo que TPDFlib.EnumPageContentStatesEx consegue percorrer o mesmo Form invocado várias vezes em uma página sem confundir reutilização legítima com um ciclo. Um Form XObject de carimbo em um modelo de fatura é o caso típico: o mesmo objeto é chamado do cabeçalho, do rodapé, e de uma camada de marca d'água em uma página, e só uma cadeia de chamadas que volta sobre si mesma é um ciclo genuíno

A ISO 32000-1 §8.10 define um Form XObject como um content stream autocontido que uma página, ou outro Form, invoca com o operador Do, completo com seu próprio sistema de coordenadas em /Matrix, uma fronteira de recorte nesse sistema de coordenadas em /BBox, e opcionalmente seu próprio dicionário de recursos. Nada na especificação limita quantas vezes um Form pode ser invocado ou quão profundamente Forms podem invocar uns aos outros, de modo que um analisador em conformidade precisa aceitar reutilização legítima e aninhamento legítimo, ao mesmo tempo em que se defende contra o único arranjo que a especificação de fato proíbe: um Form cujo content stream, direta ou transitivamente, invoca a si mesmo. O PDFlibPas reporta essa distinção por meio dos valores TPDFlibContentFormTraversalStatus anexados a cada snapshot de Do, mais notavelmente ftsEnumerated para uma descida bem-sucedida e ftsCycle para o único caso que de fato é um loop

Por que reutilizar o mesmo Form XObject não dispara um ciclo falso?

Uma referência de Form XObject repetida não é, por si só, evidência de nada errado. A ISO 32000-1 permite que o mesmo objeto Form seja invocado a partir de quantos lugares em um content stream o autor quiser, que é exatamente como um carimbo de logotipo, um modelo de papel timbrado, ou um rodapé de número de página é reutilizado ao longo de uma página sem duplicar seu content stream várias vezes. A proteção ingênua contra recursão descontrolada é um único conjunto de visitados indexado por número de objeto: na primeira vez que um percorredor vê o objeto Form 12, marca 12 como visto e se recusa a entrar nele novamente em qualquer outro lugar da árvore. Essa abordagem quebra no instante em que o mesmo carimbo aparece em dois cantos não relacionados de uma página, porque a segunda chamada, inteiramente legítima, chega depois que o número de objeto já está marcado como visto e é rejeitada como se fosse um loop

O PDFlibPas evita esse falso positivo escopando a detecção de ciclo à cadeia de chamadas atual, em vez de todo o documento. EnumPageContentStatesEx empilha o stream de Form resolvido na cadeia de chamadas ativa imediatamente antes de descer nele, depois desempilha essa mesma entrada assim que a descida retorna, com sucesso ou não. Uma invocação irmã do stream idêntico só começa depois que a primeira já foi desempilhada, de modo que a cadeia de chamadas está limpa desse stream no momento em que a chamada irmã o verifica, e o percorredor o enumera exatamente como faria com qualquer outro Form. Um ciclo verdadeiro se parece diferente nessa mesma cadeia: o Form A chama o Form B, B ainda está aberto na cadeia quando seu próprio conteúdo chama de volta em A, e A ainda está sentado na cadeia da chamada externa que ainda não retornou — essa é a única forma que ftsCycle reporta, um stream de Form ainda aberto em algum lugar mais cedo na cadeia de chamadas atual, não meramente presente em algum outro lugar da página

Quão fundo a recursão de Form XObject pode ir antes de o PDFlibPas pará-la?

Detecção de ciclo e limitação de profundidade resolvem dois problemas diferentes, e o PDFlibPas os mantém como dois resultados diferentes de TPDFlibContentFormTraversalStatus precisamente por esse motivo. Uma cadeia de vinte Forms distintos, cada um chamando o próximo e nenhum se repetindo, não é um ciclo por nenhuma definição — a verificação de cadeia ativa nunca encontra um stream repetido — mas vinte níveis honestos de aninhamento ainda são vinte níveis de análise, concatenação de matriz, e resolução de recurso que um PDF malformado ou adversarial poderia empurrar arbitrariamente mais alto se nada mais o parasse. EnumPageContentStatesEx recebe um parâmetro MaxFormDepth exatamente por esse motivo e limita qualquer valor passado a um máximo de 64, independentemente do que quem chama pede. Uma profundidade de zero é um caso especial que vale a pena conhecer por si só: ela desativa a recursão de Form por completo e reproduz o comportamento plano, apenas de página, do método mais antigo EnumPageContentStates, motivo pelo qual todo snapshot de Do nesse modo reporta ftsNotRequested, em vez de tentar qualquer coisa

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: isolando o estado gráfico

Toda descida em um Form XObject recebe seu próprio rastreador de estado gráfico, em vez de compartilhar o que já está percorrendo a página, porque um content stream de Form é exigido a deixar o estado gráfico exatamente como o encontrou, e o PDFlibPas não pode assumir que todo PDF que abre de fato honra essa exigência. O rastreador filho começa a partir de um snapshot de qualquer CTM, estado de cor, e parâmetros de texto ativos na instrução Do chamadora, depois reinicia sua própria pilha de salvar-e-restaurar e rastreamento de path atual para vazio antes de executar uma única instrução do Form. Um q desbalanceado sem nenhum Q correspondente dentro de um Form descuidado ou danificado, não algo raro de se encontrar em PDFs produzidos por ferramentas mais antigas, fica contido dentro do rastreador daquela única invocação e nunca vaza para o rastreador de página ou para uma invocação irmã do mesmo carimbo sentado uma linha depois no content stream

O /Matrix do Form se compõe com o CTM em vigor no Do da mesma forma que um operador cm faz, multiplicado à esquerda contra a transformação atual, em vez de substituí-la, e o PDFlibPas deliberadamente reutiliza esse único caminho de código, em vez de manter uma segunda fórmula, já que duas implementações independentes da mesma álgebra de matriz são exatamente o tipo de duplicação que silenciosamente diverge depois de algumas rodadas de composição de escala, rotação e cisalhamento. /BBox então recorta no próprio espaço de coordenadas do Form depois de a matriz já ter sido aplicada, e os quatro cantos dessa caixa são transformados individualmente, em vez de apenas os cantos opostos, já que um Form rotacionado ou cisalhado pode, de outra forma, reportar uma caixa delimitadora que não captura conteúdo real sentado no que costumava ser um canto extremo antes de a transformação movê-lo para outro lugar. Estender o loop 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 compartilham uma fonte?

Não. Um nome de recurso como /F1 só significa algo relativo ao dicionário de recursos ativo no ponto em que é usado, e dois Form XObjects diferentes têm liberdade para definir duas fontes completamente diferentes sob esse mesmo nome idêntico. O PDFlibPas resolve isso rastreando um escopo de recurso junto com cada nome de recurso: quando um Form carrega seu próprio dicionário /Resources, esse dicionário se torna o escopo de recurso completo para tudo dentro dele, sem nenhum fallback por chave para a página ou dicionário chamador, para o que quer que o próprio dicionário do Form por acaso omita. Só um Form sem nenhuma chave /Resources de forma alguma, um padrão ainda produzido por alguns geradores de PDF mais antigos, herda o dicionário chamador por completo, e isso é uma exceção de compatibilidade deliberada, e não uma regra geral que vale a pena usar em saída nova. A identidade de fonte em um snapshot TPDFlibContentGraphicsState é, portanto, o par de FontResource e FontResourceScope, não o nome sozinho, com FontObjectNumber disponível para confirmar exatamente para qual objeto indireto um determinado /F1 se resolveu naquele escopo particular

O mesmo escopamento se aplica a todo outro recurso nomeado que um Form pode carregar, entradas ExtGState e entradas XObject aninhadas incluídas, já que o mecanismo de resolução subjacente não trata fontes como caso especial — o caso de fonte só por acaso importa mais, porque uma identidade de fonte incompatível produz silenciosamente os glifos errados, em vez de uma falha óbvia. Código de extração que agrupa sequências de texto apenas por nome de fonte, sem também agrupar por escopo de recurso, vai mesclar duas fontes visualmente diferentes que por acaso compartilham um nome, e o erro não vai aparecer até alguém perceber numerais da tipografia errada sentados dentro do que deveria ler como uma fonte 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);

Lendo FormTraversalStatus em seu próprio pipeline

FormTraversalStatus transforma cada snapshot de Do em um pequeno relatório de diagnóstico por si só, e um pipeline que o ignora está jogando fora exatamente a informação que explicaria uma extração incompleta. ftsNotApplicable significa que a instrução nunca foi uma invocação de Form resolvida para começo de conversa; ftsNotRequested significa que a recursão foi desligada para essa chamada; ftsEnumerated significa que o Form foi analisado e percorrido com sucesso; ftsDepthLimit e ftsCycle marcam as duas formas pelas quais uma descida é cortada de propósito; e ftsMalformed cobre tudo mais que parou o percurso — uma referência de stream não resolvível, um /Matrix ou /BBox que falhou ao analisar, ou uma exceção levantada ao executar o próprio conteúdo do Form. Esse último caso importa operacionalmente, porque um percurso aninhado que falha desfaz qualquer saída parcial que já havia produzido para aquele ramo, de modo que quem chama nunca precisa adivinhar se um Form estava genuinamente vazio ou simplesmente explodiu duas instruções dentro de seu 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;

Limites, custos, e onde isso se encaixa

O content stream de um Form é decodificado e analisado exatamente uma vez por chamada de enumeração, não importa quantas vezes o Form seja invocado, porque o PDFlibPas armazena em cache a lista de instruções analisadas contra o objeto de stream subjacente, em vez de reanalisá-la a cada chamada irmã — o carimbo de três cantos do exemplo de abertura é decodificado uma vez e percorrido três vezes, não decodificado três vezes. O que de fato é reconstruído em cada invocação individual é tudo que legitimamente difere de um ponto de chamada para o próximo: o rastreador filho, o CTM concatenado, o clip intersectado, e o escopo de recurso. Essa contabilidade de CTM e clip por invocação é a mesma maquinaria por trás de o rastreador de estado de CTM e clipping de content stream do PDFlibPas, que vale a pena ler ao lado deste para qualquer percurso de content stream que vá além da própria recursão de Form

Dois limites valem a pena estabelecer expectativas antes de essa API entrar em um 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 que três ou quatro níveis de profundidade — um documento que de fato atinge ftsDepthLimit é muito mais provável de estar malformado ou ser adversarial do que incomumente elaborado, e vale a pena registrá-lo como um sinal de qualidade de dados, em vez de silenciosamente tentar de novo com um número maior. EnumPageContentStatesEx também é uma API de análise do lado de leitura: ela reporta o que um content stream faz, não se um Form deveria estar visível de forma alguma, que é uma questão separada respondida por o estado de visibilidade de Grupos de Conteúdo Opcional quando um carimbo ou marca d'água Form fica atrás de uma camada que um visualizador pode ter desativado. Detecção de ciclo por cadeia de chamadas, isolamento por invocação, e escopamento de recurso juntos compõem um canto da superfície de inspeção de content stream no componente PDFlibPas para Delphi e C++Builder