زر القراءة بصوت عالٍ يعرض كعرض تجريبي في فترة ما بعد الظهر ثم يستهلك أسبوعاً. الإصدار الذي يستغرق بعد الظهر يستخرج نص الصفحة، ويسلمه إلى SAPI، ويحصل على الصوت. يذهب الأسبوع إلى ما يجعل الميزة قابلة للاستخدام: يجب ألا يتسبب الصوت في تجميد النافذة، يجب أن تضيء الكلمة المنطوقة على الصفحة بالتزامن مع الصوت، ويجب أن يوقف مفتاح المسافة (Space) العملية برمتها. يبني هذا المقال ذلك المسار في Delphi مقابل واجهة برمجة تطبيقات نص PDFium الخام وWindows Speech API، مع كود برمجي يعمل للأجزاء الثلاثة التي يتخطاها الإصدار السريع: دورة حياة COM التي تتم مرة واحدة بدلاً من كل نطق، أحداث حدود الكلمات الحقيقية، ورياضيات الإحداثيات التي تحول مربع كلمة في فضاء PDF إلى مستطيل يمكنك رسمه
السياق التنظيمي يتلخص في جملة واحدة: القراءة بصوت عالٍ المتزامنة هي النصف الخاص بجهة العارض مما تطلبه WCAG 2.1 من برمجيات المستندات، ويحدد ISO 14289-1 (PDF/UA) النصف الخاص بالملفات ذات العلامات (tagged-file) والذي تعمل الميزة بشكل أفضل مقابله. إذا كنت تبني باستخدام PDFium Component فقد لا تحتاج إلى هذا المسار على الإطلاق: يشحن العارض مؤشر تتبع مضمّن يعين إزاحة حرف إلى تمييز كلمة مرسوم في استدعاء واحد، والذي تمت تغطيته في مقال تمييز تحويل النص إلى كلام كلمة بكلمة. ما يلي هو عندما تمتلك تطبيق العارض بأكمله وتريد المسار نفسه
مسار للرسم، ومسار للتحدث
البنية عبارة عن مسارين وعقد واحد. يمتلك مسار واجهة المستخدم (UI thread) رسم صورة نقطية (bitmap) للصفحة، وحالة التكبير والتمرير، ويرسم تراكب التمييز (highlight overlay). يمتلك مسار التحدث (speech thread) المخصص صوت SAPI، ولا يلمسه أي شيء آخر. العقد بسيط: يبلغ مسار التحدث عن التقدم كإزاحات أحرف (character offsets)، ويحول مسار واجهة المستخدم الإزاحات إلى مستطيلات
تقوم معظم أمثلة SAPI بتغليف كل نطق في CoInitialize و CoUninitialize، ويوضح العارض سبب خطأ ذلك على الفور. يعود استدعاء Speak مع SVSFlagsAsync بمجرد وضع النص في قائمة الانتظار، لذا فإن CoUninitialize في نفس كتلة finally الخاصة بالإجراء تعمل بينما لا يزال الصوت يتحدث، مما يؤدي إلى هدم شقة COM التي تمتلكه. اعتمادًا على التوقيت، تحصل على صمت، أو نطق مبتور، أو انتهاك وصول (access violation) بعد دقائق. دورة الحياة الصحيحة بسيطة: CoInitialize مرة واحدة عندما يبدأ مسار التحدث، وإنشاء الصوت داخل تلك الشقة، و CoUninitialize مرة واحدة عندما يخرج المسار، بعد أن يتم تحرير الصوت. ليس لكل نطق أبدًا
يحتاج الصوت أيضًا إلى مضخة رسائل (message pump)، والتي تقرر أين يمكن أن يعيش. يسلم كائن الأتمتة SpVoice أحداثه من خلال قائمة انتظار الرسائل (message queue) الخاصة بالمسار الذي أنشأه. قم بإنشائه على مسار واجهة المستخدم (UI thread) وستصل الأحداث بالفعل، لأن VCL يضخ الرسائل، ولكن كل عملية رسم بطيئة تؤخر حدود كلماتك؛ قم بإنشائه على مسار عامل (worker thread) بدون مضخة ولن تصل الأحداث أبدًا. يحافظ مسار مخصص مع حلقة GetMessage الخاصة به على بقاء زمن انتقال الحدود (latency) ثابتًا بغض النظر عما تفعله واجهة المستخدم
uses
System.Classes, System.SyncObjs, Winapi.Windows, Winapi.Messages,
Winapi.ActiveX, SpeechLib_TLB;
const
WM_SPEAK_PAGE = WM_APP + 1;
type
TSpeechThread = class(TThread)
private
FVoice: TSpVoice;
FLock: TCriticalSection;
FText: string;
function NextUtterance: string; // reads FText under FLock
procedure VoiceWord(ASender: TObject; StreamNumber: Integer;
StreamPosition: OleVariant; CharacterPosition, WordLength: Integer);
protected
procedure Execute; override;
procedure TerminatedSet; override;
public
procedure SpeakPage(const AText: string); // safe from the UI thread
end;
procedure TSpeechThread.Execute;
var
Msg: TMsg;
begin
CoInitialize(nil); // once, when the thread starts
try
FVoice := TSpVoice.Create(nil);
try
FVoice.EventInterests := SVEWordBoundary or SVEEndInputStream;
FVoice.OnWord := VoiceWord;
// Force creation of this thread's message queue before anyone posts to it
PeekMessage(Msg, 0, WM_USER, WM_USER, PM_NOREMOVE);
while GetMessage(Msg, 0, 0, 0) do // exits when WM_QUIT arrives
if Msg.message = WM_SPEAK_PAGE then
FVoice.Speak(NextUtterance, SVSFlagsAsync or SVSFPurgeBeforeSpeak)
else
DispatchMessage(Msg); // delivers the SAPI event callbacks
finally
FVoice.Free;
end;
finally
CoUninitialize; // once, when the thread exits
end;
end;
procedure TSpeechThread.TerminatedSet;
begin
inherited;
PostThreadMessage(ThreadID, WM_QUIT, 0, 0); // unblock GetMessage
end;
يقوم TerminatedSet بنشر WM_QUIT لكي تلغي المضخة الحظر عند إغلاق العارض. يقوم SpeakPage، الذي يتم استدعاؤه من مسار واجهة المستخدم، بتخزين النص في حقل محمي بقفل (lock-guarded field) وينشر WM_SPEAK_PAGE، لأن استدعاء دالة على FVoice مباشرة من مسار آخر سيكون استدعاء COM عبر الشقق على واجهة غير مجمعة (unmarshaled interface). يجبر الاستدعاء أحادي السطر PeekMessage قبل الحلقة نظام Windows على إنشاء قائمة انتظار رسائل المسار، مما يغلق سباق بدء التشغيل (startup race) حيث سيفشل النشر المبكر من مسار واجهة المستخدم
تصل حدود الكلمات كإزاحات للأحرف
قم باستيراد Microsoft Speech Object Library مرة واحدة من خلال مستورد مكتبة النوع (type library importer) الخاص ببيئة التطوير (IDE) وستحصل على SpeechLib_TLB مع الغلاف TSpVoice وأحداثه المكتوبة. يهمنا إعدادان. يجب تضييق EventInterests إلى الأحداث التي تستهلكها فعليًا، لأن كل اهتمام يُترك قيد التشغيل يمثل حركة مرور أحداث عبر المسارات (cross-thread event traffic) لكل كلمة في كل صفحة؛ يقود SVEWordBoundary التمييز ويخبرك SVEEndInputStream أن النطق قد انتهى. ويتلقى معالج OnWord موضع الحرف CharacterPosition والطول، والذي يفهرس بدقة السلسلة التي مررتها إلى Speak — إزاحة في مخزن التحدث المؤقت، وليس في أي شيء آخر
الفقرة الأخيرة هي الثابت الذي تعتمد عليه الميزة: الإزاحات ذات معنى فقط مقابل السلسلة التي يقرؤها الصوت، لذا انطق بالضبط النص الذي استخرجته، حرفًا بحرف. قم بقص المسافات البيضاء (Trim whitespace)، أو طي فواصل الأسطر، أو توسيع اختصار من أجل نطق أفضل، وكل تمييز بعد التعديل الأول سيهبط منزاحًا بكلمة واحدة. إذا كان يجب على واجهة المستخدم إدخال مادة منطوقة — إعلانات الصفحات، بادئات العناوين — فقم بتسجيل موضع وطول كل إدراج، واطرح الإزاحة المتراكمة من كل إزاحة قبل تعيينها
procedure TSpeechThread.SpeakPage(const AText: string);
begin
FLock.Enter;
try
FText := AText;
finally
FLock.Leave;
end;
PostThreadMessage(ThreadID, WM_SPEAK_PAGE, 0, 0);
end;
procedure TSpeechThread.VoiceWord(ASender: TObject; StreamNumber: Integer;
StreamPosition: OleVariant; CharacterPosition, WordLength: Integer);
begin
// Runs on the speech thread; hand the offsets to the UI without blocking
TThread.Queue(nil,
procedure
begin
ViewerForm.HighlightWordAt(CharacterPosition, WordLength);
end);
end;
إن TThread.Queue هو الناقل المناسب هنا، وليس Synchronize: يجب ألا يقوم المعالج بإيقاف مسار التحدث (park the speech thread) بينما تعيد واجهة المستخدم الرسم، وإذا وصلت أحداث الحدود بشكل أسرع من رسم الشاشة، فإن تحديث التمييز القديم غير ضار لأن التحديث التالي سيكتب فوقه. قم بتوصيل OnEndStream بنفس الطريقة لمسح التمييز، وفي وضع القراءة المستمرة، لتحميل نص الصفحة التالية ونشر النطق التالي
من إزاحات الأحرف إلى بكسلات على الشاشة
يبلغ PDFium عن الهندسة لكل حرف. يملأ FPDFText_GetCharBox أربعة مضاعفات (doubles) بترتيب تسبب في أخطاء صامتة أكثر من أي شيء آخر في واجهة برمجة تطبيقات النص — اليسار، اليمين، الأسفل، الأعلى، وليس الترتيب الخاص بنظام Windows: اليسار، الأعلى، اليمين، الأسفل — ويبلغ عنها في فضاء الصفحة: نقاط PDF، بمعدل 72 في البوصة، نقطة الأصل في الزاوية اليسرى السفلية مع نمو Y لأعلى. مربع الكلمة هو اتحاد مربعات أحرفها، والتحويل إلى بكسلات الجهاز يتم في ثلاث خطوات: الترجمة بواسطة نقطة أصل الصفحة، القياس بالتكبير مضروبًا في DPI للشاشة مقسومًا على 72، وعكس المحور Y
uses
System.Math;
type
TPdfRectF = record
Left, Top, Right, Bottom: Double; // PDF points, origin bottom-left
end;
function TViewerForm.WordBox(CharIndex, CharCount: Integer): TPdfRectF;
var
i, LastChar: Integer;
L, T, R, B: Double;
begin
Result.Left := MaxDouble; Result.Bottom := MaxDouble;
Result.Right := -MaxDouble; Result.Top := -MaxDouble;
LastChar := Min(CharIndex + CharCount, FPDFText_CountChars(FTextPage)) - 1;
for i := CharIndex to LastChar do
begin
// Parameter order is left, right, bottom, top - not the Windows order
FPDFText_GetCharBox(FTextPage, i, @L, @R, @B, @T);
Result.Left := Min(Result.Left, L);
Result.Right := Max(Result.Right, R);
Result.Bottom := Min(Result.Bottom, B);
Result.Top := Max(Result.Top, T);
end;
end;
function TViewerForm.PdfToDevice(const W: TPdfRectF): TRect;
var
Scale: Double;
begin
// 72 PDF points per inch; FZoom is the viewer scale factor
Scale := FZoom * FScreenDpi / 72.0;
Result.Left := Round((W.Left - FPageLeft) * Scale) - FScrollX;
Result.Right := Round((W.Right - FPageLeft) * Scale) - FScrollX;
// PDF Y grows upward from the bottom edge; device Y grows downward
Result.Top := Round((FPageTop - W.Top) * Scale) - FScrollY;
Result.Bottom := Round((FPageTop - W.Bottom) * Scale) - FScrollY;
end;
الـ FPageTop هو ارتفاع الصفحة بالنقاط من FPDF_GetPageHeight، و FPageLeft هو صفر لمعظم المستندات ولكنه يأتي من مربع القص (crop box) عندما تحدد الصفحة واحدًا، لذا اقرأ كليهما من FPDF_GetPageBoundingBox بدلاً من الافتراض. انعكاس المحور Y هو المكان الذي تتعطل فيه الإصدارات اليدوية الصنع: الجزء العلوي من مستطيل الجهاز يأتي من الجزء العلوي من مربع PDF المُقاس لأسفل من أعلى الصفحة. احصل عليه بشكل عكسي وسيرسم كل تمييز منعكسًا في النصف الخطأ من الصفحة
procedure TViewerForm.HighlightWordAt(CharIndex, CharCount: Integer);
var
Old: TRect;
begin
if CharCount <= 0 then Exit;
Old := FHighlightRect;
FHighlightRect := PdfToDevice(WordBox(CharIndex, CharCount));
InvalidateRect(PageBox.Handle, @Old, False); // erase the old word
InvalidateRect(PageBox.Handle, @FHighlightRect, False); // draw the new one
end;
procedure TViewerForm.PageBoxPaint(Sender: TObject);
var
Blend: TBlendFunction;
begin
PageBox.Canvas.Draw(0, 0, FPageBitmap); // rendered page first, always
if FHighlightRect.IsEmpty then Exit;
Blend.BlendOp := AC_SRC_OVER;
Blend.BlendFlags := 0;
Blend.SourceConstantAlpha := 96; // about 38 percent opacity
Blend.AlphaFormat := 0; // constant alpha, no per-pixel data
Winapi.Windows.AlphaBlend(PageBox.Canvas.Handle,
FHighlightRect.Left, FHighlightRect.Top,
FHighlightRect.Width, FHighlightRect.Height,
FHighlightBrush.Canvas.Handle, 0, 0, 1, 1, Blend);
end;
يرسم معالج الرسم صورة الصفحة النقطية أولاً والتمييز بعدها، في كل مرة، لذلك لا يضطر التراكب (overlay) أبدًا لمسح نفسه؛ إبطال المستطيلات القديمة والجديدة يبقي منطقة إعادة الرسم صغيرة حتى عند معدلات التحدث السريعة. الـ FHighlightBrush هو TBitmap واحد بواحد يتم تعبئته مرة واحدة عند بدء التشغيل بلون التمييز — FHighlightBrush.Canvas.Pixels[0, 0] := $0032C8FF للون الكهرماني — والذي يقوم AlphaBlend بتمديده على المستطيل المستهدف، بحيث لا يتم تخصيص أي شيء لكل إطار (frame)، و SourceConstantAlpha بقيمة 96 يبقي الكلمة مقروءة من خلال الصبغة (tint). اختبر اللون تحت أوضاع العرض المعكوسة (inverted) وعالية التباين (high-contrast)؛ فالتراكب الذي لا يمكن لمستخدم ضعيف البصر رؤيته غير موجود بالنسبة للشخص الذي صُنع من أجله بالضبط
ترتيب القراءة هو الجزء الذي لن تحله واجهة برمجة تطبيقات النص
يرجع FPDFText_GetText الأحرف بترتيب مشتق من دفق المحتوى مع بعض التنظيف المكاني، وبالنسبة لتقرير من عمود واحد، يكون هذا الترتيب جيدًا. ليس لديه أي التزام بأن يكون صحيحًا في أي مكان آخر. يمكن لرسالة إخبارية من عمودين أن تقرأ مباشرة عبر كلا العمودين، ويمكن لشريط جانبي أن يقطع جملة في منتصف المقطع، ويمكن أن يصل تذييل الصفحة في منتصف الصفحة. المعلومات التي تصلح هذا — شجرة البنية المنطقية (logical structure tree) لـ ISO 32000-1 §14.8، والتي تحملها ملفات PDF ذات العلامات (tagged PDFs) ويجعلها PDF/UA إلزامية — لا تتم استشارتها من قبل استدعاءات صفحة النص الخام على الإطلاق. إذا كنت بحاجة إلى ترتيب مدرك للبنية مع إشارة صريحة لأصله، فهذه مشكلة محلولة في مستوى أعلى: تُرجع واجهة برمجة تطبيقات القراءة في PDFium Component المحتوى مع حقل Source بقيمة rosStructure أو rosHeuristic، ويوضح مقال قارئ PDF الذي يمكن الوصول إليه ذلك. على مستوى واجهة برمجة التطبيقات الخام، الموقف الذي يمكن الدفاع عنه هو التعامل مع ترتيب الاستخراج كتقدير، وتوضيح ذلك في واجهة المستخدم، والاحتفاظ بمستند واحد متعدد الأعمدة ومسح ضوئي واحد للصور فقط في مجموعة الانحدار (regression set) حتى يظل وضعا الفشل ظاهران
يجب أن يكون العارض نفسه قابلاً للتشغيل بلوحة المفاتيح
لا يُعفي الإخراج الصوتي العارض من الوصول عبر لوحة المفاتيح؛ فالأشخاص الأكثر عرضة لاستخدام القراءة بصوت عالٍ هم الأقل عرضة لاستخدام الماوس. امنح لوحة الصفحة TabStop := True ومستطيل تركيز (focus rectangle) مرئي، ثم تعامل مع ثلاثة مفاتيح: يقوم المسافة (Space) بتبديل FVoice.Pause و FVoice.Resume، ويقوم مفتاحا اليسار واليمين بالتخطي عبر FVoice.Skip('Sentence', 1) بعدد سالب للرجوع. لا يفهم Skip في SAPI سوى دقة مستوى الجملة، لذا فإن التخطي على مستوى الكلمة يعني مسح التشغيل باستخدام SVSFPurgeBeforeSpeak وإعادة التحدث من إزاحة الكلمة التي قمت بتتبعها أخيرًا — وهو أمر غير مكلف، حيث يقوم رمز التمييز بتخزين هذه الإزاحة بدقة بالفعل. احتفظ بكل عنصر تحكم في النقل كـ TButton حقيقي مع تعليق توضيحي (caption) بحيث تعلن عنه برامج قراءة الشاشة
هذا هو المسار بأكمله، كله مقابل واجهة برمجة تطبيقات نص PDFium الخام: مسار تحدث يمتلك COM والصوت طوال فترة عمل التطبيق، أحداث حدود مرتبة إلى واجهة المستخدم كإزاحات أحرف، ومربعات في فضاء الصفحة لكل حرف محولة إلى مستطيل واحد مدمج (blended rectangle) على الشاشة. إذا كنت تفضل عدم امتلاك الهندسة والتتبع بنفسك، فإن PDFium Component يشحن مربعات لكل كلمة، ومؤشر التتبع، ومتابعة التمرير التلقائي (auto-scroll follow)، ووحدات القراءة على مستوى الجملة كخصائص للمكون، والإصدار التجريبي الخاص به للقراءة بصوت عالٍ هو مسار هذا المقال مصغرًا إلى عدد قليل من الاستدعاءات