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

Подключаемый шейпинг текста: Uniscribe и HarfBuzz

Шейпинг текста в компоненте PDFium идёт через один устанавливаемый объект. ConfigureTextShaper устанавливает шейпер, через который проходит каждая точка входа шейпинга, заменяя и освобождая всё, что было там; ActiveTextShaper возвращает установленный и создаёт платформенное умолчание при первом использовании; ActiveTextShaperName сообщает, какой бэкенд живой; ClearTextShaper сбрасывает установку и позволяет создать умолчание снова. На Windows умолчание — TPdfUniscribeTextShaper. Под Free Pascal есть TPdfHarfBuzzTextShaper, который привязывает libharfbuzz во время выполнения, поэтому отсутствующая библиотека — сообщаемое состояние, а не провал загрузки

Архитектура подключаемого шейпинга текста в компоненте PDFium Delphi: ConfigureTextShaper, ActiveTextShaper и ClearTextShaper управляют одним установленным бэкендом, Uniscribe на Windows и привязанным во время выполнения HarfBuzz под Free Pascal
Каждый вызов шейпинга идёт через единственный установленный объект шейпера, с платформенным умолчанием на каждой цели

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

Почему бэкенд Windows — один класс, а переносимый — три части?

Потому что Uniscribe — четыре API, притворяющиеся одним. ScriptItemize сегментирует строку по письму и разрешает двунаправленные уровни; ScriptShape отображает символы в глифы; ScriptPlace вычисляет продвижения и смещения; ScriptLayout ставит получившиеся прогоны в визуальный порядок. Построенному на ней бэкенду поэтому нечего добавить, оттого шейпер Windows — один класс с одним методом

HarfBuzz покрывает средние две. Он формирует и размещает прогон, чьи направление и письмо вызывающий уже решил, и не имеет мнения о том, как абзац делится на прогоны и в каком порядке прогоны появляются. Поэтому переносимый бэкенд поставляет остальное: двунаправленный алгоритм разрешает уровни вложенности, Unicode-функции HarfBuzz сегментируют текст по письму, а прогоны выкладываются в визуальный порядок, который даёт правило L2 UAX #9. Двунаправленная половина достаточно существенна, чтобы быть собственным модулем, описанным в статье об уровнях вложенности UAX #9

Сравнение конвейеров шейпинга для текста PDF: Uniscribe поставляет ScriptItemize, ScriptShape, ScriptPlace и ScriptLayout внутри одного класса, а HarfBuzz покрывает лишь формирование и размещение вокруг собственных стадий UAX #9 компонента
Uniscribe покрывает все четыре стадии; переносимый путь должен сам поставлять сегментацию и визуальный порядок

Шейпер не разрешает шрифты, и это сознательно

Uniscribe читает двоичный файл шрифта из контекста устройства GDI. Переносимого эквивалента этому нет, и изобретать его внутри модуля шейпинга значило бы решать за каждое приложение, откуда шрифты: из fontconfig, из CoreText, из папки шрифтов приложения или из базы данных. Поэтому бэкенд HarfBuzz берёт распознаватель: обратный вызов, отображающий имя шрифта в байты TrueType или OpenType. Возврат False проваливает запрос шейпинга так же, как нечитаемый шрифт GDI проваливает его на Windows

uses
  FPdfTextShaping
{$IFDEF FPC}
  , FPdfTextShapingHb
{$ENDIF}
  ;

function TFontCatalogue.Resolve(const FontName: WideString;
  out FontData: TBytes): Boolean;
var
  Path: string;
begin
  // Ваша политика: fontconfig, CoreText, папка шрифтов приложения, база данных
  Result := FLookup.TryGetValue(LowerCase(FontName), Path);
  if Result then
    FontData := TFile.ReadAllBytes(Path);
end;

procedure InstallShaper(Catalogue: TFontCatalogue);
begin
{$IFDEF FPC}
  // Владение переходит модулю; вызывайте один раз при старте,
  // прежде чем что-либо сформирует текст
  ConfigureTextShaper(TPdfHarfBuzzTextShaper.Create(Catalogue.Resolve));
{$ENDIF}
  // На Delphi платформенное умолчание (Uniscribe) создаётся по запросу,
  // поэтому установка вообще не нужна
  LogInfo('shaping backend: ' + ActiveTextShaperName);
end;

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

Запись результата нейтральна к бэкенду, и кластеры тому причина

Оба бэкенда заполняют одну и ту же TPdfShapedText: исходный текст, имя шрифта, размер, байты шрифта, массив прогонов, полную ширину, число глифов и число логических символов. Каждый TPdfShapedRun несёт свой отрезок в исходном тексте, свою визуальную позицию X, ширину, двунаправленный уровень и флаг справа-налево, плюс свои глифы. Каждый TPdfShapedGlyph несёт идентификатор глифа, продвижение, смещения X и Y и кластер, к которому он принадлежит, как начало и длину в исходном тексте

Поля кластера — то, что делает запись пригодной к использованию, а не просто информативной. Шейпинг — не взаимно-однозначное отображение: слог деванагари становится одним глифом из четырёх символов, арабская лигатура сливает два, а один символ может породить несколько знаков. Без кластерных отрезков нельзя поставить каретку, проверить попадание щелчка или подсветить выделение, потому что нельзя сказать, каким символам принадлежит глиф. С ними арифметика локальна, и один и тот же код работает для обоих бэкендов

Кластерные отрезки глифов в TPdfShapedText: один глиф слога деванагари из четырёх символов, арабская лигатура из двух и база со знаком из одного символа, каждый отображается обратно через ClusterStart и ClusterLength
Кластерные отрезки отображают каждый глиф обратно в его исходные символы, поэтому каретки, проверки попадания и выделения работают
var
  Shaped: TPdfShapedText;
  R, G: Integer;
begin
  if ShapePdfText(Line, 'Noto Sans Arabic', 14, ptdAuto, Shaped) then
    for R := 0 to High(Shaped.Runs) do
    begin
      // Прогоны приходят уже в визуальном порядке с заполненным VisualX
      X := Shaped.Runs[R].VisualX;
      for G := 0 to High(Shaped.Runs[R].Glyphs) do
      begin
        EmitGlyph(Shaped.Runs[R].Glyphs[G].GlyphID,
          X + Shaped.Runs[R].Glyphs[G].OffsetX,
          Shaped.Runs[R].Glyphs[G].OffsetY);
        X := X + Shaped.Runs[R].Glyphs[G].Advance;
      end;
    end;
end;

Лимиты принадлежат записи опций

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

Задавать направление явно, а не оставлять на автомате, стоит всякий раз, когда вы его уже знаете. Автомат применяет правила направления абзаца, угадывая по первому сильному символу, что верно для свободного текста и неверно для поля формы, чьё направление — свойство поля, а не значения, которое кто-то ввёл

Привязка во время выполнения, а не зависимость сборки

Бэкенд HarfBuzz загружает библиотеку динамически. Это решение развёртывания с настоящими последствиями: один двоичный файл работает на машине с HarfBuzz и на машине без него, сообщая о сниженной возможности во втором случае вместо провала запуска. Для библиотеки, поставляемой другим разработчикам, это единственное работоспособное устройство, ведь нельзя требовать от каждого потребителя PDF-компонента раздобыть и согласовать по версии библиотеку шейпинга, которая ему может быть не нужна

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

Установите один раз, прежде чем что-либо сформируется

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

Когда бэкенд установлен, измерение и перенос ведут себя одинаково на обеих платформах, поскольку потребляют метрики прогонов и глифов, а не вызывают платформу напрямую; модель переноса описана в статье об измерении текста и переносе по словам. Поддерживаемые платформы и тулчейны компонента перечислены на странице продукта PDFium Delphi component