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

Associated file ของ PDF/A-3 กับ AFRelationship ใน Delphi

ในการแนบไฟล์ต้นทางเข้าเอกสาร 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 จะเรียบร้อยแค่ไหน

ห่วงโซ่ object สามตัวของ associated file ใน PDF/A-3 บน PDFium Component: stream EmbeddedFile ที่มี MIME Subtype อย่าง application xml, file specification ที่ตั้ง F, UF, EF และ AFRelationship เป็น Data และ array AF ให้มันจาก catalog หรือหนึ่งหน้า สาม object ที่ตัว validator เช็กก่อนข้อ 6.8 ของ ISO 19005-3 จะผ่าน
stream, file specification และ array AF ต้องเห็นพ้องกัน การฝังแบบ name-tree ธรรมดาของ TPdf.CreateAttachment ไม่ตั้งฟิลด์ความสัมพันธ์สักอย่าง และก็จะไม่ตั้งต่อไป

ค่าความสัมพันธ์คือส่วนที่คนมักเดา TPdfAFRelationship ใน FPdfAssocFiles แมปสมาชิก enum หนึ่งตัวต่อ name token หนึ่งตัวที่ injector emit ได้ และมีแค่ห้าตัวแรกที่อยู่ใน subset ที่ ISO 19005-3 รู้จัก:

  • afSource → /Source: ต้นฉบับที่ PDF ถูกผลิตขึ้นจากมัน เช่นไฟล์ประมวลผลคำหรือสเปรดชีต
  • afData → /Data: ข้อมูลที่เครื่องอ่านได้ซึ่งเนื้อหาที่มองเห็นมาจากมันหรือแทนค่ามัน
  • afAlternative → /Alternative, afSupplement → /Supplement, afUnspecified → /Unspecified
  • afEncryptedPayload, 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 แล้วเช็กผลเข้ารหัสแบบเป๊ะ ๆ

ทำไม MIME subtype text slash plain ถึงทำให้การ parse PDF A-3 พังใน PDFium Component: การต่อค่าต่อท้ายเครื่องหมายทับผลิต name object สองตัว คือ /text เป็นค่าบวก /plain ที่ห้อยคอทำให้ dictionary EmbeddedFile เสียสมดุล และการแก้ใน v3.121.2 ส่งค่าผ่าน EscapePdfName /text#2Fplain จึงเป็นชื่อเดียวที่ decode ได้ text/plain
ความล้มเหลวหน้าตาเหมือนไฟล์เสียหายเพราะมันเกิดที่ parser ก่อนกฎ PDF/A สักข้อ ชื่อที่ escape แล้วทำให้คู่ key-value สมดุลและตัว validator อ่านต่อได้
// สิ่งที่ 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 ระบุหน้าที่ตำแหน่งนั้นในลำดับหน้าของเอกสาร

array AF ไปลงที่ไหนใน PDFium Component: TargetPage ศูนย์ผูกกับ catalog, หน้า 1 ถึง N ผูกกับ dictionary ของหน้า และค่าที่เกินช่วงซึ่งก่อน v3.122.0 ตกกลับไปที่ catalog เงียบ ๆ ตอนนี้โยน exception ขณะที่ injector ต่อทุกอย่างเป็น incremental update เดียวในผังตายตัวที่คง offset เดิมและแทนที่ entry AF เดิมทุกตัว
ก่อน v3.122.0 TargetPage ที่เกินช่วงกลายเป็นความสัมพันธ์ระดับเอกสารไปเงียบ ๆ release ปัจจุบันโยน exception แทน และการเรียกครั้งที่สองด้วยรายการไฟล์ต่างกันยังคงเป็นฝ่ายชนะ
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