Technický článek

PDF text advance a obnova clipu q/Q v Delphi rendereru

Page renderer HotPDF Delphi Component teď posouvá text počítáním každého posunu glyfu v text space, tx = ((w0 − Tj/1000) × Tfs + Tc + Tw) × Th, jak ho definuje ISO 32000-1 §9.4.4, a pak pohybuje text maticí přes její lineární část s HPDFTranslateTextMatrix. Clipping se ukládá na každý rámec q a obnovuje na Q, ale GDI region se zachytí jen když ten rámec clip skutečně mění. Obě opravy přistály v HotPDF 2.754.0 a obě přišly z reálných stránek, které se renderovaly se sbalenými slovy nebo s clip regiony přetékajícími za jejich Q. První bug je aritmetika, která vypadá správně, dokud producent nezapíše svou velikost fontu do matice. Druhá je oprava správnosti, která nás málem stála paralelní zrychlení renderu, a způsob, jak jsme rychlost dostali zpátky, stojí za znalost, pokud píšete jakékoli GDI-backed PDF zařízení

Proč se text sbalí do chuchvalce, když PDF používá Tf 1?

Protože starý advance kód přičítal vzdálenost v text space rovnou k translační složce Tm, jako by text space a user space měly vždy stejnou škálu. Spousta reálných producentů nastaví velikost fontu na 1 přes Tf a skutečnou velikost veze v text matici. S /F1 1 Tf a 12 0 0 12 72 700 Tm posune se glyf široký 500 jednotek o 0.5 v text space, což je na stránce 6 bodů, jakmile to Tm škáluje. Starý renderer provedl Tm.e := Tm.e + Adv a pohnul perem o 0.5 bodu. Každý glyf přistál jedna dvanáctina znaku po předchozím, takže řádek textu se vyrenderoval jako tmavý smyk u levého okraje, zatímco tentýž soubor vypadal v každém jiném prohlížeči dokonale

Proč se text pod Tf 1 v rendereru HotPDF sbalí: s 12 0 0 12 72 700 Tm se musí glyf o 500 jednotkách posunout o 0.5 jednotky text space, což Tm škáluje na 6 bodů, zatímco starý kód přičetl 0.5 rovnou k Tm.e a vyrenderoval řádek textu jako smyk o jedné dvanáctině znaku na glyf
Producenti kódující velikost fontu v text matici způsobili, že každý glyf přistál jedna dvanáctina znaku po předchozím, vada neviditelná na vlastním výstupu knihovny
// Content stream od producenta, který kóduje velikost v Tm, ne Tf:
//   BT
//   /F1 1 Tf
//   12 0 0 12 72 700 Tm
//   [(Hel) 30 (lo) -250 (world)] TJ
//   ET

// Starý advance (zjednodušeno): vzdálenost přičtená k Tm.e, jako by to byl user space
Adv := W * FontSize / 1000;                  // 0.5 pro glyf o 500 jednotkách
if (HorizScale <> 0) and (HorizScale <> 100) then
  Adv := Adv * HorizScale / 100;             // Th jen na šířce
Adv := Adv + CharSpace;                      // Tc neškálované Th
if Code = 32 then
  Adv := Adv + WordSpace * FontSize / 1000;  // Tw chybně škálované Tfs
Tm.e := Tm.e + Adv;                          // ignoruje Tm.a, Tm.b, Tm.c, Tm.d

// Stará TJ úprava: žádné Th a zase jen Tm.e
Tm.e := Tm.e - NumValue * FontSize / 1000;

Zkratka Tm.e nebyla jediná vada v tom bloku. Word spacing Tw se vyjadřuje v neškálovaných jednotkách text space, a přesto ho starý kód násobil FontSize / 1000, takže pod Tf 12 ztratil zarovnaný řádek skoro celou svou mezeru mezi slovy. Horizontální škálování Th se aplikovalo na šířku glyfu, ale ne na Tc nebo Tw a TJ kerning úprava ho vynechala úplně. Nepaintovací cesta posouvající neviditelný text render mode 3, jaký používají OCR textové vrstvy, a text uvnitř skrytého optional contentu nesla soukromou kopii týže aritmetiky, takže cokoli nakreslené po neviditelném běhu startovalo ze špatné pozice. Chyby text stavu v rendereru málokdy selžou hlasitě: jako bugy operand indexu a resource name, které jednou vynulovaly Tc, Tw i Tz bez jediné chyby, produkovaly věrohodné stránky na vlastním výstupu knihovny a rozbily se jen na souborech od jiných producentů

Jak definuje ISO 32000-1 §9.4.4 glyph advance?

ISO 32000-1 §9.4.4 definuje advance úplně v text space a aplikuje ho na text matici jako translační matici, takže odpověď je spočítat nejdřív tx a nechat Tm dělat škálování, rotaci i skew. Pro horizontální psaní se tx rovná ((w0 − Tj/1000) × Tfs + Tc + Tw) × Th, kde w0 je šířka glyfu v tisícinách em, Tj je TJ úprava a Th je Tz dělené 100. Nové Tm je [1 0 0 1 tx 0] × Tm, což je v HotPDF helper HPDFTranslateTextMatrix: přičítá X a Y přes maticové koeficienty a, b, c a d místo zápisu přímo do e a f. Podle §9.3.3 se Tw aplikuje jen na jednobajtový character code 32, takže multibytové CID kódy nikdy nesbírají word spacing na horizontální cestě. Týž helper teď řídí Td, TD, T*, operátory ' a ", TJ úpravy i skrytou textovou cestu, což znamená, že jediná funkce vlastní pravidlo

Glyph advance ISO 32000-1 9.4.4 v rendereru HotPDF: tx spočtený v text space z w0, Tj, Tfs, Tc, Tw a Th, pak aplikovaný přes HPDFTranslateTextMatrix, takže posun jde přes maticové koeficienty a, b, c a d a Td, TD, TJ i skrytá textová cesta sdílejí jedno pravidlo
Přičítání advance přímo k Tm.e funguje jen když se text space rovná user space; vedení přes maticové koeficienty drží škálovaný, rotovaný i zkosený text správně
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;

// Horizontální glyph advance, 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 v text space, neškálované
Adv := Adv * State.Text.HorizScale / 100;    // Th se aplikuje na celý součet
HPDFTranslateTextMatrix(Tm, Adv, 0);

// TJ number element: týž prostor, týž Th
Adjustment := -Items[I].NumValue * State.Text.FontSize / 1000;
HPDFTranslateTextMatrix(State.Text.Tm, Adjustment * State.Text.HorizScale / 100, 0);

Umístění glyfů muselo následovat tutéž logiku. Když není k dispozici žádný vložený obrys a renderer spadne zpátky na GDI TextOutW, staví teď plnou glyfovou matici z CTM × Tm × rise × em škála, včetně Th, a instaluje ji přes SetWorldTransform v módu GM_ADVANCED uvnitř páru SaveDC / RestoreDC. GDI font se vytváří s fixní výškou 1000 jednotek a transformu dělá sizing, takže rotovaný i zkosený text si drží orientaci místo kreslení vzpřímeně na transformovaném bodu původu. Vertikální writing mode je jedna záměrná asymetrie: font WMode 1 posouvá dolů po ose y o svou vertikální metriku a horizontální škálování se na tu osu nevztahuje

Co q/Q doopravdy ukládá v PDF graphics state?

ISO 32000-1 §8.4.2 řadí aktuální clipping path mezi graphics state, takže Q musí obnovit clip přesně tak, jak byl při odpovídajícím q, ne jen číselné parametry. HotPDF už vedl zásobník graphics state s CTM, barvami, parametry čar a text stavem, ale GDI drží clip v device contextu, mimo ten zásobník. Kopie číselného stavu tedy obnovila všechno kromě clipu a clip instalovaný W n uvnitř bloku q ... Q pořád ořezával každou pozdější operaci na stránce. Form XObjects přidaly druhou cestu ke stejnému selhání, protože §8.10 dává formě implicitní save a restore kolem svého obsahu a reálný obsah formulářů někdy nechává vlastní operátory q nevyvážené, i když specifikace vyžaduje, aby se párovaly. Renderer teď volá CaptureClipBeforeChange a SaveDC před spuštěním formy a po dokončení formy zahodí jakékoli uložené regiony hlubší než vstupní hloubka a zavolá RestoreDC, takže každý uložený HRGN má přesně jednu uvolňovací cestu

Líné zachytávání clipu s THPDFSavedClipState

Oprava, která vyšla, ukládá jeden záznam THPDFSavedClipState na q, ale odkládá drahou část do doby, než rámec poprvé změní clip. Záznam drží handle regionu, hloubku zásobníku, do níž patří, device context, ze kterého byl vzat, a příznak Captured. DevPushState vyplní jen hloubku a DC a roste polem rámců zdvojnásobováním od 16, takže content stream plný q 1 0 0 1 x y cm ... Q nealokuje žádný GDI objekt. Operátory, které se chystají měnit clipping, tedy path painting s čekajícím W nebo W*, operátor n, pattern fills a vstup do formy, volají nejdřív CaptureClipBeforeChange

Líné zachytávání GDI clipu v rendereru HotPDF: DevPushState zaznamenává jen hloubku a DC na q, CaptureClipBeforeChange čte region těsně před tím, než W, n nebo vstup do formy změní clipping, DevPopState ho obnoví a smaže na Q a horlivá verze zachycující na každém q upustila paralelní throughput na zhruba 1.15krát single-threaded
Vytváření GDI regionu na každém q vyhladovělo render vlákna, takže zachycení teď proběhne jen když se operátor chystá měnit clipping a brána 1.5násobného zrychlení znovu projde
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;   // already saved, or not ours
  Region := CreateRectRgn(0, 0, 0, 0);
  if Region = 0 then RaiseLastOSError;
  ClipResult := GetClipRgn(FDC, Region);         // 0 znamená žádný 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 odstraní clip
    if FSavedClips[Index].Region <> 0 then
      DeleteObject(FSavedClips[Index].Region);
    Dec(FSavedClipCount);
  end;
  FGSStack.Pop;
end;

Naměřená cena horlivé verze je důvod existence tohohle designu. První správná implementace vytvářela a četla GDI region na každém q a na stránkách složených hlavně z číselných transformací trávily render vlákna čas přetížením GDI region objektů místo rasterizace. Paralelní render pipeline spadla z očekávaného zisku na zhruba 1.13 až 1.20krát single-threaded throughput a neprošla branou 1.5násobného zrychlení v benchmark sadě. S líným zachytáváním a znovupoužívanou kapacitou rámců tatáž benchmark prochází původní branou 1.5násobného zrychlení znovu. Malé TrueType glyph antialiasing přistálo ve stejném release a bylo očividným podezřelým, ale regrese se dohledala k alokaci regionů, což je dobrá připomínka měřit, než obviníte nejnovější funkci

Kde jsou limity tohohle přístupu?

Uložený clip je GDI region v device pixelech, takže je přesný pro bitmapu, která se renderuje, a bezvýznamný pro jakýkoli jiný cíl. Proto každý rámec zaznamenává svůj device context a DevPopState přeskočí obnovu, když se DC změnil, například zatímco transparency group renderuje do vlastní layer bitmapy. GetClipRgn vracející nulu je legitimní výsledek znamenající žádný clip a jeho obnova přes SelectClipRgn(FDC, 0) je to, co správně odstraní clip, který při odpovídajícím q neexistoval. Na textové straně oprava spraví, kam každý glyf jde, ale nevynalézá šířky: pokud font vynechá své pole /Widths a vložený program není k dispozici, advance je pořád jen tak dobrý, jaký je width fallback. Když tahle oblast prochází regresním testováním, držte aspoň jednu fixturu s Tf 1 a škálovaným Tm, jednu s nenulovým Tz a Tw a jednu s clipem uvnitř q ... Q následovaným obsahem vně něj, protože žádné z nich se neukáže v dokumentech generovaných samotnou knihovnou

Pokud renderer řídíte z aplikačního kódu, volací vzorec popsaný v článku o renderování PDF stránky na bitmapu se nemění a stránky, které dřív ukazovaly rozmazané řádky nebo ořezaný obsah, by se na 2.754.0 a novějším měly prostě vyrenderovat správně. Detaily o komponentě, podporovaných verzích Delphi a C++Builderu a licencování jsou na stránce produktu HotPDF Delphi PDF Component