מאמר טכני

אימות קדם-הפקה של PDF/A ב-Delphi עם PDFium VCL

שער קליטה לארכיון דחה אצווה של קבצי "PDF/A-2b" שנפתחו בלי בעיה בכל מציג על השולחן. הספק נשבע שהם תואמים לתקן. הם לא היו: בכל אחד מהם הסתתרה פעולת JavaScript בתוך ה-catalog, מסוג הדברים שעין מזדמנת לא תופסת ובודק PDF/A מלא כמו veraPDF מסמן מיד. הבעיה היא שאיש לא רצה לחבר שרשרת כלים של Java לשירות אצווה ב-Delphi רק כדי לענות על שאלה אחת של כן או לא לכל קובץ. זה הפער ש-ValidatePdfACompliance ב-PDFium Component ממלא, וכדאי להבין איך הוא מגיע להכרעה בלי לנתח במלואו זרם תוכן

למה PDFium לבדו לא יכול לענות על זה

הדבר הראשון שצריך לומר בכנות: ל-pdfium.dll המובנה אין כלל יכולת PDF/A. אין ConvertToPDFA, אין כותב OutputIntent, ואין API של XMP בממשק הציבורי. כל חלקי PDF/A בספרייה הזו, גם צד הכתיבה וגם צד הבדיקה, יושבים ב-Pascal טהור בקובץ FPdfPdfa.pas ופועלים באמצעות ניתוח ברמת בתים יחד עם עדכון מצטבר. לכן כשקוראים ל-validator לא שואלים את ה-renderer של Chromium שום דבר. מריצים סורק טוקנים ב-Pascal על הבתים המבניים של הקובץ

הממשק הציבורי קטן במכוון. פונקציה אחת קוראת זרם מנקודה 0 ומחזירה רשומה:

function ValidatePdfACompliance(Source: TStream): TPdfAValidationResult;

type
  TPdfAValidationResult = record
    Conformance: TPdfAConformance;        // pacUnknown, pacNone, pac1b, pac2u, ...
    Issues: TPdfAValidationIssues;        // a set of TPdfAValidationIssue
    function IsCompliant: Boolean;        // True only when level <> unknown/none
  end;                                    // AND Issues is empty

IsCompliant מקודדת את הכלל שקובע בשער: קובץ עובר רק כשזוהתה רמת תאימות אמיתית וסט הבעיות ריק. ניתוח שמצליח אבל לא מוצא סימון pdfaid נפתר ל-pacNone, וזה במפורש לא עובר. זו אותה נקודה ש-CLI לדוח קדם-הפקה באצווה מדגיש מבחוץ: רשימת ממצאים ריקה על קובץ לא מזוהה אינה אישור תקינות

הסרת גופי זרם לפני כל סריקת טוקנים

כאן נמצא פרט המימוש החשוב ביותר, וגם זה שהכי קל לטעות בו כשכותבים סורק משלכם. הגלאי מוצא הפרות על ידי חיפוש טוקני שמות תחומים, דברים כמו /JavaScript, /LZWDecode, /BM. אם סורקים את הבתים הגולמיים של הקובץ, גופי הזרם הבינריים המוטמעים, תמונות דחוסות, פרופילי ICC ותוכניות גופנים, יכילו באקראי רצפי בתים שנראים כמו הטוקנים האלה. תקבלו דיווח ש-/AA או /3D "נמצאו" רק כי שלושה בתים בתוך JPEG כתבו במקרה את הרצף הזה. זה מפעל לתוצאות שווא

הפתרון הוא PdfStructureBytes: הוא עובר על הקובץ ומחליף ברווחים את הבתים בין כל הופעה של stream ל-endstream, תוך שמירה על מבנה המילונים. רק אחר כך רצה הסריקה. כל בדיקת טוקן-שם ב-validator פועלת על העותק המנוקה הזה. אם לוקחים רעיון אחד מהמאמר הזה, זה הרעיון. אותה משמעת משוכפלת ב-validator של PDF/UA, ששומר עותק משלו של השגרה כי שני התקנים מתפתחים בנפרד

29 הבעיות ומה המשמעות של כל אחת

TPdfAValidationIssue הוא חוזה מתועד. המספרים הסידוריים קבועים כי בדיקות DUnitX, ההדגמות ושכבת הדוחות כולם תלויים בהם, לכן ממצאים חדשים תמיד מצורפים רק לסוף. נכון ל-v1.63.0 יש 29 איברים. הם מתחלקים לכמה משפחות:

  • מטא-נתונים וזהות: pvaiMissingXmpMetadata, pvaiMissingPdfAIdentifier, pvaiMissingTrailerId (ISO 19005-1 6.1.3), pvaiMissingXmpDates
  • צבע ופלט: pvaiMissingOutputIntent, pvaiMissingIccProfile, ו-pvaiMixedDeviceColorSpaces כשמופיעים גם DeviceRGB וגם DeviceCMYK (6.2.3.3)
  • איסורים מוחלטים בכל חלק: pvaiEncryptionPresent (מילון /Encrypt אסור לחלוטין), pvaiJavaScriptPresent, pvaiForbiddenAction, pvaiAdditionalActions, pvaiLzwUsed, pvaiXfaPresent, pvaiNeedAppearancesTrue, pvaiForbiddenAnnotation
  • גופנים: pvaiFontNotEmbedded והמחמיר יותר pvaiUnembeddedFont, וגם pvaiUnicodeMappingMissing עבור טענת Level U בלי /ToUnicode
  • תיוג: pvaiLevelAStructureMissing כשלטענת תאימות A אין מבנה מתויג

ששת החברים החדשים ביותר, שנוספו באינדקסים 24 עד 29, מכסים את המקרים העדינים שבודקים נתקלים בהם בפועל: pvaiTrappedTrue (‏/Trapped /True במילון Info, "ידיד כוזב", כי הערך חייב להיות False או Unknown), pvaiForbiddenActionSubtype (Sound או Movie כשימוש כפעולה, לא רק כהערה), pvaiTransparentColorSpace (מצב מיזוג שאינו Normal או /CA//ca שאינו שווה ל-1.0), pvaiAnnotationDictViolation, pvaiUnembeddedFont, ו-pvaiMixedDeviceColorSpaces

בקרה לפי חלק: A-1 מחמיר, A-2 ו-A-3 מקלות

PDF/A אינו ספר חוקים אחד. שלושה דברים ש-PDF/A-1 אוסר מותרים במפורש מ-PDF/A-2 ואילך: שקיפות (קבוצת /Transparency או /SMask פעיל, 6.4), תוכן אופציונלי (/OCProperties, 6.1.13), וקבצים מוטמעים (/EmbeddedFiles או /EF, 6.1.11). בודק נאיבי שמסמן את שלושתם בכל קובץ ידחה בכמויות גדולות מסמכי PDF/A-2 תקינים לחלוטין

לכן ה-validator קורא את מספר החלק מסימון pdfaid דרך PdfAPartOf ומציב את הבדיקות האלה מאחורי PartNo = 1. בדיקות מצב המיזוג ושקיפות ההערות לבעיות השקיפות החדשות הן גם כן לחלק 1 בלבד:

if PartNo = 1 then
begin
  if PdfHasName(Struct, '/BM') then
    if not PdfHasBMNormal(Struct) then          // only /Normal or /Compatible allowed
      Include(Result.Issues, pvaiTransparentColorSpace);
  if PdfHasCaNotOne(Struct, '/CA') or PdfHasCaNotOne(Struct, '/ca') then
    Include(Result.Issues, pvaiTransparentColorSpace);
end;

ברירת מחדל שמרנית אחת ראויה לאזכור: כשאין סימון pdfaid בכלל, מתייחסים לחלק כ-1, כלומר המחמיר ביותר. ההיגיון הוא שקובץ לא מזוהה צריך להישפט לפי הכללים ההדוקים ביותר ולא לעבור סתם כך. JavaScript, פעולות אסורות, LZW, XFA, NeedAppearances, הערות אסורות וגופנים לא מוטמעים נשארים אסורים בכל חלק, כך שהבדיקות האלה לעולם לא יושבות מאחורי השער

הרחבת object streams כדי ששום דבר לא יסתתר

PDF 1.5 הציגה את זרם ה-cross-reference ואת object stream (/Type /ObjStm), והם יוצרים נקודת עיוורון לסורק בתים נאיבי. catalog, OutputIntent, מילון פעולה, כל דבר שאינו stream בפני עצמו, יכול להידחס ב-Flate בתוך ObjStm. סרקו את המבנה הגולמי ולא תראו אף אחד מהם, ואז תקבלו דיווח על קובץ נקי שהוא כל דבר חוץ מנקי

PdfExpandObjectStreams סוגר את הפער הזה. לפני שכל בדיקה רצה, ה-validator עושה Data := PdfExpandObjectStreams(Data). השגרה מאתרת כל ObjStm, קוראת את כותרות /N ו-/First כדי לקבל את מספרי האובייקטים וההיסטים הכלולים, פורשת את הגוף בעזרת PdfInflate (zlib של ה-RTL, System.ZLib ב-Delphi ו-zstream ב-FPC), ומוסיפה כל אובייקט כלול כאובייקט רגיל N 0 obj ... endobj לסוף עותק של הבתים. בדיקות הטוקן הקיימות מוצאות אז את האובייקטים האלה בלי שום שינוי בלוגיקה שלהן

שני אילוצים הופכים את זה לנקי ולא שביר. אובייקטי זרם, המטא-דטה, פרופיל ICC ותוכניות גופנים, לא יכולים לשבת בתוך object stream, רק מילונים שאינם זרמים יכולים, כך שההרחבה מתעסקת רק במילונים והאובייקטים המצורפים אינם נושאים את מילת המפתח stream שיכולה להפריע לשלב ניקוי הגוף. ומכיוון שהתוכן המצורף נוחת אחרי %%EOF, החיפוש לאחור מתוך startxref עדיין מוצא את ה-trailer המקורי. ה-trailer של זרם ה-cross-reference עצמו כבר טופל קודם, ב-v1.49.3, על ידי קריאת Root, Size ו-ID ישירות ממילון xref-stream הטקסטואלי, נושא שנדון במאמר המקביל על אימות זרמי object ו-cross-reference; עבודת object stream נדרשה רק להוסיף את שלב הפריסה, בלי צורך לפענח רשומות xref מסוג 2 או לפרום predictor של PNG

המגבלות הכנות של בודק ברמת בתים

זהו כלי קדם-הפקה, לא validator מוסמך, והגבולות אמיתיים. הטמעת גופנים היא היוריסטיקת ספירה, ולקח זמן להגיע לתיקון שכדאי להכיר. הבדיקה המקורית השתמשה ב-PdfCountName('/FontDescriptor'), אבל כל גופן תורם שני טוקנים של /FontDescriptor, הפניה אחת ממילון הגופן ואחת /Type באובייקט ה-descriptor עצמו, כך שהספירה הייתה 2N מול N תוכניות מוטמעות והבדיקה תמיד יצאה true. התיקון הוא PdfCountDescriptorRefs, שסופר רק את צורת ההפניה /FontDescriptor N G R, אחת לכל גופן, ומסמן pvaiUnembeddedFont רק כשהתוכניות המוטמעות באמת מעטות יותר:

K := PdfCountDescriptorRefs(Struct);                 // one per font dict
Emb := PdfCountName(Struct, '/FontFile')
     + PdfCountName(Struct, '/FontFile2')
     + PdfCountName(Struct, '/FontFile3');
if (K > 0) and (Emb < K) then
  Include(Result.Issues, pvaiUnembeddedFont);

גם אחרי התיקון, זו בדיקה גסה: מסמך מעורב שבו לכל descriptor במקרה יש איזה FontFile עדיין יכול לאפשר לגופן בודד שאינו תואם לחמוק דרכה. גם להרחבת object streams יש תופעת לוואי ידועה, היא חושפת את משאבי ברירת המחדל של התקן 14 ש-/DR של AcroForm נושא, כמו /Helv, והיוריסטיקה מדווחת עליהם כדברים שלא הוטמעו, אף ש-veraPDF מאפשרת אותם כי בפועל אף פעם לא משתמשים בהם לעיבוד. בדיקות ברמת אופרטורים של content stream (6.2.10) בכלל מחוץ לתחום, כי הן היו דורשות ניתוח מלא של התוכן ולא סריקת בתים. התייחסו ל-validator כאל שער ראשון מהיר ונטול תלות שתופס את ההפרות שהזרקת סימון לא יכולה לתקן, ושמרו validator מלא לאישור הסופי

זהו חצי הבדיקה של הסיפור. הצד המשלים של הכתיבה, שבו SaveAsPdfA מזריק את ה-XMP, את ה-OutputIntent ואת פרופיל ה-sRGB ICC, ומוריד בכנות בקשת Level A שאין לה מבנה מתויג, נשען על אותה מכונת עבודה ברמת בתים. שני החצאים מגיעים יחד ב-PDFium Component for Delphi, חבילת VCL אחת מעל מימוש PDF/A טהור ב-Pascal בלי זמן ריצה חיצוני להתקנה