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

เปรียบเทียบไฟล์ PDF สองไฟล์ใน Delphi: โครงสร้างและพิกเซล

HotPDF เปรียบเทียบเอกสาร PDF สองฉบับจาก Delphi ผ่าน THPDFDocComparison ซึ่งไล่เดินกราฟอ็อบเจกต์ของทั้งสองไฟล์จากแคตตาล็อกออกไปด้านนอก และเมื่อได้รับคำสั่งก็จะเรนเดอร์แต่ละคู่หน้าและวัดพิกเซลที่แตกต่างกันด้วย ผลลัพธ์คือรายงาน JSON ที่ระบุความแตกต่างทุกจุดที่พบ งบประมาณที่ใช้ไป และการเปรียบเทียบดำเนินไปจนจบหรือไม่ ทั้งสองรอบสำคัญด้วยกันทั้งคู่ เพราะการเทียบความต่างเชิงโครงสร้างและการเทียบความต่างเชิงภาพตอบคำถามที่ต่างกัน

คำถามเบื้องหลังคุณสมบัตินี้มักเป็นคำถามเกี่ยวกับการออกรุ่นใหม่ เอนจินสร้างรายงานได้รับการเปลี่ยนแปลง ผลลัพธ์ถูกสร้างขึ้นใหม่ และมีคนต้องตัดสินใจว่ามีอะไรเปลี่ยนไปหรือไม่ การเปิดไฟล์สองไฟล์เทียบกันใช้ได้ดีจนถึงราวสามหน้าก่อนที่ความสนใจจะเริ่มล้า การเปรียบเทียบไบต์ดิบล้มเหลวทันที เพราะการรันตัวสร้างเดียวกันสองครั้งให้ไบต์ที่ต่างกันด้วยเหตุผลที่ไม่เกี่ยวข้องเลยกับสิ่งที่ผู้อ่านมองเห็น

เหตุใด PDF จึงมีไบต์ต่างกันแต่มองเห็นเหมือนกันได้

PDF สองไฟล์ที่สร้างขึ้นแยกกันแต่พิมพ์ออกมาเหมือนกันทุกประการ มักมีไบต์ที่ต่างกันเป็นเรื่องปกติ และเหตุผลก็เป็นเรื่องเชิงโครงสร้างมากกว่าเรื่องความสวยงาม หมายเลขอ็อบเจกต์ถูกกำหนดตามลำดับที่อ็อบเจกต์ถูกเขียนออกมา ชุดย่อยฟอนต์จัดสรร CID ตามลำดับที่พบกลิฟครั้งแรก ดังนั้นชุดย่อยที่สร้างขึ้นระหว่างการไล่เดินที่ต่างกันเล็กน้อยจะสร้างไบต์สตรีมเนื้อหาที่ต่างกันสำหรับข้อความที่มองเห็นเหมือนกัน ออฟเซ็ตของตารางอ้างอิงข้ามจะเลื่อนทุกครั้งที่มีสิ่งใดต้นทางเปลี่ยนความยาว

นี่คือเหตุผลที่หมายเลขอ็อบเจกต์ใช้เป็นเอกลักษณ์ข้ามเอกสารไม่ได้ HotPDF จึงสร้างสแนปช็อตแต่ละชุดด้วยการไล่เดินจากแคตตาล็อก ขยายดิกชันนารีตามลำดับไบต์ของคีย์ และขยายอาร์เรย์ตามดัชนี ดังนั้นทุกอ็อบเจกต์จึงถูกตั้งชื่อตามเส้นทางที่ไปถึงมัน อ็อบเจกต์ที่การไล่เดินไปไม่ถึงจากราก จะถอยกลับไปใช้เส้นทางสังเคราะห์ $Unreachable[...] ที่พกหมายเลขอ็อบเจกต์และรุ่นไว้ ซึ่งทำให้เนื้อหาที่ถูกทิ้งไม่มีเจ้าของยังคงมองเห็นได้ในรายงานแทนที่จะหายไปอย่างเงียบๆ

สตรีมไม่ได้ถูกเปรียบเทียบด้วยการคัดลอก แต่ละสตรีมสร้างลายเซ็น SHA-256 แบบสะสม โดยคำนวณไปพร้อมกับการเรียกคืนตำแหน่งเดิมของสตรีมในภายหลัง ดังนั้นการเปรียบเทียบไฟล์ขนาดสองร้อยเมกะไบต์จึงไม่ได้หมายความว่าต้องสร้างข้อมูลสองร้อยเมกะไบต์ขึ้นมาซ้ำสองครั้ง

การจัดเรียงหน้าให้ตรงกันเมื่อเอกสารหนึ่งมีการแทรกหน้า

การเปรียบเทียบหน้า 1 กับหน้า 1 หน้า 2 กับหน้า 2 และเรื่อยไปเช่นนี้ ถูกต้องก็ต่อเมื่อไม่มีการแทรกหน้าใดเลย หากแทรกหน้าปกเข้าไป การเปรียบเทียบแบบไร้เดียงสาจะรายงานว่าทุกหน้าเปลี่ยนแปลง ซึ่งถูกต้องในทางเทคนิคแต่ไม่มีประโยชน์ในทางปฏิบัติ

HotPDF จัดเรียงหน้าให้ตรงกันก่อนเทียบความต่าง โดยสร้างลายเซ็นต่อหน้าจากข้อความที่สกัดได้ ถอยกลับไปใช้ลายเซ็นเชิงโครงสร้างสำหรับหน้าที่ไม่มีข้อความ แล้วคำนวณลำดับย่อยที่เพิ่มขึ้นยาวที่สุดเหนือดัชนีเป้าหมายที่จับคู่กันได้ หน้าที่อยู่ในลำดับย่อยนั้นคือหน้าที่แค่ขยับตำแหน่ง ส่วนหน้าที่อยู่นอกลำดับย่อยคือการเคลื่อนย้ายที่แท้จริง ความแตกต่างนี้เองที่ทำให้การเทียบความต่างของคู่มือ 400 หน้าอ่านเข้าใจได้ เพราะรายงานจะบอกว่ามีการแทรกหนึ่งหน้า แทนที่จะบอกว่าสี่ร้อยหน้าเปลี่ยนแปลง

การรันการเปรียบเทียบเชิงโครงสร้าง

การเรียกที่ง่ายที่สุดรับเอกสารที่โหลดไว้สองฉบับและโหมดหนึ่ง cmStructural ทำการไล่เดินกราฟอ็อบเจกต์ cmRenderedImage ทำการเปรียบเทียบพิกเซล cmFull ทำทั้งสองอย่าง ส่วนโหมดที่เบากว่าอย่าง cmPageCount, cmPageText และ cmObjectCount มีไว้สำหรับการตรวจสอบเบื้องต้นแบบประหยัด:

uses
  HPDFDoc, HPDFDocCompare;

var
  DocA, DocB: THotPDF;
  Report: AnsiString;
begin
  DocA := THotPDF.Create(nil);
  DocB := THotPDF.Create(nil);
  try
    if (DocA.LoadFromFile('baseline.pdf') <= 0) or
       (DocB.LoadFromFile('candidate.pdf') <= 0) then
      Exit;
    Report := THPDFDocComparison.Compare(DocA, DocB, cmStructural);
    with TFileStream.Create('diff.json', fmCreate) do
    try
      WriteBuffer(Report[1], Length(Report));
    finally
      Free;
    end;
  finally
    DocB.Free;
    DocA.Free;
  end;
end;

รายงานแยกแยะสามสถานะที่ค่าบูลีนทำไม่ได้ identical บอกว่ามีสิ่งใดแตกต่างหรือไม่ comparisonComplete บอกว่าการไล่เดินเสร็จสิ้นหรือไม่ และ comparisonBudget ระบุขีดจำกัดที่หยุดการทำงานหากมี การเปรียบเทียบที่ใช้งบประมาณจนหมดจะรายงาน comparisonComplete=false และ identical=false พร้อมกัน เพราะการไล่เดินที่ถูกตัดตอนไม่มีพื้นฐานให้อ้างความเท่ากันได้ ระบบอัตโนมัติใดๆ ที่อ่านเฉพาะ identical ในที่สุดจะปฏิบัติต่อการหยุดเพราะหมดงบประมาณเหมือนเป็นความแตกต่างจริง ดังนั้นควรอ่านทั้งสามค่า

ขีดจำกัดใดบ้างที่ควบคุมการไล่เดินให้อยู่ในขอบเขต

ค่าเริ่มต้นใน THPDFStructuralCompareLimits.Default ถูกกำหนดขนาดไว้สำหรับเอกสารจริง ไม่ใช่สำหรับเอกสารที่ประสงค์ร้าย และงบประมาณที่มีนัยสำคัญเชิงความหมายทุกตัวมีเพดานของตัวเอง ได้แก่ อ็อบเจกต์ 250,000 ชิ้น ขอบ 2,000,000 เส้น ความลึก 128 ความแตกต่างที่รายงาน 10,000 รายการ สตรีมละ 64 MB และไบต์สตรีมรวม 512 MB ค่าละ 1 MB และเส้นทางละ 4,096 ไบต์ ปรับเพิ่มค่าเหล่านี้อย่างจงใจเมื่อคุณรู้จักชุดข้อมูลของตัวเองดี และปรับลดลงเมื่อเปรียบเทียบไฟล์ที่มาจากภายนอก:

var
  Limits: THPDFStructuralCompareLimits;
  Options: THPDFRenderedCompareOptions;
begin
  Limits := THPDFStructuralCompareLimits.Default;
  Limits.MaxDifferences := 200;        // ล้มเหลวเร็วในระบบ CI
  Limits.MaxTotalStreamBytes := 128 * 1024 * 1024;

  Options := THPDFRenderedCompareOptions.Default;
  Options.DPI := 150;                  // ค่าเริ่มต้นคือ 72
  Options.ColorTolerance := 2;         // เพิกเฉยต่อสัญญาณรบกวนจากการปัดเศษระดับ 1-2
  Options.MinimumSimilarity := 0.9995;
  Options.MaxChangedPixelRatio := 0.0005;
  Options.GenerateHeatmaps := True;    // เขียนภาพซ้อนทับสำหรับตรวจสอบ

  Report := THPDFDocComparison.CompareWithOptions(DocA, DocB, cmFull,
    Limits, Options);
end;

รอบการเรนเดอร์จะประมาณจำนวนพิกเซลจากขนาดหน้าและ DPI ที่ร้องขอก่อนที่จะจัดสรรบิตแมปใดๆ แล้วตรวจสอบบิตแมปจริงอีกครั้งในภายหลัง ดังนั้นเรขาคณิตของหน้าที่ผิดรูปจึงไม่สามารถแอบผ่านงบประมาณไปได้ด้วยการโกหกเรื่องขนาดของตัวเอง การเพิ่ม DPI จะเพิ่มความละเอียดและต้นทุนแบบยกกำลังสอง 150 DPI มีพิกเซลมากกว่า 72 ถึงสี่เท่า และเพดานพิกเซลต่อหน้าและรวมทั้งหมดมีไว้ก็เพราะงานแบบแบตช์ที่ 300 DPI จะจัดสรรจนเกิดปัญหาหากไม่มีเพดานนี้

ความคล้ายกันระดับใดจึงถือว่าใกล้เคียงพอ

สองหน้าจะนับว่าคล้ายกันก็ต่อเมื่อเงื่อนไขทั้งสองข้อเป็นจริงพร้อมกัน คืออัตราส่วนพิกเซลที่เปลี่ยนแปลงต้องอยู่ที่หรือต่ำกว่า MaxChangedPixelRatio และความคล้ายคลึงต้องอยู่ที่หรือสูงกว่า MinimumSimilarity การใช้เกณฑ์สองค่าแทนที่จะเป็นค่าเดียว เพราะพิกเซลผิดพลาดร้ายแรงจำนวนหยิบมือกับการเปลี่ยนสีเล็กน้อยกระจายทั่วบริเวณเป็นความล้มเหลวคนละแบบกัน และแต่ละแบบเพียงลำพังอาจยอมรับได้ในเวิร์กโฟลว์หนึ่งแต่ตัดสิทธิ์ในอีกเวิร์กโฟลว์หนึ่ง การทดสอบเกณฑ์ใช้ค่าที่ไม่ปัดเศษ ทศนิยมหกตำแหน่งใน JSON มีไว้เพื่อให้รายงานคงที่และเทียบความต่างได้ ไม่ใช่เพื่อกำหนดนิยามการเปรียบเทียบ

พิกเซลที่เปลี่ยนแปลงจะถูกจัดกลุ่มเป็นบริเวณโดยใช้ไทล์ขนาดคงที่เป็นโหนดที่มีความติดกันแบบสี่ทิศทาง แทนที่จะใช้ flood fill ต่อพิกเซล วิธีนี้ทำให้หน่วยความจำถูกจำกัดขอบเขตและรายการบริเวณคงที่ในทุกการรัน การตัดรายละเอียดบริเวณที่เก็บไว้มีผลเฉพาะกับรายการเท่านั้น ไม่กระทบจำนวนบริเวณที่รายงาน ดังนั้นหน้าที่มีบริเวณเปลี่ยนแปลงมากกว่า MaxChangedRegions ก็ยังคงรายงานจำนวนที่แท้จริงได้

พฤติกรรมหนึ่งควรกล่าวให้ชัดเจน เพราะมันสวนทางกับสัญชาตญาณปกติ ความล้มเหลวของตัวเรนเดอร์ ความล้มเหลวของการจัดสรร และความล้มเหลวของการซ้อนทับ จะไม่มีทางถูกกลืนหายไปเฉยๆ สิ่งใดก็ตามในลักษณะนี้จะถูกบันทึกเป็น renderError หรือ renderBudget และบังคับให้ renderComparisonComplete=false เพราะหน้าที่เรนเดอร์ไม่สำเร็จคือหน้าที่ไม่มีใครเปรียบเทียบเลย และการรายงานว่ามันเหมือนกันย่อมเลวร้ายกว่าการไม่รายงานอะไรเลย

แต่ละโหมดควรอยู่ตรงไหนในไปป์ไลน์

การเปรียบเทียบเชิงโครงสร้างตอบคำถามว่าอะไรเปลี่ยนไป และเป็นค่าเริ่มต้นที่เหมาะสมสำหรับชุดทดสอบถดถอย มันระบุเส้นทาง ดัชนีหน้า และหมายเลขอ็อบเจกต์ที่เกี่ยวข้อง ดังนั้นความล้มเหลวจึงชี้ไปที่โค้ดที่สร้างมันขึ้นมาได้ การเปรียบเทียบเชิงภาพที่เรนเดอร์แล้วตอบคำถามว่าจะมีใครสังเกตเห็นหรือไม่ ซึ่งเป็นคำถามสำหรับการอนุมัติและการตรวจสอบว่ารอบการปรับให้เหมาะสมนั้นไม่สูญเสียข้อมูลจริงๆ

ทั้งสองแบบผสานกันได้ดี รัน cmStructural ในทุกรอบบิลด์และปล่อยให้มันล้มเหลวอย่างชัดเจนเมื่อพบการเปลี่ยนแปลงระดับอ็อบเจกต์ที่ไม่คาดคิด รัน cmFull พร้อมฮีตแมปก่อนออกรุ่นใหม่ เมื่อมีคนพร้อมดูภาพซ้อนทับ สำหรับไปป์ไลน์ที่สร้างมาร์กอัปหน้าอยู่แล้วด้วยเหตุผลอื่น ผลลัพธ์ข้อความที่อธิบายไว้ในการส่งออกหน้า PDF เป็น SVG ให้มุมมองที่สามที่มนุษย์เทียบความต่างได้ และการตรวจสอบอัตโนมัติในระบบอัตโนมัติสำหรับรายงานพรีไฟลต์ ครอบคลุมคำถามด้านความสอดคล้องตามมาตรฐานที่โหมดเทียบความต่างทั้งสองแบบไม่ได้ออกแบบมาให้ตอบ

การเปรียบเทียบ พรีไฟลต์ และการเรนเดอร์ ใช้แบบจำลองอ็อบเจกต์ของเอกสารที่โหลดไว้ร่วมกัน ดังนั้นการไล่ผ่านไฟล์เพียงรอบเดียวก็ป้อนข้อมูลให้ทั้งสามอย่างได้ รายการคุณสมบัติทั้งหมดสำหรับ Delphi และ C++Builder อยู่ที่หน้าคอมโพเนนต์ PDF ของ HotPDF สำหรับ Delphi