技術文章

在 Delphi PDFium 檢視器中逐字語音轉換 (TTS) 標示

朗讀功能在語音之外還有一個可見的任務:當讀出每個單字時,必須在頁面上標示該單字並保持其在視野中。要做到這一點,您需要每個單字的邊界方塊(bounding box),並將其索引至語音引擎正在讀取的相同字元流。如果取得方塊但錯失了索引,標示就會落後音訊一兩個單字;如果取得索引但處理頁面狀態失誤,標示就會落在完全錯誤的頁面上。其中語音的部分,即合成器本身,是極少出錯的部分。SAPI 會將單字邊界報告到字元等級。容易出錯的,是語音緩衝區中字元偏移量與渲染頁面上矩形之間薄薄的映射層

PDFium 元件為 Delphi、C++Builder 和 Lazarus 提供了該映射,自 v1.53 起提供詞彙方塊,而追蹤游標則自 v1.56 起提供。介面刻意保持簡潔:一個返回頁面詞彙方塊的呼叫,一個將字元偏移量轉換為繪製標示的追蹤器,以及幾個用於顏色和自動捲動的屬性。儘管介面簡潔,您呼叫它們的順序決定了功能是否能運作,而下方的大多數失敗都源於以錯誤的順序呼叫了正確的函式

字元不是單字,而 TTS 引擎是以字元說話的

語音引擎消耗一個平坦的字串,並以該字串內的字元位置來報告進度。PDF 頁面則將字形放置在頁面空間中,在該空間中,「單字」是字形序列(glyph runs)的啟發式群集。這兩個座標系統沒有任何交集,除非您交給合成器的文本,與計算詞彙方塊的文本逐字節完全相同。這是第一法則,而且是不容通融的。如果您將空白標準化、移除軟連字號,或在讀出提取的文本之前以其他方式對其進行「清理」,那麼下游的每一個偏移量都會默默出錯。請精確地讀出您提取的內容,或者維護一個明確的偏移量重新映射表。在真實檔案中,不存在能倖存的第三種選擇

重新映射表並不是一個假設的極端情況。當您的 UI 插入了一個口語的頁面廣播(「第五頁」),或者為合成器展開了一個縮寫時,口語字串就會偏離提取的字串。記錄每次插入的位置和長度,然後在每次追蹤呼叫之前減去累積的調整值。這大約需要 20 行簿記程式碼,而這正是能撐過下一個功能請求的標示,與第一次有人要求口語標題時就崩潰的標示之間的差別

詞彙方塊提供給您的資訊

每個 TPdfWordBox 記錄帶有該單字的文本、其在頁面文本中的 StartIndex 和字元 Count、一個頁面空間的 Rect 以及 1-based 的 Page 頁碼。StartIndex 欄位是兩個座標系統之間的橋樑:它與 SAPI 在讀取時交回的偏移量相同。PageWordBoxes 傳回作用中頁面的完整陣列:

procedure TReaderForm.PreparePage(PageNo: Integer);
begin
  PdfView.PageNumber := PageNo;   // 檢視器的詞彙方塊會追蹤其顯示的頁面

  FWords := PdfView.PageWordBoxes;
  FPageText := BuildSpeechText(FWords);   // 依序串聯 Word.Text

  if Length(FWords) = 0 then
    HandleImageOnlyPage(PageNo);          // 沒有文本層的掃描檔
end;

順序的註解是具承載意義的。檢視器的 PageWordBoxes 會對目前檢視器所顯示頁面的文本層進行詞彙化(tokenize),因此先進行檢視器導覽,再進行提取;這不需要渲染,只需要一份開啟的檔案。(檔案元件 TPdf 則公開了它自己基於 Pdf.PageNumber 鍵值的 PageWordBoxes 供無頭(headless)使用。這兩個頁碼是獨立的,這本身就是一個陷阱。)在視覺上有內容的頁面上得到空結果,表示這是純影像的掃描檔。將它傳遞給 OCR,或者至少宣告它(「第 4 頁未包含可讀文本」),而不是讓語音在沒有任何解釋的情況下陷入沉默

將 SAPI 單字邊界連接至追蹤器

在檢視器上的 TrackReadingWordAt,是整個功能的樞紐。給它一個頁碼和字元索引;它會找到包含該字元的詞彙方塊,在上面繪製閱讀游標,並傳回單字索引,或者當索引落在單字之間時傳回 −1。SAPI 的單字邊界通知剛好提供了它想要的確切字元位置:

procedure TReaderForm.OnSpeechWordBoundary(StreamPos: Integer);
var
  WordIdx: Integer;
begin
  // 在一次呼叫中將偏移量映射到詞彙方塊並移動標示
  WordIdx := PdfView.TrackReadingWordAt(FPageNo, StreamPos);
  if WordIdx < 0 then
    Exit;                     // 邊界落在任何單字之外:保持上一個標示
end;

有兩個防禦性細節在此發揮作用。首先,TrackReadingWordAt 針對被追蹤的頁面保留了它自己的詞彙方塊快取,在頁面變更時會自動重建,因此無論邊界抵達多快,每個邊界的成本都保持平坦。其次,它並不會寬鬆地檢查邊界。索引如果等於或超出頁面的字元數,將傳回 −1,而不是箝制(clamp)到最後一個單字。將 −1 視為「保留上一個標示」,永遠不要將其視為錯誤,因為標點符號和單字間的空白合法地會產生不屬於任何單字的邊界。記錄每個 −1 會將您淹沒。請改為按頁面計算它們的數量,並仔細檢視比率飆升的任何頁面,因為這通常意味著文本標準化與第一法則不相符

游標本身:顏色、跟隨與清理

當您自己持有詞彙方塊時,SetReadingWord 會直接繪製標示,ReadingWordColor 會為其設定樣式,而 ReadingWordFollow := True 則會捲動檢視,恰好足以保持口語單字的可見。最後一個屬性有其存在的必要。手動寫的「置中目前單字」捲動會使頁面在每次斷行時發生顛簸,而對運動敏感的讀者會在一分鐘內關閉整個功能。標示只在作用中 TPdfView 目前顯示的頁面上渲染,因此多頁閱讀必須使 PageNumber 與語音同步前進,然後在新頁面的第一個邊界事件到達之前,針對該新頁面重新執行準備步驟。跳過這一步驟,每一頁的前幾個標示都會指向過時的座標

procedure TReaderForm.StopReading;
begin
  FVoice.Stop;                // 首先停止 SAPI 播放
  PdfView.ClearReadingWord;   // 接著移除標示;一個過時的游標看起來像個錯誤
end;

關閉時的對稱性是保持標示準確的關鍵。每個暫停、停止和翻頁路徑都必須以 ClearReadingWord 結束。如果遺漏了,一個琥珀色的矩形就會停留在已停止的頁面上,看起來完全像個缺陷,這是每位測試人員都會回報的那種問題,儘管實際上並沒有任何東西損壞

與檔案大小相比,語音速率對此管線的壓力更大。在每分鐘 300 個單字的情況下,邊界事件每 200 毫秒抵達一次,而在最快的 SAPI 速率下,它們來得比眼睛舒適追蹤的速度還快。正確的應對是合併,而不是排隊。如果一個新的邊界抵達時,標示更新仍在擱置中,丟棄過時的並繪製最新的。一個依序造訪每個單字但延遲半秒的游標感覺是壞的;一個偶爾跳過一個單字,但保持與聲音同步的游標則不會

區分展示與產品的極端情況

有幾種檔案的類別會暴露出缺陷。組合字元是最微妙的:Unicode 序列(如基本字母加上組合發音符號)所佔用的字元索引,可能比視覺單字所暗示的要多,因此任何假設每個字形一個索引的偏移量算術都會慢慢偏移。這是讓 TrackReadingWordAt 擁有該映射層,而不是手動計算單字編號的最有力論據。連字號比較尋常但更常見:一個跨越斷行的單字變成了兩個方塊,如果您將其當作單一詞彙(token)讀出,它後半部分的邊界事件會解析到第一個方塊。這通常沒問題,但這是一個決定,所以請有意識地做出這個決定,而不是去發掘它。標籤則會改變閱讀順序本身。當檔案帶有適當的結構標籤(ISO 14289,PDF/UA 的範疇)時,單字順序會遵循邏輯結構;如果沒有它們,它會退回到版面配置啟發式規則,這時一個兩欄未標記的頁面可能會直接橫跨兩欄朗讀。旋轉頁面是最後一個常見的情況:每個單字的 Rect 仍然在頁面空間中正確地框住它,但是當文本垂直走向時,針對水平流動所調整的視埠跟隨原則(viewport-follow policy)會造成令人震驚的捲動,因此請在回歸測試集中保留至少一個旋轉檔案。關於閱讀順序處理、透過 ReadingUnits 獲得句子等級單元,以及更廣泛的輔助堆疊,請參閱在 Delphi 中建置無障礙 PDF 閱讀器

有一項平台限制影響了部署。SAPI 是 Windows 獨有的。詞彙方塊和追蹤 API 在 Lazarus 和 FPC 下逐字節都是相同的,但是 Linux 和 macOS 構建需要不同的合成器連線到相同的邊界事件背後;該設定在在 Lazarus 和 FPC 下執行檢視器中有所涵蓋。一旦語音速率攀升,標示成本也會與您的頁面快取產生交互作用,而渲染快取與縮放效能中的預算算術可不經修改地直接套用於此

當單字標示是錯誤粒度時

逐字的卡拉 OK 並不總是讀者想要的。在高速語音速率下,逐字閃爍的游標本身就變成了視覺雜訊,而有些聽眾跟隨一個句子比跟隨頻閃的單字更舒服。對於這種情況,元件公開了較粗略的單元。ReadingUnits 返回句子等級和區塊等級的單元,每個單元都有自己的標示矩形,您可以透過 SetReadingHighlight 而不是 SetReadingWord 來繪製它們。連接形狀是相同的:邊界偏移量仍然驅動哪個單元發亮,但您標示的單元跨越的是子句或行,而不是單一詞彙。較慢的讀者和高速播放通常都偏好它,而且沒有什麼能阻止您將這兩種模式隱藏在設定背後同時提供

在您針對這個進行建置之前,值得先確定版本底線:詞彙方塊需要 PDFium 元件 v1.53 或更新版本,而追蹤游標需要 v1.56。完整的閱讀 API、句子等級的單元,以及可運作的朗讀展示,都在 PDFium 元件 產品頁面上