Artículo técnico

Advance de texto y clip q/Q en un renderer PDF para Delphi

Un content stream que fija /F1 1 Tf, luego 12 0 0 12 72 700 Tm y luego muestra (Hello world) Tj es perfectamente legal. Un renderer hecho a mano a menudo dibuja 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 tiene una línea de largo. 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 colapsa en un pegote cuando un PDF usa Tf 1?

El texto colapsa porque el renderer suma una distancia de text space directo a la componente de traslación de Tm, lo que solo funciona mientras text space y user space comparten la misma 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. Bajo /F1 1 Tf y 12 0 0 12 72 700 Tm, un glifo de 500 unidades avanza 0.5 en text space. Tm escala eso a 6 puntos en la página. Un renderer que ejecuta Tm.e := Tm.e + Adv mueve la pluma 0.5 puntos. Cada glifo cae entonces un doceavo de carácter después del anterior, y un visor que sigue §9.4.4 muestra el mismo archivo como texto corrido normal

Por qué el texto colapsa bajo Tf 1 en el renderer de HotPDF: con 12 0 0 12 72 700 Tm un glifo de 500 unidades debe avanzar 0.5 unidades de text space, que Tm escala a 6 puntos, mientras que sumar 0.5 directo a Tm.e renderiza una línea de texto corrido como una mancha de un doceavo de carácter por glifo
Los productores que codifican el tamaño de fuente en la matriz de texto exponen de inmediato el atajo de Tm.e: cada glifo cae un doceavo de carácter después del anterior, algo que la salida escrita con el tamaño real en Tf nunca revela

El atajo sobrevive mucho tiempo porque la mayoría de los generadores con los que la gente prueba escriben /F1 12 Tf y un Tm sin escalar, y en ese caso text space y user space sí tienen el mismo tamaño. Esta es la versión ingenua completa, con el content stream que la rompe escrito como comentario arriba:

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

// Avance 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 solo sobre el ancho
Adv := Adv + CharSpace;                      // Tc sin escalar por Th
if Code = 32 then
  Adv := Adv + WordSpace * FontSize / 1000;  // Tw escalado por Tfs por error
Tm.e := Tm.e + Adv;                          // ignora Tm.a, Tm.b, Tm.c, Tm.d

// Ajuste TJ ingenuo: sin Th, y de nuevo solo Tm.e
Tm.e := Tm.e - NumValue * FontSize / 1000;

La línea de Tm.e es el bug ruidoso. Los otros son más callados, y aparecen en archivos distintos. El word spacing Tw ya está 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 espacio entre palabras. La escala horizontal 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 reparte huecos aleatorios por el texto CJK. Los errores de estado de texto como estos rara vez fallan con escándalo: 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 verosímiles de un productor y rotas del siguiente

¿Cómo define ISO 32000-1 §9.4.4 el advance del glifo?

ISO 32000-1 §9.4.4 calcula el advance enteramente en text space y lo aplica a la matriz de texto como una matriz de traslación, así que la escala, la rotación y el skew vienen 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 nueva matriz es [1 0 0 1 tx 0] × Tm. Desarrollado, ese producto suma a·tx a e y b·tx a f, y por eso una traslación en text space nunca puede ser una suma plana a e a menos que a sea 1 y b sea 0. Según §9.3.3, Tw aplica solo al código de carácter de un byte 32

Advance de glifo ISO 32000-1 9.4.4 en el renderer de HotPDF: tx calculado en text space a partir de w0, Tj, Tfs, Tc, Tw y Th, y luego aplicado a través de los coeficientes a, b, c y d de la matriz de texto para que Td, TD, TJ y el texto invisible se muevan todos por la misma regla
Sumar el advance directo a Tm.e solo funciona cuando text space iguala a user space; enrutarlo por los coeficientes de matriz mantiene correcto el texto escalado, rotado y sesgado

El HotPDF Delphi Component mantiene esa fórmula en un solo lugar. La unidad HPDFContentStream exporta HPDFTextGlyphDisplacement para el advance por glifo y HPDFTextAdjustmentDisplacement para un número del TJ, y tanto el renderer de páginas como el intérprete de extracción de texto las llaman, así que una página que se renderiza bien también reporta posiciones de glifo que coinciden con lo que usted ve. Si escribe su propio consumidor de content streams sobre el parser de la biblioteca, puede llamar a las mismas dos rutinas y aportar usted mismo el paso de matriz:

uses
  HPDFContentStream;  // HPDFTextGlyphDisplacement, HPDFTextAdjustmentDisplacement

type
  // Su propio record de matriz de texto, no es un tipo de HotPDF
  TTextMatrix = record
    a, b, c, d, e, f: Double;
  end;

// Helper propio: [1 0 0 1 Tx Ty] x Tm, moviéndose a través de 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 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 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 aplica también al kerning
end;

Dentro del renderer, el mismo paso de matriz mueve cada operador que cambia la posición del texto. Td, TD, T*, ' y " trasladan la matriz de línea a través de 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 toman el camino idéntico. El render mode 3, el modo invisible que usan las capas de texto OCR, y el texto dentro de optional content oculto avanzan Tm exactamente igual que el texto pintado. Darles una copia privada de la aritmética es el error clásico, porque entonces cualquier texto visible que siga a una corrida invisible en el mismo bloque BT arranca de una posición ligeramente distinta

Colocar el glifo en sí

La colocación de glifos sigue la misma lógica. Cuando no hay un contorno incrustado disponible y el renderer cae al TextOutW de GDI, arma la matriz completa del glifo desde CTM × Tm × rise × escala de em, con Th incluida. La matriz entra por SetWorldTransform en modo GM_ADVANCED, dentro de un par 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 y sesgado conserva por lo tanto su orientación en lugar de dibujarse derecho en un origen transformado. Los render modes con trazo reinician la transformación de mundo antes de trazar, porque un ancho de pluma medido contra un tamaño de diseño de 1000 unidades de otro modo se encogería a casi nada. La escritura vertical es la única asimetría deliberada: una fuente WMode 1 avanza hacia abajo por el eje y según su métrica vertical, Tc y Tw aplican a lo largo de ese eje, y Th no aplica en absoluto

¿Qué guarda realmente q/Q en el estado gráfico de un PDF?

ISO 32000-1 §8.4.2 lista el clipping path 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 en GDI es fácil pasarlo por alto, porque el CTM, los colores, los parámetros de línea y el estado de texto viven en la propia pila de estado del renderer mientras que el clip vive en el device context — fuera de esa pila. Copiar el estado numérico 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 faltante lejos del operador que lo causó

Los Form XObjects abren una segunda ruta al mismo fallo. §8.10 le 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 reales a veces deja sus propios q y Q desbalanceados aunque la especificación exige que se emparejen. HotPDF registra el clip y guarda el device context antes de correr un form, y cuando el form termina descarta cualquier frame de clip que el form haya dejado abierto y restaura el device context, de modo que cada región guardada se libera exactamente una vez. Un Q extraviado dentro del form que sacaría el estado del llamador se rechaza en lugar de aplicarse. Con RenderFallbackPolicy en rfpReport, ese rechazo aparece en los diagnósticos, lo que facilita distinguir un productor descuidado de un problema de renderizado:

// 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 frame lo cambia

El arreglo directo es leer la región de clip de GDI en cada q y volver a ponerla 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 dibujito en q 1 0 0 1 x y cm ... Q solo para mover el origen. Cada uno de esos pares crearía entonces una región GDI, copiaría el clip en ella y la borraría otra vez. Cuando varias páginas se renderizan en paralelo, los hilos terminan compitiendo por objetos de región GDI en lugar de rasterizar, y el speedup del pipeline de render paralelo se encoge en consecuencia

Captura lazy de clip GDI en el renderer de HotPDF: q registra solo la profundidad de pila y la superficie destino, la región de clip se lee justo antes de que W, n, un clip de texto o la entrada a un form cambien el clipping, y Q la restaura y la libera, así que las páginas llenas de pares q cm Q no asignan ninguna región GDI
Crear una región GDI en cada q hace que los hilos de render compitan por objetos de región, así que el clip se captura solo cuando un operador está por cambiar el clipping

HotPDF difiere la parte cara. Un q solo registra la profundidad de pila y la superficie a la que pertenece, en un arreglo de frames que crece duplicándose desde 16 y se reutiliza a lo largo de la página, así que una corrida de transformaciones numéricas no asigna ningún objeto GDI. El clip se captura la primera vez que algo en ese frame está por cambiarlo: el pintado de paths o n con un W o W* pendiente, un relleno de pattern, el clip que dejan los render modes de texto 4 a 7, o la entrada a un form. En Q, un frame capturado vuelve a poner su región y la libera, y un frame que nunca cambió el clip saca solo el estado numérico. La página común entonces no asigna ninguna región, y una página que recorta paga exactamente una región por cada cambio real de clip. Si construye su propio dispositivo GDI para un intérprete de PDF, la misma forma cabe en unas pocas docenas de líneas:

// Un boceto para su propio dispositivo respaldado en GDI; nada de esto es API de HotPDF
// necesita Windows, SysUtils y Math en la cláusula uses
type
  TClipFrame = record
    Region: HRGN;
    Captured: Boolean;
  end;

procedure TMyDevice.PushState;            // q: ninguna 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 copiada, 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 desbalanceado: ignorarlo
  Dec(FCount);
  if FFrames[FCount].Captured then
    SelectClipRgn(FDC, FFrames[FCount].Region);  // Region 0 quita el clip
  if FFrames[FCount].Region <> 0 then
    DeleteObject(FFrames[FCount].Region);
end;

Dos detalles de ese boceto cargan la mayor parte del peso. Que GetClipRgn devuelva 0 no es un error: significa que el device context no tenía clip en ese momento. Pasar una región cero a SelectClipRgn en Q es precisamente lo que quita un clip que no existía en el q correspondiente. Tratar el 0 como fallo deja el clip interno en su lugar. El otro detalle es la bandera Captured, que evita que un segundo cambio de clip en el mismo frame sobrescriba la región guardada con una ya recortada

Dónde terminan 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 frame también registra el device context del que se tomó, y la restauración se salta cuando el contexto cambió, por ejemplo mientras un transparency group renderiza en su propio bitmap de capa

Del lado del texto, la regla del advance coloca cada glifo a partir del ancho que recibe. Cuando una fuente simple omite su arreglo /Widths y no hay un programa de fuente incrustado disponible, el advance solo puede ser tan bueno como el fallback de anchos, y §9.4.4 no dice nada sobre de dónde sale ese ancho. Las pruebas de regresión de esta zona viven en archivos que los documentos generados simples casi nunca contienen: uno con Tf 1 y un Tm escalado, 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: la llamada es la que se muestra en renderizar una página PDF a un bitmap, y ambas reglas aplican a cada página que renderice de esa manera. Las versiones soportadas de Delphi y C++Builder y los detalles de licenciamiento están en la página del HotPDF Delphi PDF Component