Рендерер сторінок 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
// Старий advance (спрощено): відстань додається в 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 пропускав його повністю. Неперемальовний шлях, що просуває невидимий текст render mode 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: той самий простір, той самий 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 одиниць, а розмір робить трансформація, тож повернутий і скошений текст тримає орієнтацію замість малюватися вертикально у трансформованій точці початку. Вертикальний режим письма — єдина навмисна асиметрія: шрифт WMode 1 просувається вниз по осі y своєю вертикальною метрикою, і горизонтальне масштабування на ту вісь не застосовується
Що насправді зберігає q/Q у графічному стані PDF?
ISO 32000-1 §8.4.2 перелічує поточний кліп-шлях як частину графічного стану, тож Q мусить відновити кліп рівно таким, яким він був на відповідному q, а не лише числові параметри. HotPDF уже тримав стек графічного стану з CTM, кольорами, параметрами ліній і текстовим станом, але GDI тримає кліп у контексті пристрою, поза тим стеком. Копія числового стану, отже, відновлювала все, крім кліпу, і кліп, поставлений W n усередині блоку q ... Q, продовжував обрізати кожну пізнішу операцію на сторінці. Form XObjects додали другий маршрут до того самого збою, бо §8.10 дає формі неявні збереження і відновлення навколо її вмісту, а реальний формовий вміст часом лишає власні оператори q незбалансованими, хоча специфікація вимагає їм паруватися. Рендерер тепер викликає CaptureClipBeforeChange і SaveDC перед прогоном форми, а після завершення форми викидає будь-які збережені регіони, глибші за глибину входу, і викликає RestoreDC, тож кожен збережений HRGN має рівно один шлях вивільнення
Ледаче захоплення кліпу з THPDFSavedClipState
Виправлення, що вийшло, зберігає один запис THPDFSavedClipState на кожен q, але відкладає дорогу частину до того, як кадр уперше змінить кліп. Запис тримає хендл регіону, глибину стеку, до якої він належить, контекст пристрою, з якого взятий, і прапорець Captured. DevPushState лише заповнює глибину і DC і ростить масив кадрів подвоєнням від 16, тож потік вмісту, повний q 1 0 0 1 x y cm ... Q, не виділяє жодного GDI-об'єкта. Оператори, які збираються змінити кліпування, тобто малювання шляху з pending 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 змінився, — наприклад, поки група прозорості рендериться у власний бітмап шару. Нуль від GetClipRgn — легітимний результат, що означає відсутність кліпу, і відновлення його через SelectClipRgn(FDC, 0) — це те, що правильно знімає кліп, якого не існувало на відповідному q. На текстовому боці виправлення коригує, куди йде кожен гліф, але не вигадує ширин: якщо шрифт пропускає свій масив /Widths, а вбудована програма недоступна, advance досі лише настільки гарний, наскільки гарний запасний варіант ширини. Коли ви регресно тестуєте цю зону, тримайте щонайменше одну фікстуру з Tf 1 і масштабованою Tm, одну з ненульовими Tz і Tw і одну з кліпом усередині q ... Q, за яким іде вміст зовні, бо жодна з них не показується в документах, згенерованих самою бібліотекою
Якщо ви керуєте рендерером з коду застосунку, в шаблоні викликів, описаному в рендерингу сторінки PDF у бітмап, нічого не змінюється, а сторінки, які раніше показували розмазані рядки чи обрізаний вміст, мають просто рендеритися правильно на 2.754.0 і пізніших. Деталі про компонент, підтримувані версії Delphi і C++Builder та ліцензування — на сторінці продукту HotPDF Delphi PDF Component