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

Text advance и клип q/Q: рендерер PDF в Delphi

Поток содержимого, задающий /F1 1 Tf, затем 12 0 0 12 72 700 Tm и показывающий (Hello world) Tj, совершенно легален. Рукописный рендерер часто нарисует все одиннадцать символов внутри примерно пяти с половиной пунктов — темное пятно у левого поля там, где должно быть слово на 66 пунктов. Арифметика за этим пятном длиной в одну строку. ISO 32000-1 §9.4.4 определяет advance каждого глифа в text space, так что смещение обязано пройти через текстовую матрицу, прежде чем достигнет страницы, а клип, поставленный внутри q ... Q, принадлежит графическому состоянию ровно так же, как и толщина линии

Почему текст слипается в комок, когда PDF использует Tf 1?

Текст слипается, потому что рендерер прибавляет дистанцию в text space прямо к трансляционной компоненте Tm, что работает, только пока text space и user space имеют один масштаб. Множество реальных производителей пишут размер шрифта как 1 и несут настоящий размер в текстовой матрице. При /F1 1 Tf и 12 0 0 12 72 700 Tm глиф шириной 500 единиц продвигается на 0.5 в text space. Tm масштабирует это до 6 пунктов на странице. Рендерер, исполняющий Tm.e := Tm.e + Adv, двигает перо на 0.5 пункта. Каждый глиф приземляется на одну двенадцатую символа после предыдущего, и вьюер, следующий §9.4.4, показывает тот же файл как обычный основной текст

Почему текст слипается под Tf 1 в рендерере HotPDF: при 12 0 0 12 72 700 Tm глиф в 500 единиц обязан продвинуться на 0.5 единиц text space, которые Tm масштабирует до 6 пунктов, тогда как прибавление 0.5 прямо к Tm.e рисует строку основного текста размазней по одной двенадцатой символа на глиф
Производители, кодирующие размер шрифта в текстовой матрице, выдают обход через Tm.e сразу: каждый глиф приземляется на одну двенадцатую символа после предыдущего, чего никогда не покажет вывод, написанный с настоящим размером в Tf

Обход живет долго, потому что большинство генераторов, на которых люди тестируются, пишут /F1 12 Tf и немасштабированную Tm, и в этом случае text space и user space действительно одного размера. Вот наивная версия целиком, с ломающим ее потоком содержимого в комментарии сверху:

// Поток содержимого от производителя, кодирующего размер в Tm, а не Tf:
//   BT
//   /F1 1 Tf
//   12 0 0 12 72 700 Tm
//   [(Hel) 30 (lo) -250 (world)] TJ
//   ET

// Наивный advance: дистанция в text space прибавляется к Tm.e, как будто это user space
Adv := W * FontSize / 1000;                  // 0.5 для глифа в 500 единиц
if (HorizScale <> 0) and (HorizScale <> 100) then
  Adv := Adv * HorizScale / 100;             // Th только на ширину
Adv := Adv + CharSpace;                      // Tc не масштабируется Th
if Code = 32 then
  Adv := Adv + WordSpace * FontSize / 1000;  // Tw неверно масштабируется Tfs
Tm.e := Tm.e + Adv;                          // игнорирует Tm.a, Tm.b, Tm.c, Tm.d

// Наивная TJ-подстройка: без Th и опять только Tm.e
Tm.e := Tm.e - NumValue * FontSize / 1000;

Строка с Tm.e — громкий баг. Остальные тише, и вылезают они на других файлах. Межсловный Tw уже выражен в немасштабированных единицах text space, так что умножение на FontSize / 1000 превращает 3 Tw под Tf 12 в 0.036 единицы. Выключенный по ширине абзац теряет почти весь межсловный зазор. Горизонтальное масштабирование Th обязано применяться и к Tc, и к Tw, и к числу kerning в TJ. Тест Code = 32 также срабатывает на любом байте значения 32 внутри двухбайтового кода, что разбрасывает случайные зазоры по CJK-тексту. Ошибки текстового состояния такого сорта редко отказывают громко: как ловушки индекса операндов и имен ресурсов, способные обнулить Tc, Tw и Tz до того, как нарисован хоть один глиф, они дают правдоподобные страницы от одного производителя и сломанные от следующего

Как ISO 32000-1 §9.4.4 определяет advance глифа?

ISO 32000-1 §9.4.4 вычисляет advance целиком в text space и применяет его к текстовой матрице как матрицу трансляции, так что масштабирование, поворот и скос приходят все от Tm и никогда от самого advance. Для горизонтального письма tx = ((w0 − Tj/1000) × Tfs + Tc + Tw) × Th, где w0 — ширина глифа в тысячных em, Tj — подстройка TJ, а Th — это Tz, деленный на 100. Новая матрица — [1 0 0 1 tx 0] × Tm. В развернутом виде произведение прибавляет a·tx к e и b·tx к f, отчего трансляция в text space никогда не может быть простым прибавлением к e, разве что a равен 1 и b равен 0. По §9.3.3 Tw применяется только к однобайтовому коду символа 32

Advance глифа по ISO 32000-1 9.4.4 в рендерере HotPDF: tx считается в text space из w0, Tj, Tfs, Tc, Tw и Th, затем применяется через HPDFTranslateTextMatrix, так что смещение проходит через коэффициенты матрицы a, b, c и d, а Td, TD, TJ и путь скрытого текста делят одно правило
Прибавление advance прямо к Tm.e работает, только когда text space равен user space; маршрут через коэффициенты матрицы сохраняет правильность масштабированного, повёрнутого и скошенного текста

HotPDF Delphi Component держит эту формулу в одном месте. Модуль HPDFContentStream экспортирует HPDFTextGlyphDisplacement для advance каждого глифа и HPDFTextAdjustmentDisplacement для числа TJ, и их вызывают и рендерер страниц, и интерпретатор извлечения текста, так что страница, рисующаяся правильно, сообщает и позиции глифов, совпадающие с тем, что вы видите. Если вы пишете собственный потребитель потока содержимого поверх парсера библиотеки, можно вызвать те же две подпрограммы, а шаг матрицы сделать самому:

uses
  HPDFContentStream;  // HPDFTextGlyphDisplacement, HPDFTextAdjustmentDisplacement

type
  // Ваша собственная запись текстовой матрицы, не тип HotPDF
  TTextMatrix = record
    a, b, c, d, e, f: Double;
  end;

// Ваш собственный помощник: [1 0 0 1 Tx Ty] x Tm, проход через a, b, c и d
procedure TranslateInTextSpace(var M: TTextMatrix; Tx, Ty: Double);
begin
  M.e := M.e + M.a * Tx + M.c * Ty;
  M.f := M.f + M.b * Tx + M.d * Ty;
end;

// W0 — ширина глифа в 1/1000 em; Tz — операнд Tz (100 = без масштабирования)
procedure AdvanceAfterGlyph(var Tm: TTextMatrix; W0: Double; Code: Cardinal;
  TwoByteCodes: Boolean; Tfs, Tc, Tw, Tz: Double);
var
  Tx, Ty: Double;
begin
  HPDFTextGlyphDisplacement(W0 * Tfs / 1000, False,
    (Code = 32) and not TwoByteCodes,  // Tw только на однобайтовом пробеле
    Tc, Tw, Tz, Tx, Ty);
  TranslateInTextSpace(Tm, Tx, Ty);
  // Tf 1, Tm 12 0 0 12 72 700, W0 = 500: Tx = 0.5 и Tm.e вырастает на 6
end;

procedure ApplyTJNumber(var Tm: TTextMatrix; Adjustment, Tfs, Tz: Double);
var
  Tx, Ty: Double;
begin
  HPDFTextAdjustmentDisplacement(Adjustment, Tfs, Tz, False, Tx, Ty);
  TranslateInTextSpace(Tm, Tx, Ty);  // Th применяется и к kerning
end;

Внутри рендерера тот же шаг матрицы ведет каждый оператор, двигающий текстовую позицию. Td, TD, T*, ' и " транслируют матрицу строки через ее коэффициенты, так что 0 -1.2 Td под текстовой матрицей 12× двигает базовую линию на 14.4 пункта вниз, а не на 1.2. Глифы, которые никогда не рисуются, идут тем же путем. Режим рендеринга 3 — невидимый режим, каким пользуются OCR-слои текста, — и текст внутри скрытого optional content двигают Tm точно как нарисованный текст. Давать им частную копию арифметики — классическая ошибка, потому что тогда любой видимый текст, идущий за невидимым прогоном в том же блоке BT, стартует с чуть другой позиции

Размещение самого глифа

Размещение глифа следует той же логике. Когда встроенный контур недоступен и рендерер откатывается к GDI TextOutW, он строит полную матрицу глифа из CTM × Tm × rise × масштаба em, включая Th. Матрица входит через SetWorldTransform в режиме GM_ADVANCED, внутри пары SaveDC / RestoreDC. GDI-шрифт создается на фиксированной высоте 1000 единиц, и весь размер задает мировая трансформация. Повернутый и скошенный текст поэтому сохраняет ориентацию вместо того, чтобы рисоваться прямо в трансформированном начале. Режимы рендеринга с обводкой сбрасывают мировую трансформацию перед обводкой, иначе толщина пера, отмеренная против проектного размера в 1000 единиц, съежилась бы почти в ничего. Вертикальное письмо — единственная намеренная асимметрия: шрифт WMode 1 продвигается вниз по оси y на свою вертикальную метрику, Tc и Tw действуют вдоль этой оси, а Th не действует вовсе

Что q/Q реально сохраняет в графическом состоянии PDF?

ISO 32000-1 §8.4.2 перечисляет текущий путь клиппинга в составе графического состояния, так что Q обязан восстановить клип ровно таким, каким он был на парном q. Звучит очевидно. В рендерере на GDI это легко пропустить, потому что CTM, цвета, параметры линий и текстовое состояние живут в собственном стеке состояния рендерера, а клип живет в контексте устройства — вне этого стека. Копия числового состояния восстанавливает все, кроме клипа. W n внутри блока q ... Q тогда продолжает обрезать каждую последующую операцию на странице, и симптом выглядит как пропавшее содержимое вдалеке от оператора, который его вызвал

Form XObject открывают второй маршрут к тому же сбою. §8.10 дает форме неявные save и restore вокруг содержимого и клиппит ее по /BBox, а реальное содержимое форм иногда оставляет собственные q и Q несбалансированными, хоть спецификация и требует их парности. HotPDF записывает клип и сохраняет контекст устройства перед исполнением формы, а когда форма завершается, выбрасывает все кадры клипа, которые форма оставила открытыми, и восстанавливает контекст устройства, так что каждый сохраненный регион освобождается ровно один раз. Бродячий Q внутри формы, который вытолкнул бы состояние вызывающего, отвергается, а не применяется. С RenderFallbackPolicy равным rfpReport этот отказ появляется в диагностике, что позволяет легко отличить неряшливого производителя от проблемы рендеринга:

// Diags: THPDFRenderFallbackDiagnostics (объявлен в HPDFPageRenderer)
Pdf.RenderFallbackPolicy := rfpReport;
Bmp := Pdf.RenderLoadedPageToBitmap(PageIndex, 150);
try
  Diags := Pdf.GetLastRenderFallbackDiagnostics;
  for J := 0 to High(Diags) do
    if (Diags[J].Kind = rfkInvalidObject) and (Diags[J].OperatorName = 'Q') then
      Log.Add(Format('page %d: unbalanced Q refused - %s',
        [PageIndex + 1, string(Diags[J].Reason)]));
finally
  Bmp.Free;  // Free безопасен на nil
end;

Захват клипа только когда кадр его меняет

Прямолинейная починка — читать GDI-регион клипа на каждом q и возвращать его на каждом Q. Это корректно. Это еще и дорого ровно на тех страницах, которые используют q больше всего, потому что генераторы любят заворачивать каждый маленький рисунок в q 1 0 0 1 x y cm ... Q чисто ради сдвига начала. Каждая такая пара тогда создавала бы GDI-регион, копировала в него клип и снова удаляла. Когда несколько страниц рендерятся параллельно, потоки кончают конкуренцией за GDI-объекты-регионы вместо растеризации, и ускорение от параллельного конвейера рендера сжимается соответственно

Ленивый захват GDI-клипа в рендерере HotPDF: q записывает только глубину стека и целевую поверхность, регион клипа читается прямо перед тем, как W, n, текстовый клип или вход в форму меняют клиппинг, а Q восстанавливает и освобождает его, так что страницы, полные пар q cm Q, не аллоцируют ни одного GDI-региона
Создание GDI-региона на каждом q заставляет рендер-потоки конкурировать за объекты-регионы, поэтому клип захватывается, только когда оператор вот-вот изменит клиппинг

HotPDF откладывает дорогую часть. q лишь записывает глубину стека и поверхность, которой принадлежит, в массиве кадров, растущем удвоением с 16 и переиспользуемом по всей странице, так что цепочка числовых трансформаций не аллоцирует ни одного GDI-объекта. Клип захватывается в первый раз, когда что-то в этом кадре собирается его изменить: отрисовка пути или n с отложенным W или W*, заливка паттерном, клип, оставленный режимами рендеринга текста 4–7, или вход в форму. На Q захваченный кадр возвращает свой регион и освобождает его, а кадр, никогда не менявший клип, выталкивает только числовое состояние. Обычная страница тогда не аллоцирует ни одного региона, а клиппящая страница платит ровно один регион за каждое реальное изменение клипа. Если вы строите собственное GDI-устройство для интерпретатора PDF, та же форма умещается в несколько десятков строк:

// Эскиз для вашего собственного GDI-устройства; ничто из этого не является API HotPDF
// нужны Windows, SysUtils и Math в uses
type
  TClipFrame = record
    Region: HRGN;
    Captured: Boolean;
  end;

procedure TMyDevice.PushState;            // q: ни одного вызова GDI
begin
  if FCount = Length(FFrames) then
    SetLength(FFrames, Max(16, Length(FFrames) * 2));
  FFrames[FCount].Region := 0;
  FFrames[FCount].Captured := False;
  Inc(FCount);
end;

procedure TMyDevice.BeforeClipChange;     // вызов перед W n, W f, текстовым клипом, BBox формы
var
  R: HRGN;
  Res: Integer;
begin
  if (FCount = 0) or FFrames[FCount - 1].Captured then Exit;
  R := CreateRectRgn(0, 0, 0, 0);
  if R = 0 then RaiseLastOSError;
  Res := GetClipRgn(FDC, R);              // 1 скопирован, 0 клипа нет, -1 ошибка
  if Res <= 0 then
  begin
    DeleteObject(R);
    R := 0;
    if Res < 0 then RaiseLastOSError;
  end;
  FFrames[FCount - 1].Region := R;
  FFrames[FCount - 1].Captured := True;
end;

procedure TMyDevice.PopState;             // Q
begin
  if FCount = 0 then Exit;                // несбалансированный Q: игнорируем
  Dec(FCount);
  if FFrames[FCount].Captured then
    SelectClipRgn(FDC, FFrames[FCount].Region);  // Region 0 убирает клип
  if FFrames[FCount].Region <> 0 then
    DeleteObject(FFrames[FCount].Region);
end;

Две детали в том эскизе несут большую часть веса. Ноль от GetClipRgn — не ошибка: он означает, что у контекста устройства в тот момент не было клипа. Передача нулевого региона в SelectClipRgn на Q — именно то, что убирает клип, которого не существовало на парном q. Считать 0 неудачей — оставить внутренний клип на месте. Вторая деталь — флаг Captured, который не дает второму изменению клипа в том же кадре перезаписать сохраненный регион уже суженным

Где сохраненный клип и правило advance останавливаются

Сохраненный клип — GDI-регион в пикселях устройства. Он точен для рендерящегося битмапа и бессмыслен для любой другой поверхности, так что каждый кадр записывает еще и контекст устройства, с которого снят, а восстановление пропускается, когда контекст сменился, — например, пока группа прозрачности рендерится в собственный битмап слоя

На текстовой стороне правило advance размещает каждый глиф по той ширине, которую ему дали. Когда простой шрифт опускает свой массив /Widths и встроенная программа шрифта недоступна, advance хорош ровно настолько, насколько хорош fallback ширин, а §9.4.4 ничего не говорит о том, откуда эта ширина берется. Регрессионные тесты для этого живут на файлах, которых простые сгенерированные документы почти никогда не содержат: один с Tf 1 и масштабированной Tm, один с ненулевыми Tz и Tw на выключенном тексте и один с клипом W n внутри q ... Q и содержимым, нарисованным снаружи клип-области. Корректный рендер третьего показывает это внешнее содержимое целиком

Из кода приложения ничто из этого не требует никакой настройки: вызов — тот, что показан в рендеринге страницы PDF в битмап, и оба правила применяются к каждой странице, которую вы так рендерите. Поддерживаемые версии Delphi и C++Builder и детали лицензирования перечислены на странице HotPDF Delphi PDF Component