Articol tehnic

Avans text PDF și restaurare clip q/Q în rendererul Delphi

Renderer-ul de pagini din HotPDF Delphi Component avansează acum textul calculând deplasarea fiecărei glife în text space, tx = ((w0 − Tj/1000) × Tfs + Tc + Tw) × Th, așa cum o definește ISO 32000-1 §9.4.4, apoi mutând matricea de text prin partea ei liniară cu HPDFTranslateTextMatrix. Decuparea e salvată per cadru q și restaurată la Q, dar o regiune GDI e capturată doar când cadrul respectiv chiar schimbă clip-ul. Ambele reparări au aterizat în HotPDF 2.754.0, iar ambele au venit din pagini reale care se randau cu cuvinte colapsate sau cu regiuni de clip care scurgeau dincolo de Q-ul lor. Primul bug e aritmetică care arată corect până când un producător își scrie dimensiunea fontului în matrice. Al doilea e o reparație de corectitudine care a fost pe cale să ne coste accelerarea paralelă a randării, iar felul în care am recâștigat viteza merită știut dacă scriți orice dispozitiv PDF susținut de GDI

De ce se colapsează textul într-o grămadă când un PDF folosește Tf 1?

Pentru că vechiul cod de avans adăuga o distanță din text space direct la componenta de translație a lui Tm, de parcă text space și user space ar fi avut întotdeauna aceeași scară. O mulțime de producători reali setează dimensiunea fontului la 1 cu Tf și cară dimensiunea reală în matricea de text. Cu /F1 1 Tf și 12 0 0 12 72 700 Tm, o glifă lată de 500 de unități avansează 0,5 în text space, ceea ce face 6 puncte pe pagină odată ce Tm o scalează. Vechiul renderer executa Tm.e := Tm.e + Adv și muta stiloul cu 0,5 puncte. Fiecare glifă ateriza a douăsprezecea parte dintr-un caracter după precedenta, deci o linie de text de corp se randă ca o pată întunecată la marginea din stânga, în timp ce același fișier arăta perfect în orice alt vizualizator

De ce se colapsează textul sub Tf 1 în renderer-ul HotPDF: cu 12 0 0 12 72 700 Tm o glifă de 500 de unități trebuie să avanceze 0,5 unități în text space, pe care Tm le scalează la 6 puncte, în timp ce vechiul cod adăuga 0,5 direct la Tm.e și randă o linie de text de corp ca o pată de a douăsprezecea parte dintr-un caracter per glifă
Producătorii care encodează dimensiunea fontului în matricea de text făceau ca fiecare glifă să aterizeze a douăsprezecea parte dintr-un caracter după precedenta, un defect invizibil pe output-ul propriu al bibliotecii
// Content stream de la un producător care encodează dimensiunea în Tm, nu Tf:
//   BT
//   /F1 1 Tf
//   12 0 0 12 72 700 Tm
//   [(Hel) 30 (lo) -250 (world)] TJ
//   ET

// Avansul vechi (simplificat): distanță adăugată la Tm.e ca și cum ar fi user space
Adv := W * FontSize / 1000;                  // 0.5 pentru o glifă de 500 de unități
if (HorizScale <> 0) and (HorizScale <> 100) then
  Adv := Adv * HorizScale / 100;             // Th doar pe lățime
Adv := Adv + CharSpace;                      // Tc nescalat de Th
if Code = 32 then
  Adv := Adv + WordSpace * FontSize / 1000;  // Tw scalat greșit de Tfs
Tm.e := Tm.e + Adv;                          // ignoră Tm.a, Tm.b, Tm.c, Tm.d

// Ajustarea TJ veche: fără Th și din nou doar Tm.e
Tm.e := Tm.e - NumValue * FontSize / 1000;

Scurtătura Tm.e n-a fost singurul defect din blocul acela. Spațierea de cuvinte Tw e exprimată în unități de text space nescale, iar vechiul cod o înmulțea cu FontSize / 1000, deci sub Tf 12 o linie justificată pierdea aproape tot golul dintre cuvinte. Scalarea orizontală Th se aplica lățimii glifei, dar nu și lui Tc sau Tw, iar ajustarea de kerning din TJ o ocolea de tot. Calea non-picturală care avansează textul invizibil din modul de randare 3, genul de straturi de text folosite de OCR, și textul din optional content ascuns carau o copie privată a aceleiași aritmetici, deci orice desenat după o rulare invizibilă pornea dintr-o poziție greșită. Bug-urile de stare de text dintr-un renderer eșuează rar zgomotos: precum bug-urile de index de operand și de nume de resursă care odată au anulat Tc, Tw și Tz fără un singur mesaj de eroare, acestea produceau pagini plauzibile pe output-ul propriu al bibliotecii și se stricau doar pe fișiere de la alți producători

Cum definește ISO 32000-1 §9.4.4 avansul glifei?

ISO 32000-1 §9.4.4 definește avansul integral în text space și îl aplică matricei de text ca matrice de translație, deci răspunsul e să calculezi tx întâi și să lași Tm să facă scalarea, rotația și înclinarea. Pentru scriere orizontală, tx egalează ((w0 − Tj/1000) × Tfs + Tc + Tw) × Th, unde w0 e lățimea glifei în miimi de em, Tj e ajustarea TJ, iar Th e Tz împărțit la 100. Noul Tm e [1 0 0 1 tx 0] × Tm, ceea ce în HotPDF e helper-ul HPDFTranslateTextMatrix: adaugă X și Y prin coeficienții matricei a, b, c și d în loc să scrie direct în e și f. Per §9.3.3, Tw se aplică doar codului de caracter single-byte 32, deci codurile CID multibyte nu prind niciodată spațiere de cuvinte pe calea orizontală. Același helper conduce acum Td, TD, T*, operatorii ' și ", ajustările TJ și calea de text ascuns, ceea ce înseamnă că o singură funcție deține regula

Avansul glifei ISO 32000-1 9.4.4 în renderer-ul HotPDF: tx calculat în text space din w0, Tj, Tfs, Tc, Tw și Th, apoi aplicat prin HPDFTranslateTextMatrix, astfel încât deplasarea trece peste coeficienții matricei a, b, c și d, iar Td, TD, TJ și calea de text ascuns partajează o singură regulă
Adăugarea avansului direct la Tm.e funcționează doar când text space egalează user space; rutarea lui prin coeficienții matricei păstrează corect textul scalat, rotit și înclinat
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;

// Avansul orizontal al glifei, 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 în text space, nescale
Adv := Adv * State.Text.HorizScale / 100;    // Th se aplică întregii sume
HPDFTranslateTextMatrix(Tm, Adv, 0);

// Elementul numeric TJ: același spațiu, același Th
Adjustment := -Items[I].NumValue * State.Text.FontSize / 1000;
HPDFTranslateTextMatrix(State.Text.Tm, Adjustment * State.Text.HorizScale / 100, 0);

Plasarea glifelor a trebuit să urmeze aceeași logică. Când niciun contur încorporat nu e disponibil și renderer-ul revine la GDI TextOutW, construiește acum matricea completă a glifei din CTM × Tm × rise × scalare de em, inclusiv Th, și o instalează cu SetWorldTransform în modul GM_ADVANCED în interiorul unei perechi SaveDC / RestoreDC. Fontul GDI e creat la o înălțime fixă de 1000 de unități, iar transformarea face dimensionarea, deci textul rotit și înclinat își păstrează orientarea în loc să fie desenat vertical într-un punct de origine transformat. Modul de scriere vertical e singura asimetrie deliberată: un font WMode 1 avansează în jos pe axa y cu metrica lui verticală, iar scalarea orizontală nu se aplică acelei axe

Ce salvează de fapt q/Q într-o stare grafică PDF?

ISO 32000-1 §8.4.2 listează calea de decupare curentă ca parte a stării grafice, deci Q trebuie să restaureze clip-ul exact cum era la q-ul pereche, nu doar parametrii numerici. HotPDF ținea deja o stivă de stare grafică cu CTM, culori, parametri de linie și stare de text, dar GDI ține clip-ul în contextul de dispozitiv, în afara stivei aceleia. O copie a stării numerice restaura deci totul în afară de clip, iar un clip instalat cu W n în interiorul unui bloc q ... Q continua să decupeze fiecare operație ulterioară de pe pagină. Form XObjects adăuga o a doua rută către același eșec, pentru că §8.10 dă unui form un save și restore implicit în jurul conținutului lui, iar conținutul real de form lasă uneori operatorii lui q dezechilibrați deși specificația cere să se perecheze. Renderer-ul apelează acum CaptureClipBeforeChange și SaveDC înainte să ruleze un form, apoi, după ce form-ul se termină, aruncă orice regiuni salvate mai adânc decât adâncimea de intrare și apelează RestoreDC, astfel încât fiecare HRGN salvat are exact o cale de eliberare

Captură leneșă de clip cu THPDFSavedClipState

Reparația livrată salvează un record THPDFSavedClipState per q, dar amână partea scumpă până când cadrul modifică clip-ul prima dată. Recordul ține handle-ul regiunii, adâncimea stivei căreia îi aparține, contextul de dispozitiv de la care a fost luată și un flag Captured. DevPushState umple doar adâncimea și DC-ul și crește tabloul de cadre dublând de la 16, deci un stream de conținut plin de q 1 0 0 1 x y cm ... Q nu alocă niciun obiect GDI. Operatorii care sunt pe punctul de a schimba decuparea, adică pictarea de cale cu un W sau W* în așteptare, operatorul n, umpluturile de tipar și intrarea în form, apelează întâi CaptureClipBeforeChange

Captura leneșă de clip GDI în renderer-ul HotPDF: DevPushState înregistrează doar adâncimea și DC-ul per q, CaptureClipBeforeChange citește regiunea chiar înainte ca W, n sau intrarea în form să schimbe decuparea, DevPopState o restaurează și o șterge la Q, iar versiunea nerăbdătoare care captura la fiecare q arunca throughput-ul paralel la circa 1,15 ori single-threaded
Crearea unei regiuni GDI la fiecare q înfoma thread-urile de randare, deci captura se întâmplă acum doar când un operator e pe punctul de a schimba decuparea, iar poarta de accelerare de 1,5 ori trece din nou
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;   // deja salvat, sau nu al nostru
  Region := CreateRectRgn(0, 0, 0, 0);
  if Region = 0 then RaiseLastOSError;
  ClipResult := GetClipRgn(FDC, Region);         // 0 înseamnă nicio decupare deloc
  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);  // Regiunea 0 elimină clip-ul
    if FSavedClips[Index].Region <> 0 then
      DeleteObject(FSavedClips[Index].Region);
    Dec(FSavedClipCount);
  end;
  FGSStack.Pop;
end;

Costul măsurat al versiunii nerăbdătoare e motivul pentru care există acest design. Prima implementare corectă crea și citea o regiune GDI la fiecare q, iar pe paginile făcute mai ales din transformări numerice, thread-urile de randare își petreceau timpul luptând pentru obiecte de regiune GDI în loc să rasterizeze. Pipeline-ul de randare paralel a căzut de la câștigul așteptat la circa 1,13 până la 1,20 ori throughput-ul single-threaded și a picat poarta de accelerare de 1,5 ori din suita de benchmark. Cu captură leneșă și capacitate de cadre refolosită, același benchmark trece din nou poarta originală de 1,5 ori. Antialiasing-ul de glife TrueType mici a aterizat în aceeași versiune și era suspectul evident, dar regresia a fost trasată înapoi spre alocarea de regiuni, un bun memento să măsurați înainte să dați vina pe cea mai nouă funcționalitate

Unde sunt limitele acestei abordări?

Clip-ul salvat e o regiune GDI în pixeli de dispozitiv, deci e exact pentru bitmap-ul randat și fără sens pentru orice altă țintă. De aceea fiecare cadru își înregistrează contextul de dispozitiv, iar DevPopState sare restaurarea când DC-ul s-a schimbat, de exemplu cât timp un grup de transparență randează în propriul lui bitmap de strat. GetClipRgn întorcând zero e un rezultat legitim care înseamnă niciun clip, iar restaurarea lui cu SelectClipRgn(FDC, 0) e exact ce elimină corect un clip care nu exista la q-ul pereche. Pe partea de text, reparația corectează unde ajunge fiecare glifă, dar nu inventează lățimi: dacă un font omite tabloul lui /Widths și programul încorporat nu e disponibil, avansul rămâne tot atât de bun cât fallback-ul de lățime. Când testați în regresie zona aceasta, păstrați cel puțin un fixture cu Tf 1 și un Tm scalat, unul cu Tz și Tw nenule și unul cu un clip în interiorul unui q ... Q urmat de conținut în afara lui, pentru că niciunul nu apare în documentele generate de biblioteca însăși

Dacă conduceți renderer-ul din cod de aplicație, nimic nu se schimbă în tiparul de apelare descris în randarea unei pagini PDF într-un bitmap, iar paginile care înainte arătau linii pătate sau conținut decupat ar trebui pur și simplu să se randeze corect pe 2.754.0 și mai nou. Detalii despre componentă, versiunile suportate de Delphi și C++Builder și licențiere stau pe pagina de produs HotPDF Delphi PDF Component