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

การลบข้อมูล PDF ระดับ operator ใน Delphi ด้วย PDFiumPas

มีคนวาดกล่องดำทับชื่อหนึ่ง ไม่ flatten อะไรเลย ส่งไฟล์ออกไป แล้วผู้ทบทวนเลือกสี่เหลี่ยมนั้น คัดลอกชื่อวางในอีเมล PDFiumPas ตอบกรณีนี้ด้วยการลบข้อมูลระดับ operator: SaveAsRedacted ลบเฉพาะ Unicode scalar ที่กล่องอักขระของมันแตะสี่เหลี่ยมลบข้อมูล สร้างตัวรอดชีวิตคืนจากฟอนต์ ขนาด matrix render mode กับสีเดิม และครอบตัด path กับภาพแนวแกนแทนการทิ้งทั้งชิ้น

ทำไมสี่เหลี่ยมที่วาดทับจึงไม่ใช่การลบข้อมูล

การดำเนินการวาดที่เติมบน content stream ไม่ซ่อนอะไรเลย เพราะ operator แสดงข้อความที่อยู่ข้างใต้ยังนั่งอยู่ใน stream และยังแปลงกลับเป็น code point ได้ ISO 32000-1 §9.4 นิยาม text object เป็นลำดับ operator จัดตำแหน่งกับแสดงผลภายใน BT กับ ET; สี่เหลี่ยม fill ที่วาดทีหลังเป็นเพียง operator อีกตัวใน stream เดียวกัน การสกัดข้อความเดินตาม operator ไม่ใช่ตามพิกเซล ข้อความที่ถูกปิดจึงกลับมาครบถ้วน การลบข้อมูลจริงต้องเอาตัว operand ออก ไม่ใช่บังผลลัพธ์

วิธีทำง่ายและปลอดภัยที่เห็นได้ชัดคือวิธีโหด: หา page object ทุกตัวที่ bounding box ตัดกับสี่เหลี่ยมลบข้อมูลแล้วลบออบเจกต์ทั้งตัว นั่นคือสิ่งที่ PDFiumPas รุ่นก่อนทำ มันถูกต้องแต่แพง Tj เดียวสามารถแบกทั้งแถวของตาราง การทำสีดำเลขบัญชีหนึ่งชุดจึงพาวันที่ คำอธิบาย และจำนวนเงินหายไปด้วย fill สี่เหลี่ยมที่บังเอิญเป็นแถบตารางเต็มความกว้างหายไปทั้งหน้า โลโก้ใบแจ้งหนี้หายไปเพราะการลบข้อมูลตัดเข้าที่มุมหนึ่งของมัน รุ่น 3.101.0 ย้ายการตัดสินใจลงหนึ่งระดับ จาก page object มาที่ operand

การลบข้อมูลระดับ operator ลบอะไรกันแน่?

PDFiumPas ลบ Unicode scalar ไม่ใช่ text object ระหว่าง SaveAsRedacted component สร้าง mapping จากอักขระไป page object จาก text page ที่โหลดไว้ จากนั้นสำหรับอักขระทุกตัวที่ออบเจกต์ภายใต้การทดสอบเป็นเจ้าของ มันอ่านกล่องอักขระแล้ว intersect กล่องนั้นกับสี่เหลี่ยมลบข้อมูลแต่ละอัน อักขระที่แตะสี่เหลี่ยมถูกมาร์กให้ลบ ที่เหลือถูกมาร์กเป็นตัวรอดชีวิต ถ้าไม่มีอะไรตัดกันเลย ออบเจกต์ถูกปล่อยทั้งตัว ถ้าทุกอักขระตัดกัน ออบเจกต์ถูกลบทั้งชิ้น เหมือนแบบเดิมเป๊ะ เฉพาะกรณีผสมเท่านั้นที่กระตุ้นการแยก

การลบข้อมูลระดับ operator ของ PDFiumPas เทียบกับการลบทั้งออบเจกต์ใน Delphi: เส้นทางเดิมทิ้ง text object ทั้งชิ้นเมื่อเลขบัญชีหนึ่งชุดถูกปิด ขณะที่เส้นทางแยกลบเฉพาะอักขระที่ตัดกันและส่งออกตัวรอดชีวิตแต่ละตัวเป็น text object ของมันเอง
เฉพาะกรณีผสมเท่านั้นที่กระตุ้นการแยก: ไม่มีอะไรตัดกันปล่อยออบเจกต์ไว้ทั้งตัว ตัดกันหมดลบทั้งชิ้น

ตัวรอดชีวิตแต่ละตัวจากนั้นถูกส่งออกใหม่เป็น text object ของตัวเอง สร้างจาก font handle เดิม ขนาดฟอนต์เดิม text matrix ต่ออักขระ render mode เดิม และสถานะ fill กับ stroke ของออบเจกต์แม่ รวม stroke width, line join, line cap กับ dash array การใช้ font handle ซ้ำแทนการ resolve ใหม่คือสิ่งที่ทำให้ glyph เหมือนกันเชิง metric และการใช้ matrix ต่ออักขระซ้ำคือสิ่งที่ทำให้ kerning กับระยะคำคงที่โดยไม่ต้องรัน layout ใหม่ ต้นทุนคือจำนวนออบเจกต์: อักขระที่เก็บไว้หนึ่งตัวกลายเป็น text object หนึ่งตัว นั่นคือเหตุที่ TPdfRedactionOptions.MaxSplitObjects มีอยู่เป็นเพดานแบบแข็งของชิ้นส่วนที่ถูกสร้าง

procedure RedactDocument(const SourcePdf, TargetPdf: string);
var
  Pdf: TPdf;
  Options: TPdfRedactionOptions;
  Report: TPdfRedactionReport;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := SourcePdf;   // ไฟล์มี annotation /Redact มาให้แล้ว
    Pdf.Active := True;

    Options := TPdfRedactionOptions.Default;
    Options.PreservePartialObjects := True;    // แยกระดับ operator (ค่า default)
    Options.RemoveIntersectingAnnotations := True;
    Options.MaxSplitObjects := 20000;          // เพดานของชิ้นส่วนที่ถูกสร้าง

    if not Pdf.SaveAsRedacted(TargetPdf, Options, Report) then
      raise Exception.Create(Report.ErrorMessage);   // fail closed อย่าส่งไฟล์ออก
  finally
    Pdf.Free;
  end;
end;

สี่เหลี่ยมถูกครอบตัด รูปทรงที่หมุนไม่ถูก

Path ถูกแยกเมื่อ PDFiumPas พิสูจน์ได้ว่า path นั้นเป็นสี่เหลี่ยมแนวแกนเท่านั้น การพิสูจน์ถูกตั้งใจให้แคบ: matrix ของออบเจกต์ต้องมีพจน์ shear ทั้งสองต่ำกว่า 0.0001 path ต้องประกอบด้วยสี่ถึงหก segment ที่เริ่มด้วย MOVETO และต่อด้วย LINETO เท่านั้น และจุดที่ถูกแปลงต้องตกลงสี่มุมของ object bounds ครบ ภายในความคลาดเคลื่อน 0.01 path ที่ผ่านเช็คนี้ถูกลดขนาดด้วยการลบสี่เหลี่ยมต่อเนื่อง สี่เหลี่ยมลบข้อมูลแต่ละอันแกะชุดตัวรอดชีวิตเป็นแถบซ้าย ขวา ล่าง บน และแถบที่ได้ทุกอันถูกสร้างใหม่ด้วย fill mode, stroke flag กับสถานะสีเดิม เส้นโค้ง สามเหลี่ยม รูปทรงที่ถูก clip และสิ่งใดที่หมุนอยู่ล้มเหลวในเช็คและออบเจกต์ทั้งตัวถูกลบ

ภาพทำตาม ISO 32000-1 §8.9 ที่ตัวอย่างภาพครอบสี่เหลี่ยมหน่วยซึ่งถูกแมปผ่าน current transformation matrix PDFiumPas กลับ mapping นั้นเพื่อแปลงชิ้นส่วนที่รอดในหน้ากลับเป็นพิกัดภาพ normalized clamp มันเข้าช่วงหน่วย แล้วแปลงเป็น index พิกเซลด้วยการปัด เข้าด้านใน: ขอบซ้ายกับบนผ่าน Ceil ขอบขวากับล่างผ่าน Floor ทิศทางนี้สำคัญ การปัดออกด้านนอกจะยอมให้คอลัมน์พิกเซลต้นทางบางส่วนจากฝั่งที่ถูกลบรอดอยู่ที่ขอบชิ้นส่วน ขอบพิกเซลเชิงจำนวนเต็มจากนั้นถูกแปลงกลับเป็นพิกัด normalized และใช้สร้าง fragment matrix บิตแมปที่ถูกครอบตัดจึงตกลงเป๊ะขอบพิกเซลที่มันถูกตัด การครอบตัดเองเป็นการคัดลอกแถวแบบรู้จัก stride ข้ามฟอร์แมต Gray, BGR, BGRx กับ BGRA เช่นเดียวกับ path ภาพที่หมุนหรือเอียง หรือภาพที่ matrix มีพจน์ scale เป็นศูนย์ถูกลบทั้งชิ้น

PDFiumPas ครอบตัดภาพ PDF ที่ถูกลบข้อมูลบางส่วนใน Delphi อย่างไร: ชิ้นส่วนที่รอดในหน้าถูกแมปกลับผ่าน CTM ที่กลับทิศเข้าสู่พิกัดภาพ normalized ถูก clamp เข้าช่วงหน่วยและปัดเข้าด้านใน คอลัมน์พิกเซลที่ถูกลบจึงรอดไม่ได้
Ceil ที่ซ้ายกับบน Floor ที่ขวากับล่าง การตัดจึงตกลงบนขอบพิกเซลเต็มตัว
// หลังเรียก SaveAsRedacted สำเร็จ
Writeln(Format('applied %d redaction(s) on %d page(s)',
  [Report.RedactionCount, Report.RedactedPageCount]));
Writeln(Format('scanned %d object(s), removed %d',
  [Report.ScannedObjectCount, Report.RemovedObjectCount]));
Writeln(Format('split text/path/image: %d / %d / %d',
  [Report.SplitTextObjectCount, Report.SplitPathObjectCount,
   Report.SplitImageObjectCount]));
Writeln(Format('preserved %d fragment(s)', [Report.PreservedFragmentCount]));
Writeln(Format('pruned %d resource name(s), swept %d object(s)',
  [Report.ResourcePruneReport.RemovedNameCount,
   Report.ResourcePruneReport.RemovedObjectCount]));

if Report.PreservedFragmentCount = 0 then
  // แยกอะไรไม่ได้เลย: ออบเจกต์ที่ตัดกันถูกทิ้งทั้งชิ้น
  LogWholeObjectFallback(SourcePdf);

ทำไม PDFiumPas จึง fail closed เมื่อเจออักขระที่แมปไม่ได้?

เพราะ glyph ที่ไม่มี Unicode scalar ที่สร้างซ้ำได้ ไม่อาจถูกสร้างคืนอย่างสุจริต การสร้างตัวรอดชีวิตคืนหมายถึงเรียก API ตั้งข้อความด้วยสตริง และนั่นต้องการ code point ที่เสถียรสำหรับอักขระที่เก็บไว้ทุกตัว ฟอนต์ subset แบบ symbolic ที่ ToUnicode เสียหรือขาดหายสามารถคืน mapping ว่าง และการเข้ารหัสใหม่ด้วยการเดาจะผลิตผลลัพธ์ที่ดูถูกบนหน้าจอแต่ข้างในแบกอักขระคนละตัว PDFiumPas ปฏิเสธ: เช็คอักขระที่เก็บไว้ raise ข้อยกเว้น ข้อยกเว้นถูกจับภายใน SaveAsRedacted TPdfRedactionReport.Succeeded กลับมาเป็น False พร้อมข้อความใน ErrorMessage และฟังก์ชันคืน False กฎเดียวกันใช้กับงบการแยก ซึ่ง raise มากกว่าตัดชุดชิ้นส่วนอย่างเงียบ ๆ เมื่อเอกสารมีฟอนต์ที่คุณไม่ไว้ใจและคุณต้องการพฤติกรรมเก่าแบบ deterministic ตั้ง Options.PreservePartialObjects := False แล้วออบเจกต์ที่ตัดกันทุกตัวหายไปทั้งชิ้น

การตัด resource ข้าม scope ที่ถูกแชร์

การแยกออบเจกต์ทิ้งกำพร้าไว้เบื้องหลัง และการตัดมันไม่ง่ายแบบ diff พจนานุกรม /Resources ระดับหน้า ISO 32000-1 §7.8.3 ยอมให้พจนานุกรม resource เดียวถูกอ้างโดยหลายหน้า โดย Form XObject โดย pattern และโดย appearance stream ของ annotation พร้อมกัน ลบชื่อฟอนต์เพราะหนึ่งหน้าเลิกใช้แล้วหน้าอื่นที่ยังใช้จะพัง PruneUnusedPdfResources จึงทำงานต่อ scope: มัน resolve /Contents ไม่ว่าจะเป็นอาร์เรย์ตรง indirect reference ไปยังอาร์เรย์ หรือ stream เดี่ยว แล้วเก็บการใช้ resource จาก operator ที่เรียกชื่อ resource จริง — Tf สำหรับฟอนต์ Do สำหรับ XObject gs สำหรับ graphics state CS, cs, SCN กับ scn สำหรับ color space กับ pattern sh สำหรับ shading BDC กับ DP สำหรับ property ของ marked-content บวกรายการ /CS ของ inline image เมื่อพจนานุกรมหนึ่งถูกแชร์โดยหลาย scope ชุดชื่อที่ถูกใช้ถูก union ต่อหมวดหมู่ก่อนจะลบสิ่งใด

การตัด resource ใน PDFiumPas: สาม scope อ้างพจนานุกรม resource แชร์ร่วมกันหนึ่งชุด ชุดชื่อที่ถูกใช้ถูก union ต่อหมวดหมู่ และเฉพาะชื่อที่ไม่มี scope ใดอ้างถึงถูกลบก่อนออบเจกต์ที่เอื้อมไม่ถึงถูกกวาดออก
พจนานุกรมหนึ่งรับใช้หลายหน้า หลาย form XObject กับหลาย appearance stream ได้ PDFiumPas จึง union ชุดชื่อที่ถูกใช้ทุกชุดก่อนทิ้งชื่อแม้แต่ชื่อเดียว

เฉพาะชื่อที่ยืนยันแล้วว่าไม่ถูกอ้างทุก scope ที่ชี้ไปที่พจนานุกรมเท่านั้นที่ถูกทิ้ง scope ที่ parse ด้วยความมั่นใจไม่ได้ถูกปล่อยไว้ไม่แตะต้อง ซึ่งเป็นทิศทางอนุรักษ์นิยม: ไฟล์ที่ไม่ถูกตัดแค่ใหญ่ขึ้น ไฟล์ที่ถูกตัดผิดคือไฟล์เสีย พจนานุกรมที่รอดถูกเขียนกลับเป็น incremental update แบบ sparse ที่แบกเลข generation เป๊ะ ๆ และการเขียนใหม่เชิง reachability แล้วกวาดออบเจกต์ที่เอื้อมไม่ถึงเมื่อชื่อหายไป TPdfResourcePruneReport รายงาน ScannedScopeCount, UpdatedScopeCount, RemovedNameCount, RemovedObjectCount จำนวน byte และ flag Succeeded SaveAsRedacted รันขั้นนี้อัตโนมัติบนผลลัพธ์ที่ผ่าน sanitisation เส้นทางลบข้อมูลจึงมีมันอยู่แล้ว แต่ฟังก์ชันถูก export ที่ระดับ stream สำหรับ pipeline ที่อยากใช้แยก

uses
  FPdfCompress;

procedure PruneResourceNames(const SourcePdf, TargetPdf: string);
var
  Source, Dest: TFileStream;
  Report: TPdfResourcePruneReport;
begin
  Source := TFileStream.Create(SourcePdf, fmOpenRead or fmShareDenyWrite);
  try
    Dest := TFileStream.Create(TargetPdf, fmCreate);
    try
      // AllowSignedDocument ยังเป็น False: การเขียนใหม่แบบเพิ่มหน่วย
      // จะทำให้ช่วง byte ที่ลายเซ็นครอบคลุมไม่ถูกต้อง
      PruneUnusedPdfResources(Source, Dest, Report);
      if not Report.Succeeded then
        raise Exception.Create(Report.ErrorMessage);
      Writeln(Format('%d name(s) removed from %d scope(s), %d -> %d bytes',
        [Report.RemovedNameCount, Report.UpdatedScopeCount,
         Report.SourceByteCount, Report.OutputByteCount]));
    finally
      Dest.Free;
    end;
  finally
    Source.Free;
  end;
end;

ต่อเข้ากับ pipeline เอกสาร

เส้นทางลบข้อมูลไม่แตะต้องเอกสารที่คุณโหลดเด็ดขาด SaveAsRedacted จับ snapshot ที่แยกต่างหาก ใส่ annotation /Redact ลงบนนั้น ถอด attachment รัน pass ทำความสะอาดที่ลบ open action, catalog action, name tree, associated files, AcroForm กับ metadata ตัด resource แล้วจึงเขียน output stream ออกมา การเปิด output นั้นใหม่เป็นเอกสารอิสระและสกัดข้อความซ้ำคือขั้นตรวจสอบที่ควรอยู่ใน test suite ของคุณ เพราะเป็นเช็คเดียวที่ตอบคำถามเดิม — ผู้อ่านยังหยิบข้อความชุดนั้นได้อีกไหม ผลพวงหนึ่งที่ต้องวางแผน: การแยกเขียนทับ page object หมายเลข FPDF_PAGEOBJECT ที่คุณถืออยู่จึงตายไปทันที กับดัก lifetime เดียวกับที่อธิบายใน page object handle ค้างเก่าหลัง transform

อีกสองชิ้นที่อยู่ติดกันทำให้เวิร์กโฟลว์ครบ การตัดสินใจว่าสี่เหลี่ยมลบข้อมูลควรวาง ตรงไหน มักเริ่มจาก geometry ที่สกัดได้ และแบบจำลอง block กับลำดับการอ่านใน structured text blocks กับลำดับการอ่าน เป็นแหล่งกล่องผู้สมัครที่ดีกว่าการรันอักขระดิบ การเสิร์ฟผลลัพธ์ให้ผู้ทบทวนเป็นเรื่องของกฎ hardening ใน การสร้าง PDF preview ที่ปลอดภัย ซึ่ง form filling กับ JavaScript ถูกปิดเป็นค่า default รวมกันแล้วครอบวงที่เวิร์กโฟลว์ compliance ส่วนใหญ่ต้องการ: ระบุตำแหน่ง ลบที่ระดับ operator ตรวจสอบด้วยการเปิดซ้ำ พรีวิวอย่างปลอดภัย API ฉบับเต็ม ดาวน์โหลดทดลอง กับเงื่อนไขการให้สิทธิ์ของ component อยู่บน หน้าผลิตภัณฑ์ PDFium Delphi Component