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

PDF/A สำหรับการเก็บถาวรใน Delphi ด้วย PDFium VCL

คุณส่งตัวแปลงที่ติดธงไฟล์ทุกไฟล์เป็น PDF/A-1b ระบบจัดเก็บระเบียนของลูกค้ารับเข้าไปใช้งานอยู่ปีหนึ่ง แล้วพอมีการตรวจสอบจริงทั้งชุดด้วย veraPDF ไฟล์ถึงหนึ่งในสามกลับไม่ผ่านมาตรฐาน ไม่มีอะไรล้ม ไม่มี exception ถูกโยน ไฟล์เปิดได้ปกติในตัวดูไฟล์ทุกตัวบนโต๊ะของคุณ เพียงแต่ไฟล์เหล่านั้นไม่ได้เป็นมาตรฐานอย่างที่คุณติดไว้ นี่คือรูปแบบความล้มเหลวปกติของ PDF สำหรับการเก็บถาวร และเป็นเหตุผลว่าทำไมคำว่า "เราแค่ตั้ง flag แล้ว" จึงไม่เคยมีความหมายเท่ากับ "มันตรวจผ่าน"

สิ่งแรกที่ต้องเข้าใจเกี่ยวกับ PDFium กับ PDF/A คือ ตัวเอนจินแทบไม่เกี่ยวข้องกับเรื่องนี้ PDFium เรนเดอร์ แยกวิเคราะห์ และเขียน PDF ได้ แต่ส่วนสาธารณะของมันไม่มี ConvertToPDFA ไม่มีตัวเขียน OutputIntent และไม่มี API สำหรับ XMP ทุกส่วนของการทำให้เป็นไปตามมาตรฐานสำหรับการเก็บถาวร ไม่ว่าจะเป็น XMP packet, OutputIntent และ ICC profile, เครื่องหมายใน catalog, หรือการตรวจสอบความถูกต้อง ล้วนอยู่ใน PDFiumPas เอง ในหน่วย pure-Pascal ราว 2,000 บรรทัด (FPdfPdfa.pas) ที่อ่านไบต์ที่บันทึกไว้แล้วเขียนทับกลับผ่าน incremental update การรู้ว่างานเกิดตรงไหนจะบอกได้ว่าบั๊กซ่อนอยู่ที่ไหน และบั๊กเหล่านั้นไม่ได้ซ่อนอยู่ใน PDFium

PDF/A ต้องการอะไรจริง ๆ และมันกัดตรงไหน

PDF/A ไม่ได้มีเพียงรูปแบบเดียว ISO 19005 กำหนดไว้สามส่วน (PDF/A-1, -2, -3) และในแต่ละส่วนยังมีระดับความสอดคล้องที่สัญญาไม่เหมือนกัน Level B (basic) รับประกันแค่ว่ารูปลักษณ์ที่มองเห็นสามารถทำซ้ำได้ Level A (accessible) เพิ่มโครงสร้างแบบ tagged tree และการแมป Unicode เข้าไปเหนือ B ส่วน Level U ซึ่งมีเฉพาะใน part 2 และ 3 อยู่กึ่งกลางระหว่างสองระดับนั้น คือได้ข้อความ Unicode ที่เชื่อถือได้โดยไม่ต้องมี structure tree เต็มรูปแบบ ISO 19005-1 ไม่มี Level U และข้อจำกัดนี้ถูกเข้ารหัสไว้ตรง ๆ ในไลบรารี

กฎบางข้อของฟอร์แมตนี่แหละที่กัดจริงในงานใช้งานประจำ การเข้ารหัสถูกห้ามโดยตรง (ISO 19005-1 §6.1.3 และมาตรฐานต่อเนื่อง): ไฟล์ PDF/A ห้ามมี /Encrypt dictionary เอกสารต้องประกาศเงื่อนไขการแสดงผลผ่าน OutputIntent ที่ปลายทางเป็น ICC profile ที่ถูกต้อง (§6.2.3.2) ข้ออ้างเรื่องความสอดคล้องเองก็ต้องปรากฏเป็น XMP metadata ใต้ PDF/A identification schema ส่วน Level A ยังต้องมีโครงสร้างเชิงตรรกะตาม §6.8 หรือ tag tree ที่ทำให้เอกสารอ่านได้โดยเครื่อง ถ้าพลาดข้อใดข้อหนึ่ง ตัวตรวจสอบความสอดคล้องจะปฏิเสธไฟล์ทันที แม้มันจะเรนเดอร์ได้สมบูรณ์ก็ตาม

การเรียกครั้งเดียวที่สร้างไฟล์เก็บถาวร

PDFiumPas เปิดเผย pipeline ทั้งหมดไว้หลัง TPdf.SaveAsPdfA overload แบบง่ายจะรับ conformance เป้าหมายและตั้งค่าเริ่มต้นเป็น PDF/A-1b ซึ่งเป็นค่าดีฟอลต์ที่เหมาะกับกรณีทั่วไปแบบ "ทำให้ไฟล์นี้แสดงผลได้ตลอดไป"

var
  Pdf: TPdf;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.LoadFromFile('invoice.pdf');
    // Default conformance is pac1b (PDF/A-1b)
    if Pdf.SaveAsPdfA('invoice_archive.pdf') then
      // file now carries XMP, sRGB OutputIntent, and catalog markers
    else
      raise Exception.Create('PDF/A save failed');
  finally
    Pdf.Free;
  end;
end;

เบื้องหลังแล้วนี่คือการทำงานสองขั้น SaveAsPdfA จะสั่งให้ PDFium serialize เอกสารด้วย FPDF_SaveAsCopy ก่อน แล้วจึงส่ง byte stream นั้นให้ InjectPdfAMarkers ซึ่งจะต่อท้าย metadata ของ XMP, sRGB OutputIntent พร้อม ICC profile ที่ฝังอยู่ และ catalog ที่เขียนใหม่ในรูปแบบ incremental update แหล่งข้อมูลถูกอ่านจากตำแหน่งศูนย์ และปลายทางถูกเขียนจากตำแหน่งศูนย์เช่นกัน object tree เดิมยังคงอยู่ครบ และ marker จะต่อท้ายหลัง %%EOF ที่มีอยู่แล้ว ถ้าคุณต้องการไบต์แทนไฟล์ SaveAsPdfAToStream ก็รับ TStream พร้อมตัวเลือกชุดเดียวกัน

การเลือกระดับความสอดคล้องด้วย options record

ถ้าต้องการเจาะจง part และ level ให้ส่ง TPdfASaveOptions record เข้าไป ฟิลด์ Conformance ของมันรับค่า TPdfAConformance enumeration นี้ครอบคลุมทุกชุดค่าที่ถูกต้องและไม่มีอย่างอื่น: pac1b, pac1a สำหรับ part 1; pac2b, pac2u, pac2a สำหรับ part 2; pac3b, pac3u, pac3a สำหรับ part 3 รวมถึง pacUnknown และ pacNone สำหรับฝั่ง validation ไม่มี pac1u เพราะระดับนั้นไม่มีอยู่ในมาตรฐาน

var
  Pdf: TPdf;
  Opts: TPdfASaveOptions;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.LoadFromFile('report.pdf');
    Opts := TPdfASaveOptions.Default;
    Opts.Conformance := pac2u;           // PDF/A-2u: reliable Unicode text
    Opts.Title := 'Quarterly Report 2026';
    Opts.Author := 'Finance';
    // Leave IccProfileData empty to use the built-in sRGB IEC61966-2.1 profile
    if not Pdf.SaveAsPdfA('report_a2u.pdf', Opts) then
      raise Exception.Create('PDF/A-2u save failed');
  finally
    Pdf.Free;
  end;
end;

ฟิลด์ส่วนใหญ่ของ record ปล่อยว่างได้ Title, Author, Subject, Keywords, Creator, และ Producer หากปล่อยว่าง SaveAsPdfA จะเติมให้จาก Info dictionary ของเอกสารผ่าน FPDF_GetMetaText เอง ถ้าปล่อย CreationDate และ ModDate ว่าง ระบบจะใช้เวลา UTC ปัจจุบันเป็นวันที่ XMP ทั้งสองช่อง ถ้าปล่อย DocumentId และ InstanceId ว่าง ไลบรารีจะเติมค่าเริ่มต้นจาก FPDF_GetFileIdentifier แล้วค่อยถอยไปใช้ ID แบบกำหนดแน่นอนที่คำนวณจากไบต์ต้นฉบับ ฟิลด์เดียวที่คุณอาจอยากกำหนดเองโดยเจตนาคือ IccProfileData โดยค่าว่างหมายถึงใช้โปรไฟล์ sRGB IEC61966-2.1 ที่แถมมา ส่วนเวิร์กโฟลว์ CMYK หรือ grayscale ควรส่งโปรไฟล์ของตัวเอง

ทำไม Level A ถูกลดระดับ และทำไมวิธีนี้จึงซื่อตรง

นี่คือรายละเอียดเล็ก ๆ ที่มักทำให้คนซึ่งคาดหวังว่า flag จะเป็นคำรับประกันพลาดได้ คุณสามารถขอ pac1a กับเอกสารที่ไม่มี tag tree ได้ แต่ PDF/A-1a ต้องมี logical structure ตาม §6.8 และไลบรารีไม่สามารถเสก structure tree ขึ้นมาจาก PDF ที่ไม่เคยถูกแท็กไว้ได้ แทนที่จะปล่อยไฟล์ที่อ้างว่าเป็น Level A แต่จริง ๆ ไม่ผ่าน SaveAsPdfA จะตรวจหา tagged structure จริง (/StructTreeRoot รวมกับ /MarkInfo ที่มี /Marked true) และถ้าไม่พบก็จะลดระดับคำอ้างลง: pac1a กลายเป็น pac1b, pac2a กลายเป็น pac2b และทำแบบเดียวกันไปทั่วทั้งสาม part ตัวช่วยภายในคือ PdfAIsLevelA และ PdfADowngradeToLevelB

เหตุผลของเรื่องนี้ควรพูดให้ตรงไปตรงมา ไฟล์ที่ประกาศระดับที่ตัวเองทำได้อย่างซื่อสัตย์มีประโยชน์กว่าไฟล์ที่โกหกเรื่องระดับที่ทำไม่ได้ Level U ถูกจัดการต่างออกไป การตรวจจับการครอบคลุม Unicode จริงจะหมายถึงการทำ test แบบไร้เดียงสาเพียงว่า "มี /ToUnicode ไหม" ซึ่งจะลดระดับเอกสารที่ถูกต้องมากเกินไป เพราะ encoding อย่าง WinAnsi และแบบใกล้เคียงกันถูกยกเว้นไว้ ดังนั้นฝั่ง save จึงออกคำอ้าง U ตามที่ผู้เรียกประกาศไว้ แล้วปล่อยให้ความคลาดเคลื่อนไปโผล่บนฝั่ง validation แทน ถ้าคุณต้องการ archive ที่เป็น Level A แบบรับประกันจริง ต้องแท็กเอกสารก่อนแปลง เพราะตัวแปลงจะไม่เสกโครงสร้างที่ไม่มีอยู่จริงขึ้นมาให้

กับดัก ICC ที่มีตัวตรวจสอบจริงเท่านั้นที่จับได้

นี่คือความล้มเหลวที่สอนบทเรียนยากที่สุด เพราะตัวตรวจสอบของไลบรารีเองผ่านมันไปได้ แต่ veraPDF ซึ่งเป็น reference validator ของ ISO 19005 กลับไม่ผ่าน PDF/A กำหนดว่า destination profile ของ OutputIntent ต้องเป็น ICCBased stream ที่ถูกต้อง และ §6.2.3.2 บังคับให้ตัวตรวจสอบยืนยัน stream นั้นในฐานะ color space ICCBased stream ต้องประกาศ /N ซึ่งเป็นจำนวนของ colour components เวอร์ชันแรก ๆ ของตัว inject เขียน ICC stream dictionary โดยมีแค่ /Length แต่ไม่มี /N และ veraPDF ปฏิเสธผลลัพธ์ด้วยข้อความ "The N entry (value null)... is missing"

สิ่งที่ทำให้มันร้ายกาจคือการปฏิเสธนั้นเกิดเฉพาะ PDF/A-1b และ -1a เท่านั้น โมเดลความสอดคล้องของ part 2 และ part 3 ไม่ได้ตรวจ destination profile ด้วยเงื่อนไขนั้น ดังนั้นโครงสร้างที่ inject เหมือนกันจึงผ่านการตรวจภายใต้ pac2b, pac3b, และ pac2u แต่กลับล้มเหลวภายใต้ pac1b เพียงเพราะค่า pdfaid:part เท่านั้น unit test ไม่มีวันเห็นปัญหานี้ เพราะ ValidatePdfACompliance ของไลบรารีเองเช็คแค่ว่ามีคีย์ /DestOutputProfile อยู่หรือไม่ ไม่ได้ดูว่าอะไรอยู่ข้างใน stream dictionary ผลก็คือการทดสอบภายในยังเขียว แต่การตรวจสอบเพื่อเก็บถาวรจริงล้มเหลว

วิธีแก้คือ IccComponentCount ซึ่งอ่าน signature ของ data colour space ที่ offset 16 ใน ICC header แล้วแปลงเป็นจำนวน component โดย GRAY เป็น 1, RGB , Lab , และ XYZ เป็น 3, CMYK เป็น 4 และถ้าเป็น profile ที่ไม่รู้จักจะใช้ค่าเริ่มต้นเป็น 3 ค่านั้นจะถูกใส่ลงใน stream dictionary เป็น /N มันถูกคำนวณ ไม่ได้ hard-code ไว้ที่ 3 เพื่อให้ผู้เรียกที่ส่งโปรไฟล์ CMYK หรือ grayscale ผ่าน IccProfileData ยังได้ค่าที่ถูกต้อง บทเรียนที่กว้างกว่าคือเรื่องวิธีคิด ตัวตรวจสอบในไลบรารีกับ validator ที่เป็น reference ต่างก็มีจุดบอดของตัวเอง และผลลัพธ์ PDF/A ต้องถูกทดสอบแบบ end-to-end กับ implementation อ้างอิงอย่าง veraPDF ไม่ใช่เชื่อจาก self-check ของตัวเอง ลักษณะ incremental update แบบเดียวกับที่ทำให้ไฟล์เก็บถาวรสะอาดก็อธิบายไว้ใน การตรวจสอบ object และ xref streams ที่บีบอัด ซึ่งสำคัญเพราะ PDF สมัยใหม่ที่ตัว inject รับเข้ามามักสร้างบน cross-reference streams

การเข้ารหัส, xref streams และกรณีขอบอื่น ๆ

เพราะ ISO 19005 ห้ามการเข้ารหัส ฝั่งบันทึกจึงลบมันออกก่อนเขียน SaveAsPdfA จะใช้ FPDF_REMOVE_SECURITY ตอน serialize ดังนั้นแหล่งข้อมูลที่ถูกเข้ารหัสและโหลดมาพร้อมรหัสผ่านจะถูกถอดรหัสระหว่างทางเข้า archive ถ้าเป็นเอกสารที่ไม่ได้เข้ารหัสก็จะไม่มีผลอะไรเลย ข้อสรุปในอีกด้านคือข้อจำกัดเดียวกับที่ HotPDF บังคับจากฝั่งตรงข้าม นั่นคือไฟล์เดียวไม่สามารถเป็นทั้งเข้ารหัสและ PDF/A ได้ ถ้าเวิร์กโฟลว์ต้องใช้ทั้งสองอย่าง คำตอบคือสร้างสองอาร์ติแฟกต์ สำเนาที่เข้ารหัสไว้สำหรับแจกจ่าย และสำเนาที่สะอาดอีกชุดสำหรับ archive

อีกกรณีหนึ่งที่มองไม่เห็นจนกว่ามันจะกัดคือเอกสาร PDF 1.5+ ที่ใช้ pure cross-reference stream และไม่มี keyword trailer ตัว inject จะอ่าน trailer เพื่อหา /Info ต้นทางและต่อ incremental update ของมัน และต้องยอมรับรูปแบบ xref-stream ด้วย ไม่เช่นนั้นเอกสารแบบนี้จะถูกคัดลอกต่อไปพร้อม marker ที่ถูกทิ้งหายอย่างเงียบ ๆ ISO 32000-1 §7.5.6 อนุญาตอย่างชัดเจนให้ classic trailer incremental update ตามหลังเอกสาร xref-stream ได้ โดยให้ /Prev ชี้ไปยัง offset ของ xref-stream ซึ่งก็คือโครงสร้างที่ตัว inject สร้างออกมาเอง PDFium FPDF_SaveAsCopy ของตัวมันจะเขียน classic trailer เสมอ ดังนั้นใน pipeline ปกติ ตัว inject จึงไม่เคยเจอแหล่งข้อมูล pure xref-stream แต่ฝั่งอ่านก็รองรับเอกสารที่เข้ามาจากที่อื่นได้

ตรวจสอบก่อนจะเชื่อคำอ้าง

ไลบรารีแถมตัวตรวจสอบระดับไบต์มาให้คือ TPdf.ValidatePdfA ซึ่งคืนค่าเป็น TPdfAValidationResult ฟิลด์ Conformance ของมันรายงานระดับที่ตรวจพบ ส่วน Issues เป็นชุดค่า TPdfAValidationIssue เมธอดอำนวยความสะดวก IsCompliant จะเป็น true ก็ต่อเมื่อพบระดับจริงและชุด issues ว่างเปล่าเท่านั้น ใช้มันเป็นด่านแรกแบบรวดเร็วในงานแบตช์ได้เลย

var
  Pdf: TPdf;
  Res: TPdfAValidationResult;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.LoadFromFile('invoice_archive.pdf');
    Res := Pdf.ValidatePdfA;
    if Res.IsCompliant then
      Writeln('Conformant: detected level ', Ord(Res.Conformance))
    else
      Writeln('Issues found: ', SizeOf(Res.Issues), ' flags set');
  finally
    Pdf.Free;
  end;
end;

ต้องพูดให้ตรงว่ามันให้ประโยชน์อะไรบ้าง ตัวตรวจระดับไบต์จับปัญหาเชิงโครงสร้างได้ด้วยความมั่นใจสูง เช่น OutputIntent หาย, มี action ที่ห้ามใช้, มี /Encrypt, หรือมี transparency ในจุดที่ part 1 ไม่อนุญาต และการตรวจการฝังฟอนต์ใช้ heuristic แบบนับจำนวนที่ตั้งใจรายงานเฉพาะสัญญาณที่มีความมั่นใจสูง ไม่ได้ไล่ตรวจ coverage ราย glyph สิ่งที่มันไม่ทำคือการวิเคราะห์ operator ใน content stream เพราะนั่นต้องใช้ content parser เต็มรูปแบบและอยู่นอกขอบเขตโดยการออกแบบ สำหรับ release gate ให้จับคู่ตัวตรวจในไลบรารีกับ veraPDF เพราะตัวตรวจนั้นเร็วและรันได้ทุกที่โดยไม่ต้องมี DLL ส่วน veraPDF คือสิ่งที่เป็น authoritative การผูกคู่นี้เข้ากับการรันแบบแบตช์คือหัวข้อของ CLI รายงาน preflight แบบแบตช์ ซึ่งเป็นจุดที่การตรวจสอบแบบนี้ควรอยู่ในเวิร์กโฟลว์การเก็บถาวรจริง

API SaveAsPdfA, InjectPdfAMarkers, และ ValidatePdfA ที่แสดงที่นี่มาพร้อมกับ PDFium Component สำหรับ Delphi, C++Builder และ Lazarus/FPC หน้าผลิตภัณฑ์มีลิงก์ไปยังเอกสารอ้างอิง API ฉบับเต็ม รวมถึง enumeration ของ conformance ครบชุด และ options record ที่อยู่เบื้องหลังตัวอย่างเหล่านี้