Техническая статья

Обнаружение отсутствующих глифов PDF при отрисовке

Отсутствующий глиф в PDF — не ошибка. Производитель запрашивает символ, который выбранный шрифт не может отобразить, шрифт возвращает индекс глифа ноль, и выходящий файл структурно корректен, открывается везде и показывает пустой ящик там, где должно быть имя или сумма. Никто в порождающем конвейере не узнаёт. Получатель узнаёт. HotPDF замыкает этот цикл свойством TrackUnresolvedGlyphs: включите его, и путь отрисовки текста записывает каждую кодовую точку, чей поиск глифа даёт индекс ноль, возбуждая OnUnresolvedGlyph один раз на уникальную находку с кодовой точкой, шрифтом, на котором случился провал, скриптом, к которому она принадлежит, и подсказкой шрифтов, которые её покрыли бы

Обнаружение — половина ответа. Вторая половина — SetFontFallbackChain, регистрирующий упорядоченный список шрифтов на скрипт, так что обычные случаи разрешаются сами и лишь настоящие провалы доходят до вашего обработчика. Вместе они превращают класс дефекта, о котором раньше сообщали клиенты, в проверку времени сборки

Почему отсутствующий глиф не поднимает ничего?

Потому что ISO 32000 не возлагает на производителя обязанности проверять покрытие, а индекс глифа ноль — легитимный глиф. Это .notdef, чей контур выбирает дизайнер шрифта: обычно пустой или полый прямоугольник, иногда ничего вовсе. Просмотрщик, рисующий его, ведёт себя правильно. Извлечение текста может даже вернуть правильные символы, ведь отображение /ToUnicode записывается из исходного текста, а не из контуров, поэтому автоматическая проверка туда-обратно счастливо пропустит документ, чей видимый текст дыряв

Схема того, почему отсутствующий глиф PDF молчит: глиф ноль рисует пустой ящик, а извлечение ToUnicode проходит проверки туда-обратно
Глиф ноль — легитимный ответ .notdef, а /ToUnicode пишется из исходного текста, поэтому ничто в конвейере не узнаёт о провале

Практическое следствие: покрытие должно проверяться в момент отрисовки, когда библиотека ещё знает, какая кодовая точка запрашивалась и какой глиф шрифт действительно предложил. Потом информация исчезает

Детектор должен следить за состоянием сабсета, а не за контекстом устройства

Вот где ошиблась первая реализация, и причину стоит понять, ведь она относится к любой проверке покрытия, прикрученной к текстовому конвейеру. У HotPDF два текстовых пути. Один выдаёт через зарегистрированный Unicode-шрифт TrueType с картой символов в памяти, построенной при регистрации. Другой — унаследованный путь GDI, создающий свежий контекст устройства и дескриптор шрифта на каждый прогон символов

Судить покрытие по пути GDI безнадёжно. Его отображение — не то, что в итоге попадает в выдаваемый поток содержимого, и они не синхронизированы, поэтому детектор, читающий результаты GDI, объявляет весь печатный диапазон ASCII неразрешённым. Авторитетный ответ живёт в зарегистрированном шрифте: карта символов, которую разбирает RegisterUnicodeTTF, опрашивается через GetUnicodeGlyphForCodepoint. Детектор поэтому ставится за заслон состояния готовности сабсета, а не за любое условие GDI, и на документах, никогда не регистрировавших Unicode-шрифт, просто не работает, что верно: те документы и так ограничены стандартными кодировками

Рядом сидит вторая ловушка. Семейное имя шрифта GDI и имя PostScript, извлечённое из двоичного файла шрифта при регистрации, — разные строки, и не такие, которые можно нормализовать: семейство с именем Arial Unicode MS несёт имя PostScript ArialMT. Любой заслон в стиле «выбранный сейчас шрифт — тот ли, что мы зарегистрировали», сравнивающий по имени, — мёртвый код, который никогда не сработает. Ставьте заслон на состояние, никогда на имена шрифтов

Поток обнаружения неразрешённых глифов HotPDF: заслон состояния сабсета, поиск GetUnicodeGlyphForCodepoint и подключение события OnUnresolvedGlyph
Покрытие судится по карте зарегистрированного Unicode-шрифта, а не по GDI, и каждая уникальная кодовая точка возбуждает одно событие с подсказкой шрифта

Не тестируйте детектор глифов эмодзи

Очевидный тестовый случай — улыбающееся лицо, и оно убедит вас, что детектор сломан. Обычные кодовые точки эмодзи в астральных плоскостях разрешаются через путь синтеза частного использования, отображающий их в индекс глифа напрямую, поэтому они никогда не доходят до общей ветви покрытия. Детектор ведёт себя правильно, а тест измеряет не тот путь

Используйте вместо этого неназначенную кодовую точку. U+0378 навсегда не выделен в Unicode, поэтому ни один шрифт не может легитимно его отобразить, и он упражняет ровно ту ветвь, которую хотите проверить. Это различие между «возможность сломана» и «тест взял вход, обходящий возможность», стоит реальных часов, а неназначенные кодовые точки — дешёвый способ его избежать

type
  TCoverageAudit = class
  private
    FFindings: TStringList;
  public
    procedure Handle(Sender: TObject;
      const Info: THPDFUnresolvedGlyphInfo);
    property Findings: TStringList read FFindings;
  end;

procedure TCoverageAudit.Handle(Sender: TObject;
  const Info: THPDFUnresolvedGlyphInfo);
begin
  // Срабатывает раз на уникальную кодовую точку, а не раз на вхождение
  FFindings.Add(Format('U+%.4X missing in %s (script %d), try: %s',
    [Info.CodePoint, String(Info.FontName), Ord(Info.Script),
     String(Info.SuggestedFonts)]));
end;

// Подключение к порождающему заданию
Pdf := THotPDF.Create(nil);
try
  Pdf.TrackUnresolvedGlyphs := True;
  Pdf.OnUnresolvedGlyph := Audit.Handle;
  Pdf.RegisterUnicodeTTF('C:\Windows\Fonts\arial.ttf');
  Pdf.BeginDoc;
  Pdf.CurrentPage.SetFont('Arial', [], 11);
  Pdf.CurrentPage.TextOut(50, 720, 0, CustomerName);
  Pdf.EndDoc;
  if Audit.Findings.Count > 0 then
    // Валим задание вместо отгрузки страницы с ящиками
    raise Exception.Create(Audit.Findings.Text);
finally
  Pdf.Free;
end;

Цепочки замены задаются на скрипт, а не на шрифт

Причина, по которой замена ограничивается скриптом, а не исходным шрифтом, в том, что провалы покрытия кучкуются по системе письма. Латинский текстовый шрифт лишён деванагари, тайского, ханьского и эмодзи одновременно, и замена для каждого — другой шрифт. Объявление одной цепочки на скрипт поэтому описывает реальное развёртывание: один латинский шрифт для основного текста, один шрифт CJK, один шрифт эмодзи, один на все случаи

Схема замены шрифтов по скриптам: скрипты hfsCJK, hfsArabic, hfsEmoji и hfsOther отображаются в упорядоченные цепочки шрифтов замены в HotPDF
Каждый скрипт получает свою упорядоченную цепочку, поэтому латинский основной шрифт без ханьского, арабского или эмодзи проваливается к покрывающему шрифту
// THPDFFontScript покрывает hfsCommon, hfsLatin, hfsGreek, hfsCyrillic,
// hfsHebrew, hfsArabic, hfsIndic, hfsSoutheastAsian, hfsCJK, hfsKana,
// hfsHangul, hfsEmoji и hfsOther
Pdf.SetFontFallbackChain(hfsCJK,
  ['Microsoft YaHei', 'SimSun', 'Yu Gothic']);
Pdf.SetFontFallbackChain(hfsArabic, ['Segoe UI', 'Arial']);
Pdf.SetFontFallbackChain(hfsEmoji, ['Segoe UI Emoji']);
Pdf.SetFontFallbackChain(hfsOther, ['Arial Unicode MS']);

Замена и обнаружение дополняют, а не заменяют друг друга. Цепочки обрабатывают покрытие, которое вы предвидели; детектор сообщает о покрытии, которое вы не предвидели, а в системе, обрабатывающей произвольные клиентские данные, это интересная половина. Заметьте: подмена шрифта меняет метрики, поэтому абзац, ушедший в замену, может переверстаться; если макет важен, поведение замкнутости и сабсеттинга подменённого шрифта стоит изучить в статье о замкнутости сабсета шрифта, а скрипты, требующие переупорядочения или сцепления, обрабатывает стадия формовки, описанная в формовке текста сложных скриптов

Как дооснастить поведение, не рискуя существующим путём

Тот же выпуск добавил резерв на основе унаследованной таблицы kern для межпарных интервалов, и способ, которым это было ограничено, — образец, достойный копирования. Вместо добавления новой точки решения в логику кернинга замена подвешена к ветви раннего выхода, уже существовавшей для шрифтов без таблицы GPOS. Современный шрифт с GPOS до неё никогда не доходит, поэтому его поведение неизменно по построению, а не по тестированию. Пути, не регистрирующие Unicode-шрифт, дают два нулевых смещения, поэтому они тоже неизменны

Таков общий образец низкорискового дооснащения в зрелой библиотеке отрисовки: найдите ветвь, которая сейчас не производит ничего, и поместите новое поведение туда. Это превращает «мы верим, что ничего не регрессировало» в «это не могло ничего регрессировать», что куда лучше говорить о текстовом движке, через который проходят чужие счета

Сделайте это заслоном, а не журналом

Находки покрытия полезны, только если на них что-то падает. В службе порождения документов продуктивная схема — держать слежение включённым в ночном регрессионном задании на корпусе настоящих имён клиентов, адресов и описаний продуктов и валить задание на любой находке. Поскольку событие срабатывает раз на уникальную кодовую точку, а не раз на вхождение, вывод остаётся достаточно малым для чтения, даже когда отсутствует целый скрипт

В промышленной эксплуатации тот же обработчик лучше использовать как телеметрию: записывайте кодовую точку и шрифт, продолжайте обслуживать документ, а агрегат пусть говорит, какой скрипт добавить в шрифтовой набор развёртывания следующим. Поведение отрисовки для встроенных и подменённых шрифтов далее рассмотрено в материале об отрисовке глифов встроенных шрифтов, а полный список свойств, включая TrackUnresolvedGlyphs, задокументирован на странице продукта HotPDF Delphi PDF component