يسطّح HotPDF v2.743.0 تعليقات PDF التي لا تحمل تدفق مظهر /AP بدل تخطيها بصمت. إذ تمرر FlattenLoadedAnnotations عنصر widget الذي لا يملك مظهرًا عبر EnsureLoadedFieldAppearanceStream، وتبني لـmarkup الذي لا يملك مظهرًا Form XObject من خصائص التعليق نفسه، ولذلك تبقى القيم المكتوبة في نموذج /NeedAppearances داخل محتوى الصفحة بدل أن تختفي وقت التسطيح. ويبدو الفشل الذي فرض هذا التغيير كأنه عدم فعل شيء. يرسل عميل نموذج طلب مكتملًا، طُبع إلى PDF من متصفح. تحمله في HotPDF، وتستدعي FlattenLoadedAnnotations، وتحصل على 0، ثم تحفظ مستندًا بصناديق فارغة حيث كتب مقدم الطلب اسمًا ومبلغًا. لم يُرفع شيء ولم يُسجل شيء. كانت القيم في الملف طوال الوقت، جالسة في إدخال /V لكل حقل، لكن تمريرة التسطيح تجاوزتها مباشرة لأن أياً من عناصر widget تلك لم يحمل تدفق مظهر يمكن خبزه
لماذا يفقد تسطيح نموذج مطبوع من متصفح القيم المكتوبة؟
لأن نموذج /NeedAppearances يخزن القيمة من دون تخزين صورة لها. تسمح ISO 32000-1 12.7.2 للنموذج التفاعلي بضبط /NeedAppearances true في قاموس AcroForm، وهو ما يخبر العارض ببناء السطح المرئي لكل حقل وقت الفتح من /V و/DA و/Q. ويستفيد المنتجون الذين ينشئون النماذج بتكلفة منخفضة — مسارات الطباعة من المتصفح، وملء النماذج من جانب الخادم، وبعض واجهات المسح — من هذا العرض ولا يكتبون /AP أصلًا. أما التسطيح، كما تعرفه خوارزمية المظهر في ISO 32000-1 12.5.5، فهو مهمة نسخ: خذ تدفق المظهر العادي للتعليق، واربط /BBox الخاص به بـ/Rect، واستدعِه من تدفق محتوى الصفحة بمعامل Do، ثم احذف التعليق. ومن دون تدفق مصدر لا يوجد ما يُنسخ. وقد عامل تنفيذ HotPDF الأصلي منذ v2.386.0 ذلك كحالة «تخطّ»، وهو أمر يمكن الدفاع عنه منفردًا وكارثي في المحصلة: فالمستندات الأرجح احتياجًا إلى التسطيح هي الأقل احتمالًا لحمل مظاهر. وقد ابتلعت الفجوة نفسها markup — من Highlight في أداة مراجعة، وSquare في تمريرة redline، وتوقيع Ink — كلما اعتمد المنتج على العارض لرسمها
أين تربط HotPDF التوليد داخل FlattenLoadedAnnotations؟
نقطة الربط متأخرة عمدًا: بعد فشل البحث عن المظهر لا قبله. فلا تزال FlattenLoadedAnnotations تطلب المظهر العادي أولًا عبر GetLoadedAnnotationAppearanceStream، ويُخبز التعليق الذي يملك واحدًا بالطريقة نفسها تمامًا كما في v2.386.0. ولا يدخل مسار التوليد إلا نتيجة nil، وعلى تعليق يملك /Rect غير متدهور ولا يحمل علم الإخفاء. وهذا الترتيب مهم: فمؤلف المستند الذي بذل جهدًا لكتابة /AP يستعيد بايتاته هو، لا إعادة بناء HotPDF لها
NStrm:= GetLoadedAnnotationAppearanceStream(Indices[PgI], AnI, aakNormal);
if (NStrm= nil) and (RR> RL) and (RT> RB) and ((FlagsValue and 2)= 0) then
begin
if Subtype= 'Widget' then
begin
FieldIdx:= GetLoadedFormFieldIndexForAnnotation(Indices[PgI], AnI, WidgetIdx);
if FieldIdx>= 0 then
EnsureLoadedFieldAppearanceStream(FieldIdx);
// اسأل من جديد: أرفق المولّد /AP /N بالـwidget
NStrm:= GetLoadedAnnotationAppearanceStream(Indices[PgI], AnI, aakNormal);
end
else
NStrm:= SynthesizeMarkupAppearance(AnnotDict, Subtype, RL, RB, RR, RT);
end;
ومن هنا تنفصل عائلتا التعليقات. يُعاد حل widget إلى الحقل المالِك عبر GetLoadedFormFieldIndexForAnnotation، ثم يُمرر إلى EnsureLoadedFieldAppearanceStream، مولّد مظهر الحقل الموجود في مكتبة PDF هذه لـDelphi منذ v2.328.0. وإعادة استخدامه بدل كتابة مصيّر حقول ثانٍ هي الفكرة كلها، لأنه يغطي بالفعل خطوط Type0 والتفاف السطر والمحاذاة وقيم /AS لمربعات الاختيار وأزرار الراديو ودوران /MK، وهي الآلية نفسها وراء إضافة حقول AcroForm إلى PDF محمل أصلًا. وكل ما عدا ذلك يذهب إلى مولّد markup. ولا يتغير شيء لدى المستدعي: فاستدعاء التسطيح ذي السطر الواحد نفسه يعيد الآن عددًا غير صفري في المستندات التي كانت تعيد صفرًا
Doc:= THotPDF.Create(nil);
try
Doc.LoadFromFile('needappearances-form.pdf');
// v2.743.0: تُولّد مظاهر widget وmarkup التي بلا AP ثم تُخبز
Flattened:= Doc.FlattenLoadedAnnotations; // كل الصفحات وكل الأنواع الفرعية
// Flattened:= Doc.FlattenLoadedAnnotations('1-3', 'Highlight');
if Flattened= 0 then
raise Exception.Create('nothing was flattened');
Doc.SaveLoadedDocument('flattened.pdf');
finally
Doc.Free;
end;
لماذا تصل QuadPoints وInkList إلى موضع خاطئ؟
لأن هذه الإحداثيات في مساحة مستخدم الصفحة، بينما يرسم تدفق المظهر المولّد في مساحة /BBox الخاصة به، والأصلان ليسا النقطة نفسها. يحدد الجدول 176 من ISO 32000-1 /QuadPoints لتعليقات markup النصية في مساحة المستخدم الافتراضية، ويفعل الجدول 174 الشيء نفسه لنهايتي /L في تعليق الخط؛ كما يتبع /InkList الاصطلاح نفسه. ويمنح HotPDF النموذج المولّد /BBox بقيمة [0 0 W H]، ويضع أصله عند الزاوية السفلية اليسرى من /Rect. لذلك يجب نقل كل نقطة مستخرجة من /QuadPoints أو /L أو /InkList بطرح الإحداثي السفلي الأيسر لـ/Rect قبل كتابتها إلى تدفق المحتوى. أخطئ في ذلك، وسيرسم Highlight على سطر يبعد 700 نقطة أعلى الصفحة على ارتفاع 700 نقطة فوق صندوقه، ما يعني عمليًا أنه لن يرسم في أي مكان. التصحيح عملية طرح واحدة لكل إحداثي، ويتآلف مع cm الذي يصدره الخَبز بعد ذلك — فهذه المصفوفة تعيد /BBox إلى /Rect، ولذلك تلغي الخطوتان إحداهما الأخرى لتحصلا على هندسة مطلقة صحيحة
// تقع نهايات /L في مساحة مستخدم الصفحة (ISO 32000-1 Table 174)؛ ويقع
// أصل BBox عند الزاوية السفلية اليسرى لـ/Rect، لذلك نزيحه بمقدار -(RL, RB)
X1:= ArrNum(LA, 0, 0)- RL;
Y1:= ArrNum(LA, 1, 0)- RB;
X2:= ArrNum(LA, 2, 0)- RL;
Y2:= ArrNum(LA, 3, 0)- RB;
StrokeOp:= ColorOp(DArr('C'), true);
if StrokeOp= '' then
StrokeOp:= '0 G';
Result:= _FloatToStrR(BW)+ ' w '#10+ StrokeOp+ #10+
_FloatToStrR(X1)+ ' '+ _FloatToStrR(Y1)+ ' m '+
_FloatToStrR(X2)+ ' '+ _FloatToStrR(Y2)+ ' l S'#10;
ما الذي يرسمه مظهر markup المولّد فعلًا؟
يقرأ مولّد markup قاموس التعليق ولا شيء آخر، وهو ما يبقي الناتج قابلًا للتنبؤ وصريحًا بشأن ما لا يستطيع معرفته. يرسم FreeText وStamp قيمة /Contents باستخدام الخط واللون المستخرجين من /DA، بمحاذاة يحددها /Q وحشو مقداره 2 pt. ويرسم Square وCircle مخطط re أو مخطط Bezier من أربعة أقواس، مع تخطيط stroke بلون /C وملء بـ/IC عند وجوده وبالعرض المحدد من /BS /W. ويخط Line وInk رؤوسهما. ويملأ Highlight كل رباعي، بينما يخط Underline وStrikeOut وSquiggly قاعدة عند أسفل الرباعي أو منتصفه أو على شكل zigzag بطول نقطة واحدة. وتحول قيمة /CA الأقل من 1 إلى ExtGState بإدخال ca، ويُشار إليها عند رأس التدفق بصيغة /GSA gs
ويُحسم ترميز النص من إدخال /DR /Font في AcroForm الذي يسميه /DA. فإذا كان /Subtype لذلك الخط هو Type0، تكتب HotPDF السلسلة كـliteral سداسي UTF-16BE مع علامة ترتيب البايتات FEFF؛ وإلا فتكتب literal نصيًا مهربًا، مع تهريب الأقواس والشرطات المائلة العكسية، وكتابة البايتات فوق 126 بالصيغة الثمانية. ويصدر معامل Tf من /DA قبل BT، وهو قانوني لأن حالة النص تستمر عبر حد كائن النص، كما أنه يجنب تفكيك سلسلة /DA. وهناك حدان يستحقان ذكرهما بوضوح. إذ يُقدر عرض السطر للالتفاف والمحاذاة باستدلال نصف em وكامل em بدل مقاييس الخط الحقيقية، ولذلك تكون المحاذاة في خط متناسب قريبة لا مطابقة. كما أن النوع الفرعي الذي لا يمكن توليد شيء له — Popup أو Link أو Stamp لا يحمل إلا اسم أيقونة — يعيد nil ويُترك من دون مساس، كما كان من قبل
تبديل /Annots المؤقت الذي يعاقب التنظيف المفيد
يمثل FlattenOneWidget، وهو المسار الخاص بكل widget الذي تستخدمه FlattenLoadedFormFields، فخ aliasing يجب أن يحترمه أي تغيير داخل حلقة التسطيح المشتركة. فهو يستبدل مؤقتًا قيمة /Annots في الصفحة بمصفوفة من عنصر واحد حتى تعمل تمريرة التسطيح العامة على widget واحد، ثم يعيد مؤشر PHPDFDictionaryItem الأصلي في كتلة finally. وتكتب عملية الاستعادة القيمة مجددًا في خانة القاموس التي التقطتها قبل الاستدعاء
DictItem:= PHPDFDictionaryItem(PageObj.Items.Items[AnnotsIndex]);
Item:= DictItem^.Value;
TemporaryAnnots:= THPDFArrayObject.Create(nil);
TemporaryAnnots.AddObject(Target);
DictItem^.Value:= TemporaryAnnots;
try
Result:= FlattenLoadedAnnotations(IntToStr(PageIndex+ 1), 'Widget')= 1;
finally
DictItem^.Value:= Item; // يصبح معلّقًا إذا حررت الحلقة الداخلية هذا العنصر
TemporaryAnnots.Free;
end;
أضف تنظيفًا يبدو معقولًا داخل الحلقة الداخلية المشتركة — مثل DeleteValue('Annots') بمجرد فراغ المصفوفة، حتى لا تحمل الصفحة المحفوظة مصفوفة فارغة بلا فائدة — وسيفرغ ذلك الاستدعاء عنصر القاموس نفسه الذي يشير إليه DictItem. ثم تكتب كتلة finally عبر مؤشر معلّق وتموت العملية برسالة «Invalid pointer operation». وقد التقط اختباران موجودان ذلك فورًا، وهو السبب الوحيد في بقاء الأمر كحاشية لا كتذكرة دعم. والقاعدة عامة: قبل إضافة تنظيف إلى حلقة مشتركة، افحص المستدعين بحثًا عن عقود alias أو swap. فمصفوفة /Annots الفارغة المتبقية عيب تجميلي، ولا تستحق مقايضة ضمان عمر المؤشر بها
ما الذي يبقى بلا خبز، وما كلفة التسطيح؟
تُستبعد التعليقات المخفية عن قصد. فالتعليق الذي يملك العدد الصحيح /F مع ضبط موضع البت 2 يكون مخفيًا وفق ISO 32000-1 12.5.3، وعندما لا يملك أيضًا /AP يوجد إغراء حقيقي لتوليد مظهر له وخبزه مثل البقية. لكن ذلك سيكون خطأ ذا عواقب أمنية: إذ يجعل خبز ملاحظة غير مرئية في محتوى الصفحة مرئية لكل من يفتح الملف. وتترك HotPDF تلك التعليقات في مكانها تمامًا ولا تعدّها في قيمة الإرجاع. وكن واضحًا بالقدر نفسه مع المستخدمين بشأن ثمن ما يُخبز. فالتسطيح غير قابل للعكس — إذ يُحذف التعليق من مصفوفة الصفحة /Annots ويصبح مظهره الآن محتوى صفحة، فلا يعود هناك تعديل لقيمة الحقل، ولا سلسلة تعليقات، ولا تبديل لحالة /AS، ولا طريقة لاستعادة البيانات المهيكلة إلا من الملف الأصلي. سطّح نسخة واحتفظ بالأصل، ولا تلجأ إليها إلا عندما يتوقف المستند عن كونه نموذجًا ويصبح سجلًا. وإذا كانت مشكلتك مدفوعة بـXFA لا بغياب المظهر، فمسار تسطيح XFA إلى AcroForm في HotPDF هو نقطة البداية، وإذا كنت لا تزال تبني النموذج، فتغطي ملاحظات توصيل إجراءات حقول AcroForm والتحقق منها جانب الكتابة
هناك تحفظ تحقق واحد، لأن تجاهله سيكلفك فترة بعد الظهر. لا ينزل ExtractLoadedPageGlyphs إلى Form XObjects، بينما يعيش المظهر المخبوز داخل واحد منها — إذ لا يحمل تدفق محتوى الصفحة إلا التسلسل q ... cm /FlatAn<n> Do Q. ولذلك يعيد استخراج glyphs من صفحة مسطحة لا شيء، وهذا سلوك صحيح لا خبز ضائع. تحقق إما على مستوى البايت، بالبحث عن اسم المورد /FlatAn واستدعاء Do و/Subtype /Form، أو عبر مسار التصيير الذي يوسع XObjects
يبدو تسطيح التعليقات كأنه ثلاثة أسطر من النسخ إلى أن تواجه المستندات التي ينشئها الناس فعلًا. وإذا كنت تعمل مع نماذج مكتملة أو markup للمراجعة أو مخرجات أرشفة في Delphi أو C++Builder، فمن المفيد قراءة كيفية تعامل مكوّن HotPDF PDF لـDelphi مع جانب المستندات المحملة من AcroForms والتعليقات قبل أن تبني مولّد مظهر خاصًا بك فوقه