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

Рендеринг невбудованих шрифтів PDF системними в Delphi

Коли 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, preflight-звіт може показати, з яким встановленим сімейством відрендериться кожен невбудований шрифт, використовуючи перелік шрифтів, який 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 і верстала текст запасним advance у 500 одиниць. Щойно v2.766.53 почала розв'язувати непрямі посилання через рендерер, біт довелося виправити в тій самій зміні, інакше кожен шрифт із small caps раптом рендерився б жирним:

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, тож тест дескриптора $20000 у HotPDF цілився в SmallCap і лишався нешкідливим, лише поки непрямі посилання /FontDescriptor ніколи не розв'язувалися
Гілка прапорців була мертвим кодом сорок версій, бо дескриптор був непрямим — щойно посилання розв'язалися, зсув на одиницю став видимим текстом small caps

Чому 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) і відображає їх у CID перед пошуком ширин. Уживаються лише вбудовані таблиці UCS2 і UTF16 та вбудовані CMap-потоки; тотожна апроксимація для чогось на кшталт GBK-EUC-H лише виглядала б підтримкою, видаючи неправильний вивід, тож рендерер не прикидається

Чому CJK-текст із UCS2 CMap малює правильні гліфи на неправильних advance: /W індексується CID, тоді як коди — значення Unicode, тож зі STSong-Light і UniGB-UCS2-H рядкові коди 97 і вище промахуються повз запис /W [1 95 500] і беруть усталене /DW, виправлено в HotPDF відображенням кодів у CID через діапазони codespace CMap
Баг ховається, бо тут код дорівнює Unicode — гліфи виглядають правильно, тоді як кожен advance тихо береться усталеним, тож судіть про CJK-рендеринг за відстанями, а не за формами

Чому символи з діакритикою перетворюються на знаки питання на китайському Windows?

Однобайтові коди ніколи не мають доходити до ANSI («A») функцій GDI, бо GetGlyphOutlineA і GetGlyphIndicesA тлумачать байти в системній кодовій сторінці, тоді як TextOutA уживає набір символів обраного шрифту. На китайській системі байт $A9 Arial (знак copyright у Windows-1252) ставав провідним байтом 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, що є затичкою, а не збігом. Preflight вище бачить також лише шрифти в словнику /Resources кожної сторінки, а не ті, на які посилаються всередині form XObjects. Коли код усе ще не може бути намальований, відстеження нерозв'язаних гліфів під час малювання про це звітує, що кращий сигнал, ніж розглядання мініатюр

Довговічне виправлення сидить на авторському боці. Сам 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