Artigo Técnico

Avanço de texto PDF e clip q/Q num renderer Delphi

O renderizador de páginas do HotPDF Delphi Component avança agora o texto calculando o deslocamento de cada glifo em espaço de texto, tx = ((w0 − Tj/1000) × Tfs + Tc + Tw) × Th como a ISO 32000-1 §9.4.4 o define, e depois movendo a matriz de texto através da sua parte linear com o HPDFTranslateTextMatrix. O clipping é guardado por moldura q e reposto em Q, mas uma região GDI só é capturada quando essa moldura realmente muda o clip. Ambas as correções saíram no HotPDF 2.754.0, e ambas vieram de páginas reais que renderizavam com palavras colapsadas ou com regiões de clip a escapar para lá do seu Q. O primeiro bug é uma aritmética que parece certa até um produtor escrever o tamanho da fonte na matriz. O segundo é uma correção de exatidão que quase nos custou o ganho de velocidade da renderização paralela, e a maneira como recuperámos a velocidade vale a pena conhecer se escrever qualquer dispositivo PDF suportado em GDI

Porque é que o texto colapsa num montão quando um PDF usa Tf 1?

Porque o código de avanço antigo somava uma distância em espaço de texto diretamente à componente de translação de Tm, como se espaço de texto e espaço de utilizador tivessem sempre a mesma escala. Muitos produtores reais põem o tamanho da fonte a 1 com Tf e transportam o tamanho real na matriz de texto. Com /F1 1 Tf e 12 0 0 12 72 700 Tm, um glifo de 500 unidades avança 0.5 em espaço de texto, que são 6 pontos na página depois de o Tm o escalar. O renderizador antigo executava Tm.e := Tm.e + Adv e movia a pena 0.5 pontos. Cada glifo aterrava um doze avos de carácter depois do anterior, por isso uma linha de texto de corpo renderizava como uma mancha escura na margem esquerda enquanto o mesmo ficheiro parecia perfeito em todos os outros visualizadores

Porque colapsa o texto sob Tf 1 no renderer do HotPDF: com 12 0 0 12 72 700 Tm um glifo de 500 unidades tem de avançar 0.5 unidades em espaço de texto, que o Tm escala para 6 pontos, enquanto o código antigo somava 0.5 diretamente a Tm.e e renderizava uma linha de texto de corpo como uma mancha de um doze avos de carácter por glifo
Produtores que codificam o tamanho da fonte na matriz de texto faziam cada glifo aterrar um doze avos de carácter depois do anterior, um defeito invisível no output da própria biblioteca
// Content stream de um produtor que codifica o tamanho em Tm, e não em Tf:
//   BT
//   /F1 1 Tf
//   12 0 0 12 72 700 Tm
//   [(Hel) 30 (lo) -250 (world)] TJ
//   ET

// Avanço antigo (simplificado): distância somada a 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ó sobre a largura
Adv := Adv + CharSpace;                      // Tc sem escala de Th
if Code = 32 then
  Adv := Adv + WordSpace * FontSize / 1000;  // Tw escalado erradamente 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ó Tm.e
Tm.e := Tm.e - NumValue * FontSize / 1000;

O atalho do Tm.e não era o único defeito naquele bloco. O espaçamento de palavras Tw exprime-se em unidades de espaço de texto sem escala, mas o código antigo multiplicava-o por FontSize / 1000, por isso sob Tf 12 uma linha justificada perdia quase todo o seu espaço entre palavras. A escala horizontal Th aplicava-se à largura do glifo mas não ao Tc nem ao Tw, e o ajuste de kerning TJ saltava-a por completo. O caminho não pintado que avança o texto invisível do render mode 3, o género de camadas de texto OCR, e o texto dentro de optional content oculto transportavam uma cópia privada da mesma aritmética, por isso tudo o que fosse desenhado depois de uma corrida invisível partia de uma posição errada. Bugs de estado de texto num renderer raramente falham com barulho: tal como os bugs de índice de operando e nome de recurso que certa vez zeraram Tc, Tw e Tz sem um único erro, estes produziam páginas plausíveis no output da própria biblioteca e só partiam em ficheiros de outros produtores

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

A ISO 32000-1 §9.4.4 define o avanço inteiramente em espaço de texto e aplica-o à matriz de texto como uma matriz de translação, por isso a resposta é calcular o tx primeiro e deixar o Tm fazer a escala, a rotação e o enviesamento. Para escrita horizontal, tx é igual a ((w0 − Tj/1000) × Tfs + Tc + Tw) × Th, em que w0 é a largura do glifo em milésimos de um em, Tj é o ajuste TJ, e Th é Tz dividido por 100. O novo Tm é [1 0 0 1 tx 0] × Tm, que no HotPDF é o helper HPDFTranslateTextMatrix: soma X e Y através dos coeficientes de matriz a, b, c e d em vez de escrever em e e f diretamente. Por §9.3.3, o Tw aplica-se só ao código de carácter de um único byte 32, por isso códigos CID multibyte nunca apanham espaçamento de palavras no caminho horizontal. O mesmo helper conduz agora Td, TD, T*, os operadores ' e ", os ajustes TJ e o caminho de texto oculto, o que significa que uma única função é dona da regra

Avanço de glifo ISO 32000-1 9.4.4 no renderer do HotPDF: tx calculado em espaço de texto a partir de w0, Tj, Tfs, Tc, Tw e Th, e depois aplicado através do 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 partilhem uma regra
Somar o avanço diretamente a Tm.e só funciona quando o espaço de texto é igual ao espaço de utilizador; encaminhá-lo pelos coeficientes da matriz mantém texto escalado, rodado e enviesado 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;

// Avanço de glifo 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 espaço de texto, sem escala
Adv := Adv * State.Text.HorizScale / 100;    // Th aplica-se à soma inteira
HPDFTranslateTextMatrix(Tm, Adv, 0);

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

A colocação de glifos teve de seguir a mesma lógica. Quando não há contorno incorporado disponível e o renderer recua para o TextOutW GDI, agora constrói a matriz de glifo completa a partir de CTM × Tm × rise × escala de em, incluindo Th, e instala-a com SetWorldTransform em modo GM_ADVANCED dentro de um par SaveDC / RestoreDC. A fonte GDI é criada a uma altura fixa de 1000 unidades e a transformação faz o dimensionamento, por isso texto rodado e enviesado mantém a orientação em vez de ser desenhado direito num ponto de origem transformado. O modo de escrita vertical é a assimetria deliberada: uma fonte WMode 1 avança para baixo no eixo y pela sua métrica vertical, e a escala horizontal não se aplica a esse eixo

O que é que o q/Q realmente guarda num estado gráfico PDF?

A ISO 32000-1 §8.4.2 lista o caminho de clipping corrente como parte do estado gráfico, por isso o Q tem de repor o clip exatamente como estava no q correspondente, e não só os parâmetros numéricos. O HotPDF já mantinha uma pilha de estado gráfico com o CTM, cores, parâmetros de linha e estado de texto, mas o GDI guarda o clip no device context, fora dessa pilha. Uma cópia do estado numérico repunha portanto tudo exceto o clip, e um clip instalado com W n dentro de um bloco q ... Q continuava a recortar todas as operações posteriores da página. Os Form XObjects acrescentavam uma segunda via para a mesma falha, porque a §8.10 dá a um form um save e restore implícitos à volta do seu conteúdo, e conteúdo de form real às vezes deixa os seus próprios operadores q desequilibrados mesmo que a especificação exija que se emparelhem. O renderer agora chama CaptureClipBeforeChange e SaveDC antes de correr um form, e depois de o form terminar descarta regiões guardadas mais fundas que a profundidade de entrada e chama RestoreDC, por isso cada HRGN guardado tem exatamente um caminho de libertação

Captura tardia de clip com THPDFSavedClipState

A correção que saiu guarda um registo THPDFSavedClipState por q, mas adia a parte cara até a moldura modificar o clip pela primeira vez. O registo guarda o handle da região, a profundidade da pilha a que pertence, o device context de onde foi tirada e uma flag Captured. O DevPushState só preenche a profundidade e o DC e cresce o array de molduras por duplicação a partir de 16, por isso um content stream cheio de q 1 0 0 1 x y cm ... Q não aloca objeto GDI nenhum. Os operadores que estão prestes a mudar o clipping, isto é, pintura de caminho com um W ou W* pendente, o operador n, preenchimentos com pattern e entrada em form, chamam primeiro CaptureClipBeforeChange

Captura tardia de clip GDI no renderer do HotPDF: o DevPushState regista só profundidade e DC por q, o CaptureClipBeforeChange lê a região mesmo antes de W, n ou entrada em form mudarem o clipping, o DevPopState repõe e apaga no Q, e a versão antecipada que capturava em cada q derrubava o throughput paralelo para cerca de 1.15 vezes o single-threaded
Criar uma região GDI em cada q esfomeava as threads de renderização, por isso a captura agora só acontece quando um operador está prestes a mudar o clipping e o gate de aceleração de 1.5 vezes volta a passar
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á guardado, ou não é nosso
  Region := CreateRectRgn(0, 0, 0, 0);
  if Region = 0 then RaiseLastOSError;
  ClipResult := GetClipRgn(FDC, Region);         // 0 significa nenhum clip de todo
  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 antecipada é a razão de este desenho existir. A primeira implementação correta criava e lia uma região GDI em cada q, e em páginas feitas sobretudo de transformações numéricas as threads do renderer passavam o tempo a disputar objetos de região GDI em vez de rasterizar. O pipeline de renderização paralela caiu do ganho esperado para cerca de 1.13 a 1.20 vezes o throughput single-threaded e falhou o gate de aceleração de 1.5 vezes na suite de benchmarks. Com captura tardia e capacidade de molduras reutilizada, o mesmo benchmark volta a passar o gate original de 1.5 vezes. O antialiasing de glifos TrueType pequenos saiu na mesma versão e era o suspeito óbvio, mas a regressão traçava de volta para a alocação de regiões, o que é um bom lembrete para medir antes de culpar a funcionalidade mais recente

Onde estão os limites desta abordagem?

O clip guardado é uma região GDI em pixels de dispositivo, por isso é exato para o bitmap que está a ser renderizado e sem significado para qualquer outro alvo. É por isso que cada moldura regista o seu device context e o DevPopState salta o restauro quando o DC mudou, por exemplo enquanto um grupo de transparência renderiza para o seu próprio bitmap de camada. O GetClipRgn devolver zero é um resultado legítimo que significa nenhum clip, e repô-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 onde cada glifo aterra, mas não inventa larguras: se uma fonte omite o seu array /Widths e o programa incorporado não está disponível, o avanço continua a ser apenas tão bom quanto o fallback de largura. Quando testar esta área em regressão, guarde pelo 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 conduz o renderer a partir de código de aplicação, nada muda no padrão de chamadas descrito em renderizar uma página PDF para um bitmap, e páginas que antes mostravam linhas esborrachadas ou conteúdo recortado devem simplesmente renderizar corretamente em 2.754.0 e posteriores. Detalhes sobre o componente, versões suportadas de Delphi e C++Builder e licenciamento estão na página do produto HotPDF Delphi PDF Component