مقال تقني

إنشاء طبقات PDF ‏(OCG و OCMD) داخل Delphi مع PDFium

ينشئ PDFium Component طبقات PDF جديدة من Delphi عبر TPdf.SaveAsOptionalContentCreated. في حفظ متحقق واحد تضيف الطريقة مجموعات محتوى اختياري‏ (OCGs) وقواميس عضوية محتوى اختياري‏ (OCMDs) ورسمًا يقدمه المستدعي ملفوفاً في Form XObjects يشير مدخل /OC فيه إلى مجموعة جديدة، فتعرض PDFium والعارضات الأخرى ذلك الرسم أو تخفيه عند تبديل الطبقة

الطلب نادراً ما يكون مجرداً. يريد فريق هندسة مصنع أبعاداً مترية وإمبراطورية على ورقة الرسم نفسها، بمجموعة واحدة مرئية بالضبط. يريد مطبع غطاء خطوط قص يظهر على الشاشة ويمكن إطفاؤه قبل البروفة. لا طبقات لأي من الملفين بعد، فلا شيء للتبديل. تبديل الطبقات القائمة عمل مختلف، تغطيه مقالة تبديل طبقات المحتوى الاختياري القائمة في PDF بواسطة SaveAsOptionalContentConfigured؛ وهذه المقالة عن صناعة الطبقات من البداية أصلاً

لماذا لا تضبط /OC على كائن صفحة بواسطة PDFium فحسب؟

لا تملك PDFium دالة عامة لإرفاق محتوى اختياري بكائن صفحة قائم. لا تتضمن الترويسات العامة FPDFPageObj_SetOC أو ما يعادلها، فمسار التحرير ثم FPDFPage_GenerateContent المعتاد لا يستطيع إنتاج محتوى مقيد. لا بد من كتابة القيد في بنية الملف، والسؤال الوحيد هو أي بنية ستحترمها PDFium عند العرض

تعطي ISO 32000-1 §8.11.3 ثلاث طرق لجعل الرسومات اختيارية، وتعالجها PDFium بطرق مختلفة:

  • مقاطع تدفق محتوى الصفحة الملفوفة محتوىً معلّمًا /OC /Name BDC ... EMC، تُفحص لكل كائن صفحة أثناء العرض
  • Form XObjects و image XObjects تحمل مدخل /OC في قاموسها الخاص، تُفحص عند رسم الـ XObject
  • التعليقات ذات مدخل /OC في قاموس التعليق، الذي لا يجري مسار العرض في PDFium استشارته إطلاقاً، فتبقى تعليقة مقيدة مرئية أياً كانت حالة الطبقة

اللف بمحتوى معلّم يعني إعادة كتابة تدفقات المحتوى القائمة معاملاً معاملاً، والتعليقات مستبعدة من العارضة. يبقى مسار Form XObject، وهو ما يستخدمه PDFium Component: يذهب رسمك إلى Form XObject جديد يشير /OC فيه إلى المجموعة، ويُسجل النموذج في قاموس /Resources /XObject للصفحة، ويلحق تدفق محتوى جديد يحوي /Name Do واحداً إلى /Contents للصفحة. لا يُحلل محتوى الصفحة القائم ولا يُعاد كتابته قط

مسارات قيد المحتوى الاختياري في ISO 32000-1 تحت PDFium: يُفحص المحتوى المعلّم بين OC BDC و EMC لكل كائن صفحة، ويُفحص Form XObject ذو مدخل /OC عند رسمه وهو المسار الذي يكتبه PDFium Component من Delphi، بينما لا تُستشار /OC للتعليقات قط
تفحص PDFium المحتوى الاختياري لمقاطع المحتوى المعلّم وقواميس XObject لا للتعليقات، ولهذا يقيد SaveAsOptionalContentCreated الرسم المقدم عبر Form XObject ومعامل Do

وصف المجموعات والنماذج المقيدة في Delphi

تأخذ TPdf.SaveAsOptionalContentCreated ثلاث مصفوفات وتعيد تقريراً. تشترك كلتا الصيغتين الزائدتين، إحداهما تكتب إلى TStream والأخرى إلى اسم ملف، في قائمة المعاملات نفسها: ‏Groups: TPdfOptionalContentCreateGroups، و Memberships: TPdfOptionalContentCreateMemberships، و Forms: TPdfOptionalContentCreateForms، و out Report: TPdfOptionalContentCreateReport، و AllowSignedDocument: Boolean = False اختيارياً. وصلت الطريقة في PDFium Component v3.154.0

لكل TPdfOptionalContentCreateGroup ‏Name و Creator اختيارياً و Intent من نوع pociView أو pociDesign وعلم InitialStateVisible وعلم Locked و RadioTag. المجموعات المتشاركة في RadioTag نفسه تهبط في مصفوفة /RBGroups واحدة، وهكذا تعرف العارضة أن تشغيل واحدة يطفئ الأخريات. ويسمي كل TPdfOptionalContentCreateForm مجموعته المقيدة بـ GroupIndex‏ (فهرس صفري الأساس في مصفوفة Groups عندك)، و PageNumber ذا أساس 1، وبايتات تدفق المحتوى الخام، وصندوق محيط، ومصفوفة اختيارية و ResourceName اختيارياً

var
  Pdf: TPdf;
  Groups: TPdfOptionalContentCreateGroups;
  Forms: TPdfOptionalContentCreateForms;
  Report: TPdfOptionalContentCreateReport;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'sheet-a1.pdf';
    Pdf.Active := True;

    SetLength(Groups, 2);            // تمتلئ SetLength السجلات بالأصفار
    Groups[0].Name := 'Dimensions (mm)';
    Groups[0].Intent := pociView;
    Groups[0].InitialStateVisible := True;
    Groups[0].RadioTag := 'Units';
    Groups[1].Name := 'Dimensions (in)';
    Groups[1].Intent := pociView;
    Groups[1].InitialStateVisible := False;
    Groups[1].RadioTag := 'Units';

    SetLength(Forms, 2);
    Forms[0].GroupIndex := 0;
    Forms[0].PageNumber := 1;
    Forms[0].Content := TEncoding.ASCII.GetBytes(
      '0 0 1 RG 1.5 w 72 120 m 540 120 l S');
    Forms[0].BBoxRight := 612;
    Forms[0].BBoxTop := 792;
    Forms[0].ResourceName := 'DimMetric';
    Forms[1] := Forms[0];
    Forms[1].GroupIndex := 1;
    Forms[1].Content := TEncoding.ASCII.GetBytes(
      '1 0 0 RG 1.5 w 72 140 m 540 140 l S');
    Forms[1].ResourceName := 'DimImperial';

    if not Pdf.SaveAsOptionalContentCreated('sheet-a1-layers.pdf',
      Groups, nil, Forms, Report) then
      raise Exception.Create(Report.ErrorMessage);
  finally
    Pdf.Free;
  end;
end;

لاحظ أن محتوى النموذج رسم متجهي خالص. يكتب المكوّن قاموس /Resources فارغاً فقط على كل نموذج، فيجب أن يكون تدفق المحتوى معتمداً على نفسه: الألوان الجهازية والمسارات والقص والتحويلات تعمل، لكن أي شيء يحتاج مورداً مسمى (خط لـ Tf، صورة لـ Do، ‏ExtGState لـ gs) لا يجد ما يحل ضده. إن تركت ResourceName فارغاً سُميت النماذج FmOc1 و FmOc2 وهكذا؛ والاسم المخصص لا يحوي سوى حروف وأرقام وشرطة سفلية وناقصة ونقطة

ماذا تضيف OCMD فوق مجموعة عادية؟

قاموس عضوية المحتوى الاختياري يجعل الظهور معتمداً على عدة مجموعات دفعة واحدة. يعرف ISO 32000-1 §8.11.2.2 صيغتين: قائمة /OCs مع سياسة /P مثل /AnyOn أو /AllOn، وتعبير ظهور /VE مبني من /And و /Or و /Not في المواصفة. تأخذ PDFium Component ‏TPdfOptionalContentCreateMembership بـ MemberGroupIndices و Policy و Expression؛ وعندما تكون Expression.GroupIndices غير فارغة يغلب التعبير وتُكتب /VE بجوار /OCs

procedure AddUnitMemberships(var Memberships: TPdfOptionalContentCreateMemberships);
begin
  SetLength(Memberships, 2);

  // مرئية عند تشغيل أي طبقة وحدات: ‏/OCs [...] /P /AnyOn
  SetLength(Memberships[0].MemberGroupIndices, 2);
  Memberships[0].MemberGroupIndices[0] := 0;
  Memberships[0].MemberGroupIndices[1] := 1;
  Memberships[0].Policy := ocmpAnyOn;

  // مرئية فقط ما دامت طبقة الوحدات الإنجليزية مطفأة: ‏/VE [ /Not ref ]
  SetLength(Memberships[1].MemberGroupIndices, 1);
  Memberships[1].MemberGroupIndices[0] := 1;
  Memberships[1].Expression.Kind := poceNot;
  SetLength(Memberships[1].Expression.GroupIndices, 1);
  Memberships[1].Expression.GroupIndices[0] := 1;
end;

المجموعة الفرعية المدعومة ضيقة عن قصد. السياسات محدودة بـ ocmpAllOn و ocmpAnyOn؛ وتُرفض ocmpAnyOff و ocmpAllOff. التعبيرات بعمق مستوى واحد، بـ poceAll أو poceAny أو poceNot فوق المجموعات المنشأة، وتشترط /Not فهرس مجموعة واحداً بالضبط. تتسلسل poceAll بوصفها /And، و poceAny بوصفها /Or، و poceNot بوصفها /Not، المعاملات الثلاثة الوحيدة التي تعرفها §8.11.2.2. كتبت النسخ قبل 3.181.2 ‏/All و /Any، ما تقيّمه PDFium بوصفه مخفياً، فإذا أنشأت OCMDs ببناء أقدم فأعد توليدها بدل الثقة بالتعبير. وتحتاج كل عضوية مدخلاً واحداً على الأقل في MemberGroupIndices حتى مع وجود تعبير، لأن تلك القائمة تصير مصفوفة /OCs. وتعود أرقام كائنات القواميس الجديدة في Report.CreatedMembershipObjectNumbers

قواميس عضوية المحتوى الاختياري التي ينشئها PDFium Component داخل Delphi: تقيّم قائمة /OCs بسياسة /AnyOn أو /AllOn حالات المجموعات معاً، بينما يقيد تعبير ظهور /VE مثل /Not على مجموعة المحتوى بمنطق And و Or و Not بعمق مستوى واحد
تقيّم السياسات قائمة /OCs بينما تجمع تعبيرات /VE المجموعات بـ /And و /Or و /Not - وفي الحالين تحتاج العضوية مجموعة عضو واحدة على الأقل لتشير إليها

سلوكان يستحقان المعرفة قبل أن تبنِي على OCMDs. أولاً النماذج المقيدة في الاستدعاء نفسه تشير إلى مجموعات فقط: يفهرس TPdfOptionalContentCreateForm.GroupIndex ‏Groups لا Memberships، فلا يمكن قيد نموذج بـ OCMD منشأة حديثاً عبر هذه الـ API. ثانياً تضيف PDFium Component كل OCMD جديدة إلى مصفوفة /OCProperties /OCGs في الكتالوج، بينما تبقى /ON و /OFF و /Order للمجموعات فقط. توصف /OCGs في ISO 32000-1 §8.11.4.2 بوصفها قائمة المجموعات، ولكن دون ذلك التسجيل لا تعدّ TPdf.InspectOptionalContent العضوية إطلاقاً. والأثر الجانبي أن جرد المخرجات يسرد كل OCMD مدخلاً إضافياً بلا اسم في Inventory.Groups، فطابق المجموعات بـ Name لا بالموضع

كيف يرقّع المكوّن /OCProperties قائمة أصلاً؟

تبني TPdf.SaveAsOptionalContentCreated إما /OCProperties من الصفر وإما توسّع التي في الكتالوج أصلاً، وتتوسع بالإلحاق لا بإعادة البناء أبداً. المستند بلا طبقات ينال قاموساً جديداً بـ /OCGs وإعداد افتراضي /D يحمل /ON و /OFF و /Order، مع /Locked و /RBGroups عندما تطلبها أي مجموعة، كما يفصّل ISO 32000-1 §8.11.4.3

لمستند له طبقات أصلاً يُرقَّع كل مصفوفة هدف بإدراج المراجع الجديدة قبل قوسها الختامي مباشرة. يبدو ذلك تكلفاً، وهو هناك لسبب. مصفوفة /Order يمكن أن تحمل مصفوفات متداخلة وتسميات نصية تشكل شجرة الطبقات التي تعرضها العارضة، وأسقطت مقاربة سابقة مسطّحت المصفوفة وأعادت بناءها تلك التسميات. الإدراج في النهاية يبقي الشجرة القائمة سليمة ويسرد ببساطة المجموعات الجديدة بعدها. والمصفوفات الساكنة في كائناتها غير المباشرة الخاصة تُحل وترقع في مكانها

توسيع قاموس /OCProperties قائم بواسطة PDFium Component داخل Delphi: تُدرج مراجع مجموعات المحتوى الاختياري الجديدة قبل القوس الختامي مباشرة فتنجو مصفوفات /Order المتداخلة والتسميات النصية، ويُتبع الإعداد الافتراضي /D غير المباشر ويرقع في مكانه
إلحاق المراجع قبل القوس الختامي يبقي شجرة /Order قائمة سليمة، وهو ما لا يضمنه إعادة بناء مسطّح للمصفوفات المتداخلة والتسميات

منذ v3.175.0 قد يكون الإعداد الافتراضي نفسه غير مباشر أيضاً. يكتب بعض المنتجين /OCProperties << /OCGs [...] /D 12 0 R >>، وتتبع PDFium Component الآن ذلك المرجع وترقع القاموس الهدف بدل الفشل بخطأ مدخل مشوه. وتُعالج قواميس /Resources للصفحة المباشرة وغير المباشرة، بما فيها قاموس /XObject الفرعي غير المباشر، بالطريقة نفسها

إثبات أن الطبقات الجديدة تتبدل فعلاً

طبقة موجودة في /OCProperties لا تقيد شيئاً سهلة الإنتاج عسيرة الملاحظة. أثناء التطوير لفّت أول نسخة من كاتب النماذج كائن تدفق كان يحمل أصلاً ترويسته الخاصة N 0 obj ومقطعه الختامي endobj داخل ترويسة ثانية. لم تستطع PDFium تحليل الكائن المضاعف، وبدل الإبلاغ عن خطأ تخطت معامل Do بصمت. فُتح الملف وبدا لوح الطبقات صحيحاً ولم يظهر الرسم المقيد قط. ما كشفه مجس بدّل الطبقة وقارن البكسلات المعروضة: لا فرق، فشيئاً ما كان خاطئاً

لهذا تتحقق الطريقة قبل التسليم. يُعاد فتح المخرج بتحميل صارم يفحص جدول المراجع المتقاطعة، وتُقرأ كل مجموعة مطلوبة عائدةً عبر InspectOptionalContent لتأكيد اسمها وحالتها الابتدائية وتسجيلها المقفل. عندئذ فقط يُنسخ المخرج إلى تدفقك، وتكتب الصيغة الزائدة بالملف ملفاً مؤقتاً وتستبدل الهدف ذرياً. تستطيع إجراء فحص التبديل نفسه بنفسك بإعادة فتح المخرج وإيجاد المجموعة بالاسم وحفظ نسخة بالحالة المعاكسة

function SaveImperialPreset(const SourceName, TargetName: string): Boolean;
var
  Pdf: TPdf;
  Inventory: TPdfOptionalContentInventory;
  Changes: TPdfOptionalContentStateChanges;
  Options: TPdfOptionalContentConfigureOptions;
  CfgReport: TPdfOptionalContentConfigureReport;
  I: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := SourceName;
    Pdf.Active := True;
    if not Pdf.InspectOptionalContent(Inventory) then
      raise Exception.Create(Inventory.ErrorMessage);

    for I := 0 to High(Inventory.Groups) do
      if (Inventory.Groups[I].Name = 'Dimensions (mm)') or
         (Inventory.Groups[I].Name = 'Dimensions (in)') then
      begin
        SetLength(Changes, Length(Changes) + 1);
        Changes[High(Changes)].GroupObjectNumber := Inventory.Groups[I].ObjectNumber;
        Changes[High(Changes)].Visible := Inventory.Groups[I].Name = 'Dimensions (in)';
      end;

    Options := TPdfOptionalContentConfigureOptions.Default;
    Result := Pdf.SaveAsOptionalContentConfigured(TargetName, Changes,
      Options, CfgReport);
  finally
    Pdf.Free;
  end;
end;

اعرض صفحة الملفين الأولى فيجب أن تتبادل الخطوط الزرقاء والحمراء. وإن لم تتبادل فافحص قيمة EffectiveVisible للمجموعة في الجرد أولاً؛ إنها تعكس الحالة التي ستعرضها PDFium فعلاً بعد تطبيق مصفوفات الإعداد وقواعد الاستخدام

حدود وفخاخ تخطط حولها

TPdf.SaveAsOptionalContentCreated ملف تعريفي محدود، ومعظم المفاجآت تأتي من معاملته محررًا عاماً للطبقات. القيود أدناه مفروضة مسبقاً، فالاستدعاء المخالف يفشل بـ Report.ErrorMessage مضبوطة ولا يكتب أي مخرج:

  • مجموعة واحدة مطلوبة على الأقل؛ تُسقَّف المجموعات والعضويات والنماذج عند 4096 مدخلاً لكل منها، وأسماء المجموعات يجب أن تكون فريدة وطولها 1 إلى 255 محرفاً، وتقبل كل نموذج 16 MiB من بايتات المحتوى على الأكثر
  • تُرفض المستندات المشفرة، والمستند الموقع يحتاج AllowSignedDocument = True؛ والمخرج إعادة كتابة تامة فلن تتحقق التواقيع القائمة
  • كل مدخل في Forms يصير Form XObject خاصاً به، فوضع الغطاء نفسه على 200 صفحة يخزن المحتوى 200 مرة بدل مرة
  • يُلحق تدفق Do الجديد بعد محتوى الصفحة القائم، فيجري في حالة الرسوميات التي يتركها التدفقات السابقة خلفه؛ الصفحات السليمة البناء تعيد حالتها، لكن صفحة بـ q/Q غير متوازنة قد تزيح غطاءك
  • لا يُنقل محتوى الصفحة القائم إلى طبقة قط؛ لا يُقيد سوى الرسم الذي تمرره في Forms

نقطة التكرار هي الميزان لأجل المستندات الكبيرة. إن كان الغطاء ثقيلاً يتكرر على كل صفحة فمسار الكائن المشترك في أختام الصفحات القابلة لإعادة الاستخدام المبنية على Form XObjects يبقي نسخة واحدة في الملف، مقابل معالجة قيد الطبقة بنفسك

FAQ

هل يستطيع PDFium Component وضع محتوى صفحة قائم في طبقة جديدة؟

لا. يقيد TPdf.SaveAsOptionalContentCreated المحتوى الذي تقدمه في Forms فقط؛ ولا يلف المعاملات القائمة بمحتوى معلّم ولا ينقلها إلى XObject. ولتقسيم أعمال فنية قائمة على طبقات أعد توليدها محتوى نموذج معتمدًا على نفسه وأزل الرسم الأصلي في خطوة منفصلة

لماذا تبقى تعليقاتي المقيدة مرئية والطبقة مطفأة؟

لا تفحص PDFium مدخل /OC لقواميس التعليقات على مسار عرضها، فالقيد على مستوى التعليقة بلا أثر في العارضات المبنية على PDFium. لهذا يرفق PDFium Component محتوى الطبقة عبر Form XObjects التي تقيد PDFium إياها فعلاً

أي أرقام كائنات أستخدم عند تبديل الطبقات الجديدة لاحقاً؟

تحمل Report.CreatedGroupObjectNumbers الأرقام في الملف الذي كُتب للتو. وعندما تعيد فتح ذلك الملف وتضبطه خذ ObjectNumber من TPdf.InspectOptionalContent وطابق بـ Name، التي تبقى صحيحة حتى لو أعاد حفظ لاحق ترقيم الكائنات

يشحن PDFium Component ‏SaveAsOptionalContentCreated و SaveAsOptionalContentConfigured و InspectOptionalContent بوصفها حزمة أدوات واحدة للمحتوى الاختياري لـ Delphi و C++Builder، مبنية على عارض PDFium. التفاصيل والتنزيلات على صفحة منتج PDFium Component