Bài viết kỹ thuật

Xác thực cây cấu trúc PDF/UA trong Delphi với PDFium

Preflight của bạn báo tệp đã sạch PDF/UA. veraPDF mở đúng tệp đó và gắn cờ một Figure không có văn bản thay thế theo điều khoản 7.3. Cả hai công cụ đều đúng, và khoảng trống giữa chúng chính là vấn đề cốt lõi của việc kiểm tra khả năng truy cập bằng cách quét byte. Một lượt quét ở mức byte xác nhận rằng tệp tự nói mình đã được gắn thẻ: nó tìm thấy /StructTreeRoot, /MarkInfo /Marked true, pdfuaid:part trong gói XMP, tiêu đề tài liệu, ngôn ngữ. Đó là các dấu hiệu định dạng, và chúng là cần thiết. Nhưng chúng không cho bạn biết hình thực tế ở trang bốn có mang mô tả mà trình đọc màn hình có thể đọc thành tiếng hay không. Câu trả lời nằm trong cây thẻ, và muốn có nó bạn phải duyệt cây đó.

PDFium Component là thư viện PDF VCL gốc cho Delphi và C++Builder, và ValidatePdfUa của nó thực hiện cả hai lượt kiểm tra. Lượt quét mức byte xử lý các dấu hiệu định dạng. Phía trên đó là một lượt kiểm tra cây cấu trúc, nạp cây thẻ đang hoạt động, duyệt từng phần tử và kiểm tra tập nhỏ các quy tắc nội dung có độ tin cậy cao, nơi một thuộc tính bị thiếu thực sự là lỗi trợ năng chứ không chỉ là lựa chọn phong cách. Bài viết này nói về lượt kiểm tra thứ hai đó: nó kiểm tra gì, vì sao logic quy tắc là một hàm thuần không cần DLL bên dưới, và nó cố ý dừng ở đâu.

Vì sao quét byte không thể thấy Alt bị thiếu

ISO 14289-1 (PDF/UA-1) là một lớp yêu cầu đặt lên trên ISO 32000. Một số yêu cầu là cấu trúc và có thể nhìn thấy trong tệp thô: catalog phải khai báo cây cấu trúc, viewer preferences phải đặt DisplayDocTitle, font phải được nhúng. Một bộ quét token loại bỏ nội dung stream và khớp token tên theo ranh giới phân tách có thể xác minh tất cả những điều đó, và ValidatePdfUaCompliance của PDFium làm chính xác việc ấy cho các điều khoản như 7.1, 7.18 và 7.21.

Nhưng “mọi Figure đều có alternate text” không phải là thuộc tính của cú pháp tệp. Đó là thuộc tính của cấu trúc logic, tức cây các phần tử đã gắn thẻ ánh xạ nội dung sang ý nghĩa. Mục Alt của Figure có thể nằm trong từ điển phần tử cấu trúc, được cung cấp thông qua một span /ActualText, hoặc đến từ một kiểu tùy biến đã được role map. Bạn không thể tin cậy khi tìm nó bằng cách grep /Alt trong luồng byte, vì chuỗi đó xuất hiện trong ngữ cảnh không liên quan, có thể bị nén bên trong object stream, và không cho biết nó thuộc về phần tử cấu trúc nào. Cách trung thực để trả lời câu hỏi là hỏi chính cây cấu trúc của tài liệu, từng phần tử một, đúng như veraPDF và PAC đánh giá. Đó là ranh giới mà các kiểm tra Tier-1 của PDFium được xây dựng quanh: quét byte cho định dạng, duyệt cây cho nội dung.

Đọc cây thẻ đang hoạt động

Nguyên liệu đầu vào là TPdf.GetStructureElements (cũng được bộc lộ qua thuộc tính StructureElements), hàm trả về một TPdfStructureElements - mảng phẳng các bản ghi TPdfStructureElement theo thứ tự tài liệu. Mỗi bản ghi là phần chiếu của một phần tử cấu trúc thông qua các hàm truy cập của PDFium, với những trường mà quy tắc trợ năng thật sự cần:

type
  TPdfStructureElement = record
    Level: Integer;            // depth in the tag tree
    ParentIndex: Integer;      // index of parent element, or -1
    TypeName: WString;         // standard /S name: Figure, Formula, Note...
    Title: WString;            // /T
    AlternateText: WString;    // /Alt   (FPDF_StructElement_GetAltText)
    ActualText: WString;       // /ActualText
    Expansion: WString;        // /E
    ID: WString;               // /ID    (FPDF_StructElement_GetID)
    Language: WString;         // /Lang
    MarkedContentIDs: TPdfIntegerArray;
    // ... child bookkeeping fields
  end;

Trường TypeName là trường mà bộ kiểm tra bám vào. Nó đến từ FPDF_StructElement_GetType, hàm trả về kiểu cấu trúc chuẩn của phần tử, tức tên /S, sau khi PDFium đã giải quyết role map. AlternateText đến từ FPDF_StructElement_GetAltText, ActualText từ FPDF_StructElement_GetActualText, và ID từ FPDF_StructElement_GetID. Vì mảng này phẳng và đã có thứ tự, bộ kiểm tra có thể suy luận toàn bộ tài liệu cùng lúc thay vì đệ quy, điều này rất quan trọng cho quy tắc duy nhất mang tính toàn cục chứ không theo từng phần tử.

Bộ kiểm tra là một hàm thuần, và đó là chủ ý

Logic quy tắc không nằm trong phương thức nói chuyện với DLL. Nó là một hàm thuần độc lập, công khai:

function ValidatePdfUaStructureElements(
  const Elements: TPdfStructureElements): TPdfUaValidationIssues;

Nó nhận một mảng phần tử phẳng và trả về một tập lỗi. Nó không gọi hàm PDFium nào, không mở tài liệu nào, không chạm vào trạng thái toàn cục nào. Việc tách này là chủ đích, và nó đem lại hai lợi ích. Thứ nhất là khả năng kiểm thử: bạn có thể dựng một mảng TPdfStructureElements tổng hợp trong kiểm thử đơn vị, chẳng hạn một Figure không có Alt, một Formula chỉ có văn bản truy cập được nằm trong ActualText, hai Note dùng chung một ID, rồi assert trên tập kết quả mà không cần pdfium.dll. Logic quy tắc được xác minh ngoại tuyến, còn việc duyệt DLL được xác minh riêng bằng một kiểm thử nhanh với tài liệu thật, và test đó bỏ qua khi thư viện không có mặt.

Thứ hai là sự rõ ràng về trách nhiệm. TPdf.ValidatePdfUa chịu phần rối nhất - nạp từng trang, lấy các phần tử của nó, gom chúng lại - rồi chuyển một mảng sạch cho bộ kiểm tra thuần. “Lấy dữ liệu” (DLL, side effects, vòng đời) và “phán quy tắc” (thuần, xác định) không bao giờ lẫn vào nhau. Khi một quy tắc cần thay đổi, bạn chỉ thay một hàm không có I/O bên trong.

Ba quy tắc thực sự kiểm tra gì

Lượt kiểm tra cây cấu trúc phát ra ba giá trị lỗi, được nối vào cuối TPdfUaValidationIssues để enum giữ được tính ổn định ABI cho các bên gọi hiện có: pvuaiFigureMissingAlt, pvuaiFormulaMissingAlt, và pvuaiNoteMissingId. Phần thân đủ nhỏ để có thể nắm trọn bằng một lượt đọc:

for I := 0 to High(Elements) do
begin
  T := string(Elements[I].TypeName);
  if T = 'Figure' then
  begin
    // §7.3 — a Figure needs an alternate representation:
    // an Alt entry OR ActualText. Flag only when BOTH are empty.
    if (Elements[I].AlternateText = '') and (Elements[I].ActualText = '') then
      Include(Result, pvuaiFigureMissingAlt);
  end
  else if T = 'Formula' then
  begin
    // §7.7 — same rule as Figure: Alt OR ActualText.
    if (Elements[I].AlternateText = '') and (Elements[I].ActualText = '') then
      Include(Result, pvuaiFormulaMissingAlt);
  end
  else if T = 'Note' then
  begin
    // §7.9 — every Note must have a unique ID.
    NoteId := string(Elements[I].ID);
    if NoteId = '' then
      Include(Result, pvuaiNoteMissingId)
    else
      for J := 0 to I - 1 do
        if (string(Elements[J].TypeName) = 'Note') and
           (string(Elements[J].ID) = NoteId) then
        begin
          Include(Result, pvuaiNoteMissingId);
          Break;
        end;
  end;
end;

Điều khoản 7.3 chi phối figure: một phần tử Figure phải cung cấp một dạng thay thế bằng văn bản. Phiên bản đầu của kiểm tra này chỉ nhìn vào mục Alt, khiến nó chặt hơn các bộ xác thực tham chiếu. PDF/UA chấp nhận một figure mà văn bản truy cập được được cung cấp qua ActualText thay vì Alt, vì văn bản thay thế là một dạng biểu diễn hợp lệ, nên quy tắc chỉ gắn cờ Figure khi cả Alt lẫn ActualText đều trống. Điều khoản 7.7 áp dụng cho formula, và sau khi sửa tương tự, nó dùng đúng phép kiểm Alt-hoặc-ActualText; một mẫu trong bộ conformance đã cung cấp văn bản truy cập cho Formula chỉ qua ActualText từng bị bác sai cho đến khi nhánh Formula được đưa về cùng cách với nhánh Figure.

Điều khoản 7.9 khác hẳn. Một Note phải có /ID, và ID đó phải duy nhất trong toàn bộ tài liệu. Thiếu ID là lỗi ở cấp từng phần tử. Một ID trùng là quan hệ giữa hai phần tử, đó là lý do mảng phẳng quan trọng: với mỗi Note, bộ kiểm tra quét lùi qua các phần tử đã thấy và gắn cờ va chạm với mọi Note trước đó mang cùng ID. Chi phí là O(n²) rõ ràng theo số Note, điều này không đáng kể với tài liệu thực tế nào và giữ cho hàm chỉ là một vòng lặp dễ đọc, không cần chỉ số phụ để đồng bộ.

Gom trên nhiều trang để tính duy nhất ở cấp tài liệu

PDFium bộc lộ các phần tử cấu trúc theo từng trang chứ không phải theo tài liệu, nên phần điều phối trong ValidatePdfUa phải gom chúng lại trước khi các quy tắc chạy. Nó duyệt từng trang bằng FPDF_LoadPage / GetStructureElementsForPage / FPDF_ClosePage, độc lập với trang nào thành phần đang mở, rồi nối mọi phần tử của từng trang vào một mảng chung. Chỉ sau đó nó mới gọi bộ kiểm tra thuần:

// inside TPdf.ValidatePdfUa, after the byte-level pass
if (FDocument <> nil) and
   (not (pvuaiMissingStructTreeRoot in Result.Issues)) then
begin
  AllElems := nil;
  PageTotal := FPDF_GetPageCount(FDocument);
  for I := 0 to PageTotal - 1 do
  begin
    Page := FPDF_LoadPage(FDocument, I);
    if Page = nil then Continue;
    try
      PageElems := GetStructureElementsForPage(Page);
    finally
      FPDF_ClosePage(Page);
    end;
    // append PageElems into AllElems ...
  end;
  Result.Issues := Result.Issues + ValidatePdfUaStructureElements(AllElems);
end;

Việc gom này chính là điều làm cho kiểm tra tính duy nhất ở 7.9 đúng. Hai Note ở hai trang khác nhau có thể dùng chung một ID; nếu bạn xác thực từng trang riêng lẻ, bạn sẽ không bao giờ thấy va chạm đó, vì tập phần tử của mỗi trang đều tự nó có vẻ nhất quán. Chỉ khi dựng một mảng bao trùm toàn bộ tài liệu thì bản sao trùng mới lộ ra. Điểm chặn ở đầu cũng đáng chú ý: lượt duyệt cây chỉ chạy khi lượt quét mức byte không báo pvuaiMissingStructTreeRoot. Một tài liệu chưa gắn thẻ không có cây để duyệt và đã bị gắn cờ vì thiếu structure root, nên các lần nạp theo trang được bỏ qua hoàn toàn. Lượt kiểm tra sâu này không tốn gì trên những tài liệu không thể hưởng lợi từ nó.

Thiết kế thận trọng: bỏ sót lặng lẽ, không báo động giả

Thuộc tính quan trọng nhất của bộ kiểm tra này là những gì nó từ chối làm. Nó chỉ khớp các tên kiểu chuẩn /SFPDF_StructElement_GetType trả về trực tiếp - Figure, Formula, Note. Một tài liệu định nghĩa kiểu tùy biến rồi role map nó thành Figure sẽ, tùy cách PDFium giải quyết kiểu đó, báo lại chính tên của nó. Khi điều đó xảy ra, bộ kiểm tra không nhận ra và im lặng. Đó là false negative, và đó là hành vi có chủ đích. Quy tắc thiết kế là thà đánh giá thiếu còn hơn tạo false positive, vì một công cụ preflight cứ kêu oan trên các tệp đạt chuẩn sẽ khiến người dùng bỏ qua nó - và một bộ xác thực bị bỏ qua còn tệ hơn không có. Hình ảnh trang trí nằm trong artifact stream, không nằm trong structure tree, nên ngay từ đầu chúng không bao giờ hiện ra là Figure; bạn sẽ không nhận được cảnh báo “thiếu Alt” cho một quy tắc nền được đánh dấu đúng là artifact.

Đây cũng là lý do phạm vi chỉ giữ ba quy tắc. Việc lồng mức heading (điều khoản 7.4), phạm vi header của bảng (7.5), và phát hiện vòng lặp role map (7.1) đều là yêu cầu PDF/UA hợp lệ, nhưng kiểm tra tốt chúng cần phân tích đồ thị và thuộc tính thật sự, còn kiểm tra ngây thơ lại tạo ra đúng các false positive mà thiết kế cấm - PDF/UA cho phép các mẫu heading như H1, H2, H3, H3 mà một quy tắc đơn giản “phải tăng nghiêm ngặt” sẽ bác sai. Những kiểm tra đó được giao cho các công cụ conformance chuyên dụng. Bộ Tier-1 là phần con mà một thuộc tính bị thiếu là rõ ràng.

Ranh giới, nói thẳng

Hai giới hạn đáng nhớ trước khi bạn đưa nó vào cổng phát hành. Thứ nhất, bộ kiểm tra chỉ tốt ngang những gì PDFium đọc được từ phần tử cấu trúc. Một số tệp trong bộ conformance mà các bộ xác thực tham chiếu vẫn qua được sử dụng một cơ chế alternate text mà PDFium không bộc lộ, nên FPDF_StructElement_GetAltText trả về rỗng dù tệp thực sự tuân thủ. Khi đó bộ kiểm tra thuần “đúng” sẽ gắn cờ thiếu Alt trên dữ liệu chưa đầy đủ - một false positive xuất phát từ độ bao phủ accessor của DLL, không phải từ logic quy tắc. Nới lỏng quy tắc để nuốt các trường hợp này cũng sẽ làm nó mù trước những lỗi thật mà nó phải bắt, nên chúng được ghi nhận là một giới hạn đã biết của PDFium thay vì bị che đi.

Thứ hai, đây là preflight chứ không phải chứng nhận. Tier-1 bắt các lỗi nội dung có độ tin cậy cao mà quét byte về mặt cấu trúc không thể bắt, và nó làm vậy mà không báo động giả - nhưng việc tuân thủ PDF/UA đầy đủ, bao gồm ngữ nghĩa heading, cấu trúc bảng, và tính đúng của thứ tự đọc, vẫn thuộc về một bộ xác thực hoàn chỉnh và sau cùng là người duyệt. Hãy dùng ValidatePdfUa để cho rớt nhanh và rẻ những lỗi hiển nhiên trong pipeline của bạn, rồi để veraPDF hoặc PAC đưa ra kết luận cuối cùng. Chính lượt duyệt cây cấu trúc này cũng là nền tảng để xây dựng một trình đọc PDF trợ năng trong Delphi, nơi cây thẻ điều khiển thứ tự đọc và văn bản được đọc ra, và nó bổ trợ cho công việc ở mức metadata trong duyệt chú thích PDF từ Delphi.

Những API cây cấu trúc và bộ kiểm tra ValidatePdfUa được trình bày ở đây đi kèm với PDFium Component cho Delphi và C++Builder (VCL) và Lazarus/FPC (LCL). Trang sản phẩm liên kết đến toàn bộ tài liệu API, bao gồm bố cục đầy đủ của bản ghi TPdfStructureElement và enum lỗi đứng sau các kiểm tra này.