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

PDF Renderer не рисува нищо: четири тихи бъга в Delphi

PDF renderer, който не рисува нищо, обикновено няма никакъв бъг в кода си за рисуване. В HotPDF Component за Delphi и C++Builder четири отделни дефекта карали страниците да се рендират празни, докато всеки лог ред оставал чист: операнди-имена, носещи водеща наклонена черта, обърната конкатенация на cm, и token индекс, четящ нула. Никой от тях не хвърляше грешка. Никой не логваше. Content stream-ът се токенизираше правилно, dispatcher-ът на операторите разпознаваше всеки оператор, image XObject беше декодиран в валиден bitmap, а после страницата излизаше празна. Тази комбинация — pipeline, докладващ успех на всяка стъпка и произвеждащ нищо видимо — е сигнатурата на lookup или индекс, който тихо пропуска, вместо да се провали. Това е post-mortem на едно такова семейство, и на дисциплината за тестове, позволила му да оцелее през 38 издания

Защо PDF renderer не рисува абсолютно нищо?

Защото неуспешно търсене на ресурс в PDF renderer е неразличимо от празна страница. Операндите-имена в content stream и ключовете в речника с ресурси са две различни пространства от низове, а HotPDF ги сравняваше кръстосано без нормализация. Токенизаторът чете /Im0 и пази наклонената черта, защото това е token-ът; заредения речник /Resources /XObject съхранява ключа като Im0, защото parser-ът маха разделителя, когато изгражда ключовете на речника. Затова всеки FindValue спрямо име-операнд връщаше -1. Радиусът на поражение беше по-широк от изображенията. ISO 32000-1 §8.9 покрива Do, §8.4 покрива gs и неговото търсене в /ExtGState, §8.6 покрива cs и CS, а §8.7.4.3 покрива sh. Всичките пет оператора ключуваха своя ресурсен под-речник по суровия операнд, така че всичките пет пропускаха. Именуваните цветови пространства падаха обратно на DeviceGray, което превръща 1 scn в бяло мастило върху бяла страница. Image XObjects изобщо никога не бяха рисувани — пътят за bitmap изображение на практика не беше работил от деня, в който бе доставен. Поправката е unit-level помощна функция, приложена при всяко търсене, ключувано от операнд, което е единственият начин да се задържи конвенцията от повторно отклоняване

// Page content stream, the ordinary image-placement idiom:
//   q
//   /GS0 gs
//   200 0 0 120 60 400 cm
//   /Im0 Do
//   Q
// The operand token is '/Im0'. The resource dictionary key is 'Im0'.

function HPDFStripNameSlash(const N: AnsiString): AnsiString;
begin
  Result := N;
  if (Result <> '') and (Result[1] = '/') then
    Delete(Result, 1, 1);
end;

// Every resource lookup keyed by an operand name goes through the helper.
Name := HPDFStripNameSlash(Name);
XObjIdx := FPageResources.FindValue('XObject');
if XObjIdx < 0 then
  Exit;
// The /XObject sub-dictionary may itself be an indirect reference.
XObjDict := FAccess.ResolveDictionary(FAccess.Context,
  FPageResources.GetIndexedItem(XObjIdx));
if XObjDict = nil then
  Exit;

Втори, свързан пропуск седеше един слой по-надолу. Renderer-ът имаше типизирани resolver-и само за stream-ове и речници, така че indirect референция, сочеща към top-level масив-обект — обичайният /CS0 5 0 R с [/Separation ...] от другата страна — се разрешаваше в nil през двата и падаше обратно на неразрешена връзка. Добавянето на генеричен object resolver поправи именуваните цветови пространства и масивите от функции с едно движение. Ако свързвате shading речници, същата дисциплина за разрешаване важи за пътя за axial и radial shading, където записът /Function много често е indirect

Операторът cm и конкатенация, написана наопаки

Вторият дефект поставяше изображения приблизително на сто хиляди пиксела извън страницата, което изглежда точно като да не са нарисувани изобщо. ISO 32000-1 §8.3.4 дефинира PDF трансформации с редови вектори, а операторът cm конкатенира своята операндна матрица M върху текущата трансформационна матрица като M × CTM — M влиза в сила първо, съществуващата CTM след това. HotPDF сглобява матрици чрез HPDFMatMul(A, B), което прилага B преди A. Правилното извикване затова подава старата CTM като A. Доставеният код подаваше операндната матрица като A, произвеждайки CTM × M

Обърнатият ред е безобиден за единичен cm и катастрофален за стандартната двустъпкова идиома. Поставете изображение с 1 0 0 1 x y cm, следвано от w 0 0 h 0 0 cm, и правилният каскад мащабира единичния квадрат по (w, h), а после го транслира по (x, y). Под обърнатия каскад транслацията влиза първа, а мащабът я умножава, така че изображение, номинално на (60, 400), мащабирано до 200 на 120, кацва на (12000, 48000). Тестът за clip в началото на blit-а го отхвърля, blit-ът се пропуска, и нищо никъде не докладва проблем

// HPDFMatMul(A, B) applies B first, then A.
// ISO 32000-1 cm semantics: new CTM = M x CTM, so M must be B.

// Wrong, and shipped for 38 versions:
GS.CTM := HPDFMatMul(HPDFMatFromOps(NumAt(6), NumAt(5), NumAt(4),
                                    NumAt(3), NumAt(2), NumAt(1)), GS.CTM);

// Correct:
GS.CTM := HPDFMatMul(GS.CTM, HPDFMatFromOps(NumAt(6), NumAt(5), NumAt(4),
                                            NumAt(3), NumAt(2), NumAt(1)));

Това, което прави случая поучителен, е, че същият изходен файл вече съдържаше правилния ред. Записът /Matrix на Form XObject имаше същата обърната композиция, но пътят за Type 3 глиф и пътят за outline-и на вградени глифи и двата бяха правилни от самото начало, защото позиционирането на глифи видимо се свива към началото, когато се инвертира, и някой вече е бил принуден да го поправи. Две конвенции съжителстваха в един unit три дузини издания, всяка правилна в собствената си функция, и никой рецензент не забеляза, защото нито едно извикване не изглеждаше грешно изолирано

Какво се случва, когато token индекс е с едно отклонение?

Получавате дванадесет оператора, синтактично обработени и семантично мъртви. Аксесорът за операнд в renderer-а е NumAt(Back), който чете Tokens[OpIndex - Back], а OpIndex е индексът на самия token на оператора. Оператор с единичен операнд затова намира своето число на back 1. Дванадесет от тях бяха написани като NumAt(0), което чете token-а на оператора, проваля проверката за вида ctOperandNumber, и връща нулевата стойност по подразбиране. Списъкът е Tc, Tw, Tz, TL, Ts и Tr от операторите за текстово състояние на ISO 32000-1 §9.3, плюс w, J, j, M, ri и i от операторите за графично състояние на §8.4.3. Разредката между символи и думи ставаше no-op, хоризонталното мащабиране никога не се прилагаше, leading оставаше нула, така че T* никога не напредваше ред, text rise не правеше нищо, render режимът винаги беше fill, и всеки щрих във всеки документ излизаше като косъм от 1 пиксел, независимо от декларираната ширина на линията. Оператори с множество операнди като m, rg и Tm използваха NumAt(1..6) и бяха всичките правилни, така че рецензент, сканиращ функцията, виждаше стена от правдоподобна индексна аритметика с дванадесет грешни записа, вградени в нея

function NumAt(Back: Integer): Double;
begin
  Result := 0;
  if (OpIndex - Back >= 0)
    and (Tokens[OpIndex - Back].Kind = ctOperandNumber) then
    Result := Tokens[OpIndex - Back].NumValue;
end;

// OpIndex addresses the operator token, so a lone operand sits at back 1.
else if Op = 'Tc' then GS.Text.CharSpace := NumAt(1)   // previously NumAt(0)
else if Op = 'TL' then GS.Text.Leading   := NumAt(1)   // previously NumAt(0)
else if Op = 'Tr' then GS.Text.RenderMode := Round(NumAt(1))
else if Op = 'w'  then GS.LineWidth      := NumAt(1)   // previously NumAt(0)

Защо тестовият пакет остана зелен 38 издания?

Защото проверките бяха твърде слаби, за да различат рендирана страница от частично рендирана. Rendering smoke тестовете проверяваха неща като изходният bitmap да не е изцяло черен, страницата да не е празна, или digest-ът на изображението да не е нула. Всяко от тях важи, когато текстът се рендира, а изображенията не. Текстът се рисуваше добре, така че frame buffer-ът никога не беше еднороден, digest-ът никога не беше нула, и пакетът докладваше успех, докато целият pipeline за изображения беше мъртъв код на практика. Слабите проверки са примамливи за графика точно защото силните изглеждат крехки. Никой не иска тест, който се чупи, когато ръб на anti-aliasing се измести с един пиксел, така че естественото отстъпление е да се провери нещо, което никаква разумна промяна не би нарушила — а това отстъпление ви оставя с предикати, които и неразумна промяна не може да наруши. Тест за colour space Separation проверяваше, че изходът е различим от черно; сиво върху бяло го минаваше, както и бяло върху бяло. Тестът не измерваше дали е бил нарисуван правилният цвят. Той измерваше дали изобщо се е случило нещо на платното

Как се пише rendering проверка, която действително се проваля?

Брои пиксели на очаквания цвят, в очакваното количество, и оставя позицията и размера да произтекат от броя. Заменящата дисциплина е ръчно изграден минимален PDF, по един визуален факт на файл, и проверка колко пиксела попадат в толеранс на конкретен RGB тройка. Изображение 200 на 120 от чисто червено, поставено на известен offset, трябва да произведе приблизително 24000 червени пиксела. Ако търсенето на ресурс пропусне, броят е 0. Ако каскадът на cm е обърнат, броят е 0. Ако изображението се рендира в грешно цветово пространство, броят е 0. Едно число хваща и трите, а лентата на толеранс поглъща шума от anti-aliasing, който караше хората да се страхуват от точно сравнение изначало

function CountPixelsNear(Bmp: TBitmap; R, G, B, Tol: Integer): Integer;
var
  X, Y: Integer;
  C: TColor;
begin
  Result := 0;
  for Y := 0 to Bmp.Height - 1 do
    for X := 0 to Bmp.Width - 1 do
    begin
      C := Bmp.Canvas.Pixels[X, Y];
      if (Abs(GetRValue(C) - R) <= Tol)
        and (Abs(GetGValue(C) - G) <= Tol)
        and (Abs(GetBValue(C) - B) <= Tol) then
        Inc(Result);
    end;
end;

// A 200x120 red image placed at 60,400 must paint about 24000 red pixels.
Check(CountPixelsNear(Bmp, 255, 0, 0, 12) > 20000,
  'image XObject was never drawn');

Четири smoke теста бяха пренаписани по този начин — трансформация на тон Type 4, поставяне на изображение Do, случай на видимост при optional content и режим на щрих Tr — и заедно те разкриха цялото семейство. Това е истинският урок, и се обобщава отвъд тази codebase: в rendering pipeline проверката трябва да назове цвета. Всичко по-меко е проверка, че renderer-ът се е изпълнил, не че е рисувал. Ако изграждате собствен harness за страница-към-bitmap, обиколката на растеризация на страница е естественото място да прикачите помощна функция за броене на пиксели към първия си regression тест

Честни граници

Два лимита си струва да се посочат ясно. Режими на text clipping от 4 до 7 се рисуват като своя базов fill или stroke режим, защото renderer-ът не моделира натрупани clip paths от очертанията на глифи; документи, разчитащи на текст-оформено clipping, ще рендират текста, а не изрязания artwork отдолу. И дисциплината за броене на пиксели, описана тук, е техника за smoke тест, не conformance suite — тя доказва, че конкретен визуален факт е стигнал до frame buffer-а, което е много по-нисък праг от доказването, че изходът съвпада с референтен растеризатор. Той е, обаче, точно прагът, който тези четири бъга не успяха да преминат за три години издания

Renderer-ът, разгледан тук, се доставя като част от стандартния HotPDF Component за Delphi и C++Builder; продуктовата страница носи пълния API справочник за рендиране на страници, включително bitmap кеша и входните точки за фоново предизвличане