Strumień zawartości, który ustawia /F1 1 Tf, potem 12 0 0 12 72 700 Tm, a na końcu pokazuje (Hello world) Tj, jest całkowicie legalny. Renderer pisany na kolanie narysuje często wszystkie jedenaście znaków na blisko pięciu i pół punkcie — ciemna plama przy lewym marginesie tam, gdzie powinno być słowo o rozmiarze 66 punktów. Arytmetyka stojąca za tą plamą ma jedną linijkę. ISO 32000-1 §9.4.4 definiuje posuw każdego glifa w przestrzeni tekstu, więc przesunięcie musi przejść przez macierz tekstu, zanim dotrze do strony, a klip ustawiony wewnątrz q ... Q należy do stanu grafiki dokładnie tak samo jak grubość linii
Dlaczego tekst skleja się w kupkę, gdy PDF używa Tf 1?
Tekst się skleja, bo renderer dodaje odległość w przestrzeni tekstu wprost do składowej translacji Tm, a to działa tylko dopóki przestrzeń tekstu i przestrzeń użytkownika mają tę samą skalę. Spora część prawdziwych producentów zapisuje rozmiar fontu jako 1 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. Tm skaluje to do 6 punktów na stronie. Renderer wykonujący Tm.e := Tm.e + Adv przesuwa pióro o 0,5 punktu. Każdy glif ląduje wtedy jedną dwunastą znaku za poprzednim, a przeglądarka trzymająca się §9.4.4 pokazuje ten sam plik jako normalny tekst akapitowy
Skrót przeżywa długo, bo większość generatorów, na których ludzie testują, pisze /F1 12 Tf i nieskalowane Tm, a w tym układzie przestrzeń tekstu i przestrzeń użytkownika naprawdę są tego samego rozmiaru. Oto wersja naiwna w całości, ze strumieniem zawartości, który ją łamie, w komentarzu na górze:
// Strumień zawartoś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
// Naiwny posuw: odległość w przestrzeni tekstu dodana do Tm.e, jakby to była przestrzeń 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
// Naiwne dostrojenie TJ: bez Th i znowu tylko Tm.e
Tm.e := Tm.e - NumValue * FontSize / 1000;
Linijka z Tm.e to głośny błąd. Pozostałe są cichsze i wychodzą na innych plikach. Odstęp słów Tw jest już wyrażony w nieskalowanych jednostkach przestrzeni tekstu, więc pomnożenie go przez FontSize / 1000 zamienia 3 Tw pod Tf 12 w 0,036 jednostki. Akapit justowany traci prawie cały odstęp między słowami. Skalowanie poziome Th musi obejmować też Tc i Tw, a także liczbę kerningu w TJ. Test Code = 32 odpala się przy okazji na każdym bajcie o wartości 32 wewnątrz kodu dwubajtowego, co rozsiewa przypadkowe odstępy po tekście CJK. Błędy stanu tekstu tego typu rzadko padają głośno: jak pułapki indeksu operandów i nazw zasobów, które potrafią wyzerować Tc, Tw i Tz, zanim zostanie narysowany jeden glif, produkują wiarygodne strony od jednego producenta i zepsute od następnego
Jak ISO 32000-1 §9.4.4 definiuje posuw glifa?
ISO 32000-1 §9.4.4 liczy posuw całkowicie w przestrzeni tekstu i stosuje go do macierzy tekstu jako macierz translacji, więc skalowanie, obrót i skos biorą się z Tm i nigdy z samego posuwu. Dla pisma poziomego tx = ((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. Nowa macierz to [1 0 0 1 tx 0] × Tm. Rozpisany, ten iloczyn dodaje a·tx do e i b·tx do f — dlatego translacja w przestrzeni tekstu nie może być zwykłym dodaniem do e, chyba że a wynosi 1, a b wynosi 0. Zgodnie z §9.3.3 Tw dotyczy wyłącznie jednobajtowego kodu znaku 32
HotPDF Delphi Component trzyma tę formułę w jednym miejscu. Moduł HPDFContentStream eksportuje HPDFTextGlyphDisplacement dla posuwu per glif i HPDFTextAdjustmentDisplacement dla liczby TJ, a zarówno renderer strony, jak i interpreter ekstrakcji tekstu je wywołują, więc strona, która renderuje się poprawnie, raportuje też pozycje glifów zgrane z tym, co widać. Jeśli piszesz własnego konsumenta strumieni zawartości na parserze biblioteki, możesz wywołać te same dwie procedury i krok macierzowy zrobić sam:
uses
HPDFContentStream; // HPDFTextGlyphDisplacement, HPDFTextAdjustmentDisplacement
type
// Własny rekord macierzy tekstu, nie typ HotPDF
TTextMatrix = record
a, b, c, d, e, f: Double;
end;
// Własny helper: [1 0 0 1 Tx Ty] x Tm, ruch przez a, b, c i 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 to szerokość glifa w 1/1000 em; Tz to operand Tz (100 = brak skalowania)
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 tylko na jednobajtowej spacji
Tc, Tw, Tz, Tx, Ty);
TranslateInTextSpace(Tm, Tx, Ty);
// Tf 1, Tm 12 0 0 12 72 700, W0 = 500: Tx = 0.5 i Tm.e rośnie o 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 dotyczy też kerningu
end;
Wewnątrz renderera ten sam krok macierzowy napędza każdy operator, który przesuwa pozycję tekstu. Td, TD, T*, ' i " translują macierz linii przez jej współczynniki, więc 0 -1.2 Td pod macierzą tekstu 12× przesuwa linię bazową 14,4 punktu w dół zamiast 1,2. Glify nigdy nie malowane idą identyczną ścieżką. Tryb renderowania 3, niewidoczny tryb używany przez warstwy tekstu OCR, i tekst w ukrytej zawartości opcjonalnej posuwają Tm dokładnie tak jak tekst malowany. Prywatna kopia tej arytmetyki dla nich to klasyczny błąd, bo wtedy każdy widoczny tekst po niewidocznym biegu w tym samym bloku BT startuje z lekko innej pozycji
Pozycjonowanie samego glifa
Rozmieszczenie glifów idzie za tą samą logiką. Gdy brak osadzonego konturu, a renderer cofa się do GDI TextOutW, buduje pełną macierz glifa z CTM × Tm × podniesienia × skali em, z Th włącznie. Macierz wchodzi przez SetWorldTransform w trybie GM_ADVANCED, wewnątrz pary SaveDC / RestoreDC. Font GDI jest tworzony przy stałej wysokości 1000 jednostek, a transformacja świata robi całe skalowanie. Tekst obrócony i skośny zachowuje więc orientację, zamiast być rysowanym prosto w przetworzonym punkcie początkowym. Tryby renderowania kreskowego resetują transformację świata przed kreskowaniem, bo grubość pióra mierzona względem projektowego rozmiaru 1000 jednostek skurczyłaby się prawie do zera. Pionowe pisanie to jedna celowa asymetria: font WMode 1 posuwa się w dół osi y swoją metryką pionową, Tc i Tw działają wzdłuż tej osi, a Th nie działa wcale
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 stał przy pasującym q. Brzmi oczywisto. W rendererze opartym na GDI łatwo to przeoczyć, bo CTM, kolory, parametry linii i stan tekstu mieszkają na własnym stosie stanu renderera, a klip mieszka w kontekście urządzenia — poza tym stosem. Skopiowanie stanu liczbowego odtwarza wszystko poza klipem. W n wewnątrz bloku q ... Q przycina wtedy każdą późniejszą operację na stronie, a objaw wygląda jak brakująca treść daleko od operatora, który to spowodował
Form XObjects otwierają drugą drogę do tej samej porażki. §8.10 daje formularzowi niejawny zapis i odtworzenie wokół jego zawartości oraz przycina go do /BBox, a prawdziwa zawartość formularzy czasem zostawia własne q i Q niewyrównane, choć specyfikacja wymaga, by parowały. HotPDF zapisuje klip i kontekst urządzenia przed uruchomieniem formularza, a gdy formularz się kończy, wyrzuca ramki klipu, które formularz zostawił otwarte, i odtwarza kontekst urządzenia, więc każdy zapisany region jest zwalniany dokładnie raz. Zbłąkane Q wewnątrz formularza, które zdjęłoby stan wołającego, jest odrzucane zamiast zostać zastosowane. Przy RenderFallbackPolicy ustawionym na rfpReport ta odmowa pojawia się w diagnostyce, co łatwo odróżnia niechlujnego producenta od problemu z renderowaniem:
// Diags: THPDFRenderFallbackDiagnostics (deklarowane w 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 jest bezpieczne na nil
end;
Przechwytywanie klipu tylko wtedy, gdy rama go zmienia
Proste rozwiązanie to odczyt regionu klipu GDI na każdym q i odkładanie go na każdym Q. Poprawne. I drogie dokładnie na stronach, które używają q najczęściej, bo generatory uwielbiają owijać każde małe rysowanie w q 1 0 0 1 x y cm ... Q wyłącznie po to, żeby przesunąć początek. Każda taka para tworzyłaby wtedy region GDI, kopiowała do niego klip i znowu go usuwała. Gdy kilka stron renderuje się równolegle, wątki kończą z konkurowaniem o obiekty regionów GDI zamiast rasteryzować, a przyspieszenie z równoległego pipeline'u renderowania odpowiednio się kurczy
HotPDF odkłada kosztowną część na później. q zapisuje tylko głębokość stosu i powierzchnię, do której należy, w tablicy ram rosnącej podwajaniem od 16 i używanej ponownie w obrębie strony, więc ciąg liczbowych transformacji nie alokuje żadnego obiektu GDI. Klip jest przechwytywany pierwszym razem, gdy coś w tej ramie ma go zmienić: malowanie ścieżki albo n z oczekującym W lub W*, wypełnienie deseniem, klip pozostawiony przez tryby renderowania tekstu 4 do 7 albo wejście w formularz. Na Q przechwycona rama odkłada swój region z powrotem i zwalnia go, a rama, która nigdy nie zmieniła klipu, zdejmuje tylko stan liczbowy. Zwykła strona nie alokuje wtedy żadnego regionu, a strona przycinająca płaci dokładnie jeden region na każdą faktyczną zmianę klipu. Jeśli budujesz własne urządzenie GDI dla interpretera PDF, ten sam kształt mieści się w kilkudziesięciu linijkach:
// Szkic dla własnego urządzenia opartego na GDI; nic z tego nie jest API HotPDF
// wymaga Windows, SysUtils i Math w klauzuli uses
type
TClipFrame = record
Region: HRGN;
Captured: Boolean;
end;
procedure TMyDevice.PushState; // q: zero wywołań 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; // wołaj przed W n, W f, klipem tekstowym, BBox formularza
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 skopiowano, 0 brak klipu, -1 błąd
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; // niewyważone Q: zignoruj je
Dec(FCount);
if FFrames[FCount].Captured then
SelectClipRgn(FDC, FFrames[FCount].Region); // Region 0 usuwa klip
if FFrames[FCount].Region <> 0 then
DeleteObject(FFrames[FCount].Region);
end;
Dwa szczegóły tego szkicu niosą większość ciężaru. GetClipRgn zwracające 0 to nie błąd: znaczy, że kontekst urządzenia nie miał wtedy żadnego klipu. Podanie regionu zerowego do SelectClipRgn na Q to dokładnie to, co usuwa klip, który nie istniał przy pasującym q. Traktowanie 0 jako porażki zostawia wewnętrzny klip na miejscu. Drugi szczegół to flaga Captured, która blokuje drugiej zmianie klipu w tej samej ramie nadpisanie zapisanego regionu już zawężonym
Gdzie zapisany klip i reguła posuwu się kończą
Zapisany klip to region GDI w pikselach urządzenia. Jest dokładny dla renderowanej bitmapy i bez sensu dla jakiejkolwiek innej powierzchni, dlatego każda rama zapisuje też kontekst urządzenia, z którego była wzięta, a odtworzenie jest pomijane, gdy kontekst się zmienił — na przykład gdy grupa przezroczystości renderuje do własnej bitmapy warstwy
Po stronie tekstu reguła posuwu rozmieszcza każdy glif na podstawie szerokości, jaką dostaje. Gdy prosty font pomija swoją tablicę /Widths, a osadzony program fontu jest niedostępny, posuw jest tylko tak dobry jak fallback szerokości, a §9.4.4 nie mówi nic o tym, skąd ta szerokość ma pochodzić. Testy regresyjne dla tego mieszkają na plikach, których proste generowane dokumenty prawie nigdy nie zawierają: jeden z Tf 1 i skalowanym Tm, jeden z niezerowymi Tz i Tw na tekście justowanym oraz jeden z klipem W n wewnątrz q ... Q, po którym idzie treść rysowana poza przyciętym obszarem. Poprawny render trzeciego pokazuje tę zewnętrzną treść w całości
Z kodu aplikacji nic z tego nie wymaga żadnej konfiguracji: wywołanie to to samo, co w renderowaniu strony PDF do bitmapy, a obie reguły dotyczą każdej strony renderowanej w ten sposób. Obsługiwane wersje Delphi i C++Builder oraz szczegóły licencjonowania są wypisane na stronie HotPDF Delphi PDF Component