لإرفاق ملف مصدر بمستند PDF/A-3 من Delphi، يكتب مكوّن PDFium سلسلة ملفات مرافقة بنمط PDF 2.0: تدفق ملف مضمن بـ /Subtype من نوع MIME، ومواصفة ملف تحمل /AFRelationship، ومصفوفة /AF معلقة على الفهرس أو على صفحة. يبني InjectAssociateFiles وTPdf.SaveAsWithAssociateFiles تلك السلسلة في تحديث تزايدي واحد، ومنذ v3.121.2 يُسلسل نوع MIME بوصفه اسم PDF واحدًا مهربًا تهريبًا صحيحًا. يغطي ما تبقى من المقالة ما يفحصه المدقق، وعيب المحرف الواحد الذي كسر text/plain، والمواضع التي كانت الإصدارات الأقدم تفعل فيها شيئًا آخر بصمت عمّا طلبته
ماذا يحتاج الملف المرافق في PDF/A-3 فعلًا؟
لا يجتاز مرفق PDF/A-3 التحقق إلا حين تتفق ثلاثة كائنات فيما بينها: يصرح تدفق الملف المضمن بـ /Type /EmbeddedFile زائد /Subtype من نوع MIME، ويحمل قاموس مواصفة الملف (ISO 32000-2 §7.11.3) قيمَ /F و/UF و/EF و/AFRelationship، ويشيير شيء في المستند إلى مواصفة الملف تلك عبر مصفوفة /AF (ISO 32000-2 §14.13). التضمين المجرد عبر شجرة /Names /EmbeddedFiles، وهو ما يفعله TPdf.CreateAttachment، لا يضبط حقول الربط إطلاقًا. يجعل ثابت التحقق PDF/A-3b الخاص بمكوّن PDFium نفسه تلك الاعتمادية ملموسة: أعد تسمية مفتاح /AFRelationship وحده فيفشل الملف في قاعدة واحدة بالضبط من البند 6.8 من ISO 19005-3؛ وأسقط /Subtype من نوع MIME وحده فيفشل قاعدة 6.8 أخرى؛ وضع المرفق نفسه في مرشح PDF/A-1b فيُرفض رفضًا قاطعًا، لأن PDF/A-1 يحرم الملفات المضمنة مهما كانت البيانات الوصفية مرتبة
قيمة العلاقة هي الجزء الذي يميل الناس إلى تخمينه. يطابق TPdfAFRelationship في FPdfAssocFiles كل عضو تعداد برمز اسم يمكن للحقن أن يصدره، والأعضاء الخمسة الأوائل وحدهم هم من ينتمي إلى المجموعة الجزئية التي يعترف بها 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 كان الحاقن يلصق نص المستدعي مباشرة بعد شرطة مائلة، فينتج /Subtype /text/plain. وفي صياغة PDF تبدأ الشرطة المائلة الثانية كائن اسم جديد (ISO 32000-1 §7.3.5)، فأصبح قاموس التدفق فجأة يحمل المفتاح /Subtype والاسم /text واسمًا زائدًا معلقًا هو /plain يُخرب توازن أزواج المفتاح-القيمة. رفض مدقق PDF/A مستقل الملف وهو يحلل قاموس EmbeddedFile، قبل أن يبلغ أي قاعدة PDF/A، ولهذا بدا الإخفاق عطبًا في الملف لا خاصية مرفق مفقودة
يمرر الإصلاح قيمة MIME عبر EscapePdfName التي تصدر /text#2Fplain: اسم واحد قيمته بعد فك الترميز text/plain. والتهريب أوسع من الشرطة المائلة عمدًا. كل بايت قيمته 32 أو أقل (فراغ أو جدولة أو CR أو LF)، وكل بايت 127 أو فوقه، والمحددات ()<>[]{}/% ومحرف الهروب # نفسه تصير #XX. لو اكتفى التهريب بالشرطة المائلة لبقيت ثغرة أخرى: نص MIME يحتوي >> أو فراغًا يمكن أن يغلق القاموس مبكرًا أو يحقن مفاتيح زائدة، لذا يغذي اختبار الانحدار قيمة عدائية بكل محدد زائد الجدولة وLF وCR ويفحص الناتج المشفر حرفًا بحرف
// ما يكتبه الحاقن لقيمة MIMEType = 'text/plain'
// قبل v3.121.2: /Type /EmbeddedFile /Subtype /text/plain (اسمان)
// v3.121.2: /Type /EmbeddedFile /Subtype /text#2Fplain (اسم واحد)
//
// يمرر المستدعون دائمًا قيمة MIME العادية. تهريبها بنفسك سلفًا
// يرمّز '#' مرتين، فيتحول 'text#2Fplain' إلى 'text#232Fplain'
Options.Files[0].MIMEType := 'text/plain';
بناء ملف PDF/A-3 عبر InjectAssociateFiles
لمخرجات PDF/A-3، أنتج المستند الأساس المطابق عبر TPdf.SaveAsPdfAToStream ثم استدعِ InjectAssociateFiles على ذلك التدفق؛ وهذا المسار ذو الخطوتين هو بالضبط ما تجريه ثابتة التحقق قبل أن تجتاز PDF/A-3b. وTPdf.SaveAsWithAssociateFiles هو الغلاف المريح، لكنه يحفظ عبر مسار SaveAs الاعتيادي مع saRemoveSecurity لا عبر كاتب PDF/A، فلا يضيف تعريف XMP ونية الإخراج اللذين يشترطهما PDF/A. وانتبه أن أنواع السجلات تسكن FPdfAssocFiles وFPdfPdfa، فكلتا الوحدتين تنتميان إلى بند uses عندك. ومنذ v3.121.3 لم يعد يلزم أن تكون FileName وDescription من ASCII صرفة: تُكتب /UF و/Desc نصوص PDF نصية، وASCII القابل للطباعة حرفيًا وكل ما عداه UTF-16BE مع علامة ترتيب بايتات، بينما اسم /F القديم يظل ASCII قابلًا للطباعة ومحمولًا مع استبدال كل محرف آخر بـ _، فيرى القراء الذين يفكون ترميز /F بصفحة كودهم الخاصة شرطة سفلية بدل نص مشوه. كانت الإصدارات الأقدم تحوّل الثلاثة كلها عبر صفحة ANSI للنظام على 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 على مستوى الفهرس
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;
الفهرس أم الصفحة: أين تهبط مصفوفة /AF؟
يقرر TAssocFilesOptions.TargetPage مالك مصفوفة /AF: الصفر يعلقها على الفهرس بوصفها ربطًا على مستوى المستند، والقيم من 1 إلى N تعلقها على قاموس تلك الصفحة، بالترقيم القائم على الواحد. يلحق الحاقن كل شيء في تحديث تزايدي واحد بتخطيط ثابت (التدفقات المضمنة، ثم مواصفات الملفات، ثم مصفوفة /AF، ثم كائن فهرس أو صفحة معاد كتابته)، فتحافظ الكائنات القائمة على إزاحاتها ولا يُعاد ضغط شيء. وأي مدخل /AF سابق على قاموس الهدف يُستبدل لا يُدمج، وهو ما يجعل الحفظ المتكرر عديم الأثر التراكمي لكنه يعني أيضًا أن استدعاءً ثانيًا بقائمة ملفات مختلفة هو الرابح. كان السلوكان التاليان يستحقان حارسًا في كودك، وكلاهما تغير. قبل v3.122.0 كانت قيمة TargetPage خارج المدى لا تفشل؛ بل ترتد إلى الفهرس، فحوّل خطأ مطبعي ربطًا على مستوى صفحة إلى ربط على مستوى المستند بلا أي إشارة. ومنذ v3.122.0 ترفع SaveAsWithAssociateFiles وSaveAsWithAssociateFilesToStream استثناء EPdfError حين تقع TargetPage خارج 0..PageCount، ويرفع InjectAssociateFiles الاستثناء الجديد EPdfAssocFilesError لقيمة TargetPage سالبة أو تسمي صفحة غير موجودة، تاركًا تدفق الوجهة بلا مساس. وقبل 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 على مستوى الفهرس)؛ والفحص أولًا يسمي الصفحة
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 لمرفق عبر التصدير الأصلي FPDFAttachment_GetAFRelationship، لكن النص الفارغ يحمل معنيين محتملين، فاستدعِ AttachmentRelationshipFeaturesAvailable أولًا. تُحمَّل الرابطة بتسامح: حين تنزع مكتبة PDFium DLL ذلك التصدير، تُقرأ كل علاقة فارغة، وهو ما لا يمكن تمييزه عن مواصفة ملف لا تحمل /AFRelationship أصلًا. وتتقاسم الخاصية فهرسها مع AttachmentCount، التي تعد مدخلات شجرة /Names /EmbeddedFiles. ويكتب الحاقن سلسلة /AF وحدها ولا يضيف مدخل شجرة أسماء، فالملف المرفق عبر InjectAssociateFiles يقع خارج ذلك الفهرس؛ وللتأكد من السلسلة المحقونة، افحص البايتات المحفوظة أو شغّل مدقق PDF/A. وداخليات شجرة الأسماء تلك مغطاة في العمل مع مرفقات PDF في Delphi باستخدام مكوّن PDFium
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، حين كانت البايتات المحفوظة بلا مقطع ختامي مقروء أو تعذر إيجاد قاموس الفهرس، كان InjectAssociateFiles ينسخ المدخل كما هو وكانت الطريقة تعيد True مع ذلك. ومنذ v3.122.0 يرفع InjectAssociateFiles استثناء EPdfAssocFilesError في تلك الحالات قبل كتابة أي شيء، وتعيد SaveAsWithAssociateFiles القيمة False، ولأنها تبني اليوم الناتج الكامل في مخزن حفظ قبل فتح الهدف، لم يعد الحفظ المرفوض أو الفاشل يبتور ملفًا قائمًا. أما مصفوفة Files الفارغة فما تزال تنسخ المستند كما هو عمدًا. ومحتوى الحمولة مسؤوليتك أنت كذلك: لا يفحص الحاقن أن ملف XML مُحكم البنية، ولا أن نوع MIME يطابق البايتات، ولا أن المستند الأساس PDF/A أصلًا. عامل الملف النهائي غيرَ موثوق حتى يراه مدقق، وهو الانضباط نفسه المشروح في مكوّن PDFium والامتثال لأرشفة PDF/A. وإن كنت تحلل أيضًا قواميس واردة بنفسك، فقواعد أسماء #XX نفسها تنطبق بالاتجاه المعاكس، وهو موضوع مغطى في مخاطر رموز الأسماء عند تحليل قواميس PDF
الملفات المرافقة ومخرجات PDF/A وبيانات المرفقات الوصفية والتحقق كلها تُشحن في المكوّن نفسه، فيجري المسار أعلاه في البناء دون مكتبة PDF ثانية. مرجع الـ API وتنزيل النسخة التجريبية وخيارات الترخيص على صفحة منتج PDFium Component