مقال تقني

MovePage في PDFlibPas: حين تتشارك الصناديق الموروثة النسخ

في PDFlibPas، مكتبة PDF لـ Delphi، كانت الصفحة المنقولة بـ MovePage تتلقى كائنات MediaBox و CropBox و Resources ذاتها التي كان تحملها عقدة Pages القديمة لها، فـ SetPageBox أو DrawText لاحقة على الصفحة المنقولة كانت تعيد كتابة تلك العقدة وكل شقيقة ما زالت ترث منها بصمت. ومنذ v3.539.36 تحصل الصفحة المنقولة على نسخ خاصة بها، ويبقى المرجع غير المباشر مرجعاً. ويغلق الإصدار نفسه مسارين مرتبطين: ‏SetPageBox على صندوق غير مباشر تشترك فيه عدة صفحات، و CopyPageRanges الذي كان يترك صفحات المستند المصدر مربوطة بعقدة Pages تابعتها، والـ CropBox مربوطاً بالـ MediaBox

التقارير التي تقود إلى هنا لا تذكر هوية الكائنات قط. إنها تقول أشياء مثل "قصبت الصفحة 7 فصُّت الصفحات من 8 إلى 12 معها"، أو "ضيّقت الـ CropBox فتحركت الـ MediaBox معها"، أو الأكثر إرباكاً: "نسخت صفحة إلى مستند جديد فتغير الملف الأصلي". لا شيء ينهار ولا شيء يتسرب، والملف المحفوظ PDF صالح تماماً. إنه فقط يحوي هندسة أبعاد لم يطلبها أحد

لماذا يغيّر SetPageBox على صفحة واحدة حجم شقيقاتها؟

غيّر SetPageBox حجم الشقيقات لأن مدخلين في شجرة الصفحات كانا يشيران إلى مصفوفة واحدة في الذاكرة، و SetPageBox يحرر مصفوفته الهدف في مكانها. فأي صفحة أو عقدة Pages تحمل النسخة نفسها رأت التعديل. ثلاثة مسارات كود في PDFlibPas أنتجت تلك المشاركة قبل v3.539.36:

  • MovePage يجسّد الخصائص القابلة للتوريث على الصفحة قبل فصلها عن أبيها، وكان يُلحق كائنات السلف ذاتها لا نسخاً منها، فتشاركت الصفحة المنقولة وشقيقاتها السابقات مصفوفة صندوق وقاموس Resources
  • SetPageBox يتبع المراجع غير المباشرة ويحرر المصفوفة المشار إليها، فملف تشير فيه عدة صفحات إلى كائن /MediaBox 11 0 R واحد كان كل تلك الصفحات تتغير أحجامها بنداء واحد، سواء شارك MovePage في الأمر أو لا
  • CopyPageRanges يجسّد القيم الموروثة على صفحة المصدر قبل استنساخها إلى المستند الهدف، وكان يُلحق نسخ عقدة Pages بصفحة المصدر، إضافة إلى نسخة الـ MediaBox ذاتها CropBox افتراضياً
اسمية المراجع في MovePage لدى PDFlibPas حيث كانت الصفحة المنقولة وشقيقتها السابقة كلتاهما تحملان نسخة مصفوفة MediaBox الخاصة بالسلف، فيحرر SetPageBox صفحة ويغير حجم الأخرى؛ ومنذ v3.539.36 تُلحق التجسيد نسخاً مفكوكة وتبقى التعديلات محلية على الصفحة التي تلمسها
مدخلان في شجرة الصفحات يشيران إلى مصفوفة واحدة في الذاكرة جعلا كل تعديل يهبط في كل حامل، وظل الـ PDF المحفوظ صالحاً طوال الوقت

لحالة MovePage تاريخ قصير. قبل v3.539.27 كانت MovePage تنقل /Resources فقط، فصفحة منقولة تحت أب مختلف تتلبد بحجمه ودورانه بصمت. وصححت v3.539.27 غياب MediaBox و CropBox و Rotate، وهو ما يعتمد عليه CollateDocumentsEx حين تعيد ترتيب الصفحات، لكنها ألحقت قيم السلف بوصفها نسخاً مشتركة. تلك هي النافذة التي يغلقها v3.539.36. ومسارا SetPageBox و CopyPageRanges أقدم منهما؛ فأي بناء أقدم من v3.539.36 يحملهما

القيم المباشرة والمراجع غير المباشرة وتوريث خصائص الصفحة

النسخة الصحيحة من خاصية صفحة موروثة تكرر القيم المباشرة وتبقي المراجع غير المباشرة مراجعاً، لأن ذلك هو التمييز الذي يرسمه ISO 32000-1 نفسه. فالكائن المباشر مثل [0 0 400 300] المكتوب داخل قاموس يعود لذلك القاموس وحده. أما الكائن غير المباشر، المعرف مرة واحدة كـ 11 0 obj والمُستشهد به كـ 11 0 R، فمشترك بالتصميم: يجعله ISO 32000-1 §7.3.10 قابلاً للعنونة من أي مكان في الملف، وكل 11 0 R تعني الكائن نفسه

وتوريث خصائص الصفحة، ‏ISO 32000-1 §7.7.3.4، يضيف حالة ثالثة. فقيم Resources و MediaBox و CropBox و Rotate قد تستقر على عقدة Pages وتسري على كل صفحة سليلة لا تعرف نسختها الخاصة. الصفحة لا تحمل القيمة؛ إنها تبحث عنها عبر /Parent. وسلسلة البحث تلك تنكسر لحظة تغيّر الصفحة أباها، ولهذا يجب على MovePage و BalancePageTree أن تكتبا أولاً القيم الفعّالة على الصفحة ذاتها. والسؤال فقط كيف تكتبانها

لماذا يخفي مسبَح الكائنات الخطأ؟

في PDFlibPas كل كائن PDF محلَّل أو منشأ يملكه مسبَح TPDFStructure الخاص بالمستند، وتخزن القواميس والمصفوفات مؤشرات عادية إلى مدخلاتها. يسجّل TPDFDictionary.Add المؤشر ولا شيء غيره. فإضافة نسخة واحدة إلى حاويين أبويين مشروعة إذن عند كل مستوى يستطيع وقت التشغيل فحصه: لا تحرر مزدوج عند التفكيك، ولا عدّاد مراجع ليخطئ، ولا استثناء. والتسلسل متسامح بالقدر نفسه، إذ يكتب كل حاوي قيمة النسخة المشتركة الحالية سطراً واحد، وقبل أي تعديل يكون المخرج بايتاً ببايت ما سينتجه التكرار الصحيح

والاسمية (aliasing) لا تظهر إلا حين يحوّر أحد ما النسخة المشتركة في مكانها. ‏SetPageBox يفعل ذلك بالضبط عبر غلاف مستطيل فوق المصفوفة القائمة، والرسم على صفحة يفعله بالقاموس Resources عند تسجيل خط أو صورة. فيهبط التعديل، بصمت، في كل حاوي آخر يحمل المؤشر

كيف تنسخ PDFlibPas v3.539.36 بدل المشاركة

يصلح PDFlibPas v3.539.36 المشكلة من الطرفين: التجسيد الآن يُلحق نسخاً، وكتابة الصناديق الآن تحرر مصفوفة تملكها الصفحة فقط. وكل إصلاح يغطي حالة لا يستطيع الآخر تغطيتها

مساعد التجسيد، ‏PLInheritPageAttributes، يُلحق الآن Page.Owner.Decode(Value.Output) بدل Value. والدوران عبر المُسلسِل طريق غليظ لكنه دقيق يحصل على دلالات PDF مجاناً. فمصفوفة أو قاموس مباشر يسلسل إلى نصه الحرفي ويفك إلى نسخة جديدة مستقلة. والمرجع غير المباشر يسلسل إلى 11 0 R ويفك إلى كائن مرجع جديد يشير إلى الكائن 11 نفسه، فتظل الصفحة تشير إلى الكائن المشترك بدل أن تتلقى نسخة مضمّنة، وهو ما يحفظ سلوك المرجع المقدَّم في v3.539.27. والنسخة عميق بقدر البنية المباشرة بالضبط: كل ما يُبلَغ إليه عبر مرجع داخل قاموس منسوخ يبقى مشتركاً، كما يريد تنسيق الملف. ويستدعي BalancePageTree المساعد نفسه لكل صفحة يعيد أبوتتها، فالصفحات المجسَّدة هناك تحصل أيضاً على نسخ منفصلة

دوران التجسيد في PDFlibPas حيث يُلحق PLInheritPageAttributes القيمة Page.Owner.Decode(Value.Output): مصفوفة مباشرة تسلسل إلى نص حرفي وتفك إلى نسخة جديدة، بينما مرجع غير مباشر 11 0 R يسلسل ويفك إلى مرجع جديد ما زال يشير إلى الكائن المشترك 11
التسلسل ثم إعادة التحليل يحصلان على دلالات كائنات PDF مجاناً: القيم المباشرة تنسخ، والمراجع تبقى مراجعاً، تماماً كما يقصد ISO 32000-1

والنسخ وحده لا يكفي، لأن حالة المرجع ما زالت تشير إلى كائن مشترك. لو تبع SetPageBox ذلك المرجع وحرر الكائن 11 لعادت الصفحة المنقولة تغيّر حجم الأب القديم وغيره من أبنائه. لذا يطبق كاتب الصناديق الآن copy-on-write: يحرر في المكان فقط حين يكون مدخل الصفحة الخاص مصفوفة مباشرة، ويستبدل صندوقاً غير مباشر أو مفقوداً بمصفوفة مباشرة جديدة. ويُترك الكائن 11 سليماً لكل صفحة أخرى تستشهد به

قرار copy-on-write في SetPageBox لدى PDFlibPas: حين يكون مدخل الصفحة الخاص مصفوفة مباشرة يُحرر في المكان، وحين يكون مرجعاً غير مباشر أو مفقوداً يستبدله الكاتب بمصفوفة مباشرة جديدة فيحتفظ الكائن المشترك 11 بقيمته لكل صفحة أخرى تستشهد به
النسخ عند التجسيد لا يكفي ما دامت المراجع تشير إلى كائنات مشتركة، لذا يحرر كاتب الصناديق ما تملكه الصفحة فقط
مسار الكودقبل v3.539.36منذ v3.539.36
تجسيد MovePageالصفحة تحمل النسخ المباشرة الخاصة بالسلفالصفحة تحمل نسخاً مفكوكة؛ والمراجع تبقى مراجعاً
SetPageBoxيتبع مرجعاً ويحرر المصفوفة المشتركةيحرر مصفوفة مباشرة على الصفحة فقط، وإلا كتب واحدة جديدة
صفحة مصدر CopyPageRangesتشارك صناديق عقدة Pages؛ والـ CropBox هو نسخة الـ MediaBoxكل قيمة مجسَّدة على صفحة المصدر نسخة
الصناديق الافتراضية عند استنساخ موارد الصفحةيتشارك CropBox و BleedBox و TrimBox و ArtBox مصفوفة واحدةكل صندوق افتراضي يحصل على مصفوفته الخاصة

الصف الأخير هو الكامن. حين تستنسخ المكتبة موارد صفحة من أجل التقاطها أو دمجها، تملأ مدخلات CropBox و BleedBox و TrimBox و ArtBox المفقودة، وكانت تلك قديماً نسخة المصفوفة ذاتها. لم يدع أي مستدعي حالي تلك الاسمية تعيش ما يكفي لتُحرَّر، لكن المستدعي التالي كان سيُفعِلها. وكيفية اختيار قيم الصناديق الافتراضية تلك موضوع قائم بذاته، تغطيه مقالة PDFlibPas عن افتراضات TrimBox و BleedBox و CropBox

إعادة إنتاج اسمية MovePage بـ PDF مبني يدوياً

أسرع طريقة لفحص أي بناء من PDFlibPas هي PDF صغير مكتوب يدوياً يُحمَّل بـ LoadFromString، حيث كل رقم كائن معروف سلفاً. يكتب المساعد أدناه جدول مراجع متبادلة كلاسيكي بإزاحات بايت محسوبة بدقة، فلا يعتمد الاختبار على سلوك استرجاع المحلل للملفات التالفة

uses
  System.SysUtils, PDFlibrary;

function BuildPdf(const Objects: array of AnsiString): AnsiString;
var
  Offsets: array of Integer;
  I, XRefPos: Integer;
begin
  Result := '%PDF-1.4'#10;
  SetLength(Offsets, Length(Objects));
  for I := 0 to High(Objects) do
  begin
    Offsets[I] := Length(Result);   // إزاحة بايت تبدأ من 0 لسطر "N 0 obj"
    Result := Result + AnsiString(IntToStr(I + 1)) + ' 0 obj'#10 +
      Objects[I] + #10'endobj'#10;
  end;
  XRefPos := Length(Result);
  Result := Result + 'xref'#10'0 ' + AnsiString(IntToStr(Length(Objects) + 1)) +
    #10'0000000000 65535 f '#10;
  for I := 0 to High(Offsets) do      // كل مدخل 20 بايت بالضبط
    Result := Result + AnsiString(Format('%.10d 00000 n ', [Offsets[I]])) + #10;
  Result := Result + 'trailer'#10'<< /Size ' +
    AnsiString(IntToStr(Length(Objects) + 1)) + ' /Root 1 0 R >>'#10 +
    'startxref'#10 + AnsiString(IntToStr(XRefPos)) + #10'%%EOF'#10;
end;

function StreamObj(const Content: AnsiString): AnsiString;
begin
  Result := '<< /Length ' + AnsiString(IntToStr(Length(Content))) +
    ' >>'#10'stream'#10 + Content + #10'endstream';
end;

للمستند الاختباري عقدتا Pages وسيطتان. تحمل العقدة 3 ‏MediaBox غير مباشر (الكائن 11، ‏400 في 300 نقطة)، و CropBox مباشراً، وقاموس Resources مباشراً، وتمتلك صفحتين. والعقدة 4 لها MediaBox بمقاس Letter وتمتلك الصفحة الثالثة. نقل الصفحة 1 إلى الموضع 3 يعيد أبوتتها تحت العقدة 4، وهو بالضبط النقل الذي يحتاج تجسيداً: بدونه ستتحول الصفحة إلى صفحة Letter

procedure Check(Condition: Boolean; const Msg: string);
begin
  if not Condition then
    raise Exception.Create(Msg);
end;

procedure CheckMovedPageIsIsolated;
var
  Lib: TPDFlib;
  FontID: Integer;
begin
  Lib := TPDFlib.Create;
  try
    Check(Lib.LoadFromString(BuildPdf([
      '<< /Type /Catalog /Pages 2 0 R >>',
      '<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 3 >>',
      '<< /Type /Pages /Parent 2 0 R /Kids [5 0 R 6 0 R] /Count 2 ' +
        '/MediaBox 11 0 R /CropBox [10 20 390 280] /Resources << >> >>',
      '<< /Type /Pages /Parent 2 0 R /Kids [7 0 R] /Count 1 ' +
        '/MediaBox [0 0 612 792] >>',
      '<< /Type /Page /Parent 3 0 R /Contents 8 0 R >>',
      '<< /Type /Page /Parent 3 0 R /Contents 9 0 R >>',
      '<< /Type /Page /Parent 4 0 R /Contents 10 0 R >>',
      StreamObj('1 w'), StreamObj('2 w'), StreamObj('3 w'),
      '[0 0 400 300]']), '') = 1, 'load failed');

    Lib.SelectPage(1);
    Check(Lib.MovePage(3) = 1, 'MovePage failed');
    Lib.SelectPage(3);                       // الصفحة التي نقلناها للتو
    Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'inherited MediaBox lost');

    Lib.SetPageBox(1, 0, 200, 200, 200);     // ‏MediaBox ‏200 x 200
    Lib.SetPageBox(2, 0, 100, 100, 100);     // ‏CropBox ‏100 x 100
    FontID := Lib.AddStandardFont(4);        // ‏Helvetica
    Lib.SelectFont(FontID);
    Lib.SetTextSize(12);
    Lib.DrawText(20, 20, 'MOVED');

    // افحص الأب القديم قبل اختيار صفحة أخرى (انظر أدناه)
    Check(Pos(AnsiString('/Font'), Lib.GetObjectToString(3)) = 0,
      'font registered in the old Pages node');

    Lib.SelectPage(1);                       // الصفحة 2 سابقاً ما زالت تحت العقدة 3
    Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'sibling MediaBox changed');
    Check(Abs(Lib.GetPageBox(2, 2) - 380) < 0.001, 'sibling CropBox changed');
    Check(Pos(AnsiString('400'), Lib.GetObjectToString(11)) > 0,
      'shared object 11 was rewritten');
  finally
    Lib.Free;
  end;
end;

يأخذ GetPageBox(BoxType, Dimension) نوع الصندوق 1 للـ MediaBox و 2 للـ CropBox، والبعد 2 للعرض. وبالأصل السفلي الأيسر الافتراضي يعني SetPageBox(1, 0, 200, 200, 200) يسار 0، أعلى 200، عرض 200 وارتفاع 200. وعلى البناء بين v3.539.27 و v3.539.35 تفشل فحوص الشقيقات: يهبط تعديل الـ CropBox في المصفوفة المباشرة للعقدة 3، ويعيد تعديل الـ MediaBox كتابة الكائن 11 عبر المرجع

هل يغيّر CopyPageRanges المستند المصدر؟

منذ v3.539.36 ما زال CopyPageRanges يكتب على صفحات المصدر، لكن كل قيمة يكتبها نسخة منفصلة، فتبقى التعديلات اللاحقة على المصدر محلية على الصفحة التي تحررها. والكتابة نفسها مقصودة: فصفحة المصدر تحتاج MediaBox و CropBox و Rotate و Resources صريحة قبل استنساخ قاموسها إلى الهدف، وإلا فقدت النسخة كل ما ورثته. وإعادة الترقيم ونسخ الصفحة إلى الهدف تغطيهما النسخ العميق للكائنات عبر المستندات في PDFlibPas؛ وهذا الخلل كان يستقر على جانب المصدر الذي يفترض أكثر الناس أنه يُقرأ فقط أثناء نسخ

لم يُظهر المخرج شيئاً قط. مشتركةً كانت أم منسوخة، القيم المجسَّدة تسلسل بشكل متطابق، فكلا المستندين حُفظ بايتاً ببايت كما هو قبل الإصلاح وبعده. ولم يكشف الاسمية إلا تعديل على المستند المصدر بعد النسخ:

procedure CheckSourceSurvivesCopy;
var
  Lib: TPDFlib;
  SourceID, TargetID: Integer;
begin
  Lib := TPDFlib.Create;
  try
    Check(Lib.LoadFromString(BuildPdf([
      '<< /Type /Catalog /Pages 2 0 R >>',
      '<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 ' +
        '/MediaBox [0 0 400 300] /Resources << >> >>',
      '<< /Type /Page /Parent 2 0 R /Contents 5 0 R >>',
      '<< /Type /Page /Parent 2 0 R /Contents 6 0 R >>',
      StreamObj('1 w'), StreamObj('2 w')]), '') = 1, 'load failed');
    SourceID := Lib.SelectedDocument;

    TargetID := Lib.NewDocument;             // يصير المستند المختار
    Check(Lib.CopyPageRanges(SourceID, '1') = 1, 'copy failed');

    Lib.SelectDocument(SourceID);
    Lib.SelectPage(1);
    Lib.SetPageBox(2, 50, 250, 100, 100);    // ضيّق الـ CropBox فقط
    Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'MediaBox followed CropBox');
    Lib.SetPageBox(1, 0, 200, 200, 200);

    Lib.SelectPage(2);
    Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'sibling page resized');

    Lib.SelectDocument(TargetID);            // النسخة تحتفظ بحجمها الأصلي
    Lib.SelectPage(Lib.PageCount);
    Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'copied page resized');
  finally
    Lib.Free;
  end;
end;

قبل v3.539.36 ورثت كلتا الصفحتين هنا الـ MediaBox المباشر لعقدة الجذر، وألحقت النسخة تلك النسخة بصفحة المصدر 1، ثم ألحقتها من جديد بوصفها CropBox للصفحة 1. فضيّق الـ CropBox ضيّق الـ MediaBox، وتغيير حجم الـ MediaBox غيّر حجم الصفحة 2 عبر عقدة الجذر. وسير العمل التي تنسخ الصفحات خارجاً ثم تواصل تحرير المصدر، مثل تجميع المسوحات ثنائية الوجه في PDF واحد قبل قص الأصول، هو حيث ظهر هذا

لماذا يصعب اختبار اسمية النسخ؟

يصعب اختبار اسمية النسخ لأن الأثر المرئي يحتاج ثلاث خطوات بترتيب محدد: أنشئ الاسمية، ثم حوّر طرفاً منها، ثم افحص الطرف الآخر قبل أن يلمسه أي شيء آخر. وأكثر الاختبارات تكتفي بالخطوة الأولى وتقارن المخرج المحفوظ، وهو متطابق سواء وُجدت الاسمية أو لا

وفخ الترتيب في PDFlibPas هو SelectPage. فاختيار صفحة يعيد تطبيق الخط الحالي عبر SelectFont، وهو ما يسجل ذلك الخط في موارد الصفحة. وصفحة بلا /Resources خاصة بها تُفاضل إلى قاموس أبيها، فمجرد اختيار تلك الصفحة يضيف /Font إلى عقدة Pages مشروعاً. في اختبار MovePage أعلاه يضيف اختيار الصفحة 2 السابقة مدخل Helvetica إلى العقدة 3، وهذا سلوك صحيح وليس تسرّباً. ولهذا يجري فحص GetObjectToString(3) قبل SelectPage(1)؛ قلّبهما فيفشل الاختبار على بناء مصحح

وتلك القاعدة ترسم أيضاً ما يتركه v3.539.36 عمداً وحاله. فكتابة مورد إلى صفحة ترث قاموس Resources تكتب في قاموس السلف، وكل شقيقة ترى المدخل الجديد. ذلك توريث يعمل كما حُدد في المواصفة، لا مشاركة نسخ، وهو غير مؤذٍ لأن إضافة اسم خط أو صورة إلى قاموس مشترك لا تغير طريقة عرض الصفحات الأخرى. وإن احتجت صفحة تتوقف عن التوريث فأعطها قاموس Resources خاصاً بها أولاً

قائمة تحقق لكود نموذج كائنات PDF

تتعمم الدروس على أي نموذج كائنات PDF مبني على مسبَح وحاويات مؤشرات، في Delphi أو غيرها:

  • عند تجسيد خصائص موروثة وفق ISO 32000-1 §7.7.3.4، انسخ القيم المباشرة بعمق وأبقِ المراجع غير المباشرة مراجعَ جديدة إلى الكائن نفسه
  • لا تضف Add نسخة قائمة إلى حاوي ثانٍ إلا إن كانت المشاركة مقصودة وموثقة؛ فالملكية عبر مسبَح تعني أن وقت التشغيل لن يشتكي أبداً
  • حرر في المكان ما تملكه العقدة الحالية كائناً مباشراً فقط؛ واستبدل القيم غير المباشرة أو الموروثة بكائن مباشر جديد ‏(copy-on-write)
  • القيم الافتراضية المشتقة من مدخل آخر، مثل CropBox مشتق من MediaBox، تحتاج نسختها الخاصة
  • اختبر الاسمية بتسلسلات حوِّر-ثم-افحص على الحامل الآخر، وتحقق من ترتيب النداءات التي قد تكتب مشروعاً في المنتصف
  • مقارنة المخرج المحفوظ لا تثبت شيئاً هنا: القيم المشتركة والمنسوخة تسلسل بشكل متطابق حتى أول تعديل
  • على PDFlibPas، رقِّ إلى v3.539.36 أو أحدث إن كنت تستدعي MovePage أو CollateDocumentsEx أو BalancePageTree أو CopyPageRanges ثم تحرر صناديق الصفحات أو ترسم على الصفحات

يكشف PDFlibPas تحرير شجرة الصفحات والنسخ عبر المستندات والتحكم في صناديق الصفحات عبر صنف TPDFlib واحد لـ Delphi و C++Builder و Free Pascal. وانظر صفحة منتج PDFlibPas Delphi PDF library للإصدارات والمنصات ومرجع الـ API الكامل