Artykuł techniczny

CID-keyed CFF w PDFlibPas: charset zgodny z indeksami glifów

Chińska faktura złożona krojem Noto Sans SC z jego pliku .otf potrafi otworzyć się w mutool z obcymi ideogramami tam, gdzie powinna być nazwa klienta, choć krój TrueType renderuje tę samą stronę poprawnie. Winne jest charset CFF fontu; PDFlibPas przepisuje go w osadzonej kopii na identity, więc kody będące indeksami glifów trafiają na właściwe glify

Czy font pokaże ten problem, zależy od tego, jak został zbudowany. Program TrueType osadzony jako font CIDFontType2 dostaje CIDToGIDMap o wartości Identity i kod ze strumienia zawartości trafia na właściwy glif. Większość łacińskich fontów OpenType z zarysami CFF to programy name-keyed, a program CFF name-keyed używa przychodzącego CID wprost jako indeksu glifu. ISO 32000-1 w §9.7.4.2 wprost zauważa, że CID i GID pokrywają się w wielu fontach. Nie we wszystkich. Duże rodziny CJK zbudowane jako fonty CID-keyed — wśród nich Noto Sans CJK i spokrewnione z nim Source Han — niosą własną numerację CID, i writer, który wysyła indeksy glifów prosto w tę numerację, dostaje dowolny glif, akurat dzierżący pasujący CID

Co program CID-keyed CFF robi z CID

ISO 32000-1 §9.7.4.2 dzieli osadzone programy CFF na dwa przypadki, patrząc na Top DICT. Jeśli Top DICT używa operatorów CIDFont — w praktyce otwiera się operatorem ROS (operator 12 30: registry, ordering, supplement) — czytnik bierze CID ze strumienia zawartości, znajduje glif, którego wpis w charset niesie ten CID, i dopiero wtedy pobiera zarys z INDEX-u CharStrings. Bez operatorów CIDFont CID jest sam w sobie indeksem glifu, a charset nie odgrywa żadnej roli. Każdy czytnik stosujący §9.7.4.2 dosłownie narysuje więc zły glif dla kodu będącego indeksem glifu, ilekroć charset nie jest identity — na jakąkolwiek przeglądarkę się przestawisz. Sam charset, zdefiniowany w Adobe Technical Note #5176, występuje w trzech układach i ma dwa przypadki specjalne:

Osadzanie fontów CJK w Delphi przez PDFlibPas: wyszukiwanie po charset CID-keyed CFF według ISO 32000-1 §9.7.4.2 rysujące zły glif dla kodów będących indeksami glifów, dopóki osadzony charset nie jest identity
Kody będące indeksami glifów trafiają w foncie CID-keyed CFF na właściwy zarys tylko wtedy, gdy charset mapuje każdy glif na CID o tym samym numerze
  • Format 0 przechowuje jeden 2-bajtowy SID lub CID na glif, w kolejności glifów
  • Format 1 przechowuje zakresy: pierwszy CID, za nim 1-bajtowa liczba glifów następujących po nim z kolejnymi CID-ami
  • Format 2 to ten sam układ zakresów z 2-bajtową liczbą, co pasuje fontom o długich nieprzerwanych ciągach
  • Glif 0 to .notdef i nie ma wpisu w żadnym z formatów; offset charset 0, 1 albo 2 oznacza jeden z predefiniowanych charsetów zamiast wskazywać dane

Zacznij więc od ustalenia, z jakim typem fontu masz do czynienia. Identity-H po stronie PDF mówi tylko tyle, że kod 2-bajtowy równa się CID. Jak ten CID dochodzi do glifu, rozstrzyga się całkowicie wewnątrz programu fontu. Poniższa funkcja wyczytuje tabelę CFF z pliku .otf, przechodzi przez Top DICT w poszukiwaniu ROS i sprawdza, czy charset mapuje każdy glif na CID o tym samym numerze. Potrzebuje wyłącznie RTL

uses
  System.SysUtils, System.IOUtils;

type
  TCFFCharsetReport = record
    IsCFF, CIDKeyed, IdentityCharset: Boolean;
    CharsetFormat, GlyphCount: Integer;
  end;

{$RANGECHECKS ON}  // ucięty plik podniesie ERangeError zamiast błędnie odczytać dane
function InspectOtfCharset(const FileName: string): TCFFCharsetReport;
var
  B: TBytes;
  NumTables, I, Cff, P, Dict, DictEnd, Unused1, Unused2: Integer;
  Op, Operand, CharsetOfs, CharStringsOfs, G, Left: Integer;

  function BE(Pos, Count: Integer): Integer;
  var
    K: Integer;
  begin
    Result := 0;
    for K := 0 to Count - 1 do
      Result := (Result shl 8) or B[Pos + K];
  end;

  // Zwraca koniec INDEX-u spod Pos i wyznacza granice jego pierwszego obiektu
  function SkipIndex(Pos: Integer; out ObjStart, ObjEnd: Integer): Integer;
  var
    Count, OffSize, Base: Integer;
  begin
    Count := BE(Pos, 2);
    if Count = 0 then
    begin
      ObjStart := Pos + 2;
      ObjEnd := ObjStart;
      Exit(Pos + 2);
    end;
    OffSize := B[Pos + 2];
    Base := Pos + 2 + (Count + 1) * OffSize;  // offsety liczone są od 1 od tego miejsca
    ObjStart := Base + BE(Pos + 3, OffSize);
    ObjEnd := Base + BE(Pos + 3 + OffSize, OffSize);
    Result := Base + BE(Pos + 3 + Count * OffSize, OffSize);
  end;

begin
  Result := Default(TCFFCharsetReport);
  Result.CharsetFormat := -1;
  B := TFile.ReadAllBytes(FileName);
  if (Length(B) < 12) or (BE(0, 4) <> $4F54544F) then  // 'OTTO'
    Exit;
  Cff := -1;
  NumTables := BE(4, 2);
  for I := 0 to NumTables - 1 do
    if BE(12 + I * 16, 4) = $43464620 then               // 'CFF '
      Cff := BE(12 + I * 16 + 8, 4);
  if Cff < 0 then
    Exit;
  Result.IsCFF := True;
  P := SkipIndex(Cff + B[Cff + 2], Unused1, Unused2);    // Name INDEX
  SkipIndex(P, Dict, DictEnd);                           // Top DICT INDEX
  Operand := 0;
  CharsetOfs := 0;
  CharStringsOfs := 0;
  P := Dict;
  while P < DictEnd do
  begin
    Op := B[P];
    case Op of
      12:
        begin
          if B[P + 1] = 30 then
            Result.CIDKeyed := True;                     // ROS
          Inc(P, 2);
        end;
      0..11, 13..21:
        begin
          if Op = 15 then
            CharsetOfs := Operand
          else if Op = 17 then
            CharStringsOfs := Operand;
          Inc(P);
        end;
      28: begin Operand := SmallInt(BE(P + 1, 2)); Inc(P, 3); end;
      29: begin Operand := BE(P + 1, 4); Inc(P, 5); end;
      30:
        begin                                            // liczba rzeczywista
          repeat
            Inc(P);
          until ((B[P] and $F) = $F) or ((B[P] shr 4) = $F);
          Inc(P);
        end;
      32..246: begin Operand := Op - 139; Inc(P); end;
      247..250: begin Operand := (Op - 247) * 256 + B[P + 1] + 108; Inc(P, 2); end;
      251..254: begin Operand := -(Op - 251) * 256 - B[P + 1] - 108; Inc(P, 2); end;
    else
      Exit;                                              // bajt zarezerwowany
    end;
  end;
  if (not Result.CIDKeyed) or (CharsetOfs <= 2) or (CharStringsOfs <= 0) then
    Exit;
  Result.GlyphCount := BE(Cff + CharStringsOfs, 2);
  P := Cff + CharsetOfs;
  Result.CharsetFormat := B[P];
  if Result.CharsetFormat > 2 then
    Exit;
  Inc(P);
  Result.IdentityCharset := True;
  G := 1;                                                // .notdef nie ma wpisu
  while G < Result.GlyphCount do
  begin
    if BE(P, 2) <> G then
      Result.IdentityCharset := False;
    case Result.CharsetFormat of
      0: Left := 0;
      1: Left := B[P + 2];
    else
      Left := BE(P + 2, 2);
    end;
    Inc(P, Result.CharsetFormat + 2);
    Inc(G, Left + 1);
  end;
end;

Raport z CIDKeyed = True i IdentityCharset = False opisuje dokładnie ten font, o którym jest ten artykuł: każdy writer, który wrzuca indeksy glifów do strumienia zawartości bez uwzględnienia tego charsetu, narysuje nim złe glify. Wynik name-keyed albo font CID-keyed, którego charset akurat już jest identity, renderuje się poprawnie u tego samego writera. Dlatego jeden krój CJK w produkcie działa, a następny nie. Pętla porównuje wyłącznie pierwszy CID każdego zakresu. To wystarcza: zakres obejmuje kolejne glify niosące kolejne CID-y, więc zakres startujący na własnym indeksie glifu pozostaje na nim aż do końca

Indeksy glifów przechodzą przez cały słownik fontu

Writer PDF rzadko ma CID-y w ręku. Tabela cmap i silnik shapingu zwracają oba indeksy glifów, więc naturalny projekt zapisuje te indeksy jako 2-bajtowe kody Identity-H i kluczuje na nich wszystko pozostałe: tablicę szerokości /W, bitmapę /CIDSet, o którą prosi PDF/A, oraz CMap ToUnicode, dzięki której ekstrakcja odzyskuje tekst źródłowy. TrueType ma tu furtkę. Tabela 117 oferuje CIDToGIDMap, ale tylko dla CIDFonts typu 2; program CFF mieszka w foncie CIDFontType0, który nie ma żadnego sposobu, by przekierować CID po stronie PDF. To wyklucza przy okazji trik, jaki niektórzy writerzy stosują z TrueType — drugi "aliasowy" CID dla ponownie użytego glifu, poza końcem fontu, żeby ToUnicode mogło przypiąć inny tekst do każdego użycia. W foncie CFF taki alias nie wybiera żadnego glifu, a mutool zgłasza, że nie da się go zrenderować. Fonty mapujące U+4E00 i U+2F00 (KANGXI RADICAL ONE) na jeden zarys to przypadek typowy. Jeśli twój dokument potrzebuje, by te dwa znaki ekstrahowały się różnie, owiń dane wystąpienie w span marked-content z /ActualText (ISO 32000-1 §14.9.4), którego czytnik użyje zamiast mapowania fontu

Przepisywanie charsetu w miejscu

Są dwa sposoby pogodzenia kodów będących indeksami glifów z programem CID-keyed. Pierwszy tłumaczy każdy indeks glifu na CID fontu przed zapisaniem, co dotyka kodów strumienia zawartości, kluczy tablicy /W, pozycji bitów w /CIDSet, kodów źródłowych w ToUnicode i każdego subsetu powstającego przy zapisie. Drugi zmienia tę jedną niezgodną strukturę, czyli charset, tak że font odpowiada na indeksy glifów tak, jak odpowiadałby na nie program name-keyed. Druga droga jest bezpieczniejsza. Shaper, tabela szerokości, subseter i builder ToUnicode dalej pracują w przestrzeni glifów, a jedyny kod, który musi rozumieć CID-y, to krótka procedura uruchamiana raz na osadzonej kopii fontu. PDFlibPas wybiera właśnie tę drogę

Przepisanie zachowuje format charsetu i jego długość w bajtach. W formacie 0 każdy wpis jest nadpisywany własnym indeksem glifu. W formatach 1 i 2 zmienia się tylko pierwszy CID każdego zakresu, a liczby zostają bez zmian. Nic nie rośnie ani nie maleje. Offsety Top DICT do CharStrings, Private DICT-ów, FDArray i FDSelect pozostają ważne bez rekodowania czegokolwiek, a FDSelect — który przydziela słowniki hintingu według indeksu glifu — trzyma każdy glif w jego pierwotnym FD. Programy name-keyed, predefiniowane charsety, tabele CFF2 (te nie mają charsetu) i programy, które nie dają się sparsować, zostają nietknięte. Przepisanie uruchamiają trzy ścieżki kodu:

PDFlibPas przepisuje charset CID-keyed CFF na identity w miejscu, zachowując długości bajtowe formatów 0, 1 i 2, więc offsety Top DICT pozostają ważne dla AddTrueTypeFontFromFile, subsetów i DrawOpenTypeText
PDFlibPas nadpisuje wyłącznie CID-y wewnątrz charsetu osadzonej kopii CFF, zachowując jego format i długość, a żaden offset Top DICT nie musi się przesunąć
  • AddTrueTypeFontFromFile na pliku .otf osadza gołą tabelę CFF jako FontFile3 z podtypem CIDFontType0C, z charsetem już przepisanym
  • Subset tworzony przy zapisie zachowuje oryginalne indeksy glifów, ale i źródłowe CID-y, więc jego charset jest przepisywany drugi raz
  • Towarzysz shapingu stojący za DrawOpenTypeText i DrawShapedText trzyma cały sfnt; po przepisaniu suma kontrolna tabeli CFF jest liczona od nowa, a checkSumAdjustment tabeli head przyjmuje wartość 0xB1B0AFBA minus suma pliku
  • Ta korekta jest zdefiniowana wyłącznie dla pojedynczego fontu, więc krój wyjęty z kolekcji dostaje samą sumę kontrolną tabeli
uses
  SysUtils, PDFlibrary;

var
  Lib: TPDFlib;
  FontID: Integer;
begin
  Lib := TPDFlib.Create;
  try
    // Plik .otf o zarysach CFF jest osadzany jako FontFile3 /CIDFontType0C;
    // osadzona kopia dostaje identity charset, gdy jest CID-keyed
    FontID := Lib.AddTrueTypeFontFromFile('C:\Fonts\NotoSansSC-Regular.otf');
    if FontID = 0 then
      raise Exception.Create('the font file could not be read or parsed');
    Lib.SetTextSize(18);
    Lib.DrawText(56, 760, '季度报表:华东区销售汇总');
    Lib.DrawText(56, 730, '发票号码 2026-11-0187,金额 ¥12,480.00');
    if Lib.SaveToFile('noto-sc.pdf') <> 1 then
      raise Exception.CreateFmt('save failed, error %d', [Lib.LastErrorCode]);
  finally
    Lib.Free;
  end;
end;

Sprawdź wynik przez renderowanie. Ekstrakcja problemu nie pokaże. pdftotext i każdy inny ekstraktor czytają ToUnicode, które jest kluczowane kodem ze strumienia zawartości i nie wie nic o tym, który zarys font dla niego wybiera, więc strona pokazująca złe ideogramy nadal ekstrahuje właściwy napis. mutool draw -r 150 -o page%d.png noto-sc.pdf pokazuje, jak strona wygląda naprawdę, a dla tej ścieżki DrawText popplerowy pdffonts noto-sc.pdf powinien wypisać font jako CID Type 0C. Wynik oczekiwany: oba wiersze renderują się w Noto Sans SC z glifami, które nazywa tekst źródłowy, i ten sam obraz pojawia się w Ghostscript

Która forma osadzenia wymaga PDF 1.6?

Forma OpenType. Tabela 126 ISO 32000-1 wymienia strumień FontFile3 o podtypie CIDFontType0C jako cechę PDF 1.3, podczas gdy podtyp OpenType — cały sfnt ze swoją tabelą CFF — pojawia się pierwszy raz w PDF 1.6. Nowe dokumenty PDFlibPas startują od wersji 1.5, w którą mieści się ścieżka gołego CFF przez DrawText. Pierwsze wołanie DrawOpenTypeText, DrawShapedText albo pionowego składania z krojem CFF tworzy towarzysza OpenType i podnosi dokument do 1.6 na miejscu, co GetPDFVersion zgłasza od razu. Blokada wersji zmienia te zasady. Jeśli LockSaveVersion przypiął dokument niżej, podniesienie zostaje odmówione, a SaveToFile zwraca 0 z LastErrorCode 602, więc żaden plik nie podaje wersji, której jego fonty nie spełniają. Gdy profil dostawy sztywno ustala wersję poniżej 1.6 — PDF/A-1 to częsty przypadek, bo jest zbudowany na PDF 1.4 — składaj tekst CJK przez DrawText z plikiem .otf albo weź cięcie TrueType tej rodziny do wszystkiego, co wymaga shapingu

Przeływ wersji PDF przy osadzaniu fontów CFF w PDFlibPas: DrawText trzyma FontFile3 CIDFontType0C przy PDF 1.5, DrawOpenTypeText i DrawShapedText podnoszą go do PDF 1.6, a LockSaveVersion sprawia, że SaveToFile pada z błędem 602
DrawText trzyma krój CFF jako gołe FontFile3 CIDFontType0C w obrębie PDF 1.5, a wołania shapingu przełączają się na towarzysza OpenType, który wymaga PDF 1.6 i koliduje z niższym przypięciem LockSaveVersion
var
  Lib: TPDFlib;
  FontID: Integer;
begin
  Lib := TPDFlib.Create;
  try
    FontID := Lib.AddTrueTypeFontFromFile('NotoSansSC-Regular.otf');
    Writeln('before: ', Lib.GetPDFVersion);      // Oczekiwane: 1.5
    if Lib.DrawOpenTypeText(56, 760, FontID, 18, '《年度报告》第三章', 0) = 1 then
      Writeln('after: ', Lib.GetPDFVersion);     // Oczekiwane: 1.6
    Lib.SaveToFile('shaped.pdf');  // Oczekiwane w pdffonts: CID Type 0C (OT)
  finally
    Lib.Free;
  end;

  Lib := TPDFlib.Create;
  try
    Lib.LockSaveVersion('1.5');
    FontID := Lib.AddTrueTypeFontFromFile('NotoSansSC-Regular.otf');
    Lib.DrawOpenTypeText(56, 760, FontID, 18, '《年度报告》第三章', 0);
    if Lib.SaveToFile('locked.pdf') = 0 then
      Writeln('save refused, error ', Lib.LastErrorCode);  // Oczekiwane: 602
  finally
    Lib.Free;
  end;
end;

Szerokości i ToUnicode kluczowane tymi samymi liczbami

Skoro charset jest identity, CID, indeks glifu i kod strumienia zawartości to jedna i ta sama liczba. Szerokości podążają za nią. ISO 32000-1 §9.7.4.3 indeksuje /W samym CID, w grupach będących albo CID-em startowym z tablicą szerokości, albo pierwszym CID, ostatnim CID i jedną wspólną szerokością — bez offsetu /FirstChar znanego z fontów prostych. Każdy CID pominięty przez tablicę dostaje /DW, a tabela 117 ustawia domyślną wartość /DW na 1000. Zero jest złe. Czytnik, który dla niewypisanych CID-ów cofa się do 0, zwija linię ideogramów w samą siebie. Dla pliku .otf ładowanego przez AddTrueTypeFontFromFile PDFlibPas zapisuje tablicę /W startującą od CID 0 i wypisującą advance każdego glifu, obok jawnego /DW 1000. Po stronie odczytu tekst CJK ekstrahujący się jako śmieci zwykle psuje się w połowie codespace tego samego potoku, a audyt fontów PDF/UA pod kątem szerokości, CharSet i CIDSet zestawia te tablice z osadzonym programem

ToUnicode ma własną zasadę numeracji. Wpis bfrange przechowuje tylko pierwszy napis docelowy, a §9.10.3 wymaga, by jego ostatni bajt nie został przez długość zakresu wypchnięty ponad 255, bo czytnik generuje kolejne napisy docelowe inkrementując wyłącznie ten ostatni bajt. Kolejne glify mapowane na U+4EFE, U+4EFF, U+4F00 i U+4F01 wyglądają jak jeden zakres. Nie są nim: czytnik zdekodowałby trzeci jako U+4E00. Writer PDFlibPas scala sąsiednie pary glif-Unicode w jeden bfrange tylko wtedy, gdy i kody źródłowe, i napisy docelowe mieszczą się w jednym bloku 256, a w przeciwnym razie dzieli ten ciąg. Pionowe składanie CJK idzie przez te same fonty-towarzysze, więc pionowy tekst japoński i chiński dziedziczy identity charset i te zakresy ToUnicode bez dodatkowych wołań

Każdy krój, który InspectOtfCharset zgłasza jako CID-keyed z charsetem różnym od identity, to przypadek, w którym kody będące indeksami glifów i font się rozjeżdżają. Osadzony przez PDFlibPas dla Delphi, taki krój zachowuje zarysy, offsety i długość charsetu, a zmieniają się CID-y w tej jednej tabeli, plus sumy kontrolne, gdy cały sfnt zostaje zachowany