Articolo tecnico

Avanzamento testo PDF e clip q/Q nel renderer Delphi

Un content stream che imposta /F1 1 Tf, poi 12 0 0 12 72 700 Tm, poi mostra (Hello world) Tj è perfettamente legale. Un renderer scritto a mano spesso disegna tutti gli undici caratteri dentro circa cinque punti e mezzo — una macchia scura al margine sinistro dove dovrebbe esserci una parola da 66 punti. L'aritmetica dietro quella macchia è lunga una riga. ISO 32000-1 §9.4.4 definisce ogni avanzamento di glyph in text space, quindi lo spostamento deve passare per la matrice testo prima di arrivare sulla pagina, e una clip impostata dentro q ... Q appartiene allo stato grafico esattamente quanto lo spessore di linea

Perché il testo si accartoccia in un grumo quando un PDF usa Tf 1?

Il testo collassa perché il renderer somma una distanza in text space direttamente alla componente di traslazione di Tm, cosa che funziona solo finché text space e user space hanno la stessa scala. Un sacco di producer reali scrivono la dimensione del font come 1 e portano la dimensione vera nella matrice testo. Con /F1 1 Tf e 12 0 0 12 72 700 Tm, un glyph largo 500 unità avanza di 0.5 in text space. Tm lo scala a 6 punti sulla pagina. Un renderer che esegue Tm.e := Tm.e + Adv muove la penna di 0.5 punti. Ogni glyph atterra quindi un dodicesimo di carattere dopo il precedente, e un viewer che segue il §9.4.4 mostra lo stesso file come normale corpo testo

Perché il testo collassa con Tf 1 nel renderer HotPDF: con 12 0 0 12 72 700 Tm un glyph da 500 unità deve avanzare di 0.5 unità in text space, che Tm scala a 6 punti, mentre sommare 0.5 direttamente a Tm.e renderizza una riga di corpo testo come una sbavatura di un dodicesimo di carattere per glyph
I producer che codificano la dimensione del font nella matrice testo smascherano subito la scorciatoia Tm.e: ogni glyph atterra un dodicesimo di carattere dopo il precedente, cosa che un output scritto con la dimensione reale in Tf non rivela mai

La scorciatoia sopravvive a lungo perché la maggior parte dei generatori con cui si fanno i test scrive /F1 12 Tf e una Tm non scalata, e in quel caso text space e user space hanno davvero la stessa dimensione. Ecco la versione ingenua per intero, con il content stream che la rompe scritto come commento in testa:

// Content stream di un producer che codifica la dimensione in Tm, non in Tf:
//   BT
//   /F1 1 Tf
//   12 0 0 12 72 700 Tm
//   [(Hel) 30 (lo) -250 (world)] TJ
//   ET

// Avanzamento ingenuo: una distanza in text space sommata a Tm.e come se fosse user space
Adv := W * FontSize / 1000;                  // 0.5 per un glyph da 500 unità
if (HorizScale <> 0) and (HorizScale <> 100) then
  Adv := Adv * HorizScale / 100;             // Th solo sulla larghezza
Adv := Adv + CharSpace;                      // Tc non scalato da Th
if Code = 32 then
  Adv := Adv + WordSpace * FontSize / 1000;  // Tw scalato erroneamente da Tfs
Tm.e := Tm.e + Adv;                          // ignora Tm.a, Tm.b, Tm.c, Tm.d

// Correzione TJ ingenua: niente Th, e di nuovo solo Tm.e
Tm.e := Tm.e - NumValue * FontSize / 1000;

La riga Tm.e è il bug rumoroso. Gli altri sono più silenziosi, e saltano fuori su file diversi. Lo spazio tra parole Tw è già espresso in unità di text space non scalate, quindi moltiplicarlo per FontSize / 1000 trasforma un 3 Tw sotto Tf 12 in 0.036 unità. Un paragrafo giustificato perde quasi tutto il suo spazio tra le parole. La scala orizzontale Th deve applicarsi anche a Tc e Tw, e al numero di kerning di TJ. Il test Code = 32 scatta inoltre su qualsiasi byte di valore 32 dentro un codice a due byte, il che sparge spazi casuali nel testo CJK. Errori di text state come questi raramente falliscono in modo evidente: come le trappole su indice degli operandi e nomi delle risorse che possono azzerare Tc, Tw e Tz prima che venga disegnato un solo glyph, producono pagine plausibili da un producer e pagine rotte dal successivo

Come definisce l'avanzamento del glyph ISO 32000-1 §9.4.4?

ISO 32000-1 §9.4.4 calcola l'avanzamento interamente in text space e lo applica alla matrice testo come matrice di traslazione, quindi scalatura, rotazione e skew vengono tutte da Tm e mai dall'avanzamento stesso. Per la scrittura orizzontale, tx = ((w0 − Tj/1000) × Tfs + Tc + Tw) × Th, dove w0 è la larghezza del glyph in millesimi di em, Tj è la correzione TJ e Th è Tz diviso 100. La nuova matrice è [1 0 0 1 tx 0] × Tm. Scritto per esteso, quel prodotto somma a·tx a e e b·tx a f, ed è per questo che una traslazione in text space non può mai essere una semplice addizione a e, a meno che a sia 1 e b sia 0. Per il §9.3.3, Tw si applica solo al codice di carattere a byte singolo 32

Avanzamento del glyph secondo ISO 32000-1 9.4.4 nel renderer HotPDF: tx calcolato in text space da w0, Tj, Tfs, Tc, Tw e Th, poi applicato tramite i coefficienti a, b, c e d della matrice testo così che Td, TD, TJ e il testo invisibile si muovano tutti con la stessa regola
Sommare l'avanzamento direttamente a Tm.e funziona solo quando text space coincide con user space; farlo passare dai coefficienti della matrice mantiene corretto il testo scalato, ruotato e inclinato

Il componente Delphi HotPDF tiene quella formula in un solo posto. La unit HPDFContentStream esporta HPDFTextGlyphDisplacement per l'avanzamento per glyph e HPDFTextAdjustmentDisplacement per un numero di TJ, e sia il renderer di pagina sia l'interprete di estrazione testo le chiamano, così una pagina che si renderizza correttamente riporta anche posizioni dei glyph allineate a ciò che vedi. Se scrivi un tuo consumatore di content stream sopra il parser della libreria, puoi chiamare le stesse due routine e fornire tu il passo di matrice:

uses
  HPDFContentStream;  // HPDFTextGlyphDisplacement, HPDFTextAdjustmentDisplacement

type
  // Record tuo per la matrice testo, non un tipo HotPDF
  TTextMatrix = record
    a, b, c, d, e, f: Double;
  end;

// Helper tuo: [1 0 0 1 Tx Ty] x Tm, muovendosi attraverso 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 è la larghezza del glyph in 1/1000 em; Tz è l'operando Tz (100 = nessuna scalatura)
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 sullo spazio a byte singolo
    Tc, Tw, Tz, Tx, Ty);
  TranslateInTextSpace(Tm, Tx, Ty);
  // Tf 1, Tm 12 0 0 12 72 700, W0 = 500: Tx = 0.5 e Tm.e cresce di 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 si applica anche al kerning
end;

Dentro il renderer, lo stesso passo di matrice guida ogni operatore che sposta la posizione del testo. Td, TD, T*, ' e " traslano la matrice di riga attraverso i suoi coefficienti, così 0 -1.2 Td sotto una matrice testo 12× sposta la baseline di 14.4 punti verso il basso invece di 1.2. I glyph che non vengono mai dipinti seguono lo stesso identico percorso. Il render mode 3, quello invisibile usato dai layer di testo OCR, e il testo dentro optional content nascosto fanno entrambi avanzare Tm esattamente come il testo dipinto. Dare loro una copia privata dell'aritmetica è l'errore classico, perché poi qualsiasi testo visibile che segue un run invisibile nello stesso blocco BT parte da una posizione leggermente diversa

Posizionare il glyph stesso

Il posizionamento del glyph segue la stessa logica. Quando non è disponibile un outline embedded e il renderer ripiega su TextOutW di GDI, costruisce la matrice completa del glyph da CTM × Tm × rise × scala em, con Th incluso. La matrice entra tramite SetWorldTransform in modalità GM_ADVANCED, dentro una coppia SaveDC / RestoreDC. Il font GDI viene creato a un'altezza fissa di 1000 unità e la world transform fa tutto il dimensionamento. Il testo ruotato e inclinato mantiene quindi il suo orientamento invece di essere disegnato dritto a un'origine trasformata. I render mode con stroke azzerano la world transform prima di tracciare il contorno, perché uno spessore di penna misurato contro una dimensione di design da 1000 unità si ridurrebbe altrimenti a quasi nulla. La scrittura verticale è l'unica asimmetria voluta: un font con WMode 1 avanza lungo l'asse y della sua metrica verticale, Tc e Tw si applicano lungo quell'asse, e Th non si applica affatto

Che cosa salva davvero q/Q nello stato grafico di un PDF?

ISO 32000-1 §8.4.2 elenca il clipping path corrente come parte dello stato grafico, quindi Q deve ripristinare la clip esattamente com'era al q corrispondente. Sembra ovvio. In un renderer basato su GDI è facile mancarlo, perché CTM, colori, parametri di linea e text state vivono nello stack di stato del renderer mentre la clip vive nel device context — fuori da quello stack. Copiare lo stato numerico ripristina tutto tranne la clip. Un W n dentro un blocco q ... Q continua allora a ritagliare ogni operazione successiva sulla pagina, e il sintomo sembra contenuto mancante lontano dall'operatore che l'ha causato

I Form XObject aprono una seconda strada verso lo stesso fallimento. Il §8.10 dà a una form un save e un restore impliciti attorno al suo contenuto e la ritaglia al suo /BBox, e il contenuto delle form reali ogni tanto lascia i propri q e Q sbilanciati anche se la specifica richiede che si accoppino. HotPDF registra la clip e salva il device context prima di eseguire una form, e quando la form finisce butta via tutti i frame di clip che la form ha lasciato aperti e ripristina il device context, così ogni regione salvata viene rilasciata esattamente una volta. Un Q vagante dentro la form che farebbe il pop dello stato del chiamante viene rifiutato invece che applicato. Con RenderFallbackPolicy impostato a rfpReport, quel rifiuto compare nella diagnostica, il che rende facile distinguere un producer sciatto da un problema di rendering:

// Diags: THPDFRenderFallbackDiagnostics (dichiarato in 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 è sicuro su nil
end;

Catturare la clip solo quando un frame la cambia

La correzione più diretta è leggere la regione di clip GDI a ogni q e rimetterla a ogni Q. È corretta. È anche costosa proprio sulle pagine che usano q di più, perché i generatori adorano avvolgere ogni piccolo disegno in q 1 0 0 1 x y cm ... Q solo per spostare l'origine. Ognuna di quelle coppie creerebbe allora una regione GDI, ci copierebbe la clip e la cancellerebbe di nuovo. Quando più pagine si renderizzano in parallelo, i thread finiscono per contendersi gli oggetti regione GDI invece di rasterizzare, e il guadagno della pipeline di rendering parallelo si riduce di conseguenza

Cattura lazy della clip GDI nel renderer HotPDF: q registra solo la profondità dello stack e la superficie di destinazione, la regione di clip viene letta appena prima che W, n, una clip di testo o l'ingresso in una form cambino il clipping, e Q la ripristina e la libera, così le pagine piene di coppie q cm Q non allocano nessuna regione GDI
Creare una regione GDI a ogni q mette i thread di rendering in competizione per gli oggetti regione, quindi la clip viene catturata solo quando un operatore sta per cambiare il clipping

HotPDF rimanda la parte costosa. Un q registra solo la profondità dello stack e la superficie a cui appartiene, in un array di frame che cresce raddoppiando da 16 e viene riusato per tutta la pagina, così una sequenza di trasformazioni numeriche non alloca alcun oggetto GDI. La clip viene catturata la prima volta che qualcosa in quel frame sta per cambiarla: il painting di un path o n con un W o W* in sospeso, un riempimento con pattern, la clip lasciata dai render mode del testo da 4 a 7, o l'ingresso in una form. Al Q, un frame catturato rimette la sua regione e la libera, e un frame che non ha mai cambiato la clip fa il pop solo dello stato numerico. La pagina comune non alloca allora nessuna regione, e una pagina che ritaglia paga esattamente una regione per ogni cambio di clip reale. Se costruisci un tuo device GDI per un interprete PDF, la stessa forma sta in poche decine di righe:

// Uno schizzo per un tuo device basato su GDI; niente di questo è API HotPDF
// richiede Windows, SysUtils e Math nella clausola uses
type
  TClipFrame = record
    Region: HRGN;
    Captured: Boolean;
  end;

procedure TMyDevice.PushState;            // q: nessuna chiamata 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;     // da chiamare prima di W n, W f, clip di testo, BBox della 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 copiata, 0 nessuna clip, -1 errore
  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 sbilanciato: ignoralo
  Dec(FCount);
  if FFrames[FCount].Captured then
    SelectClipRgn(FDC, FFrames[FCount].Region);  // Region 0 rimuove la clip
  if FFrames[FCount].Region <> 0 then
    DeleteObject(FFrames[FCount].Region);
end;

Due dettagli in quello schizzo portano quasi tutto il peso. GetClipRgn che restituisce 0 non è un errore: significa che il device context non aveva nessuna clip in quel momento. Passare una regione zero a SelectClipRgn al Q è esattamente ciò che rimuove una clip che non esisteva al q corrispondente. Trattare 0 come fallimento lascia in piedi la clip interna. L'altro dettaglio è il flag Captured, che impedisce a un secondo cambio di clip nello stesso frame di sovrascrivere la regione salvata con una già ristretta

Dove si fermano la clip salvata e la regola di avanzamento

La clip salvata è una regione GDI in pixel del dispositivo. È esatta per la bitmap che si sta renderizzando e priva di senso per qualsiasi altra superficie, quindi ogni frame registra anche il device context da cui è stata presa, e il ripristino viene saltato quando il contesto è cambiato, per esempio mentre un transparency group si renderizza nella propria bitmap di layer

Sul lato testo, la regola di avanzamento posiziona ogni glyph in base alla larghezza che riceve. Quando un font semplice omette il suo array /Widths e non è disponibile alcun programma font embedded, l'avanzamento può essere buono solo quanto il fallback della larghezza, e il §9.4.4 non dice nulla su da dove debba venire quella larghezza. I test di regressione per tutto questo vivono su file che i documenti generati in modo semplice quasi mai contengono: uno con Tf 1 e una Tm scalata, uno con Tz e Tw diversi da zero su testo giustificato, e uno con una clip W n dentro q ... Q seguita da contenuto disegnato fuori dall'area ritagliata. Un render corretto del terzo mostra quel contenuto esterno per intero

Dal codice applicativo, niente di tutto questo richiede configurazione: la chiamata è quella mostrata in renderizzare una pagina PDF in una bitmap, ed entrambe le regole si applicano a ogni pagina che renderizzi in quel modo. Le versioni di Delphi e C++Builder supportate e i dettagli di licenza sono elencati nella pagina del componente PDF HotPDF per Delphi