Technisch artikel

Woord-voor-woord TTS-markering in Delphi PDFium-viewers

Een voorleesfunctie heeft één zichtbare taak buiten de stem: naarmate elk woord wordt uitgesproken, moet het dat woord op de pagina oplichten en in het zicht houden. Om dat te doen heeft u het begrenzingskader (bounding box) van elk woord nodig, geïndexeerd naar dezelfde tekenreeks (character stream) waar de spraak-engine van leest. Krijg de vakken (boxes) maar mis de indexering en de markering dwaalt een woord of twee achter de audio; krijg de indexering maar ga verkeerd om met de paginastatus en de markering belandt op een heel andere pagina. Het spraakgedeelte hiervan, de synthesizer zelf, is het deel dat zelden breekt. SAPI rapporteert woordgrenzen (word boundaries) op het teken. Wat breekt is de dunne toewijzingslaag (mapping layer) tussen een teken-offset in de spraakbuffer en een rechthoek op de gerenderde pagina

PDFium Component levert die mapping voor Delphi, C++Builder en Lazarus, met woordvakken beschikbaar sinds v1.53 en de volgaanwijzer (tracking cursor) sinds v1.56. Het oppervlak is opzettelijk smal: een aanroep die de woordvakken voor een pagina retourneert, een tracker die een teken-offset omzet in een geschilderde markering, en een paar eigenschappen (properties) voor kleur en auto-scroll. Hoe smal het ook is, de volgorde waarin u dingen aanroept, bepaalt of de functie werkt, en de meeste van de onderstaande mislukkingen komen voort uit het aanroepen van de juiste functies in de verkeerde volgorde

Tekens zijn geen woorden, en TTS-engines spreken in tekens

Een spraak-engine verbruikt een platte tekenreeks (string) en rapporteert de voortgang als tekenposities binnen die reeks. Een PDF-pagina heeft glyphs die in de paginaruimte zijn geplaatst, waarbij een "woord" een heuristische cluster van glyph-runs is. De twee coördinatensystemen delen niets, tenzij de tekst die u aan de synthesizer overhandigt byte-voor-byte de tekst is waaruit de woordvakken zijn berekend. Dat is regel één, en hij is onverbiddelijk. Normaliseer witruimte, verwijder zachte koppeltekens (soft hyphens), of "schoon" de geëxtraheerde tekst op een andere manier op voordat u deze uitspreekt, en elke offset stroomafwaarts is stilzwijgend verkeerd. Spreek precies uit wat u hebt geëxtraheerd, of bewaar een expliciete offset-hertoewijzingstabel. Er is geen derde optie die echte documenten overleeft

De hertoewijzingstabel is geen hypothetisch randgeval. Zodra uw UI een gesproken pagina-aankondiging ("pagina vijf") invoegt of een afkorting voor de synthesizer uitvouwt, wijkt de gesproken string af van de geëxtraheerde string. Registreer de positie en lengte van elke invoeging en trek vervolgens de geaccumuleerde aanpassing af vóór elke volgaanroep (tracking call). Het is misschien twintig regels boekhouding, en het is het verschil tussen een markering die de volgende functieaanvraag overleeft en een die breekt de eerste keer dat iemand om gesproken koppen (headings) vraagt

Wat een woordvak u geeft

Elke TPdfWordBox record draagt de tekst van het woord, zijn StartIndex en teken-Count binnen de paginatekst, een paginaruimte-Rect en het op 1 gebaseerde Page-nummer. Het veld StartIndex is de brug tussen de twee coördinatensystemen: het is dezelfde offset die SAPI teruggeeft terwijl het leest. PageWordBoxes retourneert de volledige array voor de actieve pagina:

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;

De bestelcommentaar is dragend. De PageWordBoxes van de viewer tokeniseert de tekstlaag van de pagina die de view momenteel weergeeft, dus navigeer eerst in de view en extraheer als tweede; renderen is niet nodig, alleen een open document. (De documentcomponent, TPdf, stelt zijn eigen PageWordBoxes bloot (exposes), gekoppeld aan Pdf.PageNumber voor gebruik zonder hoofd (headless). De twee paginanummers zijn onafhankelijk, wat zijn eigen valstrik is.) Een leeg resultaat op een pagina die zichtbaar inhoud draagt, betekent een scan met alleen afbeeldingen. Routeer het naar OCR, of kondig het in ieder geval aan ("pagina 4 bevat geen leesbare tekst"), in plaats van de stem zonder uitleg stil te laten vallen

SAPI-woordgrenzen bedraden aan de tracker

TrackReadingWordAt, op de viewer, is het scharnier van de hele functie. Geef het een paginanummer en een tekenindex; het vindt het woordvak dat dat teken bevat, schildert de leescursor erop en retourneert de woordindex, of −1 wanneer de index tussen woorden valt. SAPI's melding van woordgrenzen levert precies de tekenpositie die het wil:

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;

Twee defensieve details verdienen hier hun plaats. Ten eerste houdt TrackReadingWordAt zijn eigen woordvak-cache bij voor de getraceerde pagina, die automatisch opnieuw wordt opgebouwd wanneer de pagina verandert, zodat de kosten per grens vlak blijven, ongeacht hoe snel de grenzen arriveren. Ten tweede voert het geen genereuze grenscontroles (bounds-checks) uit. Een index op of na het aantal tekens van de pagina retourneert −1 in plaats van vast te klemmen aan het laatste woord. Behandel −1 als "behoud de vorige markering", nooit als een fout, want leestekens (punctuation) en witruimte tussen woorden produceren legitiem grenzen die tot geen enkel woord behoren. Het loggen van elke −1 zal u begraven. Tel ze in plaats daarvan per pagina en kijk goed naar elke pagina waar de verhouding piekt, omdat dat meestal een tekstnormalisatie-mismatch betekent terug bij regel één

De cursor zelf: kleur, volgen en opruimen

SetReadingWord schildert de markering direct wanneer u het woordvak zelf vasthoudt, ReadingWordColor stileert het, en ReadingWordFollow := True scrollt de weergave net genoeg om het gesproken woord zichtbaar te houden. Dat laatste kenmerk (property) verdient zijn plaats. Een handgerolde "centreer het huidige woord"-scroll doet de pagina bij elke regelovergang schokken, en bewegingsgevoelige lezers zullen de hele functie binnen een minuut uitschakelen. De markering rendert alleen op de pagina die momenteel wordt weergegeven in de actieve TPdfView, dus voor lezen over meerdere pagina's moet PageNumber in de pas met spraak vooruitgaan, en vervolgens de voorbereidende (prepare) stap voor de nieuwe pagina opnieuw uitvoeren voordat het eerste grensgebeurtenis landt. Sla dat over en de eerste paar markeringen op elke pagina wijzen naar oude coördinaten

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

Symmetrie bij het afsluiten is wat de markering eerlijk houdt. Elk pad voor pauzeren, stoppen en omslaan van een pagina moet eindigen in ClearReadingWord. Laat het weg en er zit een amberkleurige rechthoek op een gestopte pagina die er precies uitziet als een defect, wat het soort ding is dat elke tester zal indienen, hoewel er niets daadwerkelijk kapot is

De spraaksnelheid belast deze pijplijn zwaarder dan de documentgrootte. Bij 300 woorden per minuut komen de grensgebeurtenissen elke 200 ms binnen, en bij de hoogste SAPI-snelheden komen ze sneller dan het oog comfortabel volgt. De juiste reactie is samenvoegen (coalesce), niet in de wachtrij plaatsen (queue). Als er een nieuwe grens arriveert terwijl er nog een markeringsupdate in behandeling is, laat dan de oude vallen en schilder de nieuwste. Een cursor die elk woord in volgorde bezoekt, maar een halve seconde achterblijft, voelt gebroken; een die af en toe een woord overslaat terwijl deze synchroon blijft met de stem, is dat niet

Randgevallen die demo's scheiden van producten

Enkele documentcategorieën leggen de naden bloot. Combinatietekens zijn de meest subtiele: Unicode-sequenties zoals een basisletter plus een combinerend diakritisch teken kunnen meer tekenindexen in beslag nemen dan het visuele woord suggereert, dus elke offset-rekenkunde die uitgaat van één index per glyph, drijft langzaam af. Dat is het sterkste argument om TrackReadingWordAt de mapping te laten bezitten in plaats van handmatig woordnummers te berekenen. Woordafbreking (hyphenation) is alledaagser maar vaker voorkomend: een woord dat over een regelovergang is gebroken, wordt twee vakken, en als u het als één enkel token (token) uitspreekt, wordt de grensgebeurtenis voor de tweede helft herleid tot het eerste vak. Dat is meestal prima, maar het is een beslissing, dus neem deze met opzet in plaats van hem te ontdekken. Tagging verandert de leesvolgorde zelf. Wanneer een document de juiste structuur-tags (structure tags) draagt (het territorium van ISO 14289, PDF/UA), volgt de woordvolgorde de logische structuur; zonder hen valt het terug op lay-outheuristiek, en een getagde pagina met twee kolommen kan rechtstreeks over beide kolommen lezen. Geroteerde pagina's zijn de laatste veelvoorkomende: de Rect van elk woord begrenst het nog steeds correct in de paginaruimte, maar een viewport-follow-beleid dat is afgestemd op een horizontale stroom scrolt schokkend wanneer de tekst verticaal loopt, dus bewaar ten minste één geroteerd document in de regressieset. Voor de afhandeling van de leesvolgorde, eenheden op zinsniveau via ReadingUnits en de bredere ondersteunende (assistive) stack, zie het bouwen van een toegankelijke PDF-lezer in Delphi

Eén platformbeperking bepaalt de inzet (deployment). SAPI is alleen voor Windows. De woordvak- en tracking-API is byte-voor-byte identiek onder Lazarus en FPC, maar Linux- en macOS-builds hebben een andere synthesizer nodig die achter dezelfde grensgebeurtenissen is bedraad; die opzet wordt behandeld in het uitvoeren van de viewer onder Lazarus en FPC. Highlight-kosten werken ook samen met uw paginacache zodra de spraaksnelheden stijgen, en de budgetrekenkunde in render caching en zoomprestaties wordt hier zonder wijziging overgenomen

Wanneer markering van één woord de verkeerde granulariteit is

Woord-niveau karaoke is niet altijd wat een lezer wil. Bij hoge spraaksnelheden wordt de cursor die woord voor woord knippert een eigen visuele ruis, en sommige luisteraars volgen een zin comfortabeler dan een stroboscoop van losse woorden. Voor dat geval stelt de component een grovere eenheid bloot. ReadingUnits retourneert eenheden op zins- en blokniveau, elk met hun eigen markeringsrechthoeken, en u schildert ze met SetReadingHighlight in plaats van SetReadingWord. De bedrading heeft dezelfde vorm: een grens-offset bepaalt nog steeds welke eenheid oplicht, maar de eenheid die u markeert beslaat een clausule of een regel in plaats van een enkel token. Zowel langzamere lezers als weergave met hoge snelheid geven de voorkeur hieraan, en niets houdt u tegen om beide modi achter een instelling aan te bieden

De versiebodems zijn de moeite waard om vast te pinnen voordat u hiertegen bouwt: woordvakken vereisen PDFium Component v1.53 of hoger, en de trackingcursor heeft v1.56 nodig. De volledige lees-API, de eenheden op zinsniveau en een werkende voorlees-demo (read-aloud demo) staan op de productpagina voor PDFium Component