Bài viết kỹ thuật

Bài viết kỹ thuật: Build an Accessible PDF Reader in Delphi with PDFium bằng tiếng Việt

Một người dùng khiếm thị mở một báo cáo quý trong viewer Delphi mới toanh của bạn, bật NVDA lên, rồi nghe thấy footer trang, sau đó một cột con số, rồi mới tới tiêu đề mà bất kỳ người đọc sáng mắt nào cũng đã đọc trước tiên. Hoặc chẳng nghe thấy gì cả. Trang trông hoàn hảo trên màn hình, và đó chính xác là cái bẫy: render và đọc là hai vấn đề khác nhau được giải bằng hai đoạn code khác nhau. Thứ tự mà một PDF vẽ ra các glyph của nó không hề có nghĩa vụ khớp với thứ tự mà một con người nên nghe chúng, nên một viewer chỉ dựng trên các lệnh gọi render sẽ tạo ra một bức tranh hoàn hảo và một lời đọc vô dụng. PDFium Component, lớp bọc VCL/LCL quanh engine PDFium cho Delphi, C++Builder, và Lazarus, mang theo một bộ API đọc riêng biệt chính vì lý do này. Các API vẽ không thể khôi phục lại một thứ tự đọc mà chúng chưa bao giờ được trao cho

Một trình đọc trợ năng đứng vững hay sụp đổ dựa trên ba điều. Nó phải trích ra được một thứ tự mà một screen reader có thể đọc lên, giữ một con trỏ từ hiển thị được gắn chặt vào bất cứ điều gì giọng nói đang đọc, và thừa nhận khi một tài liệu chưa từng được gắn tag thay vì đoán mò rồi giả vờ. Mỗi điều trong số đó có một API rõ ràng để dùng tới và một kiểu thất bại sẽ cắn bạn nếu bạn bỏ qua chi tiết

Thứ tự đọc nằm trong cây cấu trúc, không nằm trong thứ tự vẽ

ISO 32000-1 §14.8 định nghĩa cấu trúc logic như một cây các phần tử được xếp lớp lên trên nội dung trang. PDF/UA (ISO 14289-1) đi xa hơn và bắt buộc cây đó phải tồn tại: mọi mẩu nội dung thật đều phải với tới được thông qua nó theo đúng thứ tự đọc, với các artifact trang được đánh dấu đúng như vậy và bị bỏ qua. Một báo cáo được gắn tag đúng đắn biết rằng "Quarterly Results" là một tiêu đề cấp hai và lưới tổng số là một bảng có ô tiêu đề. Một báo cáo chưa gắn tag chỉ là một đống các dải glyph được định vị mà tình cờ trông giống một tài liệu

ReadablePageContent duyệt qua cây đó khi nó tồn tại và trả về các đoạn được gắn một Kind ngữ nghĩa, các giá trị như cfHeadingcfParagraph, để UI có thể nói "tiêu đề" trước các từ thay vì đọc một dòng in đậm như văn bản thân bài bình thường. Khi không có cây nào dùng được, cùng lệnh gọi đó lùi về phân tích layout theo suy nghiệm: phát hiện cột, gom nhóm baseline, sắp thứ tự từ trái sang phải và từ trên xuống dưới. Cách lùi về đó ổn với một bản ghi nhớ một cột và bấp bênh với một bản tin nhiều cột, một form nhiều cột, bất cứ thứ gì có sidebar hay pull quote. Điều quan trọng là biết bạn đã nhận được kết quả nào, và API nói thẳng cho bạn biết điều đó. Bản ghi TPdfReadableContent mang theo một trường Source được đặt thành rosStructure khi thứ tự đến từ cây đã gắn tag, hoặc rosHeuristic khi nó được suy luận từ hình học. Hiển thị một thứ tự đoán mò như thể nó đã được xác minh và bạn đã giao đi phiên bản trợ năng của một huy hiệu đạt chuẩn trên một bản build chưa từng chạy thử

Một bộ đọc accessible PDFium trong Delphi lấy thứ tự đọc từ cây cấu trúc có gắn thẻ và quay về phân tích bố cục heuristic cho các PDF không gắn thẻ, với trường Source của TPdfReadableContent phân biệt rosStructure với rosHeuristic
Cây tagged công bố tiêu đề và thứ tự hàng trong khi phương án dự phòng phỏng đoán theo hình học, và trường Source giữ tách biệt giữa đã xác minh và ước lượng

Nước đi rẻ tiền lúc mở tệp là đọc IsTagged rồi gọi ValidatePdfUa một lần, sau đó cache lại câu trả lời. Một lượt kiểm tra PDF/UA thất bại không phải là căn cứ để từ chối tệp. Đó là căn cứ để hiện "thứ tự đọc ước tính" trên thanh trạng thái, để khi một khách hàng gửi thư khiếu nại về lời đọc lộn xộn, đội hỗ trợ đã biết sẵn liệu họ đang nhìn vào một vấn đề gắn tag trong tệp hay một lỗi trong code của bạn

Từ trang tới hàng đợi giọng nói với ReadingUnits

Với text-to-speech, ReadingUnits gánh phần việc nặng nhất. Nó trả về một mảng bản ghi TPdfReadingUnit cho trang đang hoạt động, mỗi bản ghi giữ văn bản cần đọc, vai trò ngữ nghĩa của nó, và các hình chữ nhật định vị nó trên trang. Có một hàm đồng hành cho toàn tài liệu, DocumentReadingUnits, khi bạn muốn đọc liên tục xuyên suốt các trang. Mỗi unit rơi thẳng vào một chỗ trong hàng đợi giọng nói:

procedure TReaderForm.QueuePageSpeech(PageNumber: Integer);
var
  Units: TPdfReadingUnits;
  i: Integer;
begin
  Pdf.PageNumber := PageNumber;   // ReadingUnits hoạt động trên trang đang active
  Units := Pdf.ReadingUnits;
  FSpeechQueue.Clear;
  for i := Low(Units) to High(Units) do
    FSpeechQueue.Add(Units[i]);  // văn bản + ngữ nghĩa + hình chữ nhật highlight
  FCurrentPage := PageNumber;
  SpeakNextUnit;
end;

Có hai điều trong vòng lặp đó dễ làm sai. Giữ hàng đợi theo từng trang và dựng lại nó bất cứ khi nào người dùng điều hướng, vì các reading unit mang theo hình chữ nhật theo không gian trang; một hàng đợi còn sót lại từ trang ba sẽ vẽ các highlight của nó lên trang bốn. Và hãy xem một mảng Units rỗng trên một trang rõ ràng có nội dung như bộ phát hiện trang chỉ-ảnh của bạn. Một trang được scan là các pixel không có lớp text nào bên dưới, và câu trả lời đúng là đọc lên một lời cảnh báo ("trang này không có văn bản trích xuất được") thay vì im lặng theo cách mà người nghe không thể phân biệt được với một lần treo máy

PDFium ReadingUnits trong Delphi biến trang hiện hoạt thành văn bản, vai trò ngữ nghĩa và các hình chữ nhật không gian trang đổ vào hàng đợi phát âm của trình đọc màn hình, mỗi khe một đơn vị, với một mảng units rỗng gắn cờ trang scan để cảnh báo bằng lời
Các đơn vị đọc rơi vào hàng đợi thoại đúng một cái mỗi slot, và một mảng rỗng trên một trang có nội dung chính là bộ dò trang scan

Một con trỏ từ đi theo giọng đọc

Highlight cả một đoạn văn cùng lúc khiến một người dùng thị lực kém cảm thấy chậm chạp khi họ đang dõi theo từng chữ bằng mắt trong lúc nó được đọc lên. Highlight theo từng từ, hiệu ứng karaoke, cần hai phần: hình học của mỗi từ, và một cách để ánh xạ các báo cáo tiến độ của engine TTS lên hình học đó. PageWordBoxes cho bạn hình học đó dưới dạng các bản ghi TPdfWordBox, mỗi bản ghi có văn bản của từ, offset ký tự của nó, số ký tự của nó, và một hình chữ nhật theo không gian trang. TrackReadingWordAt cho bạn phép ánh xạ đó. Đưa cho nó vị trí ký tự mà sự kiện word-boundary của SAPI đã báo cáo sẵn, và nó sẽ giải offset đó thành một chỉ mục trong mảng word-box rồi vẽ con trỏ lên đúng từ khớp chỉ trong một lệnh gọi

procedure TReaderForm.PrepareKaraoke(PageNumber: Integer);
begin
  // Word box của view lấy từ trang mà view đang hiển thị.
  // Chỉ đặt Pdf.PageNumber thôi sẽ không di chuyển view
  PdfView.PageNumber := PageNumber;
  FWordBoxes := PdfView.PageWordBoxes;
end;

procedure TReaderForm.OnTtsWordBoundary(Sender: TObject; CharIndex: Integer);
var
  WordIdx: Integer;
begin
  // TrackReadingWordAt vừa ánh xạ offset VỪA vẽ con trỏ từ
  WordIdx := PdfView.TrackReadingWordAt(FCurrentPage, CharIndex);
  if WordIdx < 0 then
    PdfView.ClearReadingWord;  // boundary vượt quá văn bản trang
end;

Hợp đồng này khoan dung ở một điểm và không khoan nhượng ở điểm khác. Phần khoan dung: TrackReadingWordAt tự giữ cache word-box riêng cho trang mà nó đang theo dõi, nên chẳng có gì cần nạp trước, và không có việc render nào xảy ra cả vì các word box đến từ lớp text. Một dịch vụ giọng nói không giao diện, không có cửa sổ hiển thị, vẫn có thể theo dõi vị trí. Phần không khoan nhượng: chỉ mục ký tự phải trỏ vào văn bản mà component đã trích xuất, chứ không phải vào một chuỗi đã được dọn dẹp mà bạn tự dựng lên. Khi CharIndex vượt quá cuối văn bản trang, hàm trả về -1 thay vì ném lỗi, điều xảy ra suốt khi một engine TTS bắn ra một sự kiện boundary cuối cùng cho dấu câu ở cuối. Hãy đọc -1 là "xóa con trỏ", không bao giờ là một lỗi

Ở phía hiển thị, ReadingWordColor đặt màu con trỏ. Màu hổ phách mặc định vẫn ổn trên hầu hết nền trang, nhưng hãy thử nó dưới mọi bộ lọc hiển thị mà viewer của bạn cung cấp. Một con trỏ hổ phách có thể biến mất hoàn toàn dưới chế độ đảo màu, và đảo màu chạy song song với giọng đọc chính là cách một người dùng thị lực kém làm việc, nên tổ hợp duy nhất bạn cần làm đúng nhất lại chính là tổ hợp mà một bản demo nhanh chẳng bao giờ chạy thử. Đặt ReadingWordFollow thành True và view sẽ tự cuộn từ đang được đọc vào tầm nhìn, điều bạn không thể thiếu trên một trang đã zoom trải rộng qua nhiều màn hình. Lưu ý một quy tắc phạm vi: SetReadingWord chỉ vẽ trên trang TPdfView đang hoạt động. Hãy quyết định trước liệu việc cuộn thủ công có tạm dừng giọng đọc hay hành vi follow sẽ ghi đè lên nó, vì không chọn cái nào cả sẽ để giọng đọc tiếp tục trong khi con trỏ nằm đâu đó ngoài màn hình

Các sự kiện biên từ SAPI trong một reader Delphi PDFium được ánh xạ qua TrackReadingWordAt lên hình học PageWordBoxes để tô con trỏ từ karaoke, với giá trị trả về -1 xóa con trỏ khi offset đi quá văn bản trang
Offset boundary của TTS phân giải thành một hộp từ và vẽ con trỏ, còn giá trị -1 vượt quá văn bản trang sẽ xóa nó thay vì raise

Những tài liệu làm gãy trình đọc của bạn

Một nhúm hình dạng đầu vào đánh bại một cách triển khai ngây thơ đủ đáng tin cậy tới mức chúng xứng đáng trở thành mẫu thường trực trong bộ regression suite, chứ không phải những lỗi một-lần-rồi-thôi mà bạn sửa xong rồi quên

  • Tệp chưa gắn tag nhưng giàu văn bản. Thứ tự suy nghiệm thường đúng với một báo cáo tuyến tính và sai ngay khi một sidebar hay pull quote xuất hiện. Hãy đánh dấu thứ tự đó là ước tính, cả trong UI lẫn trong log chẩn đoán của bạn, để thất bại đó dễ đọc lại về sau
  • Bản scan chỉ có ảnh. Không có lớp text nào cả. Bắt chúng thông qua các reading unit rỗng và trỏ người dùng tới một bước OCR ở phía trước thay vì để trình đọc đọc lên một trang trống rỗng
  • Ký tự tổ hợp và văn tự pha trộn. Các dấu tổ hợp Unicode không phải lúc nào cũng gộp một-đối-một thành các từ hiển thị, nên số đếm word-box có thể trôi lệch khỏi những gì tokenizer của riêng bạn kỳ vọng. Đừng đánh chỉ mục mảng word-box bằng các offset bạn tự tính ra khi tách văn bản; chỉ dùng các chỉ mục mà TrackReadingWordAt trả về

Kiểm thử nó như một kiểm toán viên, không phải như một bản demo

"Nó đọc mẫu của tôi lên rồi" chẳng chứng minh được gì cả. Một lượt kiểm tra đủ sức bảo vệ chạy ba tệp qua bản build hoàn chỉnh với NVDA gắn kèm: một tệp đã biết chắc đã gắn tag, nơi tiêu đề được xướng lên là tiêu đề và một bảng được đọc theo thứ tự hàng; một tệp đã biết chắc chưa gắn tag, nơi chỉ báo thứ tự-ước-tính hiện rõ; và một bản scan, nơi lời cảnh báo không-có-văn-bản thực sự được đọc lên. Mỗi trường hợp đều chạy qua một đường mà kịch bản suôn sẻ bỏ qua

Từ đó, hãy xác nhận con trỏ từ vẫn khóa chặt ở tốc độ đọc gấp đôi và ở một nửa, và việc cuộn của ReadingWordFollow không giằng co với việc cuộn thủ công của người dùng. Sau đó chạy giọng đọc trong khi bạn lần lượt đổi qua mọi bộ lọc màu và theo dõi xem con trỏ có bao giờ biến mất hay không. Bài viết về bộ lọc màu cho thị lực kém trình bày chi tiết đường render đó, còn bài đào sâu về con trỏ giọng đọc theo từ mổ xẻ việc định thời của TTS

Các API reading-unit và word-box dùng ở trên đi kèm sẵn trong PDFium Component cho Delphi và C++Builder (VCL) và Lazarus/FPC (LCL). Trang sản phẩm liên kết tới tài liệu tham chiếu API đầy đủ, bao gồm cả cấu trúc bản ghi cho reading unit và word box đứng sau các ví dụ này