Műszaki cikk

Akadálymentes PDF-megjelenítők készítése szövegfelolvasással Delphiben

Egy felolvasó gomb bemutatója elkészül egy délután alatt, majd felemészt egy hetet. A délutáni verzió kinyeri az oldal szövegét, átadja a SAPI-nak, és hangot kap. A hét arra megy el, ami a funkciót használhatóvá teszi: a hang nem fagyaszthatja le az ablakot, a kimondott szónak a hanggal szinkronban fel kell villannia az oldalon, és a Szóköz billentyűnek szüneteltetnie kell az egészet. Ez a cikk ezt a folyamatot építi fel Delphiben a nyers PDFium szöveges API és a Windows Speech API használatával, működő kóddal ahhoz a három részhez, amelyet a gyors verzió kihagy: a COM életciklus egyszeri futtatása a megnyilatkozásonkénti helyett, a valódi szóhatár-események, és a koordináta-matematika, amely egy PDF-térbeli szódobozt festhető téglalappá alakít

A szabályozási kontextus egy mondatban összefoglalható: a szinkronizált felolvasás a WCAG 2.1 által a dokumentumszoftverektől elvártak megjelenítő oldali fele, az ISO 14289-1 (PDF/UA) pedig a címkézett fájlok azon felét definiálja, amellyel a legjobban működik. Ha a PDFium Component-re építkezik, akkor lehet, hogy egyáltalán nincs szüksége erre a folyamatra: a megjelenítő beépített követőkurzorral érkezik, amely egyetlen hívással leképezi a karaktereltolást egy megfestett szókiemelésre, amiről a szavankénti TTS kiemelésről szóló cikkben olvashat bővebben. A következők arra az esetre vonatkoznak, amikor a teljes megjelenítő alkalmazás az Ön kezében van, és magát a folyamatot szeretné megvalósítani

Egy szál renderel, egy szál beszél

Az architektúra két szálat és egy szerződést takar. A felhasználói felület (UI) szála rendereli az oldal bittérképét, birtokolja a nagyítási és görgetési állapotot, és lefesti a kiemelés rátétjét. Egy dedikált beszédszál birtokolja a SAPI hangot, és semmi más nem ér hozzá. A szerződés vékony: a beszédszál karaktereltolásokként jelenti a folyamatot, a UI szál pedig az eltolásokat téglalapokká alakítja

A legtöbb SAPI példa minden egyes megnyilatkozást CoInitialize és CoUninitialize hívásokba csomagol, és egy megjelenítő azonnal megmutatja, miért helytelen ez. A Speak az SVSFlagsAsync jelzővel amint a szöveg sorba kerül, visszatér, így az ugyanazon eljárás finally blokkjában lévő CoUninitialize lefut, miközben a hang még beszél, lerombolva az azt birtokló COM apartmant. Az időzítéstől függően csendet, egy csonka megnyilatkozást, vagy percekkel később egy hozzáférési sértést kapunk. A helyes életciklus unalmas: egyszeri CoInitialize, amikor a beszédszál elindul, a hang létrehozása ezen az apartmanon belül, és egy CoUninitialize, amikor a szál kilép, miután a hang fel lett szabadítva. Soha nem megnyilatkozásonként

A hangnak szüksége van egy üzenetpumpára is, ami eldönti, hogy hol élhet. A SpVoice automatizációs objektum az őt létrehozó szál üzenetsorán keresztül kézbesíti az eseményeit. Ha a UI szálon hozza létre, az események megérkeznek, mert a VCL pumpálja az üzeneteket, de ekkor minden lassú festés késlelteti a szóhatárokat; ha egy pumpa nélküli munkaszálon hozza létre, az események soha nem érkeznek meg. Egy dedikált szál a saját GetMessage ciklusával laposan tartja a határkésleltetést, függetlenül attól, hogy a UI mit csinál

uses
  System.Classes, System.SyncObjs, Winapi.Windows, Winapi.Messages,
  Winapi.ActiveX, SpeechLib_TLB;

const
  WM_SPEAK_PAGE = WM_APP + 1;

type
  TSpeechThread = class(TThread)
  private
    FVoice: TSpVoice;
    FLock: TCriticalSection;
    FText: string;
    function NextUtterance: string;   // reads FText under FLock
    procedure VoiceWord(ASender: TObject; StreamNumber: Integer;
      StreamPosition: OleVariant; CharacterPosition, WordLength: Integer);
  protected
    procedure Execute; override;
    procedure TerminatedSet; override;
  public
    procedure SpeakPage(const AText: string);   // safe from the UI thread
  end;

procedure TSpeechThread.Execute;
var
  Msg: TMsg;
begin
  CoInitialize(nil);                       // once, when the thread starts
  try
    FVoice := TSpVoice.Create(nil);
    try
      FVoice.EventInterests := SVEWordBoundary or SVEEndInputStream;
      FVoice.OnWord := VoiceWord;
      // Force creation of this thread's message queue before anyone posts to it
      PeekMessage(Msg, 0, WM_USER, WM_USER, PM_NOREMOVE);
      while GetMessage(Msg, 0, 0, 0) do    // exits when WM_QUIT arrives
        if Msg.message = WM_SPEAK_PAGE then
          FVoice.Speak(NextUtterance, SVSFlagsAsync or SVSFPurgeBeforeSpeak)
        else
          DispatchMessage(Msg);            // delivers the SAPI event callbacks
    finally
      FVoice.Free;
    end;
  finally
    CoUninitialize;                        // once, when the thread exits
  end;
end;

procedure TSpeechThread.TerminatedSet;
begin
  inherited;
  PostThreadMessage(ThreadID, WM_QUIT, 0, 0);   // unblock GetMessage
end;

A TerminatedSet egy WM_QUIT üzenetet küld, így a pumpa feloldódik a megjelenítő leállításakor. A UI szálból hívott SpeakPage a szöveget egy zárolással védett mezőben tárolja, és elküldi a WM_SPEAK_PAGE üzenetet, mert egy módszer közvetlen hívása az FVoice-on egy másik szálból egy apartmanokon átívelő COM hívás lenne egy nem marshallált interfészen. A ciklus előtti egysoros PeekMessage kikényszeríti a Windowstól, hogy létrehozza a szál üzenetsorát, lezárva a kezdeti versenyhelyzetet, ahol a UI szálról érkező korai küldés elbukna

A szóhatárok karaktereltolásokként érkeznek

Importálja a Microsoft Speech Object Library-t egyszer az IDE típuskönyvtár-importálóján keresztül, és megkapja a SpeechLib_TLB-t a TSpVoice burkolóval és annak típusos eseményeivel. Két beállítás számít. Az EventInterests-t le kell szűkíteni azokra az eseményekre, amelyeket ténylegesen fogyaszt, mivel minden bekapcsolva hagyott érdekeltség szálakon átívelő eseményforgalmat jelent az oldal minden szavára; az SVEWordBoundary vezérli a kiemelést, az SVEEndInputStream pedig jelzi a megnyilatkozás végét. Az OnWord kezelő pedig egy CharacterPosition-t és egy hosszt kap, amely pontosan abba a karakterláncba indexel, amit a Speak-nek adott át – ez egy eltolás a beszédbufferben, nem pedig bármi másban

Ez az utolsó kikötés az az invariáns, amelyen a funkció múlik: az eltolások csak azzal a karakterlánccal szemben értelmezhetők, amelyet a hang éppen felolvas, tehát pontosan azt a szöveget mondassa ki, amelyet kinyert, karakterről karakterre. Vágja le a szóközöket, vonja össze a sortöréseket, vagy bontson ki egy rövidítést a szebb kiejtés érdekében, és az első szerkesztés után minden kiemelés egy szóval elcsúszik. Ha a UI-nak beszélt anyagot kell beinjektálnia – oldalbemondásokat, címsorelőtagokat –, rögzítse minden beszúrás pozícióját és hosszát, és vonja ki a felhalmozott eltolást minden eltolásból, mielőtt leképezné azt

procedure TSpeechThread.SpeakPage(const AText: string);
begin
  FLock.Enter;
  try
    FText := AText;
  finally
    FLock.Leave;
  end;
  PostThreadMessage(ThreadID, WM_SPEAK_PAGE, 0, 0);
end;

procedure TSpeechThread.VoiceWord(ASender: TObject; StreamNumber: Integer;
  StreamPosition: OleVariant; CharacterPosition, WordLength: Integer);
begin
  // Runs on the speech thread; hand the offsets to the UI without blocking
  TThread.Queue(nil,
    procedure
    begin
      ViewerForm.HighlightWordAt(CharacterPosition, WordLength);
    end);
end;

A TThread.Queue a megfelelő marshaller itt, nem a Synchronize: a kezelő nem parkoltathatja a beszédszálat, amíg a UI újrafest, és ha a határesemények gyorsabban érkeznek, mint ahogy a képernyő rajzol, egy elavult kiemelésfrissítés ártalmatlan, mert a következő úgyis felülírja. Kösse be az OnEndStream-et ugyanezen a módon a kiemelés törléséhez, és folyamatos olvasási módban a következő oldal szövegének betöltéséhez és a következő megnyilatkozás elküldéséhez

Karaktereltolásoktól a képernyőn lévő pixelekig

A PDFium karakterenként jelenti a geometriát. Az FPDFText_GetCharBox négy double értéket tölt ki egy olyan sorrendben, amely több csendes hibát okozott, mint bármi más a szöveges API-ban – bal, jobb, alsó, felső, és nem a Windows-féle bal, felső, jobb, alsó –, és ezeket PDF-térben (oldaltérben) adja meg: PDF-pontok, 72 pont hüvelykenként, az origó a bal alsó sarokban, az Y tengely felfelé nő. Egy szó doboza a karaktereinek dobozaiból képzett unió, az eszközpixelekre történő transzformáció pedig három lépésből áll: eltolás az oldal origójával, skálázás a nagyítás és a képernyő DPI-jének 72-vel osztott szorzatával, valamint az Y tengely megfordítása

uses
  System.Math;

type
  TPdfRectF = record
    Left, Top, Right, Bottom: Double;    // PDF points, origin bottom-left
  end;

function TViewerForm.WordBox(CharIndex, CharCount: Integer): TPdfRectF;
var
  i, LastChar: Integer;
  L, T, R, B: Double;
begin
  Result.Left := MaxDouble;   Result.Bottom := MaxDouble;
  Result.Right := -MaxDouble; Result.Top := -MaxDouble;
  LastChar := Min(CharIndex + CharCount, FPDFText_CountChars(FTextPage)) - 1;
  for i := CharIndex to LastChar do
  begin
    // Parameter order is left, right, bottom, top - not the Windows order
    FPDFText_GetCharBox(FTextPage, i, @L, @R, @B, @T);
    Result.Left   := Min(Result.Left, L);
    Result.Right  := Max(Result.Right, R);
    Result.Bottom := Min(Result.Bottom, B);
    Result.Top    := Max(Result.Top, T);
  end;
end;

function TViewerForm.PdfToDevice(const W: TPdfRectF): TRect;
var
  Scale: Double;
begin
  // 72 PDF points per inch; FZoom is the viewer scale factor
  Scale := FZoom * FScreenDpi / 72.0;
  Result.Left   := Round((W.Left  - FPageLeft) * Scale) - FScrollX;
  Result.Right  := Round((W.Right - FPageLeft) * Scale) - FScrollX;
  // PDF Y grows upward from the bottom edge; device Y grows downward
  Result.Top    := Round((FPageTop - W.Top)    * Scale) - FScrollY;
  Result.Bottom := Round((FPageTop - W.Bottom) * Scale) - FScrollY;
end;

Az FPageTop az oldal magassága pontokban az FPDF_GetPageHeight alapján, az FPageLeft pedig a legtöbb dokumentumnál nulla, de a vágódobozból (crop box) származik, ha az oldal definiál ilyet, ezért feltételezés helyett olvassa ki mindkettőt az FPDF_GetPageBoundingBox használatával. Az Y tengely megfordítása az a pont, ahol a saját kezűleg írt verziók elromlanak: az eszköztéglalap teteje a PDF doboz tetejéből adódik, az oldal tetejétől lefelé mérve. Ha ezt visszafelé csinálja, minden kiemelés tükrözve az oldal rossz felére lesz festve

procedure TViewerForm.HighlightWordAt(CharIndex, CharCount: Integer);
var
  Old: TRect;
begin
  if CharCount <= 0 then Exit;
  Old := FHighlightRect;
  FHighlightRect := PdfToDevice(WordBox(CharIndex, CharCount));
  InvalidateRect(PageBox.Handle, @Old, False);             // erase the old word
  InvalidateRect(PageBox.Handle, @FHighlightRect, False);  // draw the new one
end;

procedure TViewerForm.PageBoxPaint(Sender: TObject);
var
  Blend: TBlendFunction;
begin
  PageBox.Canvas.Draw(0, 0, FPageBitmap);      // rendered page first, always
  if FHighlightRect.IsEmpty then Exit;

  Blend.BlendOp := AC_SRC_OVER;
  Blend.BlendFlags := 0;
  Blend.SourceConstantAlpha := 96;             // about 38 percent opacity
  Blend.AlphaFormat := 0;                      // constant alpha, no per-pixel data
  Winapi.Windows.AlphaBlend(PageBox.Canvas.Handle,
    FHighlightRect.Left, FHighlightRect.Top,
    FHighlightRect.Width, FHighlightRect.Height,
    FHighlightBrush.Canvas.Handle, 0, 0, 1, 1, Blend);
end;

A festéskezelő minden alkalommal először az oldal bittérképét rajzolja meg, és utána a kiemelést, így a rátétnek soha nem kell törölnie önmagát; a régi és az új téglalapok érvénytelenítése kicsiben tartja az újrafestési régiót még gyors beszédsebesség mellett is. Az FHighlightBrush egy 1x1-es TBitmap, amelyet induláskor egyszer feltöltünk a kiemelés színével – FHighlightBrush.Canvas.Pixels[0, 0] := $0032C8FF egy borostyánsárgához –, amelyet az AlphaBlend nyújt rá a céltéglalapra, így semmi sincs képkockánként lefoglalva, és a 96-ra állított SourceConstantAlpha olvashatóan tartja a szót az árnyalaton keresztül. Tesztelje a színt invertált és nagy kontrasztú megjelenítési módokban; egy rátét, amelyet egy gyengénlátó felhasználó nem lát, gyakorlatilag nem létezik pont annak a személynek a számára, akinek készült

Az olvasási sorrend az a rész, amit a szöveges API nem fog megoldani

Az FPDFText_GetText olyan sorrendben adja vissza a karaktereket, amely a tartalomfolyamból származik némi térbeli tisztítással, és egy egyoszlopos jelentés esetén ez a sorrend megfelelő. Nincs rá semmilyen kötelezettsége, hogy bárhol máshol helyes legyen. Egy kétoszlopos hírlevelet olvashat egyenesen mindkét oszlopon keresztül, egy oldalsáv a tagmondat közepén megszakíthat egy mondatot, egy lábléc pedig az oldal közepén is megérkezhet. Az információt, amely ezt kijavítja – az ISO 32000-1 14.8. szakaszának logikai struktúrafája, amelyet a címkézett PDF-ek hordoznak és a PDF/UA kötelezővé tesz –, a nyers szövegoldal-hívások egyáltalán nem veszik figyelembe. Ha struktúratudatos sorrendre van szüksége az eredet egyértelmű jelzésével, az egy szinttel feljebb már megoldott probléma: a PDFium Component olvasási API-ja a tartalmat egy rosStructure vagy rosHeuristic Source mezővel adja vissza, az akadálymentes PDF-olvasóról szóló cikk pedig végigvezet ezen. Nyers API szinten a védhető álláspont az, hogy a kinyerési sorrendet becslésként kezeljük, ezt mondjuk is meg a UI-n, és tartsunk egy többoszlopos dokumentumot és egy csak képeket tartalmazó szkennelt állományt a regressziós halmazban, hogy mindkét hibamód látható maradjon

Magának a megjelenítőnek is billentyűzetről kezelhetőnek kell lennie

A beszédkimenet nem mentesíti a megjelenítőt a billentyűzetes hozzáférés alól; akik a legvalószínűbben használják a felolvasást, azok nyúlnak a legkevésbé valószínűen az egérhez. Adjon az oldallapnak TabStop := True értéket és egy látható fókusztéglalapot, majd kezeljen három billentyűt: a Szóköz kapcsolja az FVoice.Pause és FVoice.Resume állapotokat, a Balra és Jobbra nyilak pedig az FVoice.Skip('Sentence', 1) metóduson keresztül ugranak (negatív számmal a visszafelé haladáshoz). A SAPI Skip metódusa csak a mondatszintű granularitást érti, így a szószintű ugrás a lejátszás SVSFPurgeBeforeSpeak segítségével történő törlését és az utoljára követett szó eltolásától való újraolvasást jelenti – ez olcsó, mivel a kiemelő kód már amúgy is pontosan ezt az eltolást tárolja. Tartson minden vezérlőgombot igazi, felirattal rendelkező TButton-ként, hogy a képernyőolvasók be tudják azokat mondani

Ez az egész folyamat, mindez a nyers PDFium szöveges API ellenében: egy beszédszál, amely birtokolja a COM-ot és a hangot az alkalmazás teljes élettartama alatt, a karaktereltolásokként a UI-hoz marshallált határesemények, és a képernyőn egyetlen kevert téglalappá alakított karakterenkénti oldaltér-dobozok. Ha inkább nem szeretné maga kezelni a geometriát és a követést, a PDFium Component szavankénti dobozokat, követőkurzort, automatikus görgetéskövetést és mondatszintű olvasási egységeket szállít komponens-tulajdonságként, és a felolvasó demója ennek a cikknek a folyamata egy maroknyi hívásra redukálva