Artykuł techniczny

Audyt fontów PDF/UA w Delphi: Widths, CharSet, CIDSet

Raport veraPDF mówiący, że szerokość glifu nie zgadza się z osadzonym programem fontu, mówi ci prawie nic o tym, który glif, ani dlaczego. PDFlibPas odpowiada na to pytanie, rozwiązując każdy kod znaku przez osadzony cmap do indeksu glifu, normalizując metrykę programu do 1000 jednostek na em i porównując tam

Dlaczego szerokości glifów się nie zgadzają?

Bo dwie porównywane liczby żyją w różnych układach współrzędnych, a nic w słowniku PDF nie mówi ci, jak je przeliczyć. Słownik fontu zapisuje /Widths w przestrzeni glifów, którą PDF fixuje na jedną tysięczną em (ISO 32000-1 §9.2.4). Tabela hmtx wewnątrz osadzonego programu TrueType zapisuje advance'y w jednostkach projektowych fontu, a tabela head rozstrzyga, ile ich składa się na em: 2048 dla większości krojów TrueType, 1000 dla pochodnych od CFF, czasem coś zupełnie innego. Porównaj surowe wartości, a każdy font 2048-upem w twoim korpusie wygląda na zepsuty. To pułapka, którą ISO 14289-1 §7.21.5 stawia każdemu, kto próbuje audytować szerokości przez czytanie pól słownika

Audyt szerokości glifów PDFlibPas w Delphi: wpis /Widths w przestrzeni glifów i advance z hmtx w jednostkach projektowych fontu trafiają do tego samego układu współrzędnych przez przeskalowanie metryki programu do 1000 jednostek na em, zanim cokolwiek zostanie porównane
PDFlibPas skaluje każdy advance z hmtx do tysięcznej em przed porównaniem go ze szerokością ze słownika i raportuje tylko to, co odstaje o więcej niż jedną jednostkę

PDFlibPas normalizuje przy wczytaniu. TPDFTrueTypeParser zapisuje Advance * 1000 div unitsPerEm w swojej tablicy szerokości, więc Parser.GetWidth(GID) od razu odpowiada w tych samych tysięcznych em, którymi posługuje się PDF, a GetRawWidth zostaje dostępne, gdy potrzebujesz z powrotem jednostek projektowych. Pozostaje trudniejsza połowa: dostanie się od kodu znaku do indeksu glifu. Dla prostego fontu TrueType trasa zależy od flagi Symbolic w FontDescriptor, bitu 3 /Flags

Parser := TPDFTrueTypeParser.Create;
try
  Parser.LoadFromString(FontProgram);
  if Symbolic then
  begin
    // Fonty symboliczne są adresowane bezpośrednio przez cmap programu,
    // z konwencją (3,0) wysokiego bajta jako fallbackiem
    GID := Parser.GetGlyphIndex(Code);
    if GID = 0 then
      GID := Parser.GetGlyphIndex($F000 + Code);
  end
  else
  begin
    // Niesymboliczne: kod -> nazwa glifu przez encoding, nazwa -> Unicode
    // przez Adobe Glyph List, Unicode -> GID przez cmap programu
    UnicodeValue := GetGlyphUnicode(EncodingNames[Code and $FF]);
    if UnicodeValue = 0 then
      Continue;
    GID := Parser.GetGlyphIndex(UnicodeValue);
  end;
  if (GID > 0) and (GID < Parser.GlyphCount) then
    if Abs(PDFWidth - Parser.GetWidth(GID)) > 1 then
      Inc(MismatchCount);
finally
  Parser.Free;
end;

Dwa detale w tym fragmencie mają wagę. Tolerancja to jedna jednostka, nie zero, bo normalizacja to dzielenie całkowite i legalnie wyprodukowany plik może wylądować o jednostkę obok; to dokładnie sformułowanie „w granicach jednej tysięcznej em", które raportuje diagnostyka 10036. A strażnik GID < Parser.GlyphCount nie jest dekoracją. GetWidth jest napisane wyrozumiale dla wywołujących renderujących: przycina indeks poza zakresem do ostatniego wpisu w hmtx i cofa się do 750, gdy tabeli brak. Wyrozumiałość jest dobra dla renderingu i zła dla audytu, więc audyt odrzuca indeks przed pytaniem o szerokość, zamiast ufać przycinaniu

CIDFontType2 dodaje jeszcze jedno pośrednictwo

PDFlibPas przechodzi fonty złożone tak samo, z /CIDToGIDMap wstawionym między CID a glif. Szerokości przychodzą w tablicy /W, którą ISO 32000-1 §9.7.4.3 obdarza dwoma kształtami swobodnie przeplatanymi w jednej tablicy: CID startowy, po którym idzie tablica kolejnych szerokości, albo pierwszy CID, ostatni CID i jedna szerokość zastosowana do całego biegu. Audyt parsuje oba, potem podaje każdą powstałą parę temu samemu porównaniu i raportuje sumę pod diagnostyką 10037. Krok mapowania to miejsce, w którym fonty złożone się różnią, i dlatego diagnostyka 10021 o brakującej mapie ma znaczenie, zanim w ogóle przeczytasz jakąkolwiek szerokość — nieobecna albo zniekształcona /CIDToGIDMap nie tylko łamie §7.21.3.2, ona czyni pytanie o szerokość bez odpowiedzi

Trzy trasy, którymi PDFlibPas w Delphi dochodzi od kodu znaku do indeksu glifu: cmap programu dla symbolicznych fontów TrueType, wypad przez encoding i Adobe Glyph List dla niesymbolicznych oraz krok CMap plus /CIDToGIDMap dla CIDFontType2
Porównanie szerokości nie może się zacząć, dopóki kod znaku nie rozwiąże się do indeksu glifu, a każdy rodzaj fontu dochodzi do tego indeksu inną trasą
// /CIDToGIDMap to nazwa /Identity albo strumień 16-bitowych indeksów glifów
// big-endian, po jednym na CID (ISO 32000-1 sekcja 9.7.4.2)
Obj := DerefIndRef(FDoc, CIDFont.FindValueByKeyName('CIDToGIDMap'));
if (Obj is TPDFName) and (TPDFName(Obj).Name = 'Identity') then
begin
  GID := CID;
  Result := True;
end
else if Obj is TPDFStream then
begin
  Data := TPDFStream(Obj).GetDecodedStream;
  P := CID * 2 + 1;                       // łańcuchy Pascal są indeksowane od 1
  if (P >= 1) and (P + 1 <= Length(Data)) then
  begin
    GID := (Integer(Byte(Data[P])) shl 8) or Integer(Byte(Data[P + 1]));
    Result := True;
  end;
end;

Co powinien zrobić audytor, gdy program fontu nie chce się dekodować?

Nic nie mówić. Kontrole kompletności /CharSet i /CIDSet, których wymaga ISO 14289-1 §7.21.4.2 — diagnostyki 10038 i 10039 — to miejsce, w którym zbyt gorliwy walidator staje się obciążeniem, bo raport „twój CharSet jest niekompletny" jest dla czytającego nieodróżnialny od „nasz dekoder Type 1 się poddał". PDFlibPas raportuje więc brakujący wpis tylko, gdy trzy rzeczy się powiodą: program fontu się dekoduje, mapowanie kodu na glif się rozwiązuje i sam zbiór się dekoduje. TPDFType1Decoder.LoadPFBFromString musi zwrócić True i dać liczbę charstringów, zanim jakakolwiek nazwa glifu zostanie sprawdzona względem łańcucha /CharSet; ścieżka /CIDSet potrzebuje, by strumień się rozpakował, a licznik glifów wrócił dodatni, zanim pojedynczy bit zostanie przetestowany. Jakikolwiek wyjątek po drodze zapada się w „brak ustalenia", nie w defekt

Konserwatywna reguła raportowania w audycie PDF/UA PDFlibPas: brakujący wpis /CharSet albo /CIDSet jest raportowany tylko, gdy program fontu się dekoduje, mapowanie kodu na glif się rozwiązuje i sam zbiór się dekoduje, a każda porażka daje ciszę
Trzy niezależne sukcesy są wymagane, zanim zostanie zgłoszony brakujący wpis, więc dekoder, który się poddaje, kosztuje cię fałszywy negatyw zamiast fałszywego oskarżenia

To celowe wychylenie w stronę fałszywych negatywów i warto powiedzieć to wprost, zamiast zakopywać. Uszkodzona tabela CFF, niewspierany wariant Type 1 albo /CIDSet krótszy niż zakres glifów wszystkie dają ciszę zamiast diagnostyki. Uzasadnienie jest takie, że audyty PDF/UA trafiają dalej do autorów, którzy nie budowali narzędzi, a fałszywe oskarżenie kosztuje więcej niż przeoczone: autor spala dzień na dowodzeniu, że zgodny plik jest zgodny, i przestaje ufać całemu raportowi. Matterhorn Protocol robi to samo rozróżnienie w innej formie, gdy oddziela kontrole, które maszyna może rozstrzygnąć, od kontroli, które musi człowiek, a jego punkt kontrolny Fonts (31) to miejsce, gdzie te mieszkają. Jeśli potrzebujesz ostrzejszego odczytania, użyj PDFlibPas jako szybkiej bramy i dedykowanego walidatora jako drugiej opinii — to ta sama para, którą opisuje przebieg preflightu PDF/A i PDF/UA

/Contents strony to lista, nie strumień

Najdroższy pojedynczy błąd w audycie strumieni treści to traktowanie /Contents jako jednego strumienia. ISO 32000-1 §7.7.3.3 pozwala stronie trzymać tablicę strumieni, których konkatenacja, z białymi znakami między częściami, jest programem strony; producenci tną w dowolnych miejscach, a BT może siedzieć w jednym członie z pasującym ET w następnym. Procesor treści trzyma stan — głębokość zagnieżdżenia marked-content, font wybrany przez ostatnie Tf, flagę obiektu tekstowego — a Process resetuje ten stan przy wejściu. Wywołaj go raz na człon tablicy, a każdy strumień po pierwszym zacznie się bez bieżącego fontu, więc tekst doskonale otagowany czyta się jako szum nieotagowany i bez fontu. PDFlibPas najpierw skleja, potem przetwarza raz

function ContentObjectData(FDoc: TSmartPDFDocument; Obj: TPDFObject): AnsiString;
var
  I: Integer;
begin
  Result := '';
  Obj := DerefIndRef(FDoc, Obj);
  if Obj is TPDFStream then
    Result := TPDFStream(Obj).GetDecodedStream
  else if Obj is TPDFArray then
    for I := 0 to TPDFArray(Obj).Count - 1 do
      Result := Result + ContentObjectData(FDoc, TPDFArray(Obj).Item[I]) + #10;
end;

// Jedno wywołanie Process na całą konkatenację, nigdy jedno na człon
Scanner.Process(ContentObjectData(FDoc, PageDict.FindValueByKeyName('Contents')));

Które Form XObjects faktycznie liczą się jako nieustrukturyzowane?

Tylko te, które strona faktycznie wywołuje, z miejsca wywołania poza marked content, i których własna treść pokazuje tekst. Diagnostyka 10040 egzekwuje ISO 14289-1 §7.20, zapisując trzy niezależne fakty na numer obiektu — ma tekst, był wywołany, był wywołany wewnątrz marked content — i raportując tylko przecięcie dwóch pierwszych minus trzeci. Każdy z dwóch skrótów jest zły w sposób, który byś wysłał: flagowanie każdego Form z tekstem w /Resources karze bibliotekę szablonów, z której nikt nie czerpie, a flagowanie każdego wywołanego Form karze logo wektorowe, które nie niesie tekstu i nie potrzebuje tagowania. Miejsce wywołania jest rozwiązywane po numerze obiektu, nie po nazwie zasobu, bo ten sam Form jest rutynowo osiągany pod różnymi nazwami na różnych stronach. Towarzysząca diagnostyka 10041 przechodzi ten sam sklejony program dla §7.21.8, rozwiązując każdy operand pokazujący tekst przez font w zasięgu i licząc kody, które lądują na .notdef, co jest zabronione niezależnie od trybu renderowania tekstu — włącznie z trybem niewidzialnym używanym za skanowanymi obrazami. Jak owinąć przeżywsze Formy, to pytanie o drzewo struktury, pokryte w artykule o budowaniu struktury oznaczonego PDF

Fonty bez żadnego FontDescriptor

Nieosadzony font to legalne wejście do tego audytu, nie stan błędu, i każdy pomocnik poniżej kontroli osadzenia musi go przetrwać. Gdy PDFlibPas nie znajduje /FontDescriptor albo deskryptora bez FontFile, FontFile2 czy FontFile3, zapisuje diagnostykę 10020 — albo 10022, gdy nazwa to jeden ze Standard 14, których §7.21.4 NOTE 5 wymownie odmawia zwolnienia — i potem idzie dalej przez resztę pliku. W tym cały sens raportu: autor chce wszystkie ustalenia w jednym przebiegu, nie jedno ustalenie na uruchomienie. Referencja deskryptora podawana pomocnikom szerokości, cmap, CharSet i CIDSet może więc być Nil, a każdy z nich testuje to na wejściu, zamiast zakładać, że wcześniejsza kontrola przerwała audyt. Jeśli poprawką jest osadzenie tego, czego brakuje, mechanika jest w nocie o osadzaniu brakujących fontów w istniejącym PDF

Uruchomienie audytu

Jedno wywołanie, na pliku, którego niekoniecznie sam wyprodukowałeś. TPDFlib.CheckFileCompliance przyjmuje selektor testu zgodności — 2 dla PDF/UA-1 pod ISO 14289-1:2014 — i zwraca zero albo uchwyt listy łańcuchów, którego wpisy to kod numeryczny, dwukropek i czytelny komunikat. Ustalenia dotyczące fontów i strumieni treści omawiane tutaj zajmują 10020 do 10041 w tym zakresie, trzymane liczbowo osobno od kodów 00xxx PDF/A, żeby mieszany log pozostawał czytelny. Przekazanie 1 w Options ucina na pierwszym ustaleniu, czego chcesz w bramie builda, a nie w narzędziu autorskim. Dla dokumentu wciąż otwartego w pamięci GetPDFUADiagnostics robi równoważną inspekcję bez objazdu przez dysk

var
  Issues, Count, I: Integer;
begin
  // ComplianceTest = 2 wybiera PDF/UA-1; Options = 0 raportuje każdy finding
  Issues := PDF.CheckFileCompliance('delivery.pdf', '', 2, 0);
  if Issues = 0 then
    WriteLn('delivery.pdf: PDF/UA-1 conformant')
  else
  begin
    Count := PDF.GetStringListCount(Issues);
    for I := 1 to Count do
      WriteLn('  ', PDF.GetStringListItem(Issues, I));   // np. 10037 CIDFontType2 ...
  end;
end;

Żadne z tego nie potrzebuje zewnętrznego binarium walidatora na maszynie, co jest różnicą między kontrolą, która działa przy każdym buildzie, a kontrolą, która działa, gdy ktoś sobie przypomni. API zgodności i diagnostyki opisane tutaj są w standardowej PDFlibPas Delphi PDF Library, której strona produktowa niesie pełną tabelę kodów diagnostycznych dla PDF/UA-1 obok suit testowych PDF/A, PDF/X i PDF/E