เกตนำเข้าคลังเก็บปฏิเสธไฟล์ชุดหนึ่งที่ระบุว่าเป็น "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 ภายนอก