Teknisk artikkel

Ord-for-ord TTS-markering i Delphi PDFium-lesere

En høytlesingsfunksjon (read-aloud) har én synlig oppgave utover selve stemmen: etter hvert som hvert ord leses opp, må den lyse opp det ordet på siden og holde det i syne. For å få til det trenger du avgrensningsboksen (bounding box) for hvert eneste ord, indeksert til den samme tegnstrømmen som talemotoren (the speech engine) leser fra. Finn boksene, men bom på indekseringen, og markeringen driver et ord eller to bak lyden; finn indekseringen, men feilhåndter sidetilstanden, og markeringen lander på feil side i sin helhet. Taledelen av dette, selve synthesizeren, er den delen som sjelden bryter sammen. SAPI rapporterer ordgrenser på tegnnivå. Det som feiler er det tynne tilordningslaget (mapping layer) mellom et tegnutgangspunkt (character offset) i talebufferen og et rektangel på den gjengitte siden

PDFium Component leverer denne tilordningen for Delphi, C++Builder og Lazarus, med ord-bokser tilgjengelig siden v1.53 og sporingsmarkøren (the tracking cursor) siden v1.56. Overflaten (the surface) er bevisst smal: et kall som returnerer ordboksene for en side, en sporer (tracker) som gjør om et tegnutgangspunkt til en tegnet markering, og et par egenskaper for farge og auto-rull (auto-scroll). Smal som den er, rekkefølgen du kaller ting i avgjør om funksjonen fungerer, og de fleste av feilene nedenfor stammer fra å kalle de riktige funksjonene i feil rekkefølge

Tegn er ikke ord, og TTS-motorer snakker i tegn

En talemotor forbruker en flat streng og rapporterer fremdrift som tegnposisjoner innenfor den strengen. En PDF-side har tegn (glyphs) plassert i siderommet (page space), der et "ord" er en heuristisk klynge av tegngrupper (glyph runs). De to koordinatsystemene deler ingenting med mindre teksten du overleverer synthesizeren er byte-for-byte den samme teksten ord-boksene ble beregnet fra. Dette er regel nummer én, og den er nådeløs. Normaliser mellomrom (whitespace), fjern myke bindestreker (soft hyphens), eller "rydd opp" i den utpakkede teksten på noen annen måte før du uttaler den, og hvert etterfølgende utgangspunkt (offset) er stille feil. Les opp nøyaktig det du trakk ut, eller hold en eksplisitt tabell for omkoding av utgangspunkt. Det finnes ingen tredje utvei som overlever ekte dokumenter

Omkodingstabellen er ikke et hypotetisk spesialtilfelle (edge case). I det øyeblikket brukergrensesnittet ditt setter inn en opplest sidekunngjøring ("side fem") eller utvider en forkortelse for synthesizeren, avviker den oppleste strengen fra den utpakkede. Registrer hver innsettings posisjon og lengde, og trekk deretter fra den akkumulerte justeringen før hvert sporingskall. Det er kanskje tjue linjer med bokføring, og det utgjør forskjellen mellom en markering som overlever neste funksjonsforespørsel, og en som bryter sammen første gang noen ber om oppleste overskrifter

Hva en ordboks gir deg

Hver TPdfWordBox-record (post) bærer ordets tekst, dens StartIndex og tegn-Count (tegnantall) innenfor sideteksten, et sideroms-Rect, og det 1-baserte Page-nummeret (sidenummeret). StartIndex-feltet er broen mellom de to koordinatsystemene: det er det samme utgangspunktet SAPI vil levere tilbake mens den leser. PageWordBoxes returnerer den fulle arrayen (matrisen) for den aktive siden:

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;

Kommentaren om rekkefølge er bærende (load-bearing). Leserens PageWordBoxes deler tekstlaget til den siden visningen for øyeblikket viser opp i biter (tokeniserer), så naviger visningen først og trekk ut etterpå; ingen gjengivelse kreves, kun et åpent dokument. (Dokumentkomponenten, TPdf, eksponerer sine egne PageWordBoxes nøklet til Pdf.PageNumber for hodeløs (headless) bruk. De to sidenumrene er uavhengige, noe som er en felle i seg selv.) Et tomt resultat på en side som synlig inneholder innhold, betyr en skanning med bare bilder (image-only scan). Send det videre til OCR, eller i det minste kunngjør det ("side 4 inneholder ingen lesbar tekst"), i stedet for å la stemmen bli taus uten noen forklaring

Koble SAPIs ordgrenser til sporeren

TrackReadingWordAt, på leseren (the viewer), er hengselet for hele funksjonen. Gi den et sidenummer og en tegnindeks; den finner ordboksen som inneholder det tegnet, maler lesemarkøren på den, og returnerer ordindeksen, eller −1 når indeksen faller mellom ord. SAPIs varsling om ordgrense leverer nøyaktig den tegnposisjonen den vil ha:

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;

To defensive detaljer forsvarer sin plass her. For det første holder TrackReadingWordAt sin egen ordboks-buffer (cache) for den sporede siden, bygget om automatisk når siden endres, så kostnaden per grense forblir flat uansett hvor raskt grensene ankommer. For det andre sjekker den ikke grensene (bounds-check) sjenerøst. En indeks ved eller over sidens tegnantall returnerer −1 i stedet for å klemme til (clamping) det siste ordet. Behandle −1 som "behold den forrige markeringen," aldri som en feil, fordi tegnsettingsgrupper og mellomrom mellom ord legitimt produserer grenser som ikke tilhører noe ord. Å logge hver −1 vil begrave deg. Tell dem per side i stedet, og se nøye på enhver side der forholdet skyter i været, siden det vanligvis betyr et misforhold (mismatch) i tekstnormalisering tilbake ved regel én

Selve markøren: farge, følg og opprydding

SetReadingWord maler markeringen direkte når du holder ordboksen selv, ReadingWordColor formgir den, og ReadingWordFollow := True ruller (scrolls) visningen akkurat nok til å holde det talte ordet synlig. Den siste egenskapen fortjener plassen sin. En hjemmesnekret "sentrer gjeldende ord"-rull (scroll) får siden til å rykke ved hvert linjeskift, og bevegelsesfølsomme lesere (motion-sensitive readers) vil slå av hele funksjonen i løpet av et minutt. Markeringen (the highlight) gjengis kun på siden som for øyeblikket vises i det aktive TPdfView, så flersidig lesing (multi-page reading) må fremme PageNumber i takt med talen, for deretter å kjøre forberedelsestrinnet (prepare step) på nytt for den nye siden før dens første grensehendelse lander. Hopp over det, og de første markeringene på hver side peker på utdaterte (stale) koordinater

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

Symmetri ved avslutning (shutdown) er det som holder markeringen ærlig. Hver pause, stopp og sideblaing-sti (page-turn path) må ende i ClearReadingWord. Utelat det, og et gult rektangel sitter på en stoppet side og ser nøyaktig ut som en feil, noe som er den typen ting hver tester vil rapportere, selv om ingenting faktisk er ødelagt

Talehastighet (Speech rate) stresser denne rørledningen (pipeline) hardere enn dokumentstørrelse gjør. Ved 300 ord i minuttet ankommer grensehendelsene (the boundary events) hver 200. ms, og ved de raskeste SAPI-hastighetene kommer de raskere enn øyet komfortabelt klarer å spore. Den rette responsen er å slå sammen (coalesce), ikke å sette i kø. Hvis en ny grense ankommer mens en markeringsoppdatering (highlight update) fortsatt venter, slipp den utdaterte og mal den siste. En markør som besøker hvert ord i rekkefølge, men som henger etter et halvt sekund, føles ødelagt; en som av og til hopper over et ord mens den holder seg synkronisert med stemmen, gjør det ikke

Spesialtilfeller som skiller demoer fra produkter

Noen kategorier av dokumenter avslører skjøtene. Kombinerende tegn er de mest subtile: Unicode-sekvenser som en grunnbokstav pluss et kombinerende diakritisk tegn kan oppta flere tegnindekser enn det visuelle ordet antyder, så all utgangspunktaritmetikk (offset arithmetic) som antar én indeks per tegn (glyph), vil sakte drive. Det er det sterkeste argumentet for å la TrackReadingWordAt eie tilordningen (the mapping) i stedet for å beregne ordnumre for hånd. Orddeling er mer prosaisk, men mer vanlig: et ord delt over et linjeskift blir til to bokser, og hvis du uttaler det som en enkelt enhet (token), løses grensehendelsen for dens andre halvdel til den første boksen. Det går som regel fint, men det er en beslutning, så gjør den med hensikt i stedet for å oppdage den. Tagging endrer selve leserekkefølgen. Når et dokument bærer korrekte struktur-tags (territoriet til ISO 14289, PDF/UA), følger ordrekkefølgen den logiske strukturen; uten dem faller den tilbake til layout-heuristikk, og en to-kolonners utagget side kan lese rett over begge kolonnene. Roterte sider er den siste vanlige: Hvert ords Rect avgrenser det fortsatt riktig i siderommet, men en visningsfølgepolicy (viewport-follow policy) innstilt for horisontal flyt, ruller (scrolls) rystende når teksten løper vertikalt, så behold minst ett rotert dokument i regresjonssettet (the regression set). For håndtering av leserekkefølge, enheter på setningsnivå via ReadingUnits, og den bredere støttestakken (assistive stack), se å bygge en tilgjengelig PDF-leser i Delphi

Én plattformbegrensning former utrullingen (deployment). SAPI finnes kun på Windows. Ord-boks- og sporings-API-et er byte-for-byte identisk under Lazarus og FPC, men Linux- og macOS-bygg trenger en annen synthesizer koblet bak de samme grensehendelsene; det oppsettet dekkes i kjøring av leseren (the viewer) under Lazarus og FPC. Kostnad for markering samhandler også med din sides hurtigbuffer (page cache) når talehastighetene stiger, og budsjettaritmetikken (budget arithmetic) i hurtigbuffer for gjengivelse (render caching) og zoom-ytelse overføres hit uten endringer

Når enkeltordsmarkering er feil detaljnivå (granularity)

Karaoke på ordnivå er ikke alltid det en leser vil ha. Ved høye talehastigheter (speech rates) blir markøren (cursor) som flimrer ord for ord sin egen visuelle støy, og noen lyttere følger en setning mer komfortabelt enn en stroboskop av enkeltord. For dette tilfellet eksponerer komponenten en grovere enhet. ReadingUnits returnerer enheter på setnings- og blokknivå, hver med sine egne markeringsrektangler, og du maler dem med SetReadingHighlight i steden for SetReadingWord. Oppkoblingen (wiring) er den samme formen: et grenseutgangspunkt (boundary offset) styrer fortsatt hvilken enhet som lyser opp, men enheten du markerer spenner over et ledd (clause) eller en linje i steden for et enkelt token. Tretigere lesere og avspilling i høy hastighet har begge en tendens til å foretrekke det, og ingenting hindrer deg i å tilby begge moduser bak en innstilling

Det er verdt å fastslå versjonsgulvene (version floors) før du bygger mot dette: ordbokser krever PDFium Component v1.53 eller nyere, og sporingsmarkøren (the tracking cursor) krever v1.56. Det fulle lese-API-et (reading API), enhetene på setningsnivå (sentence-level units) og en fungerende høytlesingsdemo finnes på produktsiden for PDFium Component