ผู้ใช้ตาบอดคนหนึ่งเปิดรายงานประจำไตรมาสใน viewer Delphi ตัวใหม่เอี่ยมของคุณ เปิด NVDA แล้วได้ยิน footer ของหน้าก่อน ตามด้วยคอลัมน์ตัวเลข แล้วค่อยได้ยินชื่อเรื่องที่ผู้อ่านตาปกติคนไหนก็จะอ่านก่อนเป็นอันดับแรก หรือไม่ก็ไม่ได้ยินอะไรเลย หน้ากระดาษดูสมบูรณ์แบบบนหน้าจอ และนั่นแหละคือกับดักตัวจริง: การ render และการอ่านคือปัญหาคนละเรื่องที่แก้ด้วยโค้ดคนละชุด ลำดับที่ PDF วาด glyph ของมันไม่มีข้อผูกมัดใด ๆ ที่ต้องตรงกับลำดับที่คนควรได้ยิน ดังนั้น viewer ที่สร้างขึ้นจากแค่การเรียก render จึงให้ภาพที่สมบูรณ์แบบแต่การบรรยายที่ใช้งานไม่ได้ PDFium Component ตัวห่อ VCL/LCL รอบเอนจิ้น PDFium สำหรับ Delphi, C++Builder และ Lazarus พก API การอ่านชุดแยกต่างหากไว้ด้วยเหตุผลนี้เอง API การวาดไม่มีทางกู้คืนลำดับการอ่านที่มันไม่เคยได้รับมาตั้งแต่แรกได้เลย
reader ที่เข้าถึงได้จะรอดหรือล้มขึ้นอยู่กับสามสิ่ง มันต้องสกัดลำดับที่ screen reader พูดออกมาได้ ต้องเก็บ word cursor ที่มองเห็นได้ให้ปักอยู่กับสิ่งที่เสียงกำลังพูดอยู่ และต้องยอมรับเมื่อเอกสารไม่เคยถูก tag ไว้ แทนที่จะเดาแล้วแสร้งทำเป็นรู้ แต่ละอย่างมี API ที่ชัดเจนให้เอื้อมไปหยิบใช้ และมีความล้มเหลวที่จะกัดคุณถ้าคุณข้ามรายละเอียดไป
ลำดับการอ่านอยู่ใน structure tree ไม่ใช่ลำดับการวาด
ISO 32000-1 §14.8 นิยาม logical structure ให้เป็นต้นไม้ของ element ที่ซ้อนอยู่เหนือเนื้อหาของหน้า PDF/UA (ISO 14289-1) ไปไกลกว่านั้นอีก ด้วยการทำให้ต้นไม้นั้นเป็นข้อบังคับ: เนื้อหาจริงทุกชิ้นต้องเข้าถึงได้ผ่านมันตามลำดับการอ่าน โดย artifact ของหน้าถูกทำเครื่องหมายไว้ว่าเป็น artifact และถูกข้ามไป รายงานที่ tag ไว้ถูกต้องจะรู้ว่า "Quarterly Results" คือหัวข้อระดับสอง และตารางยอดรวมคือตารางที่มีเซลล์หัวตาราง รายงานที่ไม่ได้ tag ไว้ก็เป็นแค่กองของ glyph run ที่วางตำแหน่งไว้ ซึ่งบังเอิญดูเหมือนเอกสารเท่านั้นเอง
ReadablePageContent ไล่ผ่านโครงสร้างนั้นเมื่อมันมีอยู่ แล้วส่ง fragment ที่ tag ด้วย Kind เชิงความหมายกลับมา ค่าอย่าง cfHeading และ cfParagraph ทำให้ UI พูดว่า "หัวข้อ" ก่อนคำเหล่านั้นได้ แทนที่จะอ่านบรรทัดตัวหนาเป็นเนื้อความธรรมดา ถ้าไม่มีต้นไม้ที่ใช้งานได้ การเรียกเดียวกันนั้นก็จะตกไปใช้การวิเคราะห์ layout แบบ heuristic: ตรวจจับคอลัมน์ จัดกลุ่ม baseline เรียงจากซ้ายไปขวาและบนลงล่าง fallback แบบนั้นใช้ได้ดีกับ memo คอลัมน์เดียว แต่สั่นคลอนกับจดหมายข่าว แบบฟอร์มหลายคอลัมน์ หรืออะไรก็ตามที่มี sidebar หรือ pull quote สิ่งที่สำคัญคือรู้ว่าคุณได้ผลลัพธ์แบบไหนมา และ API ก็บอกคุณตรง ๆ record TPdfReadableContent พกฟิลด์ Source ที่ตั้งเป็น rosStructure เมื่อลำดับมาจากต้นไม้ที่ tag ไว้ หรือ rosHeuristic เมื่อมันถูกอนุมานมาจากรูปทรง แสดงลำดับที่เดามาราวกับว่ามันถูกยืนยันแล้ว แล้วคุณก็ได้ส่งมอบ accessibility เวอร์ชันของป้าย passing บน build ที่ไม่มีใครรันจริง ๆ ออกไป
การเคลื่อนไหวที่ราคาถูกตอนเปิดไฟล์คืออ่าน IsTagged แล้วเรียก ValidatePdfUa ครั้งเดียว จากนั้นแคชคำตอบไว้ การตรวจสอบ PDF/UA ที่ล้มเหลวไม่ใช่เหตุผลให้ปฏิเสธไฟล์ แต่เป็นเหตุผลให้ใส่ "ลำดับการอ่านโดยประมาณ" ไว้ใน status bar เพื่อว่าเมื่อลูกค้าส่งข้อร้องเรียนเข้ามาเรื่องการบรรยายที่ฟังไม่รู้เรื่อง ทีม support จะรู้อยู่แล้วว่ากำลังเจอปัญหาการ tag ในไฟล์หรือบั๊กในโค้ดของคุณกันแน่
จากหน้ากระดาษสู่คิวเสียงพูดด้วย ReadingUnits
สำหรับ text-to-speech ReadingUnits ทำงานหนักส่วนใหญ่ให้ มันคืน array ของ record TPdfReadingUnit สำหรับหน้าที่ active อยู่ แต่ละตัวเก็บข้อความที่จะพูด บทบาทเชิงความหมายของมัน และสี่เหลี่ยมที่ระบุตำแหน่งของมันบนหน้ากระดาษ มีตัวคู่กันระดับทั้งเอกสารชื่อ DocumentReadingUnits เมื่อคุณต้องการอ่านต่อเนื่องข้ามหน้า หนึ่งยูนิตวางลงตรงหนึ่งช่องของคิวเสียงพูดได้เลย:
procedure TReaderForm.QueuePageSpeech(PageNumber: Integer);
var
Units: TPdfReadingUnits;
i: Integer;
begin
Pdf.PageNumber := PageNumber; // ReadingUnits ทำงานบนหน้าที่ active อยู่
Units := Pdf.ReadingUnits;
FSpeechQueue.Clear;
for i := Low(Units) to High(Units) do
FSpeechQueue.Add(Units[i]); // ข้อความ + ความหมาย + สี่เหลี่ยม highlight
FCurrentPage := PageNumber;
SpeakNextUnit;
end;
มีสองอย่างใน loop นั้นที่พลาดได้ง่าย เก็บคิวไว้ต่อหน้า แล้วสร้างใหม่ทุกครั้งที่ผู้ใช้เปลี่ยนหน้า เพราะ reading unit พกสี่เหลี่ยมแบบ page-space ไว้ คิวที่หลงเหลือจากหน้าสามจะวาด highlight ของมันลงบนหน้าสี่ผิด ๆ และให้ถือว่า array Units ที่ว่างเปล่าบนหน้าที่มีเนื้อหาชัดเจนคือตัวตรวจจับ image-only ของคุณ หน้าที่สแกนมาคือพิกเซลที่ไม่มี text layer อยู่ข้างใต้เลย และคำตอบที่ถูกต้องคือพูดคำเตือน ("หน้านี้ไม่มีข้อความที่สกัดออกมาได้") แทนที่จะเงียบไปในแบบที่ผู้ฟังแยกไม่ออกว่าเป็นการค้าง
word cursor ที่ตามเสียงพูดไป
การ highlight ทั้งย่อหน้าทีเดียวรู้สึกช้าอืดสำหรับผู้ใช้สายตาเลือนรางที่ไล่ตามคำด้วยตาขณะที่มันถูกอ่านออกเสียง การ highlight ระดับคำ หรือเอฟเฟกต์คาราโอเกะ ต้องการสองส่วน: รูปทรงของแต่ละคำ และวิธี map รายงานความคืบหน้าของเอนจิ้น TTS เข้ากับรูปทรงนั้น PageWordBoxes ให้รูปทรงแก่คุณในรูป record TPdfWordBox แต่ละตัวมีข้อความคำ ตำแหน่งอักขระ จำนวนอักขระ และสี่เหลี่ยมแบบ page-space TrackReadingWordAt ให้การ map แก่คุณ ป้อนตำแหน่งอักขระที่ event word-boundary ของ SAPI รายงานมาอยู่แล้วให้มัน แล้วมันจะแปลงตำแหน่งนั้นเป็น index ในอาเรย์ word-box และวาด cursor ลงบนคำที่ตรงกันในการเรียกครั้งเดียว
procedure TReaderForm.PrepareKaraoke(PageNumber: Integer);
begin
// word box ของ view มาจากหน้าที่ view กำลังแสดงอยู่
// การตั้งค่า Pdf.PageNumber เพียงอย่างเดียวจะไม่ทำให้ view เลื่อนตาม
PdfView.PageNumber := PageNumber;
FWordBoxes := PdfView.PageWordBoxes;
end;
procedure TReaderForm.OnTtsWordBoundary(Sender: TObject; CharIndex: Integer);
var
WordIdx: Integer;
begin
// TrackReadingWordAt แปลง offset และวาด word cursor ด้วยในตัว
WordIdx := PdfView.TrackReadingWordAt(FCurrentPage, CharIndex);
if WordIdx < 0 then
PdfView.ClearReadingWord; // boundary วิ่งเลยข้อความของหน้าไปแล้ว
end;
สัญญานี้ใจกว้างในเรื่องหนึ่งและไม่ให้อภัยในอีกเรื่อง ส่วนที่ใจกว้าง: TrackReadingWordAt เก็บแคช word-box ของตัวเองไว้สำหรับหน้าที่มันกำลังติดตาม จึงไม่มีอะไรต้อง preload ล่วงหน้าเลย และไม่มีการ render เกิดขึ้นเลยแม้แต่น้อย เพราะ word box มาจาก text layer บริการเสียงพูดแบบ headless ที่ไม่มีหน้าต่างให้เห็นก็ยังติดตามตำแหน่งได้ ส่วนที่ไม่ให้อภัย: ตำแหน่งอักขระต้องชี้เข้าไปในข้อความที่ component สกัดออกมา ไม่ใช่ชี้เข้าไปในสตริงที่คุณทำความสะอาดมาเองแล้ว เมื่อ CharIndex วิ่งเลยจุดสิ้นสุดของข้อความในหน้าไป ฟังก์ชันจะคืนค่า -1 แทนที่จะ raise ซึ่งเกิดขึ้นตลอดเวลาเมื่อเอนจิ้น TTS ยิง boundary event สุดท้ายสำหรับเครื่องหมายวรรคตอนท้าย ๆ ให้อ่าน -1 ว่า "ล้าง cursor ทิ้ง" ไม่ใช่อ่านเป็น error เด็ดขาด
ฝั่งการแสดงผล ReadingWordColor ตั้งสีของ cursor สีเหลืองอำพันเริ่มต้นทนทานอยู่ได้บนพื้นหลังหน้ากระดาษส่วนใหญ่ แต่ต้องทดสอบมันภายใต้ทุก display filter ที่ viewer ของคุณมีให้ cursor สีเหลืองอำพันหายไปได้เลยทั้งหมดภายใต้การกลับสี และการกลับสีที่รันไปพร้อมกับเสียงพูดก็คือวิธีที่ผู้ใช้สายตาเลือนรางทำงานพอดี ดังนั้นชุดค่าผสมเดียวที่คุณต้องทำให้ถูกต้องที่สุดก็คือชุดที่ demo เร็ว ๆ ไม่เคยทดสอบเลย ตั้งค่า ReadingWordFollow เป็น True แล้ว view จะเลื่อนคำที่กำลังพูดเข้ามาให้เห็นเองโดยอัตโนมัติ ซึ่งเป็นสิ่งที่ขาดไม่ได้บนหน้าที่ซูมจนล้นข้ามหลายหน้าจอ จำกฎขอบเขตหนึ่งข้อไว้: SetReadingWord วาดเฉพาะบนหน้าที่ active ของ TPdfView เท่านั้น ตัดสินใจไว้ล่วงหน้าว่าการเลื่อนด้วยมือจะหยุดเสียงพูดไว้ หรือพฤติกรรม follow จะทับมันไป เพราะถ้าไม่เลือกอย่างไหนเลย เสียงก็จะอ่านต่อไปเรื่อย ๆ ในขณะที่ cursor ไปอยู่นอกจอที่ไหนสักแห่ง
เอกสารที่ทำให้ reader ของคุณพัง
รูปแบบ input จำนวนหนึ่งเอาชนะการ implement แบบไร้เดียงสาได้อย่างเชื่อถือได้พอที่มันควรเป็นตัวอย่างถาวรใน regression suite ไม่ใช่บั๊กครั้งเดียวที่คุณแก้แล้วก็ลืมไป
- ไฟล์ที่ไม่ได้ tag แต่มีข้อความเยอะ ลำดับแบบ heuristic มักถูกต้องสำหรับรายงานแบบเชิงเส้น และผิดทันทีที่มี sidebar หรือ pull quote เข้ามา ให้ตั้ง flag ว่าลำดับนี้เป็นการประมาณ ทั้งใน UI และใน diagnostics log ของคุณ เพื่อให้ความล้มเหลวอ่านเข้าใจได้ในภายหลัง
- ไฟล์สแกนที่เป็นรูปภาพล้วน ไม่มี text layer เลยแม้แต่นิดเดียว จับพวกมันได้ผ่าน reading unit ที่ว่างเปล่า แล้วชี้ผู้ใช้ไปยังขั้นตอน OCR ต้นทาง แทนที่จะปล่อยให้ reader บรรยายหน้าที่ว่างเปล่า
- อักขระแบบผสมและสคริปต์ผสมกัน เครื่องหมายผสมของ Unicode ไม่ได้ยุบรวมเป็นคำที่มองเห็นแบบหนึ่งต่อหนึ่งเสมอไป ดังนั้นจำนวน word-box อาจเบี่ยงไปจากที่ tokenizer ของคุณเองคาดไว้ อย่า index อาเรย์ word-box ด้วย offset ที่คุณคำนวณเองจากการแยกข้อความ ให้ใช้แค่ index ที่
TrackReadingWordAtคืนกลับมาเท่านั้น
ทดสอบมันแบบผู้ตรวจสอบ ไม่ใช่แบบ demo
"มันอ่านตัวอย่างของฉันออกเสียงได้" ไม่ได้พิสูจน์อะไรเลย การผ่านที่คุณป้องกันได้จริงต้องรันสามไฟล์ผ่าน build ที่เสร็จสมบูรณ์โดยต่อ NVDA ไว้: ไฟล์ที่รู้อยู่แล้วว่า tag ไว้แล้วหนึ่งไฟล์ ที่หัวข้อถูกประกาศเป็นหัวข้อและตารางถูกอ่านตามลำดับแถว ไฟล์ที่รู้อยู่แล้วว่าไม่ได้ tag ไว้อีกหนึ่งไฟล์ ที่ตัวบ่งชี้ลำดับโดยประมาณมองเห็นได้ และไฟล์สแกนอีกหนึ่งไฟล์ ที่คำเตือนไม่มีข้อความถูกพูดออกมาจริง ๆ แต่ละไฟล์ทดสอบเส้นทางที่กรณีมาตรฐานข้ามไป
จากนั้น ยืนยันว่า word cursor ยังคงล็อกอยู่ที่อัตราความเร็วเสียงพูดสองเท่าและครึ่งเดียว และการเลื่อนของ ReadingWordFollow ไม่ปะทะกับการเลื่อนของผู้ใช้เอง แล้วรันเสียงพูดขณะที่คุณวนผ่านทุก color filter และดูว่า cursor ไม่หายไปเลย บทความเรื่อง color filter สำหรับผู้มีสายตาเลือนราง ครอบคลุมเส้นทางการ render นั้นอย่างละเอียด และ บทความเจาะลึกเรื่อง word speech cursor แยกแยะจังหวะเวลาของ TTS ไว้
API ของ reading-unit และ word-box ที่ใช้ข้างต้นมาพร้อมกับ PDFium Component สำหรับ Delphi และ C++Builder (VCL) และ Lazarus/FPC (LCL) หน้าผลิตภัณฑ์เชื่อมโยงไปยัง API reference ฉบับเต็ม รวมถึง record layout ของ reading unit และ word box ที่อยู่เบื้องหลังตัวอย่างเหล่านี้ด้วย