Рендерер страниц 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 пункта. Каждый глиф приземлялся на одну двенадцатую символа после предыдущего, так что строка основного текста рисовалась тёмной размазней у левого поля, тогда как тот же файл выглядел безупречно во всех остальных вьюверах
// Поток содержимого от производителя, кодирующего размер в 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 и путь скрытого текста, то есть одно правило держит одна функция
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
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