مقال تقني

قياس نص PDF للتخطيط والتفاف الكلمات في Delphi

الاستدعاء الذي يضع النص على صفحة PDF يكون مباشراً. تُعطي AddText سلسلة نصية (string)، وخطاً (font)، وحجماً، وموضعاً، وتظهر الصور الرمزية (glyphs). ما لا تفعله هو إخبارك بمدى عرض تلك السلسلة بمجرد رسمها، ولا تكسر سلسلة طويلة عبر عدة أسطر. يرسم استدعاء واحد سلسلة من النص (run of text) في موضع واحد. إذا كان المسار (run) أعرض من العمود الذي قصدت أن يتناسب معه، فإنه يتجاوز الحافة ببساطة، ولا يوجد شيء في استدعاء الرسم يحذرك. في اللحظة التي تريد فيها فقرة بدلاً من تسمية واحدة، فإن القطعة المفقودة هي عرض سلسلة بخط وحجم مختارين، يتم قياسها قبل تثبيتها (commit) على الصفحة

هذه هي مشكلة التخطيط (layout) الكلاسيكية. لالتفاف فقرة (wrap a paragraph) في عمود، يجب أن تعرف، كلمة بكلمة، مقدار المساحة الأفقية التي سيشغلها كل سطر مرشح (candidate line)، ويجب أن تعرف ذلك قبل رسم أي شيء. التفاف الكلمات (Word wrap) عبارة عن حلقة قياس ملفوفة حول استدعاء رسم، والارتباط (binding) الذي لا يقوم إلا بالرسم يمنحك النصف الثاني فقط. يسد دعم قياس النص (text measurement) في مكون PDFium هذه الفجوة بدالتين، MeasureText و MeasureTextWidth، تبلغان عن المدى المعروض لسلسلة دون وضع علامة على أي صفحة

لماذا يعتبر القياس مساعد فئة (class helper)، وليس طريقة جديدة (method) في TPdf

يصل دعم القياس كمساعد فئة (class helper) Delphi لـ TPdf، يعيش في وحدة خاصة به (own unit)، بدلاً من كونه طرقاً (methods) جديدة مثبتة في فئة TPdf. مساعد الفئة هو ميزة في اللغة تتيح لك إرفاق طرق (methods) بنوع موجود من خارج إعلانه (declaration). بمجرد أن تكون الوحدة ضمن النطاق (in scope)، يتم استدعاء الطرق الجديدة تماماً كما لو كانت تنتمي إلى الفئة، بحيث تُقرأ طريقة المساعد على أنها Pdf.MeasureTextWidth(...) بدون وجود كائن منفصل لإنشائه أو تمريره

السبب وراء طبقات هذا الترتيب (layer it this way) هو الفصل (separation). يظل نوع TPdf الأساسي كما هو، دون إضافة أي حقل أو المساس بأي توقيع حالي (existing signature)، لذلك فإن المشروع الذي لا يحتاج أبداً إلى تخطيط لا يحمل أبداً كود القياس. المشروع الذي يحتاج إليه يضيف وحدة واحدة إلى جملة uses وتضيء (light up) الطرق. تصبح الإمكانية اختيارية (opt-in) على مستوى تفصيل (granularity) وحدة واحدة، وهي أنظف طريقة لتوسيع نوع لا تملكه أو لا ترغب في إزعاجه

uses
  PDFium, FPdfView, FPdfEdit,
  FPdfMeasure;   // the helper unit; brings MeasureText into scope on TPdf

// With the unit in scope the methods read as members of TPdf:
var
  W, H: Double;
begin
  Pdf.MeasureText('Subtotal', 'Helvetica', 11, W, H);
  // W and H are now the rendered width and height in PDF user units
end;

القياس دون لمس الصفحة

يجب أن يكون القياس خالياً من الآثار الجانبية (side effects). يجب أن يبلغ عن العرض دون أن يترك أي شيء خلفه، لأنك تستدعيه عدة مرات أثناء اتخاذ قرار بشأن التخطيط ويجب أن تبدو الصفحة تماماً كما لو لم تقم بالقياس على الإطلاق. التقنية التي تجعل ذلك ممكناً هي بناء كائن نصي (text object)، وسؤاله عن حجمه، والتخلص منه قبل أن يتم إرفاقه بصفحة

التسلسل عبارة عن أربعة استدعاءات من PDFium. تنشئ FPDFPageObj_NewTextObj كائناً نصياً مقابل المستند، معطى اسم الخط وحجمه. تحدد FPDFText_SetText السلسلة التي يحملها هذا الكائن. تقرأ FPDFPageObj_GetBounds المربع المحيط (bounding box) للكائن. تقوم FPDFPageObj_Destroy بتحرير (frees) الكائن. وبشكل حاسم، لا يستدعي أي شيء في هذا التسلسل واجهة برمجة تطبيقات (API) إدراج الصفحة (page-insertion). يتم إنشاء الكائن، والاستعلام عنه، وتدميره في عزلة، بحيث لا يتغير المستند عندما تعود الدالة (function returns). إنه مسبار للاستخدام مرة واحدة (throwaway probe) ومخرجاته الوحيدة هي الأرقام الأربعة للمربع المحيط به

هذه هي الطريقة القوية (robust way) للقيام بذلك لأن PDFium لا يعرض عرض تقدم مريح لكل صورة رمزية (per-glyph advance width) يمكنك جمعه بنفسك. تعتمد مقاييس الصور الرمزية (Glyph metrics) على برنامج الخط، وعلى التشفير (encoding)، وعلى كيفية تحميل PDFium للوجه (face)، ولا يوجد استدعاء عام يسلمك تقدم كل حرف في السلسلة. من ناحية أخرى، يتم حساب المربع المحيط لكائن نصي حقيقي بواسطة نفس الآلية التي من شأنها تخطيط الصور الرمزية للرسم، بحيث يعكس المدى المعروض الفعلي (actual rendered extent) بدلاً من التقريب (approximation). إن بناء كائن واحد يمكن التخلص منه وقراءة حدوده هو القياس الأكثر موثوقية الذي يمكن للمكتبة تقديمه

// The shape of MeasureText, expressed against the verified PDFium calls.
// A text object is built, measured, and destroyed; no page is involved.
procedure TPdfMeasureHelper.MeasureText(const Text, Font: WString;
  FontSize: Single; out Width, Height: Double);
var
  TextObject: FPDF_PAGEOBJECT;
  L, B, R, T: Single;
begin
  Width  := 0;
  Height := 0;
  if Self.Document = nil then
    Exit;
  TextObject := FPDFPageObj_NewTextObj(Self.Document,
    FPDF_BYTESTRING(AnsiString(Font)), FontSize);
  if TextObject = nil then
    Exit;
  try
    if FPDFText_SetText(TextObject, FPDF_WIDESTRING(WideString(Text))) = 0 then
      Exit;
    if FPDFPageObj_GetBounds(TextObject, L, B, R, T) <> 0 then
    begin
      Width  := R - L;
      Height := T - B;
    end;
  finally
    FPDFPageObj_Destroy(TextObject);   // probe discarded, page untouched
  end;
end;

إحداثيات ووحدات النتيجة

يعود المربع المحيط كأربعة حواف، يسار (left)، وأسفل (bottom)، ويمين (right)، وأعلى (top)، ويتم الحصول على البعدين عن طريق الطرح. العرض (Width) هو اليمين ناقص اليسار والارتفاع (height) هو الأعلى ناقص الأسفل. يتم التعبير عن كلاهما بوحدات مستخدم (user units) PDF، حيث تعادل الوحدة الواحدة جزءاً من اثنين وسبعين من البوصة، وهي نفس مساحة الإحداثيات (coordinate space) التي تضع فيها النص على الصفحة. لا توجد وحدة جهاز (device unit) مخفية ولا بكسل متضمن في هذه المرحلة. العرض 36 يعني نصف بوصة من الصفحة، أياً كانت دقة العرض (rendering resolution) النهائية

يمتد المحور الرأسي (vertical axis) بالطريقة التي يحددها PDF، حيث يزيد Y صعوداً، وهذا هو السبب في أن الارتفاع هو الأعلى ناقص الأسفل بدلاً من العكس. تهم هذه التفصيلة عندما تقوم بتقديم مؤشر (cursor) لأسفل في عمود. يمكنك قياس ارتفاع السطر، ثم طرحه من خط الأساس (baseline) الحالي للعثور على الخط التالي، لأن التحرك لأسفل الصفحة يعني التحرك نحو Y أصغر. إذا كانت وجهتك شاشة (screen) وليس ورقاً، يمكنك تحويل وحدات المستخدم إلى بكسل جهاز باستخدام دقة العرض (display resolution): القيمة بوحدات المستخدم مضروبة في DPI ومقسومة على 72 تعطي البكسل، لذلك يمكن مطابقة عرض العمود الذي حددته بالنقاط مع السلسلة المقاسة (measured run) قبل أن تقرر أين يذهب الفاصل (break)

ماذا يحدث مع المدخلات التالفة (degenerate input)

مكتوبة الدوال للفشل بهدوء (fail quietly). إذا لم يكن هناك مستند مفتوح، أو إذا تعذر إنشاء الكائن النصي، تكون النتيجة نطاقاً صفرياً (zero extent) بدلاً من استثناء مثار (raised exception). يتم تهيئة العرض والارتفاع إلى الصفر في الأعلى ويتم استبدالهما فقط بمجرد قراءة المربع المحيط بنجاح. سلسلة نصية فارغة، أو مستند مفقود، أو خط لا تستطيع المكتبة تحويله إلى كائن، كل هذه الحالات تُرجع صفراً بدلاً من إثارة استثناء

هذا الخيار يجعل حلقة القياس بسيطة، لأن الحلقة التي تعمل على آلاف الكلمات ليست المكان المناسب لمعالجة الاستثناءات في كل تكرار (iteration). التكلفة هي أن المتصل (caller) يحمل الفحص. العرض الصفري هو حارس (sentinel)، وليس حقيقة حول النص، لذلك فإن الكود الذي يقسم على عرض مقاس أو يفترض قيمة إيجابية يجب أن يحترس من الصفر قبل الوثوق به. تعامل مع الصفر على أنه "لم يتمكن من القياس" وسيكون العقد واضحاً؛ تجاهله وستصبح المدخلات التالفة بهدوء تخطيطاً بعمود من الصور الرمزية المتداخلة (overlapping glyphs)

التفاف كلمات جشع مبني على القياس (greedy word wrap)

مع وجود دالة العرض في متناول اليد، يصبح التفاف الكلمات عبارة عن حلقة جشعة (greedy loop) قصيرة. يمكنك تقسيم الفقرة إلى كلمات، والاحتفاظ بسطر حالي، ولكل كلمة تقيس ما سيكون عليه السطر إذا قمت بإلحاق (appended) تلك الكلمة. طالما أن السطر التجريبي (trial line) لا يزال يتناسب مع عرض العمود، فإنك تستمر في الإضافة؛ عندما يفيض (overflow) تقوم بمسح (flush) السطر الحالي باستخدام AddText وتبدأ سطراً جديداً بالكلمة التي لم تتسع. يتم التراكم بالكامل باستخدام MeasureTextWidth، والشيء الوحيد الذي يصل إلى الصفحة على الإطلاق هو السطر الذي أكدت بالفعل أنه مناسب

procedure WrapParagraph(Pdf: TPdf; const Para, Font: WString;
  FontSize: Single; X, TopY, ColumnWidth, LineHeight: Double);
var
  Words: TArray<WideString>;
  Line, Trial: WideString;
  I: Integer;
  Y: Double;
begin
  Words := WideString(Para).Split([' ']);
  Line  := '';
  Y     := TopY;
  for I := 0 to High(Words) do
  begin
    if Line = '' then
      Trial := Words[I]
    else
      Trial := Line + ' ' + Words[I];
    // Measure the candidate line before drawing anything.
    if (Line <> '') and (Pdf.MeasureTextWidth(Trial, Font, FontSize) > ColumnWidth) then
    begin
      Pdf.AddText(X, Y, Font, FontSize, Line);   // flush the line that fit
      Y    := Y - LineHeight;                    // Y decreases going down
      Line := Words[I];                          // overflowing word starts next line
    end
    else
      Line := Trial;
  end;
  if Line <> '' then
    Pdf.AddText(X, Y, Font, FontSize, Line);      // flush the final line
end;

تقيس الحلقة السطر التجريبي بدلاً من قياس كل كلمة وجمعها، لأن عرض السطر ليس مجموع عروض كلماته. تساهم المسافات بين الكلمات في ذلك، والسلسلة المقاسة (measured run) تلتقط ذلك مباشرة. القاعدة الجشعة (greedy rule)، التي تلائم أكبر عدد ممكن من الكلمات كما يسمح به العمود وتكسر عند آخر كلمة تناسب، هي نفس القاعدة التي تملأ الفجوة بين AddText الخام وفقرة حقيقية. لم يكن استدعاء الرسم هو الجزء الصعب أبداً. القياس الذي يجب أن يسبقه هو الجزء الصعب، وهذا هو بالضبط ما يوفره المساعد (helper)

أين يتناسب هذا

القياس هو الطبقة بين إنشاء المحتوى (generating content) وتقديمه (rendering)، لذلك فهو يقترن بشكل طبيعي ببقية سير عمل مستند من الصفر (from-scratch). إذا كنت تقوم بتجميع الصفحات ووضع النص في المقام الأول، فإن الأساس (groundwork) موجود في إنشاء مستندات PDF من الصفر باستخدام مكون PDFium VCL في Delphi، حيث تتم تغطية AddText وإعداد الصفحة بالكامل. عندما يهم الخط الذي تقيسه بقدر ما تهم السلسلة، لأن المقاييس (metrics) تعتمد على الوجه (face)، فإن تحليل خصائص خط PDF باستخدام مكون PDFium VCL في Delphi يوضح كيف تُبلغ المكتبة عن معلومات الخط التي تدفع تلك المربعات المحيطة (bounding boxes). يعتمد كلاهما على نفس الارتباط (binding)، وهو PDFium Component لـ Delphi و Lazarus، حيث يشحن مساعد القياس (measurement helper) إلى جانب واجهات برمجة تطبيقات المستند، والصفحة، والنص (text APIs) الموصوفة عبر هذه المدونة