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
// 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
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
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