מאמר טכני

הדגשת TTS מילה במילה במציגי PDFium ב-Delphi

לתכונת הקראה בקול יש תפקיד גלוי אחד מעבר לקול: ככל שכל מילה נאמרת, עליה להאיר את המילה הזו בדף ולשמור אותה בשדה הראייה. כדי לעשות זאת, אתה צריך את התיבה התוחמת (bounding box) של כל מילה, המאונדקסת לאותו זרם תווים שמנוע הדיבור קורא ממנו. קבל את התיבות אך פספס את האינדקס, וההדגשה תפגר מילה או שתיים אחרי השמע; קבל את האינדקס אך טפל לא נכון במצב הדף, וההדגשה תנחת בדף הלא נכון לחלוטין. חלק הדיבור של זה, הסינתיסייזר עצמו, הוא החלק שלעיתים נדירות נשבר. SAPI מדווח על גבולות מילים ברמת התו. מה שנשבר הוא שכבת המיפוי הדקה שבין היסט (offset) תווים במאגר הדיבור לבין מלבן בדף המרונדר

PDFium Component מספק מיפוי זה עבור Delphi,‏ C++Builder ו-Lazarus, עם תיבות מילים זמינות מאז גרסה v1.53 וסמן מעקב מאז v1.56. הממשק צר במכוון: קריאה שמחזירה את תיבות המילים עבור דף, גשש (tracker) שהופך היסט תווי להדגשה מצוירת, ועוד כמה מאפיינים לצבע ולגלילה אוטומטית. צר ככל שיהיה, הסדר שבו אתה קורא לדברים מחליט האם התכונה פועלת, ורוב הכישלונות למטה נובעים מקריאה לפונקציות הנכונות בסדר הלא נכון

תווים אינם מילים, ומנועי TTS מדברים בתווים

מנוע דיבור צורך מחרוזת שטוחה ומדווח על התקדמות כמיקומי תווים בתוך מחרוזת זו. דף PDF מכיל גליפים (glyphs) הממוקמים במרחב הדף, כאשר "מילה" היא אשכול יוריסטי של ריצות גליפים. שתי מערכות הקואורדינטות לא חולקות דבר, אלא אם הטקסט שאתה מוסר לסינתיסייזר הוא בדיוק (byte-for-byte) הטקסט שממנו חושבו תיבות המילים. זהו כלל ראשון, והוא אינו סלחני. נרמל רווחים, הסר מקפים רכים, או "נקה" בדרך אחרת את הטקסט המחולץ לפני שאתה מקריא אותו, וכל היסט בהמשך יהיה שגוי בשקט. קרא בדיוק את מה שחילצת, או שמור טבלת מיפוי מחדש מפורשת של היסטים. אין אפשרות שלישית ששורדת מסמכים אמיתיים

טבלת המיפוי מחדש אינה מקרה קצה היפותטי. ברגע שממשק המשתמש שלך מכניס הכרזת דף מדוברת ("page five") או מרחיב קיצור עבור הסינתיסייזר, המחרוזת המדוברת סוטה מהמחרוזת המחולצת. תעד את המיקום והאורך של כל הכנסה, ואז החסר את ההתאמה המצטברת לפני כל קריאת מעקב. מדובר אולי בעשרים שורות של הנהלת חשבונות, וזה ההבדל בין הדגשה ששורדת את בקשת התכונה הבאה לבין אחת שנשברת בפעם הראשונה שמישהו מבקש כותרות מדוברות

מה שתיבת מילה נותנת לך

כל רשומת TPdfWordBox נושאת את הטקסט של המילה, ה-StartIndex וה-Count (ספירת התווים) שלה בתוך טקסט הדף, Rect במרחב הדף, ואת מספר ה-Page (מבוסס 1). PageWordBoxes מחזיר את המערך המלא עבור הדף הפעיל:

procedure TReaderForm.PreparePage(PageNo: Integer);
begin
  PdfView.PageNumber := PageNo;   // תיבות המילים של התצוגה עוקבות אחר הדף המוצג שלה

  FWords := PdfView.PageWordBoxes;
  FPageText := BuildSpeechText(FWords);   // שרשור Word.Text לפי הסדר

  if Length(FWords) = 0 then
    HandleImageOnlyPage(PageNo);          // סריקה ללא שכבת טקסט
end;

ההערה לגבי הסדר נושאת משקל. PageWordBoxes של המציג מחלק לאסימונים (tokenizes) את שכבת הטקסט של הדף שהתצוגה מציגה כעת, לכן נווט תחילה בתצוגה וחלץ שנית; אין צורך ברינדור, רק במסמך פתוח. (רכיב המסמך, TPdf, חושף PageWordBoxes משלו המאונדקס ל-Pdf.PageNumber לשימוש ללא ממשק (headless). שני מספרי הדפים בלתי תלויים, שזו מלכודת בפני עצמה). תוצאה ריקה בדף שנושא באופן חזותי תוכן משמעותה סריקה של תמונה בלבד. נתב אותה ל-OCR, או לפחות הכרז על כך ("page 4 contains no readable text"), במקום לתת לקול להשתתק ללא הסבר

חיבור גבולות המילים של SAPI לגשש

TrackReadingWordAt, במציג, הוא הציר של התכונה כולה. תן לו מספר דף ואינדקס תו; הוא מוצא את תיבת המילה המכילה תו זה, מצייר עליה את סמן הקריאה, ומחזיר את אינדקס המילה, או 1- כאשר האינדקס נופל בין מילים. ההתראה על גבול מילה של SAPI מספקת בדיוק את מיקום התו שהוא רוצה:

procedure TReaderForm.OnSpeechWordBoundary(StreamPos: Integer);
var
  WordIdx: Integer;
begin
  // ממפה את ההיסט לתיבת מילה ומזיז את ההדגשה בקריאה אחת
  WordIdx := PdfView.TrackReadingWordAt(FPageNo, StreamPos);
  if WordIdx < 0 then
    Exit;                     // הגבול נפל מחוץ לכל מילה: שמור על ההדגשה הקודמת
end;

שני פרטים הגנתיים מצדיקים את קיומם כאן. ראשית, TrackReadingWordAt שומר מטמון תיבות מילים משלו עבור הדף במעקב, אשר נבנה מחדש אוטומטית כאשר הדף משתנה, כך שהעלות לכל גבול נשארת שטוחה לא משנה כמה מהר הגבולות מגיעים. שנית, הוא אינו בודק גבולות בנדיבות. אינדקס השווה לספירת התווים של הדף או חורג ממנה מחזיר 1- במקום להיצמד למילה האחרונה. התייחס ל-1- כ"החזק את ההדגשה הקודמת", לעולם לא כשגיאה, כי רצפים של פיסוק ורווחים בין מילים מייצרים לגיטימית גבולות שאינם שייכים לאף מילה. רישום ביומן של כל 1- יקבור אותך. ספור אותם פר דף במקום זאת, והתבונן היטב על כל דף שבו היחס מזנק, שכן הדבר אומר בדרך כלל חוסר התאמה בנרמול הטקסט בחזרה לכלל הראשון

הסמן עצמו: צבע, מעקב, וניקוי

SetReadingWord מצייר את ההדגשה ישירות כאשר אתה מחזיק בתיבת המילה בעצמך, ReadingWordColor מעצב אותה, ו-ReadingWordFollow := True גולל את התצוגה בדיוק מספיק כדי לשמור על המילה המדוברת גלויה. המאפיין האחרון הזה מרוויח את מקומו. גלילת "מרכז את המילה הנוכחית" בקוד ידני גורמת לדף לזנק בכל מעבר שורה, וקוראים הרגישים לתנועה יכבו את התכונה כולה בתוך דקה. ההדגשה מרונדרת רק בדף המוצג כעת ב-TPdfView הפעיל, כך שקריאה מרובת דפים חייבת לקדם את PageNumber בקצב אחד עם הדיבור, ואז להריץ מחדש את שלב ההכנה עבור הדף החדש לפני שאירוע הגבול הראשון שלו נוחת. דלג על כך, וההדגשות הראשונות בכל דף יצביעו על קואורדינטות ישנות

procedure TReaderForm.StopReading;
begin
  FVoice.Stop;                // עצור את הנגינת SAPI תחילה
  PdfView.ClearReadingWord;   // לאחר מכן הסר את ההדגשה; סמן ישן נראה כבאג
end;

סימטריה בכיבוי היא מה ששומר על ההדגשה כנה. כל נתיב של השהיה, עצירה והפיכת דף חייב להסתיים ב-ClearReadingWord. השאר זאת בחוץ ומלבן ענבר יישב על דף שעצר, וייראה בדיוק כמו פגם, שזה סוג הדבר שכל בודק ידווח עליו למרות ששום דבר לא באמת שבור

קצב הדיבור מלחיץ את הצינור הזה חזק יותר ממה שעושה גודל המסמך. בקצב של 300 מילים לדקה אירועי הגבול מגיעים כל 200 אלפיות שנייה, ובקצבי ה-SAPI המהירים ביותר הם באים מהר יותר ממה שהעין יכולה לעקוב בנוחות. התגובה הנכונה היא למזג (coalesce), לא לתור (queue). אם מגיע גבול חדש בזמן שעדכון הדגשה עדיין ממתין, הפל את הישן וצייר את החדש ביותר. סמן שמבקר בכל מילה לפי הסדר אך מפגר בחצי שנייה מרגיש שבור; כזה שמדלג מדי פעם על מילה תוך שהוא נשאר מסונכרן עם הקול אינו מרגיש כך

מקרי קצה המפרידים בין דגמים למוצרים

מספר קטגוריות של מסמכים חושפות את התפרים. תווים משלבים (Combining characters) הם העדינים ביותר: רצפי Unicode כגון אות בסיס בתוספת סימן דיאקריטי משלב יכולים לתפוס יותר אינדקסי תווים ממה שהמילה החזותית מרמזת, כך שכל אריתמטיקת היסט שמניחה אינדקס אחד לכל גליף סוטה לאט לאט. זהו הטיעון החזק ביותר לאפשר ל-TrackReadingWordAt להחזיק במיפוי במקום לחשב מספרי מילים ביד. מיקוף הוא שכיח יותר אך גם נפוץ יותר: מילה שנשברה מעבר למעבר שורה הופכת לשתי תיבות, ואם אתה קורא אותה כאסימון יחיד, אירוע הגבול עבור החצי השני שלה יפנה לתיבה הראשונה. זה בדרך כלל בסדר, אבל זו החלטה, אז קבל אותה בכוונה במקום לגלות אותה. תיוג (Tagging) משנה את סדר הקריאה עצמו. כאשר מסמך נושא תגי מבנה נאותים (הטריטוריה של ISO 14289,‏ PDF/UA), סדר המילים עוקב אחר המבנה הלוגי; בלעדיהם הוא נופל חזרה ליוריסטיקות של פריסה, ודף דו-טורי לא מתוייג יכול להיקרא ישר לרוחב שתי העמודות. דפים מסובבים הם המקרה הנפוץ האחרון: ה-Rect של כל מילה עדיין תוחם אותה כראוי במרחב הדף, אבל מדיניות מעקב-תצוגה מכווננת לזרימה אופקית נגללת באופן צורם כאשר הטקסט רץ אנכית, לכן שמור לפחות מסמך מסובב אחד בסט הרגרסיה. לטיפול בסדר קריאה, יחידות ברמת המשפט דרך ReadingUnits, והמערך המסייע (assistive stack) הרחב יותר, ראה בניית קורא PDF נגיש ב-Delphi

אילוץ פלטפורמה אחד מעצב את הפריסה. SAPI הוא ל-Windows בלבד. הממשק של תיבות המילים והמעקב זהה ב-Lazarus וב-FPC (byte-for-byte), אך מערכות Linux ו-macOS זקוקות לסינתיסייזר שונה המחובר לאחורי אותם אירועי גבול; תצורה זו מכוסה בהפעלת המציג תחת Lazarus ו-FPC. עלות ההדגשה גם מתקשרת עם מטמון הדפים שלך ברגע שקצבי הדיבור עולים, וחשבון התקציב במטמון רינדור וביצועי זום תקף גם כאן ללא שינוי

כאשר הדגשה של מילה בודדת היא הגרעיניות השגויה

קריוקי ברמת המילה הוא לא תמיד מה שקורא רוצה. בקצבי דיבור גבוהים הסמן המהבהב מילה במילה הופך לרעש חזותי בפני עצמו, ויש מאזינים שעוקבים אחרי משפט בצורה נוחה יותר מאשר הבהוב של מילים בודדות. למקרה כזה, הרכיב חושף יחידה גסה יותר. ReadingUnits מחזיר יחידות ברמת משפט וברמת בלוק, שלכל אחת מלבני הדגשה משלה, ואתה מצייר אותם עם SetReadingHighlight במקום SetReadingWord. החיבור הוא באותה צורה: היסט גבול עדיין מכתיב איזו יחידה מוארת, אבל היחידה שאתה מדגיש משתרעת על פני פסוקית או שורה במקום אסימון יחיד. קוראים איטיים יותר והשמעה בקצב מהיר נוטים להעדיף זאת, ושום דבר לא מונע ממך להציע את שני המצבים מאחורי הגדרה

רצפות הגרסה שוות עיגון לפני שאתה בונה נגד זה: תיבות מילים זקוקות ל-PDFium Component גרסה v1.53 ואילך, וסמן המעקב זקוק ל-v1.56. ממשק ה-API המלא לקריאה, היחידות ברמת המשפט, והדגמת הקראה בקול שפועלת נמצאים בדף המוצר עבור PDFium Component