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

Рендер невстроенных шрифтов PDF системными шрифтами

Когда PDF не встраивает шрифт, компонент HotPDF рисует этот текст установленным шрифтом Windows, который выбирает HPDFMapBaseFontToSystem: он декодирует имя /BaseFont, отрезает стилевую часть, пробует несколько написаний, пока GDI не подтвердит, что гарнитура установлена, мерит отсутствующие ширины standard 14 по метрически совместимым шрифтам и конвертирует однобайтовые коды в Unicode перед рисованием. Каждый из этих шагов существует потому, что наивная версия падала на настоящих файлах. Рендерер страниц RenderLoadedPageToBitmap хорошо справляется с встроенными программами; это история о шрифтах, которых в файле нет вовсе

Почему GDI молча рисует не ту гарнитуру для невстроенного шрифта?

GDI никогда не сообщает об отсутствии шрифта: подсуньте CreateFontIndirect имя гарнитуры, которого он не знает, и он тихо выберет замену, часто другой serif без жирного начертания. Ранний рендерер передавал PDF-имя почти дословно, так что TimesNewRoman,Bold, TimesNewRomanPS-BoldMT и SegoeUI-Semibold не совпадали ни с чем и выходили тем, что GDI подбирал. Имена бывают и хуже. ISO 32000-1 §7.3.5 позволяет имени писать любой байт как #xx, и CJK-производители регулярно записывают имена шрифтов эскейпленным UTF-8 или байтами легаси-кодовой страницы; до v2.766.69 сами эскейпы становились именем гарнитуры. HPDFMapBaseFontToSystem теперь сперва декодирует эскейпы, возвращает валидную последовательность байтов UTF-8 как символы и читает прочие старшие байты в системной кодовой странице

Отрезание стиля — место, где кусают эвристики. Запятая всегда кончает гарнитуру (Arial,Bold даёт Arial), а дефис — только когда слово после него стиль: Bold, Italic, Oblique, Regular, Roman, Medium, Light, Black, Heavy, Semi, Demi, Thin, Extra, Ultra или Condensed. Это правило оставляет MS-Mincho целым, превращая Calibri-Light в Calibri. Затем HotPDF пробует написания гарнитуры, за ними — написания с отрезанным суффиксом PSMT, MT или PS. С v2.768.18 те написания покрывают каждый выбор пробелов в местах, где может начинаться слово: перед заглавной, следующей за строчной (MyriadPro становится Myriad Pro), на последней заглавной серии, за которой идёт строчная (UIGothic), и после ведущего MS (MSPGothic) — от полностью раздельного вида до имени как написано; после четырёх таких мест пробуются лишь полностью раздельное и слитное имена. Расставлять пробелы вслепую нельзя, потому что Windows держит некоторые слова слитно: SimSun установлен ровно под таким написанием, тогда как MicrosoftYaHei, MicrosoftJhengHei и MSPGothic принадлежат Microsoft YaHei, Microsoft JhengHei и MS PGothic. До v2.768.18 маппер ставил пробел перед каждой внутренней заглавной, поэтому MicrosoftYaHei искался как Microsoft Ya Hei и никогда не находился. Кандидат считается установленным, когда CreateFontIndirect с последующим GetTextFace возвращает запрошенное имя или, с v2.768.18, когда таблица name выбранного шрифта перечисляет его как семейство — полное или типографское, на любом языке; ответ кэшируется по имени, так что документы со множеством неустановленных шрифтов больше не щупают Windows по каждому имени на каждой странице

Конвейер HotPDF, рисующий невстроенные шрифты PDF системными шрифтами в Delphi: HPDFMapBaseFontToSystem декодирует эскейпленные байты #xx в имени /BaseFont, отрезает стилевые суффиксы Bold, Italic и Light, сохраняя MS-Mincho целым, строит кандидатов вроде Myriad Pro и Microsoft YaHei и принимает имя, только когда его подтверждает GetTextFace или таблица имён шрифта
GDI никогда не сообщает о пропавшем шрифте, он молча подменяет — маппинг перебирает кандидатов по порядку и доверяет лишь имени, которое вернул GDI или перечисляет таблица имён выбранного шрифта

Поскольку функция отображения публична в юните HPDFRenderFontMetrics, префлайт-отчёт может показать, с какой установленной гарнитурой отрисуется каждый невстроенный шрифт, пользуясь перечислением шрифтов, которое THotPDF уже выставляет для загруженных документов:

uses
  HPDFDoc, HPDFRenderFontMetrics;

procedure ListSystemFontMappings(Pdf: THotPDF; Log: TStrings);
var
  Page, I: Integer;
  Info: THPDFLoadedFontInfo;
begin
  for Page := 0 to Pdf.LoadedPageCount - 1 do
    for I := 0 to Pdf.GetLoadedFontCount(Page) - 1 do
      if Pdf.GetLoadedFontInfo(Page, I, Info) and not Info.IsEmbedded then
        Log.Add(Format('page %d  /%s  %s -> %s',
          [Page + 1, string(Info.ResourceName), string(Info.FontName),
           HPDFMapBaseFontToSystem(Info.FontName)]));
end;

Как HotPDF мерит шрифты standard 14 без /Widths?

HotPDF мерит недостающие advance на установленном шрифте с теми же метриками, потому что ISO 32000-1 §9.6.2.2 разрешает шрифтам standard 14 опускать /Widths, а библиотека AFM-таблиц не возит. Arial несёт метрики Helvetica, Times New Roman — Times, а Courier New — Courier, поэтому HPDFMeasureBaseFontWidths создаёт парную гарнитуру на lfHeight = -1000 и зовёт GetCharWidth32W; на той высоте результат уже в единицах 1/1000 em, которыми пользуются PDF-ширины. Рендерер сперва переводит каждый код в Unicode через /Encoding, /BaseEncoding и /Differences, с дефолтом StandardEncoding. SVG-экспорт и извлечение текста поймали ещё одну ловушку: стандартный шрифт Type 1 вовсе без /Encoding порождал декодер без информации о кодировке, SVG-экспорт его никогда не регистрировал, и каждая измеренная ширина пропадала впустую. Подстановка подразумеваемого StandardEncoding починила это — при условии, что кодировка помечена как предопределённая; маршрут по пути CMap-имён декодирует каждый код в 0, и за ним следуют все ширины

Bold, italic и ошибка на единицу во флагах дескриптора шрифта

Запись /Flags дескриптора шрифта нумерует биты с 1, а не с 0, поэтому ForceBold — бит 19 ($40000), а Italic — бит 7 ($40), по ISO 32000-1 Table 123. Старый код тестировал $20000, то есть бит 18, SmallCap. Ошибка прожила с v2.345.0 до v2.766.53, потому что /FontDescriptor почти всегда косвенная ссылка, а строитель шрифтов читал только прямые объекты, так что вся флаговая ветка не выполнялась никогда, и та же слепота игнорировала /Widths 12 0 R, выкладывая текст fallback-advance в 500 единиц. Когда v2.766.53 начал разрешение косвенных ссылок через рендерер, бит пришлось править в той же правке — иначе каждая гарнитура с капителью внезапно отрисовалась бы жирным:

const
  // ISO 32000-1 Table 123 нумерует позиции битов с 1
  FD_ITALIC     = $00040;  // бит 7
  FD_SMALLCAP   = $20000;  // бит 18, не насыщенность
  FD_FORCEBOLD  = $40000;  // бит 19

procedure ApplyDescriptorFlags(Flags: Integer; var LF: TLogFont);
begin
  if (Flags and FD_FORCEBOLD) <> 0 then
    LF.lfWeight := FW_BOLD;
  if (Flags and FD_ITALIC) <> 0 then
    LF.lfItalic := 1;
end;
Нумерация битов записи /Flags дескриптора шрифта PDF: ISO 32000-1 Table 123 считает с бита 1, делая Italic $40 на бите 7, SmallCap $20000 на бите 18 и ForceBold $40000 на бите 19, поэтому тест дескриптора в HotPDF на $20000 целился в SmallCap и оставался безвредным, лишь пока косвенные ссылки /FontDescriptor не разрешались
Флаговая ветка была мёртвым кодом сорок версий, потому что дескриптор был косвенным — стоило ссылкам заработать, как ошибка на единицу стала видимой капителью

Почему CJK-шрифты с UCS2 CMap получают неверные ширины?

CJK-текст с предопределённым UCS2 CMap рисует правильные глифы, но неверный интервал, когда рендерер принимает код за CID, потому что /W индексируется по CID, а не по коду символа. С STSong-Light и UniGB-UCS2-H код совпадает со значением Unicode, так что GDI рисует правильные символы, и баг прячется в advance: строчные буквы приезжают кодами 97 и выше, выпадают из записи /W вроде [1 95 500] и все получают дефолтную ширину /DW в 1000. С v2.766.56 рендерер HotPDF читает коды через codespace-диапазоны CMap (ISO 32000-1 §9.7.6.2) и проецирует их в CIDs, прежде чем искать ширины. Используются только встроенные таблицы UCS2 и UTF16 и встроенные CMap-потоки; identity-аппроксимация для чего-то вроде GBK-EUC-H лишь делала вид поддержки, выдавая неверный вывод, поэтому рендерер не прикидывается

Почему CJK-текст с UCS2 CMap рисует правильные глифы на неверных advance: /W индексируется по CID, тогда как коды — значения Unicode, поэтому у STSong-Light и UniGB-UCS2-H строчные коды 97 и выше промахиваются мимо записи /W [1 95 500] и берут дефолт /DW; в HotPDF починено проецированием кодов в CIDs через codespace-диапазоны CMap
Баг прячется, потому что здесь код равен Unicode — глифы выглядят правильно, пока каждый advance молча берёт дефолт, так что судите о CJK-рендеринге по интервалам, а не по формам

Почему символы с диакритикой превращаются в вопросики на китайском Windows?

Однобайтовые коды никогда не должны доходить до ANSI («A») функций GDI, потому что GetGlyphOutlineA и GetGlyphIndicesA толкуют байты в системной кодовой странице, а TextOutA использует набор символов выбранного шрифта. На китайской системе байт Arial $A9 (знак копирайта в Windows-1252) становился lead-байтом GBK и рисовался как «?», — ловушка, в которую сходу шагнул бесподсказный контурный путь из v2.766.83. v2.767.3 спрашивает у реализованного шрифта его набор символов через GetTextCharset, переводит его в кодовую страницу через TranslateCharsetInfo, гонит байт через MultiByteToWideChar и зовёт W-функции; символные шрифты вместо этого используют U+F000 плюс код. Кодировки, расходящиеся с Windows-1252, — /Differences, StandardEncoding, MacRomanEncoding — проецируются в Unicode до того, как их увидит хоть какой-то системный шрифт

Каковы пределы рисования системными шрифтами?

Рисование системными шрифтами — аппроксимация, и компонент HotPDF честен насчёт того, где она кончается. До v2.768.18 проверка установки сравнивала только имя, которое возвращает GetTextFace, а на локализованном Windows эта функция отчитывает имя семейства на языке системы, поэтому Microsoft YaHei на китайском Windows или Yu Mincho на японском считались отсутствующими и рисовались GDI-заменой; с v2.768.18 гарнитура, вернувшаяся под другим именем, дополнительно ищется в таблице name шрифта, и такие шрифты находятся. Метрическая совместимость гарантирована только для семейств Helvetica, Times и Courier; Symbol проецируется на Symbol, а ZapfDingbats на Wingdings, что затычка, а не совпадение. Префлайт выше тоже видит только шрифты из словаря /Resources каждой страницы, а не упомянутые внутри form XObject. Когда код всё же не рисуется, об этом отчитывается трекинг неразрешённых глифов во время рисования — сигнал получше разглядывания миниатюр

Прочная починка сидит на стороне авторинга. Сам HotPDF пишет с FontEmbedding, выставленным в True по умолчанию, подставляя встроенный Arial, даже если код зовёт SetFont с Helvetica, а встроенный текст идёт через рендерер глифов встроенных шрифтов вместо всей описанной выше гадательной машинерии. Дешёвый предохранитель для входящих файлов — предупреждать перед рендерингом, когда отображённой гарнитуры нет в списке экранных шрифтов:

// VCL: Screen.Fonts перечисляет имена установленных семейств (юнит Forms)
function MissingSystemFamily(const BaseFont: AnsiString): Boolean;
begin
  Result := Screen.Fonts.IndexOf(HPDFMapBaseFontToSystem(BaseFont)) < 0;
end;

О полном компоненте — с рендерингом страниц, извлечением текста и субсеттингом шрифтов на пишущей стороне — читайте на странице продукта HotPDF Delphi PDF component