Τεχνικό Άρθρο

Δημιουργία προσβάσιμων προγραμμάτων προβολής PDF με μετατροπή κειμένου σε ομιλία στο Delphi

Ένα κουμπί εκφώνησης (read-aloud) παρουσιάζεται μέσα σε ένα απόγευμα και μετά απαιτεί μια εβδομάδα δουλειάς. Η απογευματινή έκδοση εξάγει το κείμενο της σελίδας, το παραδίδει στο SAPI και παράγει ήχο. Η εβδομάδα αφιερώνεται σε ό,τι κάνει τη λειτουργία εύχρηστη: η φωνή δεν πρέπει να παγώνει το παράθυρο, η λέξη που εκφωνείται πρέπει να επισημαίνεται στη σελίδα συγχρονισμένα με τον ήχο και το πλήκτρο Space πρέπει να θέτει το σύνολο σε παύση. Αυτό το άρθρο δημιουργεί αυτήν τη ροή (pipeline) στο Delphi έναντι του raw PDFium text API και του Windows Speech API, με κώδικα που λειτουργεί για τα τρία κομμάτια που παραλείπει η γρήγορη έκδοση: ο κύκλος ζωής COM εκτελείται μία φορά αντί για κάθε εκφώνηση, τα πραγματικά συμβάντα ορίων λέξεων (word-boundary) και τα μαθηματικά συντεταγμένων που μετατρέπουν ένα πλαίσιο λέξης του χώρου PDF σε ορθογώνιο που μπορείτε να σχεδιάσετε

Το κανονιστικό πλαίσιο συνοψίζεται σε μία πρόταση: η συγχρονισμένη εκφώνηση είναι το μισό της πλευράς του προγράμματος προβολής από αυτό που ζητά το WCAG 2.1 από το λογισμικό εγγράφων, και το ISO 14289-1 (PDF/UA) ορίζει το μισό του αρχείου με ετικέτες (tagged-file) έναντι του οποίου λειτουργεί καλύτερα. Αν αναπτύσσετε με το PDFium Component, ίσως να μην χρειάζεστε καθόλου αυτήν τη ροή: το πρόγραμμα προβολής διαθέτει έναν ενσωματωμένο κέρσορα παρακολούθησης που αντιστοιχίζει μια μετατόπιση (offset) χαρακτήρα σε μια ζωγραφισμένη επισήμανση λέξης με μία μόνο κλήση, κάτι που καλύπτεται στο άρθρο για την επισήμανση TTS λέξη προς λέξη. Αυτά που ακολουθούν απευθύνονται σε εσάς όταν σας ανήκει ολόκληρη η εφαρμογή προβολής και θέλετε την ίδια τη ροή

Ένα νήμα (thread) σχεδιάζει, ένα νήμα μιλάει

Η αρχιτεκτονική αποτελείται από δύο νήματα και μία σύμβαση. Το νήμα διεπαφής χρήστη (UI) σχεδιάζει το bitmap της σελίδας, κατέχει την κατάσταση ζουμ και κύλισης και σχεδιάζει την επικάλυψη επισήμανσης. Ένα αποκλειστικό νήμα ομιλίας κατέχει τη φωνή SAPI και τίποτα άλλο δεν την αγγίζει. Η σύμβαση είναι απλή: το νήμα ομιλίας αναφέρει την πρόοδο ως μετατοπίσεις χαρακτήρων και το νήμα UI μετατρέπει τις μετατοπίσεις σε ορθογώνια

Τα περισσότερα παραδείγματα του SAPI τυλίγουν κάθε εκφώνηση σε CoInitialize και CoUninitialize, και ένα πρόγραμμα προβολής δείχνει αμέσως γιατί αυτό είναι λάθος. Η μέθοδος Speak με SVSFlagsAsync επιστρέφει μόλις το κείμενο μπει στην ουρά, επομένως ένα CoUninitialize στο μπλοκ finally της ίδιας διαδικασίας εκτελείται ενώ η φωνή εξακολουθεί να μιλάει, καταστρέφοντας το διαμέρισμα COM που της ανήκει. Ανάλογα με τον χρονισμό, λαμβάνετε σιωπή, μια κομμένη εκφώνηση ή μια παραβίαση πρόσβασης (access violation) λεπτά αργότερα. Ο σωστός κύκλος ζωής είναι ανιαρός: CoInitialize μία φορά όταν ξεκινά το νήμα ομιλίας, δημιουργία της φωνής μέσα σε αυτό το διαμέρισμα και CoUninitialize μία φορά όταν τερματίζεται το νήμα, αφού η φωνή έχει ελευθερωθεί. Ποτέ ανά εκφώνηση

Η φωνή χρειάζεται επίσης μια αντλία μηνυμάτων (message pump), η οποία αποφασίζει πού μπορεί να ζήσει. Το αντικείμενο αυτοματισμού SpVoice παραδίδει τα συμβάντα του μέσω της ουράς μηνυμάτων του νήματος που το δημιούργησε. Δημιουργήστε το στο νήμα UI και τα συμβάντα φτάνουν πράγματι, επειδή το VCL αντλεί μηνύματα, αλλά κάθε αργή σχεδίαση καθυστερεί τα όρια των λέξεών σας· δημιουργήστε το σε ένα worker thread χωρίς αντλία και τα συμβάντα δεν φτάνουν ποτέ. Ένα αποκλειστικό νήμα με τον δικό του βρόχο GetMessage διατηρεί τον λανθάνοντα χρόνο ορίων (boundary latency) σταθερό, ανεξάρτητα από το τι κάνει το UI

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;

Το TerminatedSet στέλνει WM_QUIT έτσι ώστε η αντλία να ξεμπλοκάρει όταν το πρόγραμμα προβολής κλείνει. Η SpeakPage, η οποία καλείται από το νήμα UI, αποθηκεύει το κείμενο σε ένα πεδίο προστατευμένο με κλείδωμα (lock-guarded) και στέλνει WM_SPEAK_PAGE, επειδή η κλήση μιας μεθόδου στο FVoice απευθείας από άλλο νήμα θα ήταν μια κλήση COM cross-apartment σε μια διεπαφή χωρίς unmarshaling. Η PeekMessage μιας γραμμής πριν από τον βρόχο αναγκάζει τα Windows να δημιουργήσουν την ουρά μηνυμάτων του νήματος, κλείνοντας τον αγώνα εκκίνησης όπου μια πρώιμη ανάρτηση (post) από το νήμα UI θα αποτύγχανε

Τα όρια λέξεων φτάνουν ως μετατοπίσεις χαρακτήρων

Εισαγάγετε τη Microsoft Speech Object Library μία φορά μέσω του εισαγωγέα βιβλιοθήκης τύπων του IDE και λαμβάνετε το SpeechLib_TLB με το wrapper TSpVoice και τα πληκτρολογημένα συμβάντα του. Δύο ρυθμίσεις έχουν σημασία. Το EventInterests θα πρέπει να περιοριστεί στα συμβάντα που πραγματικά καταναλώνετε, επειδή κάθε ενδιαφέρον που παραμένει ενεργοποιημένο είναι κίνηση συμβάντων μεταξύ νημάτων (cross-thread) για κάθε λέξη κάθε σελίδας· το SVEWordBoundary οδηγεί την επισήμανση και το SVEEndInputStream σας λέει ότι η εκφώνηση τελείωσε. Και ο χειριστής (handler) OnWord λαμβάνει τη CharacterPosition και ένα μήκος, τα οποία παραπέμπουν στο ακριβές string που περάσατε στη Speak — μια μετατόπιση μέσα στο buffer ομιλίας, όχι σε οτιδήποτε άλλο

Αυτή η τελευταία ρήτρα είναι η σταθερά (invariant) στην οποία στηρίζεται η λειτουργία: οι μετατοπίσεις έχουν νόημα μόνο σε σχέση με το string που διαβάζει η φωνή, επομένως εκφωνήστε ακριβώς το κείμενο που εξάγατε, χαρακτήρα προς χαρακτήρα. Περικόψτε τα κενά (whitespace), συμπτύξτε τις αλλαγές γραμμής ή αναπτύξτε μια συντομογραφία για καλύτερη προφορά, και κάθε επισήμανση μετά την πρώτη επεξεργασία θα απέχει μία λέξη. Αν το UI πρέπει να εισαγάγει προφορικό υλικό — ανακοινώσεις σελίδων, προθέματα επικεφαλίδων — καταγράψτε τη θέση και το μήκος κάθε εισαγωγής και αφαιρέστε τη συσσωρευμένη μετατόπιση από κάθε μετατόπιση πριν την αντιστοιχίσετε

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;

Η TThread.Queue είναι η σωστή μέθοδος (marshal) εδώ, όχι η Synchronize: ο χειριστής δεν πρέπει να σταθμεύει το νήμα ομιλίας ενώ το UI επανασχεδιάζει, και αν τα συμβάντα ορίων φτάνουν πιο γρήγορα από τη σχεδίαση της οθόνης, μια παλιά ενημέρωση επισήμανσης είναι αβλαβής, επειδή η επόμενη θα την αντικαταστήσει. Συνδέστε το OnEndStream με τον ίδιο τρόπο για να καθαρίσετε την επισήμανση και, σε λειτουργία συνεχούς ανάγνωσης, για να φορτώσετε το κείμενο της επόμενης σελίδας και να στείλετε την επόμενη εκφώνηση

Από τις μετατοπίσεις χαρακτήρων στα pixel της οθόνης

Το PDFium αναφέρει τη γεωμετρία ανά χαρακτήρα. Το FPDFText_GetCharBox γεμίζει τέσσερις τιμές double με μια σειρά που έχει προκαλέσει περισσότερα σιωπηλά σφάλματα (silent bugs) από οτιδήποτε άλλο στο text API — αριστερά, δεξιά, κάτω, πάνω (left, right, bottom, top), και όχι το αριστερά, πάνω, δεξιά, κάτω (left, top, right, bottom) των Windows — και τα αναφέρει στον χώρο της σελίδας: σημεία (points) PDF, 72 ανά ίντσα, αρχή των αξόνων (origin) στην κάτω-αριστερή γωνία με το Y να αυξάνεται προς τα πάνω. Το πλαίσιο μιας λέξης είναι η ένωση των πλαισίων των χαρακτήρων της, και ο μετασχηματισμός σε pixel της συσκευής αποτελείται από τρία βήματα: μετατόπιση κατά την αρχή της σελίδας, κλιμάκωση (scale) επί το ζουμ επί το DPI της οθόνης διά του 72, και αναστροφή του άξονα Y

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;

Το FPageTop είναι το ύψος της σελίδας σε σημεία (points) από το FPDF_GetPageHeight, και το FPageLeft είναι μηδέν για τα περισσότερα έγγραφα αλλά προέρχεται από το πλαίσιο περικοπής (crop box) όταν η σελίδα ορίζει ένα, επομένως διαβάστε και τα δύο από το FPDF_GetPageBoundingBox αντί να υποθέτετε. Η αναστροφή του άξονα Y είναι το σημείο όπου οι χειροκίνητες (hand-rolled) εκδόσεις σπάνε: το πάνω μέρος του ορθογωνίου της συσκευής προέρχεται από το πάνω μέρος του πλαισίου PDF, μετρούμενο προς τα κάτω από το πάνω μέρος της σελίδας. Αν το κάνετε ανάποδα, κάθε επισήμανση θα σχεδιάζεται με κατοπτρισμό στο λάθος μισό της σελίδας

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;

Ο χειριστής σχεδίασης ζωγραφίζει πρώτα το bitmap της σελίδας και μετά την επισήμανση, κάθε φορά, έτσι ώστε η επικάλυψη (overlay) να μην χρειάζεται ποτέ να σβήσει τον εαυτό της· η ακύρωση (invalidating) των παλιών και νέων ορθογωνίων διατηρεί την περιοχή επανασχεδίασης μικρή ακόμα και σε γρήγορους ρυθμούς ομιλίας. Το FHighlightBrush είναι ένα TBitmap διαστάσεων ένα προς ένα (one-by-one), γεμάτο μία φορά κατά την εκκίνηση με το χρώμα επισήμανσης — FHighlightBrush.Canvas.Pixels[0, 0] := $0032C8FF για ένα κεχριμπαρένιο χρώμα (amber) — το οποίο η AlphaBlend τεντώνει πάνω στο ορθογώνιο στόχο, επομένως δεν εκχωρείται τίποτα ανά καρέ (frame), και το SourceConstantAlpha στο 96 διατηρεί τη λέξη ευανάγνωστη μέσα από την απόχρωση. Δοκιμάστε το χρώμα σε λειτουργίες ανεστραμμένης (inverted) και υψηλής αντίθεσης (high-contrast) οθόνης· μια επικάλυψη που δεν μπορεί να δει ένας χρήστης με χαμηλή όραση δεν υπάρχει ακριβώς για το άτομο για το οποίο φτιάχτηκε

Η σειρά ανάγνωσης είναι το μέρος που δεν θα λύσει το text API

Το FPDFText_GetText επιστρέφει τους χαρακτήρες με μια σειρά που προέρχεται από τη ροή περιεχομένου (content stream) με κάποιο χωρικό καθαρισμό, και για μια αναφορά μίας στήλης αυτή η σειρά είναι μια χαρά. Δεν έχει καμία υποχρέωση να είναι σωστή οπουδήποτε αλλού. Ένα ενημερωτικό δελτίο δύο στηλών μπορεί να διαβαστεί ευθεία κατά μήκος και των δύο στηλών, μια πλαϊνή γραμμή (sidebar) μπορεί να διακόψει μια πρόταση στη μέση, και ένα υποσέλιδο (footer) μπορεί να φτάσει στη μέση της σελίδας. Οι πληροφορίες που το διορθώνουν αυτό — το δέντρο λογικής δομής (logical structure tree) του ISO 32000-1 §14.8, το οποίο φέρουν τα PDF με ετικέτες και το PDF/UA καθιστά υποχρεωτικό — δεν ελέγχονται καθόλου από τις απλές (raw) κλήσεις σελίδας-κειμένου. Αν χρειάζεστε σειρά με επίγνωση δομής με ένα ρητό σήμα της προέλευσής της, αυτό είναι ένα λυμένο πρόβλημα ένα επίπεδο πιο πάνω: το API ανάγνωσης του PDFium Component επιστρέφει περιεχόμενο με ένα πεδίο Source με τιμή rosStructure ή rosHeuristic, και το άρθρο για τον προσβάσιμο αναγνώστη PDF το αναλύει. Σε επίπεδο raw API, η υπερασπίσιμη θέση είναι να αντιμετωπίζεται η σειρά εξαγωγής ως εκτίμηση, να αναφέρεται αυτό στο UI, και να διατηρείται ένα έγγραφο πολλών στηλών και μια σάρωση μόνο με εικόνες στο σύνολο παλινδρόμησης (regression set), ώστε και οι δύο τρόποι αποτυχίας να παραμένουν ορατοί

Το ίδιο το πρόγραμμα προβολής πρέπει να λειτουργεί με το πληκτρολόγιο

Η έξοδος ομιλίας δεν απαλλάσσει το πρόγραμμα προβολής από την πρόσβαση μέσω πληκτρολογίου· τα άτομα που είναι πιο πιθανό να χρησιμοποιήσουν την εκφώνηση είναι τα λιγότερο πιθανά να χρησιμοποιήσουν ποντίκι. Δώστε στον πίνακα (panel) σελίδας το TabStop := True και ένα ορατό ορθογώνιο εστίασης, και έπειτα χειριστείτε τρία πλήκτρα: το Space εναλλάσσει τα FVoice.Pause και FVoice.Resume, και τα Αριστερά και Δεξιά (Left, Right) παραλείπουν μέσω του FVoice.Skip('Sentence', 1) με αρνητικό αριθμό για μετάβαση προς τα πίσω. Η Skip του SAPI κατανοεί μόνο επίπεδο πρότασης (sentence granularity), επομένως η παράλειψη σε επίπεδο λέξης σημαίνει εκκαθάριση της αναπαραγωγής με SVSFPurgeBeforeSpeak και εκ νέου εκφώνηση από τη μετατόπιση της λέξης που παρακολουθήσατε τελευταία — φθηνή λειτουργία, αφού ο κώδικας επισήμανσης αποθηκεύει ήδη ακριβώς αυτήν τη μετατόπιση. Διατηρήστε κάθε έλεγχο μεταφοράς (transport control) ως πραγματικό TButton με λεζάντα (caption), ώστε οι αναγνώστες οθόνης (screen readers) να το ανακοινώνουν

Αυτή είναι όλη η ροή, ολοκληρωμένη έναντι του raw PDFium text API: ένα νήμα ομιλίας που κατέχει το COM και τη φωνή για όλη τη διάρκεια ζωής της εφαρμογής, συμβάντα ορίων που προωθούνται (marshaled) στο UI ως μετατοπίσεις χαρακτήρων και πλαίσια χώρου σελίδας (page-space) ανά χαρακτήρα που μετατρέπονται σε ένα αναμεμειγμένο (blended) ορθογώνιο στην οθόνη. Αν προτιμάτε να μην διαχειρίζεστε τη γεωμετρία και την παρακολούθηση (tracking) μόνοι σας, το PDFium Component παρέχει πλαίσια ανά λέξη, τον κέρσορα παρακολούθησης, την αυτόματη κύλιση (auto-scroll follow) και τις μονάδες ανάγνωσης σε επίπεδο πρότασης (sentence-level) ως ιδιότητες στοιχείου (component properties), και το demo εκφώνησής του είναι η ροή αυτού του άρθρου συμπυκνωμένη σε μια χούφτα κλήσεις