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

PDF text advance и q/Q clip restore в Delphi рендер

Page рендерът на 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, и двете дойдоха от реални страници, рендирани със смачкани думи или clip региони, изтичащи отвъд своя Q. Първият бъг е аритметика, която изглежда вярна, докато някой producer не запише размера на шрифта в матрицата. Вторият е коректност поправка, която почти ни струва паралелното ускорение на рендирането, а начинът, по който си върнахме скоростта, си струва да знаете, ако пишете какъвто и да е GDI-базиран PDF device

Защо текстът се смачква на купчина, когато PDF ползва Tf 1?

Защото старият advance код прибавяше text-space разстояние направо към транслационната компонента на Tm, все едно text space и user space винаги имат един и същ мащаб. Множество реални producer-и задават размера на шрифта на 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 точки. Всяка глиф отиваше дванайсетина от знак след предишния, така че ред обикновен текст се рендираше като тъмно размазване в левия маргин, докато същият файл изглеждаше съвършен във всеки друг viewer

Защо текстът се смачква под Tf 1 в HotPDF рендера: при 12 0 0 12 72 700 Tm глиф от 500 единици трябва да напредне 0.5 text-space единици, които Tm мащабира на 6 точки, докато старият код прибавяше 0.5 направо към Tm.e и рендираше ред обикновен текст като размазване от по дванайсетина знак на глиф
Producer-и, кодиращи размера на шрифта в текстовата матрица, караха всеки глиф да отива дванайсетина от знак след предишния — дефект, невидим върху собствения изход на библиотеката
// Content stream от producer, кодиращ размера в 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 adjustment: без Th и пак само Tm.e
Tm.e := Tm.e - NumValue * FontSize / 1000;

Tm.e съкращението не беше единственият дефект в този блок. Word spacing Tw се изразява в нескалирани text space единици, а старият код го умножаваше по FontSize / 1000, така че под Tf 12 подравнен ред губеше почти цялото си междудумно разстояние. Хоризонталното мащабиране Th важеше за глифната ширина, но не за Tc или Tw, а kerning adjustment-ът на TJ го прескачаше изцяло. Нерисуващият път, който напредва невидим текст в render режим 3 — такъв, какъвто ползват OCR текстовите слоеве — и текстът в скрит optional content носеха частно копие на същата аритметика, така че всичко, рисувано след невидимо изпълнение, тръгваше от грешна позиция. Text state бъгове в рендер рядко провалят високо: като бъговете с operand индекс и resource име, които веднъж занулиха Tc, Tw и Tz без нито една грешка, и тези раждаха правдоподобни страници върху собствения изход на библиотеката и се чупеха само по файлове от други producer-и

Как 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 е adjustment-ът на TJ, а Th е Tz, делено на 100. Новото Tm е [1 0 0 1 tx 0] × Tm, което в HotPDF е helper-ът HPDFTranslateTextMatrix: той прибавя X и Y през матричните коефициенти a, b, c и d, вместо да пише направо в e и f. По §9.3.3 Tw важи само за еднобайтовия character code 32, така че многобайтови CID кодове никога не вземат word spacing на хоризонталния път. Същият helper вече движе Td, TD, T*, операторите ' и ", adjustment-ите на TJ и скрития текстов път, което значи, че една-единствена функция притежава правилото

ISO 32000-1 9.4.4 глифен advance в 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 number елемент: същото пространство, същото Th
Adjustment := -Items[I].NumValue * State.Text.FontSize / 1000;
HPDFTranslateTextMatrix(State.Text.Tm, Adjustment * State.Text.HorizScale / 100, 0);

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

Какво всъщност записва q/Q в PDF graphics state?

ISO 32000-1 §8.4.2 изброява текущия clipping път като част от graphics state, така че Q трябва да възстанови клипа точно какъвто е бил на съответното q, не само числовите параметри. HotPDF вече държеше стек на graphics state с CTM, цветове, line параметри и text state, но GDI пази клипа в device context-а, извън този стек. Копие на числовото състояние следователно възстановяваше всичко освен клипа, а клип, инсталиран с W n вътре в блок q ... Q, продължаваше да отрязва всяка следваща операция на страницата. Form XObjects добавиха втори маршрут към същия провал, защото §8.10 дава на форма неявно save и restore около съдържанието ѝ, а реално form съдържание понякога оставя собствените си q оператори небалансирани, макар спецификацията да изисква те да се сдвояват. Рендерът вече вика CaptureClipBeforeChange и SaveDC, преди да изпълни форма, а след като формата свърши, изхвърля всеки записан регион, по-дълбок от входната дълбочина, и вика RestoreDC, така че всеки записан HRGN има точно един път за освобождаване

Лениво улавяне на клип с THPDFSavedClipState

Поправката, която излезе, записва по един запис THPDFSavedClipState на q, но отлага скъпата част, докато кадърът първо не модифицира клипа. Записът държи region handle-а, дълбочината на стека, към която принадлежи, device context-а, от който е взет, и флага Captured. DevPushState попълва само дълбочината и DC-то и разраства масива с кадри чрез удвояване от 16, така че content stream, пълен с q 1 0 0 1 x y cm ... Q, не заделя нито един GDI обект. Оператори, които предстоят да сменят клипирането — тоест рисуване на път със закачено W или W*, операторът n, pattern fill-ове и влизане в форма — викат първо CaptureClipBeforeChange

Лениво улавяне на GDI клип в HotPDF рендера: DevPushState записва само дълбочина и DC на всяко q, CaptureClipBeforeChange прочита региона точно преди W, n или влизане в форма да сменят клипирането, DevPopState го възстановява и изтрива на Q, а допъващата версия, улавяща на всяко q, свали паралелния throughput на около 1.15 пъти single-threaded
Създаването на GDI регион на всяко q гладуваше render нишките, така че улавянето вече става само когато оператор предстои да смени клипирането, а вратата за ускорение 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, а на страници, съставени предимно от числови трансформации, render нишките прекарваха времето си в съперничене за GDI region обекти вместо да растеризират. Паралелната render линия падна от очакваната си печалба на около 1.13 до 1.20 пъти single-threaded throughput и провали вратата за 1.5 пъти ускорение в benchmark набора. С лениво улавяне и преизползван капацитет на кадрите същият benchmark минава оригиналната врата от 1.5 пъти пак. Малки TrueType glyph antialiasing влезе в същия release и беше очевидният заподозрян, но регресията се проследи до заделянето на региони — добро напомняне да мериш, преди да обвиниш най-новата функция

Къде са границите на този подход?

Записаният клип е GDI регион в device пиксели, така че е точен за рендирания битмап и безсмислен за всяка друга цел. Затова всеки кадър записва своя device context, а DevPopState прескача възстановяването, когато DC-то се е сменило — например докато transparency group рендира в собствения си layer битмап. GetClipRgn, връщаща нула, е легитимен резултат, значещ няма клип, а възстановяването му с SelectClipRgn(FDC, 0) е точно това, което коректно маха клип, който не е съществувал на съответното q. На текстовата страна поправката оправя къде отива всеки глиф, но не измисля ширини: ако шрифт пропусне своя /Widths масив и вградената програма е недостъпна, advance-ът е все още толкова добър, колкото width fallback-ът. Като регресионно тествате тази зона, дръжте поне един fixture с Tf 1 и мащабирано Tm, един с ненулеви Tz и Tw и един с клип вътре в q ... Q, следван от съдържание извън него, защото нито един от тях не се показва в документи, генерирани от самата библиотека

Ако управлявате рендера от приложен код, нищо не се мени в извикващия модел, описан в рендирането на PDF страница към битмап, а страници, показвали размазани редове или отрязано съдържание, просто би трябвало да рендират коректно на 2.754.0 и по-нови. Подробности за компонента, поддържаните Delphi и C++Builder версии и лицензирането са на продуктовата страница на HotPDF Delphi PDF Component