Bài viết kỹ thuật

Renderer PDF không vẽ gì: bốn lỗi Delphi thầm lặng

Một renderer PDF không vẽ ra gì thường không có lỗi nào trong mã vẽ của nó cả. Trong HotPDF Component cho Delphi và C++Builder, bốn khiếm khuyết riêng biệt khiến các trang render ra trống trơn trong khi mọi dòng log vẫn sạch sẽ: operand tên mang dấu gạch chéo dẫn đầu, một phép nối cm bị đảo ngược, và một chỉ số token đọc ra số không. Không cái nào trong số đó ném ra lỗi (throw). Không cái nào trong số đó ghi log. Content stream vẫn được tokenize đúng, bộ điều phối toán tử vẫn nhận diện mọi toán tử, image XObject vẫn được giải mã thành một bitmap hợp lệ, rồi trang lại ra trống trơn. Sự kết hợp đó — một pipeline báo cáo thành công ở mọi giai đoạn nhưng không tạo ra gì hiển thị được — chính là dấu hiệu của một lượt tra cứu hay một chỉ số âm thầm trượt thay vì thất bại. Đây là một bài phân tích hậu kỳ về một họ lỗi như vậy, và về kỷ luật kiểm thử đã để nó sống sót qua 38 phiên bản

Vì sao một renderer PDF không vẽ ra bất cứ thứ gì?

Vì một lượt tra cứu tài nguyên thất bại trong một renderer PDF không thể phân biệt được với một trang trống. Operand tên trong content stream và khóa dictionary tài nguyên là hai không gian chuỗi khác nhau, và HotPDF đã so sánh giữa chúng mà không chuẩn hóa. Bộ tokenize đọc /Im0 và giữ nguyên dấu gạch chéo, vì đó chính là những gì token đó là; dictionary /Resources /XObject đã tải lưu khóa dưới dạng Im0, vì bộ phân tích cú pháp cắt bỏ ký tự phân định khi nó xây các khóa dictionary. Vì vậy mọi lần FindValue với một tên operand đều trả về -1. Bán kính ảnh hưởng rộng hơn cả hình ảnh. ISO 32000-1 §8.9 bao quát Do, §8.4 bao quát gs và lượt tra cứu /ExtGState của nó, §8.6 bao quát csCS, và §8.7.4.3 bao quát sh. Cả năm toán tử đều dùng operand thô làm khóa cho sub-dictionary tài nguyên của chúng, nên cả năm đều trượt. Không gian màu có tên rơi về DeviceGray, biến 1 scn thành mực trắng trên nền trắng. Image XObject hoàn toàn không bao giờ được vẽ — trên thực tế, đường xử lý hình ảnh bitmap chưa từng hoạt động kể từ ngày nó ra mắt. Cách sửa là một hàm trợ giúp ở mức unit được áp dụng tại mọi lượt tra cứu theo khóa operand, đó là cách duy nhất để quy ước này không trôi dạt lần nữa

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

Một lượt trượt thứ hai, có liên quan, nằm sâu thêm một lớp. Renderer chỉ có các bộ giải quyết được định kiểu cho stream và dictionary mà thôi, nên một tham chiếu gián tiếp trỏ tới một đối tượng mảng ở mức cao nhất — trường hợp phổ biến /CS0 5 0 R với [/Separation ...] ở đầu kia — giải quyết ra nil qua cả hai và rơi về liên kết chưa giải quyết. Thêm một bộ giải quyết đối tượng tổng quát đã sửa cả không gian màu có tên lẫn mảng hàm chỉ trong một bước. Nếu bạn đang nối dây các dictionary shading, cùng kỷ luật giải quyết đó áp dụng cho đường shading trục và xuyên tâm, nơi mục /Function rất thường là gián tiếp

Toán tử cm và một phép nối được viết ngược

Khiếm khuyết thứ hai đặt hình ảnh lệch khỏi trang khoảng một trăm nghìn pixel, trông y hệt như không vẽ chúng ra vậy. ISO 32000-1 §8.3.4 định nghĩa các phép biến đổi PDF bằng vector hàng, và toán tử cm nối ma trận operand M của nó vào ma trận biến đổi hiện hành (CTM) theo dạng M × CTM — M có hiệu lực trước, CTM hiện có sau đó. HotPDF hợp thành ma trận qua HPDFMatMul(A, B), hàm này áp dụng B trước A. Vì vậy lệnh gọi đúng phải truyền CTM cũ làm A. Đoạn mã đã phát hành truyền ma trận operand làm A, tạo ra CTM × M

Thứ tự bị đảo ngược vô hại với một cm đơn lẻ và thảm họa với thành ngữ hai bước tiêu chuẩn. Đặt một hình ảnh bằng 1 0 0 1 x y cm theo sau bởi w 0 0 h 0 0 cm và tầng xử lý đúng sẽ co dãn hình vuông đơn vị theo (w, h) rồi tịnh tiến nó theo (x, y). Dưới tầng xử lý bị đảo ngược, phép tịnh tiến đi vào trước và phép co dãn nhân nó lên, nên một hình ảnh danh nghĩa ở (60, 400) được co dãn thành 200 nhân 120 lại rơi vào (12000, 48000). Phép kiểm tra clip ở đầu bước blit từ chối nó, bước blit bị bỏ qua, và không có gì ở đâu báo cáo một vấn đề

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

Điều khiến trường hợp này đáng suy ngẫm là chính tệp mã nguồn đó đã chứa sẵn thứ tự đúng. Mục /Matrix của một Form XObject có cùng phép hợp thành bị đảo ngược, nhưng đường xử lý glyph Type 3 và đường xử lý đường viền glyph đã nhúng đều làm đúng ngay từ đầu, vì việc đặt glyph sẽ sụp đổ rõ rệt về gốc tọa độ khi bạn đảo ngược nó và có ai đó đã buộc phải sửa nó trước đây. Hai quy ước cùng tồn tại trong một unit suốt ba mươi mấy phiên bản, mỗi cái đúng trong hàm của riêng nó, và không người review nào để ý vì không nơi gọi nào trông sai khi xét riêng lẻ

Điều gì xảy ra khi một chỉ số token bị lệch một?

Bạn nhận được mười hai toán tử được xử lý đúng về cú pháp nhưng chết về ngữ nghĩa. Bộ truy cập operand trong renderer là NumAt(Back), đọc Tokens[OpIndex - Back], và OpIndex là chỉ số của chính token toán tử. Vì vậy một toán tử một-operand tìm con số của nó ở back 1. Mười hai trong số đó được viết là NumAt(0), đọc token toán tử, thất bại ở phép kiểm tra loại ctOperandNumber, và trả về giá trị mặc định là không. Danh sách là Tc, Tw, Tz, TL, TsTr từ các toán tử trạng thái văn bản của ISO 32000-1 §9.3, cộng với w, J, j, M, rii từ các toán tử trạng thái đồ họa của §8.4.3. Giãn cách ký tự và giãn cách từ trở thành thao tác vô tác dụng, tỷ lệ co giãn ngang không bao giờ được áp dụng, độ dẫn dòng (leading) giữ ở không nên T* không bao giờ tiến sang dòng mới, độ nâng văn bản không làm gì cả, chế độ render luôn luôn là tô đặc (fill), và mọi nét vẽ trong mọi tài liệu ra thành một đường mảnh 1 pixel bất kể độ rộng nét đã khai báo. Các toán tử nhiều operand như m, rgTm dùng NumAt(1..6) và đều đúng, nên một người review lướt qua hàm này sẽ thấy một bức tường các phép toán chỉ số trông có vẻ hợp lý với mười hai mục sai được nhúng vào bên trong

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)

Vì sao bộ test suite vẫn xanh suốt 38 phiên bản?

Vì các phép assert quá yếu để phân biệt một trang đã render với một trang chỉ render một phần. Các smoke test render assert những điều như bitmap đầu ra không hoàn toàn đen, hoặc trang không trống, hoặc digest hình ảnh khác không. Mọi điều đó đều đúng khi văn bản render còn hình ảnh thì không. Văn bản vẫn vẽ tốt, nên frame buffer không bao giờ đồng nhất, digest không bao giờ bằng không, và bộ test báo cáo thành công trong khi toàn bộ pipeline hình ảnh trên thực tế là mã chết. Các phép assert yếu quyến rũ đối với đồ họa chính vì các phép assert mạnh trông có vẻ dễ vỡ. Không ai muốn một test bị hỏng khi một cạnh khử răng cưa dịch đi một pixel, nên lối rút lui tự nhiên là assert điều gì đó mà không thay đổi hợp lý nào có thể vi phạm — và lối rút lui đó đưa bạn tới những vị từ mà không thay đổi bất hợp lý nào cũng có thể vi phạm được. Một test không gian màu Separation assert rằng đầu ra có thể phân biệt được với màu đen; xám trên trắng vượt qua nó, và trắng trên trắng cũng vậy. Test đó không đo xem màu đúng có được tô hay không. Nó đang đo xem có bất cứ điều gì xảy ra trên canvas hay không

Làm sao viết một phép assert render thực sự có thể thất bại?

Đếm số pixel mang màu mong đợi, với số lượng mong đợi, và để vị trí cùng kích thước tự lộ ra từ con số đếm đó. Kỷ luật thay thế là một PDF tối giản dựng tay, một sự thật hình ảnh cho mỗi tệp, và một phép assert về số pixel nằm trong dung sai của một bộ ba RGB cụ thể. Một hình ảnh đỏ thuần 200 nhân 120 đặt tại một offset đã biết phải tạo ra khoảng 24000 pixel đỏ. Nếu lượt tra cứu tài nguyên trượt, con số đếm là 0. Nếu tầng xử lý cm bị đảo ngược, con số đếm là 0. Nếu hình ảnh render sai không gian màu, con số đếm là 0. Một con số duy nhất bắt được cả ba, và dải dung sai hấp thụ nhiễu khử răng cưa vốn khiến người ta né tránh so sánh chính xác ngay từ đầu

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

Bốn smoke test đã được viết lại theo cách này — một phép biến đổi sắc độ (tint transform) Type 4, một lần đặt Do hình ảnh, một trường hợp hiển thị nội dung tùy chọn và một chế độ nét vẽ Tr — và cùng nhau chúng phơi bày ra toàn bộ họ lỗi này. Đó chính là bài học thật sự, và nó tổng quát hóa vượt xa codebase này: trong một pipeline render, phép assert phải nêu tên màu sắc. Bất cứ điều gì mềm hơn chỉ là một phép kiểm tra rằng renderer đã chạy, không phải một phép kiểm tra rằng nó đã vẽ. Nếu bạn đang xây dựng bộ khai thác trang-sang-bitmap của riêng mình, bài hướng dẫn về rasterize trang là nơi tự nhiên để gắn một hàm đếm pixel vào bài hồi quy đầu tiên của bạn

Ranh giới trung thực

Hai giới hạn đáng được nói rõ. Các chế độ render clip văn bản 4 tới 7 được vẽ theo chế độ tô đặc hoặc nét vẽ cơ bản của chúng, vì renderer không mô hình hóa các đường clip tích lũy từ đường viền glyph; các tài liệu dựa vào clipping theo hình dạng văn bản sẽ render ra văn bản thay vì phần tác phẩm đã clip bên dưới. Và kỷ luật đếm pixel được mô tả ở đây là một kỹ thuật smoke test, không phải một bộ test tuân thủ quy chuẩn — nó chứng minh rằng một sự thật hình ảnh cụ thể đã tới được frame buffer, một tiêu chuẩn thấp hơn nhiều so với việc chứng minh đầu ra khớp với một bộ rasterise tham chiếu. Tuy nhiên, đó chính xác là tiêu chuẩn mà bốn lỗi này đã không vượt qua được suốt ba năm phát hành

Renderer được bàn tới ở đây được cung cấp như một phần của HotPDF Component tiêu chuẩn cho Delphi và C++Builder; trang sản phẩm có đầy đủ tài liệu tham khảo API render trang, bao gồm cache bitmap và các điểm vào prefetch nền