Technical Article

Žodis po žodžio TTS paryškinimas Delphi PDFium peržiūros programose

Skaitymo balsu funkcija turi vieną matomą užduotį be paties balso: ištariant kiekvieną žodį, ji privalo paryškinti tą žodį puslapyje ir išlaikyti jį matomą ekrane. Norint tai padaryti, jums reikia kiekvieno žodžio apribojimo rėmelio (bounding box), susieto su tuo pačiu simbolių srautu, kurį skaito kalbos variklis. Gauti rėmelius, bet nesuderinti indeksavimo – ir paryškinimas atsiliks žodžiu ar dviem nuo garso; teisingai atlikti indeksavimą, bet netinkamai valdyti puslapio būseną – ir paryškinimas atsidurs visai kitame puslapyje. Sudėtingiausia dalis – pats sintezatorius – yra ta, kuri genda retai. SAPI praneša apie žodžių ribas vieno simbolio tikslumu. Genda plonas susiejimo sluoksnis tarp simbolio poslinkio kalbos buferyje ir stačiakampio atvaizduotame puslapyje.

„PDFium Component“ suteikia šį susiejimą Delphi, C++Builder ir Lazarus aplinkoms; žodžių rėmeliai prieinami nuo v1.53 versijos, o sekimo žymeklis – nuo v1.56. Sąsaja yra tikslingai siaura: iškvietimas, kuris grąžina puslapio žodžių rėmelius, sekiklis, kuris simbolio poslinkį paverčia nupieštu paryškinimu, bei keletas spalvos ir automatinio slinkimo savybių. Nors ji ir siaura, jūsų iškvietimų tvarka lemia, ar funkcija veiks, ir dauguma gedimų kyla dėl to, kad teisingos profesijos funkcijos iškviečiamos netinkama seka.

Simboliai nėra žodžiai, o TTS varikliai skaito simboliais

Kalbos variklis nuskaito plokščią eilutę ir praneša apie pažangą kaip apie simbolių pozicijas toje eilutėje. PDF puslapyje yra glifų pozicijos puslapio erdvėje, kur „žodis“ yra euristinė glifų sekų grupė. Dvi koordinačių sistemos neturi nieko bendro, nebent tekstas, kurį perduodate sintezatoriui, baitas po baito sutampa su tekstu, pagal kurį buvo apskaičiuoti žodžių rėmeliai. Tai pirmoji taisyklė, ir ji yra negailestinga. Normalizuokite tarpus, pašalinkite minkštus brūkšnelius ar kitaip „išvalykite“ išgautą tekstą prieš jį ištariant, ir visi tolimesni poslinkiai bus tyliai klaidingi. Sakykite tiksliai tai, ką išgavote, arba palaikykite aiškią poslinkių perbraižymo lentelę. Kito kelio, kuris tiktų tikriems dokumentams, nėra.

Perbraižymo lentelė nėra teorinis išskirtinis atvejis. Tą akimirką, kai jūsų sąsaja įterpia ištariamą puslapio pranešimą („penktas puslapis“) arba išplečia santrumpą sintezatoriui, tariama eilutė išsiskiria su išgauta. Užregistruokite kiekvieno įterpimo poziciją ir ilgį, tada atimkite sukauptą korekciją prieš kiekvieną sekimo iškvietimą. Tai užima apie dvidešimt eilučių kodo, ir būtent tai garantuoja, kad paryškinimas veiks po kito atnaujinimo, užuot sugriuvęs pirmą kartą pareikalavus ištarti antraštes.

Ką suteikia žodžio rėmelis

Kiekvienas TPdfWordBox įrašas turi žodžio tekstą, jo StartIndex ir simbolių skaičių (Count) puslapio tekste, stačiakampį Rect puslapio erdvėje ir 1-based puslapio numerį (Page). Laukas StartIndex yra tiltas tarp dviejų koordinačių sistemų: tai yra tas pats poslinkis, kurį SAPI grąžins skaitymo metu. PageWordBoxes grąžina pilną aktyvaus puslapio masyvą:

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;

Pastaba dėl eiliškumo yra labai svarbi. Peržiūros programos PageWordBoxes skaido į žodžius puslapį, kurį peržiūros programa šiuo metu rodo, todėl pirmiausia nustatykite rodinio poziciją, o tik tada išgaukite tekstą; atvaizdavimas nereikalingas, reikia tik atidaryto dokumento. (Dokumento komponentas TPdf pateikia savo PageWordBoxes, susietą su Pdf.PageNumber, darbui be vartotojo sąsajos. Šie du puslapių numeriai yra nepriklausomi, o tai yra atskiri spąstai.) Tuščias rezultatas puslapyje, kuriame matomas turinys, reiškia skenuotą vaizdą be teksto sluoksnio. Nukreipkite jį į OCR arba bent jau praneškite apie tai („ketvirtame puslapyje nėra skaitomo teksto“), užuot leidę balsui tiesiog nutilti be paaiškinimo.

SAPI žodžių ribų sujungimas su sekikliu

TrackReadingWordAt peržiūros programoje yra visos šios funkcijos pagrindas. Nurodykite puslapio numerį ir simbolio indeksą; ji suranda žodžio rėmelį, kuriame yra šis simbolis, nupiešia jame skaitymo žymeklį ir grąžina žodžio indeksą arba −1, kai indeksas patenka tarp žodžių. SAPI pranešimas apie žodžio ribą pateikia būtent tą simbolio poziciją, kurios reikia:

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;

Dvi apsauginės detalės čia yra labai naudingos. Pirma, TrackReadingWordAt palaiko savo žodžių rėmelių talpyklą sekamam puslapiui, atkuriamą automatiškai keičiantis puslapiui, todėl kaina už vieną ribos patikrinimą išlieka vienoda, nesvarbu, kaip greitai tos ribos ateina. Antra, ji neatlieka apytikslės ribų patikros. Indeksas, pasiekiantis ar viršijantis puslapio simbolių skaičių, grąžina −1, užuot prisitaikęs prie paskutinio žodžio. Vertinkite −1 kaip „išlaikyti ankstesnį paryškinimą“, o ne kaip klaidą, nes skyrybos ženklai ir tarpai tarp žodžių dėsningai sukuria ribas, nepriklausančias jokiam žodžiui. Žurnalizuoti kiekvieną −1 reikšmę būtų perteklinis darbas. Vietoj to skaičiuokite juos puslapyje ir atkreipkite dėmesį į puslapius, kuriuose šis santykis šokteli į viršų, nes tai dažniausiai reiškia teksto normalizavimo neatitikimą, paminėtą pirmojoje taisyklėje.

Pats žymeklis: spalva, sekimas ir išvalymas

SetReadingWord nupiešia paryškinimą tiesiogiai, kai patys valdote žodžio rėmelį, ReadingWordColor nustato jo stilių, o ReadingWordFollow := True paslenka rodinį tiek, kad ištartas žodis liktų matomas. Ši paskutinė savybė yra labai naudinga. Pačių sukurtas slinkimas „centruoti esamą žodį“ priverčia puslapį trūkčioti per kiekvieną eilutės perkėlimą, ir judesiui jautrūs skaitytojai išjungs šią funkciją per minutę. Paryškinimas atvaizduojamas tik tame puslapyje, kurį šiuo metu rodo aktyvus TPdfView, so multi-page skaitymas turi keisti PageNumber sinchroniškai su kalba, o tada vėl atlikti paruošimo žingsnį naujam puslapiui prieš atkeliaujant pirmajam ribos įvykiui. Praleiskite tai, ir pirmieji paryškinimai kiekviename puslapyje rodys pasenusias koordinates.

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

Sinchroniškumas išjungiant yra tai, kas palaiko paryškinimą tvarkingą. Kiekvienas sustabdymo, pristabdymo ir puslapio vertimo kelias turi baigtis ClearReadingWord iškvietimu. Pamirškite tai, ir gintarinis stačiakampis liks kaboti ant sustabdyto puslapio, atrodydamas kaip programos klaida – tai yra būtent tai, ką pastebės kiekvienas testuotojas, net jei iš tikrųjų niekas nesugedo.

Kalbos greitis apkrauna šią sistemą labiau nei dokumento dydis. Esant 300 žodžių per minutę greičiui, ribos įvykiai atkeliauja kas 200 ms, o esant didžiausiam SAPI greičiui, jie ateina greičiau, nei akis gali patogiai sekti. Teisingas sprendimas yra apjungti (coalesce), o ne rikiuoti į eilę. Jei nauja riba ateina, kol paryškinimo atnaujinimas vis dar laukia, atmeskite pasenusį ir nupieškite naujausią. Žymeklis, kuris aplanko kiekvieną žodį iš eilės, bet vėluoja puse sekundės, atrodo sugedęs; tas, kuris retkarčiais praleidžia žodį, bet išlaiko sinchronizaciją su balsu – ne.

Išskirtiniai atvejai, kurie skiria demonstracijas nuo realių produktų

Kelios dokumentų kategorijų atskleidžia kodo siūles. Kombinuojamieji simboliai (combining characters) yra patys subtiliausi: Unicode sekos (pavyzdžiui, bazinė raidė ir diakritinis ženklas) gali užimti daugiau simbolių indeksų, nei rodo vizualus žodis, todėl bet kokia poslinkių aritmetika, daranti prielaidą, kad vienas glifas atitinka vieną indeksą, pamažu nukrypsta. Tai yra stipriausias argumentas už tai, kad susiejimą valdytų TrackReadingWordAt, o ne žodžių numerių skaičiavimas rankiniu būdu. Žodžių perkėlimas brūkšneliu yra paprastesnis, bet dažnesnis atvejis: perkeltai žodis sudaro du rėmelius, ir jei ištariate jį kaip vieną leksemą, antrosios pusės ribos įvykis nukreipiamas į pirmąjį rėmelį. Tai dažniausiai yra priimtina, tačiau tai turi būti sprendimas, priimtas apgalvotai. Žymos (tagging) keičia pačią skaitymo tvarką. Kai dokumentas turi struktūros žymas (ISO 14289, PDF/UA sritis), žodžių seka seka loginę struktūrą; be jų ji remiasi išdėstymo euristika, todėl dviejų stulpelių nepažymėtas puslapis gali būti skaitomas tiesiai per abu stulpelius. Pasukti puslapiai yra paskutinis dažnas atvejis: kiekvieno žodžio Rect vis tiek teisingai riboja jį puslapio erdvėje, tačiau slinkimo politika, pritaikyta horizontaliam srautui, slenka netvarkingai, kai tekstas eina vertikaliai, todėl testavimo rinkinyje būtinai turėkite bent vieną pasuktą dokumentą. Apie skaitymo tvarkos valdymą, sakinių lygio vienetus per ReadingUnits ir bendrą prieinamumo sistemą skaitykite straipsnyje apie prieinamos PDF skaitytuvės kūrimas Delphi.

Vienas platformos apribojimas lemia diegimą. SAPI yra prieinamas tik Windows aplinkoje. Žodžių rėmelių ir sekimo API yra baitas po baito identiška Lazarus ir FPC aplinkose, tačiau Linux ir macOS versijoms reikia kitokio sintezatoriaus, prijungto prie tų pačių ribų įvykių; ši konfigūracija aprašyta straipsnyje apie peržiūros programos vykdymą Lazarus ir FPC aplinkose. Paryškinimo kaina taip pat sąveikauja su jūsų puslapių talpykla kylant kalbos greičiui, o atminties biudžeto apskaičiavimas iš straipsnio apie atvaizdavimo talpyklą ir mastelio keitimo našumą čia tinka be pakeitimų.

Kai vieno žodžio paryškinimas yra netinkamas mastelis

Žodžio lygio paryškinimas ne visada yra tai, ko nori skaitytojas. Esant dideliam kalbos greičiui, žodžio žymeklio mirgėjimas tampa vizualiniu triukšmu, o kai kurie klausytojai sakinį seka patogiau nei atskirų žodžių žybsėjimą. Šiam atvejui komponentas pateikia stambesnius vienetus. Savybė ReadingUnits grąžina sakinių ir blokų lygio vienetus, kurių kiekvienas turi savo paryškinimo stačiakampius, ir jūs juos nupiešiate naudodami SetReadingHighlight, o ne SetReadingWord. Pajungimas yra tokios pačios formos: ribos poslinkis vis tiek nustato, kuris vienetas nušvinta, tačiau jūsų paryškinamas vienetas apima frazę ar eilutę, o ne vieną žodį. Lėčiau skaitantys asmenys bei greitas atkūrimas dažniausiai renkasi šį būdą, ir niekas netrukdo pasiūlyti abiejų režimų per nustatymus.

Prieš pradedant darbą, verta žinoti minimalius versijų reikalavimus: žodžių rėmeliams reikalinga „PDFium Component“ v1.53 arba naujesnė versija, o sekimo žymekliui – v1.56 versija. Pilna skaitymo API, sakinių lygio vienetai ir veikianti skaitymo balsu demonstracinė versija yra pateikiamos produkto puslapyje: „PDFium Component“.