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

Comb-поля с арабским и ивритом в Delphi на HotPDF

HotPDF заполняет comb-поля AcroForm арабским, ивритом, диакритикой и смешанным по направлению текстом через THPDFHeadlessDocument.SetTextFieldValueWithFamilies. Comb-сетка считается в скалярах Unicode, лигатура или базовая буква со своими знаками центрируется как один кластер на тех k ячейках, которыми владеют её скаляры, а вся строка прогоняется через алгоритм Unicode Bidirectional до того, как ячейки будут розданы

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

Два способа заполнить арабское comb-поле в HotPDF на Delphi: позначное сокращение давит лигатуру лам-алиф, шейпинговое заполнение центрирует каждый кластер
счёт в скалярах Unicode и переупорядочивание строки до раздачи ячеек держат лигатуру отцентрованной на двух её ячейках и не оставляют пустых

Чего Comb-флаг на самом деле требует от PDF-писателя?

Comb-флаг велит писателю разбить поле ровно на MaxLen равноотстоящих позиций и положить в каждую по символу. ISO 32000-1 §12.7.4.3 определяет Comb как бит 25 в Table 228 — флаги полей, специфичные для текстовых полей, — и придаёт ему смысл только при наличии записи MaxLen из Table 229 и чистых флагах Multiline, Password и FileSelect. Сам бит — $1000000 в 32-битном значении /Ff; если вы когда-нибудь видели, как comb-поля сходят с ума после круга через переменную с плавающей точкой, статья о 32-битных флагах полей AcroForm объяснит, почему этот бит уходит первым

Спецификация говорит «символ» и останавливается. Она писалась под латинские боксы фиксированного шага и не говорит, что такое символ, когда пара арабских букв становится одним глифом или когда огласовка не имеет собственной ширины. HotPDF приходится выбирать единицу, и, выбирая, он строго проводит структурные правила: comb-поле, которое также multiline, или поле без положительного MaxLen, отвергается с диагностикой, а password-поля comb-полями быть не могут вовсе

Почему считать скаляры Unicode, а не единицы UTF-16, глифы или графемы?

HotPDF считает comb-ячейки в скалярах Unicode, потому что это та же единица, которой он проводит MaxLen, а сетка и проверка длины обязаны соглашаться — иначе значение, прошедшее валидацию, не влезет в свои ячейки. Три соблазнительные альтернативы ломают это согласие каждая по-своему

  • Кодовые единицы UTF-16 — то, что возвращает Delphi-овский Length, поэтому дополнительный символ вроде U+20BB7 съел бы две ячейки и два слота бюджета поля
  • Счёт глифов зависит от шрифта: большинство арабских шрифтов превращают лам плюс алиф в один глиф, поэтому число ячеек менялось бы от смены гарнитуры
  • Счёт графем сливает базовую букву с её знаками, а значит сетка разошлась бы с проверкой MaxLen, считающей каждый набранный пользователем скаляр

Валидация MaxLen в THPDFHeadlessDocument идёт по строке, только когда её UTF-16-длина превышает лимит, и тогда она считает валидную суррогатную пару за один раз и сразу отвергает непарную суррогат. Если ваш UI хочет предупреждать до вызова, а не ловить исключение после — считайте так же

Матрица сравнения единиц UTF-16, глифов и графем со скалярами Unicode для comb-ячеек HotPDF в Delphi: лам-алиф, бе с фатхой, U+20BB7 и e с акутом
только колонка скаляров Unicode соглашается с проверкой MaxLen — суррогатная пара считается один раз, и число ячеек никогда не зависит от шрифта
// Считает скаляры Unicode так же, как comb-сетка:
// валидная суррогатная пара — один скаляр
function CountScalars(const S: UnicodeString): Integer;
var
  I: Integer;
begin
  Result := 0;
  I := 1;
  while I <= Length(S) do
  begin
    if (Ord(S[I]) >= $D800) and (Ord(S[I]) <= $DBFF) and
       (I < Length(S)) and
       (Ord(S[I + 1]) >= $DC00) and (Ord(S[I + 1]) <= $DFFF) then
      Inc(I, 2)
    else
      Inc(I);
    Inc(Result);
  end;
end;

Несколько значений показывают, насколько разъезжаются единицы и почему только колонка скаляров совпадает с тем, на что comb-поле размечает бюджет

ЗначениеЕдиницы UTF-16Скаляры (ячейки)Типичные глифы
Лам + алиф (لا)221 лигатура
Бе + фатха (بَ)222, знак прикреплён к базе
U+20BB7 (𠮷)211
Разложенное e + U+0301221 или 2, зависит от шрифта

Как лигатуры и диакритика делят свои ячейки?

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

Центрирование — место, где ломается очевидная реализация. Центрирование каждого глифа в собственной ячейке отрывает знак от его базы, потому что позиция знака что-то значит только относительно буквы, которую он украшает. HotPDF держит каждый глиф кластера на его шейпинговом advance и смещениях X и Y, затем центрирует кластер по суммарному горизонтальному размаху естественного advance, позиционированных истоков глифов и реальных очертаний глифов. Кластер двигается блоком; внутри него ничего не переставляется

Одно следствие легко пропустить. Направляющие контролы вроде U+200F RIGHT-TO-LEFT MARK, U+061C ARABIC LETTER MARK и контролов embedding и isolate — скаляры Unicode без глифа. Они идут в счёт MaxLen и держат ячейку, которая останется пустой. Если исходные данные приходят из системы, подсыпающей эти знаки в имена, чистите их перед заполнением — иначе поле покажет дыру и начнёт отвергать значения на символ раньше, чем ожидает пользователь

Почему bidi-переупорядочивание обязано происходить до раздачи ячеек?

HotPDF сперва прогоняет всю строку через алгоритм Unicode Bidirectional (UAX #9) и только затем даёт каждому скаляру ячейку по его визуальной позиции. Раздача ячеек в логическом порядке с последующим переворотом результата работает на чисто арабской строке и падает, как только в строке появляются цифры или латинский токен: эти прогоны обязаны читать слева направо внутри строки справа налево. Значение вроде референс-кода с арабским префиксом и числом держит цифры в правильном порядке только если переупорядочивание видит всю строку разом. Обзор шейпинга complex script глубже раскрывает проблему логического и визуального порядка

Раздача comb-ячеек для арабского значения с цифрами в HotPDF для Delphi: логический порядок разворачивает число, а UAX 9 первым держит цифры целыми
сперва прогоните bidi-алгоритм по всей строке, затем дайте каждому скаляру ячейку по визуальной позиции, чтобы встроенные цифры сохранили порядок

Затем выравнивание размещает всю сетку занятых ячеек внутри поля. Унаследованная запись /Q поля побеждает, когда есть; Options.Alignment действует, только если у формы её нет. Для форм справа налево это существенно: короткое арабское значение в выровненном влево comb-поле заполнит левые ячейки — соответствующий спецификации и почти никогда не задуманный дизайнером бланка результат. Почините /Q формы или выставьте htaRight, когда собственного выравнивания у поля нет. Поворот виджета приходит из записи /MK /R, и при 90 или 270 градусах логический текстовый вьюпорт меняет ширину и высоту местами, тогда как логика ячеек остаётся прежней

Заполнение comb-поля смешанными шрифтами из Delphi

Comb-текст смешанными шрифтами использует одну сетку ячеек, один размер шрифта и одну базовую линию для всех поданных шрифтов. При FallbackFontFamilies равном True HotPDF подбирает шрифт для каждой полной графемы из поданных семейств в их настроенном порядке, поэтому арабская гарнитура может нести буквы, а латинская — встроенный ASCII. Каждый шрифтовой прогон шейпится с целой строкой в качестве контекста, знак никогда не отрывается от базы между шрифтами, и лигатура не выдумывается через границу шрифтов. Разложить каждый прогон по собственной локальной сетке или отмасштабировать его в собственный bounding box было бы проще — и сломало бы и позиции ячеек, и повёрнутые поля в момент, когда fallback переключает шрифт посреди строки

uses
  System.SysUtils, System.IOUtils,
  HPDFHeadlessDocument, HPDFHeadlessAppearance;

procedure FillArabicComb(const SourceFile, TargetFile, Value: string);
var
  Doc: THPDFHeadlessDocument;
  Field: THPDFHeadlessFormField;
  Families: THPDFHeadlessRichTextFontFamilies;
  Options: THPDFHeadlessTextAppearanceOptions;
  FieldIndex: Integer;
begin
  Doc := THPDFHeadlessDocument.Create;
  try
    Doc.LoadFromFile(SourceFile);
    FieldIndex := Doc.FindFormField('applicant.name_ar');
    if FieldIndex < 0 then
      raise Exception.Create('Field not found');
    Field := Doc.GetFormField(FieldIndex);
    if (Field.Flags and $1000000) = 0 then   // бит 25: Comb
      raise Exception.Create('Not a comb field');

    SetLength(Families, 2);                  // от одного до шестнадцати семейств
    Families[0].FamilyName := 'Noto Naskh Arabic';
    Families[0].Fonts[hrfsRegular] :=
      TFile.ReadAllBytes('NotoNaskhArabic-Regular.ttf');
    Families[1].FamilyName := 'Noto Sans';
    Families[1].Fonts[hrfsRegular] :=
      TFile.ReadAllBytes('NotoSans-Regular.ttf');

    Options := THPDFHeadlessTextAppearanceOptions.Default;
    Options.ShapeComplexText := True;        // уже умолчание
    Options.FallbackFontFamilies := True;    // умолчание False
    Options.FontSize := 0;                   // автоподбор, потолок 12
    Options.Alignment := htaRight;           // используется только без /Q

    Doc.SetTextFieldValueWithFamilies(FieldIndex, Value, Families, Options);
    Doc.SaveToFile(TargetFile);              // должно отличаться от SourceFile
  finally
    Doc.Free;
  end;
end;

Заметьте, чего пример не выставляет. При заполнении документа Width, Height, Rotation и CombCells выводятся из прямоугольника каждого виджета, его записи /MK и MaxLen поля, поэтому ваши значения в этих членах будут перезаписаны. Нулевой FontSize просит автоматический подбор, который сверяет размах каждого кластера с шириной его k ячеек и общей метрической и чернильной высотой всех участвующих шрифтов; фиксированный размер просто обрезается виджетом. CharacterSpacing и WordSpacing никогда не двигают comb-позиции: где приземлится кластер, решает одна сетка. Свои рантаймы HarfBuzz и FriBidi HotPDF грузит, только когда значение реально требует шейпинга, а значения без шейпинга оставляют существующую прямую comb-раскладку нетронутой. Общий конвейер заполнения загруженного PDF, включая права и сохранение, — в статье о задании значений полей формы в загруженном PDF

Чего шейпинговый comb-вид не гарантирует?

Шейпинговый comb-вид гарантирует верную раздачу ячеек, целые кластеры и сохранённое логическое значение; он не гарантирует бесшовные курсивные соединения и идеальное извлечение текста в каждом инструменте. Оба предела следуют из того, что такое comb-поле, и о них стоит знать, прежде чем обещать что-то клиенту

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

Второе — извлечение текста. Запись /V поля хранит чистое логическое значение, а родительский Form XObject вида несёт один спан /ActualText (ISO 32000-1 §14.9.4) с полным вводом; дочерние Forms не несут ничего. Экстракторы, уважающие ActualText, восстановят исходную строку. Экстракторы, игнорирующие её, пересоберут текст по позициям глифов и могут вернуть другой порядок или продублировать текст кластера: у многоглифового кластера нет единственного очевидного значения Unicode на глиф. Если нижестоящей системе нужно значение, читайте поле, а не текст страницы

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

try
  Doc.SetTextFieldValueWithFamilies(FieldIndex, Value, Families, Options);
except
  on E: EHPDFHeadlessDocumentError do
    if E.Code = hdLimitExceeded then
      ShowFieldError('The value has more characters than the field allows')
    else
      raise;
  on E: EHPDFHeadlessAppearanceError do
    if E.Failure = haMissingGlyph then
      ShowFieldError('None of the supplied fonts covers this text')
    else
      raise;
end;
// На любой из этих ошибок поле сохраняет прежнее значение и вид

Куда шейпинговые comb-поля вписываются в конвейер форм

Шейпинговая поддержка comb больше всего важна для форм, которые уже существуют: налоговые декларации, визовые анкеты, банковские onboarding-пакеты и регистрации по документу, спроектированные с клечатыми полями и теперь заполняемые арабским, ивритом, персидским или урду. Считайте в скалярах, храните исходное значение в /V, дайте одной сетке управлять всеми шрифтами и тестируйте значением, смешивающим буквы справа налево, цифры и диакритику — одна такая строка прогоняет каждое правило этой статьи. Описанные здесь headless API документа, шейпинг comb и fallback шрифтов — часть HotPDF Delphi PDF component