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

การตรวจสอบความสอดคล้อง PDF/A ก่อนส่งใน Delphi ด้วย PDFium VCL

เกตนำเข้าคลังเก็บปฏิเสธไฟล์ชุดหนึ่งที่ระบุว่าเป็น "PDF/A-2b" ทั้งที่เปิดได้ปกติในโปรแกรมดูเอกสารทุกตัวบนเครื่อง ผู้ส่งยืนยันว่าสอดคล้องตามมาตรฐาน แต่ความจริงไม่ใช่ เพราะแต่ละไฟล์มีการกระทำ JavaScript ซ่อนอยู่ใน catalog ซึ่งเป็นสิ่งที่มองด้วยตาเปล่าแทบไม่เจอ และตัวตรวจสอบ PDF/A เต็มรูปแบบอย่าง veraPDF จะจับได้ในทันที ปัญหาคือไม่มีใครอยากผูก toolchain ภาษา Java เข้ากับบริการแบตช์ของ Delphi เพื่อแค่ตอบคำถามแบบใช่หรือไม่ใช่ต่อไฟล์หนึ่งไฟล์ ช่องว่างนี้คือสิ่งที่ ValidatePdfACompliance ใน PDFium Component เข้ามาเติม และควรเข้าใจว่ามันตัดสินผลอย่างไรโดยไม่ต้อง parse content stream ทั้งก้อน

ทำไม PDFium เองจึงตอบเรื่องนี้ไม่ได้

สิ่งแรกที่ต้องพูดตรงๆ คือ pdfium.dll ที่มาพร้อมกันนั้นไม่มีความสามารถด้าน PDF/A เลย ไม่มี ConvertToPDFA ไม่มีตัวเขียน OutputIntent และไม่มี XMP API ใน public surface ส่วนที่เกี่ยวกับ PDF/A ในไลบรารีนี้ ทั้งฝั่งเขียนและฝั่งตรวจ ล้วนอยู่ใน Pascal ล้วนๆ ภายใน FPdfPdfa.pas และทำงานด้วยการ parse ระดับไบต์ร่วมกับ incremental update ดังนั้นเวลาคุณเรียกตัวตรวจสอบ คุณไม่ได้ถาม renderer ของ Chromium อะไรเลย คุณกำลังให้ตัวสแกนโทเคนภาษา Pascal วิ่งผ่านไบต์เชิงโครงสร้างของไฟล์

public API ตั้งใจให้มีขนาดเล็กมาก ฟังก์ชันเดียวอ่าน stream จากตำแหน่ง 0 แล้วคืนค่าเป็น record:

function ValidatePdfACompliance(Source: TStream): TPdfAValidationResult;

type
  TPdfAValidationResult = record
    Conformance: TPdfAConformance;        // pacUnknown, pacNone, pac1b, pac2u, ...
    Issues: TPdfAValidationIssues;        // a set of TPdfAValidationIssue
    function IsCompliant: Boolean;        // True only when level <> unknown/none
  end;                                    // AND Issues is empty

IsCompliant เขียนกฎสำคัญสำหรับเกตเอาไว้ว่า ไฟล์จะผ่านได้ก็ต่อเมื่อพบระดับความสอดคล้องจริง และชุดปัญหาต้องว่างเปล่า การ parse ที่สำเร็จแต่ไม่พบ marker ของ pdfaid จะได้ผลเป็น pacNone ซึ่งไม่ถือว่าผ่านโดยชัดเจน นี่คือแนวคิดเดียวกับที่ CLI รายงาน preflight แบบแบตช์ แสดงจากภายนอก รายการผลลัพธ์ที่ว่างเปล่าบนไฟล์ที่ยังไม่ถูกจดจำไม่ได้หมายความว่าไฟล์นั้นสะอาด

การตัดเนื้อหาในสตรีมออกก่อนสแกนโทเคนใดๆ

นี่คือรายละเอียดการทำงานที่สำคัญที่สุด และเป็นจุดที่พลาดได้ง่ายที่สุดหากคุณเขียนตัวสแกนเอง ตัวตรวจจับจะหาการละเมิดด้วยการค้นหา name token ที่มีขอบเขตชัดเจน เช่น /JavaScript, /LZWDecode, /BM ถ้าคุณสแกนไบต์ดิบของไฟล์ ตำแหน่งของ stream แบบไบนารี รูปภาพที่ถูกบีบอัด ICC profile และโปรแกรมฟอนต์ อาจบังเอิญมีลำดับไบต์ที่ดูเหมือน token เหล่านั้น คุณจะรายงานว่าเจอ /AA หรือ /3D ทั้งที่จริงมีแค่สามไบต์ใน JPEG ไปสะกดคำพอดี นั่นคือโรงงานผลิต false positive

วิธีแก้คือ PdfStructureBytes มันไล่ทั้งไฟล์แล้วแทนไบต์ระหว่างคีย์เวิร์ด stream และ endstream ทุกจุดด้วยช่องว่าง โดยยังคงโครงสร้างของ dictionary เอาไว้ครบ จากนั้นจึงค่อยเริ่มสแกน ทุกการตรวจ name token ในตัวตรวจสอบจะทำงานกับสำเนาที่ถูกตัดเนื้อหาออกแล้ว ถ้าคุณจะจำแนวคิดเดียวจากบทความนี้ ให้จำอันนี้ไว้ และแนวทางเดียวกันนี้ก็ถูกสะท้อนอยู่ในตัวตรวจสอบ PDF/UA ซึ่งเก็บสำเนาของ routine นี้แยกต่างหาก เพราะมาตรฐานทั้งสองพัฒนาอย่างอิสระ

ประเด็นตรวจสอบทั้ง 29 รายการและความหมายของแต่ละรายการ

TPdfAValidationIssue เป็นสัญญาที่ระบุไว้ชัดเจน เลขลำดับถูกตรึงไว้เพราะทั้ง DUnitX tests, demo และชั้นรายงานต่างพึ่งพามันอยู่ ดังนั้นรายการที่เพิ่มใหม่จึงถูกต่อท้ายเสมอ ณ v1.63.0 มีสมาชิกทั้งหมด 29 รายการ และแบ่งได้เป็นหลายกลุ่ม:

  • Metadata และตัวตน: pvaiMissingXmpMetadata, pvaiMissingPdfAIdentifier, pvaiMissingTrailerId (ISO 19005-1 6.1.3), pvaiMissingXmpDates
  • สีและผลลัพธ์: pvaiMissingOutputIntent, pvaiMissingIccProfile และ pvaiMixedDeviceColorSpaces เมื่อพบทั้ง DeviceRGB และ DeviceCMYK (6.2.3.3)
  • ข้อห้ามแบบเด็ดขาดสำหรับทุก part: pvaiEncryptionPresent (ห้ามมี dictionary /Encrypt โดยสิ้นเชิง), pvaiJavaScriptPresent, pvaiForbiddenAction, pvaiAdditionalActions, pvaiLzwUsed, pvaiXfaPresent, pvaiNeedAppearancesTrue, pvaiForbiddenAnnotation
  • ฟอนต์: pvaiFontNotEmbedded และตัวที่เข้มงวดกว่าอย่าง pvaiUnembeddedFont รวมถึง pvaiUnicodeMappingMissing สำหรับการอ้างว่าเป็น Level U แต่ไม่มี /ToUnicode
  • การทำ tagging: pvaiLevelAStructureMissing เมื่อการอ้างว่า conformant=A ไม่มีโครงสร้างที่ถูก tag

สมาชิกใหม่ 6 รายการที่เพิ่มในลำดับ 24 ถึง 29 ครอบคลุมกรณีละเอียดอ่อนที่ผู้ตรวจทานมักสะดุดจริงๆ ได้แก่ pvaiTrappedTrue (มี /Trapped /True อยู่ใน Info dictionary ซึ่งเป็น "กับดักคำคล้าย" เพราะค่าที่ถูกต้องต้องเป็น False หรือ Unknown), pvaiForbiddenActionSubtype (ใช้ Sound หรือ Movie เป็น action ไม่ใช่แค่ annotation), pvaiTransparentColorSpace (blend mode ที่ไม่ใช่ Normal หรือ /CA//ca ที่ไม่เท่ากับ 1.0), pvaiAnnotationDictViolation, pvaiUnembeddedFont และ pvaiMixedDeviceColorSpaces

การเปิดหรือปิดการตรวจตาม part: A-1 เข้มงวดกว่า ส่วน A-2 และ A-3 ผ่อนคลายกว่า

PDF/A ไม่ได้มี rulebook เดียว สามสิ่งที่ PDF/A-1 ห้ามไว้จะถูกอนุญาตอย่างชัดเจนตั้งแต่ PDF/A-2 เป็นต้นไป ได้แก่ transparency (group /Transparency หรือ /SMask ที่ยังทำงานอยู่, 6.4), optional content (/OCProperties, 6.1.13) และ embedded files (/EmbeddedFiles หรือ /EF, 6.1.11) ตัวตรวจสอบที่ไม่แยกตาม part แล้วฟ้องทั้งสามอย่างกับทุกไฟล์จะปฏิเสธเอกสาร PDF/A-2 ที่ถูกต้องสมบูรณ์ไปจำนวนมาก

ดังนั้นตัวตรวจสอบจึงอ่านหมายเลข part จาก marker ของ pdfaid ผ่าน PdfAPartOf แล้วเปิดหรือปิดการตรวจเหล่านั้นด้วยเงื่อนไข PartNo = 1 การตรวจ blend mode และ alpha ของ annotation สำหรับปัญหา transparency ใหม่ก็เป็นของ part-1 เท่านั้นเช่นกัน:

if PartNo = 1 then
begin
  if PdfHasName(Struct, '/BM') then
    if not PdfHasBMNormal(Struct) then          // only /Normal or /Compatible allowed
      Include(Result.Issues, pvaiTransparentColorSpace);
  if PdfHasCaNotOne(Struct, '/CA') or PdfHasCaNotOne(Struct, '/ca') then
    Include(Result.Issues, pvaiTransparentColorSpace);
end;

มีค่าเริ่มต้นแบบอนุรักษนิยมอย่างหนึ่งที่ควรพูดถึง คือถ้าไม่มี marker ของ pdfaid เลย ระบบจะถือว่า part เป็น 1 ซึ่งเข้มงวดที่สุด เหตุผลคือไฟล์ที่ยังระบุไม่ได้ควรถูกตรวจด้วยกฎที่รัดกุมที่สุด ไม่ใช่ปล่อยให้ผ่านไปเฉยๆ JavaScript, forbidden actions, LZW, XFA, NeedAppearances, forbidden annotations และฟอนต์ที่ไม่ได้ embed ยังคงถูกห้ามในทุก part ดังนั้นการตรวจเหล่านี้จึงไม่เคยอยู่หลังเกต

การขยาย object stream เพื่อไม่ให้สิ่งใดซ่อนตัวได้

PDF 1.5 ได้แนะนำ cross-reference stream และ object stream (/Type /ObjStm) ซึ่งสร้างจุดบอดให้กับ byte scanner แบบง่ายๆ catalog, OutputIntent, action dictionary และทุกอย่างที่ไม่ใช่ stream เองสามารถถูกบีบอัดด้วย Flate อยู่ภายใน ObjStm ได้ ถ้าคุณสแกนโครงสร้างดิบโดยตรง คุณจะไม่เห็นสิ่งเหล่านี้เลย แล้วก็อาจรายงานว่าไฟล์สะอาด ทั้งที่ไม่ใช่

PdfExpandObjectStreams เข้ามาปิดช่องว่างนั้น ก่อนการตรวจใดๆ ตัวตรวจสอบจะทำ Data := PdfExpandObjectStreams(Data) routine นี้หา ObjStm ทุกตัว อ่าน header /N และ /First เพื่อดึงหมายเลขและออฟเซ็ตของ object ที่บรรจุอยู่ ขยายเนื้อหาด้วย PdfInflate (zlib ของ RTL, System.ZLib บน Delphi และ zstream บน FPC) แล้วต่อท้ายแต่ละ object ที่อยู่ภายในในรูปของ N 0 obj ... endobj ธรรมดาไว้ที่ท้ายสำเนาของไบต์ จากนั้นการตรวจโทเคนที่มีอยู่แล้วก็จะหา object เหล่านั้นเจอโดยไม่ต้องเปลี่ยนตรรกะเลย

มีข้อจำกัดสองอย่างที่ทำให้วิธีนี้สะอาดแทนที่จะเปราะบาง Stream objects, Metadata, ICC profile และโปรแกรมฟอนต์ ไม่สามารถอยู่ใน object stream ได้ มีเพียง dictionary ที่ไม่ใช่ stream เท่านั้นที่ทำได้ ดังนั้นการขยายจึงจัดการแค่ dictionary และ object ที่ถูกต่อท้ายก็ไม่มีคีย์เวิร์ด stream มารบกวนขั้นตอนตัดเนื้อหาในสตรีม และเพราะเนื้อหาที่ต่อท้ายถูกวางไว้หลัง %%EOF การค้นหาแบบย้อนจาก startxref ก็ยังหา trailer เดิมเจออยู่ดี trailer ของ cross-reference stream เองถูกจัดการไปก่อนแล้วตั้งแต่ v1.49.3 โดยอ่าน Root, Size และ ID ตรงจาก dictionary ของ xref-stream แบบ plaintext ซึ่งมีอธิบายไว้ในบทความประกอบเรื่อง การตรวจสอบ object stream และ cross-reference stream ส่วนงานของ object stream แค่เติมขั้นตอน inflate เข้าไป โดยไม่ต้อง decode xref entry แบบ type 2 หรือคลาย PNG predictor

ข้อจำกัดที่ยอมรับกันตรงๆ ของตัวตรวจระดับไบต์

นี่เป็นเครื่องมือ preflight ไม่ใช่ตัวตรวจที่ได้รับการรับรอง และขอบเขตของมันมีจริง การ embed ฟอนต์อาศัย heuristic แบบนับจำนวน และการทำให้ถูกต้องต้องมีการแก้ไขที่ควรรู้ไว้ เดิมการตรวจใช้ PdfCountName('/FontDescriptor') แต่ฟอนต์แต่ละตัวจะมี token /FontDescriptor สองจุด คือ reference หนึ่งจาก font dictionary และ /Type อีกหนึ่งในตัว descriptor object เอง ทำให้จำนวนที่ได้เป็น 2N เทียบกับโปรแกรมที่ embed อยู่ N ชิ้น และการทดสอบจึงเป็นจริงเสมอ วิธีแก้คือ PdfCountDescriptorRefs ซึ่งนับเฉพาะรูปแบบ reference /FontDescriptor N G R หนึ่งครั้งต่อฟอนต์ และจะยก pvaiUnembeddedFont ขึ้นมาก็ต่อเมื่อโปรแกรมที่ embed จริงๆ มีน้อยกว่า:

K := PdfCountDescriptorRefs(Struct);                 // one per font dict
Emb := PdfCountName(Struct, '/FontFile')
     + PdfCountName(Struct, '/FontFile2')
     + PdfCountName(Struct, '/FontFile3');
if (K > 0) and (Emb < K) then
  Include(Result.Issues, pvaiUnembeddedFont);

แม้จะแก้แล้ว มันก็ยังเป็นการตรวจแบบหยาบ เอกสารที่ผสมกันซึ่ง descriptor ทุกตัวบังเอิญมี FontFile บางอย่าง ก็ยังปล่อยฟอนต์ที่ไม่สอดคล้องบางตัวหลุดไปได้ การขยาย object stream ก็มีผลข้างเคียงที่รู้กันอยู่ มันจะทำให้ standard-14 default resources ที่ AcroForm /DR ถืออยู่ เช่น /Helv ถูกมองเห็น และ heuristic ก็จะรายงานอย่างซื่อสัตย์ว่ามันไม่ได้ embed แม้ว่า veraPDF จะปล่อยผ่านเพราะมันไม่เคยถูกใช้จริงในการ render การตรวจระดับ operator ของ content stream (6.2.10) อยู่นอกขอบเขตทั้งหมด เพราะต้อง parse เนื้อหาเต็มแทนที่จะสแกนแบบ byte scan ใช้ตัวตรวจนี้เป็นเกตแรกที่เร็วและไม่ต้องพึ่ง dependency เพื่อจับการละเมิดที่การฝัง marker แก้ไม่ได้ และเก็บตัวตรวจเต็มรูปแบบไว้สำหรับการรับรองขั้นสุดท้าย

นี่คือฝั่งการตรวจของเรื่องนี้ ส่วนฝั่งการเขียนที่ทำงานคู่กัน ซึ่ง SaveAsPdfA จะฉีด XMP, OutputIntent และ sRGB ICC profile เข้าไป และลดระดับคำขอ Level A อย่างตรงไปตรงมาถ้าไม่มีโครงสร้างที่ถูก tag นั้น ตั้งอยู่บนกลไกระดับไบต์เดียวกัน ทั้งสองฝั่งมาพร้อมกันใน PDFium Component for Delphi ซึ่งเป็นแพ็กเกจ VCL เดียวที่ครอบการ implement PDF/A แบบ pure Pascal โดยไม่ต้องติดตั้ง runtime ภายนอก