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

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

Рендерер страниц HotPDF Delphi Component теперь продвигает текст, вычисляя смещение каждого глифа в text space, tx = ((w0 − Tj/1000) × Tfs + Tc + Tw) × Th, как определяет ISO 32000-1 §9.4.4, и затем двигая текстовую матрицу по её линейной части через HPDFTranslateTextMatrix. Клиппинг сохраняется на кадр q и восстанавливается на Q, но GDI-регион захватывается, только когда кадр реально меняет клип. Обе починки вышли в HotPDF 2.754.0, и обе родом из реальных страниц, рисовавшихся со слипшимися словами или с клип-регионами, протекавшими за свой Q. Первый баг — арифметика, выглядящая правильной, пока производитель не запишет размер шрифта в матрицу. Вторая — починка корректности, чуть не стоившая нам параллельного ускорения рендера, и то, как мы вернули скорость, стоит знать, если вы пишете хоть какой-нибудь GDI-бэкенд PDF-устройства

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

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

Почему текст слипается под Tf 1 в рендерере HotPDF: при 12 0 0 12 72 700 Tm глиф в 500 единиц обязан продвинуться на 0.5 единиц text space, которые Tm масштабирует до 6 пунктов, тогда как старый код прибавлял 0.5 прямо к Tm.e и рисовал строку основного текста размазней по одной двенадцатой символа на глиф
Производители, кодирующие размер шрифта в текстовой матрице, заставляли каждый глиф приземляться на одну двенадцатую символа после предыдущего — дефект, невидимый на собственном выводе библиотеки
// Поток содержимого от производителя, кодирующего размер в Tm, а не Tf:
//   BT
//   /F1 1 Tf
//   12 0 0 12 72 700 Tm
//   [(Hel) 30 (lo) -250 (world)] TJ
//   ET

// Старое продвижение (упрощённо): дистанция прибавляется к 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, так что при Tf 12 выключенная по ширине строка теряла почти весь межсловный зазор. Горизонтальное масштабирование Th применялось к ширине глифа, но не к Tc или Tw, а kerning-подстройка TJ пропускала его вовсе. Непоющий путь, продвигающий невидимый текст режима рендеринга 3 — того сорта, каким пользуются OCR-текстовые слои, — и текст внутри скрытого optional content несли частную копию той же арифметики, так что всё, нарисованное после невидимого прогона, стартовало с неверной позиции. Баги текстового состояния в рендерере редко отказывают громко: как баги индекса операндов и имён ресурсов, обнулившие когда-то Tc, Tw и Tz без единой ошибки, эти давали правдоподобные страницы на собственном выводе библиотеки и ломались только на файлах чужих производителей

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

ISO 32000-1 §9.4.4 определяет advance целиком в text space и применяет его к текстовой матрице как матрицу трансляции, так что ответ — сперва посчитать tx, а масштабирование, поворот и скос оставить Tm. Для горизонтального письма tx равен ((w0 − Tj/1000) × Tfs + Tc + Tw) × Th, где w0 — ширина глифа в тысячных em, Tj — подстройка TJ, а Th — это Tz, делённый на 100. Новая Tm — это [1 0 0 1 tx 0] × Tm, что в HotPDF делает хелпер HPDFTranslateTextMatrix: он прибавляет X и Y через коэффициенты матрицы a, b, c и d вместо прямой записи в e и f. По §9.3.3 Tw применяется только к однобайтовому коду символа 32, так что многобайтовые CID-коды никогда не подхватывают межсловный интервал на горизонтальном пути. Тот же хелпер теперь ведёт Td, TD, T*, операторы ' и ", подстройки TJ и путь скрытого текста, то есть одно правило держит одна функция

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; маршрут через коэффициенты матрицы сохраняет правильность масштабированного, повёрнутого и скошенного текста
procedure HPDFTranslateTextMatrix(var Matrix: THPDFAffineMatrix; X, Y: Double);
begin
  Matrix.e := Matrix.e + Matrix.a * X + Matrix.c * Y;
  Matrix.f := Matrix.f + Matrix.b * X + Matrix.d * Y;
end;

// Горизонтальный advance глифа, ISO 32000-1 9.4.4
W   := HPDFFontCharWidth(F, Code);
Adv := W * State.Text.FontSize / 1000 + State.Text.CharSpace;
if (Code = 32) and not F.CID2Byte then
  Adv := Adv + State.Text.WordSpace;         // Tw в text space, без масштаба
Adv := Adv * State.Text.HorizScale / 100;    // Th применяется ко всей сумме
HPDFTranslateTextMatrix(Tm, Adv, 0);

// Числовой элемент TJ: тот же space, тот же Th
Adjustment := -Items[I].NumValue * State.Text.FontSize / 1000;
HPDFTranslateTextMatrix(State.Text.Tm, Adjustment * State.Text.HorizScale / 100, 0);

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

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

ISO 32000-1 §8.4.2 перечисляет текущий путь клиппинга в составе графического состояния, поэтому Q обязан восстановить клип ровно таким, каким он был на парном q, а не только числовые параметры. HotPDF уже держал стек графического состояния с CTM, цветами, параметрами линий и текстовым состоянием, но GDI хранит клип в контексте устройства, вне этого стека. Копия числового состояния, таким образом, восстанавливала всё, кроме клипа, и клип, поставленный W n внутри блока q ... Q, продолжал обрезать каждую последующую операцию на странице. Form XObject добавили второй путь к тому же сбою: §8.10 даёт форме неявные save и restore вокруг содержимого, а реальное содержимое форм иногда оставляет собственные операторы q несбалансированными, хоть спецификация и требует их парности. Рендерер теперь зовёт CaptureClipBeforeChange и SaveDC перед исполнением формы, а после её завершения выбрасывает все сохранённые регионы глубже глубины входа и зовёт RestoreDC, так что у каждого сохранённого HRGN ровно один путь освобождения

Ленивый захват клипа с THPDFSavedClipState

Вышедшая починка сохраняет по одной записи THPDFSavedClipState на q, но откладывает дорогую часть до первого изменения клипа кадром. Запись держит хендл региона, глубину стека, к которой он относится, контекст устройства, с которого он снят, и флаг Captured. DevPushState лишь заполняет глубину и DC и растит массив кадров удвоением с 16, так что поток содержимого, набитый q 1 0 0 1 x y cm ... Q, не аллоцирует ни одного GDI-объекта. Операторы, собирающиеся менять клиппинг, — то есть отрисовка пути с отложенными W или W*, оператор n, заливки паттернами и вход в форму, — сперва зовут CaptureClipBeforeChange

Ленивый захват GDI-клипа в рендерере HotPDF: DevPushState записывает на каждый q только глубину и DC, CaptureClipBeforeChange читает регион прямо перед тем, как W, n или вход в форму меняют клиппинг, DevPopState восстанавливает и удаляет его на Q, а жадная версия, захватывавшая на каждом q, роняла параллельную пропускную способность примерно до 1.15 однокнопточной
Создание GDI-региона на каждом q морило рендер-потоки голодом, поэтому захват теперь случается, только когда оператор вот-вот изменит клиппинг, и фильтр ускорения в 1.5 раза снова проходится
procedure THPDFPageRenderer.CaptureClipBeforeChange;
var
  Index, ClipResult: Integer;
  Region: HRGN;
begin
  ClearSavedClipRegions(FGSStack.Count);
  Index := FSavedClipCount - 1;
  if (Index < 0) or FSavedClips[Index].Captured or
     (FSavedClips[Index].StackDepth <> FGSStack.Count) or
     (FSavedClips[Index].DC <> FDC) then Exit;   // уже сохранён, или не наш
  Region := CreateRectRgn(0, 0, 0, 0);
  if Region = 0 then RaiseLastOSError;
  ClipResult := GetClipRgn(FDC, Region);         // 0 значит клипа нет вовсе
  if ClipResult <= 0 then
  begin
    DeleteObject(Region);
    Region := 0;
    if ClipResult < 0 then RaiseLastOSError;
  end;
  FSavedClips[Index].Region := Region;
  FSavedClips[Index].Captured := True;
end;

procedure THPDFPageRenderer.DevPopState;
var
  Index: Integer;
begin
  ClearSavedClipRegions(FGSStack.Count);
  Index := FSavedClipCount - 1;
  if (Index >= 0) and (FSavedClips[Index].StackDepth = FGSStack.Count) then
  begin
    if FSavedClips[Index].Captured and (FSavedClips[Index].DC = FDC) then
      SelectClipRgn(FDC, FSavedClips[Index].Region);  // Region 0 убирает клип
    if FSavedClips[Index].Region <> 0 then
      DeleteObject(FSavedClips[Index].Region);
    Dec(FSavedClipCount);
  end;
  FGSStack.Pop;
end;

Измеренная цена жадной версии — причина существования этого дизайна. Первая корректная реализация создавала и читала GDI-регион на каждом q, и на страницах, собранных по большей части из числовых трансформаций, рендер-потоки проводили время в конкуренции за GDI-объекты-регионы вместо растеризации. Параллельный конвейер рендера упал с ожидаемого выигрыша примерно до 1.13–1.20 однокнопточной пропускной способности и провалил фильтр ускорения 1.5 в бенчмарк-наборе. С ленивым захватом и переиспользуемой ёмкостью кадров тот же бенчмарк снова проходит исходный фильтр 1.5. В том же релизе приземлилось сглаживание мелких TrueType-глифов, и оно было очевидным подозреваемым, но регрессия проследилась до аллокации регионов — доброе напоминание мерить, прежде чем обвинять новейшую фичу

Где пределы этого подхода?

Сохранённый клип — GDI-регион в пикселях устройства, так что он точен для рендерящегося битмапа и бессмыслен для любой другой цели. Поэтому каждый кадр записывает свой контекст устройства, а DevPopState пропускает восстановление, когда DC сменился, — например, пока группа прозрачности рендерится в собственный bitmap слоя. Ноль от GetClipRgn — легитимный результат, значащий «клипа нет», и восстановление через SelectClipRgn(FDC, 0) — то, что корректно убирает клип, которого не существовало на парном q. На текстовой стороне починка исправляет, куда идёт каждый глиф, но не выдумывает ширин: если шрифт опустил массив /Widths, а встроенная программа недоступна, advance всё ещё хорош ровно настолько, насколько хорош fallback ширин. Регрессионно тестируя эту область, держите хотя бы одну фикстуру с Tf 1 и масштабированной Tm, одну с ненулевыми Tz и Tw и одну с клипом внутри q ... Q и содержимым снаружи — ни одна из них не встречается в документах, порождённых самой библиотекой

Если вы рулите рендерером из кода приложения, в паттерне вызовов, описанном в статье о рендеринге страницы PDF в битмап, ничего не меняется, а страницы, рисовавшиеся прежде размазанными строками или обрезанным содержимым, должны просто отрисоваться правильно на 2.754.0 и новее. Детали по компоненту, поддерживаемым версиям Delphi и C++Builder и лицензированию — на странице продукта HotPDF Delphi PDF Component