ในการแนบไฟล์ต้นทางเข้าเอกสาร PDF/A-3 จาก Delphi PDFium Component เขียนห่วงโซ่ associated file แบบ PDF 2.0: stream ไฟล์ฝังที่มี MIME /Subtype, file specification ที่พก /AFRelationship และ array /AF ที่แขวนอยู่กับ catalog หรือหนึ่งหน้า InjectAssociateFiles กับ TPdf.SaveAsWithAssociateFiles สร้างห่วงโซ่นี้ใน incremental update เดียว และตั้งแต่ v3.121.2 MIME type ถูก serialize เป็น PDF name ตัวเดียวที่ escape ถูกต้อง ส่วนที่เหลือของโพสต์นี้เล่าว่าตัว validator เช็กอะไร, bug หนึ่งตัวอักษรที่ทำให้ text/plain พัง และจุดที่ release เก่าเคยทำสิ่งอื่นจากที่คุณสั่งไปเงียบ ๆ
associated file ของ PDF/A-3 ต้องการอะไรกันแน่
attachment ของ PDF/A-3 ผ่านการตรวจต่อเมื่อ object ทั้งสามเห็นพ้องกัน: stream ไฟล์ฝังประกาศ /Type /EmbeddedFile บวก MIME /Subtype, dictionary ของ file specification (ISO 32000-2 §7.11.3) พก /F, /UF, /EF และ /AFRelationship และมีบางอย่างในเอกสารอ้างอิง file specification นั้นผ่าน array /AF (ISO 32000-2 §14.13) การฝังธรรมดาผ่าน tree /Names /EmbeddedFiles ซึ่งเป็นสิ่งที่ TPdf.CreateAttachment ทำ ไม่เคยตั้งฟิลด์ความสัมพันธ์เลยสักอย่าง fixture ตรวจ PDF/A-3b ของ PDFium Component เองทำให้เห็นความสัมพันธ์นี้เป็นรูปเป็นร่าง: เปลี่ยนชื่อ key /AFRelationship อย่างเดียว ไฟล์ก็พังกฎข้อเดียวพอดีในข้อ 6.8 ของ ISO 19005-3, ถอด MIME /Subtype อย่างเดียวกฎ 6.8 อีกข้อพัง, ยัด attachment เดิมเข้าตัวเลือก PDF/A-1b มันถูกปฏิเสธทันที เพราะ PDF/A-1 ห้ามไฟล์ฝัง ไม่ว่า metadata จะเรียบร้อยแค่ไหน
ค่าความสัมพันธ์คือส่วนที่คนมักเดา TPdfAFRelationship ใน FPdfAssocFiles แมปสมาชิก enum หนึ่งตัวต่อ name token หนึ่งตัวที่ injector emit ได้ และมีแค่ห้าตัวแรกที่อยู่ใน subset ที่ ISO 19005-3 รู้จัก:
afSource→/Source: ต้นฉบับที่ PDF ถูกผลิตขึ้นจากมัน เช่นไฟล์ประมวลผลคำหรือสเปรดชีตafData→/Data: ข้อมูลที่เครื่องอ่านได้ซึ่งเนื้อหาที่มองเห็นมาจากมันหรือแทนค่ามันafAlternative→/Alternative,afSupplement→/Supplement,afUnspecified→/UnspecifiedafEncryptedPayload,afFormData,afTemplate: สิ่งที่เพิ่มใน PDF 2.0 ซึ่งอยู่นอก subset ของ PDF/A-3 อย่าเอาไปใส่ผลลัพธ์สำหรับจัดเก็บถาวร
ทำไม /Subtype /text/plain ถึงทำให้การตรวจพัง
bug MIME ตัวนี้เป็นความผิดพลาดด้าน tokenization ไม่ใช่ช่องโหว่ความสอดคล้อง: ก่อน v3.121.2 injector ต่อ string ของผู้เรียกตรง ๆ ต่อท้ายเครื่องหมายทับ ได้ /Subtype /text/plain ออกมา ใน syntax ของ PDF เครื่องหมายทับตัวที่สองเริ่ม name object ใหม่ (ISO 32000-1 §7.3.5) dictionary ของ stream จึงกลายเป็นมี key /Subtype, ชื่อ /text และชื่อเกินมาห้อย ๆ อย่าง /plain ที่ทำให้คู่ key-value เสียสมดุล ตัว validate PDF/A อิสระปฏิเสธไฟล์ระหว่าง parse dictionary EmbeddedFile ก่อนจะไปถึงกฎ PDF/A สักข้อ ความล้มเหลวจึงหน้าตาเหมือนไฟล์เสียหายมากกว่า property ของ attachment ที่หายไป
การแก้เสี่ยงค่า MIME ผ่าน EscapePdfName ซึ่ง emit /text#2Fplain ออกมา: ชื่อเดียวที่ค่า decode แล้วคือ text/plain การ escape นี้กว้างกว่าแค่เครื่องหมายทับอย่างตั้งใจ ไบต์ทุกตัวที่ต่ำกว่าหรือเท่ากับ 32 (ช่องว่าง, tab, CR, LF), ไบต์ทุกตัวที่สูงกว่าหรือเท่ากับ 127, ตัวคั่น ()<>[]{}/% และตัว escape # ล้วนกลายเป็น #XX ถ้า escape แค่เครื่องหมายทับจะเหลือรูรั่วอีกแบบ: string MIME ที่มี >> หรือช่องว่างสามารถปิด dictionary ก่อนเวลาอันควรหรือฉีด key เพิ่มเข้าไป regression test จึงป้อนค่าไม่เป็นมิตรที่ครบทุกตัวคั่นบวก tab, LF และ CR แล้วเช็กผลเข้ารหัสแบบเป๊ะ ๆ
// สิ่งที่ injector เขียนเมื่อ MIMEType = 'text/plain'
// ก่อน v3.121.2: /Type /EmbeddedFile /Subtype /text/plain (สองชื่อ)
// v3.121.2: /Type /EmbeddedFile /Subtype /text#2Fplain (หนึ่งชื่อ)
//
// ผู้เรียกส่งค่า MIME ธรรมดามาเสมอ ถ้า escape เองล่วงหน้า
// จะ encode '#' ซ้ำ ทำให้ 'text#2Fplain' กลายเป็น 'text#232Fplain'
Options.Files[0].MIMEType := 'text/plain';
สร้างไฟล์ PDF/A-3 ด้วย InjectAssociateFiles
สำหรับผลลัพธ์ PDF/A-3 ให้ผลิตเอกสารฐานที่สอดคล้องด้วย TPdf.SaveAsPdfAToStream แล้วค่อยเรียก InjectAssociateFiles กับ stream นั้น pipeline สองขั้นแบบนี้คือสิ่งที่ fixture ตรวจสอบรันก่อนจะผ่าน PDF/A-3b พอดี TPdf.SaveAsWithAssociateFiles เป็น wrapper เพื่อความสะดวก แต่มันบันทึกผ่านเส้นทาง SaveAs ธรรมดาพร้อม saRemoveSecurity แทนที่จะผ่านตัวเขียน PDF/A มันจึงไม่เติม XMP identification กับ output intent ที่ PDF/A บังคับ อย่าลืมว่า record types อยู่ใน FPdfAssocFiles กับ FPdfPdfa ทั้งสอง unit จึงต้องอยู่ใน uses ของคุณ ตั้งแต่ v3.121.3 FileName กับ Description ไม่ต้องเป็น ASCII ธรรมดาอีกต่อไป: /UF กับ /Desc ถูกเขียนเป็น PDF text string, ASCII ที่พิมพ์ได้เก็บตรง ๆ และอย่างอื่นเป็น UTF-16BE พร้อม byte order mark ขณะที่ชื่อ /F รายเก่าเป็น printable ASCII แบบพกพาเสมอ โดยตัวอักษรอื่นทุกตัวถูกแทนด้วย _ ตัวอ่านที่ decode /F ด้วย code page ของตัวเองจะเห็นขีดล่างแทน mojibake บิลด์ก่อนหน้าแปลงทั้งสามผ่าน system ANSI code page บน Delphi หรือเขียนไบต์ UTF-8 ดิบ ๆ บน Free Pascal เก็บชื่อเป็น ASCII เฉพาะเมื่อบิลด์เก่าต้องผลิตผลลัพธ์เดียวกันเท่านั้น
uses
System.SysUtils, System.Classes, System.IOUtils,
PDFium, FPdfPdfa, FPdfAssocFiles;
procedure SaveWithSourceData(Pdf: TPdf; const XmlPath, OutPath: string);
var
PdfAOptions: TPdfASaveOptions;
Options: TAssocFilesOptions;
Base: TMemoryStream;
Output: TFileStream;
begin
PdfAOptions := TPdfASaveOptions.Default;
PdfAOptions.Conformance := pac3b;
Options := TAssocFilesOptions.Default; // TargetPage = 0: /AF ระดับ catalog
SetLength(Options.Files, 1);
Options.Files[0].FileName := 'invoice-data.xml';
Options.Files[0].Description := 'Structured invoice data';
Options.Files[0].Content := TFile.ReadAllBytes(XmlPath);
Options.Files[0].Relationship := afData;
Options.Files[0].MIMEType := 'application/xml'; // เขียนเป็น /application#2Fxml
Base := TMemoryStream.Create;
try
if not Pdf.SaveAsPdfAToStream(Base, PdfAOptions) then
raise Exception.Create('PDF/A-3 base save failed');
Output := TFileStream.Create(OutPath, fmCreate);
try
InjectAssociateFiles(Base, Output, Options); // ย้อน Base กลับ โยน EPdfAssocFilesError เมื่อล้มเหลว
finally
Output.Free;
end;
finally
Base.Free;
end;
end;
catalog หรือหน้า: array /AF ไปลงที่ไหน
TAssocFilesOptions.TargetPage ตัดสินว่าใครเป็นเจ้าของ array /AF: 0 ผูกกับ catalog เป็นความสัมพันธ์ระดับเอกสาร และ 1..N ผูกกับ dictionary ของหน้านั้น แบบ 1-based injector ต่อทุกอย่างเป็น incremental update เดียวในผังตายตัว (stream ที่ฝัง แล้ว file specification แล้ว array /AF แล้ว object catalog หรือหน้าที่เขียนใหม่) object ที่มีอยู่จึงคง offset เดิม และไม่มีอะไรถูกบีบอัดใหม่ entry /AF เดิมบน dictionary เป้าหมายถูกแทนที่ ไม่ใช่ถูกรวม การบันทึกซ้ำจึงเป็น idempotent แต่ก็แปลว่าการเรียกครั้งที่สองด้วยรายการไฟล์ต่างกันเป็นฝ่ายชนะ มีพฤติกรรมสองอย่างที่เคยสมควรได้รับ guard ในโค้ดของคุณเอง และทั้งสองเปลี่ยนไปแล้ว ก่อน v3.122.0 TargetPage ที่เกินช่วงไม่พัง มันตกกลับไปที่ catalog พิมพ์ผิดนิดเดียวจึงเปลี่ยนความสัมพันธ์ระดับหน้าให้กลายเป็นระดับเอกสารโดยไม่มีสัญญาณใด ๆ ตั้งแต่ v3.122.0 SaveAsWithAssociateFiles กับ SaveAsWithAssociateFilesToStream โยน EPdfError เมื่อ TargetPage อยู่นอก 0..PageCount และ InjectAssociateFiles โยน EPdfAssocFilesError ตัวใหม่สำหรับ TargetPage ติดลบหรือที่ระบุหน้าที่ไม่มีอยู่จริง โดยปล่อย stream ปลายทางตามเดิม ก่อน v3.121.4 การค้นหน้าไล่กวาดไบต์ที่บันทึกหา dictionary /Type /Page ตามลำดับไฟล์ ซึ่งอาจผูกไฟล์เข้ากับหน้าอื่นเมื่อ object หน้าถูกเก็บเรียงต่างจากที่แสดง ยกตัวอย่างหลังจากจัดลำดับหน้าใหม่หรือแทรกหน้า ตั้งแต่ v3.121.4 TargetPage ระบุหน้าที่ตำแหน่งนั้นในลำดับหน้าของเอกสาร
procedure AttachChartSource(Pdf: TPdf; PageNumber: Integer;
const CsvBytes: TBytes; const OutPath: string);
var
Options: TAssocFilesOptions;
begin
// ตั้งแต่ v3.122.0 TargetPage ที่เกินช่วงโยน EPdfError (บิลด์เก่า
// ตกกลับไป /AF ระดับ catalog เงียบ ๆ) เช็กก่อนจะระบุหน้าได้ถูกตัว
if (PageNumber < 1) or (PageNumber > Pdf.PageCount) then
raise EArgumentOutOfRangeException.CreateFmt('No page %d', [PageNumber]);
Options := TAssocFilesOptions.Default;
Options.TargetPage := PageNumber;
SetLength(Options.Files, 1);
Options.Files[0].FileName := 'chart-data.csv';
Options.Files[0].Content := CsvBytes;
Options.Files[0].Relationship := afSource;
Options.Files[0].MIMEType := 'text/csv';
if not Pdf.SaveAsWithAssociateFiles(OutPath, Options) then
raise Exception.Create('Associated-file save failed');
end;
จะอ่าน AFRelationship กลับมาอย่างน่าเชื่อถือได้อย่างไร
TPdf.AttachmentRelationship[Index] คืนชื่อ /AFRelationship ของ attachment ผ่าน export FPDFAttachment_GetAFRelationship แท้ ๆ แต่ string ว่างมีความหมายได้สองแบบ จึงต้องเรียก AttachmentRelationshipFeaturesAvailable ก่อน binding ถูกโหลดแบบใจกว้าง: เมื่อ DLL ของ PDFium ไม่มี export นั้น ความสัมพันธ์ทุกตัวอ่านเป็นค่าว่าง ซึ่งแยกจาก file specification ที่แค่ไม่มี /AFRelationship ไม่ออก property นี้ยังใช้ index ร่วมกับ AttachmentCount ซึ่งนับ entry ใน tree /Names /EmbeddedFiles injector เขียนแต่ห่วงโซ่ /AF และไม่เพิ่ม entry ใน name tree ไฟล์ที่แนบผ่าน InjectAssociateFiles จึงอยู่นอก index นั้น จะยืนยันห่วงโซ่ที่ฉีดเข้าไปต้องไล่ดูไบต์ที่บันทึกหรือรันตัว validate PDF/A ภายในของ name tree ตัวนี้เล่าไว้ในการทำงานกับ attachment ของ PDF ใน Delphi ด้วย PDFium Component
procedure ReportRelationships(const FileName: string);
var
Pdf: TPdf;
I: Integer;
Rel: string;
begin
if not AttachmentRelationshipFeaturesAvailable then
begin
Writeln('This PDFium build cannot report /AFRelationship');
Exit; // คำตอบว่างจะกำกวม อย่าไปถามดีกว่า
end;
Pdf := TPdf.Create(nil);
try
Pdf.FileName := FileName;
Pdf.Active := True;
for I := 0 to Pdf.AttachmentCount - 1 do
begin
Rel := Pdf.AttachmentRelationship[I];
if Rel = '' then
Rel := '(no /AFRelationship)';
Writeln(Pdf.AttachmentName[I], ': ', Rel);
end;
finally
Pdf.Free;
end;
end;
SaveAsWithAssociateFiles ไม่การันตีอะไร
TPdf.SaveAsWithAssociateFiles การันตีซองรูปแบบไฟล์และว่าไฟล์ที่ขอมาถูกฉีดเข้าไปจริง ไม่การันตีความสอดคล้อง ส่วนการฉีดนี้เป็นของใหม่: ก่อน v3.122.0 เมื่อไบต์ที่บันทึกไม่มี trailer ที่อ่านได้หรือหา dictionary ของ catalog ไม่เจอ InjectAssociateFiles จะ copy input ผ่านไปตามเดิมและเมธอดก็ยังคืน True ตั้งแต่ v3.122.0 InjectAssociateFiles โยน EPdfAssocFilesError ในกรณีเหล่านั้นก่อนเขียนอะไรลงไป SaveAsWithAssociateFiles คืน False และเพราะมันตอนนี้สร้างผลลัพธ์ทั้งก้อนใน save store ก่อนเปิดไฟล์เป้าหมาย การบันทึกที่ถูกปฏิเสธหรือล้มเหลวจึงไม่ตัดไฟล์เดิมให้สั้นลงอีก array Files ว่างยัง copy เอกสารผ่านไปตามเดิมด้วยการออกแบบ เนื้อหาของ payload ก็เป็นความรับผิดชอบของคุณ: injector ไม่เช็กว่าไฟล์ XML มีรูปร่างถูกต้อง, MIME type เข้ากับไบต์ หรือเอกสารฐานเป็น PDF/A จริงหรือเปล่าเลย ให้ถือว่าไฟล์สุดท้ายยังไม่ผ่านการยืนยัน จนกว่าตัว validator จะได้เห็นมัน วินัยแบบเดียวกับที่เล่าไว้ในPDFium Component กับความสอดคล้อง PDF/A เพื่อการจัดเก็บถาวร ถ้าคุณ parse dictionary ขาเข้าเองด้วย กฎชื่อ #XX เดียวกันก็ใช้ทางกลับด้าน หัวข้อนี้เล่าไว้ในกับดัก name token ตอน parse dictionary ของ PDF
associated file, ผลลัพธ์ PDF/A, metadata ของ attachment และการตรวจสอบ ship มาใน component เดียวกัน pipeline ข้างบนจึงรันได้โดยไม่ต้องมี PDF library ตัวที่สองในบิลด์ ข้อมูลอ้างอิง API, ดาวน์โหลดรุ่นทดลองและตัวเลือก license อยู่บนหน้าผลิตภัณฑ์ PDFium Component