Artigo Técnico

Advance de texto PDF e clip q/Q num renderer Delphi

Um content stream que define /F1 1 Tf, depois 12 0 0 12 72 700 Tm, e então mostra (Hello world) Tj é perfeitamente legal. Um renderer feito à mão costuma desenhar os onze caracteres dentro de uns cinco pontos e meio — uma mancha escura na margem esquerda onde deveria estar uma palavra de 66 pontos. A aritmética por trás dessa mancha tem uma linha. A ISO 32000-1 §9.4.4 define cada advance de glifo em text space, então o deslocamento precisa passar pela matriz de texto antes de chegar à página, e um clip definido dentro de q ... Q pertence ao estado gráfico tanto quanto a largura de linha

Por que o texto colapsa em um borrão quando um PDF usa Tf 1?

O texto colapsa porque o renderer soma uma distância de text space direto na componente de translação do Tm, o que só funciona enquanto text space e user space compartilham a mesma escala. Muitos produtores reais escrevem o tamanho da fonte como 1 e carregam o tamanho real na matriz de texto. Sob /F1 1 Tf e 12 0 0 12 72 700 Tm, um glifo de 500 unidades avança 0,5 em text space. O Tm escala isso para 6 pontos na página. Um renderer que executa Tm.e := Tm.e + Adv move a caneta 0,5 ponto. Cada glifo então aterrissa um doze avos de caractere depois do anterior, e um visualizador que segue a §9.4.4 mostra o mesmo arquivo como texto de corpo normal

Por que o texto colapsa sob Tf 1 no renderer do HotPDF: com 12 0 0 12 72 700 Tm, um glifo de 500 unidades deve avançar 0,5 unidade de text space, que o Tm escala para 6 pontos, enquanto somar 0,5 direto no Tm.e renderiza uma linha de corpo como um borrão de um doze avos de caractere por glifo
Produtores que codificam o tamanho da fonte na matriz de texto expõem o atalho do Tm.e na hora: cada glifo aterrissa um doze avos de caractere depois do anterior, o que uma saída escrita com o tamanho real no Tf nunca revela

O atalho sobrevive por muito tempo porque a maioria dos geradores com que as pessoas testam escreve /F1 12 Tf e um Tm sem escala, e nesse caso text space e user space realmente têm o mesmo tamanho. Aqui está a versão ingênua por completo, com o content stream que a quebra escrito como comentário no topo:

// Content stream de um produtor que codifica o tamanho no Tm, não no Tf:
//   BT
//   /F1 1 Tf
//   12 0 0 12 72 700 Tm
//   [(Hel) 30 (lo) -250 (world)] TJ
//   ET

// Advance ingênuo: uma distância de text space somada ao Tm.e como se fosse user space
Adv := W * FontSize / 1000;                  // 0.5 para um glifo de 500 unidades
if (HorizScale <> 0) and (HorizScale <> 100) then
  Adv := Adv * HorizScale / 100;             // Th só na largura
Adv := Adv + CharSpace;                      // Tc não escalado por Th
if Code = 32 then
  Adv := Adv + WordSpace * FontSize / 1000;  // Tw escalado errado por Tfs
Tm.e := Tm.e + Adv;                          // ignora Tm.a, Tm.b, Tm.c, Tm.d

// Ajuste TJ ingênuo: sem Th e, de novo, só no Tm.e
Tm.e := Tm.e - NumValue * FontSize / 1000;

A linha do Tm.e é o bug escandaloso. Os outros são mais silenciosos e aparecem em arquivos diferentes. O word spacing Tw já vem expresso em unidades de text space sem escala, então multiplicá-lo por FontSize / 1000 transforma um 3 Tw sob Tf 12 em 0,036 unidade. Um parágrafo justificado perde quase todo o espaço entre palavras. O horizontal scaling Th precisa se aplicar a Tc e Tw também, e ao número de kerning do TJ. O teste Code = 32 também dispara em qualquer byte de valor 32 dentro de um código de dois bytes, o que espalha buracos aleatórios por texto CJK. Erros de estado de texto assim raramente falham alto: como as armadilhas de índice de operando e nome de recurso que podem zerar Tc, Tw e Tz antes que um único glifo seja desenhado, eles produzem páginas plausíveis de um produtor e quebradas do próximo

Como a ISO 32000-1 §9.4.4 define o advance do glifo?

A ISO 32000-1 §9.4.4 calcula o advance inteiramente em text space e o aplica à matriz de texto como uma matriz de translação, então escala, rotação e cisalhamento vêm todos do Tm e nunca do advance em si. Para escrita horizontal, tx = ((w0 − Tj/1000) × Tfs + Tc + Tw) × Th, em que w0 é a largura do glifo em milésimos de em, Tj é o ajuste do TJ e Th é Tz dividido por 100. A matriz nova é [1 0 0 1 tx 0] × Tm. Por extenso, esse produto soma a·tx a e e b·tx a f, que é por que uma translação em text space nunca pode ser uma adição simples ao e, a menos que a seja 1 e b seja 0. Pela §9.3.3, Tw se aplica apenas ao código de caractere single-byte 32

Advance de glifo da ISO 32000-1 9.4.4 no renderer do HotPDF: tx calculado em text space a partir de w0, Tj, Tfs, Tc, Tw e Th, depois aplicado pelos coeficientes a, b, c e d da matriz de texto, de modo que Td, TD, TJ e texto invisível avançam todos pela mesma regra
Somar o advance direto no Tm.e só funciona quando text space é igual a user space; encaminhá-lo pelos coeficientes da matriz mantém texto escalado, rotacionado e cisalhado correto

O HotPDF Delphi Component guarda essa fórmula em um único lugar. A unit HPDFContentStream exporta HPDFTextGlyphDisplacement para o advance por glifo e HPDFTextAdjustmentDisplacement para um número de TJ, e tanto o renderer de página quanto o interpretador de extração de texto os chamam, então uma página que renderiza certo também reporta posições de glifo que batem com o que você vê. Se você escreve o seu próprio consumidor de content stream em cima do parser da biblioteca, pode chamar as mesmas duas rotinas e fazer o passo da matriz por conta própria:

uses
  HPDFContentStream;  // HPDFTextGlyphDisplacement, HPDFTextAdjustmentDisplacement

type
  // Seu próprio registro de matriz de texto, não é um tipo HotPDF
  TTextMatrix = record
    a, b, c, d, e, f: Double;
  end;

// Helper seu: [1 0 0 1 Tx Ty] x Tm, passando por a, b, c e d
procedure TranslateInTextSpace(var M: TTextMatrix; Tx, Ty: Double);
begin
  M.e := M.e + M.a * Tx + M.c * Ty;
  M.f := M.f + M.b * Tx + M.d * Ty;
end;

// W0 é a largura do glifo em 1/1000 em; Tz é o operando Tz (100 = sem escala)
procedure AdvanceAfterGlyph(var Tm: TTextMatrix; W0: Double; Code: Cardinal;
  TwoByteCodes: Boolean; Tfs, Tc, Tw, Tz: Double);
var
  Tx, Ty: Double;
begin
  HPDFTextGlyphDisplacement(W0 * Tfs / 1000, False,
    (Code = 32) and not TwoByteCodes,  // Tw só no espaço single-byte
    Tc, Tw, Tz, Tx, Ty);
  TranslateInTextSpace(Tm, Tx, Ty);
  // Tf 1, Tm 12 0 0 12 72 700, W0 = 500: Tx = 0.5 e o Tm.e cresce 6
end;

procedure ApplyTJNumber(var Tm: TTextMatrix; Adjustment, Tfs, Tz: Double);
var
  Tx, Ty: Double;
begin
  HPDFTextAdjustmentDisplacement(Adjustment, Tfs, Tz, False, Tx, Ty);
  TranslateInTextSpace(Tm, Tx, Ty);  // Th se aplica ao kerning também
end;

Dentro do renderer, o mesmo passo de matriz comanda todo operador que move a posição de texto. Td, TD, T*, ' e " transladam a matriz de linha pelos coeficientes dela, então um 0 -1.2 Td sob uma matriz de texto de 12× move a baseline 14,4 pontos para baixo em vez de 1,2. Glifos que nunca são pintados seguem o caminho idêntico. O render mode 3, o modo invisível que camadas de texto de OCR usam, e texto dentro de optional content oculto avançam o Tm exatamente como texto pintado. Dar a eles uma cópia privada da aritmética é o erro clássico, porque então qualquer texto visível que siga uma passagem invisível no mesmo bloco BT começa de uma posição um pouco diferente

Posicionando o glifo em si

O posicionamento do glifo segue a mesma lógica. Quando não há outline embutida disponível e o renderer cai para o TextOutW da GDI, ele monta a matriz completa do glifo a partir de CTM × Tm × rise × escala de em, com o Th incluído. A matriz entra por SetWorldTransform em modo GM_ADVANCED, dentro de um par SaveDC / RestoreDC. A fonte GDI é criada com altura fixa de 1000 unidades e a world transform faz todo o dimensionamento. Texto rotacionado e cisalhado mantém a orientação em vez de ser desenhado ereto numa origem transformada. Render modes com traço resetam a world transform antes de traçar, porque uma largura de caneta medida contra um tamanho de design de 1000 unidades encolheria a quase nada. Escrita vertical é a assimetria deliberada: uma fonte WMode 1 avança pelo eixo y segundo a métrica vertical dela, Tc e Tw se aplicam ao longo desse eixo, e o Th não se aplica de jeito nenhum

O que o q/Q realmente salva no estado gráfico de um PDF?

A ISO 32000-1 §8.4.2 lista o caminho de recorte corrente como parte do estado gráfico, então o Q precisa restaurar o clip exatamente como ele estava no q correspondente. Isso soa óbvio. Num renderer apoiado na GDI é fácil deixar passar, porque a CTM, as cores, os parâmetros de linha e o estado de texto vivem na pilha de estado do próprio renderer, enquanto o clip vive no device context — fora dessa pilha. Copiar o estado numérico restaura tudo, menos o clip. Um W n dentro de um bloco q ... Q então continua cortando toda operação posterior na página, e o sintoma parece conteúdo faltando longe do operador que o causou

Form XObjects abrem uma segunda rota para a mesma falha. A §8.10 dá a um form um save e um restore implícitos em torno do conteúdo dele e o recorta pelo /BBox, e conteúdo de form no mundo real às vezes deixa o próprio q e Q desbalanceados embora a especificação exija que eles venham em par. O HotPDF registra o clip e salva o device context antes de rodar um form e, quando o form termina, joga fora quaisquer frames de clip que o form deixou aberto e restaura o device context, então cada região salva é liberada exatamente uma vez. Um Q perdido dentro do form, que estouraria o estado de quem chamou, é recusado em vez de aplicado. Com RenderFallbackPolicy em rfpReport, essa recusa aparece nos diagnósticos, o que facilita distinguir um produtor desleixado de um problema de renderização:

// Diags: THPDFRenderFallbackDiagnostics (declarado em HPDFPageRenderer)
Pdf.RenderFallbackPolicy := rfpReport;
Bmp := Pdf.RenderLoadedPageToBitmap(PageIndex, 150);
try
  Diags := Pdf.GetLastRenderFallbackDiagnostics;
  for J := 0 to High(Diags) do
    if (Diags[J].Kind = rfkInvalidObject) and (Diags[J].OperatorName = 'Q') then
      Log.Add(Format('page %d: unbalanced Q refused - %s',
        [PageIndex + 1, string(Diags[J].Reason)]));
finally
  Bmp.Free;  // Free é seguro em nil
end;

Capturando o clip só quando um frame o muda

A correção direta é ler a região de clip da GDI a cada q e recolocá-la a cada Q. Está correto. Também é caro justamente nas páginas que mais usam q, porque geradores adoram embrulhar cada desenho pequeno em q 1 0 0 1 x y cm ... Q puramente para mover a origem. Cada um desses pares criaria uma região GDI, copiaria o clip para ela e a apagaria de novo. Quando várias páginas renderizam em paralelo, as threads acabam disputando objetos de região da GDI em vez de rasterizar, e o ganho do pipeline de renderização paralela encolhe na mesma medida

Captura lazy de clip GDI no renderer do HotPDF: o q registra só a profundidade da pilha e a superfície alvo, a região de clip é lida pouco antes de W, n, um clip de texto ou a entrada em form mudarem o recorte, e o Q restaura e libera, então páginas cheias de pares q cm Q não alocam região GDI alguma
Criar uma região GDI a cada q faz as threads de renderização disputarem objetos de região, então o clip só é capturado quando um operador está prestes a mudar o recorte

O HotPDF adia a parte cara. Um q registra apenas a profundidade da pilha e a superfície a que pertence, num array de frames que cresce por duplicação a partir de 16 e é reutilizado pela página, então uma sequência de transforms numéricas não aloca objeto GDI nenhum. O clip é capturado na primeira vez em que algo naquele frame está prestes a mudá-lo: pintura de path ou n com um W ou W* pendente, um pattern fill, o clip deixado por render modes de texto 4 a 7, ou a entrada num form. No Q, um frame capturado recoloca a região dele e a libera, e um frame que nunca mudou o clip estoura apenas o estado numérico. A página comum então não aloca região alguma, e uma página que recorta paga exatamente uma região por mudança de clip real. Se você constrói o seu próprio device GDI para um interpretador de PDF, a mesma forma cabe em poucas dezenas de linhas:

// Um esboço para o seu próprio device GDI; nada disso é API do HotPDF
// precisa de Windows, SysUtils e Math na uses
type
  TClipFrame = record
    Region: HRGN;
    Captured: Boolean;
  end;

procedure TMyDevice.PushState;            // q: nenhuma chamada GDI
begin
  if FCount = Length(FFrames) then
    SetLength(FFrames, Max(16, Length(FFrames) * 2));
  FFrames[FCount].Region := 0;
  FFrames[FCount].Captured := False;
  Inc(FCount);
end;

procedure TMyDevice.BeforeClipChange;     // chame antes de W n, W f, clip de texto, BBox de form
var
  R: HRGN;
  Res: Integer;
begin
  if (FCount = 0) or FFrames[FCount - 1].Captured then Exit;
  R := CreateRectRgn(0, 0, 0, 0);
  if R = 0 then RaiseLastOSError;
  Res := GetClipRgn(FDC, R);              // 1 copiado, 0 sem clip, -1 erro
  if Res <= 0 then
  begin
    DeleteObject(R);
    R := 0;
    if Res < 0 then RaiseLastOSError;
  end;
  FFrames[FCount - 1].Region := R;
  FFrames[FCount - 1].Captured := True;
end;

procedure TMyDevice.PopState;             // Q
begin
  if FCount = 0 then Exit;                // Q sem par: ignore
  Dec(FCount);
  if FFrames[FCount].Captured then
    SelectClipRgn(FDC, FFrames[FCount].Region);  // Region 0 remove o clip
  if FFrames[FCount].Region <> 0 then
    DeleteObject(FFrames[FCount].Region);
end;

Dois detalhes desse esboço carregam a maior parte do peso. GetClipRgn devolvendo 0 não é erro: significa que o device context não tinha clip naquele momento. Passar uma região zero para SelectClipRgn no Q é precisamente o que remove um clip que não existia no q correspondente. Tratar o 0 como falha deixa o clip interno no lugar. O outro detalhe é a flag Captured, que impede que uma segunda mudança de clip no mesmo frame sobrescreva a região salva com uma já estreitada

Onde o clip salvo e a regra de advance param

O clip salvo é uma região GDI em pixels de dispositivo. Ele é exato para o bitmap sendo renderizado e sem sentido para qualquer outra superfície, então cada frame também registra o device context de onde foi tirado, e o restore é pulado quando o contexto mudou, por exemplo enquanto um transparency group renderiza no bitmap de camada dele

No lado do texto, a regra de advance posiciona cada glifo a partir da largura que lhe é dada. Quando uma fonte simples omite o array /Widths e nenhum programa de fonte embutida está disponível, o advance só pode ser tão bom quanto o fallback de largura, e a §9.4.4 não diz nada sobre de onde vem essa largura. Testes de regressão para isso moram em arquivos que documentos gerados simples quase nunca contêm: um com Tf 1 e Tm escalado, um com Tz e Tw não nulos em texto justificado, e um com um clip W n dentro de q ... Q seguido de conteúdo desenhado fora da área recortada. Uma renderização correta do terceiro mostra esse conteúdo externo por inteiro

Do código da aplicação, nada disso exige qualquer configuração: a chamada é a mostrada em renderizar uma página de PDF para um bitmap, e as duas regras se aplicam a toda página que você renderizar assim. Versões suportadas de Delphi e C++Builder e detalhes de licenciamento estão na página do HotPDF Delphi PDF Component