บทความเทคนิค

การไฮไลต์คำต่อคำ TTS ใน Delphi PDFium Viewer

ฟีเจอร์อ่านออกเสียงมีงานที่มองเห็นได้หนึ่งอย่างนอกเหนือจากเสียง: เมื่อแต่ละคำถูกพูด มันต้องจุดไฟคำนั้นบนหน้าและให้คำนั้นอยู่ในมุมมอง เพื่อทำสิ่งนั้น คุณต้องการ bounding box ของทุกคำ ที่ indexed ไปยัง character stream เดียวกับที่ speech engine กำลังอ่านจาก ได้ box แต่พลาด indexing และไฮไลต์จะตามหลังเสียงหนึ่งหรือสองคำ ได้ indexing แต่จัดการ page state ผิดและไฮไลต์จะลงบนหน้าผิดทั้งหมด ส่วน speech ของสิ่งนี้ ซึ่งก็คือตัว synthesizer เอง คือส่วนที่แทบไม่เคยพัง SAPI รายงาน word boundary ไปยังอักขระ สิ่งที่พังคือ mapping layer บาง ๆ ระหว่าง character offset ใน speech buffer และสี่เหลี่ยมบนหน้าที่เรนเดอร์แล้ว

PDFium Component มี mapping นั้นสำหรับ Delphi, C++Builder และ Lazarus โดยมี word box ตั้งแต่ v1.53 และ tracking cursor ตั้งแต่ v1.56 surface นั้นแคบโดยเจตนา: การเรียกที่ส่งคืน word box สำหรับหน้า tracker ที่แปลง character offset เป็นไฮไลต์ที่ paint แล้ว และ property สองสามตัวสำหรับสีและ auto-scroll แม้จะแคบแต่ลำดับที่คุณเรียกสิ่งต่าง ๆ กำหนดว่าฟีเจอร์จะทำงานหรือไม่ และความล้มเหลวส่วนใหญ่ด้านล่างมาจากการเรียกฟังก์ชันที่ถูกต้องในลำดับที่ผิด

อักขระไม่ใช่คำ และ TTS engine พูดเป็นอักขระ

speech engine บริโภค string ราบและรายงานความก้าวหน้าเป็น character position ภายใน string นั้น หน้า PDF มี glyph ที่วางใน page space ซึ่ง "คำ" คือ cluster ของ glyph run แบบ heuristic ระบบพิกัดสองอย่างนั้นไม่มีอะไรร่วมกันเว้นแต่ข้อความที่คุณส่งให้ synthesizer จะเป็น byte ต่อ byte เหมือนกับข้อความที่ word box ถูกคำนวณจาก นั่นคือกฎข้อหนึ่ง และมันไม่ให้อภัย Normalize whitespace ตัด soft hyphen หรือ "ทำความสะอาด" ข้อความที่ดึงมาก่อนพูด และทุก offset ปลายน้ำจะผิดอย่างเงียบ พูดสิ่งที่คุณดึงมาพอดี หรือเก็บ offset remapping table ที่ชัดเจน ไม่มีตัวเลือกที่สามที่รอดในเอกสารจริง

remapping table ไม่ใช่ edge case สมมติ ทันทีที่ UI ของคุณแทรก page announcement พูด ("หน้าห้า") หรือขยายคำย่อสำหรับ synthesizer string ที่พูดจะแยกออกจากอันที่ดึงมา บันทึกตำแหน่งและความยาวของการแทรกแต่ละอัน แล้วลบการปรับที่สะสมออกก่อนทุกการเรียก tracking มันอาจเป็นประมาณ 20 บรรทัดของการบุ๊กคีปปิ้ง และมันคือความแตกต่างระหว่างไฮไลต์ที่รอดจาก feature request ถัดไปและอันที่พังในครั้งแรกที่ใครขอหัวข้อที่พูด

word box ให้อะไรคุณ

record TPdfWordBox แต่ละตัวมีข้อความของคำ StartIndex และ Count อักขระภายใน page text, Rect ใน page space และหมายเลข Page แบบ 1-based ฟิลด์ StartIndex คือสะพานระหว่างระบบพิกัดสอง: มันเป็น offset เดียวกับที่ SAPI จะส่งกลับขณะที่มันอ่าน PageWordBoxes ส่งคืน array เต็มสำหรับหน้า active:

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;

ความคิดเห็นเรื่องลำดับมีน้ำหนัก PageWordBoxes ของ viewer tokenize text layer ของหน้าที่ view แสดงอยู่ปัจจุบัน ดังนั้น navigate view ก่อนและดึงทีหลัง ไม่จำเป็นต้องเรนเดอร์ เพียงแต่เปิดเอกสาร (document component, TPdf แสดง PageWordBoxes ของตัวเองที่ key ด้วย Pdf.PageNumber สำหรับใช้แบบ headless หมายเลขหน้าสองตัวนั้นเป็นอิสระ ซึ่งเป็นกับดักของตัวเอง) ผลลัพธ์ว่างเปล่าในหน้าที่มองเห็นว่ามีเนื้อหาหมายความว่าเป็นการสแกนแบบ image-only ส่ง route ไปยัง OCR หรืออย่างน้อยประกาศ ("หน้า 4 ไม่มีข้อความที่อ่านได้") แทนที่จะปล่อยให้เสียงเงียบโดยไม่มีคำอธิบาย

เชื่อมต่อ SAPI word boundary กับ tracker

TrackReadingWordAt บน viewer คือบานพับของฟีเจอร์ทั้งหมด ให้หมายเลขหน้าและ character index; มันจะหา word box ที่มีอักขระนั้น paint reading cursor บนมัน และส่งคืน word index หรือ −1 เมื่อ index ตกระหว่างคำ การแจ้งเตือน word-boundary ของ SAPI ให้ character position ที่มันต้องการพอดี:

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;

รายละเอียด defensive สองอย่างได้ตำแหน่งของมัน แรกคือ TrackReadingWordAt เก็บ word-box cache ของตัวเองสำหรับหน้าที่ tracked ซึ่ง rebuild อัตโนมัติเมื่อหน้าเปลี่ยน ดังนั้นต้นทุนต่อ boundary ยังคงเท่าเดิมไม่ว่า boundary จะมาเร็วแค่ไหน ประการที่สองมันไม่ bounds-check อย่างใจกว้าง index ที่หรือเกิน character count ของหน้าส่งคืน −1 แทนการ clamp ไปยังคำสุดท้าย Treat −1 ว่า "ถือไฮไลต์ก่อนหน้าไว้" ไม่ใช่ error เพราะ punctuation run และ whitespace ระหว่างคำก่อให้เกิด boundary ที่ถูกต้องที่ไม่เป็นของคำใด ๆ การ log ทุก −1 จะท่วมคุณ นับทีละหน้าแทน และดูอย่างถี่ถ้วนที่หน้าใด ๆ ที่อัตราส่วนพุ่งขึ้น เพราะนั่นมักหมายความว่า text-normalization mismatch ย้อนกลับไปที่กฎข้อหนึ่ง

cursor เอง: สี follow และการทำความสะอาด

SetReadingWord paint ไฮไลต์โดยตรงเมื่อคุณถือ word box เอง ReadingWordColor จัดสไตล์ และ ReadingWordFollow := True เลื่อน view พอดีเพื่อให้คำที่พูดอยู่ในมุมมอง property สุดท้ายได้ตำแหน่งของมัน การ scroll "center the current word" แบบ hand-rolled ทำให้หน้ากระตุกที่ทุก line break และผู้อ่านที่ sensitive ต่อการเคลื่อนไหวจะปิดฟีเจอร์ทั้งหมดภายในหนึ่งนาที ไฮไลต์ render เฉพาะบนหน้าที่แสดงอยู่ใน TPdfView ที่ active ดังนั้นการอ่านหลายหน้าต้องเลื่อน PageNumber ตามเสียง จากนั้น re-run ขั้นตอน prepare สำหรับหน้าใหม่ก่อนที่ boundary event แรกจะลง ข้ามสิ่งนั้นและไฮไลต์แรก ๆ ในทุกหน้าจะชี้ไปยังพิกัดที่ล้าสมัย

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

ความสมมาตรในการปิดคือสิ่งที่รักษาไฮไลต์ให้ซื่อสัตย์ ทุก path ของ pause, stop และ page-turn ต้องจบด้วย ClearReadingWord ปล่อยออกไปและสี่เหลี่ยมสีเหลืองอำพันนั่งบนหน้าที่หยุดแล้วดูเหมือนข้อบกพร่อง ซึ่งเป็นสิ่งที่ผู้ทดสอบทุกคนจะ file แม้ว่าไม่มีอะไรพังจริง ๆ

Speech rate เครียด pipeline นี้หนักกว่าขนาดเอกสาร ที่ 300 คำต่อนาที boundary event มาทุก 200 ms และที่ SAPI rates เร็วที่สุดมันมาเร็วกว่าที่ตาจะติดตามสบาย การตอบสนองที่ถูกต้องคือ coalesce ไม่ใช่ queue หาก boundary ใหม่มาในขณะที่ highlight update ยังค้างอยู่ ให้ทิ้งอันเก่าและ paint อันล่าสุด cursor ที่เยี่ยมทุกคำตามลำดับแต่ล่าช้าครึ่งวินาทีรู้สึกพัง ส่วนอันที่ข้ามคำเป็นครั้งคราวในขณะที่ sync กับเสียงไม่รู้สึกพัง

Edge case ที่แยก demo ออกจาก product

เอกสารบางประเภทเปิดเผยรอยต่อ Combining character เป็นอะไรที่ละเอียดที่สุด: Unicode sequence เช่น base letter บวก combining diacritic อาจใช้ character index มากกว่าที่คำภาพบอก ดังนั้น offset arithmetic ใด ๆ ที่สมมติหนึ่ง index ต่อหนึ่ง glyph จะค่อย ๆ drift นั่นเป็นข้อโต้แย้งที่แข็งแกร่งที่สุดสำหรับการให้ TrackReadingWordAt เป็นเจ้าของ mapping แทนที่จะคำนวณหมายเลขคำด้วยมือ Hyphenation เป็นเรื่องธรรมดากว่าแต่พบบ่อยกว่า: คำที่แบ่งข้าม line break กลายเป็นสอง box และหากคุณพูดเป็น token เดียว boundary event สำหรับครึ่งที่สองจะแก้ไขเป็น box แรก โดยทั่วไปนั่นถือว่าใช้ได้ แต่มันเป็นการตัดสินใจ ดังนั้นทำโดยเจตนาแทนที่จะค้นพบมัน Tagging เปลี่ยนลำดับการอ่านเอง เมื่อเอกสารมี structure tag ที่เหมาะสม (อาณาเขตของ ISO 14289, PDF/UA) การเรียงคำตาม logical structure หากไม่มี tag มันจะ fallback ไปยัง layout heuristic และหน้าสองคอลัมน์ที่ไม่มี tag อาจอ่านตรงข้ามทั้งสองคอลัมน์ หน้าที่หมุนเป็น edge case ทั่วไปสุดท้าย: Rect ของแต่ละคำยังคง bound มันอย่างถูกต้องใน page space แต่ viewport-follow policy ที่ tune สำหรับ horizontal flow จะ scroll อย่างกระตุกเมื่อข้อความวิ่งในแนวตั้ง ดังนั้นเก็บอย่างน้อยเอกสารที่หมุนหนึ่งรายการไว้ใน regression set สำหรับการจัดการ reading-order, sentence-level unit ผ่าน ReadingUnits และ assistive stack ที่กว้างกว่า ดู การสร้าง accessible PDF reader ใน Delphi

ข้อจำกัดของ platform หนึ่งกำหนด deployment SAPI เป็น Windows เท่านั้น word-box และ tracking API เป็น byte ต่อ byte เหมือนกันภายใต้ Lazarus และ FPC แต่ Linux และ macOS build ต้องการ synthesizer ที่ต่างกันที่เชื่อมต่อหลัง boundary event เดียวกัน การตั้งค่านั้นครอบคลุมใน การรัน viewer ภายใต้ Lazarus และ FPC ต้นทุนไฮไลต์ยังโต้ตอบกับ page cache ของคุณเมื่อ speech rate เพิ่มขึ้น และ budget arithmetic ใน render caching และ zoom performance ใช้ที่นี่โดยไม่เปลี่ยนแปลง

เมื่อการไฮไลต์ทีละคำเป็น granularity ที่ผิด

Word-level karaoke ไม่ได้เป็นสิ่งที่ผู้อ่านต้องการเสมอ ที่ speech rate สูง cursor ที่กะพริบทีละคำกลายเป็น visual noise ของตัวเอง และผู้ฟังบางคนติดตามประโยคได้สบายกว่าแสงสโตรบของคำเดี่ยว สำหรับกรณีนั้น component แสดง unit ที่หยาบกว่า ReadingUnits ส่งคืน sentence- และ block-level unit แต่ละอันมี highlight rectangle ของตัวเอง และคุณ paint มันด้วย SetReadingHighlight แทน SetReadingWord การเชื่อมต่อเป็นรูปแบบเดียวกัน: boundary offset ยังคงขับ unit ใดที่ไฟขึ้น แต่ unit ที่คุณไฮไลต์ span ประโยคหรือบรรทัดแทนที่จะเป็น token เดียว ผู้อ่านช้าและการเล่นที่ rate สูงต่างก็มีแนวโน้มที่จะชอบมัน และไม่มีอะไรหยุดคุณจากการเสนอทั้งสองโหมดใน setting

version floor ควรระบุก่อนที่คุณจะ build กับสิ่งนี้: word box ต้องการ PDFium Component v1.53 หรือใหม่กว่า และ tracking cursor ต้องการ v1.56 reading API เต็มรูปแบบ, sentence-level unit และ demo อ่านออกเสียงที่ทำงานได้อยู่ในหน้าสินค้าของ PDFium Component