Artigo Técnico

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

O renderer de páginas do HotPDF Delphi Component agora avança texto calculando cada deslocamento de glyph em text space, tx = ((w0 − Tj/1000) × Tfs + Tc + Tw) × Th como a ISO 32000-1 §9.4.4 define, e então movendo a text matrix pela parte linear dela com o HPDFTranslateTextMatrix. O clipping é salvo por frame q e restaurado no Q, mas uma região GDI só é capturada quando esse frame realmente muda o clip. Ambas as correções entraram no HotPDF 2.754.0, e ambas vieram de páginas reais que renderizavam com palavras colapsadas ou regiões de clip vazando para além do Q delas. O primeiro bug é aritmética que parece certa até um produtor escrever o tamanho da fonte dele na matriz. O segundo é uma correção de corretude que quase nos custou o speedup de render paralelo, e o jeito como recuperamos a velocidade vale conhecer se você escreve qualquer device PDF apoiado em GDI

Por que texto colapsa num amontoado quando um PDF usa Tf 1?

Porque o código de advance antigo somava uma distância em text space direto ao componente de translação do Tm, como se text space e user space tivessem sempre a mesma escala. Vários produtores reais definem o tamanho da fonte como 1 com Tf e carregam o tamanho real na text matrix. Com /F1 1 Tf e 12 0 0 12 72 700 Tm, um glyph de 500 units avança 0.5 em text space, que são 6 pontos na página depois que o Tm o escala. O renderer antigo executava Tm.e := Tm.e + Adv e movia a caneta 0.5 pontos. Todo glyph pousava um doze avos de caractere depois do anterior, então uma linha de texto de corpo renderizava como uma mancha escura na margem esquerda enquanto o mesmo arquivo parecia perfeito em todo outro viewer

Por que texto colapsa sob Tf 1 no renderer do HotPDF: com 12 0 0 12 72 700 Tm um glyph de 500 units precisa avançar 0.5 units de text space, que o Tm escala para 6 pontos, enquanto o código antigo somava 0.5 direto no Tm.e e renderizava uma linha de texto de corpo como uma mancha de um doze avos de caractere por glyph
Produtores que codificam o tamanho da fonte na text matrix faziam todo glyph pousar um doze avos de caractere depois do anterior, um defeito invisível no output da própria biblioteca
// 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 antigo (simplificado): distância somada ao Tm.e como se fosse user space
Adv := W * FontSize / 1000;                  // 0.5 para um glyph de 500 units
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 erradamente escalado por Tfs
Tm.e := Tm.e + Adv;                          // ignora Tm.a, Tm.b, Tm.c, Tm.d

// Ajuste TJ antigo: sem Th, e de novo só o Tm.e
Tm.e := Tm.e - NumValue * FontSize / 1000;

O atalho do Tm.e não era o único defeito naquele bloco. O word spacing Tw é expresso em units de text space sem escala, mas o código antigo o multiplicava por FontSize / 1000, então sob Tf 12 uma linha justificada perdia quase todo o espaço entre palavras. A escala horizontal Th valia para a largura do glyph mas não para Tc nem Tw, e o ajuste de kerning do TJ o pulava inteiro. O caminho sem pintura que avança texto invisível de render mode 3 — o tipo que camadas de texto OCR usam — e texto dentro de optional content oculto carregavam uma cópia privada da mesma aritmética, então qualquer coisa desenhada depois de uma sequência invisível partia da posição errada. Bugs de text state num renderer raramente falham barulhentos: como os bugs de índice de operando e nome de recurso que um dia zeraram Tc, Tw e Tz sem um único erro, esses produziam páginas plausíveis no output da própria biblioteca e só quebravam em arquivos de outros produtores

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

A ISO 32000-1 §9.4.4 define o advance inteiramente em text space e o aplica à text matrix como uma matriz de translação, então a resposta é calcular o tx primeiro e deixar o Tm fazer escala, rotação e skew. Para escrita horizontal, o tx é ((w0 − Tj/1000) × Tfs + Tc + Tw) × Th, em que w0 é a largura do glyph em milésimos de em, Tj é o ajuste do TJ, e Th é o Tz dividido por 100. O novo Tm é [1 0 0 1 tx 0] × Tm, que no HotPDF é o helper HPDFTranslateTextMatrix: ele soma X e Y pelos coeficientes de matriz a, b, c e d em vez de escrever direto no e e no f. Por §9.3.3, o Tw só vale para o character code 32 de byte único, então códigos CID multibyte nunca pegam word spacing no caminho horizontal. O mesmo helper agora comanda Td, TD, T*, os operadores ' e ", os ajustes do TJ e o caminho de texto oculto, o que significa que uma única função é dona da regra

Advance de glyph 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, então aplicado via HPDFTranslateTextMatrix para que o deslocamento passe pelos coeficientes de matriz a, b, c e d, e Td, TD, TJ e o caminho de texto oculto compartilhem uma regra
Somar o advance direto no Tm.e só funciona quando text space é igual a user space; roteá-lo pelos coeficientes da matriz mantém texto escalado, rotacionado e skewado correto
procedure HPDFTranslateTextMatrix(var Matrix: THPDFAffineMatrix; X, Y: Double);
begin
  Matrix.e := Matrix.e + Matrix.a * X + Matrix.c * Y;
  Matrix.f := Matrix.f + Matrix.b * X + Matrix.d * Y;
end;

// Advance de glyph horizontal, ISO 32000-1 9.4.4
W   := HPDFFontCharWidth(F, Code);
Adv := W * State.Text.FontSize / 1000 + State.Text.CharSpace;
if (Code = 32) and not F.CID2Byte then
  Adv := Adv + State.Text.WordSpace;         // Tw em text space, sem escala
Adv := Adv * State.Text.HorizScale / 100;    // Th vale para a soma inteira
HPDFTranslateTextMatrix(Tm, Adv, 0);

// Elemento número do TJ: mesmo espaço, mesmo Th
Adjustment := -Items[I].NumValue * State.Text.FontSize / 1000;
HPDFTranslateTextMatrix(State.Text.Tm, Adjustment * State.Text.HorizScale / 100, 0);

O posicionamento de glyphs precisou seguir a mesma lógica. Quando nenhum contorno embutido está disponível e o renderer cai no TextOutW do GDI, ele agora monta a matriz completa do glyph de CTM × Tm × rise × escala de em, incluindo o Th, e a instala com SetWorldTransform em modo GM_ADVANCED dentro de um par SaveDC / RestoreDC. A fonte GDI é criada numa altura fixa de 1000 units e o transform faz o dimensionamento, então texto rotacionado e skewado mantém a orientação em vez de ser desenhado reto num ponto de origem transformado. O modo de escrita vertical é a única assimetria deliberada: uma fonte WMode 1 avança para baixo no eixo y pela métrica vertical dela, e a escala horizontal não se aplica a esse eixo

O que o q/Q realmente salva num graphics state de PDF?

A ISO 32000-1 §8.4.2 lista o clipping path corrente como parte do graphics state, então o Q precisa restaurar o clip exatamente como estava no q correspondente, e não só os parâmetros numéricos. O HotPDF já mantinha uma stack de graphics state com o CTM, cores, parâmetros de linha e text state, mas o GDI guarda o clip no device context, fora dessa stack. Uma cópia do estado numérico portanto restaurava tudo menos o clip, e um clip instalado com W n dentro de um bloco q ... Q continuava recortando toda operação posterior na página. Form XObjects acrescentavam uma segunda rota para a mesma falha, porque a §8.10 dá a um form um save e restore implícitos em volta do conteúdo dele, e conteúdo de form do mundo real às vezes deixa os próprios operadores q dele desbalanceados mesmo que a especificação exija que eles se pareem. O renderer agora chama CaptureClipBeforeChange e SaveDC antes de rodar um form, e depois que o form termina ele descarta qualquer região salva mais funda que a profundidade de entrada e chama RestoreDC, então cada HRGN salvo tem exatamente um caminho de liberação

Captura de clip lazy com THPDFSavedClipState

A correção que entrou salva um record THPDFSavedClipState por q, mas adia a parte cara até o frame modificar o clip pela primeira vez. O record guarda o handle da região, a profundidade de stack a que pertence, o device context de onde foi tirado e uma flag Captured. O DevPushState só preenche a profundidade e o DC e cresce o array de frames dobrando a partir de 16, então um content stream cheio de q 1 0 0 1 x y cm ... Q não aloca objeto GDI nenhum. Operadores que estão prestes a mudar o clipping — ou seja, pintura de path com um W ou W* pendente, o operador n, pattern fills e entrada de form — chamam o CaptureClipBeforeChange primeiro

Captura lazy de clip GDI no renderer do HotPDF: o DevPushState registra só profundidade e DC por q, o CaptureClipBeforeChange lê a região logo antes de W, n ou entrada de form mudarem o clipping, o DevPopState restaura e apaga no Q, e a versão eager que capturava em todo q derrubava o throughput paralelo para cerca de 1.15 vezes o single-threaded
Criar uma região GDI a cada q famintizava as threads de render, então a captura agora só acontece quando um operador está prestes a mudar o clipping, e o gate de speedup de 1.5 vezes passa de novo
procedure THPDFPageRenderer.CaptureClipBeforeChange;
var
  Index, ClipResult: Integer;
  Region: HRGN;
begin
  ClearSavedClipRegions(FGSStack.Count);
  Index := FSavedClipCount - 1;
  if (Index < 0) or FSavedClips[Index].Captured or
     (FSavedClips[Index].StackDepth <> FGSStack.Count) or
     (FSavedClips[Index].DC <> FDC) then Exit;   // já salvo, ou não é nosso
  Region := CreateRectRgn(0, 0, 0, 0);
  if Region = 0 then RaiseLastOSError;
  ClipResult := GetClipRgn(FDC, Region);         // 0 significa nenhum clip
  if ClipResult <= 0 then
  begin
    DeleteObject(Region);
    Region := 0;
    if ClipResult < 0 then RaiseLastOSError;
  end;
  FSavedClips[Index].Region := Region;
  FSavedClips[Index].Captured := True;
end;

procedure THPDFPageRenderer.DevPopState;
var
  Index: Integer;
begin
  ClearSavedClipRegions(FGSStack.Count);
  Index := FSavedClipCount - 1;
  if (Index >= 0) and (FSavedClips[Index].StackDepth = FGSStack.Count) then
  begin
    if FSavedClips[Index].Captured and (FSavedClips[Index].DC = FDC) then
      SelectClipRgn(FDC, FSavedClips[Index].Region);  // Region 0 remove o clip
    if FSavedClips[Index].Region <> 0 then
      DeleteObject(FSavedClips[Index].Region);
    Dec(FSavedClipCount);
  end;
  FGSStack.Pop;
end;

O custo medido da versão eager é a razão de este design existir. A primeira implementação correta criava e lia uma região GDI a cada q, e em páginas feitas quase só de transforms numéricos as threads do renderer gastavam o tempo delas disputando objetos de região GDI em vez de rasterizar. O pipeline de render paralelo caiu do ganho esperado para cerca de 1.13 a 1.20 vezes o throughput single-threaded e reprovou o gate de speedup de 1.5 vezes na suíte de benchmarks. Com captura lazy e capacidade de frames reutilizada, o mesmo benchmark passa de novo no gate original de 1.5 vezes. Antialiasing de glyphs TrueType pequenos entrou na mesma release e era o suspeito óbvio, mas a regressão remontava à alocação de regiões, o que é um bom lembrete de medir antes de culpar a feature mais nova

Onde estão os limites dessa abordagem?

O clip salvo é uma região GDI em pixels de device, então ele é exato para o bitmap sendo renderizado e sem sentido para qualquer outro alvo. É por isso que cada frame registra o device context dele e o DevPopState pula o restore quando o DC mudou, por exemplo enquanto um transparency group renderiza no bitmap de camada próprio dele. O GetClipRgn retornar zero é um resultado legítimo que significa nenhum clip, e restaurá-lo com SelectClipRgn(FDC, 0) é o que corretamente remove um clip que não existia no q correspondente. Do lado do texto, a correção acerta para onde cada glyph vai, mas ela não inventa larguras: se uma fonte omite o array /Widths dela e o programa embutido está indisponível, o advance continua sendo apenas tão bom quanto o fallback de largura. Quando você fizer teste de regressão dessa área, mantenha ao menos um fixture com Tf 1 e um Tm escalado, um com Tz e Tw não nulos, e um com um clip dentro de q ... Q seguido de conteúdo fora dele, porque nenhum desses aparece em documentos gerados pela própria biblioteca

Se você comanda o renderer de código da aplicação, nada muda no padrão de chamada descrito em renderizar uma página PDF para um bitmap, e páginas que antes mostravam linhas borradas ou conteúdo recortado devem simplesmente renderizar corretamente na 2.754.0 e depois. Detalhes sobre o componente, versões suportadas de Delphi e C++Builder e licenciamento estão na página de produto do HotPDF Delphi PDF Component