Un content stream que fija /F1 1 Tf, luego 12 0 0 12 72 700 Tm y después muestra (Hello world) Tj es perfectamente legal. Un renderer hecho a mano dibuja a menudo los once caracteres dentro de unos cinco puntos y medio — una mancha oscura en el margen izquierdo donde debería haber una palabra de 66 puntos. La aritmética detrás de esa mancha mide una línea. ISO 32000-1 §9.4.4 define cada advance de glifo en text space, así que el desplazamiento tiene que pasar por la matriz de texto antes de llegar a la página, y un clip fijado dentro de q ... Q pertenece al estado gráfico tanto como el ancho de línea
¿Por qué el texto se apelotona cuando un PDF usa Tf 1?
El texto se apelotona porque el renderer suma una distancia de text space directamente a la componente de traslación de Tm, lo que solo funciona mientras text space y user space comparten escala. Un montón de productores reales escriben el tamaño de fuente como 1 y llevan el tamaño real en la matriz de texto. Con /F1 1 Tf y 12 0 0 12 72 700 Tm, un glifo de 500 unidades avanza 0.5 en text space. Tm lo escala a 6 puntos en la página. Un renderer que ejecuta Tm.e := Tm.e + Adv mueve el lápiz 0.5 puntos. Cada glifo aterriza entonces un doceavo de carácter después del anterior, y un visor que sigue §9.4.4 muestra el mismo fichero como texto de cuerpo normal
El atajo sobrevive mucho tiempo porque la mayoría de generadores con los que la gente prueba escriben /F1 12 Tf y una Tm sin escalar, y en ese caso text space y user space de verdad tienen el mismo tamaño. Aquí está la versión ingenua completa, con el content stream que la rompe escrito como comentario encima:
// Content stream de un productor que codifica el tamaño en Tm, no en Tf:
// BT
// /F1 1 Tf
// 12 0 0 12 72 700 Tm
// [(Hel) 30 (lo) -250 (world)] TJ
// ET
// Advance ingenuo: una distancia de text space sumada a Tm.e como si fuera user space
Adv := W * FontSize / 1000; // 0.5 para un glifo de 500 unidades
if (HorizScale <> 0) and (HorizScale <> 100) then
Adv := Adv * HorizScale / 100; // Th sobre el ancho solamente
Adv := Adv + CharSpace; // Tc sin escalar por Th
if Code = 32 then
Adv := Adv + WordSpace * FontSize / 1000; // Tw mal escalado por Tfs
Tm.e := Tm.e + Adv; // ignora Tm.a, Tm.b, Tm.c, Tm.d
// Ajuste TJ ingenuo: sin Th, y otra vez solo Tm.e
Tm.e := Tm.e - NumValue * FontSize / 1000;
La línea de Tm.e es el fallo estridente. Los demás son más silenciosos y aparecen en ficheros distintos. El word spacing Tw ya viene expresado en unidades de text space sin escalar, así que multiplicarlo por FontSize / 1000 convierte un 3 Tw bajo Tf 12 en 0.036 unidades. Un párrafo justificado pierde casi todo su hueco entre palabras. El horizontal scaling Th debe aplicarse también a Tc y Tw, y al número de kerning del TJ. La prueba Code = 32 además se dispara con cualquier byte de valor 32 dentro de un código de dos bytes, lo que siembra huecos aleatorios en texto CJK. Los errores de estado de texto así casi nunca fallan a lo grande: como las trampas de índice de operando y nombre de recurso que pueden poner a cero Tc, Tw y Tz antes de dibujar un solo glifo, producen páginas plausibles con un productor y rotas con el siguiente
¿Cómo define el advance del glifo ISO 32000-1 §9.4.4?
ISO 32000-1 §9.4.4 calcula el advance por completo en text space y lo aplica a la matriz de texto como matriz de traslación, así que el escalado, la rotación y la inclinación salen todos de Tm y nunca del propio advance. Para escritura horizontal, tx = ((w0 − Tj/1000) × Tfs + Tc + Tw) × Th, donde w0 es el ancho del glifo en milésimas de em, Tj es el ajuste del TJ y Th es Tz dividido por 100. La matriz nueva es [1 0 0 1 tx 0] × Tm. Desarrollado, ese producto suma a·tx a e y b·tx a f, que es por lo que una traslación en text space nunca puede ser una suma plana a e salvo que a valga 1 y b valga 0. Por §9.3.3, Tw se aplica solo al código de carácter de un byte 32
HotPDF Delphi Component guarda esa fórmula en un único sitio. La unidad HPDFContentStream exporta HPDFTextGlyphDisplacement para el advance por glifo y HPDFTextAdjustmentDisplacement para un número de TJ, y tanto el renderer de página como el intérprete de extracción de texto los llaman, así que una página que se renderiza bien también reporta posiciones de glifo que cuadran con lo que ves. Si escribes tu propio consumidor de content streams sobre el parser de la biblioteca, puedes llamar a las mismas dos rutinas y dar el paso de matriz tú mismo:
uses
HPDFContentStream; // HPDFTextGlyphDisplacement, HPDFTextAdjustmentDisplacement
type
// Tu propio registro de matriz de texto, no es un tipo de HotPDF
TTextMatrix = record
a, b, c, d, e, f: Double;
end;
// Tu propio ayudante: [1 0 0 1 Tx Ty] x Tm, moviéndose por a, b, c y 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 es el ancho del glifo en 1/1000 em; Tz es el operando Tz (100 = sin escalado)
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 solo en el espacio de un byte
Tc, Tw, Tz, Tx, Ty);
TranslateInTextSpace(Tm, Tx, Ty);
// Tf 1, Tm 12 0 0 12 72 700, W0 = 500: Tx = 0.5 y Tm.e crece en 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 también al kerning
end;
Dentro del renderer, el mismo paso de matriz gobierna cada operador que mueve la posición de texto. Td, TD, T*, ' y " trasladan la matriz de línea por sus coeficientes, así que 0 -1.2 Td bajo una matriz de texto de 12× mueve la línea base 14.4 puntos hacia abajo en lugar de 1.2. Los glifos que nunca se pintan siguen el camino idéntico. El modo de render 3, el modo invisible que usan las capas de texto OCR, y el texto dentro de contenido opcional oculto avanzan Tm exactamente igual que el texto pintado. Darles una copia privada de la aritmética es el fallo clásico, porque entonces cualquier texto visible que siga a una tirada invisible en el mismo bloque BT parte de una posición ligeramente distinta
Colocar el propio glifo
La colocación del glifo sigue la misma lógica. Cuando no hay contorno incrustado disponible y el renderer cae al TextOutW de GDI, construye la matriz completa del glifo desde CTM × Tm × rise × escala de em, con Th incluido. La matriz entra por SetWorldTransform en modo GM_ADVANCED, dentro de una pareja SaveDC / RestoreDC. La fuente GDI se crea a una altura fija de 1000 unidades y la transformación de mundo hace todo el dimensionado. El texto rotado e inclinado conserva así su orientación en lugar de dibujarse erguido en un origen transformado. Los modos de render con trazo resetean la transformación de mundo antes del trazo, ya que un ancho de pluma medido contra un tamaño de diseño de 1000 unidades se encogería casi a nada. La escritura vertical es la única asimetría deliberada: una fuente WMode 1 avanza por el eje y según su métrica vertical, Tc y Tw se aplican por ese eje, y Th no se aplica en absoluto
¿Qué guarda en realidad q/Q en un estado gráfico PDF?
ISO 32000-1 §8.4.2 lista la ruta de recorte actual como parte del estado gráfico, así que Q tiene que restaurar el clip exactamente como estaba en el q correspondiente. Suena obvio. En un renderer respaldado por GDI es fácil perderlo de vista, porque la CTM, los colores, los parámetros de línea y el estado de texto viven en la pila de estado del propio renderer mientras el clip vive en el contexto de dispositivo — fuera de esa pila. Copiar el estado numérico lo restaura todo menos el clip. Un W n dentro de un bloque q ... Q sigue entonces recortando cada operación posterior de la página, y el síntoma parece contenido que falta lejos del operador que lo causó
Los Form XObjects abren una segunda vía hacia el mismo fallo. §8.10 da a un form un save y restore implícitos alrededor de su contenido y lo recorta a su /BBox, y el contenido de forms real a veces deja sus q y Q desequilibrados aunque la especificación exige que se emparejen. HotPDF registra el clip y guarda el contexto de dispositivo antes de ejecutar un form, y cuando el form termina tira cualquier marco de clip que el form dejara abierto y restaura el contexto de dispositivo, así que cada región guardada se libera exactamente una vez. Un Q perdido dentro del form que haría pop del estado de quien llama se rechaza en lugar de aplicarse. Con RenderFallbackPolicy a rfpReport, ese rechazo aparece en los diagnósticos, lo que facilita distinguir un productor descuidado de un problema de render:
// Diags: THPDFRenderFallbackDiagnostics (declarado en 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 es seguro sobre nil
end;
Capturar el clip solo cuando un marco lo cambia
El arreglo directo es leer la región de clip de GDI en cada q y devolverla en cada Q. Es correcto. También es caro justo en las páginas que más usan q, porque a los generadores les encanta envolver cada dibujo pequeño en q 1 0 0 1 x y cm ... Q puramente para mover el origen. Cada una de esas parejas crearía entonces una región GDI, copiaría el clip dentro y la borraría otra vez. Cuando varias páginas se renderizan en paralelo, los hilos acaban compitiendo por objetos de región GDI en lugar de rasterizar, y la aceleración del pipeline de render paralelo se encoge en consecuencia
HotPDF aplaza la parte cara. Un q solo registra la profundidad de pila y la superficie a la que pertenece, en un array de marcos que crece duplicándose desde 16 y se reutiliza a lo largo de la página, así que una tirada de transformaciones numéricas no asigna ningún objeto GDI. El clip se captura la primera vez que algo en ese marco está a punto de cambiarlo: pintado de path o n con un W o W* pendiente, un relleno con patrón, el clip que dejan los modos de render de texto 4 a 7, o la entrada en un form. En Q, un marco capturado devuelve su región y la libera, y un marco que nunca cambió el clip solo hace pop del estado numérico. La página corriente entonces no asigna ninguna región, y una página que recorta paga exactamente una región por cada cambio de clip real. Si construyes tu propio dispositivo GDI para un intérprete PDF, la misma forma cabe en unas pocas docenas de líneas:
// Un boceto para tu propio dispositivo sobre GDI; nada de esto es API de HotPDF
// necesita Windows, SysUtils y Math en el uses
type
TClipFrame = record
Region: HRGN;
Captured: Boolean;
end;
procedure TMyDevice.PushState; // q: ni una llamada 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; // llamar 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 sin clip, -1 error
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 desequilibrado: ignóralo
Dec(FCount);
if FFrames[FCount].Captured then
SelectClipRgn(FDC, FFrames[FCount].Region); // Región 0 quita el clip
if FFrames[FCount].Region <> 0 then
DeleteObject(FFrames[FCount].Region);
end;
Dos detalles de ese boceto cargan con la mayor parte del peso. Que GetClipRgn devuelva 0 no es un error: significa que el contexto de dispositivo no tenía clip en ese momento. Pasar una región cero a SelectClipRgn en el Q es precisamente lo que quita un clip que no existía en el q correspondiente. Tratar el 0 como fallo deja el clip interior en su sitio. El otro detalle es el flag Captured, que evita que un segundo cambio de clip en el mismo marco sobrescriba la región guardada con una ya estrechada
Dónde se detienen el clip guardado y la regla del advance
El clip guardado es una región GDI en píxeles de dispositivo. Es exacto para el bitmap que se está renderizando y no significa nada para cualquier otra superficie, así que cada marco registra también el contexto de dispositivo del que se tomó, y la restauración se salta cuando el contexto ha cambiado, por ejemplo mientras un grupo de transparencia renderiza a su propio bitmap de capa
Por el lado del texto, la regla del advance coloca cada glifo a partir del ancho que le dan. Cuando una fuente simple omite su array /Widths y no hay programa de fuente incrustado disponible, el advance solo puede ser tan bueno como la reserva de anchos, y §9.4.4 no dice nada sobre de dónde sale ese ancho. Los tests de regresión para esto viven en ficheros que los documentos generados simples casi nunca contienen: uno con Tf 1 y una Tm escalada, uno con Tz y Tw distintos de cero sobre texto justificado, y uno con un clip W n dentro de q ... Q seguido de contenido dibujado fuera del área recortada. Un render correcto del tercero muestra ese contenido exterior completo
Desde el código de aplicación, nada de esto necesita configuración alguna: la llamada es la que se muestra en renderizar una página PDF a un bitmap, y las dos reglas se aplican a cada página que renderices así. Las versiones de Delphi y C++Builder soportadas y los detalles de licencia están en la página de HotPDF Delphi PDF Component