บทความเทคนิค

Renderer PDF ไม่วาดอะไรเลย: บั๊กเงียบ 4 ตัวใน Delphi

PDF renderer ที่ไม่วาดอะไรเลยมักไม่มีบั๊กในโค้ดวาดของมันเลยแม้แต่น้อย ใน HotPDF Component สำหรับ Delphi และ C++Builder มีข้อบกพร่องแยกกันสี่ตัวที่ทำให้หน้าเว็บเรนเดอร์ออกมาว่างเปล่า ในขณะที่ log ทุกบรรทัดยังคงสะอาด: name operand ที่มี slash นำหน้า การต่อ cm ที่กลับด้าน และ token index ที่อ่านค่าเป็นศูนย์ ไม่มีสักตัวที่ throw ไม่มีสักตัวที่ log content stream ถูก tokenize อย่างถูกต้อง operator dispatcher จำ operator ทุกตัวได้ image XObject ถูก decode เป็น bitmap ที่ถูกต้อง แล้วหน้าก็ออกมาว่างเปล่า การผสมกันแบบนั้น คือ pipeline ที่รายงานความสำเร็จในทุกขั้นตอนแต่ไม่ผลิตอะไรที่มองเห็นได้เลย คือลายเซ็นของการค้นหาหรือ index ที่พลาดอย่างเงียบ ๆ แทนที่จะล้มเหลว นี่คือการชันสูตรของตระกูลบั๊กแบบนั้นตระกูลหนึ่ง และของวินัยการทดสอบที่ปล่อยให้มันรอดมาได้ 38 รีลีส

ทำไม PDF renderer ถึงไม่วาดอะไรเลย

เพราะการค้นหา resource ที่ล้มเหลวใน PDF renderer นั้นแยกไม่ออกจากหน้าที่ว่างเปล่า name operand ใน content stream กับคีย์ของ resource dictionary เป็นพื้นที่ string สองชนิดที่ต่างกัน และ HotPDF กำลังเปรียบเทียบข้ามพื้นที่ทั้งสองโดยไม่ normalise ตัว tokenizer อ่าน /Im0 แล้วเก็บ solidus ไว้ เพราะนั่นคือสิ่งที่ token เป็นจริง ๆ ในขณะที่ dictionary /Resources /XObject ที่โหลดไว้เก็บคีย์เป็น Im0 เพราะ parser ตัด delimiter ออกตอนสร้างคีย์ dictionary ทุกครั้งที่ FindValue ถูกเรียกกับชื่อ operand จึงคืนค่า -1 รัศมีความเสียหายกว้างกว่าแค่ภาพ ISO 32000-1 §8.9 ครอบคลุม Do, §8.4 ครอบคลุม gs และการค้นหา /ExtGState ของมัน, §8.6 ครอบคลุม cs และ CS, และ §8.7.4.3 ครอบคลุม sh ทั้งห้า operator ใช้ operand ดิบเป็นคีย์ค้นหา sub-dictionary ของ resource ทั้งหมด ดังนั้นทั้งห้าจึงพลาด color space ที่ตั้งชื่อไว้ตกกลับไปเป็น DeviceGray ซึ่งเปลี่ยน 1 scn ให้กลายเป็นหมึกสีขาวบนหน้าสีขาว image XObject ไม่เคยถูกวาดเลย ในทางปฏิบัติแล้ว path การวาดภาพ bitmap ไม่เคยทำงานเลยตั้งแต่วันที่มันถูกเพิ่มเข้ามา วิธีแก้คือ helper ระดับ unit ที่ถูกใช้ในทุกจุดค้นหาที่ใช้ operand เป็นคีย์ ซึ่งเป็นวิธีเดียวที่จะป้องกันไม่ให้ convention นี้เพี้ยนอีกในอนาคต

// 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 และ dictionary เท่านั้น ดังนั้น indirect reference ที่ชี้ไปยัง array object ระดับบนสุด อย่างเช่น /CS0 5 0 R ที่มี [/Separation ...] อยู่ปลายทางอีกด้าน จึงถูก resolve เป็น nil ผ่านทั้งสองแบบ แล้วตกกลับไปเป็นลิงก์ที่ยัง resolve ไม่ได้ การเพิ่ม generic object resolver แก้ปัญหาทั้ง named colour space และ function array ได้ในการเปลี่ยนแปลงครั้งเดียว ถ้าคุณกำลังเชื่อมต่อ shading dictionary วินัยการ resolve แบบเดียวกันนี้ใช้ได้กับ path การทำ axial และ radial shading ด้วย ซึ่ง entry /Function มักเป็น indirect บ่อยมาก

Operator cm กับการต่อกันที่เขียนกลับด้าน

ข้อบกพร่องที่สองวางภาพไว้ไกลจากหน้าประมาณหนึ่งแสนพิกเซล ซึ่งดูเหมือนกับไม่ได้วาดมันเลยพอดี ISO 32000-1 §8.3.4 นิยาม PDF transformation ด้วย row vector และ operator cm ต่อ matrix operand M ของมันเข้ากับ current transformation matrix เป็น M × CTM คือ M มีผลก่อน แล้ว CTM ที่มีอยู่เดิมทำงานทีหลัง HotPDF ประกอบ matrix ผ่าน HPDFMatMul(A, B) ซึ่งจะใช้ B ก่อน A ดังนั้นการเรียกที่ถูกต้องจึงต้องส่ง CTM เก่าเป็น A แต่โค้ดที่ปล่อยออกไปส่ง matrix operand เป็น A ทำให้ได้ CTM × M แทน

ลำดับที่กลับด้านไม่มีผลอะไรกับ cm ตัวเดียว แต่หายนะกับสำนวนสองขั้นตอนมาตรฐาน วางภาพด้วย 1 0 0 1 x y cm ตามด้วย w 0 0 h 0 0 cm แล้ว cascade ที่ถูกต้องจะขยายหน่วยจตุรัสด้วย (w, h) แล้วเลื่อนมันด้วย (x, y) ภายใต้ cascade ที่กลับด้าน การเลื่อนจะเข้าไปก่อน แล้วการขยายก็คูณมันต่อ ดังนั้นภาพที่ตั้งใจไว้ที่ (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)));

สิ่งที่ทำให้กรณีนี้เป็นบทเรียนคือไฟล์ต้นฉบับเดียวกันนี้มีลำดับที่ถูกต้องอยู่แล้ว entry /Matrix ของ Form XObject มีการประกอบแบบกลับด้านเดียวกันนี้อยู่ แต่ path glyph แบบ Type 3 และ path outline glyph ที่ฝังไว้ ทั้งคู่ทำถูกต้องมาตั้งแต่แรก เพราะตำแหน่ง glyph จะยุบเห็นได้ชัดไปที่จุดกำเนิดทันทีที่คุณกลับด้านมัน และมีคนถูกบังคับให้แก้มันไปแล้ว สอง convention อยู่ร่วมกันใน unit เดียวมาสามสิบกว่ารีลีส แต่ละอันถูกต้องในฟังก์ชันของตัวเอง และไม่มี reviewer คนไหนสังเกตเห็น เพราะไม่มี call site ไหนดูผิดปกติเมื่อมองแยกเดี่ยว ๆ

เกิดอะไรขึ้นเมื่อ token index เพี้ยนไปหนึ่ง

คุณจะได้ operator สิบสองตัวที่ถูกจัดการทางไวยากรณ์แต่ตายทางความหมาย ตัว accessor สำหรับ operand ใน renderer คือ NumAt(Back) ซึ่งอ่าน Tokens[OpIndex - Back] และ OpIndex คือ index ของ operator token เอง operator ที่มี operand เดียวจึงพบตัวเลขของมันที่ back 1 มีสิบสองตัวที่ถูกเขียนเป็น NumAt(0) ซึ่งอ่าน operator token เอง ไม่ผ่านการตรวจสอบชนิด ctOperandNumber และคืนค่าเริ่มต้นเป็นศูนย์ รายการนี้คือ Tc, Tw, Tz, TL, Ts และ Tr จาก text state operator ของ ISO 32000-1 §9.3 บวก w, J, j, M, ri และ i จาก graphics state operator ของ §8.4.3 ระยะห่างตัวอักษรและคำกลายเป็น no-op การขยายแนวนอนไม่เคยถูกใช้เลย ค่า leading อยู่ที่ศูนย์ตลอด ดังนั้น T* จึงไม่เคยเลื่อนบรรทัด text rise ไม่ทำอะไรเลย render mode เป็น fill เสมอ และเส้นทุกเส้นในทุกเอกสารออกมาเป็นเส้นบางระดับ 1 พิกเซล ไม่ว่าความกว้างเส้นที่ประกาศไว้จะเป็นเท่าไรก็ตาม operator ที่มีหลาย operand อย่าง m, rg และ Tm ใช้ NumAt(1..6) และทั้งหมดถูกต้อง ดังนั้น reviewer ที่ไล่ดูฟังก์ชันจึงเห็นกำแพงของเลขคณิต index ที่ดูสมเหตุสมผล โดยมี entry ที่ผิดสิบสองตัวฝังอยู่ในนั้น

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)

ทำไม test suite ถึงเขียวมาตลอด 38 เวอร์ชัน

เพราะ assertion อ่อนแอเกินกว่าจะแยกแยะหน้าที่เรนเดอร์แล้วออกจากหน้าที่เรนเดอร์ไปแค่บางส่วน smoke test ของการเรนเดอร์ยืนยันเพียงว่า output bitmap ไม่ได้เป็นสีดำทั้งหมด หรือหน้าไม่ว่างเปล่า หรือ digest ของภาพไม่เป็นศูนย์ ทุกข้อยังคงเป็นจริงเมื่อข้อความเรนเดอร์แต่ภาพไม่เรนเดอร์ ข้อความวาดได้ดี ดังนั้น frame buffer จึงไม่เคยเป็นสีเดียวสม่ำเสมอ digest ไม่เคยเป็นศูนย์ และ suite ก็รายงานความสำเร็จในขณะที่ pipeline ของภาพทั้งหมดเป็นโค้ดที่ตายในทางปฏิบัติ Assertion ที่อ่อนแอนั้นล่อลวงได้ง่ายสำหรับงานกราฟิก เพราะ assertion ที่แข็งแรงดูเปราะบาง ไม่มีใครอยากได้ test ที่พังเมื่อขอบ anti-aliasing เลื่อนไปหนึ่งพิกเซล ดังนั้นทางถอยตามธรรมชาติคือการยืนยันบางอย่างที่การเปลี่ยนแปลงที่สมเหตุสมผลใด ๆ ไม่มีทางละเมิดได้ และทางถอยนั้นก็พาคุณไปสู่ predicate ที่แม้แต่การเปลี่ยนแปลงที่ไม่สมเหตุสมผลก็ยังไม่ละเมิดด้วย test สำหรับ separation colour-space ยืนยันแค่ว่า output แยกแยะได้จากสีดำ สีเทาบนพื้นขาวก็ผ่าน และสีขาวบนพื้นขาวก็ผ่านเช่นกัน test นั้นไม่ได้วัดว่าสีที่ถูกต้องถูกวาดหรือไม่ มันวัดแค่ว่ามีอะไรเกิดขึ้นบน canvas เลยหรือเปล่า

คุณจะเขียน assertion สำหรับการเรนเดอร์ที่ล้มเหลวจริง ๆ ได้อย่างไร

นับพิกเซลของสีที่คาดหวังไว้ ในปริมาณที่คาดหวังไว้ แล้วปล่อยให้ตำแหน่งกับขนาดตกลงมาจากการนับนั้นเอง วินัยแบบทดแทนคือ PDF ขั้นต่ำที่สร้างขึ้นด้วยมือ ข้อเท็จจริงทางภาพหนึ่งข้อต่อไฟล์หนึ่งไฟล์ และ assertion ว่ามีพิกเซลกี่พิกเซลที่อยู่ในระยะยอมรับของค่า RGB สามค่าเฉพาะ ภาพขนาด 200 คูณ 120 ของสีแดงล้วนที่วางไว้ที่ offset ที่รู้จักต้องผลิตพิกเซลสีแดงประมาณ 24000 พิกเซล ถ้าการค้นหา resource พลาด จำนวนจะเป็น 0 ถ้า cascade ของ cm กลับด้าน จำนวนจะเป็น 0 ถ้าภาพเรนเดอร์ผิด colour space จำนวนจะเป็น 0 ตัวเลขเดียวจับได้ทั้งสามกรณี และแถบความคลาดเคลื่อนก็ดูดซับ noise จาก 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 test สี่ตัวถูกเขียนใหม่ด้วยวิธีนี้ คือ tint transform แบบ Type 4, การวาง image ด้วย Do, กรณีการมองเห็นของ optional content และโหมด stroke ของ Tr และเมื่อรวมกันแล้วทั้งสี่ตัวก็เปิดโปงบั๊กทั้งตระกูลออกมา นั่นคือบทเรียนที่แท้จริง และมันเป็นจริงกว้างกว่าแค่ codebase นี้ ใน rendering pipeline assertion ต้องระบุชื่อสีให้ชัดเจน อะไรที่อ่อนกว่านั้นเป็นแค่การตรวจสอบว่า renderer ทำงาน ไม่ใช่การตรวจสอบว่ามันวาดจริง ถ้าคุณกำลังสร้าง harness สำหรับแปลงหน้าเป็น bitmap ของตัวเอง คำอธิบายเรื่อง page rasterisation เป็นจุดที่เหมาะสมสำหรับติดตั้ง helper นับพิกเซลเข้ากับ regression ตัวแรกของคุณ

ขอบเขตที่ซื่อตรง

มีสองข้อจำกัดที่ควรระบุไว้อย่างตรงไปตรงมา text clipping render mode 4 ถึง 7 ถูกวาดเป็นโหมด fill หรือ stroke พื้นฐานของมัน เพราะ renderer ไม่ได้จำลอง clip path ที่สะสมจาก glyph outline เอกสารที่พึ่งพา clipping ตามรูปทรงตัวอักษรจะเรนเดอร์ข้อความออกมาแทนที่จะเป็นภาพวาดที่ถูก clip อยู่ข้างใต้ และวินัยการนับพิกเซลที่อธิบายไว้ที่นี่เป็นเทคนิค smoke test ไม่ใช่ conformance suite มันพิสูจน์ว่าข้อเท็จจริงทางภาพเฉพาะเจาะจงหนึ่งอย่างไปถึง frame buffer ซึ่งเป็นมาตรฐานที่ต่ำกว่าการพิสูจน์ว่า output ตรงกับ rasteriser อ้างอิงมาก แต่มันก็เป็นมาตรฐานที่บั๊กทั้งสี่ตัวนี้ไม่ผ่านมาตลอดสามปีของการรีลีสพอดี

Renderer ที่กล่าวถึงที่นี่มาพร้อมกับ HotPDF Component มาตรฐานสำหรับ Delphi และ C++Builder หน้าผลิตภัณฑ์มีเอกสารอ้างอิง API การเรนเดอร์หน้าแบบเต็ม รวมถึง bitmap cache และจุดเริ่มต้นการ prefetch เบื้องหลัง