Technical Article

Błąd EndDoc, który po cichu wyłączył podzbiory czcionek

Generujesz raport, osadzasz czcionkę TrueType, a wynikowy plik otwiera się poprawnie w każdej przeglądarce, którą wypróbujesz. Glify są prawidłowe, tekst można zaznaczać, plik jest prawidłowy. Jedyną rzeczą, która jest nie tak, jest rozmiar. Dokument, w którym użyto kilkudziesięciu znaków łacińskich, zawiera całą czcionkę o rozmiarze 350 KB. Dokument, w którym wydrukowano akapit w języku chińskim, zawiera czcionkę CJK o rozmiarze 14 MB zamiast półmegabajtowego wycinka, którego powinien potrzebować. Nie zgłoszono żadnego wyjątku, nie zarejestrowano żadnego ostrzeżenia, a plik przeszedł walidację. Tak właśnie wygląda błędnie uporządkowany krok finalizacji z zewnątrz: nic nie kończy się niepowodzeniem, a jedynym dowodem jest zbyt duża liczba.

Błąd, który go spowodował, istniał w HotPDF przez jedną linię wydań i od tego czasu został naprawiony. Warto o nim napisać nie jako o powiadomieniu o defekcie, ale jako o lekcji, ponieważ charakter tego błędu jest ogólny. Każdy silnik dokumentów ma etap finalizacji, który modyfikuje obiekty tuż przed ich zapisaniem, a poprawność tego etapu zależy całkowicie od kolejności jego kroków w stosunku do serializacji. Wykonaj jeden krok po niewłaściwej stronie zapisu, a nie zrobi on nic, po cichu.

Co mają robić podzbiory czcionek

Czcionka będąca podzbiorem to ta część pliku TrueType, której dokument faktycznie używa. Norma ISO 32000-1 §9.9 opisuje, jak osadzony program czcionki znajduje się w strumieniu wskazywanym przez deskryptor czcionki, a dla programu TrueType tym strumieniem jest /FontFile2 z /Length1 podającym nieskompresowaną liczbę bajtów. Tworzenie podzbioru polega na przepisywaniu tabel glyf i loca tak, aby zawierały tylko glify, do których odwołuje się dokument, ponownym numerowaniu identyfikatorów glifów i poprzedzaniu nazwy /BaseFont sześcioliterowym znacznikiem, takim jak ABCDEF+, aby oznaczyć czcionkę jako podzbiór, dokładnie tak, jak wymaga tego specyfikacja. Krój pisma łacińskiego, którego podzbiór zajmuje dziesięć lub piętnaście kilobajtów, to różnica między odchudzonym plikiem PDF a takim, który przesyła cały krój pisma ze względu na jeden nagłówek.

Moment, w którym to następuje, ma znaczenie. Tworzenie podzbiorów nie jest transformacją stosowaną do bajtów znajdujących się już na dysku. Modyfikuje ono graf obiektów w pamięci: zmniejsza zawartość strumienia /FontFile2, poprawia /Length1 i przepisuje ciąg znaków /BaseFont. Wszystko to musi być na swoim miejscu, gdy serializer przechodzi przez graf i emituje bajty. Jeśli modyfikacje nastąpią po zapisaniu bajtów, zaktualizują one obiekty, których nikt nigdy nie przeczyta.

Objaw i dlaczego nic nie zgłaszało błędu

Zgłoszonym zachowaniem było pojawianie się pełnych czcionek w pliku wyjściowym bez żadnych komunikatów diagnostycznych. Użytkownik, który zarejestrował czcionkę TrueType Unicode i wygenerował normalny dokument, stwierdzał, że osadzony obiekt czcionki miał taką samą długość jak źródłowy plik .ttf, a nazwa /BaseFont nie zawierała sześcioliterowego prefiksu podzbioru. Rozmiar pliku wyjściowego nigdy nie malał między przebiegami, które używały dziesięciu glifów, a przebiegami, które używały dziesięciu tysięcy.

Brak jakiegokolwiek błędu to część, która sprawia, że ta klasa błędów jest kosztowna. Procedura tworzenia podzbiorów, która uruchamia się w złym czasie, nadal działa. Przechodzi przez nagromadzony zestaw używanych punktów kodowych, buduje idealnie poprawny podzbiór i stosuje go do grafu obiektów w pamięci. Wewnętrznie praca została wykonana i wywołanie wraca bez błędów. Jedyną rzeczą, która jest nie tak, jest to, że graf obiektów, który został zmodyfikowany, nie jest już tym, który jest zapisywany, ponieważ moduł zapisu zdążył już zakończyć pracę. Z punktu widzenia wywołującego dokument został wygenerowany i zapisany bez zakłóceń, co jest dokładnie wrażeniem, jakie daje cicha awaria.

Przyczyną źródłową była kolejność finalizacji

W HotPDF praca końcowa odbywa się wewnątrz EndDoc. Krok tworzenia podzbioru to wewnętrzna procedura o nazwie BuildAndApplyUnicodeFontSubset. Odczytuje ona zestaw używanych punktów kodowych dla danego dokumentu, przechowywany w mapie bitowej, którą ścieżka emisji tekstu wypełnia w miarę wyświetlania glifów, mapuje każdy używany punkt kodowy poprzez buforowaną tabelę punktów kodowych na rzeczywisty identyfikator glifu i przepisuje program czcionki wokół tego domknięcia. Gdy rejestrowana jest czcionka TrueType Unicode, ścieżka emisji ustawia bit w zestawie używanych punktów kodowych dla każdego rysowanego znaku, dzięki czemu do czasu zamknięcia dokumentu silnik dokładnie wie, które glify musi zachować podzbiór.

Defekt polegał na tym, że procedura BuildAndApplyUnicodeFontSubset była wywoływana po tym, jak SaveToStream lub SaveToFile dokonały już serializacji dokumentu. Modyfikacje strumienia /FontFile2, poprawiona wartość /Length1 i sześcioliterowy prefiks /BaseFont dokonane przez moduł tworzenia podzbiorów zostały obliczone na podstawie grafu obiektów, który został już przekształcony w bajty. Rozwiązaniem była zmiana kolejności o jedną linię: przeniesienie wywołania podzbioru przed serializację, tak aby moduł zapisu emitował czcionkę z utworzonym podzbiorem, a nie oryginalną. Poprawiona sekwencja najpierw uruchamia moduł tworzenia podzbiorów, a dopiero potem wykonuje serializację.

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.RegisterUnicodeTTF('C:\Fonts\NotoSansSC-Regular.ttf');
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Noto Sans SC', [], 12);
    Pdf.CurrentPage.TextOut(72, 760, 0, '报表标题 Report Heading');
    Pdf.EndDoc;                 // subsetting runs here, before the write
    Pdf.SaveToFile('Report.pdf');
  finally
    Pdf.Free;
  end;
end;

Po poprawieniu kolejności nic nie zmienia się w kodzie wywołującym. Tworzenie podzbiorów jest włączone domyślnie po zarejestrowaniu czcionki TrueType Unicode. Rejestrujesz czcionkę, rozpoczynasz dokument, rysujesz i kończysz go, a podzbiór jest budowany z użytych glifów, zanim bajty opuszczą pamięć.

Dlaczego jeden niewłaściwie umieszczony krok to cała kategoria

Powodem, dla którego warto wyciągnąć z tego lekcję, zamiast traktować to jako przypis, jest to, że EndDoc emituje listę kroków końcowych, a każdy z nich jest wrażliwy na swoją pozycję względem zapisu. Tworzenie podzbiorów czcionek to jeden z nich. Wyjście PDF/A wymaga strumienia /CIDSet, który wylicza dokładnie identyfikatory glifów obecne w podzbiorze, co jest ograniczeniem narzuconym przez normę ISO 19005, aby walidator mógł potwierdzić, że osadzony program pasuje do tego, co deklaruje deskryptor czcionki; strumień ten jest emitowany w tym samym oknie finalizacji i zależy od uprzedniego zbudowania podzbioru. Standard PDF/UA-1 wymaga, zgodnie z normą ISO 14289-1 §7.18.3, aby każda strona zawierająca adnotację deklarowała klucz /Tabs z wartością /S, a wewnętrzna procedura o nazwie EnsurePDFUATabsOnAnnotatedPages zapisuje ten klucz na tym samym etapie. W tym miejscu uruchamiane są również testy intencji wyjściowej.

Ten sam błąd kolejności, który wyłączył tworzenie podzbiorów, usunął również klucz kolejności tabulacji PDF/UA na stronach z adnotacjami, ponieważ ten krok znajdował się po tej samej, złej stronie zapisu. Narzędzia veraPDF i PAC zgłaszają brak /Tabs /S jako naruszenie punktu kontrolnego 21-001 protokołu Matterhorn. Zatem pojedyncze błędnie umieszczone wywołanie nie tylko zwiększyło rozmiar pliku; jednocześnie po cichu naruszyło wymóg zgodności z dostępnością, przy tym samym braku jakichkolwiek błędów. Na tym polega zagrożenie związane z etapem finalizacji: jego kroki dzielą warunek wstępny, a jeden błąd kolejności może wyeliminować kilka z nich naraz, podczas gdy każde wywołanie nadal zwraca sukces.

Jak faktycznie wykrywa się cichą awarię emisji

Błąd, który nie zgłasza żadnego wyjątku, nie zostanie wykryty przez samo uruchomienie programu. Wykrywa się go poprzez zbadanie pliku wyjściowego i porównanie go z tym, co powinno zostać wygenerowane. W przypadku podzbiorów czcionek testy są konkretne. Porównaj rozmiar pliku wyjściowego z przybliżonym oczekiwaniem: dokument, który dotyczył garści glifów, nie powinien mieć rozmiaru całego kroju pisma. Otwórz osadzony obiekt czcionki i odczytaj jego długość w bajtach; strumień /FontFile2 z utworzonym podzbiorem dla kroju łacińskiego stanowi ułamek pliku źródłowego. Odczytaj nazwę /BaseFont i potwierdź obecność sześcioliterowego prefiksu, ponieważ jego brak jest bezpośrednim sygnałem, że nie zastosowano żadnego podzbioru.

var
  Pdf: THotPDF;
  Output: TMemoryStream;
begin
  Output := TMemoryStream.Create;
  try
    Pdf := THotPDF.Create(nil);
    try
      Pdf.RegisterUnicodeTTF('C:\Fonts\DejaVuSans.ttf');
      Pdf.BeginDoc;
      Pdf.CurrentPage.SetFont('DejaVu Sans', [], 11);
      Pdf.CurrentPage.TextOut(72, 760, 0, 'Subset me');
      Pdf.EndDoc;
      Pdf.SaveToStream(Output);
    finally
      Pdf.Free;
    end;
    // A few glyphs from a ~700 KB face must not yield a multi-hundred-KB stream.
    if Output.Size > 100 * 1024 then
      raise Exception.Create('Font subset did not shrink the output');
  finally
    Output.Free;
  end;
end;

W przypadku wyjścia PDF/A test jest jeszcze dokładniejszy, ponieważ walidator wykonuje pracę za Ciebie. Ustaw poziom zgodności i sprawdź wynik w programie veraPDF: brak /CIDSet lub podzbiór, który nie pasuje do deskryptora, jest zgłaszany jako niezaliczona klauzula, a nie pozostawiany do zauważenia gołym okiem. Przełączniki zgodności, które sterują tą pracą finalizacyjną, są właściwościami dokumentu. Opcja PDFACompliance przyjmuje ciąg znaków, taki jak '2B' dla PDF/A-2 Poziom B, a PDFUACompliance to wartość logiczna, która włącza wymagania dotyczące tagowanego PDF oraz kolejności tabulacji.

Pdf := THotPDF.Create(nil);
try
  Pdf.PDFACompliance := '2B';     // PDF/A-2 Level B, drives /CIDSet emission
  Pdf.PDFUACompliance := True;    // stamps /Tabs /S on annotated pages
  Pdf.RegisterUnicodeTTF('C:\Fonts\NotoSansSC-Regular.ttf');
  Pdf.BeginDoc;
  Pdf.CurrentPage.SetFont('Noto Sans SC', [], 12);
  Pdf.CurrentPage.TextOut(72, 760, 0, '合规报告');
  Pdf.EndDoc;
  Pdf.SaveToFile('Report_PDFA.pdf');
finally
  Pdf.Free;
end;

Lekcja inżynieryjna

Wynikają z tego dwie zasady. Pierwsza jest taka, że każdy krok finalizacji modyfikujący obiekty musi zostać wykonany przed serializacją tych obiektów, a końcowy etap silnika dokumentów powinien być traktowany jako uporządkowany potok, w którym serializacja jest ostatnią czynnością, a nie jedną z wielu. Druga zasada to ta, która kosztowała tutaj najwięcej czasu: w przypadku kroku emisji brak błędu nie jest dowodem sukcesu. Procedura, która buduje właściwy podzbiór i stosuje go do niewłaściwego, zapisanego już grafu, nie zgłasza niczego złego, ponieważ z jej własnej perspektywy wszystko było w porządku. Weryfikacja musi dotyczyć artefaktu, a nie kodu powrotu. Sprawdź rozmiar wyjściowy, odczytaj długość osadzonej czcionki w bajtach oraz jej prefiks /BaseFont i pozwól programowi veraPDF ocenić wyjście PDF/A, gdzie brakujący /CIDSet zmienia cichy błąd w nazwaną awarię.

Kwestie związane z obsługą czcionek po stronie producenta, rejestrowaniem i osadzaniem krojów pisma na potrzeby raportów, zostały omówione w naszym artykule na temat czcionek i obrazów w raportach wyjściowych. Kwestie walidacji, gdzie te kroki finalizacji są sprawdzane pod kątem zgodności ze standardami, opisano w przewodniku po walidacji PDF/A i PDF/UA. Oba tematy łączą się z pracami nad tworzeniem podzbiorów i zgodnością opisanymi tutaj, które są dostarczane jako część pakietu HotPDF Component dla Delphi i C++Builder wraz z interfejsami API do ładowania, edycji, szyfrowania i podpisywania omówionymi w innych miejscach tego bloga.