Technický článek

PDFlibPas: automatický fallback písem pro CJK a emoji

PDFlibPas řeší znaky, které zvolené písmo neumí vykreslit, tak, že prohledává řetězec náhradních (fallback) písem nainstalovaných v systému, a to po jednotlivých clusterech, přičemž zachovává tvarování textu i pořadí obousměrných běhů. Funkci zapnete metodou SetAutomaticFontFallback, řetězec rozšíříte metodou AddFontFallback a do souboru se vloží jen ta náhradní písma, která byla pro výstup skutečně použita

Řeší problém, na který narazí každý generátor dokumentů ve chvíli, kdy poprvé dorazí jméno zákazníka v písmu, se kterým šablona vůbec nepočítala. Selhání je tiché, a právě proto je drahé

Proč nepodporovaný text zmizí, místo aby vyvolal chybu?

Protože formát PDF vůbec nezná pojem písma, které neumí vykreslit daný znak. Jednoduché písmo mapuje bajtové kódy na názvy glyfů prostřednictvím kódování; složené písmo mapuje kódy přes CMap na indexy glyfů. Když si vyžádáte glyf, který dané písmo neobsahuje, dostanete index glyfu nula, .notdef, který většina písem vykreslí jako nic nebo jako prázdný obdélník. Soubor je strukturálně platný, textový operátor je správně zapsaný a stránka se vykreslí. Jen tam, kde mělo být jméno, zůstane prázdno

Norma ISO 32000-1 po producentovi nijak nevyžaduje, aby si toho všiml. Generátor, který zapisuje text bez kontroly pokrytí, vyprodukuje technicky vyhovující PDF, jež tiše ztratilo obsah, a ztráta se projeví na obrazovce zákazníka až o týdny později. Právě proto se funkce fallbacku a report chybějících glyfů dodávají společně: vyřešit to, co vyřešit lze, je jen polovina úkolu, druhou polovinou je nahlásit to, co vyřešit nešlo

Fallback probíhá po clusterech, ne po jednotlivých bodech kódu

Granularita je právě ten detail, který odlišuje funkční implementaci od jen zdánlivě funkční. Text není posloupnost nezávislých znaků. Slabika v dévanágarí, emoji s modifikátorem odstínu pleti, základní písmeno s kombinujícími znaménky – to všechno jsou jednotlivé clustery, které musí vykreslit jedno jediné písmo, protože rozhodnutí o tvarování uvnitř clusteru závisí na tabulkách právě v tomto písmu

PDFlibPas řeší celé clustery, takže cluster pokrytý náhradním písmem vykreslí od začátku do konce právě to písmo. Rozdělení uprostřed clusteru, kdy by se polovina vykreslila primárním písmem a polovina náhradním, by sice technicky poskytlo obsah, ale viditelně rozbitý, což je vlastně horší než prázdné místo, od kterého jste vyšli. Zachovává se i pořadí běhů, takže fallback uvnitř textu psaného zprava doleva okolní text nepřehodí; stejný mechanismus stojí i za svislým rozvržením popsaným v článku o svislém psaní pro japonštinu a čínštinu

var
  Lib: TPDFlib;
begin
  Lib := TPDFlib.Create;
  try
    Lib.SetOrigin(1);
    Lib.SetAutomaticFontFallback(1);

    // Pořadí hledání: vyhrává první shoda, proto dejte nejobecnější písma na konec
    Lib.AddFontFallback('Microsoft YaHei');   // zjednodušená čínština
    Lib.AddFontFallback('Meiryo');            // japonština
    Lib.AddFontFallback('Segoe UI Symbol');
    Lib.AddFontFallback('Segoe UI Emoji');

    Lib.SetMissingGlyphPolicy(PDF_MISSING_GLYPH_REPORT);

    Lib.AddTrueTypeFont('Arial', 1);          // 1 = vložit písmo do souboru
    Lib.SetTextSize(11);
    Lib.DrawText(72, 720, 'Invoice for 北京示例科技有限公司');
    Lib.DrawText(72, 700, 'Delivery status: on time');

    Lib.SaveToFile('invoice.pdf');
  finally
    Lib.Free;
  end;
end;

Pořadí v řetězci volte záměrně. Řešení bere první písmo, které cluster pokrývá, takže široké pan-unikódové písmo umístěné na první místo vyhraje téměř všechno a vaše pečlivě vybraná písma pro konkrétní skripty nikdy nepřijdou na řadu. Konkrétní písma dejte na začátek a to univerzální až na konec

Report, nebo přerušení: jaký typ selhání chcete?

SetMissingGlyphPolicy přijímá buď PDF_MISSING_GLYPH_REPORT, což je kompatibilní výchozí hodnota, nebo PDF_MISSING_GLYPH_ABORT. Při politice reportu operace s textem proběhne, nevyřešitelné body kódu se jako dřív vynechají a každý z nich se zaznamená. Při politice přerušení je operace s textem odmítnuta ještě předtím, než se zapíše jakýkoli obsah, a LastErrorCode se nastaví na 521

Volbu odvíjejte od účelu dokumentu. Dávka interních reportů by měla vykreslování dokončit a mezery jen zalogovat, protože dnes mírně neúplný report je lepší než žádný. Právně závazná smlouva, faktura nebo cokoli, kde figuruje jméno, by naopak mělo skončit přerušením, protože tiše vynechaný znak ve jméně strany je vada, kterou chcete odhalit ve vlastním procesu, ne až ve sporu. Politika přerušení selže ještě před zápisem, takže po sobě nezanechá žádný napůl vytvořený obsahový proud

var
  Lib: TPDFlib;
  Report: WideString;
begin
  Lib := TPDFlib.Create;
  try
    Lib.SetMissingGlyphPolicy(PDF_MISSING_GLYPH_ABORT);
    // ... sestavení dokumentu ...

    if Lib.DrawText(72, 660, CustomerName) <> 1 then
      if Lib.LastErrorCode = PDFLIB_ERROR_MISSING_GLYPH then
      begin
        Report := Lib.GetMissingGlyphReportJSON;
        // {"valid":false,"policy":1,"eventCount":1,"events":[
        //   {"sequence":1,"documentIndex":0,"page":1,"utf16Index":12,
        //    "codePoint":21271,"unicode":"U+5317","fontName":"Arial",
        //    "fontType":"TrueType","operation":"DrawText"}]}
        EscalateToOperator(Report);
      end;
  finally
    Lib.Free;
  end;
end;

Report je záměrně strojově čitelný a omezený. Každá událost nese stránku, index UTF-16 uvnitř řetězce, bod kódu v číselné podobě i ve tvaru U+XXXX, vybrané písmo, jeho typ a operaci, při které nastal problém, takže tiket podpory může pojmenovat přesný znak místo popisu příznaku. Sledovač uchovává posledních 256 událostí, což stačí k diagnóze dokumentu a zároveň je to dost málo na to, aby patologický běh nezměnil diagnostiku v problém s pamětí

Měření a kreslení se musí shodovat

Měření šířky používá stejná rozhodnutí o fallbacku po clusterech jako samotné kreslení. Zní to samozřejmě, a přesto je to přesně to, co většina domácích implementací fallbacku zpackává: opraví se jen cesta pro kreslení, měření zůstane na primárním písmu, a každý textový box, zarovnání doprava i sloupec tabulky se pak počítá z šířek, které neodpovídají tomu, co se skutečně vykreslilo

Protože obě cesty sdílejí stejné řešení, řetězec změřený před vykreslením zabere přesně tu šířku, na kterou byl změřen, včetně úseků řešených fallbackem. Právě to dělá z fallbacku funkci, kterou lze bezpečně zapnout globálně, a ne jen na místech, která jste ručně prověřili

Vloží se jen to, co jste opravdu použili

Náhradní písma se vkládají líně: písmo v řetězci, které nikdy nevyřešilo žádný cluster, do výstupu nepřispěje ničím. Dokument obsahující jeden čínský znak a 5000 latinských znaků nepotáhne za sebou celé CJK písmo; ponese jen to, co pro daný jeden glyf vyprodukoval proces vytváření podmnožin, což je chování popsané v článku o optimalizaci velikosti souboru a vytváření podmnožin písem

Právě tato lenost dělá z rozsáhlého řetězce levnou věc na konfiguraci. Zaregistrujte písma, která vaše sada dokumentů může potřebovat napříč všemi lokalizacemi, které obsluhujete, a každé jednotlivé PDF zaplatí jen za to, co skutečně použilo. U dokumentů, které jste sami nevygenerovali a kde chybějící písma už jsou uvnitř existujícího souboru, je postup opravy jiný a popisuje ho článek o vkládání chybějících písem do existujícího PDF

Jedno provozní upozornění stojí za to říct otevřeně: fallback řeší proti písmům nainstalovaným na stroji, kde kód běží. Server bez nainstalovaných CJK písem nemá na co se fallbackovat, a report vám to sdělí už u prvního dokumentu, ne až po první stížnosti. Písma, na kterých závisíte, si dodejte sami a ověřte si licenční podmínky pro jejich vkládání

PDFlibPas je knihovna pro práci s PDF pro Delphi, C++Builder a Lazarus s odpovídajícím rozhraním DLL a ActiveX, takže API pro fallback i pro chybějící glyfy je dostupné i pro volající mimo Pascal. Úplná dokumentace je na stránce PDFlibPas Delphi PDF library