مقاله فنی

اعتبارسنجی درخت ساختار PDF/UA در Delphi با PDFium

گزارش preflight شما فایل را از نظر PDF/UA پاک اعلام می‌کند. veraPDF همان فایل را باز می‌کند و یک Figure بدون متن جایگزین زیر clause 7.3 را علامت می‌زند. هر دو ابزار درست می‌گویند، و شکاف میان آن‌ها تمام مسئلهٔ بررسی دسترس‌پذیری با اسکن bytes است. یک pass در سطح byte فقط تأیید می‌کند که فایل اعلام می‌کند که tagged است: /StructTreeRoot، /MarkInfo /Marked true، pdfuaid:part را در XMP packet، document title و language پیدا می‌کند. این‌ها markerهای فرمت هستند، و لازم‌اند. اما هیچ چیز به شما نمی‌گویند که آیا Figure واقعی روی صفحهٔ چهار یک description دارد که screen reader بتواند با صدای بلند بخواند یا نه. پاسخ در tag tree زندگی می‌کند، و برای به‌دست آوردنش باید tree را walk کنید

PDFium Component یک کتابخانهٔ بومی VCL PDF برای Delphi و C++Builder است، و ValidatePdfUa آن هر دو pass را انجام می‌دهد. pass سطح byte markerهای فرمت را رسیدگی می‌کند. روی آن یک passِ structure-tree قرار دارد که tagged tree زنده را بارگذاری می‌کند، هر element را walk می‌کند، و مجموعهٔ کوچک ruleهای محتواییِ با اطمینان بالا را بررسی می‌کند، جایی که یک attribute گمشده به‌جای سلیقهٔ سبکی، یک نقص واقعی دسترس‌پذیری است. این مقاله دربارهٔ آن pass دوم است: چه چیزهایی را بررسی می‌کند، چرا منطق rule یک pure function بدون DLL زیر آن است، و کجا عمداً متوقف می‌شود

چرا یک scan در سطح byte Altِ گمشده را نمی‌بیند

ISO 14289-1 (PDF/UA-1) یک لایهٔ requirement روی ISO 32000 است. بعضی از این requirementها ساختاری‌اند و در فایل خام دیده می‌شوند: catalog باید یک structure tree اعلام کند، viewer preferenceها باید DisplayDocTitle را تنظیم کنند، فونت‌ها باید embed شوند. یک token scanner که stream bodyها را حذف می‌کند و name tokenها را با مرزهای delimiter تطبیق می‌دهد، می‌تواند همهٔ آن‌ها را بررسی کند، و ValidatePdfUaCompliance دقیقاً همین کار را برای clauseهایی مثل 7.1، 7.18 و 7.21 انجام می‌دهد

اما «هر Figure متن جایگزین دارد» یک property از syntax فایل نیست. این یک property از ساختار منطقی است - treeِ elementهای tagged که content را به معنا map می‌کند. Altِ یک Figure می‌تواند در structure element dictionary بنشیند، از طریق یک /ActualText span ارائه شود، یا از یک custom type که role-mapped شده بیاید. شما نمی‌توانید آن را با grep کردن /Alt در byte stream به‌طور قابل‌اعتماد پیدا کنید، چون آن رشته در contextهای نامرتبط هم ظاهر می‌شود، ممکن است درون یک object stream فشرده شده باشد، و هیچ چیزی دربارهٔ این‌که ساختار elementِ متعلق به آن چیست نمی‌گوید. راه صادقانه برای پاسخ دادن به سؤال این است که به structure tree خود document، element به element، همان سطحی را که veraPDF و PAC ارزیابی می‌کنند، بپرسید. این همان خطی است که Tier-1 checkهای PDFium حول آن ساخته شده‌اند: scanِ byte برای فرمت، walkِ tree برای content

خواندن tag tree زنده

مادهٔ خام TPdf.GetStructureElements است (که به‌صورت StructureElements property هم surfaced می‌شود)، و یک TPdfStructureElements برمی‌گرداند - یک array تخت از TPdfStructureElement record در document order. هر record projection یک structure element از طریق accessor functionهای PDFium است، با fieldهایی که ruleهای دسترس‌پذیری واقعاً به آن‌ها نیاز دارند:

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;

fieldِ TypeName همان چیزی است که validator روی آن pivot می‌کند. این field از FPDF_StructElement_GetType می‌آید، که standard structure type عنصر - یعنی /S name آن - را پس از این‌که PDFium role map را resolve کرده باشد برمی‌گرداند. AlternateText از FPDF_StructElement_GetAltText می‌آید، ActualText از FPDF_StructElement_GetActualText، و ID از FPDF_StructElement_GetID. چون array تخت و مرتب است، validator می‌تواند کل document را یک‌جا reasoning کند، نه به‌صورت recursive - و این برای تنها ruleای که global است نه per-element، مهم است

منطق rule داخل روشی که با DLL حرف می‌زند زندگی نمی‌کند. این منطق یک pure function مستقل و عمومی است:

این یک flat element array می‌گیرد و یک set از issueها برمی‌گرداند. هیچ تابع PDFiumی را صدا نمی‌زند، هیچ documentی را باز نمی‌کند، و به هیچ global stateی دست نمی‌زند. این جداسازی عمدی است، و دو بار سود می‌دهد. اول testability: می‌توانید یک

function ValidatePdfUaStructureElements(
  const Elements: TPdfStructureElements): TPdfUaValidationIssues;

array مصنوعی در یک unit test بسازید - یک Figure بدون Alt، یک Formula که تنها متن دسترس‌پذیرش در ActualText است، دو Note که یک ID مشترک دارند - و بدون TPdfStructureElements present بودن PDFium روی set نتیجه assert کنید. منطق rule آفلاین verify می‌شود؛ عبور DLL جداگانه با یک smoke test روی document زنده verify می‌شود که وقتی library غایب است skip می‌شود.pdfium.dllدوم، شفافیت مسئولیت

بخش messy را برعهده دارد - بارگذاری هر page، بیرون کشیدن elementهایش، جمع کردن آن‌ها - و بعد یک array تمیز را به checkerِ pure می‌سپارد. «داده را بگیر» (DLL، side effectها، lifetime) و «ruleها را قضاوت کن» (pure، deterministic) هیچ‌وقت در هم نمی‌پیچند. وقتی یک rule باید عوض شود، شما تابعی را عوض می‌کنید که هیچ I/Oای در آن نیست.TPdf.ValidatePdfUaسه ruleی که واقعاً بررسی می‌شوند

passِ structure-tree سه issue value بالا می‌برد که به انتهای

append می‌شوند تا enum برای callerهای موجود ABI-stable بماند: TPdfUaValidationIssues, pvuaiFigureMissingAlt، و pvuaiFormulaMissingAlt. body به‌اندازهٔ کافی کوچک است که بتوان آن را کامل فهمید:pvuaiNoteMissingIdClause 7.3 دربارهٔ Figureهاست: یک

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;

element باید یک text alternative ارائه کند. نسخهٔ اولیهٔ این check فقط Alt entry را نگاه می‌کرد، که آن را سخت‌گیرانه‌تر از reference validatorها می‌کرد. PDF/UA یک Figure را وقتی accessible text آن از طریق Figure داده شده باشد هم می‌پذیرد - replacement text یک representation جایگزین معتبر است - پس rule فقط وقتی Figure را علامت می‌زند که ActualTextAlt و ActualText هر دو خالی باشند. Clause 7.7 دربارهٔ formulaهاست، و پس از همان اصلاح، از همان testِ Alt-or-ActualText استفاده می‌کند؛ یک sample از conformance corpus که accessible textِ یک Formula را فقط از طریق ActualText داده بود، تا وقتی شاخهٔ Formula با شاخهٔ Figure هم‌راستا شد، به‌اشتباه رد می‌شد.دو واقعی

Clause 7.9 از جنسی دیگر است. یک Note باید یک /ID داشته باشد، و آن ID باید در کل document یکتا باشد. یک ID گمشده یک failure per-element است. یک duplicate ID یک رابطه بین دو element است، و به همین دلیل array تخت مهم است: برای هر Note، checker روی elementهایی که قبلاً دیده شده‌اند به عقب scan می‌کند و با هر Note قبلی که همان ID را دارد collision را علامت می‌زند. هزینهٔ کار، همان O(n²) واضح بر حسب تعداد Noteهاست که برای هر document واقعی بی‌اهمیت است و function را به یک loop خوانا با هیچ index کمکی که بخواهد همگام نگه داشته شود تبدیل می‌کند

تجمیع across pageها تا یکتایی global باشد

PDFium structure elementها را per-page expose می‌کند، نه per-document، پس orchestration در ValidatePdfUa باید آن‌ها را پیش از اجرای ruleها جمع کند. این کد هر page را با FPDF_LoadPage / GetStructureElementsForPage / FPDF_ClosePage walk می‌کند، مستقل از هر pageای که component الان باز دارد، و elementهای هر page را در یک array واحد append می‌کند. فقط بعد از آن pure checker را صدا می‌زند:

// 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;

این تجمیع همان چیزی است که check یکتایی 7.9 را درست می‌کند. دو Note روی pageهای متفاوت می‌توانند یک ID مشترک داشته باشند؛ اگر page به page validate می‌کردید، هرگز collision را نمی‌دیدید، چون set elementهای هر page درونی سازگار به نظر می‌رسد. ساختن یک array در سطح کل document تنها راهی است که duplicate را visible می‌کند. guard در front هم worth noting است: tree walk فقط وقتی اجرا می‌شود که pass سطح byte نتواند را report کند. یک document بدون tag tree چیزی برای walk کردن ندارد و از قبل برای ساختار rootِ گمشده علامت خورده است، پس loadهای per-page به‌طور کامل skip می‌شوند. pass عمیق روی documentهایی که نمی‌توانند از آن سود ببرند هیچ هزینه‌ای ندارد.pvuaiMissingStructTreeRoot. یک document بدون tag tree چیزی برای walk کردن ندارد و از قبل برای root ساختارِ گمشده علامت خورده است، پس loadهای per-page به‌طور کامل skip می‌شوند. pass عمیق روی documentهایی که نمی‌توانند از آن سود ببرند هیچ هزینه‌ای ندارد

به‌صورت عمدی محتاط: اگر مشکوک نیست، داد و قال نکن

مهم‌ترین property این validator چیزی است که حاضر نیست انجام دهد. فقط /S استانداردی را match می‌کند که FPDF_StructElement_GetType مستقیم برمی‌گرداند - Figure, Formula, Note. یک document که یک type سفارشی تعریف می‌کند و آن را به Figure role-map می‌کند، بسته به این‌که PDFium type را چگونه resolve کند، ممکن است نام خودش را report کند. وقتی این اتفاق می‌افتد checker آن را نمی‌شناسد و ساکت می‌ماند. این یک false negative است، و رفتار موردنظر است. rule طراحی این است که under-report کند تا هرگز false positive تولید نکند، چون یک preflight tool که روی fileهای منطبق گریهٔ گرگ سر می‌دهد، کاربرانش را عادت می‌دهد نادیده‌اش بگیرند - و یک validator نادیده‌گرفته‌شده بدتر از نداشتن آن است. imageهای تزئینی در artifact stream زندگی می‌کنند، نه در structure tree، پس از همان اول به‌عنوان Figure ظاهر نمی‌شوند؛ بنابراین دربارهٔ یک rule پس‌زمینه که درست به‌عنوان artifact علامت خورده است، شکایت «missing Alt» نخواهید گرفت

این هم دلیل دیگری است که scope روی سه rule نگه داشته شده است. nesting سطح heading (clause 7.4)، scope هدرهای table (7.5)، و تشخیص cycle در role map (7.1) همگی requirementهای مشروع PDF/UA هستند، اما بررسی درست آن‌ها به graph و attribute analysis واقعی نیاز دارد، و بررسی ساده‌انگارانهٔ آن‌ها دقیقاً همان false positiveهایی را تولید می‌کند که design ممنوع می‌کند - PDF/UA الگوهای heading مثل H1، H2، H3، H3 را مجاز می‌داند، در حالی که یک rule سادهٔ «باید strictly افزایش یابد» آن‌ها را نادرست رد می‌کند. این checkها به ابزارهای conformance اختصاصی واگذار شده‌اند. مجموعهٔ Tier-1 زیرمجموعه‌ای است که در آن یک attribute گمشده بی‌ابهام است

مرز، به‌روشنی بیان‌شده

دو محدودیت هست که قبل از این‌که آن را در یک release gate وصل کنید باید بدانید. اول این‌که checker فقط به‌اندازهٔ چیزی خوب است که PDFium از structure element بتواند بخواند. چند file در conformance corpus که validatorهای reference آن‌ها را قبول می‌کنند از یک mechanism متن جایگزین استفاده می‌کنند که PDFium surface نمی‌کند، بنابراین FPDF_StructElement_GetAltText خالی برمی‌گرداند، هرچند فایل واقعاً منطبق است. pure checker سپس به‌طور «درست» یک Alt گمشده را روی دادهٔ ناقص علامت می‌زند - یک false positive که از پوشش accessor در DLL سرچشمه می‌گیرد، نه از منطق rule. شل کردن rule برای جذب این caseها آن را نسبت به failureهای واقعی که قرار است بگیرد کور می‌کند، بنابراین این‌ها به‌عنوان یک limitation شناخته‌شدهٔ PDFium مستند می‌شوند، نه این‌که سرپوش گذاشته شوند

دوم، این preflight است، نه certification. Tier-1 خطاهای محتواییِ با اطمینان بالا را که یک scan در سطح byte از نظر ساختاری نمی‌تواند بگیرد، catch می‌کند، و این کار را بدون false alarm انجام می‌دهد - اما conformance کامل PDF/UA، از جمله semantics هدرها، ساختار table، و درستی reading order، هنوز به یک validator کامل و در نهایت به یک بازبین انسانی تعلق دارد. ValidatePdfUa را به کار بگیرید تا defectهای obvious را سریع و ارزان در pipeline خودتان fail کنید، و بعد بگذارید veraPDF یا PAC حرف آخر را بزنند. همان traversalِ structure-tree زیربنای ساخت یک PDF reader دسترس‌پذیر در Delphi را شکل می‌دهد، جایی که tag tree reading order و spoken text را هدایت می‌کند، و با کار سطح metadata در بازبینی annotationهای PDF از Delphi مکمل می‌شود

APIهای structure-tree و ValidatePdfUa validator که اینجا نشان داده شده‌اند همراه با PDFium Component برای Delphi و C++Builder (VCL) و Lazarus/FPC (LCL) ship می‌شوند. صفحهٔ محصول مرجع کامل API را لینک می‌کند، شامل چیدمان کامل TPdfStructureElement record و issue enumeration پشت این checkها