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