Технічна стаття

Підсвічування TTS слово за словом у переглядачах Delphi PDFium

Функція читання вголос має одне видиме завдання, крім самого голосу: коли вимовляється кожне слово, це слово має підсвічуватися на сторінці і залишатися у полі зору. Для цього потрібен обмежувальний прямокутник кожного слова, прив'язаний до того самого потоку символів, з якого читає рушій синтезу мовлення. Отримати прямокутники, але пропустити прив'язку – і підсвічення відставатиме від аудіо на слово-два; отримати прив'язку, але неправильно обробити стан сторінки – і підсвічення з'явиться на зовсім іншій сторінці. Мовна частина, сам синтезатор – це та частина, яка рідко ламається. SAPI повідомляє межі слів на символ. Ламається тонкий шар відображення між зміщенням символу в буфері мовлення і прямокутником на відрендереній сторінці

PDFium Component постачає це відображення для Delphi, C++Builder і Lazarus: словникові бокси доступні з v1.53, а курсор відстеження – з v1.56. Інтерфейс навмисно вузький: виклик, що повертає словникові бокси для сторінки, трекер, що перетворює зміщення символу в намальоване підсвічення, і кілька властивостей для кольору та автопрокрутки. Незважаючи на вузькість, порядок виклику визначає, чи працює функція, і більшість відмов нижче виникає через виклик правильних функцій у неправильній послідовності

Символи – це не слова, а рушії TTS оперують символами

Рушій синтезу мовлення споживає плоский рядок і повідомляє про прогрес як позиції символів у цьому рядку. Сторінка PDF має гліфи, розміщені в просторі сторінки, де "слово" – це евристичний кластер груп гліфів. Дві системи координат не мають нічого спільного, якщо текст, переданий синтезатору, не є побайтово ідентичним тексту, за яким обчислювалися словникові бокси. Це правило перше, і воно невблаганне. Нормалізуйте пробіли, видаліть м'які дефіси або будь-яким чином "очистіть" витягнутий текст перед промовлянням – і кожне наступне зміщення буде мовчки помилковим. Промовляйте саме те, що витягли, або зберігайте явну таблицю перевідображення зміщень. Третього варіанту, що виживе на реальних документах, не існує

Таблиця перевідображення – це не гіпотетичний крайній випадок. Щойно ваш інтерфейс вставляє оголошення сторінки ("сторінка п'ять") або розгортає скорочення для синтезатора, промовлений рядок розходиться з витягнутим. Запишіть позицію і довжину кожної вставки, а потім віднімайте накопичену поправку перед кожним викликом відстеження. Це близько двадцяти рядків обліку, і це різниця між підсвіченням, що переживе наступний запит на функцію, і тим, що зламається, щойно хтось попросить озвучувати заголовки

Що дає словниковий бокс

Кожен запис TPdfWordBox містить текст слова, його StartIndex і кількість символів Count у тексті сторінки, Rect у просторі сторінки та 1-засноваий номер Page. Поле StartIndex – це міст між двома системами координат: це те саме зміщення, яке поверне SAPI під час читання. 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;

Коментар про порядок є ключовим. PageWordBoxes переглядача токенізує текстовий шар сторінки, що відображається у поточний момент, тому спочатку перейдіть до потрібної сторінки, а потім витягайте; рендеринг не потрібен, лише відкритий документ. (Компонент документа, TPdf, надає власний PageWordBoxes, прив'язаний до Pdf.PageNumber для headless-використання. Два номери сторінок є незалежними, що само по собі є пасткою.) Порожній результат на сторінці, що візуально містить контент, означає скан лише із зображенням. Передайте його до OCR або принаймні оголосіть про це ("сторінка 4 не містить читабельного тексту"), а не дозволяйте голосу замовкнути без пояснень

Підключення меж слів SAPI до трекера

TrackReadingWordAt на переглядачі – це вісь усієї функції. Передайте номер сторінки та індекс символу; він знаходить словниковий бокс, що містить цей символ, малює на ньому курсор читання та повертає індекс слова або −1, якщо індекс потрапляє між словами. Повідомлення 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 підтримує власний кеш словникових боксів для відстежуваної сторінки, що автоматично перебудовується при зміні сторінки, тому вартість кожної межі залишається постійною незалежно від швидкості їх надходження. По-друге, він не перевіряє межі щедро. Індекс на рівні або за межами кількості символів сторінки повертає −1 замість обмеження до останнього слова. Трактуйте −1 як "зберегти попереднє підсвічення", ніколи як помилку, бо символи пунктуації та пробіли між словами законно утворюють межі, що не належать жодному слову. Логування кожного −1 вас поховає. Натомість рахуйте їх на сторінку та уважно дивіться на сторінки, де співвідношення різко зростає – це зазвичай означає невідповідність нормалізації тексту ще на першому правилі

Сам курсор: колір, відстеження та очищення

SetReadingWord малює підсвічення безпосередньо, коли ви тримаєте словниковий бокс самостійно, ReadingWordColor стилізує його, а ReadingWordFollow := True прокручує вигляд рівно настільки, щоб вимовлене слово залишалося видимим. Остання властивість виправдовує своє місце. Власноруч написана прокрутка "відцентрувати поточне слово" змушує сторінку смикатися при кожному переносі рядка, і читачі, чутливі до руху, вимкнуть всю функцію менш ніж за хвилину. Підсвічення рендериться лише на сторінці, що відображається в активному TPdfView, тому багатосторінкове читання повинно просувати PageNumber разом із мовленням, а потім повторно виконувати крок підготовки для нової сторінки до того, як надійде перша межа. Пропустіть це – і перші кілька підсвічень на кожній сторінці будуть вказувати на застарілі координати

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

Симетрія під час зупинки – це те, що підтримує чесність підсвічення. Кожен шлях паузи, зупинки та перегортання сторінки має закінчуватися на ClearReadingWord. Залиште це – і бурштиновий прямокутник стоятиме на зупиненій сторінці і виглядатиме точно як дефект, а це те, що кожен тестувальник занесе до системи навіть попри те, що насправді нічого не зламано

Швидкість мовлення навантажує цей конвеєр сильніше, ніж розмір документа. При 300 словах на хвилину межі надходять кожні 200 мс, а при найшвидших швидкостях SAPI вони надходять швидше, ніж оку комфортно відстежувати. Правильна реакція – об'єднання, а не черга. Якщо нова межа надходить, поки оновлення підсвічення ще очікує, відкиньте застарілу та намалюйте найновішу. Курсор, що відвідує кожне слово по порядку, але відстає на пів секунди, здається зламаним; той, що зрідка пропускає слово, залишаючись синхронізованим із голосом – ні

Крайні випадки, що відокремлюють демо від продуктів

Кілька категорій документів розкривають слабкі місця. Комбіновані символи є найтоншою пасткою: послідовності Unicode, наприклад базова літера плюс діакритичний знак, що поєднується, можуть займати більше індексів символів, ніж підказує візуальне слово, тому будь-яка арифметика зміщень, що припускає один індекс на гліф, повільно дрейфує. Це найвагоміший аргумент на користь того, щоб TrackReadingWordAt керував відображенням, а не обчислювати номери слів вручну. Перенос рядка – більш буденний, але більш поширений: слово, розбите переносом рядка, стає двома боксами, і якщо ви вимовляєте його як єдиний токен, межа для його другої половини розв'язується до першого бокса. Зазвичай це нормально, але це рішення, тому приймайте його свідомо, а не відкривайте випадково. Теги змінюють сам порядок читання. Коли документ має правильні теги структури (область ISO 14289, PDF/UA), послідовність слів слідує логічній структурі; без них вона повертається до евристики макету, і двоколонна несправна сторінка може читатися прямо через обидві колонки. Повернуті сторінки – останній поширений випадок: Rect кожного слова досі правильно обмежує його у просторі сторінки, але політика прокрутки, налаштована для горизонтального потоку, прокручується різко, коли текст іде вертикально, тому тримайте принаймні один повернутий документ у наборі регресій. Для обробки порядку читання, одиниць рівня речення через ReadingUnits та ширшого стеку допоміжних засобів, дивіться побудову доступного засобу читання PDF у Delphi

Одне платформне обмеження впливає на розгортання. SAPI є лише для Windows. API словникових боксів і відстеження є побайтово ідентичним під Lazarus і FPC, але збірки для Linux і macOS потребують іншого синтезатора, підключеного за тими самими межевими подіями; це налаштування розглянуто в статті запуск переглядача під Lazarus і FPC. Вартість підсвічення також взаємодіє з кешем сторінок при зростанні швидкостей мовлення, і арифметика бюджету в статті кешування рендерингу та продуктивність масштабування застосовується тут без змін

Коли підсвічення одного слова – неправильна гранулярність

Підсвічення на рівні слова – це не завжди те, що хоче читач. При високій швидкості мовлення курсор, що мигає слово за словом, сам стає зоровим шумом, і деяким слухачам комфортніше стежити за реченням, ніж за стробом окремих слів. Для цього компонент надає більш грубу одиницю. ReadingUnits повертає одиниці рівня речення і блоку, кожна зі своїми прямокутниками підсвічення, і ви малюєте їх за допомогою SetReadingHighlight замість SetReadingWord. Підключення має ту саму форму: зміщення межі досі визначає, яка одиниця підсвічується, але одиниця, яку ви підсвічуєте, охоплює речення або рядок, а не окремий токен. Повільніші читачі та відтворення з високою швидкістю зазвичай віддають перевагу цьому варіанту, і ніщо не заважає пропонувати обидва режими через налаштування

Мінімальні версії варто зафіксувати перед побудовою: словникові бокси потребують PDFium Component v1.53 або пізнішої версії, а курсор відстеження – v1.56. Повний API читання, одиниці рівня речення та робоча демонстрація читання вголос знаходяться на сторінці продукту PDFium Component