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

Встраивание шрифтов CID Type 2 в PDF из Delphi

PDFium Component записывает текст на странице PDF на уровне глифов через AddCidType2Text, встраивая данные TrueType, которые вы предоставляете, в виде шрифта CID Type 2. Он назначает каждому экземпляру глифа последовательный CID, генерирует явную карту CID-to-GID и CMap ToUnicode, а также объединяет глифы на одной базовой линии в единый нативный текстовый объект. Опциональное создание подмножества шрифта удерживает файл небольшим, с явной политикой того, что происходит при неудаче создания подмножества

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

AddCidType2Text в Delphi назначает каждому шейпленному экземпляру глифа последовательный CID, пишет CIDToGIDMap и ToUnicode CMap и вливает прогон в один текстовый объект страницы, который извлекается чисто
Каждый экземпляр глифа берёт собственный CID, словарь шрифта маппит CID в ID глифов и обратно в Unicode, а фрагмент ложится на страницу одним текстовым объектом, извлекающимся обратно в задуманный текст

Почему каждый экземпляр глифа получает собственный CID

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

Глиф лигатуры — очевидный случай. Один и тот же глиф «ffi» может обозначать три символа в одном слове, а после другого решения о шейпинге — что-то иное в другом месте. Карта ToUnicode индексируется по CID, поэтому общий CID может нести только одно сопоставление, и тот текст, что проигрывает в этом споре, становится неизвлекаемым

Поэтому каждый экземпляр глифа получает собственный CID, и каждое сопоставление вправе состоять из нескольких кодовых единиц UTF-16. Глиф без осмысленного текста — декоративный элемент, глиф только для позиционирования — явно отображается на U+200B, пробел нулевой ширины, поэтому у каждого CID есть наблюдаемый результат извлечения, а не пробел в данных

uses
  PDFium;

var
  Pdf: TPdf;
  Glyphs: TPdfCidGlyphs;
  Options: TPdfCidFontOptions;
  Report: TPdfCidFontReport;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'label.pdf';
    Pdf.LoadDocument;
    Pdf.PageNumber := 1;

    // По одной записи на шейпированный глиф: идентификатор глифа, текст,
    // который он представляет, и его продвижение и смещения в текстовом пространстве
    SetLength(Glyphs, 3);
    Glyphs[0].GlyphID := 402;  Glyphs[0].UnicodeText := 'ffi';
    Glyphs[0].Advance := 18.4;
    Glyphs[1].GlyphID := 71;  Glyphs[1].UnicodeText := 'c';
    Glyphs[1].Advance := 9.8;
    Glyphs[2].GlyphID := 74;   Glyphs[2].UnicodeText := 'e';
    Glyphs[2].Advance := 9.2;

    Options := TPdfCidFontOptions.Default;   // подмножество предпочтительно
    Options.VerifyExtraction := True;

    if Pdf.AddCidType2Text(LoadFileBytes('NotoSans.ttf'), Glyphs,
         11, 72, 700, Options, Report) then
      Writeln(Format('%d glyphs, %d unique, font %d -> %d bytes, match=%s',
        [Report.GlyphCount, Report.UniqueGlyphCount,
         Report.OriginalFontBytes, Report.EmbeddedFontBytes,
         BoolToStr(Report.ExtractionMatches, True)]));
  finally
    Pdf.Free;
  end;
end;

Создание подмножества и проверки, которых не даёт код возврата

Сведение шрифта до фактически используемых глифов — это разница между встраиванием 300 КБ и встраиванием 12 КБ, а в документе с несколькими шрифтами именно это решает, можно ли отправить файл по почте. Платформенный путь создания подмножества принимает список глифов напрямую, что идеально подходит для этого API, поскольку вызывающий код уже знает каждый используемый идентификатор глифа

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

Что происходит дальше — политика вызывающего кода. При pcfemSubsetPreferred, значении по умолчанию, отклонённое подмножество откатывается на встраивание полного шрифта, поэтому страница корректна и лишь крупнее. При pcfemSubsetRequired операция завершается ошибкой до изменения страницы — это то, что нужно конвейеру с ограничением по размеру. pcfemFull полностью пропускает создание подмножества. Отчёт сообщает, какой путь был выбран, через SubsetAttempted, SubsetApplied, UsedFullFontFallback и SubsetErrorCode

Субсетирование шрифта CID Type 2 в Delphi проходит три проверки валидации — размер, целостность sfnt и покрытие глифами — до принятия, а отклонённый субсет следует политике pcfem: откат к полному шрифту или жёсткий сбой
Сабсет принимается только после прохождения проверок размера, sfnt и покрытия глифов, а отвергнутый сабсет затем следует политике вызывающего — откатиться к полному шрифту или провалиться до модификации страницы

Проверка, которая не может дать ложноположительный результат

При включённом VerifyExtraction компонент подтверждает, что записанное можно прочитать обратно. Наивный способ это сделать — извлечь всю страницу и поискать ожидаемую строку, и это неверно: страница, уже содержавшая этот текст, пройдёт проверку, даже если новый текст был записан некорректно

Вместо этого текстовая страница перестраивается, а дескрипторы объектов, вставленных этим вызовом, читаются по отдельности, в порядке вставки, и конкатенируются. Результат сравнивается с ожидаемым текстом, и отчёт раскрывает обе строки вместе с булевым значением, поэтому расхождение можно диагностировать, а не просто обнаружить

Включайте эту проверку во время разработки и в любом конвейере, где извлекаемость — требование: поисковые архивы, соответствие требованиям доступности, последующий текстовый майнинг. Стоимость — одна перестройка текстовой страницы на вызов, поэтому по умолчанию она выключена в плотных циклах

Бюджеты и атомарный сбой

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

Сбой атомарен на уровне страницы. Если загрузка шрифта, запись объекта, генерация содержимого или проверка извлечения завершаются ошибкой, каждый объект, вставленный этим вызовом, удаляется в обратном порядке, а содержимое страницы регенерируется. Неудачный вызов оставляет страницу такой, какой она была, а не с половиной текстового прогона на ней

Место этого API в текстовом конвейере

Разделение труда стоит обозначить прямо. Шейпинг — превращение символов в позиционированные глифы — не задача этого API; это дело движка шейпинга, а собственная поддержка двунаправленного текста и сложных письменностей компонента описана в статье обработка эмодзи, CJK и суррогатных пар. Этот API вызывается впоследствии, с прогоном глифов, который выдал шейпер

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

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

Запись текста на уровне глифов, предоставление шрифтов и извлечение текста используют одну и ту же модель страницы в Delphi, C++Builder и Lazarus; полное описание API — на странице PDFium Component для Delphi