一位視障使用者在您嶄新的 Delphi 檢視器中打開一份季報,開啟 NVDA,先聽到頁尾,接著是一欄數字,然後才聽到任何明眼讀者都會先讀的標題。或者根本什麼都沒聽到。頁面在螢幕上看起來完美無缺,而那正是陷阱所在:呈現與閱讀是兩個不同的問題,由不同的程式碼解決。PDF 塗上字符的順序,沒有義務與人應該聽到它們的順序一致,所以一個只建立在呈現呼叫之上的檢視器,產出的是無瑕的畫面與無法使用的朗讀。PDFium Component 是 PDFium 引擎為 Delphi、C++Builder 與 Lazarus 提供的 VCL/LCL 包裝,正因如此它另外帶著一組閱讀 API。繪圖 API 救不回它們從未拿到過的閱讀順序
一個無障礙閱讀器的成敗取決於三件事。它必須擷取出螢幕閱讀器唸得出來的順序,讓可見的字詞游標始終釘在語音正在說的那個字上,並且在文件從來沒被標記時老實承認,而不是靠猜再裝作沒事。每一件都有一個明確該伸手去拿的 API,也各有一個您忽略細節時就會反咬一口的失敗
閱讀順序住在結構樹裡,不在塗繪順序裡
ISO 32000-1 §14.8 把邏輯結構定義為一棵疊在頁面內容之上的元素樹。PDF/UA(ISO 14289-1)更進一步,把那棵樹變成強制的:每一塊真實內容都必須依閱讀順序經由它抵達,而頁面裝飾物則要標示出來並跳過。一份正確標記的報表知道「Quarterly Results」是第二層標題,也知道那個合計格線是一個帶標題儲存格的表格。一份未標記的報表,則是一堆碰巧看起來像文件的定位字符串
ReadablePageContent 會在結構樹存在時走過它,交回帶著語意 Kind 標記的片段,值像是 cfHeading 與 cfParagraph,讓 UI 能在唸出文字之前先說「標題」,而不是把一行粗體當成普通內文讀掉。若沒有可用的結構樹,同一個呼叫會退回啟發式版面分析:偵測欄、把基線分群、由左至右、由上至下排序。那條退路對單欄的備忘錄還行,對電子報、多欄表單,以及任何帶側欄或引文區塊的東西就很勉強。要緊的是知道您拿到的是哪一種結果,而 API 直白地告訴您。TPdfReadableContent 記錄帶著一個 Source 欄位,順序來自標記樹時它是 rosStructure,從幾何推論出來時則是 rosHeuristic。把猜出來的順序當成已驗證的呈現給使用者,您出貨的就是無障礙版的「沒人跑過的建置卻掛著綠色徽章」
開檔時最便宜的動作,是讀一下 IsTagged 並呼叫一次 ValidatePdfUa,然後把答案快取起來。PDF/UA 檢查沒過,不構成拒收檔案的理由。它構成的是在狀態列寫上「估算的閱讀順序」的理由,這樣當客戶寄來一封抱怨朗讀亂七八糟的信時,支援人員一眼就知道他們面對的是檔案的標記問題,還是您程式碼裡的臭蟲
用 ReadingUnits 把頁面變成語音佇列
做文字轉語音時,ReadingUnits 扛起了重活。它為目前作用中的頁面回傳一個 TPdfReadingUnit 記錄陣列,每一筆都裝著要唸的文字、它的語意角色,以及在頁面上定位它的那些矩形。若您想跨頁連續朗讀,還有一個文件層級的搭檔 DocumentReadingUnits。一個單元剛好落進語音佇列的一個槽位:
procedure TReaderForm.QueuePageSpeech(PageNumber: Integer);
var
Units: TPdfReadingUnits;
i: Integer;
begin
Pdf.PageNumber := PageNumber; // ReadingUnits 作用於目前作用中的頁面
Units := Pdf.ReadingUnits;
FSpeechQueue.Clear;
for i := Low(Units) to High(Units) do
FSpeechQueue.Add(Units[i]); // 文字 + 語意 + 標示矩形
FCurrentPage := PageNumber;
SpeakNextUnit;
end;
那個迴圈裡有兩件事很容易做錯。請讓佇列以頁為單位,並在使用者換頁時重建它,因為閱讀單元帶的是頁面空間的矩形;從第三頁留下來的佇列,會把它的標示畫到第四頁上。另外,當一頁明明有內容、Units 陣列卻是空的,請把它當成純影像的偵測器。掃描頁是沒有底層文字層的像素,正確的作法是唸出一句警告(「這一頁沒有可擷取的文字」),而不是就此沉默,讓聽的人分不出這跟當機有什麼差別
跟著語音走的字詞游標
對一位用眼睛跟著朗讀追字的弱視使用者來說,一次標示一整段感覺遲鈍。逐字標示,也就是卡拉 OK 效果,需要兩塊東西:每個字詞的幾何,以及把 TTS 引擎的進度回報對映到那些幾何上的方法。PageWordBoxes 以 TPdfWordBox 記錄給您幾何,每一筆都帶著字詞文字、它的字元位移、字元數,以及一個頁面空間矩形。TrackReadingWordAt 則給您那道對映。把 SAPI 的字詞邊界事件本來就會回報的字元位置餵給它,它就會把那個位移解析成字詞方框陣列中的索引,並在一次呼叫裡把游標畫到對應的字詞上
procedure TReaderForm.PrepareKaraoke(PageNumber: Integer);
begin
// 檢視的字詞方框來自檢視所顯示的那一頁。
// 只設定 Pdf.PageNumber 並不會移動檢視
PdfView.PageNumber := PageNumber;
FWordBoxes := PdfView.PageWordBoxes;
end;
procedure TReaderForm.OnTtsWordBoundary(Sender: TObject; CharIndex: Integer);
var
WordIdx: Integer;
begin
// TrackReadingWordAt 會對映位移,同時畫出字詞游標
WordIdx := PdfView.TrackReadingWordAt(FCurrentPage, CharIndex);
if WordIdx < 0 then
PdfView.ClearReadingWord; // 邊界事件跑過了頁面文字的結尾
end;
這份契約在一件事上很寬厚,在另一件事上毫不留情。寬厚的部分:TrackReadingWordAt 會為它正在追蹤的那一頁保有自己的字詞方框快取,所以沒有什麼要預先載入的,而且完全不會發生呈現,因為字詞方框來自文字層。一個沒有可見視窗的無介面語音服務,照樣追蹤得到位置。不留情的部分:那個字元索引必須指進元件擷取出來的文字,而不是指進您自己整理出來的某條字串。當 CharIndex 跑過頁面文字結尾時,函式回傳 -1 而不是擲出例外,而這在 TTS 引擎為結尾標點多發一次邊界事件時天天都在發生。請把 -1 讀成「清掉游標」,絕不要把它當成錯誤
在顯示這一側,ReadingWordColor 設定游標顏色。預設的琥珀色在多數頁面背景上都撐得住,但請在您檢視器提供的每一種顯示濾鏡下測試它。琥珀色游標在色彩反轉之下可能徹底消失,而反轉與語音並用,恰恰是弱視使用者的工作方式,所以您最需要做對的那個組合,正是隨手示範永遠不會演練到的那個。把 ReadingWordFollow 設為 True,檢視就會自動把唸到的字詞捲進視野,在一個放大到跨越好幾個螢幕的頁面上,這是不可或缺的。請留意一條範圍規則:SetReadingWord 只畫在目前作用中的 TPdfView 頁面上。請一開始就決定手動捲動是暫停語音,還是由跟隨行為覆蓋它,因為兩者都不選,就會落得語音繼續唸、游標卻停在畫面外某處
會弄壞您閱讀器的那些文件
有少數幾種輸入形態,能穩定地擊敗一份天真的實作,穩定到它們該被當成回歸測試套件裡的長駐樣本,而不是修完就忘的一次性臭蟲
- 未標記但文字豐富的檔案。啟發式順序對線性報表通常是對的,但只要側欄或引文區塊一進場就錯。請在 UI 與診斷記錄兩處都把順序標示為估算值,讓這個失敗日後讀得懂
- 純影像的掃描檔。完全沒有文字層。請透過空的閱讀單元抓出它們,並把使用者指向上游的 OCR 步驟,而不是讓閱讀器對著空白頁朗讀
- 組合字元與混合書寫系統。Unicode 組合記號不見得一對一收合成視覺上的字詞,所以字詞方框的數量可能與您自家斷詞器所預期的漂開。請不要用您自己切分文字算出的位移去索引字詞方框陣列;只用
TrackReadingWordAt回傳的索引
像稽核員那樣測,別像做示範那樣測
「它把我的樣本唸出來了」什麼都證明不了。一次站得住腳的通過,是拿三份檔案在完成的建置上、接著 NVDA 跑一遍:一份已知有標記的檔案,其中標題被宣告為標題,表格依列的順序被讀出來;一份已知未標記的檔案,其中估算順序的指示器看得見;還有一份掃描檔,其中無文字的警告真的被唸了出來。每一份都演練到順利路徑會跳過的一條路
接著,請確認字詞游標在兩倍語速與半速下都穩穩鎖住,而且 ReadingWordFollow 的捲動不會和使用者自己的捲動角力。然後一邊放語音、一邊把每一種色彩濾鏡循環一遍,盯著游標從不消失。弱視色彩濾鏡一文詳談了那條呈現路徑,而字詞語音游標深入剖析則拆解了 TTS 的時序
上面用到的閱讀單元與字詞方框 API,隨適用於 Delphi 與 C++Builder(VCL)以及 Lazarus/FPC(LCL)的 PDFium Component 出貨。產品頁面連向完整的 API 參考,其中包含這些範例背後閱讀單元與字詞方框的記錄版面