مقال تقني

إبراز TTS كلمة بكلمة في عوارض Delphi PDFium

سار العرض الأول لميزة read-aloud في تطبيق literacy بشكل جيد لفقرتين. ثم وصلت الصفحة إلى drop cap، وقال الصوت "Chapter" بينما بقي highlight على السطر السابق، وبحلول أسفل الصفحة كان cursor متأخرًا عن الصوت بثلاث كلمات. لم يكن الصوت هو المشكلة أبدًا؛ كان SAPI يبلغ word boundaries بدقة. المشكلة كانت طبقة mapping بين character offsets في speech buffer والمستطيلات على صفحة PDF مصيرة، وتلك الطبقة هي المكان الذي ينجح أو يفشل فيه كل highlighter بأسلوب karaoke. يشحن PDFium Component، مع word boxes منذ v1.53، وtracker وhighlight cursor منذ v1.56، ذلك mapping لـ Delphi وC++Builder وLazarus كـ API صغير ومقصود: word boxes، وoffset-to-word tracker، وhighlight cursor مع auto-scroll. إذا استُخدم بالترتيب الصحيح فهو robust؛ وإذا استُخدم بالترتيب الخاطئ ينتج بالضبط الانحراف الذي عرضناه.

Characters ليست words، وTTS engines تتكلم بـ characters

يستهلك speech engine سلسلة flat ويبلغ التقدم كمواقع characters داخل تلك السلسلة. أما صفحة PDF فتملك glyphs موضوعة في page space، حيث تكون "word" cluster heuristically من glyph runs. لا يشترك نظاما الإحداثيات في شيء ما لم يكن النص الذي تعطيه synthesizer هو byte-for-byte النص الذي حُسبت منه word boxes. هذه هي القاعدة الأولى، وهي قاسية: إذا طبعت whitespace، أو أزلت soft hyphens، أو "نظفت" النص المستخرج قبل نطقه، فإن كل offset لاحق يصبح خاطئًا بصمت. انطق بالضبط ما استخرجته، أو احتفظ بجدول offset remapping صريح؛ لا يوجد خيار ثالث يصمد أمام مستندات حقيقية.

خيار remapping ليس افتراضيًا. إذا كانت UI تضيف إعلانات صفحات منطوقة مثل "page five" أو توسع abbreviations من أجل synthesizer، فسجل موضع كل insertion وطوله، واطرح adjustment المتراكم قبل كل tracking call. إنها عشرون سطرًا من bookkeeping، وهي الفرق بين highlight يصمد مع نمو الميزة وآخر ينكسر أول مرة يطلب فيها product عناوين منطوقة.

ما الذي تعطيه لك word box

يحمل كل سجل TPdfWordBox نص الكلمة، وStartIndex الخاص بها، وCount من characters داخل نص الصفحة، وRect في page-space، ورقم Page بترقيم 1-based. تعيد PageWordBoxes المصفوفة الكاملة للصفحة النشطة:

procedure TReaderForm.PreparePage(PageNo: Integer);
begin
  PdfView.PageNumber := PageNo;   // the view's word boxes track its displayed page

  FWords := PdfView.PageWordBoxes;
  FPageText := BuildSpeechText(FWords);   // concatenate Word.Text in order

  if Length(FWords) = 0 then
    HandleImageOnlyPage(PageNo);          // a scan with no text layer
end;

تعليق ordering هنا مهم: PageWordBoxes في العارض tokenizes طبقة النص للصفحة التي يعرضها view حاليًا، لذلك انتقل بالعرض أولًا ثم استخرج، ولا يتطلب ذلك تصييرًا، بل مستندًا مفتوحًا فقط. كما يقدم document component نسخة PageWordBoxes الخاصة به keyed إلى Pdf.PageNumber للاستخدام headless. نتيجة empty على صفحة تحمل محتوى مرئيًا تعني scan image-only؛ أرسلها إلى OCR أو تخطاها صوتيًا، مثل "page 4 contains no readable text"، بدل ترك الصوت يصمت بلا تفسير.

توصيل SAPI word boundaries إلى tracker

TrackReadingWordAt، على العارض، هو hinge الميزة كلها: أعطه رقم صفحة وcharacter index، فيجد word box التي تحتوي ذلك character، ويرسم reading cursor عليها، ويعيد word index أو −1. يوفر notification الخاص بحدود الكلمات في SAPI الموضع الحرفي المطلوب تمامًا:

procedure TReaderForm.OnSpeechWordBoundary(StreamPos: Integer);
var
  WordIdx: Integer;
begin
  // Maps the offset to a word box and moves the highlight in one call
  WordIdx := PdfView.TrackReadingWordAt(FPageNo, StreamPos);
  if WordIdx < 0 then
    Exit;                     // boundary fell outside any word: keep last highlight
end;

تفصيلان دفاعيان. يحتفظ TrackReadingWordAt بـ word-box cache خاص به للصفحة المتتبعة، ويعاد بناؤه تلقائيًا عند تغير الصفحة، لذلك تبقى تكلفة كل boundary ثابتة. كما أنه لا يفعل bounds-check بسخاء: index عند عدد characters في الصفحة أو بعده يعيد −1 بدل أن يثبت على الكلمة الأخيرة. عامل −1 كـ "احتفظ بالـ highlight السابق"، لا كخطأ، لأن punctuation runs وinter-word whitespace تنتج شرعيًا boundaries لا تنتمي إلى أي word. إذا سجلت كل −1 فستغرق؛ عدّها لكل صفحة، وافحص الصفحات التي يرتفع فيها ratio، فهذا يشير عادة إلى mismatch في text-normalization من القاعدة الأولى.

Cursor نفسه: اللون، والمتابعة، والتنظيف

SetReadingWord يرسم highlight مباشرة عندما تمسك word box بنفسك، وReadingWordColor ينسقه، وReadingWordFollow := True يمرر view بالقدر اللازم فقط لإبقاء الكلمة المنطوقة مرئية. هذه الخاصية الأخيرة أهم مما تبدو: تنفيذ يدوي من نوع "center the current word" يجعل الصفحة تقفز عند كل line break، وسيوقف القراء الحساسون للحركة الميزة خلال دقيقة. لا يرسم highlight إلا على الصفحة المعروضة حاليًا في TPdfView النشط، لذلك يجب على قراءة متعددة الصفحات أن تقدم PageNumber بالتزامن مع speech، وأن تعيد خطوة prepare للصفحة الجديدة قبل وصول أول boundary event لها، حتى يتطابق speech text والoffsets مع الصفحة الجديدة.

procedure TReaderForm.StopReading;
begin
  FVoice.Stop;                // halt SAPI playback first
  PdfView.ClearReadingWord;   // then remove the highlight; a stale cursor reads as a bug
end;

التماثل مهم عند shutdown: كل مسار pause وstop وpage-turn يجب أن ينتهي بـ ClearReadingWord. أكثر "bug" أُبلغ عنها في beta لدينا كانت rectangle بلون amber بقيت على صفحة متوقفة؛ غير ضارة، لكن كل tester سجلها.

يضغط speech rate هذا pipeline أكثر مما يضغطه حجم المستند. عند 300 words per minute تصل boundary events كل 200 ms؛ وعند أسرع معدلات SAPI تصل أسرع مما ترتاح العين لتتبعه. coalesce بدل أن تضع في queue: إذا وصل boundary جديد بينما تحديث highlight لا يزال pending، أسقط التحديث القديم. cursor يزور كل كلمة بالترتيب لكنه متأخر نصف ثانية يبدو broken؛ أما cursor يتخطى كلمة أحيانًا ويبقى synchronized فلا يبدو كذلك.

حالات حدية تفصل demos عن products

تتكرر ثلاث فئات. combining characters: تسلسلات Unicode مثل الحروف الأساسية مع combining diacritics قد تشغل character indices أكثر مما توحي به الكلمة المرئية، لذلك offset arithmetic الذي يفترض index واحدًا لكل glyph مرئي ينحرف، وهذا سبب إضافي لترك TrackReadingWordAt يقوم بالـ mapping بدل حساب أرقام الكلمات بنفسك. hyphenation: كلمة مكسورة عبر line break تصبح boxes اثنتين؛ إذا نطقتها token واحدة، فإن boundary event للنصف الثاني قد يطابق box الأولى، وهذا مقبول، لكن قرره عمدًا. وtagged versus untagged documents: يتبع word sequencing البنية المنطقية للمستند عندما توجد tags صحيحة، وهي منطقة ISO 14289 وPDF/UA، ويرجع إلى layout heuristics خلاف ذلك، لذلك قد تقرأ صفحة untagged ذات عمودين أفقيًا عبر العمودين. تضيف الصفحات rotated رابعًا: تظل Rect لكل كلمة تحدها بشكل صحيح في page space، لكن سياسة viewport-follow المضبوطة للتدفق الأفقي تمرر بصورة مزعجة عندما يسير النص عموديًا، لذلك احتفظ بمستند rotated واحد على الأقل في regression set. لمعالجة reading-order، ووحدات sentence-level عبر ReadingUnits، وassistive stack الأوسع، راجع بناء قارئ PDF قابل للوصول في Delphi.

ملاحظة platform واحدة: SAPI يعمل على Windows فقط. API الخاص بـ word-box وtracking مطابق تحت Lazarus/FPC، لكن builds على Linux وmacOS تحتاج synthesizer مختلفًا خلف boundary events نفسها، وتغطي تشغيل العارض تحت Lazarus وFPC اختلافات الإعداد. كما يتفاعل cost رسم highlight مع page cache عند معدلات speech عالية؛ حسابات budget في render caching وzoom performance تنطبق هنا بلا تغيير.

الأسئلة الشائعة

لماذا يعيد TrackReadingWordAt دائمًا −1؟

غالبًا لأحد ثلاثة أسباب: رقم الصفحة الممرر خارج النطاق أو المستند غير active، أو النص المعطى إلى TTS engine يختلف عن نص الصفحة المستخرج فلا تصطف offsets، أو character index ينتمي إلى whitespace بين الكلمات. افحصها بهذا الترتيب.

لماذا يتوقف highlight عن التحديث بعد page turn؟

يرسم reading cursor على الصفحة الحالية في active view فقط. قدم PageNumber وأعد جلب PageWordBoxes لنص speech قبل الاستئناف، حتى تشير boundary offsets إلى الصفحة المعروضة الآن.

هل يمكنني إبراز جمل كاملة بدل كلمات منفردة؟

نعم، ReadingUnits يعيد وحدات على مستوى الجملة والكتلة مع مستطيلات highlight الخاصة بها، ارسمها باستخدام SetReadingHighlight، وهذا يلائم المستمعين الأبطأ ويقلل visual churn عند معدلات speech العالية.

متطلبات الإصدار، v1.53 أو أحدث لـ word boxes وv1.56 لـ tracking cursor، وreading API الكامل وread-aloud demo عامل موجودة في صفحة المنتج: PDFium Component.