مقال تقني

ربط نص PDF ببايتات تدفق المحتوى في Delphi

قد يؤدي محرف واحد خاطئ في رقم فاتورة إلى أن تعيد أداة التعديل الوحيدة المتاحة كتابة سطر النص كاملًا. وتسُد PDF Library for Delphi هذه الفجوة: إذ تربط GetTextBlockCharContentLocation كل موضع مستخرج بترميز UTF-16 مرة أخرى بتعليمة تدفق المحتوى والمعامل ونطاق البايتات المشفرة التي أنتجته، بينما تستبدل ReplaceTextBlockCharSourceBytes ذلك النطاق فقط. وعادةً ما يهدر استخراج النص كل ما تحتاجه لهذا الغرض. فتحصل على Unicode والعروض والهندسة، ثم يختفي مصدر المحرف، فيصبح المحرف الموجود في الموضع 7 من الكتلة 3 مجرد محرف. أي تدفق أنتجه، وأي تعليمة، وأي معامل، وأي بايت داخل ذلك المعامل: كل ذلك يختفي. وكل استراتيجية تعديل نقطي مبنية فوق هذه البيانات تضطر إلى التخمين، غالبًا بالبحث عن سلسلة فرعية في المحتوى المفكوك والأمل في ظهورها مرة واحدة بالضبط. لكنها لا تظهر كذلك في صفحة حقيقية

لماذا تؤدي إعادة كتابة سطر النص كاملًا إلى إفساد الصفحة؟

لأن السطر ليس نصًا فقط. تتضمن معاملات إظهار النص في ISO 32000-1 §9.4.3 المعامل TJ الذي يكون معامله مصفوفة تداخل السلاسل مع تعديلات رقمية، وهذه الأرقام هي فن تنضيد النص. فسطر منسق بصيغة [(AB) -120 (CD)] TJ يحمل تقاربًا مقداره 120 من ألف em بين السلسلتين. وإذا أصدرت Tj جديدة بالنص المضموم، يختفي التقارب، وينساب السطر بمقدار ضئيل، وفي نموذج قد تخرج القيمة من صندوقها. وينطبق الاعتراض نفسه على الخط: فبايتات المعامل رموز بالترميز الذي اختاره Tf، وليست Unicode، وقد تكون مع خط مركب CIDs ذات بايتين لا علاقة لها بالمحرف الذي استخرجه القارئ. وإذا أعدت توليد السطر فعليك أن تكون مصيبًا بشأن ترميز الخط وخريطة /ToUnicode وتغطية glyphs. أما التعديل النقطي فيتجاوز كل ذلك لأنه لا يغادر نطاق البايتات أصلًا

ماذا يعيد GetTextBlockCharContentLocation؟

تحل الطريقة محرفًا واحدًا إلى سجل من تسعة حقول، وكل حقل فيه عنوان لا قيمة. فـContentLayer هو الفهرس الذي يبدأ من 1 في مصفوفة الصفحة /Contents، أو 0 عندما يأتي المحرف من محتوى متداخل. ويحدد StreamObjectNumber وStreamGeneration التدفق المحتوي. ويمثل InstructionIndex موضعًا يبدأ من 0 في برنامج المحتوى المفكوك، بينما يمثل OperandIndex معامل سلسلة النص، ويمثل ArrayElementIndex العنصر داخل مصفوفة TJ أو القيمة -1 عندما يكون المعامل سلسلة مباشرة. ثم يحدد SourceByteOffset وSourceByteLength نطاق البايت داخل تلك السلسلة المفكوكة

Var
  Lib: TPDFlib;
  ListID, Block, CharPos: Integer;
  ContentLayer, StreamObjectNumber, StreamGeneration: Integer;
  InstructionIndex, OperandIndex, ArrayElementIndex: Integer;
  SourceByteOffset, SourceByteLength, Flags: Integer;
Begin
  Lib:= TPDFlib.Create;
  Try
    Lib.LoadFromFile('invoice.pdf', '');
    Lib.SelectPage(1);
    ListID:= Lib.ExtractPageTextBlocks(3);
    Try
      // يأتي Block وCharPos من فحصك الخاص لنص GetTextBlockText
      If Lib.GetTextBlockCharContentLocation(ListID, Block, CharPos,
        ContentLayer, StreamObjectNumber, StreamGeneration,
        InstructionIndex, OperandIndex, ArrayElementIndex,
        SourceByteOffset, SourceByteLength, Flags)= 1 Then
      Begin
        // ContentLayer = 0 يعني أن glyph موجود في Form XObject متداخل
        // ArrayElementIndex = -1 يعني معامل Tj عاديًا لا مصفوفة TJ
      End;
    Finally
      Lib.ReleaseTextBlocks(ListID);
    End;
  Finally
    Lib.Free;
  End;
End;

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

تعديل البايتات لا Unicode

تأخذ ReplaceTextBlockCharSourceBytes قيمة AnsiString من بايتات الاستبدال الخام بترميز خط PDF النشط. هذه هي الفكرة كلها، وهي مقصودة. فلا يحدث تحويل ترميز، ولا إعادة ترميز، ولا تخمين بشأن الخط. إذ تصل المكتبة بايتاتك فوق النطاق المسمى من السلسلة المستهدفة، ثم تعيد إصدار طبقة المحتوى التي تحتويها. وتبقى السلاسل المجاورة في مصفوفة TJ نفسها والأرقام التي تفصل بينها مطابقة للبايتات الأصلية. خذ التخطيط السابق: سيؤدي تحديد B في [(AB) -120 (CD)] TJ إلى ArrayElementIndex بقيمة 0، وSourceByteOffset بقيمة 1، وSourceByteLength بقيمة 1. واستبدله بـZ، فيحتوي المحتوى الناتج على (AZ) يليه -120 و(CD) كما هما من دون مساس. وتثبت مجموعة التراجع ذلك بالضبط، لأن عبارة «حافظنا على التقارب» من النوع الذي يتوقف عن كونه صحيحًا بصمت

Function EditableHere(Flags: Integer): Boolean;
Begin
  Result:= ((Flags and PDF_TEXT_CHAR_CONTENT_LOCATION_VALID)<> 0)and
    ((Flags and (PDF_TEXT_CHAR_CONTENT_LOCATION_GENERATED or
      PDF_TEXT_CHAR_CONTENT_LOCATION_ACTUALTEXT or
      PDF_TEXT_CHAR_CONTENT_LOCATION_NESTED or
      PDF_TEXT_CHAR_CONTENT_LOCATION_CROSS_LAYER or
      PDF_TEXT_CHAR_CONTENT_LOCATION_TRANSCODED))= 0);
End;

// ...
If EditableHere(Flags) Then
Begin
  If Lib.ReplaceTextBlockCharSourceBytes(ListID, Block, CharPos, 'Z')= 1 Then
  Begin
    // أصبحت كل المواقع في القائمة القديمة غير صالحة الآن. أعد الاستخراج.
    Lib.ReleaseTextBlocks(ListID);
    ListID:= Lib.ExtractPageTextBlocks(3);
  End
  Else If Lib.LastErrorCode= PDFLIB_ERROR_TEXT_LOCATION_STALE Then
    // تغيرت الطبقة منذ الاستخراج
  Else If Lib.LastErrorCode= PDFLIB_ERROR_TEXT_LOCATION_READ_ONLY Then
    // علم لم نتحقق منه، أو علم أضافه إصدار لاحق
End;

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

ما المحارف التي لا يمكن تعديلها؟

ست فئات، وتسمي المكتبة كل فئة منها في قناع البتات Flags بدل الفشل بصورة مبهمة. وهذا أهم من مسار النجاح، لأن الحالات غير القابلة للربط شائعة في المستندات الحقيقية ولكل منها سبب مختلف

  • PDF_TEXT_CHAR_CONTENT_LOCATION_LIGATURE: تتوسع عدة مواضع مستخرجة بترميز UTF-16 من glyph مصدر واحد. فإذا ربط إدخال /ToUnicode رمزًا واحدًا بـfi، فسيشترك المحرفان في نطاق بايت واحد، ولذلك عاملهما كـglyph مصدر واحد وعدّل النطاق مرة واحدة
  • PDF_TEXT_CHAR_CONTENT_LOCATION_GENERATED: جرى تركيب المحرف أثناء التخطيط. والمسافات المستنتجة بين الكلمات هي الحالة المعتادة، ولا تملك بايتات مصدر أصلًا، ولذلك يعود SourceByteOffset بالقيمة -1 ويعود SourceByteLength بالقيمة 0
  • PDF_TEXT_CHAR_CONTENT_LOCATION_ACTUALTEXT: جاء النص الذي قرأته من استبدال /ActualText. ولا يوجد ربط عكسي فريد من السلسلة المستبدلة إلى بايتات المصدر، ولذلك يكون الموقع تشخيصيًا فقط
  • PDF_TEXT_CHAR_CONTENT_LOCATION_NESTED: يقع glyph داخل Form XObject. ويمكن عنونة البايتات، لكن قد ترسم عدة صفحات الـForm، ولذلك سيكون تعديله عبر API عالية المستوى تعديلًا لم تطلبه
  • PDF_TEXT_CHAR_CONTENT_LOCATION_TRANSCODED: كان المعامل سلسلة سداسية تحمل علامة ترتيب بايتات UTF-16BE، وقد فك مسار الاستخراج الحالي ترميزها قبل ربط الخط. ولم تعد الإزاحات داخل النتيجة المفكوكة تشير إلى البايتات الأصلية، ولذلك يُمسح العلم الصالح
  • PDF_TEXT_CHAR_CONTENT_LOCATION_CROSS_LAYER: تقع سلسلة المعامل ومعامل إظهار النص الخاص بها في تدفقين مختلفين

وتستحق الفئة الأخيرة جملة مستقلة، لأن المهندسين يفترضون بصورة متكررة أنها لا يمكن أن تحدث. تنص ISO 32000-1 §7.8.2 على أن التدفقات في مصفوفة الصفحة /Contents تُضم إلى بعضها، ولا يُشترط أن يقع الفصل بينها إلا عند حد لغوي. لذلك فإن وجود BT /F1 16 Tf 220 340 Td (CrossLayer) في تدفق وTj ET في التدفق التالي صفحة قانونية تمامًا. وتحافظ الخريطة على الموضع التشخيصي لكنها تضع عليه علامة القراءة فقط، لأن فهرس تعليمة المعامل ينتمي إلى طبقة مختلفة عن بايتات السلسلة، واستخدام أحدهما لعنونة الآخر قد يفسد الملف

كيف تعرف المكتبة أن الخريطة لا تزال صالحة؟

بصمات، تُفحص فورًا قبل الكتابة. تسجل كل قائمة استخراج صفحة المصدر، إضافة إلى طول كل طبقة محتوى وتجزيئتين متدحرجتين مستقلتين: تجزئة FNV-1a وتجزيئة بأسلوب XOR شبيه بـDJB2. وقبل أن تحلل ReplaceTextBlockCharSourceBytes أي شيء، تعيد قراءة الطبقة المستهدفة وتقارن القيم الثلاث كلها. ويؤدي أي تغير في أي بايت داخل تلك الطبقة إلى إعادة PDFLIB_ERROR_TEXT_LOCATION_STALE من دون تنفيذ الكتابة. وهذا تحفظ مقصود: فالفحص على مستوى الطبقة لا التعليمة، ولذلك يبطل تعديل غير مرتبط في مكان آخر من تدفق المحتوى موقعك أيضًا. وهذه مفاضلة صحيحة، لأن الإزاحة داخل تدفق تحرك بايتًا واحدًا فقط ليست خطأً قريبًا، بل فساد صامت. ويحكم الانضباط نفسه بقية سطح التعديل، بما فيها متتبع حالة تدفق المحتوى لـCTM والقص. وبعد أي استبدال ناجح، تخلّص من القائمة واستخرجها من جديد

الربط للقراءة فقط عبر Direct Access

يعطيك DAGetTextBlockCharContentLocation السجل نفسه لصفحة فُتحت عبر مسار Direct Access، مع مجموعة الأعلام نفسها. وهو تشخيصي فقط بحكم بنائه: إذ يعمل ReplaceTextBlockCharSourceBytes على المستند المحدد القابل للتعديل، بينما Direct Access مسار قراءة. وتبقى بيانات الموقع في قائمة كتلة النص بعد إغلاق مقبض الملف، ما يجعلها صالحة للتدقيق غير المتصل

FileHandle:= Lib.DAOpenFileReadOnly('audit.pdf', '');
Try
  PageRef:= Lib.DAFindPage(FileHandle, 1);
  DirectList:= Lib.DAExtractPageTextBlocks(FileHandle, PageRef, 3);
  Try
    Lib.DAGetTextBlockCharContentLocation(DirectList, Block, 1,
      ContentLayer, StreamObjectNumber, StreamGeneration,
      InstructionIndex, OperandIndex, ArrayElementIndex,
      SourceByteOffset, SourceByteLength, Flags);
    // تبقى المواقع قابلة للقراءة بعد DACloseFile
  Finally
    Lib.DAReleaseTextBlocks(DirectList);
  End;
Finally
  Lib.DACloseFile(FileHandle);
End;

استخدمها للإجابة عن الأسئلة لا لتغيير الأشياء. ما الصفحات التي تحمل نصًا لا يمكنك تعديله في مكانه أبدًا؟ ما مقدار هذا corpus الذي يصل مع تجاوزات /ActualText؟ مخرجات أي مورّد تقسّم المعاملات عبر طبقات المحتوى؟ هذه استعلامات زهيدة الكلفة بمجرد أن يكون لكل محرف عنوان، وتستحق التشغيل قبل الالتزام بمسار تصحيح

أين يتوقف التعديل النقطي؟

التعديل النقطي مشرط لا محرك نصوص. فهو يغيّر البايتات في مكانها، ولذلك لن يعيد تدفق نص الاستبدال الأعرض أو الأضيق من الأصل، ولن يعيد التفافه، ولن يحدّث التقاربات المحيطة به. واستبدال رقم بآخر في حقل أحادي المسافة مناسب له. أما إعادة كتابة فقرة فليست كذلك. وهو بالتأكيد ليس أداة أمان: إذ يترك تجاوز بايتات glyph البايتات الأصلية قابلة للاستعادة من سجل مراجعات الملف، ولذلك ينتمي كل ما يتطلب سرية إلى تنقيح حقيقي يزيل المحتوى بدل تغطيته. وما تحصل عليه مقابل هذه الحدود هو الصراحة. فإما أن يملك كل محرف عنوان بايت يمكنك التصرف عنده، أو يملك علمًا مسمى يوضح سبب عدم ذلك، ويحوّل فحص البصمة الخريطة القديمة إلى خطأ صريح بدل صفحة تالفة. ويأتي ربط المحرف ببايت المحتوى واستبدال بايت المصدر في مكانه ضمن سطح استخراج النص وتعديل المحتوى في PDF Library for Delphi، وهي مكتبة PDF أصلية بـObject Pascal لـDelphi وC++Builder وLazarus