מאמר טכני

תאימות ארכיונית PDF/A ב-Delphi עם PDFium VCL

אתם משחררים ממיר שמסמן כל קובץ כ-PDF/A-1b, מערכת הרישומים של הלקוח קולטת אותם במשך שנה, ואז בביקורת מריצים את כל האצווה דרך veraPDF ושליש מהם חוזרים כלא תואמים. שום דבר לא קרס, לא נזרקה חריגה, והקבצים נפתחים היטב בכל מציג שעל השולחן שלכם. הם פשוט לא היו התקן שסימנתם עליהם. זהו כשל אופייני ל-PDF ארכיוני, ולכן ״הגדרנו את הדגל״ לעולם אינו שקול ל״זה עובר ולידציה״

הדבר הראשון שצריך להבין לגבי PDFium ו-PDF/A הוא שלמנוע עצמו אין בכך חלק. PDFium מציג, מנתח וכותב PDF, אבל בממשק הציבורי שלו אין ConvertToPDFA, אין כותב OutputIntent ואין API ל-XMP. כל רכיב של תאימות ארכיונית, חבילת ה-XMP, ה-OutputIntent ופרופיל ה-ICC שלו, סמני הקטלוג והוולידציה, חי בתוך PDFiumPas עצמו, ביחידת Pascal טהורה של כ-2,000 שורות (FPdfPdfa.pas) שמנתחת את הבתים שנשמרו וכותבת אותם מחדש בעדכון מצטבר. הידיעה היכן העבודה באמת מתבצעת אומרת לכם היכן מסתתרים הבאגים, והם לא מסתתרים ב-PDFium

מה PDF/A באמת דורש, ואיפה זה נושך

PDF/A אינו פורמט אחד. ISO 19005 מגדיר שלושה חלקים (PDF/A-1, -2, -3) ובכל אחד מהם רמות תאימות שמבטיחות דברים שונים. רמה B (בסיסית) מבטיחה רק שהמראה החזותי ניתן לשחזור. רמה A (נגישה) מוסיפה מעל B עץ מבנה מתויג ומיפוי Unicode. רמה U, שקיימת רק בחלקים 2 ו-3, נמצאת ביניהן: טקסט Unicode אמין בלי עץ המבנה המלא. ל-ISO 19005-1 אין רמת U, אילוץ שהספרייה מקודדת ישירות

קומץ מכללי הפורמט הם אלה שנושכים בפועל. הצפנה אסורה לחלוטין (ISO 19005-1 §6.1.3 והתקנים שבאו אחריו): קובץ PDF/A אינו יכול לשאת מילון /Encrypt. המסמך חייב להצהיר על תנאי תצוגה דרך OutputIntent שמיעד שלו הוא פרופיל ICC תקין (§6.2.3.2). עצם טענת התאימות חייבת להופיע כמטא-נתוני XMP תחת סכמת הזיהוי של PDF/A. רמה A דורשת בנוסף את המבנה הלוגי של §6.8, עץ התגים שהופך את המסמך לקריא מכונה. חסר אחד מאלה, ומאמת תאימות ידחה את הקובץ אף על פי שהוא מוצג בצורה מושלמת

הקריאה האחת שמייצרת ארכיון

PDFiumPas חושף את כל הצינור מאחורי TPdf.SaveAsPdfA. הגרסה הפשוטה מקבלת תאימות יעד וברירת המחדל שלה היא PDF/A-1b, שהיא ברירת המחדל הנכונה למקרה הנפוץ של ״להפוך את זה לקריא לנצח״

var
  Pdf: TPdf;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.LoadFromFile('invoice.pdf');
    // Default conformance is pac1b (PDF/A-1b)
    if Pdf.SaveAsPdfA('invoice_archive.pdf') then
      // file now carries XMP, sRGB OutputIntent, and catalog markers
    else
      raise Exception.Create('PDF/A save failed');
  finally
    Pdf.Free;
  end;
end;

מאחורי הקלעים זהו מהלך דו-שלבי. SaveAsPdfA מבקש תחילה מ-PDFium לסדר את המסמך באמצעות FPDF_SaveAsCopy, ואז מעביר את זרם הבתים הזה ל-InjectPdfAMarkers, שמצרף את מטא-נתוני ה-XMP, את ה-sRGB OutputIntent עם פרופיל ה-ICC המוטמע שלו, וקטלוג שנכתב מחדש כעדכון מצטבר. המקור נקרא מנקודה אפס והיעד נכתב מנקודה אפס; עץ האובייקטים המקורי נשאר שלם והסמנים מצטרפים אחרי %%EOF הקיים. אם אתם צריכים את הבתים במקום קובץ, SaveAsPdfAToStream מקבל TStream ואת אותן אפשרויות

בחירת התאימות עם רשומת האפשרויות

אם צריך לכוון לחלק ורמה מסוימים, מעבירים רשומת TPdfASaveOptions. השדה Conformance שלה מקבל ערך TPdfAConformance. המניין מכסה כל צירוף תקין ורק אותם: pac1b, pac1a עבור חלק 1; pac2b, pac2u, pac2a עבור חלק 2; pac3b, pac3u, pac3a עבור חלק 3, ובנוסף pacUnknown ו-pacNone בצד הוולידציה. אין pac1u, כי רמה כזאת אינה קיימת בתקן

var
  Pdf: TPdf;
  Opts: TPdfASaveOptions;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.LoadFromFile('report.pdf');
    Opts := TPdfASaveOptions.Default;
    Opts.Conformance := pac2u;           // PDF/A-2u: reliable Unicode text
    Opts.Title := 'Quarterly Report 2026';
    Opts.Author := 'Finance';
    // Leave IccProfileData empty to use the built-in sRGB IEC61966-2.1 profile
    if not Pdf.SaveAsPdfA('report_a2u.pdf', Opts) then
      raise Exception.Create('PDF/A-2u save failed');
  finally
    Pdf.Free;
  end;
end;

את רוב הרשומה אפשר להשאיר ריקה. אם משאירים את Title, Author, Subject, Keywords, Creator ו-Producer ריקים, SaveAsPdfA ממלא אותם אוטומטית ממילון ה-Info של המסמך דרך FPDF_GetMetaText. אם משאירים את CreationDate ואת ModDate ריקים, הוא משתמש בשעת UTC הנוכחית עבור שני תאריכי ה-XMP. אם משאירים את DocumentId ואת InstanceId ריקים, הספרייה מאכלסת אותם מראש מ-FPDF_GetFileIdentifier, ובמקרה הצורך נופלת ל-ID דטרמיניסטי שמחושב מהבתים המקוריים. השדה היחיד שכדאי לעקוף במכוון הוא IccProfileData: ערך ריק אומר פרופיל sRGB IEC61966-2.1 המובנה, אבל תהליך עבודה של CMYK או גווני אפור צריך לספק פרופיל משלו

למה רמה A מדרדרת, ולמה זו הבחירה הכנה

זו נקודה עדינה שמכשילה מי שמצפה שדגל יהיה הבטחה. אפשר לבקש pac1a על מסמך שאין בו עץ תגים, אבל PDF/A-1a דורש מבנה לוגי לפי §6.8, והספרייה אינה יכולה לייצר עץ מבנה מתוך PDF שלא תויג. במקום להוציא קובץ שטוען לרמה A אך נכשל בה, SaveAsPdfA בודק אם קיים מבנה מתויג אמיתי (/StructTreeRoot יחד עם /MarkInfo עם /Marked true) ואם הוא חסר, מוריד את הטענה דרגה: pac1a הופך ל-pac1b, pac2a הופך ל-pac2b, וכן הלאה בכל שלושת החלקים. העוזרים הפנימיים הם PdfAIsLevelA ו-PdfADowngradeToLevelB

ההיגיון כאן פשוט: קובץ שמצהיר בכנות על הרמה שהוא באמת עומד בה שימושי יותר מקובץ שמשקר על רמה שהוא לא עומד בה. רמה U מטופלת אחרת. זיהוי כיסוי Unicode אמיתי היה מחייב בדיקה נאיבית של ״האם יש לו /ToUnicode״, בדיקה שמורידה רמה יותר מדי ממסמכים לגיטימיים (WinAnsi וקידודים דומים מוחרגים), ולכן צד השמירה מוציא את טענת U כפי שהלקוח הכריז עליה ומשאיר את הפער להיות מסומן בצד הוולידציה. אם אתם צריכים ארכיון Level A מובטח, תייגו את המסמך לפני ההמרה; הממיר לא ימציא מבנה שאינו קיים

מלכודת ה-ICC שרק מאמת אמיתי תופס

זהו הכשל שלימד את הלקח הקשה ביותר, כי הבודק של הספרייה עצמה עבר עליו בעוד veraPDF, מאמת הייחוס של ISO 19005, לא. PDF/A דורש שפרופיל היעד של ה-OutputIntent יהיה זרם ICCBased תקין, ו-§6.2.3.2 מחייב את המאמת לאמת את הזרם הזה כמרחב צבע. זרם ICCBased חייב להצהיר על /N, מספר רכיבי הצבע. גרסה מוקדמת של המזריק כתבה את מילון זרם ה-ICC עם /Length בלבד וללא /N, ו-veraPDF דחה את התוצאה עם "The N entry (value null)... is missing"

מה שהפך את זה לתקלה ערמומית הוא שהדחייה הופיעה רק עבור PDF/A-1b ו-PDF/A-1a. מודלי התאימות של חלקים 2 ו-3 לא הריצו את הבדיקה המסוימת הזו על פרופיל היעד, ולכן המבנה המוזרק הזהה עבר ולידציה תחת pac2b, pac3b ו-pac2u אבל נכשל תחת pac1b על סמך הערך pdfaid:part בלבד. בדיקת יחידה לא יכלה לראות את זה, כי ValidatePdfACompliance של הספרייה בדק רק שהמפתח /DestOutputProfile קיים, ולא מה נמצא בתוך מילון הזרם. הבדיקות הפנימיות נשארו ירוקות, אבל הוולידציה הארכיונית האמיתית נכשלה

התיקון הוא IccComponentCount, שקורא את חתימת מרחב הצבע של הנתונים במיקום 16 בכותרת ICC וממפה אותה למניין רכיבים: GRAY הוא 1, RGB , Lab ו-XYZ הם 3, CMYK הוא 4, ופרופיל לא מוכר מקבל ברירת מחדל של 3. המניין הזה נכנס למילון הזרם כ-/N. הוא מחושב ולא מקודד קשיח ל-3, כך שמי שמספק פרופיל CMYK או גווני אפור דרך IccProfileData עדיין מקבל את הערך הנכון. הלקח הרחב יותר הוא מתודולוגי: לבודק המובנה ולמאמת הסמכותי יש כתמים עיוורים שונים, ופלט PDF/A חייב להיבדק מקצה לקצה מול מימוש ייחוס כמו veraPDF ולא להסתמך על בדיקות עצמיות. אותו משטר של עדכון מצטבר שמאחורי ארכיונים נקיים מתואר ב-אימות זרמי אובייקטים ו-xref דחוסים, וזה חשוב כי PDF מודרני שהמזריק צורך בנוי לעיתים קרובות על זרמי cross-reference

הצפנה, זרמי xref וקצוות אחרים

מכיוון ש-ISO 19005 אוסר על הצפנה, נתיב השמירה מסיר אותה לפני הכתיבה. SaveAsPdfA מפעיל FPDF_REMOVE_SECURITY בעת הסריאליזציה, כך שמקור מוצפן (שנטען עם הסיסמה שלו) מפוענח בדרך אל הארכיון. במסמך לא מוצפן זהו no-op ולא משתנה דבר. המסקנה היא אותו אילוץ ש-HotPDF אוכף מהכיוון השני: קובץ בודד לא יכול להיות גם מוצפן וגם PDF/A. כשזרימת העבודה צריכה את שניהם, הפתרון הוא שני פריטים, עותק מוצפן להפצה ועותק נקי נפרד לארכיון

קצה נוסף נשאר בלתי נראה עד שהוא נושך: מסמכי PDF 1.5+ שמשתמשים בזרם cross-reference טהור ואינם נושאים את מילת המפתח trailer. המזריק קורא את ה-trailer כדי למצוא את /Info המקורי ולצרף את העדכון המצטבר שלו, והוא חייב לקבל את הצורה של xref-stream, אחרת מסמך כזה היה מועתק הלאה כשהסמנים נופלים בשקט. ISO 32000-1 §7.5.6 מתיר במפורש לעדכון מצטבר בסגנון קלאסי לבוא אחרי מסמך xref-stream, עם /Prev שמצביע על ההיסט של xref-stream, וזו בדיוק המסגרת שהמזריק פולט. FPDF_SaveAsCopy של PDFium עצמו תמיד כותב trailer קלאסי, כך שבצינור הרגיל המזריק לעולם לא פוגש מקור xref-stream טהור, אבל נתיב הקריאה מטפל בכך עבור מסמכים שמגיעים ממקום אחר

אימות לפני שסומכים על הטענה

הספרייה מספקת בודק ברמת הבתים, TPdf.ValidatePdfA, שמחזיר TPdfAValidationResult. השדה Conformance שלו מדווח על הרמה שזוהתה ו-Issues הוא סט של ערכי TPdfAValidationIssue; השיטה הנוחה IsCompliant מחזירה true רק כשזוהתה רמה אמיתית וסט הבעיות ריק. הריצו אותו כשער ראשון ומהיר באצווה

var
  Pdf: TPdf;
  Res: TPdfAValidationResult;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.LoadFromFile('invoice_archive.pdf');
    Res := Pdf.ValidatePdfA;
    if Res.IsCompliant then
      Writeln('Conformant: detected level ', Ord(Res.Conformance))
    else
      Writeln('Issues found: ', SizeOf(Res.Issues), ' flags set');
  finally
    Pdf.Free;
  end;
end;

צריך להיות כנים לגבי מה זה נותן לכם. בודק ברמת הבתים מזהה בעיות מבניות, כמו OutputIntent חסר, פעולה אסורה, /Encrypt נוכח, או שקיפות במקום שבו חלק 1 אוסר עליה, ברמת ביטחון גבוהה, וזיהוי הטמעת גופנים משתמש בהיוריסטיקה של ספירה שמדווחת במכוון רק על סימן ברור בעל ביטחון גבוה במקום לרדוף אחר כיסוי לכל גליף. מה שהוא לא עושה הוא ניתוח אופרטורים של content stream, דבר שהיה דורש מנתח תוכן מלא וממילא נמצא מחוץ לתחום לפי התכנון. בשער שחרור, חברו את הבודק המובנה ל-veraPDF: הבודק מיידי ורץ בכל מקום בלי DLL, ו-veraPDF הוא הסמכות. חיבור הזוג הזה להרצה באצווה הוא הנושא של CLI לדוח בדיקת קדם-טיסה באצווה, ושם שייכת הוולידציה הזו בזרימת ארכיון אמיתית

ה-API-ים SaveAsPdfA, InjectPdfAMarkers ו-ValidatePdfA שמוצגים כאן מגיעים עם PDFium Component עבור Delphi, C++Builder ו-Lazarus/FPC. דף המוצר מקשר אל מסמכי ה-API המלאים, כולל מניין התאימות המלא ורשומת האפשרויות שמאחורי הדוגמאות האלה