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

advance ข้อความ PDF กับ restore clip ที่ q/Q ใน Delphi

renderer หน้าของ HotPDF Delphi Component ตอนนี้เลื่อนข้อความโดยคำนวณการกระจัดของ glyph แต่ละตัวใน text space ตาม tx = ((w0 − Tj/1000) × Tfs + Tc + Tw) × Th ที่ ISO 32000-1 §9.4.4 นิยามไว้ แล้วจึงเขย่า text matrix ผ่านส่วนเชิงเส้นของมันด้วย HPDFTranslateTextMatrix clipping ถูกเก็บต่อหนึ่งกรอบ q และคืนตอน Q แต่ region ของ GDI ถูกจับเมื่อกรอบนั้นเปลี่ยน clip จริงเท่านั้น สองการแก้ตกลงใน HotPDF 2.754.0 และมาจากหน้าจริงที่ render แล้วคำยุบรวมกันหรือ region ของ clip รั่วทะลุ Q ของมันไป บั๊กแรกเป็นเรขาคณิตที่ดูถูกไปจนกระทั่ง producer เขียนขนาดฟอนต์ลงใน matrix ส่วนการแก้ที่สองเป็นการแก้ความถูกต้องที่เกือบพา parallel render speedup ไปล่มกับมัน วิธีที่เราเอาความเร็วกลับมาจึงสมควรรู้ไว้ถ้าคุณเขียน PDF device ที่วางบน GDI สักตัว

ทำไมข้อความยุบกองเมื่อ PDF ใช้ Tf 1

เพราะโค้ด advance รุ่นเก่าบวกระยะทางใน text space ตรง ๆ เข้าไปที่องค์ประกอบการเลื่อนของ Tm ราวกับว่า text space กับ user space สเกลเท่ากันตลอด producer จริง ๆ จำนวนไม่น้อยตั้งขนาดฟอนต์เป็น 1 ด้วย Tf แล้วฝากขนาดจริงไว้ใน text matrix ด้วย /F1 1 Tf กับ 12 0 0 12 72 700 Tm glyph ที่กว้าง 500 หน่วยเลื่อน 0.5 ใน text space ซึ่งเป็น 6 พอยต์บนหน้าเมื่อ Tm สเกลมัน renderer เดิมรัน Tm.e := Tm.e + Adv แล้วเข็มขยับ 0.5 พอยต์ glyph ทุกตัวจึงตกที่ห่างจากตัวก่อนหน้าหนึ่งในสิบสองของตัวอักษร บรรทัดข้อความเนื้อหาจึง render ออกมาเป็นคราบดำที่ขอบซ้าย ขณะที่ไฟล์เดียวกันดูสมบูรณ์แบบใน viewer อื่นทุกตัว

ทำไมข้อความยุบใต้ Tf 1 ใน renderer ของ HotPDF: ด้วย 12 0 0 12 72 700 Tm glyph ที่กว้าง 500 หน่วยต้องเลื่อน 0.5 หน่วยใน text space ซึ่ง Tm สเกลเป็น 6 พอยต์ ขณะที่โค้ดเดิมบวก 0.5 ตรง ๆ เข้า Tm.e แล้ว render บรรทัดข้อความเนื้อหาเป็นคราบหนึ่งในสิบสองของตัวอักษรต่อหนึ่ง glyph
producer ที่ฝังขนาดฟอนต์ไว้ใน text matrix ทำให้ glyph ทุกตัวตกห่างจากตัวก่อนหน้าหนึ่งในสิบสองของตัวอักษร ข้อบกพร่องที่มองไม่เห็นบน output ของ library เอง
// content stream จาก producer ที่ฝังขนาดไว้ใน Tm ไม่ใช่ Tf:
//   BT
//   /F1 1 Tf
//   12 0 0 12 72 700 Tm
//   [(Hel) 30 (lo) -250 (world)] TJ
//   ET

// advance เดิม (ย่อ): บวกระยะทางเข้า Tm.e ราวกับเป็น user space
Adv := W * FontSize / 1000;                  // 0.5 สำหรับ glyph 500 หน่วย
if (HorizScale <> 0) and (HorizScale <> 100) then
  Adv := Adv * HorizScale / 100;             // Th บนความกว้างเท่านั้น
Adv := Adv + CharSpace;                      // Tc ไม่ถูกสเกลด้วย Th
if Code = 32 then
  Adv := Adv + WordSpace * FontSize / 1000;  // Tw ถูกสเกลด้วย Tfs ผิด
Tm.e := Tm.e + Adv;                          // เมิน Tm.a, Tm.b, Tm.c, Tm.d

// adjustment TJ แบบเดิม: ไม่มี Th และก็แตะแค่ Tm.e อีกเช่นเคย
Tm.e := Tm.e - NumValue * FontSize / 1000;

ทางลัด Tm.e ไม่ใช่ข้อบกพร่องตัวเดียวในบล็อกนั้น word spacing Tw ถูกแสดงเป็นหน่วยใน text space ที่ไม่ถูกสเกล แต่โค้ดเดิมคูณมันด้วย FontSize / 1000 ภายใต้ Tf 12 บรรทัดที่จัดชิดขอบสองข้างจึงเสียช่องว่างระหว่างคำไปเกือบหมด scaling แนวนอน Th ใช้กับความกว้าง glyph แต่ไม่ใช้กับ Tc หรือ Tw และ adjustment kerning ของ TJ เมินมันทิ้งสนิท เส้นทางไม่วาดภาพที่เลื่อนข้อความล่องหนโหมด render 3 ซึ่งเป็นพวกที่ชั้นข้อความ OCR ใช้ และข้อความข้างใน optional content ที่ซ่อนไว้แบกสำเนาเรขาคณิตชุดเดียวกันเป็นของส่วนตัว อะไรก็ตามที่วาดหลังชุดรันล่องหนจึงเริ่มจากตำแหน่งผิด บั๊ก text state ใน renderer พังไม่ค่อยดัง: เหมือนบั๊ก operand index กับชื่อ resource ที่เคยทำ Tc, Tw กับ Tz กลายเป็นศูนย์โดยไม่โยน error แม้แต่ครั้งเดียว พวกมันผลิตหน้าที่ดูน่าเชื่อบน output ของ library เองแล้วพังแค่ไฟล์ที่มาจาก producer อื่น

ISO 32000-1 §9.4.4 นิยาม advance ของ glyph อย่างไร

ISO 32000-1 §9.4.4 นิยาม advance ไว้ทั้งก้อนใน text space และนำมันไปใช้กับ text matrix เป็น matrix การเลื่อน คำตอบจึงคือคำนวณ tx ก่อนแล้วปล่อยให้ Tm จัดการสเกล การหมุนกับการเบ้ สำหรับการเขียนแนวนอน tx เท่ากับ ((w0 − Tj/1000) × Tfs + Tc + Tw) × Th โดย w0 คือความกว้าง glyph ในหน่วยพันส่วนของ em, Tj คือ adjustment ของ TJ และ Th คือ Tz หารด้วย 100 Tm ใหม่คือ [1 0 0 1 tx 0] × Tm ซึ่งใน HotPDF คือ helper HPDFTranslateTextMatrix: มันบวก X กับ Y ผ่านสัมประสิทธิ์ของ matrix a, b, c กับ d แทนที่จะเขียนลง e กับ f ตรง ๆ ตาม §9.3.3 Tw ใช้เฉพาะรหัสอักขระ 32 แบบไบต์เดียว รหัส CID หลายไบต์จึงไม่เคยเก็บ word spacing บนเส้นทางแนวนอน helper ตัวเดียวกันตอนนี้ขับ Td, TD, T*, operator ' กับ ", adjustment ของ TJ และเส้นทางข้อความซ่อน แปลว่าฟังก์ชันเดียวเป็นเจ้าของกฎ

advance ของ glyph ตาม ISO 32000-1 9.4.4 ใน renderer ของ HotPDF: tx ถูกคำนวณใน text space จาก w0, Tj, Tfs, Tc, Tw กับ Th แล้วใช้ผ่าน HPDFTranslateTextMatrix การกระจัดจึงไหลผ่านสัมประสิทธิ์ของ matrix a, b, c กับ d และ Td, TD, TJ กับเส้นทางข้อความซ่อนใช้กฎเดียวกัน
การบวก advance เข้า Tm.e ตรง ๆ ใช้ได้เมื่อ text space เท่ากับ user space เท่านั้น การร้อยมันผ่านสัมประสิทธิ์ของ matrix ทำให้ข้อความที่ถูกสเกล หมุน และเบ้ยังถูกต้อง
procedure HPDFTranslateTextMatrix(var Matrix: THPDFAffineMatrix; X, Y: Double);
begin
  Matrix.e := Matrix.e + Matrix.a * X + Matrix.c * Y;
  Matrix.f := Matrix.f + Matrix.b * X + Matrix.d * Y;
end;

// advance ของ glyph แนวนอน, ISO 32000-1 9.4.4
W   := HPDFFontCharWidth(F, Code);
Adv := W * State.Text.FontSize / 1000 + State.Text.CharSpace;
if (Code = 32) and not F.CID2Byte then
  Adv := Adv + State.Text.WordSpace;         // Tw ใน text space แบบไม่สเกล
Adv := Adv * State.Text.HorizScale / 100;    // Th ใช้กับผลรวมทั้งก้อน
HPDFTranslateTextMatrix(Tm, Adv, 0);

// element ตัวเลขของ TJ: space เดียวกัน Th เดียวกัน
Adjustment := -Items[I].NumValue * State.Text.FontSize / 1000;
HPDFTranslateTextMatrix(State.Text.Tm, Adjustment * State.Text.HorizScale / 100, 0);

การวาง glyph ต้องเดินตามตรรกะเดียวกันด้วย เมื่อไม่มีเส้นขอบที่ฝังมาให้ใช้และ renderer ย้อนไปใช้ GDI TextOutW มันตอนนี้ประกอบ matrix ของ glyph เต็มรูปจาก CTM × Tm × rise × em scale รวม Th ด้วย แล้วติดตั้งผ่าน SetWorldTransform ในโหมด GM_ADVANCED ภายในคู่ SaveDC / RestoreDC ฟอนต์ GDI ถูกสร้างที่ความสูง 1000 หน่วยตายตัวและ transform เป็นตัวกำหนดขนาด ข้อความที่หมุนกับเบ้จึงคงทิศทางของตัวเอง แทนที่จะถูกวาดตั้งตรงที่จุดกำเนิดที่ถูกแปลง โหมดเขียนแนวตั้งเป็นความไม่สมมาตรตัวเดียวที่ตั้งใจมา: ฟอนต์ WMode 1 เลื่อนลงตามแกน y ด้วย metric แนวตั้งของมัน และ scaling แนวนอนไม่มีผลกับแกนนั้น

q/Q เก็บอะไรจริง ๆ ใน graphics state ของ PDF

ISO 32000-1 §8.4.2 ลิสต์ clipping path ปัจจุบันเป็นส่วนหนึ่งของ graphics state Q จึงต้องคืน clip ให้เป๊ะเหมือนตอน q ที่จับคู่ ไม่ใช่แค่พารามิเตอร์ตัวเลข HotPDF เก็บ stack ของ graphics state ไว้แล้ว พร้อม CTM, สี, พารามิเตอร์เส้นและ text state แต่ GDI เก็บ clip ไว้ใน device context ซึ่งอยู่นอก stack นั้น สำเนาของสถานะตัวเลขจึงคืนทุกอย่างยกเว้น clip และ clip ที่ติดตั้งด้วย W n ข้างในบล็อก q ... Q ครอบตัด operation ทุกตัวที่มาทีหลังบนหน้า Form XObject เพิ่มเส้นทางที่สองเข้าสู่ความล้มเหลวแบบเดียวกัน เพราะ §8.10 ให้ form มีการเก็บกับคืนโดยปริยายรอบเนื้อหา และเนื้อหา form ในโลกจริงบางครั้งปล่อย operator q ของตัวเองให้ไม่ครบคู่ ทั้งที่สเปกทวงว่าต้องคู่กัน renderer ตอนนี้เรียก CaptureClipBeforeChange กับ SaveDC ก่อนรัน form แล้วเมื่อ form จบมันทิ้ง region ที่เก็บไว้ที่ลึกกว่าความลึกตอนเข้า แล้วเรียก RestoreDC HRGN ที่เก็บไว้แต่ละตัวจึงมีเส้นทางปล่อยเพียงเส้นทางเดียวพอดี

การจับ clip แบบ lazy ด้วย THPDFSavedClipState

การแก้ที่ ship ออกไปเก็บ record THPDFSavedClipState หนึ่งชุดต่อหนึ่ง q แต่เลื่อนส่วนที่แพงไปไว้จนกว่ากรอบจะเปลี่ยน clip เป็นครั้งแรก record ถือ region handle, ความลึก stack ที่มันอยู่, device context ที่มันถูกดึงมาและธง Captured DevPushState เติมแค่ความลึกกับ DC และขยาย array ของกรอบด้วยการเดินทวีคูณจาก 16 สตรีมเนื้อหาที่กองแต่ q 1 0 0 1 x y cm ... Q จึงไม่ allocate object GDI เลยแม้แต่ตัวเดียว operator ที่กำลังจะเปลี่ยน clipping ซึ่งหมายถึงการวาด path ที่มี W หรือ W* ค้างอยู่, operator n, การเติมด้วย pattern และการเข้า form เรียก CaptureClipBeforeChange ก่อน

การจับ clip ของ GDI แบบ lazy ใน renderer ของ HotPDF: DevPushState จดแค่ความลึกกับ DC ต่อหนึ่ง q, CaptureClipBeforeChange อ่าน region ก่อน W, n หรือการเข้า form จะเปลี่ยน clipping พอดี, DevPopState คืนและลบมันตอน Q ขณะที่เวอร์ชันกระตือรือร้นที่จับทุก q ดัน throughput แบบขนานลงเหลือประมาณ 1.15 เท่าของแบบเธรดเดียว
การสร้าง region ของ GDI ทุก q ทำให้ render thread ขาดแคลน การจับจึงเกิดเฉพาะตอนที่ operator กำลังจะเปลี่ยน clipping และประตูความเร็ว 1.5 เท่าผ่านกลับมาอีกครั้ง
procedure THPDFPageRenderer.CaptureClipBeforeChange;
var
  Index, ClipResult: Integer;
  Region: HRGN;
begin
  ClearSavedClipRegions(FGSStack.Count);
  Index := FSavedClipCount - 1;
  if (Index < 0) or FSavedClips[Index].Captured or
     (FSavedClips[Index].StackDepth <> FGSStack.Count) or
     (FSavedClips[Index].DC <> FDC) then Exit;   // เซฟไปแล้ว หรือไม่ใช่ของเรา
  Region := CreateRectRgn(0, 0, 0, 0);
  if Region = 0 then RaiseLastOSError;
  ClipResult := GetClipRgn(FDC, Region);         // 0 แปลว่าไม่มี clip เลย
  if ClipResult <= 0 then
  begin
    DeleteObject(Region);
    Region := 0;
    if ClipResult < 0 then RaiseLastOSError;
  end;
  FSavedClips[Index].Region := Region;
  FSavedClips[Index].Captured := True;
end;

procedure THPDFPageRenderer.DevPopState;
var
  Index: Integer;
begin
  ClearSavedClipRegions(FGSStack.Count);
  Index := FSavedClipCount - 1;
  if (Index >= 0) and (FSavedClips[Index].StackDepth = FGSStack.Count) then
  begin
    if FSavedClips[Index].Captured and (FSavedClips[Index].DC = FDC) then
      SelectClipRgn(FDC, FSavedClips[Index].Region);  // Region 0 คือการถอด clip
    if FSavedClips[Index].Region <> 0 then
      DeleteObject(FSavedClips[Index].Region);
    Dec(FSavedClipCount);
  end;
  FGSStack.Pop;
end;

ต้นทุนที่วัดได้ของเวอร์ชันกระตือรือร้นคือเหตุผลที่ดีไซน์นี้ถือกำเนิด implementation ที่ถูกต้องแรกสร้างและอ่าน region ของ GDI บนทุก q บนหน้าที่ประกอบด้วย transform ตัวเลขเป็นหลัก เธรด renderer ใช้เวลาไปกับการแย่ง object region ของ GDI แทนการ rasterize parallel render pipeline ตกจากกำไรที่คาดไว้เหลือประมาณ 1.13 ถึง 1.20 เท่าของ throughput แบบเธรดเดียวและตกประตูความเร็ว 1.5 เท่าในชุด benchmark เมื่อใช้การจับแบบ lazy กับความจุกรอบที่ใช้ซ้ำ benchmark เดิมผ่านประตู 1.5 เท่าแบบต้นฉบับกลับมา antialiasing ของ glyph TrueType ขนาดเล็กลงมาในรีลีสเดียวกันและเป็นผู้ต้องสงสัยตัวง่าย แต่ regression ไล่กลับไปเจอที่การ allocate region ซึ่งเป็นเรื่องเตือนใจดี ๆ ว่าให้วัดก่อนโทษฟีเจอร์ใหม่ล่าสุด

ขีดจำกัดของแนวทางนี้อยู่ตรงไหน

clip ที่เก็บไว้เป็น region ของ GDI ในหน่วยพิกเซลของอุปกรณ์ มันจึงแม่นยำเฉพาะกับ bitmap ที่กำลัง render และไร้ความหมายกับ target อื่น กรอบแต่ละอันจด device context ของตัวเองและ DevPopState ข้ามการคืนเมื่อ DC เปลี่ยนไป เช่น ขณะ transparency group render ลง bitmap layer ของตัวเอง GetClipRgn คืนศูนย์เป็นผลลัพธ์ที่ชอบธรรม แปลว่าไม่มี clip และการคืนด้วย SelectClipRgn(FDC, 0) คือวิธีถอด clip ที่ไม่เคยมีอยู่ตอน q คู่แฝดอย่างถูกต้อง ฝั่งข้อความ การแก้ทำให้ glyph แต่ละตัวไปถูกที่ แต่มันไม่กุความกว้างขึ้นมา ถ้าฟอนต์ละ array /Widths ทิ้งและโปรแกรมที่ฝังมาใช้ไม่ได้ advance ก็ดีเท่า fallback ของความกว้างเท่านั้น ตอนทดสอบ regression แถวนี้ เก็บ fixture อย่างน้อยหนึ่งชิ้นที่ใช้ Tf 1 กับ Tm ที่สเกลแล้ว, อีกชิ้นที่มี Tz กับ Tw ไม่เป็นศูนย์ และอีกชิ้นที่มี clip ข้างใน q ... Q ตามด้วยเนื้อหานอกมัน เพราะไม่มีอย่างไหนเลยที่โผล่ในเอกสารที่ library เองสร้าง

ถ้าคุณขับ renderer จากโค้ดแอปพลิเคชัน แนวการเรียกที่เล่าไว้ในการ render หน้า PDF ลง bitmap ไม่เปลี่ยน และหน้าที่เคยโชว์บรรทัดเป็นคราบหรือเนื้อหาโดนครอบตัดควร render ถูกต้องซะอย่างเดียวบน 2.754.0 ขึ้นไป รายละเอียดของ component, เวอร์ชัน Delphi กับ C++Builder ที่รองรับและเรื่อง licensing อยู่ที่หน้าผลิตภัณฑ์ HotPDF Delphi PDF Component