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

Керування заміною шрифтів PDF у Delphi за допомогою PDFium

PDFium Component дозволяє застосунку на Delphi вирішувати, які байти шрифту використовуються, коли PDF посилається на шрифт, якого не вбудовує. ConfigureSystemFontProvider встановлює реалізацію IPdfSystemFontProvider, що отримує кожен запит на відображення шрифту, який робить PDFium, разом з назвою гарнітури, насиченістю, прапорцем курсиву, набором символів і родиною кроку, і відповідає байтами TrueType, TrueType Collection чи OpenType, які слід використати

Це існує, бо невбудовані шрифти — це лотерея рендерингу. PDF, що називає Arial і нічого не вбудовує, рендериться з Arial на робочій станції, з метрично сумісною заміною на сервері Linux, і з тим, що знайде відображувач хоста на заблокованому образі контейнера. Той самий інвойс виглядає інакше на кожному, розриви рядків зсуваються, і клієнт отримує документ, що не збігається з архівною копією

Чому просто не встановити шрифти на сервер?

Іноді саме це і є відповіддю, і коли так, скористайтеся нею. Але це не спрацьовує в трьох поширених ситуаціях. Ліцензування може забороняти встановлення шрифту на сервер для автоматизованого рендерингу. Образи контейнерів перебудовуються часто, і шрифт, встановлений вручну, зникає з наступним розгортанням. А регульовані робочі процеси потребують, щоб стек рендерингу був відтворюваним з артефактів під контролем версій, а загальносистемна інсталяція шрифту такою не є

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

Встановлення постачальника

Налаштування має відбуватися до завантаження бібліотеки. PDFium приймає структуру інформації системного шрифту під час ініціалізації й зберігає дескриптори, які видає згодом, тож заміна постачальника, поки документи відкриті, зробила б недійсними дескриптори шрифтів, які PDFium досі тримає; компонент прямо відхиляє це, а не дозволяє це пошкодити рендеринг:

uses
  PDFium;

type
  TAppFontProvider = class(TInterfacedObject, IPdfSystemFontProvider)
  public
    function ResolveFont(const Request: TPdfSystemFontRequest;
      out Font: TPdfSystemFontData): Boolean;
  end;

function TAppFontProvider.ResolveFont(const Request: TPdfSystemFontRequest;
  out Font: TPdfSystemFontData): Boolean;
var
  Path: string;
begin
  // Детерміноване відображення: назва гарнітури плюс насиченість і
  // курсив вирішують, який файл ми постачаємо для цього запиту
  Path := MapFaceToBundledFile(Request.FaceName, Request.Weight,
    Request.Italic, Request.Charset);
  Result := Path <> '';
  if not Result then
    Exit;
  Font.FaceName := Request.FaceName;
  Font.FontData := LoadFileBytes(Path);   // повні байти sfnt або TTC
  Font.Charset := Request.Charset;
  Font.TTCIndex := 0;                     // індекс усередині колекції
end;

var
  Policy: TPdfSystemFontPolicy;
begin
  Policy := TPdfSystemFontPolicy.Default;
  Policy.AllowDefaultFallback := False;   // хост вирішує все
  Policy.AllowFaceSubstitution := False;  // відхиляти іншу назву гарнітури
  Policy.MaxFontBytes := 32 * 1024 * 1024;
  Policy.MaxCacheEntries := 64;

  ConfigureSystemFontProvider(TAppFontProvider.Create, Policy);
  // Лише тепер завантажуйте бібліотеку й відкривайте документи
end;

Завершення роботи відбувається у зворотному порядку: спершу постачальник відокремлюється від PDFium, потім бібліотека вивантажується. Пропуск відокремлення лишає нативні дескриптори шрифтів, що вказують на об'єкти Pascal, які от-от буде звільнено, — це класичне порушення доступу при завершенні роботи в коді, що змішує інтерфейси з підрахунком посилань з бібліотекою на C

Що насправді вирішують прапорці політики

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

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

Компонент валідує кожну відповідь постачальника перш ніж вона дістається PDFium: порожні дані відхиляються, завеликі шрифти відхиляються проти MaxFontBytes, перевіряється індекс TTC, а окремі таблиці sfnt подаються з каталогу шрифту, коли PDFium запитує таблицю, а не весь файл. Ця остання можливість означає, що постачальник може передати повний файл шрифту й дозволити компоненту відповідати на запити рівня таблиці, замість того щоб виставляти необроблені об'єкти Pascal через C ABI

Кешування без висячих даних шрифту

Запити на відображення шрифту повторюються постійно під час рендерингу, тож відповіді кешуються з ключем, що охоплює кожен параметр вибору шрифту, і витісняються за обмеженим порядком «давно не використовувався». Тонкість — тривалість життя: PDFium може досі читати байти шрифту, чий запис кешу щойно витіснено

Кеш зберігає динамічні масиви з підрахунком посилань, і кожен нативний дескриптор тримає власний знімок, тож витіснення скидає посилання, а не звільняє пам'ять, що використовується. Зворотний виклик видалення звільняє дескриптор і підтримує лічильник активних. Практично це означає, що MaxCacheEntries можна налаштовувати під пам'ять без жодного ризику видерти дані з-під рендерингу, що виконується

Чи викликається постачальник у моєму потоці?

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

Найбезпечніша форма — постачальник, що не торкається жодного змінного спільного стану: читає з таблиці, побудованої під час запуску, завантажує байти з файлу чи ресурсу, повертає. Якщо пошуку потрібен власний спільний кеш, захистіть його. І тримайте винятки всередині вашої реалізації, оскільки виняток Pascal ніколи не повинен розкручуватися через стек PDFium; компонент перехоплює на межі C ABI й перетворює на збій чи необов'язковий типовий відкат, але покладатися на це як на звичайний потік керування коштує продуктивності й приховує помилки. Правила потоковості для решти компонента слідують тим самим принципам, що й у статті дисципліна блокування рендерингу

Доведення відображення у продакшні

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

var
  Stats: TPdfSystemFontStatistics;
begin
  Stats := GetSystemFontProviderStatistics;
  Writeln(Format('requests=%d cache=%d provider=%d fallback=%d',
    [Stats.MapRequests, Stats.CacheHits, Stats.ProviderHits,
     Stats.DefaultFallbackHits]));
  Writeln(Format('rejected=%d failed=%d handles=%d cached=%d',
    [Stats.RejectedProviderResponses, Stats.FailedRequests,
     Stats.ActiveHandles, Stats.CachedFonts]));

  // У прогоні перевірки відповідності з вимкненим відкатом будь-яке
  // влучання відкату чи невдалий запит означає, що документ
  // послався на шрифт, який ми не постачаємо
  if (Stats.DefaultFallbackHits > 0) or (Stats.FailedRequests > 0) then
    raise Exception.Create('unmapped font encountered - update the font set');
end;

Зростання лічильника RejectedProviderResponses — сигнал, що постачальник відповідає даними, які політика відхиляє, зазвичай завеликим файлом чи заміненою гарнітурою, і на це варто налаштувати сповіщення, оскільки такі запити мовчки деградують до відкату чи збою. Для діагностики, які шрифти документ насправді потребує, перш ніж будувати таблицю відображення, шлях огляду у статті аналіз властивостей шрифтів PDF перелічує вбудовані та невбудовані шрифти для кожного документа

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