برای پیوست کردن یک فایل مبدأ به سند 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 فایلهای جاسازیشده را منع میکند مهم نیست متادیتا چقدر مرتب باشد
مقدار رابطه همان بخشی است که آدمها معمولاً حدسش میزنند. TPdfAFRelationship در FPdfAssocFiles هر عضو enum را به یکی از توکنهای نامی که injector میتواند صادر کند نگاشت میکند، و فقط پنجتای اول به زیرمجموعهای تعلق دارند که ISO 19005-3 میشناسد:
afSource→/Source: سند اصلی که PDF از آن تولید شده، مثل یک فایل واژهپرداز یا صفحهگستردهafData→/Data: دادهٔ ماشینخوانی که محتوای دیداری از آن مشتق شده یا نمایندهٔ آن استafAlternative→/Alternative،afSupplement→/Supplement،afUnspecified→/UnspecifiedafEncryptedPayload،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شدهٔ دقیق را بررسی میکند
// آنچه 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 صفحه در آن موقعیت در ترتیب صفحات سند را نام میبرد
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 است