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

ความเท่ากันของ metadata Info กับ XMP ใน PDF/A บน Delphi

PDFium Component ตรวจความเท่ากันของ metadata จาก Info ไป XMP ของ PDF/A ด้วย TPdf.InspectPdfAMetadata และซ่อมด้วย TPdf.NormalizePdfAMetadata ISO 19005-1 (ตามที่แก้ไขโดย Cor.1) บังคับว่า Info entry ที่ map กันไว้ทั้งแปดตัว ตั้งแต่ Title ถึง ModDate ต้องพกค่าเดียวกับ property XMP ของตัวเอง ไม่ใช่มีอยู่แค่นั้น การตรวจอ่านรูปร่าง RDF ที่ถูกต้อง, จับคู่ namespace ด้วย URI และเทียบวันที่ในฐานะช่วงเวลา

รายงาน bug ที่มักเปิดบทสนทนานี้ดูไร้พิษภัย ระบบจัดการเอกสารประทับ /ModDate ใหม่ลง Info dictionary ทุกครั้งที่ save แบบขยาย ปล่อย XMP packet ให้เฉย ๆ และหกเดือนต่อมาการ audit คลังเอกสารก็ flag ไฟล์เป็นพันฉบับว่าไม่เข้าเกณฑ์ วันที่ทั้งสองตัวก็อยู่ครบ แค่หยุดเห็นพ้องกันตั้งแต่การแก้ครั้งแรก และการเช็กแค่ว่ามีอยู่ก็ไม่เคยสังเกต การแก้ Title ผ่าน API ที่แตะแค่ Info และสตริง Author แบบ Finance; Controlling ที่เครื่องมือบางตัวแตกเป็นสอง item ใน dc:creator ก็พังด้วยวิธีเดียวกัน

ทำไม PDF/A ถึงปฏิเสธ metadata ที่มีอยู่ทั้งสองที่

PDF/A ปฏิเสธเพราะ ISO 19005-1 §6.7.3 เป็นกฎเรื่องค่า ไม่ใช่กฎเรื่องการมีอยู่: ตาราง 1 map Info key แปดตัวไปยัง property XMP และเมื่อ Info key หนึ่งมีอยู่ property XMP ที่ map กันต้องพกค่าที่เทียบเท่ากัน ตัวสแกนระดับ byte ที่อธิบายไว้ในการ preflight validation ของ PDF/A ด้วย PDFium Component ยืนยันแค่ว่า xmp:CreateDate กับ xmp:ModifyDate มีอยู่ (pvaiMissingXmpDates) ตั้งแต่ v3.72.0 TPdf.ValidatePdfA รันการเทียบค่าเต็มรูปแบบเพิ่มด้วย และเติม pvaiInfoXmpValueMismatch ลงชุดปัญหาเมื่อ XMP packet มีอยู่แต่ไม่เห็นพ้องกับ Info (packet ที่ parse ไม่ได้นับเป็นการไม่เห็นพ้อง) packet ที่หายไปยังถูกรายงานเป็น pvaiMissingXmpMetadata อยู่ ปัญหาสองตัวจึงไม่มีวันนับซ้ำ defect เดียวกัน

property XMP ที่ map แต่ละตัวต้องมีรูปร่าง RDF แบบไหน

การ map ทั้งแปดตัวมี type XMP ตายตัว และค่าที่ถูกต้องใน container ที่ผิดก็ยัง fail ComparePdfAInfoAndXmp ใน FPdfPdfa.pas ค้น property ด้วย namespace URI packet ที่ผูก http://purl.org/dc/elements/1.1/ กับ prefix แปลก ๆ จึงถูกอ่านเหมือนกับที่ใช้ dc เป๊ะ ๆ รูปร่างที่ต้องการคือ:

  • Title → dc:title และ Subject → dc:description: เป็นทางเลือกภาษาแบบ rdf:Alt เทียบกับ item x-default ของมันเท่านั้น (language tag จับคู่แบบไม่สนตัวพิมพ์) Alt ที่ไม่มี x-default นับเป็นขาดหาย
  • Author → dc:creator: เป็น rdf:Seq ที่มี text item พอดีหนึ่งตัวถือสตริง Info ทั้งก้อน รายชื่อผู้เขียนที่คั่นด้วยเซมิโคลอนจึงยังเป็น entry เดียว
  • Keywords → pdf:Keywords และ Producer → pdf:Producer (namespace http://ns.adobe.com/pdf/1.3/): เป็น property ข้อความธรรมดา
  • Creator → xmp:CreatorTool, CreationDate → xmp:CreateDate, ModDate → xmp:ModifyDate (namespace http://ns.adobe.com/xap/1.0/): เป็น property ข้อความธรรมดา
คู่ที่ map กันทั้งแปดที่ ComparePdfAInfoAndXmp ตรวจเพื่อความเท่ากันของ metadata PDF/A ใน Delphi: Title กับ Subject ต้องการ rdf:Alt ที่มี item x-default, Author ต้องการ rdf:Seq หนึ่ง item, ส่วน Keywords, Producer, Creator และสองวันที่เป็นข้อความธรรมดา ทุกตัวค้นด้วย namespace URI ของ XMP ไม่ใช่ prefix
เมื่อ Info key หนึ่งมีอยู่ ISO 19005-1 บังคับว่า property XMP ที่ map กันต้องพกค่าเทียบเท่าในรูปร่าง RDF ที่กำหนด ค่าที่อยู่ใน container ผิดจึงยัง fail อยู่ดี
<dc:title><rdf:Alt><rdf:li xml:lang="x-default">Quarterly Report 2026</rdf:li></rdf:Alt></dc:title>
<dc:creator><rdf:Seq><rdf:li>Finance; Controlling</rdf:li></rdf:Seq></dc:creator>
<pdf:Producer>PDFium Component</pdf:Producer>

ค่าข้อความถูกเทียบเป็นลำดับ Unicode code point แบบเป๊ะ ๆ ไม่มีการ trim, ไม่ fold ตัวพิมพ์ ไม่ normalize เว้นวรรคท้าย หรือ é แบบ precompose ฝั่งหนึ่งเจอกับ e แยกเครื่องหมายผสมฝั่งโน้น ถือเป็น mismatch ของจริง ฝั่ง Info มาจากการ decode ของ PDFium เองเสมอ ทั้ง PDFDocEncoding และข้อความ UTF-16 ผ่าน FPDF_GetMetaText ไลบรารีจึงไม่ต้องเขียนการ decode สตริงซ้ำแล้วพลาดแบบละเอียดอ่อน ส่วนฝั่ง XMP สะอาดแค่ไหนขึ้นกับ byte ที่ผลิตมัน นั่นเหตุผลที่กับดัก codepage ที่ทำลาย metadata XMP ใต้ Free Pascal สำคัญในที่นี้ด้วย

เมื่อไรที่วันที่ PDF กับวันที่ XMP ถือว่าเท่ากัน

วันที่ PDF กับวันที่ XMP เท่ากันเมื่อพวกมันบรรยายช่วงเวลาเดียวกันถึงระดับวินาที พร้อมความรู้เขตเวลาเท่ากันทั้งสองฝั่ง parser ทั้งคู่รับความละเอียดที่ลดลงอย่างถูกกฎ D:2026 กับ 2026 จึงหมายถึง 1 มกราคม 2026, 00:00:00 ทั้งคู่ เมื่อทั้งสองค่าพกเขตเวลา พวกมันถูกแปลงเป็น UTC ก่อนเทียบ: D:20260827093659+08'00' เท่ากับ 2026-08-27T01:36:59Z เมื่อไม่มีใครพกเขตเวลา ส่วนประกอบ local ถูกเทียบตามที่เขียนไว้ เมื่อมีแค่ฝั่งเดียวพกเขตเวลา ผลลัพธ์คือ pamsValueMismatch เพราะการสมมุติ offset ขึ้นมาคือการเดา วินาทีเศษส่วนไม่เป็นศูนย์อย่าง .250 ใน XMP ก็บังคับ mismatch เช่นกัน เพราะวันที่แบบ PDF ไม่มีทางแสดงมันได้ และการปัดทิ้งเงียบ ๆ จะซ่อนความไม่ตรงกันที่แท้จริง ส่วน .000 ถูกรับได้ ค่าที่ parse ไม่ได้ถูกรายงานแยกเป็น pamsInvalidInfoDate หรือ pamsInvalidXmpDate

PDFium Component ตัดสินว่าวันที่ Info ของ PDF กับวันที่ XMP เท่ากันใน Delphi อย่างไร: มีเขตเวลาทั้งคู่แปลงเป็น UTC แล้วเทียบช่วงเวลา, ไม่มีเขตเวลาทั้งคู่เทียบตามที่เขียน, มีเขตเวลาฝั่งเดียวเป็น pamsValueMismatch, วินาทีเศษส่วนไม่เป็นศูนย์แสดงไม่ได้ และค่าที่ parse ไม่ได้ถูกรายงานแยก
ความเท่ากันหมายถึงช่วงเวลาเดียวกันถึงระดับวินาทีพร้อมความรู้เขตเวลาเท่ากันทั้งสองฝั่ง การสมมุติ offset หรือปัดวินาทีเศษส่วนทิ้งจึงซ่อนความไม่ตรงกันที่แท้จริง

การมีอยู่มีกฎของตัวเอง TPdfAMetadataValues.Present เป็น set ที่ถูกเติมโดยเดินไล่ /Info dictionary ของ trailer ที่ใช้งานอยู่ และมันแยก "key ไม่มี" ออกจาก "key มีอยู่แต่เป็นสตริงว่าง" key ที่ไม่มีให้ pamsNotRequired และไม่เรียกร้องอะไรจาก XMP ส่วน /Title () นับว่ามีอยู่ XMP packet จึงต้องพก title x-default ว่าง ๆ มาด้วยเช่นกัน

จะตรวจ metadata Info กับ XMP ก่อน save อย่างไร

TPdf.InspectPdfAMetadata คืน TPdfAMetadataReport ที่มี TPdfAMetadataComparison หนึ่งตัวต่อ field แต่ละตัวพกค่า Info, ค่า XMP และ TPdfAMetadataState ความล้มเหลวจึงอธิบายได้โดยไม่ต้อง reverse engineer flag validation ตัวเดียว MismatchFields สรุปชุดที่ fail, HasXmpPacket บอกว่าเจอ packet หรือไม่ และ XmpParseError พกข้อความจาก parser เมื่อ packet มีอยู่แต่อ่านไม่ได้

uses
  System.SysUtils, PDFium, FPdfPdfa;

const
  FieldNames: array[TPdfAMetadataField] of string = (
    'Title', 'Author', 'Subject', 'Keywords',
    'Creator', 'Producer', 'CreationDate', 'ModDate');
  StateNames: array[TPdfAMetadataState] of string = (
    'not required', 'equivalent', 'XMP missing', 'XMP type mismatch',
    'value mismatch', 'invalid Info date', 'invalid XMP date');

procedure ReportMetadata(Pdf: TPdf);
var
  Report: TPdfAMetadataReport;
  Item: TPdfAMetadataComparison;
begin
  Report := Pdf.InspectPdfAMetadata;
  if Report.XmpParseError <> '' then
    Writeln('XMP packet unreadable: ', Report.XmpParseError)
  else if not Report.HasXmpPacket then
    Writeln('No XMP packet at all');
  for Item in Report.Comparisons do
    if not Item.IsEquivalent then
      Writeln(Format('%-12s %-18s Info="%s" XMP="%s"',
        [FieldNames[Item.Field], StateNames[Item.State],
         Item.InfoValue, Item.XmpValue]));
end;

NormalizePdfAMetadata แก้อะไร และปฏิเสธอะไร

TPdf.NormalizePdfAMetadata ถือ Info dictionary เป็นแหล่งความจริง และเขียนทับเฉพาะ property XMP ที่ field ของมันตกไปอยู่ใน MismatchFields สิ่งอื่นใน packet รอดทั้งหมด Title กับ Subject ถูกเขียนลง item x-default โดยทางเลือกภาษาอื่นยังคงเดิม Author กลายเป็น rdf:Seq หนึ่ง item namespace ที่ไม่รู้จักกับ property ที่ไม่เกี่ยวข้องถูกเก็บไว้ และ property XMP ของ Info key ที่ไม่มีก็ไม่ถูกแตะ วันที่ Info ที่มีเขตเวลาถูกเขียนเป็นวันที่ XMP แบบ UTC มาตรฐานพร้อม suffix Z ส่วนที่ไม่มีเขตเวลาคงส่วนประกอบ local ของมัน overload แบบไฟล์ save ผ่านไฟล์ชั่วคราวกับการ replace แบบ atomic และการอัปเดต XMP เองถูก append เป็น incremental update

NormalizePdfAMetadata เขียนทับอะไรเมื่อซ่อม metadata PDF/A ใน Delphi ด้วย PDFium Component: Info เป็นแหล่งความจริง มีเฉพาะ entry ใน MismatchFields ที่ถูกเขียนกลับเป็นข้อความ Alt แบบ x-default, Seq หนึ่ง item หรือวันที่ UTC มาตรฐาน ขณะที่ namespace ที่ไม่รู้จัก, property ที่ไม่เกี่ยวข้อง และ property ของ key ที่ไม่มีรอดไปโดยไม่ถูกแตะ
การซ่อมปฏิเสธ XMP packet ที่หายไป, วันที่ Info ที่เพี้ยน และเอกสารที่ลงลายเซ็น เพราะการสร้างชุด metadata PDF/A ครบชุดเป็นหน้าที่ของ SaveAsPdfA ไม่ใช่ของการแก้ความเท่ากันแบบจุดเดียว

การปฏิเสธพวกนี้ตั้งใจให้เป็นแบบนั้น เมื่อไม่มี XMP packet เมธอด raise EPdfError เพราะการสร้างชุดระบุตัวตนกับ metadata ของ PDF/A ครบชุดเป็นหน้าที่ของ SaveAsPdfA ตามที่อธิบายในการสร้างไฟล์คลัง PDF/A ด้วย PDFium Component วันที่ Info ที่เพี้ยน raise EPdfXmpError แทนที่จะเขียนค่าผิดที่ดูน่าเชื่อลงไป และไม่มีอะไรถูก save เอกสารที่ลงลายเซ็นถูกปฏิเสธ เว้นแต่ผู้เรียกจะส่ง AllowSignedDocument = True ความเท่ากันเองก็เป็นกฎหนึ่งของ ISO 19005-1 ไฟล์ที่ normalize แล้วจึงไม่ได้แปลว่าเข้าเกณฑ์โดยอัตโนมัติ

uses
  System.SysUtils, PDFium, FPdfPdfa, FPdfXmp;

procedure NormalizeArchive(const Source, Target: string);
var
  Pdf: TPdf;
  Report: TPdfAMetadataReport;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := Source;
    Pdf.Active := True;
    Report := Pdf.InspectPdfAMetadata;
    if Report.IsEquivalent then
      Exit;                      // สอดคล้องกันอยู่แล้ว ปล่อยไฟล์ไว้เฉย ๆ
    if not Report.HasXmpPacket then
      raise Exception.Create('No XMP packet: convert with SaveAsPdfA instead');
    try
      if not Pdf.NormalizePdfAMetadata(Target) then
        raise Exception.Create('Normalized save failed');
    except
      on E: EPdfXmpError do      // วันที่ Info เพี้ยน หรือ packet อ่านไม่ได้
        raise Exception.CreateFmt('Cannot normalize %s: %s', [Source, E.Message]);
    end;
  finally
    Pdf.Free;
  end;
end;

รันการเทียบบน XMP packet ของคุณเอง

ComparePdfAInfoAndXmp กับ SynchronizePdfAInfoToXmp เป็นฟังก์ชันธรรมดาใน FPdfPdfa ทำงานบน TPdfXmpPacket โดยไม่ต้องโหลดเอกสาร เหมาะกับเทสต์แบบ unit และ pipeline ที่ประกอบ XMP จาก template กับดักตัวเดียวคือ Present: record ที่ initialize ด้วย Default(TPdfAMetadataValues) มี set ว่าง ทุก field จะรายงาน pamsNotRequired และการเทียบจะผ่านแบบว่างเปล่า ไม่ว่าคุณจะกรอกค่าอะไรลงไป

uses
  System.SysUtils, System.IOUtils, FPdfPdfa, FPdfXmp;

procedure AlignTemplate(const TemplateFile: string);
var
  Info: TPdfAMetadataValues;
  Packet: TPdfXmpPacket;
  Changed: TPdfAMetadataFields;
begin
  Info := Default(TPdfAMetadataValues);
  Info.Title := 'Quarterly Report 2026';
  Info.Author := 'Finance; Controlling';
  Info.ModDate := 'D:20260827093659+08''00''';
  // Present ตัดสินว่า field ใดบังคับ ค่าอย่างเดียวถูกเมิน
  Info.Present := [pamfTitle, pamfAuthor, pamfModDate];

  Packet := TPdfXmpPacket.Parse(TFile.ReadAllText(TemplateFile, TEncoding.UTF8));
  try
    Changed := SynchronizePdfAInfoToXmp(Info, Packet);
    // ตอนนี้ xmp:ModifyDate เป็น 2026-08-27T01:36:59Z, dc:creator เป็น rdf:Seq หนึ่ง item
    if Changed <> [] then
      TFile.WriteAllBytes(TemplateFile, Packet.ToUtf8);
  finally
    Packet.Free;
  end;
end;

ถ้า pipeline ของคุณเก็บถาวรเอกสารที่ระบบอื่นเอาไปแก้ต่อเรื่อย ๆ จงจับกวาด InspectPdfAMetadata รายคืนคู่กับ NormalizePdfAMetadata สำหรับไฟล์ที่หลุดจากกัน และตั้ง ValidatePdfA เป็น gate ก่อนอะไรจะถูกส่งไปคลังระยะยาว รายงานแบบมี type, เส้นทางซ่อม และเครื่องมือ PDF/A ที่เหลือมาพร้อมกันใน PDFium Component for Delphi และ C++Builder