Рендерер 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 — это индекс самого токена оператора. Оператор с одним операндом поэтому находит своё число на back 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 версий?
Потому что утверждения были слишком слабы, чтобы отличить отрендеренную страницу от частично отрендеренной. Дымовые рендер-тесты утверждали такие вещи, как «выходной битмап не полностью чёрный», или «страница не пустая», или «дайджест изображения ненулевой». Каждое из этих условий выполняется, когда текст рендерится, а изображения нет. Текст рисовался нормально, так что кадровый буфер никогда не был однородным, дайджест никогда не был нулевым, а набор сообщал об успехе, пока весь конвейер изображений на практике был мёртвым кодом. Слабые утверждения соблазнительны для графики именно потому, что сильные выглядят хрупкими. Никто не хочет теста, ломающегося, когда край сглаживания сдвигается на один пиксель, так что естественное отступление — утверждать нечто, что не нарушит ни одно разумное изменение, — а это отступление приводит вас к предикатам, которые не нарушит и ни одно неразумное изменение. Тест цветового пространства Separation утверждал, что вывод отличим от чёрного; серое на белом проходило его, как и белое на белом. Тест измерял не то, был ли нарисован правильный цвет. Он измерял, произошло ли на холсте вообще хоть что-то
Как написать утверждение рендеринга, которое на самом деле проваливается?
Считайте пиксели ожидаемого цвета в ожидаемом количестве, и пусть позиция и размер вытекают из счёта. Заменяющая дисциплина — это вручную построенный минимальный 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 для Delphi и C++Builder; страница продукта содержит полный справочник API рендеринга страниц, включая кэш битмапов и точки входа фонового предзагрузчика