מאמר טכני

אימות עץ המבנה של PDF/UA ב-Delphi עם PDFium

בדיקת ה-preflight שלך מדווחת שהקובץ נקי לפי PDF/UA. אותו קובץ בדיוק נפתח ב-veraPDF ומסומן כ-Figure בלי טקסט חלופי לפי סעיף 7.3. שתי התוצאות נכונות, והפער ביניהן הוא בדיוק הבעיה של בדיקת נגישות באמצעות סריקת בתים. מעבר ברמת בתים מאשר שהקובץ טוען שהוא מתויג: הוא מוצא את /StructTreeRoot, את /MarkInfo /Marked true, את pdfuaid:part בחבילת ה-XMP, את כותרת המסמך ואת השפה. אלו סמני תבנית, והם נחוצים. הם לא אומרים דבר על כך אם ה-Figure האמיתי בעמוד ארבע כולל תיאור שקורא מסך יכול להקריא. התשובה לכך נמצאת בעץ התגים, וכדי לקבל אותה צריך לעבור על העץ

PDFium Component היא ספריית PDF מקורית ל-VCL עבור Delphi ו-C++Builder, והמתודה ValidatePdfUa שלה מבצעת את שני המעברים. המעבר ברמת הבתים מטפל בסמני התבנית. מעליו יושב מעבר עץ מבנה שטוען את העץ המתויג החי, עובר על כל רכיב ובודק את קבוצת חוקי התוכן המצומצמת ובעלת הביטחון הגבוה, שבה שדה חסר הוא פגם נגישות אמיתי ולא העדפה סגנונית. מאמר זה עוסק במעבר השני, מה הוא בודק, למה לוגיקת החוקים היא פונקציה טהורה בלי DLL מתחתיה, ואיפה היא נעצרת במכוון

למה סריקת בתים לא יכולה לראות Alt חסר

ISO 14289-1 (PDF/UA-1) היא שכבת דרישות מעל ISO 32000. חלק מהדרישות האלה מבניות ונראות בקובץ הגולמי: הקטלוג חייב להכריז על עץ מבנה, העדפות הצפייה חייבות להגדיר DisplayDocTitle, והגופנים חייבים להיות מוטמעים. סורק אסימונים שמסיר גופי stream ומזהה אסימוני שמות לפי גבולות מפריד יכול לאמת את כל אלה, ו-ValidatePdfUaCompliance של PDFium עושה בדיוק את זה עבור סעיפים כמו 7.1, 7.18 ו-7.21

אבל "כל Figure כולל טקסט חלופי" אינו תכונה של התחביר של הקובץ. זו תכונה של המבנה הלוגי, עץ רכיבי התג שממפה תוכן למשמעות. ערך Alt של Figure יכול להופיע במילון רכיב המבנה, להינתן דרך span של /ActualText, או להגיע מסוג מותאם שממופה בתפקידי התג. אי אפשר למצוא אותו באופן אמין על ידי grepping עבור /Alt בזרם הבתים, כי המחרוזת הזו מופיעה בהקשרים לא קשורים, עשויה להיות דחוסה בתוך object stream, ואינה אומרת לך לאיזה רכיב מבנה היא שייכת. הדרך הישרה לענות על השאלה היא לשאול את עץ המבנה של המסמך עצמו, רכיב אחר רכיב, בדיוק כמו ש-veraPDF ו-PAC בודקים. זה הקו שסביבו בנויים בדיקות Tier-1 של PDFium, סריקת בתים לתבנית, מעבר עץ לתוכן

קריאת עץ התגים החי

החומר הגולמי הוא TPdf.GetStructureElements (שמופיע גם כמאפיין StructureElements), שמחזיר TPdfStructureElements, מערך שטוח של רשומות TPdfStructureElement לפי סדר המסמך. כל רשומה היא הקרנה של רכיב מבנה אחד דרך פונקציות הגישה של PDFium, עם השדות שחוקי הנגישות באמת צריכים:

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;

השדה TypeName הוא זה שהבודק נשען עליו. הוא מגיע מ-FPDF_StructElement_GetType, שמחזירה את סוג המבנה התקני של הרכיב, כלומר את שם /S שלו, אחרי ש-PDFium פתר את מפת התפקידים. AlternateText מגיע מ-FPDF_StructElement_GetAltText, ActualText מ-FPDF_StructElement_GetActualText, ו-ID מ-FPDF_StructElement_GetID. מכיוון שהמערך שטוח ומסודר, הבודק יכול להסתכל על כל המסמך בבת אחת במקום לרדת רקורסיבית, וזה חשוב במיוחד עבור החוק היחיד שהוא גלובלי ולא פר-רכיב

הבודק הוא פונקציה טהורה, וזה מכוון

לוגיקת החוקים אינה יושבת בתוך המתודה שמדברת עם ה-DLL. זוהי פונקציה טהורה, עצמאית וציבורית:

function ValidatePdfUaStructureElements(
  const Elements: TPdfStructureElements): TPdfUaValidationIssues;

היא מקבלת מערך שטוח של רכיבים ומחזירה קבוצת בעיות. היא לא קוראת לאף פונקציה של PDFium, לא פותחת מסמך, ולא נוגעת במצב גלובלי. ההפרדה הזו מכוונת, והיא משתלמת בשתי דרכים. ראשית, בדיקות: אפשר לבנות במבחן יחידה מערך TPdfStructureElements סינתטי, Figure בלי Alt, Formula שהטקסט הנגיש היחיד שלו נמצא ב-ActualText, ושני Notes שחולקים ID, ואז לאמת את קבוצת התוצאות בלי ש-pdfium.dll יהיה נוכח בכלל. לוגיקת החוקים מאומתת לא מקוונת, והמעבר דרך ה-DLL מאומת בנפרד על ידי בדיקת עשן מול מסמך חי, שנדחית אם הספרייה חסרה

שנית, בהירות אחריות. TPdf.ValidatePdfUa נושאת בחלק המלוכלך, טעינת כל עמוד, שליפת הרכיבים שלו, צבירתם, ואז מעבירה מערך נקי לבודק הטהור. "קבל את הנתונים" (DLL, תופעות לוואי, מחזור חיים) ו"שפוט את החוקים" (טהור, דטרמיניסטי) לא מתערבבים לעולם. כשהחוק צריך להשתנות, משנים פונקציה שאין בה I/O

מה שלושת החוקים באמת בודקים

מעבר עץ המבנה מעלה שלושה ערכי בעיה, שמתווספים לסוף TPdfUaValidationIssues כדי שה-enum יישאר יציב ABI עבור קוראים קיימים: pvuaiFigureMissingAlt, pvuaiFormulaMissingAlt, ו-pvuaiNoteMissingId. הגוף קטן מספיק כדי שאפשר יהיה להבין אותו לחלוטין:

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;

סעיף 7.3 עוסק ב-figures: רכיב Figure חייב לספק חלופה טקסטואלית. הגרסה המוקדמת של הבדיקה הזו הסתכלה רק על ערך Alt, מה שהפך אותה למחמירה יותר מהבודקים הייחוסיים. PDF/UA מקבל Figure שהטקסט הנגיש שלו מגיע דרך ActualText במקום זאת, כלומר replacement text היא ייצוג חלופי תקין, ולכן החוק מסמן Figure רק כאשר גם Alt וגם ActualText ריקים. סעיף 7.7 חל על formulas, ולאחר אותו תיקון הוא משתמש בדיוק באותה בדיקה של Alt או ActualText; דוגמה מקובץ conformance corpus שנתנה ל-Formula את הטקסט הנגיש שלה רק דרך ActualText נדחתה בטעות עד שהענף של Formula הותאם לענף של Figure

סעיף 7.9 שונה במהותו. ל-Note חייב להיות /ID, וה-ID הזה חייב להיות ייחודי בכל המסמך. ID חסר הוא כשל פר-רכיב. ID כפול הוא קשר בין שני רכיבים, ולכן המערך השטוח חשוב: עבור כל Note, הבודק סורק לאחור על הרכיבים שכבר נראו ומסמן התנגשות מול כל Note מוקדם יותר שנושא את אותו ID. העלות היא כמובן O(n²) ביחס למספר ה-Notes, וזה לא רלוונטי לשום מסמך אמיתי, ובמקום זאת מתקבלת פונקציה אחת קריאה עם לולאה אחת וללא אינדקס עזר שצריך לסנכרן

צבירה בין עמודים כדי שהייחודיות תהיה גלובלית

PDFium חושף רכיבי מבנה לפי עמוד, ולא לפי מסמך, ולכן התזמור ב-ValidatePdfUa צריך לאסוף אותם לפני שהחוקים רצים. הוא עובר על כל עמוד עם FPDF_LoadPage / GetStructureElementsForPage / FPDF_ClosePage, בלי קשר לאיזה עמוד הרכיב פתוח כרגע, ומצרף את רכיבי כל העמודים למערך אחד. רק אז הוא קורא לבודק הטהור:

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

הצבירה היא מה שהופך את בדיקת הייחודיות של 7.9 לנכונה. שני Notes בעמודים שונים יכולים לחלוק ID; אם היית מאמת עמוד אחר עמוד, לעולם לא היית רואה את ההתנגשות, כי קבוצת הרכיבים של כל עמוד נראית עקבית פנימית. בניית מערך אחד לכל המסמך היא הדרך היחידה לגרום לכפילות להופיע. גם השער הקדמי שווה ציון: מעבר העץ רץ רק כאשר המעבר ברמת הבתים לא דיווח על pvuaiMissingStructTreeRoot. למסמך לא מתויג אין עץ לעבור עליו, והוא כבר סומן בגלל מבנה שורש חסר, ולכן טעינות העמודים נדחות לגמרי. המעבר העמוק לא עולה דבר על מסמכים שלא יכולים להרוויח ממנו

שמרני לפי התכנון: לפספס בשקט, לא להתריע לשווא

התכונה החשובה ביותר של הבודק הזה היא מה שהוא מסרב לעשות. הוא מתאים רק לשמות הסוג התקניים של /S ש-FPDF_StructElement_GetType מחזירה ישירות, Figure, Formula, Note. מסמך שמגדיר סוג מותאם וממפה אותו בתפקידים ל-Figure ידווח, בהתאם לאופן שבו PDFium פותר את הסוג, בשם שלו עצמו. כשהדבר הזה קורה הבודק לא מזהה אותו ונשאר שקט. זו תוצאה שלילית כוזבת, וזה בדיוק ההתנהגות הרצויה. כלל התכנון הוא לדווח בחסר ולא לייצר לעולם חיובי כוזב, כי כלי preflight שצורח לשווא על קבצים תקינים מחנך את המשתמשים להתעלם ממנו, ובודק שמתעלמים ממנו גרוע יותר מחוסר בודק. תמונות דקורטיביות חיות בזרם ה-artifact, לא בעץ המבנה, ולכן הן ממילא לא מופיעות כ-Figures; לא תקבל תלונה על "missing Alt" עבור כלל רקע שסומן כראוי כ-artifact

זו גם הסיבה שהתחום נשמר לשלושה חוקים. קינון רמות כותרת (סעיף 7.4), תחולת כותרות טבלה (7.5), וזיהוי לולאות במפת התפקידים (7.1) הם כולם דרישות PDF/UA לגיטימיות, אבל בדיקה טובה שלהם דורשת ניתוח אמיתי של גרפים ושל תכונות, ובדיקה נאיבית מייצרת בדיוק את החיוביים הכוזבים שהתכנון אוסר. PDF/UA מאפשר דפוסי כותרות כמו H1, H2, H3, H3, שכלל פשוט של "חייב לעלות בקפדנות" היה דוחה בטעות. את הבדיקות האלה משאירים לכלי conformance ייעודיים. קבוצת Tier-1 היא התת-קבוצה שבה שדה חסר הוא חד-משמעי

הגבול, במפורש

שני גבולות כדאי להכיר לפני שמחברים את זה לשער שחרור. ראשית, הבודק טוב רק כמו מה ש-PDFium יודע לקרוא מרכיב המבנה. כמה קבצים בקורפוס התאימות שעוברים בבודקים הייחוסיים משתמשים במנגנון טקסט חלופי ש-PDFium לא חושף, ולכן FPDF_StructElement_GetAltText מחזירה ריק גם כשהקובץ באמת תקין. הבודק הטהור מסמן אז, "נכון" מבחינתו, Alt חסר על בסיס נתונים חסרים, חיובי כוזב שנובע מכיסוי האקססורים של ה-DLL ולא מלוגיקת החוקים. ריכוך החוק כדי לספוג את המקרים האלה היה גם מעוור אותו לכשלים האמיתיים שהוא אמור לתפוס, ולכן הם מתועדים כמגבלה ידועה של PDFium במקום לטשטש אותם

שנית, זו בדיקת preflight, לא הסמכה. Tier-1 תופסת את שגיאות התוכן בעלות הביטחון הגבוה שסורק בתים לא יכול לתפוס מבנית, והיא עושה זאת בלי אזעקות שווא, אבל התאמה מלאה ל-PDF/UA, כולל סמנטיקה של כותרות, מבנה טבלאות ודיוק סדר הקריאה, עדיין שייכת לבודק מלא ובסופו של דבר לבודק אנושי. השתמש ב-ValidatePdfUa כדי להכשיל במהירות ובזול את הפגמים הברורים בצינור שלך, ואז תן ל-veraPDF או ל-PAC לקבוע את המילה האחרונה. אותו מעבר על עץ המבנה הוא הבסיס לבניית קורא PDF נגיש ב-Delphi, שבו עץ התגים מניע את סדר הקריאה ואת הטקסט המדובר, והוא משלים את העבודה ברמת המטא-דאטה של סקירת הערות PDF מ-Delphi

ממשקי עץ המבנה והבודק ValidatePdfUa שמוצגים כאן מגיעים עם PDFium Component עבור Delphi ו-C++Builder (VCL) ו-Lazarus/FPC (LCL). עמוד המוצר מקשר למפרט ה-API המלא, כולל הפריסה השלמה של הרשומה TPdfStructureElement וה-enum של הבעיות שמאחורי הבדיקות האלה