ปุ่มอ่านออกเสียงสามารถทำเป็นตัวอย่างให้ดูได้ภายในบ่ายเดียว แต่หลังจากนั้นจะกินเวลาของคุณไปทั้งสัปดาห์ เวอร์ชันที่สร้างเสร็จในตอนบ่ายจะเป็นการดึงข้อความในหน้ากระดาษ ส่งให้กับ SAPI และจะได้เสียงออกมา ส่วนเวลาตลอดทั้งสัปดาห์ที่เสียไปจะถูกใช้กับสิ่งที่ทำให้ฟีเจอร์นี้ใช้งานได้จริง: เสียงจะต้องไม่ทำให้หน้าต่างค้าง คำที่พูดจะต้องติดสว่างขึ้นบนหน้าเอกสารให้สัมพันธ์กับจังหวะของเสียง และสเปซบาร์จะต้องหยุดทุกสิ่งทุกอย่างได้ บทความนี้สร้างท่อทาง (pipeline) นี้ใน Delphi กับ API ข้อความดิบของ PDFium และ Windows Speech API พร้อมโค้ดการทำงานสำหรับสามส่วนที่เวอร์ชันเร่งด่วนมักมองข้าม: วงจรชีวิต COM ที่ทำแค่ครั้งเดียวแทนที่จะทำทุกๆ คำพูด, เหตุการณ์ขอบเขตคำ (word-boundary events) ที่แท้จริง, และคณิตศาสตร์พิกัดที่จะแปลงกล่องคำบนพื้นที่ PDF ให้กลายเป็นสี่เหลี่ยมผืนผ้าที่คุณสามารถระบายสีลงไปได้
บริบทของข้อบังคับนั้นสามารถสรุปจบได้ในประโยคเดียว: การอ่านออกเสียงแบบซิงโครไนซ์คือสิ่งที่ WCAG 2.1 ร้องขอจากซอฟต์แวร์เอกสารในฝั่งของโปรแกรมดู (viewer) และ ISO 14289-1 (PDF/UA) ก็เป็นตัวกำหนดให้ไฟล์ที่ถูกแท็ก (tagged-file) เป็นฝ่ายทำงานตอบสนองกับมันได้ดีที่สุด หากคุณกำลังสร้างโปรแกรมขึ้นมาบน PDFium Component คุณอาจไม่จำเป็นต้องใช้ท่อทางนี้เลย: โปรแกรมดูมาพร้อมเคอร์เซอร์ติดตามตัวในตัว ที่จับคู่ตำแหน่งตัวอักษรกับไฮไลต์คำที่ถูกวาดในขั้นตอนเดียว ซึ่งได้ครอบคลุมเอาไว้แล้วใน บทความเกี่ยวกับการทำไฮไลต์คำต่อคำของ TTS แต่สำหรับสิ่งที่กำลังจะนำเสนอต่อไปนี้ เหมาะสำหรับกรณีที่คุณเป็นเจ้าของแอปพลิเคชันโปรแกรมดูทั้งหมดและต้องการสร้างท่อทางขึ้นมาใช้งานเอง
เธรดหนึ่งเรนเดอร์, อีกเธรดหนึ่งทำหน้าที่พูด
สถาปัตยกรรมประกอบด้วยเธรดสองเธรดและข้อตกลงหนึ่งข้อ เธรดของ UI จะทำการเรนเดอร์บิตแมปของหน้ากระดาษ เป็นเจ้าของสถานะการซูมและการเลื่อน (scroll) และวาดพื้นที่ไฮไลต์ซ้อนทับลงไป (highlight overlay) เธรดคำพูดที่ถูกแยกออกมาโดยเฉพาะ จะเป็นเจ้าของเสียง SAPI และไม่มีอะไรไปแตะต้องมันได้ ข้อตกลงนั้นเรียบง่าย: เธรดเสียงพูดจะรายงานความคืบหน้าเป็นออฟเซ็ตอักขระ (character offsets) และเธรด UI จะแปลงออฟเซ็ตเหล่านั้นให้เป็นสี่เหลี่ยมผืนผ้า (rectangles)
ตัวอย่าง SAPI ส่วนใหญ่จะครอบทุกการออกเสียงไว้ใน CoInitialize และ CoUninitialize และโปรแกรมดูก็แสดงให้เห็นทันทีว่าทำไมเรื่องนี้จึงเป็นสิ่งที่ผิด Speak ร่วมกับ SVSFlagsAsync จะคืนค่ากลับมาทันทีที่ข้อความถูกส่งเข้าคิว ดังนั้นการใช้ CoUninitialize ในบล็อก finally ของกระบวนการเดียวกัน จะทำให้มันทำงานในขณะที่เสียงยังคงถูกเปล่งออกมา และนั่นคือการรื้อทำลายอพาร์ทเมนต์ COM ที่เป็นเจ้าของเสียงนั้นทิ้งไป ขึ้นอยู่กับจังหวะเวลาว่าคุณอาจจะเจอทั้งความเงียบ คำพูดถูกตัดทอน หรือพบกับข้อผิดพลาด (access violation) ในอีกไม่กี่นาทีต่อมา วงจรชีวิตที่ถูกต้องนั้นแสนจะน่าเบื่อ: เรียกใช้ CoInitialize เพียงครั้งเดียวเมื่อเธรดเริ่มทำงาน สร้างเสียงภายในอพาร์ทเมนต์นั้น และ CoUninitialize เพียงครั้งเดียวเมื่อเธรดนั้นสิ้นสุดลง หลังจากที่ปลดปล่อย (freed) เสียงไปแล้ว ห้ามทำแบบทุกๆ การออกเสียงเด็ดขาด
เสียงยังต้องการวงจรเพื่อสูบฉีดข้อความ (message pump) ซึ่งเป็นตัวกำหนดว่ามันจะอาศัยอยู่ที่ไหน ออบเจ็กต์ทำงานอัตโนมัติ (automation object) SpVoice จะส่งต่อเหตุการณ์ผ่านทางคิวข้อความ (message queue) ของเธรดที่สร้างมันขึ้นมา หากสร้างมันบนเธรด UI เหตุการณ์จะส่งไปถึง เพราะ VCL จะทำหน้าที่สูบฉีดข้อความ แต่เมื่อการระบายสี (paint) เป็นไปอย่างเชื่องช้า มันก็จะทำให้เหตุการณ์ขอบเขตคำของคุณเกิดความล่าช้า; แต่หากไปสร้างบนเธรดของผู้ปฏิบัติงานที่ไม่มีการสูบฉีด เหตุการณ์ต่างๆ ก็จะไม่มีทางส่งไปถึงเลย เธรดเฉพาะที่มีลูป GetMessage ของตัวเองเท่านั้น จะช่วยรักษาเวลาแฝง (latency) ของขอบเขตคำให้ราบเรียบ ไม่ว่า UI จะกำลังทำอะไรอยู่ก็ตาม
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 ที่เรียกใช้งานจากเธรดของ UI จะจัดเก็บข้อความเอาไว้ในฟิลด์ที่มีการล็อค (lock-guarded field) แล้วโพสต์ WM_SPEAK_PAGE เพราะการเรียกใช้เมธอดบน FVoice โดยตรงจากเธรดอื่นจะเป็นการโทรข้ามอพาร์ทเมนต์ COM บนอินเทอร์เฟซที่ไม่ได้ถูกมาร์แชล (unmarshaled) การวางโค้ด PeekMessage ไว้หนึ่งบรรทัดก่อนถึงลูป จะบังคับให้ Windows สร้างคิวข้อความของเธรดนี้ขึ้นมา ซึ่งจะเป็นการปิดปัญหาช่วงชิงเวลาเมื่อระบบเริ่มต้นทำงาน (startup race) ในจุดที่มีการโพสต์มาจากเธรด UI ก่อนเวลาอันควรล้มเหลว
ขอบเขตของคำถูกส่งมาในรูปแบบออฟเซ็ตอักขระ
เพียงแค่อิมพอร์ต Microsoft Speech Object Library ผ่านตัวอิมพอร์ตไลบรารีชนิดของ IDE ในครั้งแรก คุณจะได้รับ SpeechLib_TLB พร้อมกับ TSpVoice แรปเปอร์ และเหตุการณ์ต่างๆ ที่ถูกกำหนดชนิดของมัน การตั้งค่าสองอย่างที่มีความสำคัญ EventInterests ควรถูกปรับให้แคบลงเหลือแค่เฉพาะเหตุการณ์ที่คุณต้องการบริโภคจริงๆ เพราะเหตุการณ์ที่เราปล่อยให้เปิดทิ้งไว้นั้นคือปริมาณการรับส่งเหตุการณ์ข้ามเธรดสำหรับคำทุกคำของทุกหน้าเอกสาร; SVEWordBoundary จะทำหน้าที่ขับเคลื่อนไฮไลต์ และ SVEEndInputStream จะบอกคุณว่าการออกเสียงเสร็จสมบูรณ์แล้ว และแฮนด์เลอร์ของ OnWord จะได้รับตัวแปร CharacterPosition พร้อมกับค่าความยาว (length) ซึ่งจะทำดัชนีชี้ไปยังสตริงเดียวกันกับที่คุณเพิ่งส่งไปยังฟังก์ชัน Speak พอดีเป๊ะ — มันคือตำแหน่งออฟเซ็ตบนบัฟเฟอร์เสียงพูด ไม่ใช่สิ่งอื่นใดเลย
ข้อความท่อนสุดท้ายนี้คือส่วนที่เสถียรที่สุดซึ่งฟีเจอร์นี้ต้องพึ่งพิง: ออฟเซ็ตต่างๆ จะมีความหมายต่อเมื่อมันผูกอยู่กับสตริงเดียวกันกับที่เสียงนั้นกำลังอ่านอยู่ ดังนั้นโปรดให้เสียงอ่านเฉพาะข้อความที่คุณสกัดออกมาให้ตรงตัวเป๊ะอักขระต่ออักขระ หากคุณทำการลบช่องว่าง นำตัวแบ่งบรรทัดมารวมกัน หรือขยายคำย่อเพื่อให้การออกเสียงฟังดูดีขึ้น ทุกๆ ไฮไลต์หลังจากที่มีการแก้ไขเป็นครั้งแรกก็จะคลาดเคลื่อนไปหนึ่งคำทันที หาก UI จำเป็นต้องฉีดสิ่งที่ต้องพูดเสริมเข้าไป — เช่น การประกาศเลขหน้า (page announcements) คำนำหน้าส่วนหัวของเอกสาร (heading prefixes) — ให้บันทึกตำแหน่งของแต่ละจุดแทรกและความยาวของมัน จากนั้นนำค่าความเปลี่ยนแปลงที่สะสมไว้นั้น ไปลบออกจากค่าของออฟเซ็ตก่อนที่จะแปลงมันให้เป็นการแสดงผล (mapping)
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: แฮนด์เลอร์ต้องไม่จอดเธรดเสียงพูดเอาไว้ในขณะที่ UI ทำการวาดสีใหม่ (repaints) และหากเหตุการณ์เกี่ยวกับขอบเขตมาถึงเร็วกว่าที่หน้าจอจะวาดได้ทัน การอัปเดตไฮไลต์ที่ล่าช้าก็จะไม่เกิดอันตรายใดๆ เนื่องจากการอัปเดตครั้งต่อไปจะเขียนทับไปเอง ให้ทำการเชื่อมต่อตัว OnEndStream ในลักษณะเดียวกันนี้เพื่อใช้ในการเคลียร์ไฮไลต์ และสำหรับโหมดการอ่านอย่างต่อเนื่อง ก็เพื่อใช้ในการโหลดข้อความในหน้าถัดไปแล้วส่งคำสั่งสำหรับให้ออกเสียงในครั้งต่อไป
จากออฟเซ็ตอักขระไปเป็นพิกเซลบนหน้าจอ
PDFium จะรายงานรูปทรงเรขาคณิต (geometry) เป็นรายอักขระ ฟังก์ชัน FPDFText_GetCharBox จะป้อนค่า double สี่ค่าในลำดับที่ได้สร้างบั๊กแบบเงียบๆ มากกว่าสิ่งอื่นใดใน API ของข้อความ — เป็น ซ้าย, ขวา, ล่าง, บน ไม่ใช่ ซ้าย, บน, ขวา, ล่าง แบบที่ใช้กันในระบบ Windows — และมันจะรายงานตัวเลขเหล่านี้ในขอบเขตหน้ากระดาษ (page space): ซึ่งหมายถึงจุดพอยต์ (points) ของ PDF โดยที่ 72 จุดพอยต์เท่ากับหนึ่งนิ้ว โดยมีจุดเริ่มต้นอยู่ที่มุมซ้ายล่าง และแกน Y จะมีค่าเพิ่มขึ้นเมื่อขยับขึ้นข้างบน กล่องของคำคือการรวมกล่องอักขระของมันเข้าด้วยกัน และการแปลงผล (transform) ไปเป็นพิกเซลของอุปกรณ์ประกอบด้วยสามขั้นตอน: คือ การเลื่อน (translate) ตามจุดเริ่มต้นของหน้า (page origin), การปรับสเกลโดยอาศัยการซูมคูณด้วย DPI ของหน้าจอหารด้วย 72, และการกลับหัวแกน Y (flip the Y axis)
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 (Y flip) คือจุดที่เวอร์ชันที่เขียนเอง (hand-rolled versions) มักจะพัง: ขอบบนของกล่องสี่เหลี่ยมผืนผ้าของอุปกรณ์ มาจากขอบบนของกล่อง 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;
แฮนด์เลอร์สำหรับระบายสี (paint handler) จะวาดบิตแมปของหน้ากระดาษเป็นอันดับแรกและค่อยวาดไฮไลต์ตามหลังในทุกครั้ง เพื่อให้สี่เหลี่ยมผืนผ้าที่วาดซ้อนทับไม่ต้องคอยลบภาพตัวมันเอง; การบอกยกเลิกความถูกต้อง (invalidating) ของสี่เหลี่ยมผืนผ้าเก่าและใหม่ จะรักษาภูมิภาคที่วาดสีใหม่ (repaint region) ให้มีขนาดเล็กเสมอแม้จะมีอัตราการออกเสียงที่รวดเร็ว FHighlightBrush คือ TBitmap แบบ 1x1 ซึ่งจะถูกระบายครั้งเดียวตอนที่ระบบเริ่มต้นด้วยสีสำหรับไฮไลต์ — FHighlightBrush.Canvas.Pixels[0, 0] := $0032C8FF คือสีเหลืองอำพัน — ซึ่ง AlphaBlend จะทำการยืดปกคลุมสี่เหลี่ยมผืนผ้าเป้าหมายเอาไว้ จึงไม่เกิดการจัดสรร (allocated) ต่อเฟรมใดๆ เลย และ SourceConstantAlpha ที่ระดับ 96 จะทำให้คำยังคงอ่านออกได้ชัดเจนผ่านสีอ่อนที่แต่งเติม (tint) เข้ามา กรุณาทดสอบสีภายใต้โหมดที่แสดงผลภาพแบบกลับสีและโหมดที่หน้าจอมีคอนทราสต์สูง (high-contrast); สีเคลือบซ้อนทับ (overlay) ที่ผู้ใช้งานสายตาเลือนรางไม่สามารถมองเห็น ถือว่าไม่ได้ถูกออกแบบมาเพื่อบุคคลที่มันถูกสร้างขึ้นมาให้ใช้งานอย่างแท้จริง
ลำดับการอ่านคือส่วนที่ API ของข้อความจะไม่ยอมแก้ไขให้
FPDFText_GetText จะส่งคืนอักขระในลำดับที่ได้มาจากสตรีมของเนื้อหา (content stream) รวมไปถึงการทำความสะอาดเชิงพื้นที่ด้วยเล็กน้อย และสำหรับรายงานที่มีเพียงคอลัมน์เดียว การเรียงลำดับนี้ก็ถือว่าใช้ได้แล้ว มันไม่มีภาระผูกพันที่จะต้องทำให้ถูกต้องในที่อื่นๆ... จดหมายข่าวที่มีสองคอลัมน์อาจจะอ่านข้ามคอลัมน์ตรงๆ ไปเลยก็ได้ คอลัมน์แถบด้านข้าง (sidebar) อาจจะขัดจังหวะประโยคขณะอ่านอยู่กลางอนุประโยค (mid-clause) และท้ายกระดาษอาจจะโผล่มาอยู่ตรงกลางหน้าได้ ข้อมูลที่จะแก้ไขปัญหานี้คือ — โครงสร้างเชิงตรรกะแบบต้นไม้ของมาตรฐาน ISO 32000-1 ในหัวข้อ §14.8 ซึ่ง PDF แบบแท็กนำติดตัวมาด้วย และมาตรฐาน PDF/UA ก็ทำให้มันกลายเป็นข้อบังคับ — ข้อมูลนี้จะไม่ถูกเรียกใช้งานจากการเรียกดูข้อความบนหน้ากระดาษแบบดิบเลย หากคุณต้องการการเรียงลำดับที่รับรู้โครงสร้างและมีสัญญาณระบุแหล่งกำเนิด (origin) ที่ชัดเจน นี่คือปัญหาที่ได้รับการแก้ไขในระดับที่สูงขึ้นมาหนึ่งขั้น: Reading API ของ PDFium Component จะคืนเนื้อหากลับมาพร้อมกับเขตข้อมูล Source ที่เป็น rosStructure หรือ rosHeuristic และ บทความเกี่ยวกับการอ่าน PDF ที่สามารถเข้าถึงได้ จะเป็นผู้ให้คำแนะนำ ในระดับ API แบบดิบ (raw API level) วิธีแก้ตัวที่สมเหตุสมผล (defensible position) คือปฏิบัติต่อลำดับการดึงข้อมูลเพื่อประเมิน บอกเรื่องนั้นผ่าน UI และรักษาเอกสารแบบหลายคอลัมน์จำนวนหนึ่งชุด และการสแกนแบบภาพ-อย่างเดียว จำนวนหนึ่งชุดไว้ในคอลเลกชันสำหรับการทดสอบย้อนหลัง (regression set) เพื่อให้เห็นถึงรูปแบบการทำงานที่ล้มเหลวอย่างชัดเจนทั้งคู่
ตัวโปรแกรมดูเองก็ต้องสามารถใช้งานผ่านคีย์บอร์ดได้
แม้จะมีระบบพูดด้วยเสียงก็ไม่ใช่ข้ออ้างที่จะทำให้โปรแกรมดูหลุดพ้นจากการใช้งานด้วยคีย์บอร์ด; คนที่น่าจะใช้ฟีเจอร์การอ่านออกเสียงมากที่สุด คือคนที่มีแนวโน้มจะใช้เมาส์น้อยที่สุด ให้หน้าต่างหน้ากระดาษ (page panel) นี้คือ TabStop := True และมีกรอบสี่เหลี่ยมสำหรับระบุตำแหน่งโฟกัส (focus rectangle) ที่มองเห็นได้ชัดเจน จากนั้นก็ต้องจัดการปุ่มคีย์บอร์ดด้วยกัน 3 ปุ่ม ได้แก่: ปุ่ม Space จะเป็นการสลับระหว่าง FVoice.Pause และ FVoice.Resume และปุ่ม ซ้าย ขวา จะเป็นการข้ามผ่าน FVoice.Skip('Sentence', 1) ด้วยตัวนับลบเมื่อต้องการกลับหลัง ฟังก์ชัน Skip ของ SAPI จะเข้าใจเพียงระดับประโยคเท่านั้น ดังนั้นการข้ามแบบระดับคำ จึงต้องใช้คำสั่งอย่าง SVSFPurgeBeforeSpeak เพื่อทำการกวาดล้างไฟล์ที่เปิดอยู่ออกไป (purging playback) และค่อยสั่งให้พูดใหม่เริ่มตั้งแต่จุดออฟเซ็ตของคำที่คุณเพิ่งได้ติดตามเป็นครั้งล่าสุด — ซึ่งค่าใช้จ่ายน้อยนิด เพราะโค้ดของส่วนที่เป็นการไฮไลต์ ได้จัดเก็บเอาค่าออฟเซ็ตนั้นเอาไว้เรียบร้อยแล้ว จงคอยดูแลให้ปุ่มควบคุมจังหวะการเล่น (transport control) ให้เป็น TButton ที่แท้จริง พร้อมกับคำอธิบายสั้นๆ เสมอ เพื่อให้โปรแกรมอ่านหน้าจอ (screen readers) สามารถประกาศชื่อให้ผู้อื่นรับรู้ได้
ทั้งหมดนี้คือท่อทางตลอดทั้งสาย ซึ่งทั้งหมดนี้ขัดแย้งกับ API ข้อความดิบของ PDFium: เธรดเสียงพูดที่เป็นเจ้าของ COM และดูแลเสียงตลอดอายุของแอป, เหตุการณ์ขอบเขตคำถูกจัดเตรียมให้กับ UI เป็นแบบออฟเซ็ตอักขระ, และกล่องในพื้นที่ของ PDF (page-space boxes) ต่อหนึ่งอักขระถูกแปลงร่างเป็นสี่เหลี่ยมผืนผ้าที่ผสมกันเป็นหนึ่งเดียวบนหน้าจอ หากคุณไม่อยากเป็นเจ้าของรูปทรงเรขาคณิตและการติดตามด้วยตัวคุณเอง, PDFium Component จะมาพร้อมกล่องแบบคำต่อคำ (per-word boxes), เคอร์เซอร์สำหรับการติดตาม (tracking cursor), การติดตามด้วยการเลื่อนหน้าจอแบบอัตโนมัติ (auto-scroll follow), และการแยกหน่วยของส่วนที่อ่านเป็นระดับประโยค ซึ่งทั้งหมดมาในรูปแบบของพร็อพเพอร์ตี้ของคอมโพเนนต์, และเดโมการอ่านออกเสียงของบทความนี้ ได้ลดทอนให้เหลือท่อทางให้เรียกใช้งานด้วยโค้ดเพียงหยิบมือ