Технічна стаття

Замикання субсету шрифту: втрачені гліфи форматування в Delphi

Сформовані гліфи рендеряться як блоки .notdef, коли субсетер шрифту зберігає лише гліфи, досяжні з видаваних кодових точок. HotPDF, нативний VCL-компонент PDF для Delphi та C++Builder, ніс рівно цей дефект аж до версії 2.435.0: вивід OpenType GSUB записувався у внутрішній бітмап використання, який субсетер заявляв, що враховуватиме, а насправді ніколи не читав

Це інший збій, ніж описаний у баг EndDoc, що тихо вимикав субсетинг шрифтів. Той баг стосувався коли запускався субсетинг відносно серіалізації, і він вимикав субсетинг повністю. Цей — про те, що міститься в субсеті, коли субсетинг запускається абсолютно вчасно за розкладом. Конвеєр спрацьовує в правильний момент, шестилітерний префікс субсету з'являється в /BaseFont точно так, як вимагає ISO 32000-1 §9.6.4, файл стає меншим, кожна латинська сторінка вичитується чисто, а арабська сторінка виходить рядом порожніх прямокутників. Баги порядку виконання гучні, щойно подивишся. Баги замикання лишаються тихими назавжди, бо субсет структурно валідний і хибний лише щодо власного списку членства

Чому сформовані гліфи рендеряться як .notdef?

Тому що набір кодових точок, які видає документ, — не той самий набір гліфів, що документ малює, а субсетер, що плутає ці два, відкидає кожен гліф, вироблений формуванням. Формування тексту перетворює логічну послідовність символів на позиційну послідовність гліфів, і вся його мета — виробити гліфи, до яких не веде жоден окремий вхідний символ: арабська медіальна heh, лігатура fi, деванагарі-конюнкт, контекстна альтернатива, обрана функцією rclt. Кожен з них — це ID гліфа, вироблений пошуком GSUB, а не той, що видає таблиця cmap для будь-якого символу в рядку. Тож субсетер, керований суто cmap, обходить неправильний індекс. Він сумлінно зберігає кожен гліф, який текст міг би використати до формування, і відкидає саме ті гліфи, які текст використовує після формування. Рендерер тоді просить у вбудованого шрифту GID 1847, субсет обнулив цей запис у loca, і замість нього повертається індекс гліфа 0. Індекс гліфа 0 — це .notdef за визначенням OpenType, тому підпис збою — порожній блок, а не неправильна літера чи збій. У PDF немає нічого некоректного; шрифт просто не містить гліфа, який запитав потік вмісту

Кодові точки — не гліфи: три джерела субсету

Коректне замикання субсету мусить об'єднувати три незалежних джерела, кожне зі своїм акумулятором. Перше — набір, похідний від кодових точок: HotPDF накопичує FUnicodeUsedCps у міру видачі символів BMP та FUnicodeSmpUsed для символів додаткової площини, досяжних через сурогатні пари, потім відображає кожен через FUnicodeCpToGid в ID гліфа. Друге — набір, похідний від формування, ID гліфів, вироблені підстановкою GSUB, записані через MarkUnicodeGlyphUsed та EnableShapingFeatureForSubset у FUnicodeExtraUsedGlyphs. Третє — композитне замикання: гліф, чий numberOfContours дорівнює -1 у glyf, збирається з ID компонентних гліфів, і збереження композита з відкиданням його компонентів дає порожній контур замість .notdef, що можна вважати навіть гіршим, бо читається як баг інтервалів

HotPDF завжди обробляв перше і третє. BuildAndApplyUnicodeFontSubset, точка входу субсетингу, яку викликає EndDoc перед серіалізацією, засіює масив використаних гліфів GID 0, обходить кодові точки BMP, обходить список використання SMP і передає масив білдеру субсету, що внутрішньо розв'язує композитні компоненти. Друге джерело було написане, але ніколи не споживалося, і оскільки три джерела дають збій на різному вмісті, розрив може ховатися роками в кодовій базі, чий регресійний корпус переважно латинський

Масив, який записували і ніколи не читали

Контракт був задокументований у трьох місцях і виконаний у жодному з них. Оголошення FUnicodeExtraUsedGlyphs стверджувало, що субсетер EndDoc об'єднує його з використанням, похідним від кодових точок; коментар заголовка на ApplyArabicGSUBRefinement обіцяв, що кожен видаваний підставний GID проходить через MarkUnicodeGlyphUsed, тож субсетер затягує гліф у вбудований шрифт; та сама обіцянка з'являється дослівно на ApplyArabicGSUBContextualRefinement для шляху rclt. Обидва викликачі виконали свою половину. Grep по кожному посиланню на це поле вирішив другу половину приблизно за дев'яносто секунд: одне оголошення, одне виділення SetLength всередині RegisterUnicodeTTF, і записи в двох процедурах маркування. Жодного читання. Це та діагностика, яку варто засвоїти, бо вона узагальнюється далеко за межі шрифтів. Коли поле записується кількома точками виклику і не читається жодною, функція, яку воно представляє, не існує, хоч би як ретельно вона була закоментована. Крок 1 субсетера достатньо малий, щоб прочитати на одному екрані, і розрив очевидний, щойно знаєш, що його треба шукати

// Step 1: derive the used-glyph set (as it stood before 2.435.0)
SetLength(UsedGlyphs, FUnicodeNumGlyphs);
for I := 0 to FUnicodeNumGlyphs - 1 do
  UsedGlyphs[I] := False;
UsedGlyphs[0] := True;                       // .notdef is always present

for Cp := 0 to $FFFF do                      // source 1a: BMP code points
  if (Cp < Length(FUnicodeUsedCps)) and FUnicodeUsedCps[Cp]
     and (Cp < Length(FUnicodeCpToGid)) then
  begin
    GID := FUnicodeCpToGid[Cp];
    if (GID > 0) and (GID < FUnicodeNumGlyphs) then
      UsedGlyphs[GID] := True;
  end;

for I := 0 to High(FUnicodeSmpUsed) do       // source 1b: SMP code points
begin
  GID := FUnicodeSmpUsed[I].GID;
  if (GID > 0) and (GID < FUnicodeNumGlyphs) then
    UsedGlyphs[GID] := True;
end;

// source 2 was missing here: nothing ever consulted FUnicodeExtraUsedGlyphs

Виправлення одним циклом і самостійне маркування гліфів

Виправлення — це об'єднання, і його аргумент безпеки походить з напрямку операції: воно лише встановлює біти, ніколи не очищає їх, тож жоден гліф, що раніше переживав субсетинг, не може почати відкидатися

// v2.435.0: pull GSUB-derived extra glyphs into the subset.
// MarkUnicodeGlyphUsed / EnableShapingFeatureForSubset record GIDs that
// shaping produced but that no emitted code point maps to directly.
for I := 0 to FUnicodeNumGlyphs - 1 do
  if (I < Length(FUnicodeExtraUsedGlyphs)) and FUnicodeExtraUsedGlyphs[I] then
    UsedGlyphs[I] := True;

Три властивості роблять цю зміну низькоризиковою, а не переписом шрифтового рушія. Вона монотонна, як вище. Вона нейтральна на шрифтах, що ніколи нічого не формували, оскільки FUnicodeExtraUsedGlyphs лишається повністю False, а байтовий вивід для суто латинського документа не змінюється. І вона потрапляє перед Кроком 2, тож обидва білдери субсету успадковують її: розріджений білдер, що зберігає оригінальну нумерацію GID, та компактний білдер _BuildCompactSubsetTTF, який HotPDF обирає під PDF/A, щоб перенумерувати збережені гліфи в щільний діапазон, зменшити maxp.numGlyphs і видати відображення старе-в-нове як потік /CIDToGIDMap, вимаганий ISO 32000-1 §9.7.4.2. Обидва внутрішньо викликають _TTFWalkCompositeClosure, тож сформований гліф, що виявляється композитним, тепер також затягує свої компоненти. Композитне замикання ніколи не було зламаним; воно просто ніколи не досягалося для цих ID гліфів, бо ID гліфів не були в наборі, який воно обходить. Якщо ти керуєш рушієм GSUB напряму замість покладання на вбудовані проходи уточнення, замикання стає твоєю відповідальністю, і кожен підставний ID гліфа, який ти видаєш, має бути позначений до того, як EndDoc заморожує набір використаних гліфів

var
  Pdf: THotPDF;
  GIDs: array[0..1] of Word;
  LigGID: Word;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := 'shaped.pdf';
    Pdf.BeginDoc;
    Pdf.RegisterUnicodeTTF('C:\Fonts\NotoNaskhArabic-Regular.ttf');
    Pdf.ShapingFeatures := [sfArabicGSUB, sfStandardLigatures,
                            sfContextualAlternates];

    GIDs[0] := Pdf.GetUnicodeGlyphForCodepoint($0644);   // lam
    GIDs[1] := Pdf.GetUnicodeGlyphForCodepoint($0627);   // alef
    if Pdf.ApplyLigatureSubstitution(GIDs, 0, 'liga', LigGID) then
      Pdf.MarkUnicodeGlyphUsed(LigGID);   // omit this and you get .notdef

    Pdf.EnableShapingFeatureForSubset('rclt');
    Pdf.CurrentPage.RtLTextOut(50, 700, 0, WideString(ArabicText));
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

EnableShapingFeatureForSubset — пакетний відповідник виклику з одним GID, і він навмисно консервативний. Він обходить список пошуків GSUB для пошуків, прив'язаних до одного чотирибайтового тегу функції під поточно вибраним шляхом скрипту й мови, і позначає ID підставних гліфів, які ці пошуки можуть виробити. Це захисна нейтральна операція, коли шрифт не несе таблиці GSUB чи коли функція відсутня на цьому шляху, тож викликати її безумовно безпечно. Це також надлишкове наближення за задумом: він може зберігати гліфи, які конкретний документ ніколи не малює. Для субсетингу надлишкова інклюзія коштує байтів, а недостатня — коректності, що робить цей компроміс легким. Структура цих пошуків і таблиці покриття, що вирішують, які гліфи беруть участь, розглянуті в огляді стилістичних альтернатив GSUB у чистому Delphi

Як довести, що гліф справді в субсеті?

Читанням виданого шрифту, а не оглядом сторінки в переглядачі, який може підставляти системний шрифт за твоєю спиною. Перевірка, що ловить весь цей клас багів, механічна: витягни потік /FontFile2 з вихідного PDF, розпарси loca і підтверди, що ID гліфа, який ти очікуєш, несе непорожній запис, тобто його початковий і кінцевий зсуви відрізняються. Порожній запис означає, що субсетер вирішив, що гліф не використовується. Дві звички роблять набагато складнішим повторно постачити цей збій. Тримай сторінку сформованого скрипту в автоматизованому смоук-корпусі, а не лише в наборі ручної вичитки, бо арабська, деванагарі й кхмерська задіюють шляхи замикання, яких не торкнеться жодна кількість латинського покриття. І щоразу, коли існує акумулятор, стверджуй, що щось його споживає, бо поле лише для запису — це функція, що компілюється, тестується зелено на неправильному корпусі і не робить нічого

Де виправлення зупиняється

Замикання субсету необхідне, щоб сформований гліф рендерився, і воно недостатнє. Гліф також має бути адресовним з потоку вмісту, а це окрема проблема зі своєю власною межею. Вбудовані проходи арабського уточнення HotPDF фіксують підстановку лише тоді, коли кожен підставний ID гліфа досяжний через кодову точку Unicode presentation-form через зворотне сканування cmap по приблизно 690 кодових точках від U+FB50 до U+FDFF та від U+FE70 до U+FEFF. Коли підстановка потрапляє на ID гліфа поза цим діапазоном, вхідне вікно проходить незміненим, а не видає щось, що читач не зможе адресувати; специфічні для шрифту альтернативи на довільних ID гліфів потребують синтетичної кодової точки приватного використання, виділеної в U+E000 - U+F8FF, щоб пронести їх через шлях видачі. Тож чесне резюме таке, що виправлення 2.435.0 усунуло жорсткий блокер, а не завершило історію. До нього гліф міг бути сформований коректно, виданий коректно і все одно зникнути під час субсетингу, а це означало, що рушію формування не можна було довіряти наскрізно, хоч би якими добрими були його пошуки. Що лишається — адресовність, і це обмеження принаймні провалюється видимо в точці видачі, а не тихо на кроці збірки, що виконується після всього, за чим ти спостерігав. Для боку видачі того самого конвеєра дивись посібник з арабського та RTL-формування тексту в PDF на Delphi

Субсетинг шрифтів, рушій GSUB та формування складних скриптів, описані тут, постачаються у стандартному HotPDF Component для Delphi та C++Builder; сторінка продукту містить повний довідник API для названих вище викликів Unicode-шрифтів та формування