Технічна стаття

Рендерер PDF не малює нічого: чотири тихі баги Delphi

Рендерер PDF, що не малює нічого, зазвичай не має жодного багу у своєму коді малювання взагалі. У компоненті HotPDF для Delphi та C++Builder чотири окремі дефекти робили сторінки порожніми, тоді як кожен рядок логу лишався чистим: операнди-назви з провідною похилою рискою, реверсована конкатенація cm та індекс токена, що читав нуль. Жоден з них не викликав виняток. Жоден не логувався. Потік вмісту токенізувався коректно, диспетчер операторів розпізнавав кожен оператор, XObject зображення декодувався у валідний растр, а потім сторінка виходила порожньою. Це поєднання — конвеєр, що звітує про успіх на кожному етапі й не виробляє нічого видимого, — підпис пошуку чи індексу, що тихо промахується, а не провалюється. Це посмертний розбір однієї такої родини і дисципліни тестування, що дозволила їй вижити протягом 38 релізів

Чому рендерер PDF не малює нічого взагалі?

Тому що провалений пошук ресурсу в рендерері PDF невідрізнимий від порожньої сторінки. Операнди-назви потоку вмісту та ключі словника ресурсів — два різні простори рядків, і HotPDF порівнював їх без нормалізації. Токенізатор читає /Im0 і зберігає похилу риску, бо саме так виглядає токен; завантажений словник /Resources /XObject зберігає ключ як Im0, бо парсер відрізає роздільник, коли будує ключі словника. Тож кожен FindValue проти назви операнда повертав -1. Радіус ураження був ширшим за зображення. ISO 32000-1 §8.9 охоплює Do, §8.4 охоплює gs та його пошук /ExtGState, §8.6 охоплює cs та CS, а §8.7.4.3 охоплює sh. Усі п'ять операторів індексували свій піддомен ресурсів за сирим операндом, тож усі п'ять промахувалися. Іменовані колірні простори відкочувалися до DeviceGray, що перетворює 1 scn на білі чорнила на білій сторінці. XObject-зображення взагалі ніколи не малювалися — шлях растрового зображення, по суті, ніколи не працював з дня свого впровадження. Виправлення — хелпер на рівні модуля, застосований у кожному пошуку, що індексується операндом, і це єдиний спосіб не дати конвенції знову розповзтися

// 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;

Другий, пов'язаний промах сидів на шар нижче. Рендерер мав типізовані резолвери лише для потоків та словників, тож непряме посилання, що вказує на об'єкт-масив верхнього рівня — поширений /CS0 5 0 R з [/Separation ...] на іншому кінці — розв'язувалося в nil через обидва і відкочувалося до нерозв'язаного посилання. Додавання загального резолвера об'єктів виправило іменовані колірні простори та масиви функцій одним рухом. Якщо ти монтуєш словники штрихування, та сама дисципліна розв'язання застосовується до шляху осьового та радіального штрихування, де запис /Function дуже часто непрямий

Оператор 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). Тест обрізання на початку блітингу відхиляє його, бліт пропускається, і ніщо ніде не звітує про проблему

// 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 та шлях контуру вбудованого гліфа обидва зробили це правильно з самого початку, бо розміщення гліфа видимо колапсує в початок координат, коли інвертуєш його, і комусь уже довелося це виправити. Дві конвенції співіснували в одному модулі протягом трьох дюжин релізів, кожна правильна у своїй власній функції, і жоден рецензент не помітив, бо жодна точка виклику не виглядала неправильною окремо

Що стається, коли індекс токена зсунутий на один?

Отримуєш дванадцять операторів, що синтаксично оброблені й семантично мертві. Аксесор операнда в рендерері — NumAt(Back), що читає Tokens[OpIndex - Back], а OpIndex — це індекс самого токена оператора. Тож оператор з одним операндом знаходить своє число на відступі 1. Дванадцять з них були написані як NumAt(0), що читає токен оператора, провалює перевірку типу ctOperandNumber і повертає нульове значення за замовчуванням. Список: Tc, Tw, Tz, TL, Ts та Tr з операторів стану тексту ISO 32000-1 §9.3, плюс w, J, j, M, ri та i з операторів стану графіки §8.4.3. Інтервал між символами й словами перетворився на порожню операцію, горизонтальне масштабування ніколи не застосовувалося, провідність лишалася нульовою, тож T* ніколи не просувала рядок, підйом тексту не робив нічого, режим рендерингу завжди був заливкою, а кожен штрих у кожному документі виходив як волосяна лінія в 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 версій?

Тому що твердження assert були задосяжно слабкими, щоб відрізнити відрендерену сторінку від частково відрендереної. Тести-смоуки рендерингу стверджували щось на кшталт "вихідний растр не повністю чорний", "сторінка не порожня" чи "дайджест зображення ненульовий". Кожне з цього виконується, коли текст рендериться, а зображення — ні. Текст малювався добре, тож буфер кадру ніколи не був однорідним, дайджест ніколи не був нульовим, і набір тестів звітував успіх, тоді як увесь конвеєр зображень був на практиці мертвим кодом. Слабкі твердження спокусливі саме для графіки, бо сильні виглядають крихкими. Ніхто не хоче тест, що ламається, коли край згладжування зсувається на один піксель, тож природний відступ — стверджувати щось, що жодна розумна зміна не могла б порушити, а цей відступ приземляє тебе на предикати, які і жодна нерозумна зміна теж не порушить. Тест колірного простору поділу стверджував, що вихід відрізнявся від чорного; сірий на білому проходив його, і білий на білому теж. Тест вимірював не те, чи намальовано правильний колір. Він вимірював, чи щось взагалі сталося на полотні

Як написати твердження рендерингу, що справді провалюється?

Рахуй пікселі очікуваного кольору в очікуваній кількості й дозволь позиції та розміру випливти з підрахунку. Замінна дисципліна — ручно побудований мінімальний PDF, один візуальний факт на файл, і твердження про те, скільки пікселів потрапляє в межі допуску конкретного RGB-триплета. Зображення 200 на 120 чистого червоного, розміщене за відомим зсувом, повинне виробити приблизно 24000 червоних пікселів. Якщо пошук ресурсу промахується, підрахунок — 0. Якщо каскад cm реверсований, підрахунок — 0. Якщо зображення рендериться в неправильному колірному просторі, підрахунок — 0. Одне число ловить усі три, а смуга допуску поглинає шум згладжування, що спочатку й змушував людей ухилятися від точного порівняння

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');

Чотири смоук-тести переписали в такий спосіб — трансформація тону Type 4, розміщення зображення Do, випадок видимості опційного вмісту та режим штриха Tr — і разом вони виявили всю родину. Це справжній урок, і він узагальнюється за межі цієї кодової бази: у конвеєрі рендерингу твердження мусить називати колір. Будь-що м'якше — це перевірка, що рендерер запустився, а не перевірка, що він щось намалював. Якщо ти будуєш власну обв'язку сторінка-в-растр, огляд растеризації сторінки — природне місце, щоб прикрутити хелпер підрахунку пікселів до своєї першої регресії

Чесні межі

Варто прямо заявити два обмеження. Режими рендерингу обрізання тексту з 4 по 7 малюються як їхній базовий режим заливки чи штриха, бо рендерер не моделює накопичені контури обрізання з контурів гліфів; документи, що покладаються на обрізання, сформоване текстом, рендеритимуть текст, а не обрізану графіку під ним. І дисципліна підрахунку пікселів, описана тут, — це техніка смоук-тесту, а не набір відповідності специфікації — вона доводить, що конкретний візуальний факт досяг буфера кадру, а це набагато нижча планка, ніж доведення того, що вихід збігається з еталонним растеризатором. Проте це рівно та планка, яку ці чотири баги не могли подолати протягом трьох років релізів

Рендерер, обговорений тут, постачається як частина стандартного HotPDF Component для Delphi та C++Builder; сторінка продукту містить повний довідник API рендерингу сторінок, включно з кешем растрових зображень та точками входу фонового попереднього завантаження