Articolo tecnico

Avanzamento testo PDF e clip q/Q nel renderer Delphi

Il renderer di pagine di HotPDF Delphi Component ora avanza il testo calcolando lo spostamento di ogni glyph in text space, tx = ((w0 − Tj/1000) × Tfs + Tc + Tw) × Th come lo definisce ISO 32000-1 §9.4.4, e poi muovendo la matrice testo attraverso la sua parte lineare con HPDFTranslateTextMatrix. Il clipping viene salvato per ogni frame q e ripristinato su Q, ma una regione GDI viene catturata solo quando quel frame cambia davvero la clip. Entrambe le correzioni sono atterrate in HotPDF 2.754.0, e entrambe vengono da pagine reali che renderizzavano con parole accartocciate o regioni di clip che trapelavano oltre il loro Q. Il primo bug è aritmetica che sembra giusta finché un producer non scrive la dimensione del font nella matrice. Il secondo è una correzione di correttezza che ha quasi fatto perdere l'accelerazione del rendering parallelo, e il modo in cui abbiamo ripreso la velocità vale la pena conoscerlo se scrivi un qualunque dispositivo PDF appoggiato a GDI

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

Perché il vecchio codice di avanzamento aggiungeva una distanza in text space direttamente alla componente di traslazione di Tm, come se text space e user space avessero sempre la stessa scala. Un sacco di producer reali impostano la dimensione del font a 1 con Tf 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, che sono 6 punti sulla pagina una volta che Tm lo scala. Il vecchio renderer eseguiva Tm.e := Tm.e + Adv e muoveva la penna di 0.5 punti. Ogni glyph atterrava un dodicesimo di carattere dopo il precedente, così una riga di corpo testo si renderizzava come una macchia scura al margine sinistro mentre lo stesso file appariva perfetto in ogni altro viewer

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 il vecchio codice aggiungeva 0.5 direttamente a Tm.e e renderizzava 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 facevano atterrare ogni glyph a un dodicesimo di carattere dal precedente, un difetto invisibile sull'output della libreria stessa
// Content stream da 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

// Vecchio avanzamento (semplificato): distanza aggiunta 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 per errore da Tfs
Tm.e := Tm.e + Adv;                          // ignora Tm.a, Tm.b, Tm.c, Tm.d

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

La scorciatoia Tm.e non era l'unico difetto in quel blocco. Lo word spacing Tw è espresso in unità di text space non scalate, eppure il vecchio codice lo moltiplicava per FontSize / 1000, quindi sotto Tf 12 una riga giustificata perdeva quasi tutto il suo spazio tra le parole. La scalatura orizzontale Th si applicava alla larghezza del glyph ma non a Tc o Tw, e la correzione di kerning TJ la saltava del tutto. Il percorso non pittorico che avanza il testo invisibile in render mode 3, il genere che usano i layer di testo OCR, e il testo dentro optional content nascosti portavano una copia privata della stessa aritmetica, quindi qualunque cosa disegnata dopo una corsa invisibile partiva dalla posizione sbagliata. I bug di stato testo in un renderer raramente falliscono rumorosamente: come i bug dell'indice degli operandi e dei nomi di risorsa che una volta azzerarono Tc, Tw e Tz senza un solo errore, questi producevano pagine plausibili sull'output della libreria stessa e si rompevano solo su file di altri producer

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

ISO 32000-1 §9.4.4 definisce l'avanzamento interamente in text space e lo applica alla matrice testo come matrice di traslazione, quindi la risposta è calcolare prima tx e lasciare che sia Tm a fare scalatura, rotazione e skew. Per la scrittura orizzontale, tx eguaglia ((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 Tm è [1 0 0 1 tx 0] × Tm, che in HotPDF è l'helper HPDFTranslateTextMatrix: somma X e Y attraverso i coefficienti di matrice a, b, c e d invece di scrivere direttamente su e e f. Per §9.3.3, Tw si applica solo al codice di carattere a byte singolo 32, quindi i codici CID multibyte non raccolgono mai word spacing sul percorso orizzontale. Lo stesso helper ora pilota Td, TD, T*, gli operatori ' e ", le correzioni TJ e il percorso del testo nascosto, il che significa che una sola funzione possiede la regola

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 attraverso HPDFTranslateTextMatrix così lo spostamento passa sui coefficienti di matrice a, b, c e d, e Td, TD, TJ e il percorso del testo nascosto condividono una sola regola
Aggiungere l'avanzamento direttamente a Tm.e funziona solo quando text space eguaglia user space; instradarlo attraverso i coefficienti di matrice mantiene corretto il testo scalato, ruotato e inclinato
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;

// Avanzamento orizzontale del glyph, 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 in text space, non scalato
Adv := Adv * State.Text.HorizScale / 100;    // Th si applica all'intera somma
HPDFTranslateTextMatrix(Tm, Adv, 0);

// Elemento numerico TJ: stesso spazio, stesso Th
Adjustment := -Items[I].NumValue * State.Text.FontSize / 1000;
HPDFTranslateTextMatrix(State.Text.Tm, Adjustment * State.Text.HorizScale / 100, 0);

Il piazzamento dei glyph ha dovuto seguire la stessa logica. Quando non è disponibile alcun contorno incluso e il renderer ripiega su GDI TextOutW, ora costruisce la matrice completa del glyph da CTM × Tm × rise × scala em, Th inclusa, e la installa con SetWorldTransform in modalità GM_ADVANCED dentro una coppia SaveDC / RestoreDC. Il font GDI viene creato a un'altezza fissa di 1000 unità e la trasformazione fa il ridimensionamento, così il testo ruotato e inclinato mantiene il suo orientamento invece di essere disegnato dritto in un punto di origine trasformato. La scrittura verticale è l'unica asimmetria deliberata: un font WMode 1 avanza lungo l'asse y della sua metrica verticale, e la scalatura orizzontale non si applica a quell'asse

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, non solo i parametri numerici. HotPDF teneva già uno stack di stato grafico con CTM, colori, parametri di linea e stato testo, ma GDI tiene la clip nel device context, fuori da quello stack. Una copia dello stato numerico ripristinava dunque tutto tranne la clip, e una clip installata con W n dentro un blocco q ... Q continuava a ritagliare ogni operazione successiva sulla pagina. I Form XObjects aggiungevano una seconda strada verso lo stesso fallimento, perché §8.10 dà a una form un save e restore impliciti attorno al suo contenuto, e il contenuto delle form reali ogni tanto lascia i propri operatori q sbilanciati pur richiedendo la specifica che si accoppino. Il renderer ora chiama CaptureClipBeforeChange e SaveDC prima di eseguire una form, poi dopo che la form ha finito scarta le regioni salvate più profonde della profondità di ingresso e chiama RestoreDC, così ogni HRGN salvata ha esattamente un percorso di rilascio

Cattura lazy della clip con THPDFSavedClipState

La correzione spedita salva un record THPDFSavedClipState per ogni q, ma rinvia la parte costante finché il frame non modifica per la prima volta la clip. Il record tiene l'handle della regione, la profondità di stack a cui appartiene, il device context da cui è stata presa e un flag Captured. DevPushState riempie solo profondità e DC e fa crescere l'array dei frame raddoppiando da 16, così un content stream pieno di q 1 0 0 1 x y cm ... Q non alloca nessun oggetto GDI. Gli operatori che stanno per cambiare il clipping, cioè la pittura di un percorso con un W o W* in sospeso, l'operatore n, i fill a pattern e l'ingresso in form, chiamano prima CaptureClipBeforeChange

Cattura lazy della clip GDI nel renderer HotPDF: DevPushState registra solo profondità e DC per ogni q, CaptureClipBeforeChange legge la regione subito prima che W, n o l'ingresso in form cambino il clipping, DevPopState la ripristina e la cancella su Q, e la versione eager che catturava su ogni q abbassava il throughput parallelo a circa 1.15 volte il single-threaded
Creare una regione GDI a ogni q faceva morire di fame i thread di rendering, quindi la cattura ora avviene solo quando un operatore sta per cambiare il clipping e il gate di speedup 1.5 volte passa di nuovo
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;   // già salvato, o non nostro
  Region := CreateRectRgn(0, 0, 0, 0);
  if Region = 0 then RaiseLastOSError;
  ClipResult := GetClipRgn(FDC, Region);         // 0 significa nessuna 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 rimuove la clip
    if FSavedClips[Index].Region <> 0 then
      DeleteObject(FSavedClips[Index].Region);
    Dec(FSavedClipCount);
  end;
  FGSStack.Pop;
end;

Il costo misurato della versione eager è la ragione per cui questo progetto esiste. La prima implementazione corretta creava e leggeva una regione GDI a ogni q, e sulle pagine fatte per lo più di trasformazioni numeriche i thread del renderer passavano il tempo a contendersi gli oggetti regione GDI invece di rasterizzare. La pipeline di rendering parallela scendeva dal guadagno atteso a circa 1.13-1.20 volte il throughput single-threaded e falliva il gate di speedup 1.5 volte nella suite di benchmark. Con cattura lazy e capacità dei frame riutilizzata, lo stesso benchmark passa di nuovo il gate originale a 1.5 volte. La piccola antialiasing dei glyph TrueType atterrata nella stessa release era il sospetto ovvio, ma la regression risaliva all'allocazione delle regioni, che è un buon promemoria per misurare prima di incolpare la feature più recente

Dove stanno i limiti di questo approccio?

La clip salvata è una regione GDI in pixel di dispositivo, quindi è esatta per la bitmap che viene renderizzata e senza senso per qualunque altro target. Ecco perché ogni frame registra il suo device context e DevPopState salta il ripristino quando il DC è cambiato, per esempio mentre un transparency group renderizza nella sua bitmap di layer. Un GetClipRgn che restituisce zero è un risultato legittimo che significa nessuna clip, e ripristinarlo con SelectClipRgn(FDC, 0) è ciò che rimuove correttamente una clip che non esisteva al q corrispondente. Sul lato testo, la correzione sistemata dove va ogni glyph, ma non inventa le larghezze: se un font omette il suo array /Widths e il programma incluso non è disponibile, l'avanzamento resta comunque buono solo quanto il fallback delle larghezze. Quando fai regression su quest'area, tieni almeno una fixture con Tf 1 e una Tm scalata, una con Tz e Tw non nulli, e una con una clip dentro q ... Q seguita da contenuto fuori, perché nessuna di queste compare nei documenti generati dalla libreria stessa

Se piloti il renderer da codice applicativo, nel pattern di chiamata descritto in renderizzare una pagina PDF in una bitmap non cambia nulla, e le pagine che prima mostravano righe sbavate o contenuti ritagliati dovrebbero semplicemente renderizzarsi correttamente sulla 2.754.0 e successive. Dettagli sul componente, versioni Delphi e C++Builder supportate e licenze sono sulla pagina di prodotto di HotPDF Delphi PDF Component