مقاله فنی

هم‌ارزی متادیتای Info و XMP مربوط به PDF/A در Delphi

PDFium Component هم‌ارزی متادیتای Info تا XMP مربوط به PDF/A را با TPdf.InspectPdfAMetadata می‌سنجد و با TPdf.NormalizePdfAMetadata ترمیم می‌کند. ISO 19005-1 (با اصلاحیهٔ Cor.1) از هر یک از هشت مدخل نگاشت‌شدهٔ Info، از Title تا ModDate، می‌خواهد همان مقدار پراپرتی XMP متناظرش را حمل کند، نه صرفاً وجود داشتن؛ چک شکل درست RDF را می‌خواند، namespaceها را با URI مچ می‌کند و تاریخ‌ها را به‌عنوان لحظه مقایسه می‌کند

گزارش باگی که معمولاً این بحث را شروع می‌کند بی‌آزار به نظر می‌رسد. یک سیستم مدیریت اسناد روی هر ذخیرهٔ افزایشی یک /ModDate جدید داخل دیکشنری Info می‌کوبد، بستهٔ XMP را به حال خودش می‌گذارد، و شش ماه بعد یک ممیزی آرشیو هزاران فایل را ناهمخوان علامت می‌زند. هر دو تاریخ سر جایشان‌اند. فقط از اولین ویرایش دیگر با هم موافق نیستند، و یک چک حضور هرگز متوجهش نشد. ویرایش‌های Title که از یک API فقط-Info انجام شده، و رشتهٔ Author مثل Finance; Controlling که بعضی ابزارها به دو مدخل dc:creator می‌شکنند، به همان شکل رد می‌شوند

چرا PDF/A متادیتایی را که در هر دو جا هست رد می‌کند؟

PDF/A ردش می‌کند چون ISO 19005-1 §6.7.3 یک قاعدهٔ مقداری است نه قاعدهٔ حضوری: Table 1 هشت کلید Info را به پراپرتی‌های XMP نگاشت می‌کند، و به‌محض حضور یک کلید Info، پراپرتی XMP متناظر باید مقدار معادلی را حمل کند. اسکنر بایتی که در preflight اعتبارسنجی PDF/A با PDFium Component توصیف شده فقط تأیید می‌کند که xmp:CreateDate و xmp:ModifyDate وجود دارند (pvaiMissingXmpDates). از نسخهٔ v3.72.0، TPdf.ValidatePdfA علاوه بر آن مقایسهٔ کامل مقدار را اجرا می‌کند و وقتی بستهٔ XMP وجود دارد ولی با Info نمی‌خواند pvaiInfoXmpValueMismatch را به مجموعهٔ موارد اضافه می‌کند (بسته‌ای که پارس نشود ناهمخوان حساب می‌شود). بستهٔ غایب همچنان به‌عنوان pvaiMissingXmpMetadata گزارش می‌شود، تا این دو مورد هرگز همان نقص را دوبار نشمارند

هر پراپرتی XMP نگاشت‌شده به چه شکلی از RDF نیاز دارد؟

هر یک از هشت نگاشت یک نوع XMP ثابت دارد، و یک مقدار درست در ظرف غلط باز هم رد می‌شود. ComparePdfAInfoAndXmp در FPdfPdfa.pas پراپرتی‌ها را با URI namespace پیدا می‌کند، پس بسته‌ای که http://purl.org/dc/elements/1.1/ را به یک پیشوند عجیب بسته باشد دقیقاً مثل بسته‌ای با dc خوانده می‌شود. شکل‌های مورد نیاز این‌هاست:

  • Title → dc:title و Subject → dc:description: یک جایگزین زبانی از نوع rdf:Alt که فقط با مدخل x-default آن مقایسه می‌شود (تگ زبان case-insensitive مچ می‌شود)؛ Alt بدون x-default غایب حساب می‌شود
  • Author → dc:creator: یک rdf:Seq با دقیقاً یک مدخل متنی که کل رشتهٔ Info را حمل می‌کند، پس یک فهرست نویسنده که با سمی‌کالن جدا شده یک مدخل تکی می‌ماند
  • Keywords → pdf:Keywords و Producer → pdf:Producer (namespace با http://ns.adobe.com/pdf/1.3/): پراپرتی‌های متنی ساده
  • Creator → xmp:CreatorTool و CreationDate → xmp:CreateDate و ModDate → xmp:ModifyDate (namespace با http://ns.adobe.com/xap/1.0/): پراپرتی‌های متنی ساده
هشت جفت نگاشت‌شده‌ای که ComparePdfAInfoAndXmp برای هم‌ارزی متادیتای PDF/A در Delphi چک می‌کند: Title و Subject یک rdf:Alt با مدخل x-default می‌خواهند، Author یک rdf:Seq تک‌مدخله، Keywords و Producer و Creator و دو تاریخ متن ساده‌اند، و هر یک با URI مربوط به namespace در XMP پیدا می‌شود نه با پیشوند
به‌محض حضور یک کلید Info، ISO 19005-1 از پراپرتی XMP متناظر می‌خواهد مقدار معادلی در شکل RDF مورد نیاز حمل کند، پس یک مقدار در ظرف غلط باز هم رد می‌شود
<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>

مقادیر متنی به‌عنوان دنباله‌های دقیق code point یونیکد مقایسه می‌شوند، بدون trim و بدون case folding و بدون نرمال‌سازی. یک فاصلهٔ انتهایی، یا یک é پیش‌ساخته در یک طرف در برابر یک e تجزیه‌شده به‌علاوهٔ اکسنت جدا در طرف دیگر، یک ناهمخوانی واقعی است. طرف Info همیشه از دیکدینگ خود PDFium برای متن‌های PDFDocEncoding و UTF-16 از طریق FPDF_GetMetaText می‌آید، که نمی‌گذارد کتابخانه دیکدینگ رشته را دوباره پیاده کند و ظریف اشتباه بزند؛ طرف XMP فقط به تمیزی بایت‌هایی است که ساخته‌اش کرده‌اند، و دلیلش همین است که تله‌های codepage که متادیتای XMP را زیر Free Pascal خراب می‌کنند اینجا هم مهم‌اند

کی یک تاریخ PDF و یک تاریخ XMP برابرند؟

یک تاریخ PDF و یک تاریخ XMP وقتی برابرند که همان لحظه تا دقت ثانیه را توصیف کنند، با همان میزان آگاهی از منطقهٔ زمانی در هر دو طرف. هر دو پارسر دقت کاهش‌یافتهٔ مجاز را قبول می‌کنند، پس D:2026 و 2026 هر دو یعنی 1 ژانویه 2026، 00:00:00. وقتی هر دو مقدار منطقه دارند، قبل از مقایسه به UTC تبدیل می‌شوند: D:20260827093659+08'00' برابر 2026-08-27T01:36:59Z است. وقتی هیچ‌کدام منطقه ندارند، مؤلفه‌های محلی همان‌طور که نوشته شده‌اند مقایسه می‌شوند. وقتی فقط یک طرف منطقه دارد، نتیجه pamsValueMismatch است، چون ساختن یک آفست از صفر حدس زدن است. کسر ثانیهٔ ناصفری مثل .250 در XMP هم ناهمخوانی تحمیل می‌کند، چون تاریخ PDF راهی برای بیانش ندارد و گرد کردن بی‌سروصدایش یک مخالفت واقعی را می‌پوشاند؛ .000 قبول می‌شود. مقادیر غیرقابل پارس جداگانه به‌عنوان pamsInvalidInfoDate یا pamsInvalidXmpDate گزارش می‌شوند

PDFium Component چطور در Delphi تصمیم می‌گیرد یک تاریخ Info مربوط به PDF و یک تاریخ XMP برابرند: دو منطقه به UTC تبدیل و لحظه‌ها مقایسه می‌شوند، دو مقدار بی‌منطقه همان‌طور که نوشته شده‌اند مقایسه می‌شوند، منطقهٔ تنها یعنی pamsValueMismatch، کسر ثانیهٔ ناصفرب قابل بیان نیست، و مقادیر غیرقابل پارس جداگانه گزارش می‌شوند
برابری یعنی همان لحظه تا دقت ثانیه با همان میزان آگاهی از منطقهٔ زمانی در هر دو طرف، پس ساختن یک آفست از صفر یا گرد کردن کسر ثانیه یک مخالفت واقعی را می‌پوشاند

حضور قاعدهٔ خودش را دارد. TPdfAMetadataValues.Present مجموعه‌ای است که با گشتن در دیکشنری /Info تریلر فعال پر می‌شود، و «کلید غایب» را از «کلید حاضر با رشتهٔ خالی» جدا نگه می‌دارد. کلید غایب pamsNotRequired می‌دهد و از XMP هیچ نمی‌خواهد؛ /Title () حاضر است، پس بستهٔ XMP باید یک عنوان x-default خالی هم حمل کند

قبل از ذخیره چطور Info و متادیتای XMP را بازرسی کنید؟

TPdf.InspectPdfAMetadata یک TPdfAMetadataReport برمی‌گرداند با یک TPdfAMetadataComparison به‌ازای هر فیلد، که هر کدام مقدار Info و مقدار XMP و یک TPdfAMetadataState را حمل می‌کند، پس یک شکست را می‌شود بدون مهندسی معکوس فلگ اعتبارسنجی تکی توضیح داد. MismatchFields مجموعهٔ نافرمان را خلاصه می‌کند، HasXmpPacket می‌گوید بسته‌ای پیدا شده یا نه، و XmpParseError پیام پارسر را حمل می‌کند وقتی بسته هست ولی خوانده نمی‌شود

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 را حقیقت مبدأ می‌گیرد و فقط پراپرتی‌های XMPی را بازنویسی می‌کند که فیلدشان در MismatchFields افتاده؛ بقیهٔ بسته سالم می‌ماند. Title و Subject داخل مدخل x-default نوشته می‌شوند در حالی که جایگزین‌های زبانی دیگر دست‌نخورده می‌مانند، Author به یک rdf:Seq تک‌مدخله تبدیل می‌شود، namespaceهای ناشناخته و پراپرتی‌های بی‌ربط حفظ می‌شوند، و پراپرتی‌های XMP مربوط به کلیدهای غایب Info دست نمی‌خورند. یک تاریخ Info منطقه‌دار به‌عنوان یک تاریخ XMP کانونی UTC با پسوند Z نوشته می‌شود؛ تاریخ بی‌منطقه مؤلفه‌های محلیش را نگه می‌دارد. overload فایلی از طریق فایل موقت و جایگزینی اتمیک ذخیره می‌کند، و خود آپدیت XMP به‌عنوان یک incremental update append می‌شود

NormalizePdfAMetadata هنگام ترمیم متادیتای PDF/A در Delphi با PDFium Component چه چیزی را بازنویسی می‌کند: Info حقیقت مبدأ است، فقط مدخل‌های MismatchFields به‌عنوان متن Alt با x-default یا یک Seq تک‌مدخله یا یک تاریخ کانونی UTC برگردانده می‌شوند، در حالی که namespaceهای ناشناخته و پراپرتی‌های بی‌ربط و پراپرتی‌های کلیدهای غایب دست‌نخورده می‌مانند
ترمیم از بستهٔ XMP غایب و تاریخ Info بدشکل و سندهای امضاشده سر باز می‌زند، چون ساختن یک مجموعهٔ کامل متادیتای PDF/A کار SaveAsPdfA است نه کار یک fix هم‌ارزی هدفمند

رد کردن‌ها عامدانه‌اند. بدون بستهٔ XMP متد EPdfError می‌دهد، چون ساختن یک مجموعهٔ کامل شناسایی و متادیتای PDF/A کار SaveAsPdfA است که در ساختن فایل‌های آرشیوی PDF/A با PDFium Component پوشش داده شده. یک تاریخ Info بدشکل به‌جای نوشتن یک مقدار غلط محتمل-به-نظر EPdfXmpError می‌دهد و هیچ چیزی ذخیره نمی‌شود. سندهای امضاشده رد می‌شوند مگر اینکه فراخوان AllowSignedDocument = True بدهد. هم‌ارزی خودش هم یک قاعدهٔ ISO 19005-1 است، پس یک فایل نرمال‌شده خودبه‌خود همخوان نیست

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 بدشکل یا بستهٔ غیرقابل خواندن
        raise Exception.CreateFmt('Cannot normalize %s: %s', [Source, E.Message]);
    end;
  finally
    Pdf.Free;
  end;
end;

اجرای مقایسه روی بستهٔ XMP خودتان

ComparePdfAInfoAndXmp و SynchronizePdfAInfoToXmp توابع ساده‌ای در FPdfPdfa هستند که روی یک TPdfXmpPacket بدون سند لودشده کار می‌کنند، که برای unit testها و پایپ‌لاین‌هایی که XMP را از یک قالب مونتاژ می‌کنند مناسب است. یک تله هست: Present. رکوردی که با Default(TPdfAMetadataValues) مقداردهی شود مجموعهٔ خالی دارد، بعد هر فیلدی 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 تصمیم می‌گیرد کدام فیلدها الزامی‌اند؛ فقط مقادیر نادیده گرفته می‌شوند
  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 تک‌مدخله
    if Changed <> [] then
      TFile.WriteAllBytes(TemplateFile, Packet.ToUtf8);
  finally
    Packet.Free;
  end;
end;

اگر پایپ‌لاین شما سندهایی را آرشیو می‌کند که سیستم‌های دیگر مدام ویرایششان می‌کنند، یک sweep شبانه از نوع InspectPdfAMetadata را با NormalizePdfAMetadata برای فایل‌هایی که drift کرده‌اند جفت کنید، و ValidatePdfA را به‌عنوان گیت نگه دارید قبل از اینکه چیزی به انبار بلندمدت برود. گزارش تایپ‌دار و مسیر ترمیم و بقیهٔ ابزارهای PDF/A همراه PDFium Component برای Delphi و C++Builder ارسال می‌شود