Artykuł techniczny

Advance tekstu i odtwarzanie klipu q/Q w rendererze Delphi

Renderer stron HotPDF Delphi Component posuwa teraz tekst naprzód, licząc przesunięcie każdego glifa w przestrzeni tekstu, tx = ((w0 − Tj/1000) × Tfs + Tc + Tw) × Th, jak definiuje to ISO 32000-1 §9.4.4, a potem przesuwając macierz tekstu przez jej część liniową z HPDFTranslateTextMatrix. Przycinanie jest zapisywane per rama q i odtwarzane na Q, ale region GDI jest przechwytywany tylko wtedy, gdy ta rama faktycznie zmienia klip. Obie poprawki wylądowały w HotPDF 2.754.0 i obie przyszły z prawdziwych stron, które renderowały się ze sklejonymi słowami albo regionami klipu wyciekającymi za ich Q. Pierwszy błąd to arytmetyka, która wygląda dobrze, dopóki producent nie zapisze rozmiaru fontu w macierzy. Druga to poprawka poprawności, która prawie kosztowała nas przyspieszenie renderowania równoległego, a sposób, w jaki odzyskaliśmy szybkość, jest wart poznania, jeśli piszesz jakiekolwiek urządzenie PDF oparte na GDI

Dlaczego tekst skleja się w kupkę, gdy PDF używa Tf 1?

Bo stary kod posuwu dodawał odległość w przestrzeni tekstu prosto do składowej translacji Tm, jakby przestrzeń tekstu i przestrzeń użytkownika zawsze miały tę samą skalę. Spora część prawdziwych producentów ustawia rozmiar fontu na 1 przez Tf i niesie prawdziwy rozmiar w macierzy tekstu. Przy /F1 1 Tf i 12 0 0 12 72 700 Tm glif szeroki na 500 jednostek posuwa się o 0.5 w przestrzeni tekstu, co po przeskalowaniu przez Tm daje 6 punktów na stronie. Stary renderer wykonywał Tm.e := Tm.e + Adv i przesuwał pióro o 0.5 punktu. Każdy glif lądował jedną dwunastą znaku za poprzednim, więc wiersz tekstu renderował się jako ciemna smuga przy lewym marginesie, podczas gdy ten sam plik wyglądał idealnie w każdej innej przeglądarce

Dlaczego tekst się skleja pod Tf 1 w rendererze HotPDF: przy 12 0 0 12 72 700 Tm glif o 500 jednostkach musi posunąć się o 0.5 jednostki przestrzeni tekstu, co Tm skaluje do 6 punktów, podczas gdy stary kod dodawał 0.5 wprost do Tm.e i renderował wiersz tekstu jako smugę po jednej dwunastej znaku na glif
Producenci zapisujący rozmiar fontu w macierzy tekstu sprawiali, że każdy glif lądował jedną dwunastą znaku za poprzednim — defekt niewidoczny na własnym wyjściu biblioteki
// Strumień treści od producenta zapisującego rozmiar w Tm, nie w Tf:
//   BT
//   /F1 1 Tf
//   12 0 0 12 72 700 Tm
//   [(Hel) 30 (lo) -250 (world)] TJ
//   ET

// Stary posuw (uproszczony): odległość dodawana do Tm.e jak do przestrzeni użytkownika
Adv := W * FontSize / 1000;                  // 0.5 dla glifa o 500 jednostkach
if (HorizScale <> 0) and (HorizScale <> 100) then
  Adv := Adv * HorizScale / 100;             // Th tylko na szerokości
Adv := Adv + CharSpace;                      // Tc nieskalowane przez Th
if Code = 32 then
  Adv := Adv + WordSpace * FontSize / 1000;  // Tw błędnie skalowane przez Tfs
Tm.e := Tm.e + Adv;                          // ignoruje Tm.a, Tm.b, Tm.c, Tm.d

// Stare dostrojenie TJ: bez Th i znowu tylko Tm.e
Tm.e := Tm.e - NumValue * FontSize / 1000;

Skrót przez Tm.e nie był jedynym defektem w tym bloku. Odstęp słów Tw jest wyrażony w nieskalowanych jednostkach przestrzeni tekstu, a stary kod mnożył go przez FontSize / 1000, więc przy Tf 12 wiersz justowany tracił niemal cały odstęp między słowami. Skalowanie poziome Th obejmowało szerokość glifa, ale nie Tc ani Tw, a dostrojenie kerningu TJ pomijało je całkowicie. Ścieżka niemalująca, która posuwa niewidoczny tekst w trybie renderowania 3, rodzaj używany przez warstwy tekstu OCR, i tekst w ukrytym optional content, nosiła prywatną kopię tej samej arytmetyki, więc cokolwiek narysowanego po niewidocznym biegu startowało ze złej pozycji. Błędy stanu tekstu w rendererze rzadko padają głośno: jak błędy indeksu operandów i nazw zasobów, które kiedyś wyzerowały Tc, Tw i Tz bez ani jednego błędu, te dawały wiarygodne strony na własnym wyjściu biblioteki i psuły się dopiero na plikach od innych producentów

Jak ISO 32000-1 §9.4.4 definiuje posuw glifa?

ISO 32000-1 §9.4.4 definiuje posuw całkowicie w przestrzeni tekstu i stosuje go do macierzy tekstu jako macierz translacji, więc odpowiedź brzmi: najpierw policz tx i pozwól Tm zrobić skalowanie, obrót i skos. Dla pisma poziomego tx równa się ((w0 − Tj/1000) × Tfs + Tc + Tw) × Th, gdzie w0 to szerokość glifa w tysięcznych em, Tj to dostrojenie TJ, a Th to Tz podzielone przez 100. Nowe Tm to [1 0 0 1 tx 0] × Tm, co w HotPDF jest helperem HPDFTranslateTextMatrix: dodaje X i Y przez współczynniki macierzy a, b, c i d, zamiast pisać wprost do e i f. Zgodnie z §9.3.3 Tw dotyczy tylko jednobajtowego kodu znaku 32, więc wielobajtowe kody CID nigdy nie łapią odstępu słów na ścieżce poziomej. Ten sam helper napędza teraz Td, TD, T*, operatory ' i ", dostrojenia TJ i ścieżkę ukrytego tekstu, co znaczy, że jedna funkcja posiada regułę

Posuw glifa wg ISO 32000-1 9.4.4 w rendererze HotPDF: tx liczone w przestrzeni tekstu z w0, Tj, Tfs, Tc, Tw i Th, potem stosowane przez HPDFTranslateTextMatrix, więc przesunięcie przechodzi przez współczynniki macierzy a, b, c i d, a Td, TD, TJ i ścieżka ukrytego tekstu dzielą jedną regułę
Dodanie posuwu wprost do Tm.e działa tylko, gdy przestrzeń tekstu równa się przestrzeni użytkownika; prowadzenie go przez współczynniki macierzy trzyma tekst skalowany, obrócony i skośny w ryzach
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;

// Poziomy posuw glifa, 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 w przestrzeni tekstu, nieskalowane
Adv := Adv * State.Text.HorizScale / 100;    // Th obejmuje całą sumę
HPDFTranslateTextMatrix(Tm, Adv, 0);

// Element liczbowy TJ: ta sama przestrzeń, to samo Th
Adjustment := -Items[I].NumValue * State.Text.FontSize / 1000;
HPDFTranslateTextMatrix(State.Text.Tm, Adjustment * State.Text.HorizScale / 100, 0);

Rozmieszczenie glifów musiało pójść za tą samą logiką. Gdy brak osadzonego konturu, a renderer cofa się do GDI TextOutW, buduje teraz pełną macierz glifa z CTM × Tm × podniesienia × skali em, łącznie z Th, i instaluje ją przez SetWorldTransform w trybie GM_ADVANCED wewnątrz pary SaveDC / RestoreDC. Font GDI jest tworzony przy stałej wysokości 1000 jednostek, a transformacja robi skalowanie, więc tekst obrócony i skośny zachowuje orientację, zamiast być rysowanym prosto w przetworzonym punkcie początkowym. Pionowy tryb pisania to jedna celowa asymetria: font WMode 1 posuwa się w dół osi y swoją metryką pionową, a skalowanie poziome nie dotyczy tej osi

Co q/Q faktycznie zapisuje w stanie grafiki PDF?

ISO 32000-1 §8.4.2 zalicza bieżącą ścieżkę przycinania do stanu grafiki, więc Q musi odtworzyć klip dokładnie taki, jaki był przy pasującym q, a nie tylko parametry liczbowe. HotPDF już trzymał stos stanu grafiki z CTM, kolorami, parametrami linii i stanem tekstu, ale GDI trzyma klip w kontekście urządzenia, poza tym stosem. Kopia stanu liczbowego odtwarzała więc wszystko poza klipem, a klip zainstalowany W n wewnątrz bloku q ... Q dalej przycinał każdą późniejszą operację na stronie. Form XObjects dodały drugą drogę do tej samej porażki, bo §8.10 daje formularzowi niejawny zapis i odtworzenie wokół jego treści, a prawdziwa treść formularzy czasem zostawia własne operatory q niewyrównane, choć specyfikacja wymaga, by parowały. Renderer woła teraz CaptureClipBeforeChange i SaveDC przed biegiem formularza, a po jego zakończeniu wyrzuca zapisane regiony głębsze niż głębokość wejścia i woła RestoreDC, więc każdy zapisany HRGN ma dokładnie jedną ścieżkę zwolnienia

Leniwe przechwytywanie klipu z THPDFSavedClipState

Poprawka, która wyszła, zapisuje jeden rekord THPDFSavedClipState na q, ale odkłada kosztowną część do chwili, gdy rama pierwszy raz rusza klip. Rekord trzyma uchwyt regionu, głębokość stosu, do której należy, kontekst urządzenia, z którego został wzięty, i flagę Captured. DevPushState wypełnia tylko głębokość i DC i powiększa tablicę ram dwukrotnie od 16, więc strumień treści pełen q 1 0 0 1 x y cm ... Q nie alokuje żadnego obiektu GDI. Operatory tuż przed zmianą przycinania, czyli malowanie ścieżki z oczekującym W albo W*, operator n, wypełnienia deseniami i wejście w formularz, wołają najpierw CaptureClipBeforeChange

Leniwe przechwytywanie klipu GDI w rendererze HotPDF: DevPushState zapisuje tylko głębokość i DC na q, CaptureClipBeforeChange czyta region tuż przed tym, jak W, n albo wejście w formularz zmienia przycinanie, DevPopState odtwarza i usuwa go na Q, a gorliwa wersja przechwytująca na każdym q zrzucała przepustowość równoległą do ok. 1.15 raza jednowątkowej
Tworzenie regionu GDI na każdym q zagłodziło wątki renderujące, więc przechwytywanie dzieje się teraz tylko wtedy, gdy operator zaraz zmieni przycinanie, a bramka przyspieszenia 1,5x znowu przechodzi
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;   // już zapisane, albo nie nasze
  Region := CreateRectRgn(0, 0, 0, 0);
  if Region = 0 then RaiseLastOSError;
  ClipResult := GetClipRgn(FDC, Region);         // 0 znaczy brak klipu w ogóle
  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 usuwa klip
    if FSavedClips[Index].Region <> 0 then
      DeleteObject(FSavedClips[Index].Region);
    Dec(FSavedClipCount);
  end;
  FGSStack.Pop;
end;

Zmierzony koszt wersji gorliwej to powód istnienia tego projektu. Pierwsza poprawna implementacja tworzyła i czytała region GDI na każdym q, a na stronach zbudowanych głównie z transformacji liczbowych wątki renderera traciły czas na walkę o obiekty regionów GDI zamiast rasteryzować. Równoległy pipeline renderowania spadł z oczekiwanego zysku do mniej więcej 1.13 do 1.20 raza przepustowości jednowątkowej i nie przeszedł bramki przyspieszenia 1.5x w zestawie benchmarków. Z leniwym przechwytywaniem i reużytą pojemnością ram ten sam benchmark znów przechodzi oryginalną bramkę 1.5x. Małe antyaliasowanie glifów TrueType wylądowało w tym samym wydaniu i było oczywistym podejrzanym, ale regresja wróciła do alokacji regionów, co jest dobrym przypomnieniem, by mierzyć, zanim obwinisz najnowszą funkcję

Gdzie są granice tego podejścia?

Zapisany klip to region GDI w pikselach urządzenia, więc jest dokładny dla renderowanej bitmapy i bez sensu dla każdego innego celu. Dlatego każda rama zapisuje swój kontekst urządzenia, a DevPopState pomija odtworzenie, gdy DC się zmienił, na przykład gdy grupa przezroczystości renderuje do własnej bitmapy warstwy. GetClipRgn zwracające zero to legalny wynik znaczący brak klipu, a odtworzenie go przez SelectClipRgn(FDC, 0) to właściwy sposób usunięcia klipu, który nie istniał przy pasującym q. Po stronie tekstu poprawka prostuje, gdzie ląduje każdy glif, ale nie wymyśla szerokości: jeśli font pomija swoją tablicę /Widths, a osadzony program jest niedostępny, posuw jest nadal tylko tak dobry jak fallback szerokości. Testując regresyjnie ten rejon, trzymaj co najmniej jedną fixturę z Tf 1 i skalowanym Tm, jedną z niezerowym Tz i Tw i jedną z klipem wewnątrz q ... Q, po którym idzie treść na zewnątrz, bo żadna z nich nie pojawia się w dokumentach generowanych przez samą bibliotekę

Jeśli sterujesz rendererem z kodu aplikacji, nic się nie zmienia we wzorcu wywołań opisanym w artykule o renderowaniu strony PDF do bitmapy, a strony, które wcześniej pokazywały rozmazane wiersze albo przyciętą treść, powinny po prostu renderować się poprawnie na 2.754.0 i nowszych. Szczegóły komponentu, obsługiwane wersje Delphi i C++Buildera i licencjonowanie są na stronie produktu HotPDF Delphi PDF Component