מאמר טכני

בניית מציג PDF עם הקראה ב-Delphi עם SAPI TTS

כפתור הקראה (read-aloud) יכול לעבוד כהדגמה תוך אחר צהריים, ואז לגזול שבוע שלם. גרסת אחר הצהריים מחלצת את טקסט העמוד, מוסרת אותו ל-SAPI ומקבלת שמע. השבוע כולו מושקע במה שהופך את התכונה לשמישה: הקול חייב שלא להקפיא את החלון, המילה המדוברת חייבת להיות מוארת על העמוד בסנכרון עם השמע, ומקש הרווח צריך להשהות את כל העסק. מאמר זה בונה את צינור העיבוד (pipeline) הזה ב-Delphi מול ה-API הגולמי לטקסט של PDFium וה-Windows Speech API, עם קוד עובד עבור שלושת החלקים שהגרסה המהירה מדלגת עליהם: מחזור החיים של COM שמבוצע פעם אחת במקום בכל אמירה (utterance), אירועי גבולות-מילים אמיתיים, ומתמטיקת הקואורדינטות שהופכת תיבת מילה במרחב-PDF למלבן שניתן לצייר עליו

ההקשר הרגולטורי נכנס במשפט אחד: הקראה מסונכרנת היא החצי בצד-המציג של מה ש-WCAG 2.1 דורש מתוכנות מסמכים, ו-ISO 14289-1 (PDF/UA) מגדיר את החצי של הקובץ-המתויג שבו היא פועלת בצורה הטובה ביותר. אם אתם בונים על PDFium Component, ייתכן שכלל לא תזדקקו לצינור זה: המציג כולל סמן מעקב מובנה שממפה היסט תווים (character offset) להדגשת מילה צבועה בקריאה אחת, כפי שמכוסה במאמר על הדגשת מילה-אחר-מילה ב-TTS. מה שמופיע להלן מיועד למקרה שבו אתם הבעלים של יישום המציג כולו ורוצים את הצינור בעצמכם

תהליכון אחד מרנדר, תהליכון אחד מדבר

הארכיטקטורה כוללת שני תהליכונים (threads) וחוזה אחד. תהליכון ה-UI מרנדר את מפת הסיביות (bitmap) של העמוד, מחזיק במצב התקריב והגלילה, ומצייר את שכבת ההדגשה. תהליכון דיבור ייעודי מחזיק בקול של SAPI, ושום דבר אחר לא נוגע בו. החוזה דק: תהליכון הדיבור מדווח על ההתקדמות כהיסט תווים, ותהליכון ה-UI הופך היסטים למלבנים

רוב הדוגמאות של SAPI עוטפות כל אמירה ב-CoInitialize ו-CoUninitialize, ומציג מראה מיד מדוע זה שגוי. Speak עם SVSFlagsAsync חוזר מיד כשהטקסט נכנס לתור, כך ש-CoUninitialize בבלוק ה-finally של אותה פרוצדורה רץ בזמן שהקול עדיין מדבר, והורס את דירת ה-COM שמחזיקה בו. בהתאם לתזמון, תקבלו שתיקה, אמירה קטועה, או שגיאת גישה (access violation) כעבור מספר דקות. מחזור החיים הנכון הוא משעמם: CoInitialize פעם אחת כשתהליכון הדיבור מתחיל, יצירת הקול בתוך אותה דירה, ו-CoUninitialize פעם אחת כשהתהליכון מסתיים, לאחר שהקול שוחרר. לעולם לא פר אמירה

הקול צריך גם משאבת הודעות (message pump), מה שקובע היכן הוא יכול לחיות. אובייקט האוטומציה SpVoice מעביר את האירועים שלו דרך תור ההודעות של התהליכון שיצר אותו. אם תצרו אותו בתהליכון ה-UI האירועים אכן יגיעו, כיוון שה-VCL שואב הודעות, אך כל ציור איטי יעכב אז את גבולות המילים שלכם; אם תצרו אותו בתהליכון עבודה ללא משאבה והאירועים לעולם לא יגיעו. תהליכון ייעודי עם לולאת GetMessage משלו שומר על השהיית גבולות קבועה ללא קשר למה שה-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, שומר את הטקסט בשדה המוגן בנעילה ושולח WM_SPEAK_PAGE, מכיוון שקריאה למתודה ב-FVoice ישירות מתהליכון אחר תהיה קריאת COM חוצת-דירות על ממשק לא מסודר (unmarshaled). השורה האחת של PeekMessage לפני הלולאה מאלצת את Windows ליצור את תור ההודעות של התהליכון, וסוגרת את מרוץ ההפעלה שבו פוסט מוקדם מתהליכון ה-UI היה נכשל

גבולות מילים מגיעים כהיסט תווים

ייבאו את ה-Microsoft Speech Object Library פעם אחת דרך יבואן ספריית הטיפוסים של סביבת הפיתוח (IDE) ותקבלו את SpeechLib_TLB עם העטיפה TSpVoice והאירועים הטיפוסיים שלו. שתי הגדרות חשובות. יש לצמצם את EventInterests לאירועים שאתם צורכים בפועל, כיוון שכל אינטרס שנשאר מופעל יוצר תעבורת אירועים חוצת-תהליכונים עבור כל מילה בכל עמוד; SVEWordBoundary מניע את ההדגשה ו-SVEEndInputStream אומר לכם שהאמירה הסתיימה. ומטפל ה-OnWord מקבל את CharacterPosition ואורך, המצביעים לתוך המחרוזת המדויקת שהעברתם ל-Speak — היסט לתוך חוצץ הדיבור, ולא לשום דבר אחר

הסעיף האחרון הוא המשתנה הקבוע (invariant) שעליו תלויה התכונה: היסטים הם משמעותיים רק כלפי המחרוזת שהקול קורא, לכן דברו בדיוק את הטקסט שחילצתם, תו אחר תו. קיצוץ רווחים, קריסת מעברי שורה, או הרחבת קיצור לשם הגייה יפה יותר, וכל הדגשה אחרי העריכה הראשונה תנחת מילה אחת הצידה. אם על ה-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 הוא המרשל הנכון כאן, לא Synchronize: למטפל אסור להחנות את תהליכון הדיבור בזמן שה-UI מצייר מחדש, ואם אירועי גבול מגיעים מהר יותר ממה שהמסך מצייר, עדכון הדגשה מיושן אינו מזיק מכיוון שהבא אחריו ידרוס אותו. חברו את OnEndStream באותה צורה כדי לנקות את ההדגשה, ובמצב קריאה רציפה, כדי לטעון את הטקסט של העמוד הבא ולשלוח את האמירה הבאה

מהיסט תווים לפיקסלים על המסך

PDFium מדווח על גיאומטריה עבור כל תו. FPDFText_GetCharBox ממלא ארבעה ערכי double בסדר שגרם ליותר באגים שקטים מכל דבר אחר ב-API של הטקסט — שמאל, ימין, למטה, למעלה, ולא שמאל, למעלה, ימין, למטה של Windows — והוא מדווח עליהם במרחב העמוד: נקודות PDF, 72 לאינץ', כאשר המקור בפינה השמאלית-תחתונה וה-Y גדל כלפי מעלה. התיבה של מילה היא האיחוד של תיבות התווים שלה, וההתמרה לפיקסלי מכשיר כוללת שלושה שלבים: תרגום לפי מקור העמוד, שינוי קנה מידה לפי זום כפול 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 הוא גובה העמוד בנקודות מ-FPDF_GetPageHeight, ו-FPageLeft הוא אפס ברוב המסמכים אך מגיע מתיבת החיתוך (crop box) כאשר העמוד מגדיר כזו, ולכן קראו את שניהם מ-FPDF_GetPageBoundingBox במקום להניח. היפוך ה-Y הוא המקום שבו גרסאות שנכתבו ידנית נשברות: החלק העליון של מלבן המכשיר מגיע מהחלק העליון של תיבת ה-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;

מטפל הציור (paint handler) מצייר תחילה את מפת הסיביות של העמוד ואחריה את ההדגשה, בכל פעם, כך שהשכבה מעל (overlay) לא צריכה למחוק את עצמה לעולם; פסילת (invalidating) המלבן הישן והחדש שומרת על אזור הציור-מחדש קטן גם בקצבי דיבור מהירים. FHighlightBrush הוא TBitmap של פיקסל על פיקסל שממולא פעם אחת באתחול בצבע ההדגשה — FHighlightBrush.Canvas.Pixels[0, 0] := $0032C8FF עבור צבע ענבר — ש-AlphaBlend מותח מעל מלבן היעד, כך ששום דבר אינו מוקצה לכל פריים (frame), ו-SourceConstantAlpha של 96 שומר על המילה קריאה דרך הגוון. בדקו את הצבע במצבי תצוגה הפוכים ובניגודיות גבוהה; שכבה המסתירה שמשתמש עם ראייה ירודה אינו יכול לראות פשוט אינה קיימת בדיוק עבור האדם שעבורו היא נבנתה

סדר קריאה הוא החלק ש-API הטקסט לא יפתור

FPDFText_GetText מחזיר תווים בסדר הנגזר מזרם התוכן עם קצת ניקוי מרחבי, ועבור דוח של עמודה אחת סדר זה בסדר גמור. אין לו שום מחויבות להיות צודק בשום מקום אחר. ניוזלטר של שתי עמודות עלול להיקרא ישר לרוחב שתי העמודות, סרגל צד עלול לקטוע משפט באמצע סעיף, וכותרת תחתונה עשויה להופיע באמצע העמוד. המידע שמתקן זאת — עץ המבנה הלוגי של ISO 32000-1 §14.8, ש-PDFs מתויגים נושאים ו-PDF/UA מחייב — אינו נבדק כלל על ידי קריאות העמוד-טקסט הגולמיות. אם אתם זקוקים לסדר מודע-למבנה עם אות מפורש של מקורו, זו בעיה פתורה מדף אחד למעלה: קריאת ה-API של PDFium Component מחזירה תוכן עם שדה Source של rosStructure או rosHeuristic, והמאמר על קורא PDF נגיש מדריך בכך. ברמת ה-API הגולמית, העמדה שניתנת להגנה היא להתייחס לסדר החילוץ כאל הערכה, לומר זאת ב-UI, ולשמור מסמך מרובה-עמודות אחד וסריקה מבוססת-תמונה אחת בסט הרגרסיה כך ששני מצבי הכשל יישארו גלויים

המציג עצמו צריך להיות ניתן להפעלה באמצעות המקלדת

פלט דיבור אינו פוטר את המציג מגישה למקלדת; האנשים שהכי סביר שישתמשו בהקראה הם אלו שהכי פחות סביר שיושיטו יד לעכבר. תנו לפאנל העמוד TabStop := True ומלבן מיקוד גלוי, ואז טפלו בשלושה מקשים: מקש הרווח (Space) מחליף בין FVoice.Pause ו-FVoice.Resume, וימין ושמאל מדלגים דרך FVoice.Skip('Sentence', 1) עם ספירה שלילית כדי לחזור אחורה. ה-Skip של SAPI מבין רק רזולוציה של משפטים, ולכן דילוג ברמת-המילה אומר טיהור ההשמעה עם SVSFPurgeBeforeSpeak ודיבור-מחדש מההיסט של המילה האחרונה שעקבתם אחריה — מהלך זול, מכיוון שקוד ההדגשה כבר מאחסן בדיוק את ההיסט הזה. שמרו על כל בקרת תעבורה כ-TButton אמיתי עם כתובית כדי שקוראי מסך יכריזו עליו

זה כל הצינור, כולו מול ה-API הגולמי לטקסט של PDFium: תהליכון דיבור שמחזיק ב-COM ובקול לכל אורך חיי האפליקציה, אירועי גבול המועברים למרשל אל ה-UI כהיסט תווים, ותיבות במרחב-העמוד עבור כל תו המהופכות למלבן מעורבל אחד על המסך. אם אתם מעדיפים שלא לנהל את הגיאומטריה והמעקב בעצמכם, PDFium Component מספק תיבות לכל-מילה, את סמן המעקב, גלילה אוטומטית עוקבת, ויחידות קריאה ברמת המשפט כמאפייני רכיב (component properties), והדגמת ההקראה שלו היא הצינור של מאמר זה המצומצם לקומץ קריאות