مقاله فنی

فایل‌های مرتبط PDF/A-3 و AFRelationship در دلفی

برای پیوست کردن یک فایل مبدأ به سند PDF/A-3 از دلفی، PDFium Component یک زنجیرهٔ فایل-مرتبط PDF 2.0 می‌نویسد: یک استریم فایل جاسازی‌شده با /Subtype مربوط به MIME، یک file specification حامل /AFRelationship، و یک آرایهٔ /AF آویخته روی catalog یا یک صفحه. InjectAssociateFiles و TPdf.SaveAsWithAssociateFiles این زنجیره را در یک به‌روزرسانی افزایشی می‌سازند، و از v3.121.2 به بعد نوع MIME به‌صورت یک name تک‌واحدی و درست-escape‌شدهٔ PDF سریال می‌شود. باقی این پست می‌پردازد به اینکه validator چه چیزی را بررسی می‌کند، باگ تک‌کاراکتری که text/plain را شکست، و جاهایی که انتشارهای قدیمی‌تر بی‌سروصدا چیز دیگری جز خواستهٔ شما انجام می‌دادند

یک فایل مرتبط PDF/A-3 واقعاً به چه چیزی نیاز دارد؟

پیوست PDF/A-3 فقط وقتی از اعتبارسنجی می‌گذرد که سه شیء با هم هم‌عقیده باشند: استریم فایل جاسازی‌شده /Type /EmbeddedFile به‌علاوهٔ /Subtype مربوط به MIME را اعلام کند، دیکشنری file specification (ISO 32000-2 §7.11.3) حامل /F، /UF، /EF و /AFRelationship باشد، و چیزی در سند آن file specification را از طریق یک آرایهٔ /AF (ISO 32000-2 §14.13) ارجاع دهد. جاسازی ساده از طریق درخت /Names /EmbeddedFiles — کاری که TPdf.CreateAttachment می‌کند — اصلاً فیلدهای انجمن را ست نمی‌کند. fixture اعتبارسنجی PDF/A-3b خود PDFium Component این وابستگی را ملموس می‌کند: فقط کلید /AFRelationship را تغییر نام بدهید و فایل دقیقاً یک قاعده در بند 6.8 از ISO 19005-3 را می‌اندازد؛ فقط /Subtype مربوط به MIME را حذف کنید و یک قاعدهٔ دیگر از 6.8 می‌افتد؛ همان پیوست را در کاندیدای PDF/A-1b بگذارید و کلاً رد می‌شود، چون PDF/A-1 فایل‌های جاسازی‌شده را منع می‌کند مهم نیست متادیتا چقدر مرتب باشد

زنجیرهٔ سه‌شیءِ فایل مرتبط PDF A-3 در PDFium Component: یک استریم EmbeddedFile با Subtype مربوط به MIME مثل application xml، یک file specification با F و UF و EF و AFRelationship برابر Data، و یک آرایهٔ AF برای آن از catalog یا یک صفحه؛ همان سه شیءای که validator پیش از پاس شدن بند 6.8 از ISO 19005-3 بررسی می‌کند
استریم، file specification و آرایهٔ AF باید هم‌عقیده باشند؛ جاسازی سادهٔ name-tree در TPdf.CreateAttachment هیچ‌کدام از فیلدهای انجمن را ست نمی‌کند و نخواهد کرد

مقدار رابطه همان بخشی است که آدم‌ها معمولاً حدسش می‌زنند. TPdfAFRelationship در FPdfAssocFiles هر عضو enum را به یکی از توکن‌های نامی که injector می‌تواند صادر کند نگاشت می‌کند، و فقط پنج‌تای اول به زیرمجموعه‌ای تعلق دارند که ISO 19005-3 می‌شناسد:

  • afSource → /Source: سند اصلی که PDF از آن تولید شده، مثل یک فایل واژه‌پرداز یا صفحه‌گسترده
  • afData → /Data: دادهٔ ماشین‌خوانی که محتوای دیداری از آن مشتق شده یا نمایندهٔ آن است
  • afAlternative → /Alternative، afSupplement → /Supplement، afUnspecified → /Unspecified
  • afEncryptedPayload، afFormData، afTemplate: افزوده‌های PDF 2.0 که بیرون زیرمجموعهٔ PDF/A-3 می‌مانند، پس از خروجی آرشیوی کنارشان بگذارید

چرا /Subtype /text/plain اعتبارسنجی را شکست؟

باگ مربوط به MIME یک خطای توکن‌سازی بود، نه یک شکاف سازگاری: پیش از v3.121.2 injector رشتهٔ فراخواننده را درست بعد از یک اسلش چسبانده می‌شد و /Subtype /text/plain تولید می‌کرد. در سینتکس PDF اسلش دوم یک شیء name جدید را شروع می‌کند (ISO 32000-1 §7.3.5)، پس دیکشنری استریم ناگهان کلید /Subtype و nameی /text و یک name اضافی سرگردان به نام /plain را در خود داشت که جفت‌های کلید-مقدار را نامتوازن می‌کرد. یک اعتبارسنج مستقل PDF/A فایل را هنگام parse دیکشنری EmbeddedFile رد می‌کرد، پیش از آنکه اصلاً به یک قاعدهٔ PDF/A برسد؛ به همین دلیل خرابی شبیه فساد فایل به نظر می‌رسید نه یک پراپرتی پیوست جاافتاده

fix مقدار MIME را از EscapePdfName می‌گذراند که /text#2Fplain صادر می‌کند: یک name که مقدار decode‌شده‌اش text/plain است. escape عمداً فراتر از اسلش است. هر بایتی در حد یا زیر 32 (فاصله، tab، CR، LF)، هر بایتی در حد یا بالای 127، جداکننده‌های ()<>[]{}/% و خود کاراکتر escape یعنی # همه به #XX تبدیل می‌شوند. escape کردن فقط اسلش حفرهٔ دیگری باقی می‌گذاشت: رشتهٔ MIMEای حاوی >> یا فاصله می‌توانست دیکشنری را زودتر ببندد یا کلیدهای اضافی تزریق کند، پس آزمون بازگشتی یک مقدار خصمانه با همهٔ جداکننده‌ها به‌علاوهٔ tab و LF و CR می‌دهد و خروجی encode‌شدهٔ دقیق را بررسی می‌کند

چرا زیرنوع MIME به‌شکل text slash plain در PDFium Component parse مربوط به PDF A-3 را شکست: چسباندن مقدار بعد از یک اسلش دو شیء name تولید می‌کرد، /text به‌عنوان مقدار به‌علاوهٔ /plain سرگردانی که دیکشنری EmbeddedFile را نامتوازن می‌کرد، و fix مربوط به v3.121.2 مقدار را از EscapePdfName می‌گذراند تا /text#2Fplain یک name باشد که به text/plain decode می‌شود
خرابی شبیه فساد فایل به نظر می‌رسید چون در پارسر اتفاق می‌افتاد، پیش از هر قاعدهٔ PDF/A؛ nameی escaped جفت‌ها را متوازن و validator را سرِ خواندن نگه می‌دارد
// آنچه injector برای MIMEType = 'text/plain' می‌نویسد
//   پیش از v3.121.2:  /Type /EmbeddedFile /Subtype /text/plain     (دو name)
//   v3.121.2:         /Type /EmbeddedFile /Subtype /text#2Fplain   (یک name)
//
// فراخواننده‌ها همیشه مقدار MIME معمولی را پاس می‌دهند. اگر خودتان
// از قبل escape‌اش کنید '#' دوباره رمزگذاری می‌شود و 'text#2Fplain' می‌شود 'text#232Fplain'
Options.Files[0].MIMEType := 'text/plain';

ساخت یک فایل PDF/A-3 با InjectAssociateFiles

برای خروجی PDF/A-3، سند پایهٔ سازگار را با TPdf.SaveAsPdfAToStream تولید کنید و بعد InjectAssociateFiles را روی همان استریم صدا بزنید؛ pipeline دومرحله‌ای دقیقاً همان است که fixture اعتبارسنجی پیش از پاس شدن PDF/A-3b اجرا می‌کند. TPdf.SaveAsWithAssociateFiles wrapper راحتی است، اما از مسیر معمول SaveAs با saRemoveSecurity ذخیره می‌کند نه از طریق writer مربوط به PDF/A، پس شناسهٔ XMP و output intent ای که PDF/A می‌خواهد را اضافه نمی‌کند. توجه کنید نوع‌های رکورد در FPdfAssocFiles و FPdfPdfa زندگی می‌کنند، پس هر دو unit در بند uses شما باید باشند. از v3.121.3 به بعد FileName و Description لازم نیست حتماً ASCII ساده باشند: /UF و /Desc به‌صورت رشتهٔ متنی PDF نوشته می‌شوند، ASCII قابل چاپ به‌طور تحت‌اللفظی و هر چیز دیگر به‌صورت UTF-16BE با علامت ترتیب بایت، در حالی که name قدیمی /F همیشه ASCII قابل چاپ قابل‌حمل است با هر کاراکتر دیگری که با _ جایگزین شده، پس readerهایی که /F را با code page خودشان decode می‌کنند به‌جای mojibake یک زیرخط نشان می‌دهند. بیلدهای قدیمی‌تر هر سه را در دلفی از طریق code page ANSI سیستم تبدیل می‌کردند یا در Free Pascal بایت‌های خام UTF-8 می‌نوشتند، پس فقط وقتی بیلدهای قدیمی‌تر باید خروجی یکسان بدهند نام‌ها را فقط-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 یا صفحه: آرایهٔ /AF کجا فرود می‌آید؟

TAssocFilesOptions.TargetPage مالک آرایهٔ /AF را تصمیم می‌گیرد: 0 آن را به‌عنوان انجمن در سطح سند به catalog می‌چسباند و 1..N آن را به دیکشنری همان صفحه می‌چسباند، یک‌مبنا. injector همه‌چیز را به‌صورت یک به‌روزرسانی افزایشی واحد در یک چیدمان ثابت ضمیمه می‌کند (استریم‌های جاسازی‌شده، بعد file specificationها، بعد آرایهٔ /AF، بعد شیء بازنویسی‌شدهٔ catalog یا صفحه)، پس شیءهای موجود آفست‌هایشان را نگه می‌دارند و هیچ چیزی دوباره فشرده نمی‌شود. هر ورودی /AF قبلی روی دیکشنری هدف جایگزین می‌شود نه ادغام، که ذخیرهٔ تکراری را idempotent می‌کند اما یعنی فراخوانی دوم با فهرست فایل متفاوت برنده است. دو رفتار بودند که قبلاً شایستهٔ گارد در کد خودتان بودند و هر دو عوض شده‌اند. پیش از v3.122.0 یک TargetPage بیرون از بازه شکست نمی‌خورد؛ به catalog برمی‌گشت، پس یک تایپو انجمن در سطح صفحه را بی‌هیچ علامتی به انجمن در سطح سند تبدیل می‌کرد. از v3.122.0 به بعد SaveAsWithAssociateFiles و SaveAsWithAssociateFilesToStream وقتی TargetPage بیرون از 0 تا PageCount باشد EPdfError می‌دهند، و InjectAssociateFiles برای TargetPage منفی یا عددی که به هیچ صفحهٔ موجود اشاره نمی‌کند خطای جدید EPdfAssocFilesError می‌دهد و استریم مقصد را دست‌نخورده می‌گذارد. پیش از v3.121.4 جست‌وجوی صفحه بایت‌های ذخیره‌شده را برای دیکشنری‌های /Type /Page به ترتیب فایل می‌گشت، که می‌توانست فایل را وقتی شیءهای صفحه به ترتیب دیگری از نمایش ذخیره شده بودند — مثلاً بعد از جابه‌جایی یا درج صفحه‌ها — به صفحهٔ دیگری بچسباند؛ از v3.121.4 به بعد TargetPage صفحه در آن موقعیت در ترتیب صفحات سند را نام می‌برد

آرایهٔ AF در PDFium Component کجا فرود می‌آید: TargetPage برابر صفر آن را به catalog می‌چسباند، صفحه‌های 1 تا N آن را به دیکشنری صفحه می‌چسبانند، و مقدار بیرون از بازه که پیش از v3.122.0 بی‌سروصدا به catalog برمی‌گشت حالا exception می‌دهد، در حالی که injector همه‌چیز را به‌صورت یک به‌روزرسانی افزایشی در چیدمان ثابت ضمیمه می‌کند که آفست‌های موجود را نگه می‌دارد و هر ورودی AF قبلی را جایگزین می‌کند
پیش از v3.122.0 یک TargetPage بیرون از بازه بی‌سروصدا انجمنی در سطح سند می‌شد؛ انتشارهای جاری به‌جایش 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 یک پیوست را از طریق export بومی FPDFAttachment_GetAFRelationship برمی‌گرداند، اما یک رشتهٔ خالی دو معنای ممکن دارد، پس اول AttachmentRelationshipFeaturesAvailable را صدا بزنید. binding آسان‌گیر بار می‌شود: وقتی DLL مربوط به PDFium آن export را ندارد، هر رابطه‌ای خالی خوانده می‌شود که از file specificationای که اصلاً /AFRelationship ندارد قابل تمایز نیست. پراپرتی ایندکسش را با AttachmentCount شریک است، که ورودی‌های درخت /Names /EmbeddedFiles را می‌شمارد. injector فقط زنجیرهٔ /AF را می‌نویسد و ورودی name-tree اضافه نمی‌کند، پس فایلی که از طریق InjectAssociateFiles پیوست شده بیرون از آن ایندکس است؛ برای تأیید زنجیرهٔ تزریقی، بایت‌های ذخیره‌شده را بازرسی کنید یا یک اعتبارسنج PDF/A را اجرا کنید. درون‌مایهٔ آن درخت نام در کار با پیوست‌های PDF در دلفی با 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 قابل‌خوانی نداشتند یا دیکشنری catalog پیدا نمی‌شد، InjectAssociateFiles ورودی را بدون تغییر کپی می‌کرد و متد همچنان True برمی‌گرداند. از v3.122.0 به بعد InjectAssociateFiles در آن حالت‌ها پیش از نوشتن هر چیزی EPdfAssocFilesError می‌دهد، SaveAsWithAssociateFiles مقدار False برمی‌گرداند، و چون حالا خروجی کامل را قبل از باز کردن هدف در یک save store می‌سازد، ذخیرهٔ ردشده یا شکسته دیگر فایل موجود را نمی‌بُرد. آرایهٔ Files خالی همچنان به‌صورت عمدی سند را بدون تغییر عبور می‌دهد. محتوای payload هم مسئولیت خودتان است: injector بررسی نمی‌کند که فایل XML خوش‌فرم باشد، نوع MIME با بایت‌ها بخواند، یا اصلاً سند پایه PDF/A باشد. فایل نهایی را تا وقتی validatorی ندیده‌اش غیرتحقیق‌شده بگیرید؛ همان نظمی که در PDFium Component و سازگاری آرشیوی PDF/A توضیح داده شده. اگر خودتان هم دیکشنری‌های ورودی را parse می‌کنید، همان قواعد name با #XX برعکس صدق می‌کنند؛ موضوعی که در دام‌های توکن name هنگام parse دیکشنری‌های PDF پوشش داده شده

فایل‌های مرتبط، خروجی PDF/A، متادیتای پیوست و اعتبارسنجی همگی در یک کامپوننت عرضه می‌شوند، پس pipeline بالا بدون کتابخانهٔ PDF دوم در بیلد اجرا می‌شود. مرجع API، دانلود آزمایشی و گزینه‌های لایسنس در صفحهٔ محصول PDFium Component است