Technický článek

Audit písem PDF/UA v Delphi: šířky, CharSet, CIDSet

veraPDF hlášení, že šířka glyfu nesouhlasí s vloženým fontovým programem, vám skoro nic neřekne o tom, který glyf to je, ani proč. PDFlibPas na tu otázku odpovídá tak, že každý znakový kód převádí přes vložený cmap na index glyfu, normalizuje metriku programu na 1000 jednotek na em a porovnává tam

Proč se šířky glyfů neshodují?

Protože obě porovnávaná čísla žijí v různých souřadných systémech a nic ve slovníku PDF vám konverzi neřekne. Fontový slovník zapisuje /Widths v prostoru glyfů, který PDF pevně stanovuje na tisícinu em (ISO 32000-1 §9.2.4). Tabulka hmtx uvnitř vloženého TrueType programu zapisuje posuny v designových jednotkách písma a tabulka head rozhoduje, kolik jich tvoří jedno em: 2048 u většiny TrueType písem, 1000 u písem odvozených od CFF, občas úplně něco jiného. Porovnáte-li syrové hodnoty, vypadá ve vašem korpusu každé písmo s 2048 upem jako rozbité. To je past, kterou ISO 14289-1 §7.21.5 staví komukoli, kdo se pokouší auditovat šířky čtením polí slovníku

Audit šířek glyfů PDFlibPas v Delphi: položka /Widths v prostoru glyfů a posun hmtx v designových jednotkách písma se uvedou do téhož souřadného systému škálováním metriky programu na 1000 jednotek na em, než dojde k porovnání
PDFlibPas přeškáluje každý posun hmtx na tisícinu em, než ho porovná se šířkou ze slovníku, a hlásí jen to, co se liší o více než jednotku

PDFlibPas normalizuje hned při načtení. TPDFTrueTypeParser ukládá do pole šířek hodnotu Advance * 1000 div unitsPerEm, takže Parser.GetWidth(GID) už odpovídá ve stejných tisícinách em, jaké používá PDF, a GetRawWidth zůstává k dispozici, když potřebujete designové jednotky zpět. Tím zůstává těžší polovina: dostat se od znakového kódu k indexu glyfu. U jednoduchého TrueType písma závisí cesta na příznaku Symbolic ve FontDescriptor, bitu 3 /Flags

Parser := TPDFTrueTypeParser.Create;
try
  Parser.LoadFromString(FontProgram);
  if Symbolic then
  begin
    // Symbolická písma se adresují přímo přes cmap programu,
    // s konvencí vysokého bajtu (3,0) jako zálohou
    GID := Parser.GetGlyphIndex(Code);
    if GID = 0 then
      GID := Parser.GetGlyphIndex($F000 + Code);
  end
  else
  begin
    // Nesymbolická: kód -> název glyfu přes kódování, název -> Unicode
    // přes Adobe Glyph List, Unicode -> GID přes 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;

Dva detaily v tomto úryvku mají váhu. Tolerance je jedna jednotka, ne nula, protože normalizace je celočíselné dělení a korektně vyrobený soubor může o jednotku ulétnout; přesně to je formulace „do tisíciny em", kterou hlásí diagnostika 10036. A hlídka GID < Parser.GlyphCount není ozdoba. GetWidth je psáno shovívavě pro volající z renderování: index mimo rozsah zalamuje na poslední položku hmtx a při chybějící tabulce sahá po 750. Shovívavost se hodí při vykreslování a škodí při auditu, takže audit index odmítá dřív, než si o šířku řekne, místo aby se spoléhal na zalamování

CIDFontType2 přidává jedno zprostředkování navíc

Kompozitními písmy prochází PDFlibPas stejně, jen mezi CID a glyf vkládá /CIDToGIDMap. Šířky přicházejí v poli /W, kterému ISO 32000-1 §9.7.4.3 dává dva tvary volně se střídající v jednom poli: počáteční CID následovaný polem po sobě jdoucích šířek, nebo první CID, poslední CID a jediná šířka platná pro celé rozpětí. Audit parsuje obojí, každou vzniklou dvojici předává témuž srovnání a celkové součty hlásí pod diagnostikou 10037. Krok mapování je místo, kde se kompozitní písma liší, a proto je diagnostika chybějící mapy 10021 důležitá dřív, než začtete jakoukoli šířku — chybějící nebo poškozená /CIDToGIDMap neporušuje jen §7.21.3.2, ale činí otázku po šířce nezodpověditelnou

Tři cesty, kterými se PDFlibPas v Delphi dostane od znakového kódu k indexu glyfu: cmap programu pro symbolická TrueType písma, objížďka přes kódování a Adobe Glyph List pro nesymbolická a krok CMap plus /CIDToGIDMap pro CIDFontType2
Porovnávání šířek nemůže začít, dokud se znakový kód nerozliší na index glyfu, a každý druh písma vede k tomu indexu jinou cestou
// /CIDToGIDMap je jméno /Identity nebo proud velkobajtových 16bitových
// indexů glyfů, jeden na CID (ISO 32000-1 oddíl 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;                       // řetězce v Pascalu indexují 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 má auditor dělat, když se fontový program nedekóduje?

Neříkat nic. Kontroly úplnosti /CharSet a /CIDSet, které vyžaduje ISO 14289-1 §7.21.4.2 — diagnostiky 10038 a 10039 — jsou místo, kde se z horlivého validátoru stává zátěž, protože hlášení „váš CharSet není úplný" rozezná čtenář od „náš dekodér Type 1 to vzdal" jen stěží. Proto PDFlibPas hlásí chybějící položku jen tehdy, když se podaří tři věci najednou: dekóduje se fontový program, rozliší se mapování kódu na glyf a dekóduje se samotná množina. TPDFType1Decoder.LoadPFBFromString musí vrátit True a vynést počet charstringů, než se jakýkoli název glyfu porovná s řetězcem /CharSet; cesta /CIDSet potřebuje, aby se proud rozbalil a počet glyfů vrátil kladný, než se otestuje jediný bit. Jakákoli výjimka na cestě se zredukuje na „bez nálezu", ne na závadu

Konzervativní pravidlo hlášení v auditu PDF/UA od PDFlibPas: chybějící položku /CharSet nebo /CIDSet hlásí jen tehdy, když se dekóduje fontový program, rozliší mapování kódu na glyf a dekóduje se samotná množina; jakékoli selhání zůstane tiché
Tři nezávislé úspěchy musejí nastat, než se vydá nález o chybějící položce, takže dekodér, který se vzdá, vás stojí falešný negativ, ne křivé obvinění

To je vědomá preference falešných negativů a stojí za to ji vyslovit nahlas, ne zahrabat. Poškozená tabulka CFF, nepodporovaná varianta Type 1 nebo /CIDSet kratší, než je rozsah glyfů, všechny zaniknou v tichu místo v diagnostice. Úvaha je taková: audity PDF/UA putují k autorům, kteří nástroje nestavěli, a křivé obvinění stojí víc než propásnutý nález — autor spálí den tím, že dokazuje, že vyhovující soubor vyhovuje, a přestane věřit celému hlášení. Matterhorn Protocol dělá totéž rozlišení v jiné podobě, když odděluje kontroly, o kterých může rozhodnout stroj, od těch, které musí rozhodnout člověk, a jeho kontrolní bod Fonts (31) je místo, kde tyto kontroly bydlí. Kdyby se vám hodilo přísnější čtení, pusťte PDFlibPas jako rychlou bránu a specializovaný validátor jako druhý posudek — ta dvojice je ta samá, jakou popisuje průchod preflightem PDF/A a PDF/UA

Stránkový /Contents je seznam, ne proud

Nejdražší chyba při auditu proudů obsahu je brát /Contents jako jeden proud. ISO 32000-1 §7.7.3.3 umožňuje stránce držet pole proudů, jejichž spojení, s mezerou mezi částmi, je program stránky; producenti je dělí v libovolných bodech a BT může sedět v jednom členu, zatímco jeho ET v dalším. Procesor obsahu si drží stav — hloubku zanoření označeného obsahu, písmo vybrané posledním Tf, příznak textového objektu — a Process ten stav při vstupu vynuluje. Zavoláte-li ho pro každý člen pole zvlášť, každý proud po prvním startuje bez aktuálního písma, takže dokonale otagovaný text se čte jako neoznačený šum bez písma. PDFlibPas spojí nejdřív a zpracuje jednou

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 volání Process přes celé spojení, nikdy jedno volání na člen
Scanner.Process(ContentObjectData(FDoc, PageDict.FindValueByKeyName('Contents')));

Které Form XObject se skutečně počítají jako nestrukturované?

Jen ty, které stránka skutečně vyvolá, z místa volání mimo označený obsah, jejichž vlastní obsah ukazuje text. Diagnostika 10040 vymáhá ISO 14289-1 §7.20 tím, že si pro každé číslo objektu zapisuje tři nezávislé fakty — má text, byl vyvolán, byl vyvolán uvnitř označeného obsahu — a hlásí jen průnik prvních dvou mínus třetí. Každá z těch dvou zkratek je špatná způsobem, který byste expedovali: označovat každý Form nesoucí text v /Resources trestá šablonovou knihovnu, z níž nikdo nekreslí, a označovat každý vyvolaný Form trestá vektorová loga bez textu, která žádné značkování nepotřebují. Místo vyvolání se rozlišuje podle čísla objektu, nikoli podle názvu zdroje, protože tentýž Form se běžně dostupuje pod různými názvy na různých stránkách. Sesterská diagnostika 10041 projde tímže spojeným programem pro §7.21.8, rozliší každý operand ukazující text přes písmo v rozsahu a počítá kódy, které dopadnou na .notdef, což je zakázané bez ohledu na režim vykreslování textu — včetně neviditelného režimu používaného za naskenovanými obrázky. Jak by přeživší Formy měly být zabaleny, je otázka stromu struktur, kterému se věnuje článek o stavbě tagovaného PDF

Písma bez jakéhokoli FontDescriptor

Nevložené písmo je legitimní vstup tohoto auditu, ne chybový stav, a každý pomocník pod kontrolou vložení ho musí přežít. Když PDFlibPas nenajde /FontDescriptor, nebo najde deskriptor bez FontFile, FontFile2 a FontFile3, zaznamená diagnostiku 10020 — respektive 10022, když jméno patří mezi Standard 14, které §7.21.4 NOTE 5 vyhraněně odmítá prominout — a pak pokračuje zbytkem souboru. V tom je celý smysl hlášení: autor chce všechny nálezy v jednom průchodu, ne jeden nález za běh. Deskriptorová reference, kterou dostávají pomocníci pro šířky, cmap, CharSet a CIDSet, proto může být Nil a každý z nich ji testuje na vstupu, místo aby předpokládal, že dřívější kontrola audit přerušila. Když je opravou vložit to, co chybí, mechanismy popisuje poznámka o vkládání chybějících písem do existujícího PDF

Spuštění auditu

Jedno volání, na souboru, který jste nemuseli vyrábět vy. TPDFlib.CheckFileCompliance přijímá volič testu shody — 2 pro PDF/UA-1 podle ISO 14289-1:2014 — a vrací buď nulu, nebo handle seznamu řetězců, jehož položky jsou číselný kód, dvojtečka a čitelné hlášení. Nálezy o písmech a proudech obsahu zde popsané zabírají v tom rozsahu 10020 až 10041, numericky oddělené od kódů PDF/A 00xxx, aby smíšený log zůstal čitelný. Hodnota 1 v Options zkratuje na prvním nálezu, což se hodí v build bráně spíš než v autorském nástroji. Pro dokument ještě otevřený v paměti provede GetPDFUADiagnostics rovnocennou prohlídku bez objížďky přes disk

var
  Issues, Count, I: Integer;
begin
  // ComplianceTest = 2 vybírá PDF/UA-1; Options = 0 hlásí každý nález
  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));   // např. 10037 CIDFontType2 ...
  end;
end;

Nic z toho nepotřebuje na stroji binární externí validátor, a právě v tom je rozdíl mezi kontrolou, která běží při každém buildu, a kontrolou, která běží, až si to někdo vzpomene. Compliance a diagnostická API popsaná zde putují ve standardní PDFlibPas Delphi PDF Library, jejíž produktová stránka nese úplnou tabulku diagnostických kódů pro PDF/UA-1 spolu se sadami testů PDF/A, PDF/X a PDF/E