מאמר טכני

קבצים משויכים של PDF/A-3 ו-AFRelationship בדלפי

כדי לצרף קובץ מקור אל מסמך PDF/A-3 מדלפי, PDFium Component כותבת שרשרת associated-file של PDF 2.0: stream של קובץ מוטמע עם ‎/Subtype מסוג MIME, מפרט קובץ שנושא ‎/AFRelationship, ומערך ‎/AF שתלוי על הקטלוג או על עמוד. InjectAssociateFiles ו-‎TPdf.SaveAsWithAssociateFiles בונות את השרשרת הזאת ב-update אינקרמנטלי אחד, והחל מ-v3.121.2 סוג ה-MIME נכתב כשם PDF אחד, מטופל כהלכה ב-escaping. שאר הפוסט עוסק במה validator בודק, בבאג של תו אחד ששבר את ‎text/plain, ובמקומות שבהם מהדורות ישנות עשו בשקט משהו אחר ממה שביקשתם

מה קובץ משויך של PDF/A-3 צריך באמת?

קובץ מצורף של PDF/A-3 עובר אימות רק כששלושה אובייקטים מסכימים זה עם זה: ה-stream של הקובץ המוטמע מכריז ‎/Type /EmbeddedFile בתוספת ‎/Subtype של MIME, מילון מפרט הקובץ (ISO 32000-2 §7.11.3) נושא ‎/F, ‎/UF, ‎/EF ו-‎/AFRelationship, ומשהו במסמך מפנה אל מפרט הקובץ הזה דרך מערך ‎/AF (ISO 32000-2 §14.13). הטמעה רגילה דרך עץ ה-‎/Names /EmbeddedFiles, שזה מה ש-TPdf.CreateAttachment עושה, לא מציבה את שדות הקישור בכלל. fixture אימות ה-PDF/A-3b הפנימי של PDFium Component הופך את התלות למוחשית: שינוי רק של המפתח ‎/AFRelationship והקובץ נכשל בדיוק בכלל אחד מסעיף 6.8 של ISO 19005-3; השמטה רק של ‎/Subtype ה-MIME וכלל 6.8 אחר נכשל; הכנסת אותו קובץ מצורף אל מועמד ל-PDF/A-1b והוא נדחה על הסף, כי PDF/A-1 אוסרת על קבצים מוטמעים בלי קשר לכמה ה-metadata מסודרת

שרשרת שלושת האובייקטים של קובץ משויך של PDF A-3 ב-PDFium Component: stream של EmbeddedFile עם Subtype של MIME כמו application xml, מפרט קובץ עם F, UF, EF ו-AFRelationship מוצב ל-Data, ומערך AF עבורו מהקטלוג או מעמוד — שלושת האובייקטים ש-validator בודק לפני שסעיף 6.8 של ISO 19005-3 עובר
ה-stream, מפרט הקובץ ומערך ה-AF חייבים להסכים; ההטמעה הפשוטה בעץ השמות של TPdf.CreateAttachment לא מציבה אף שדה קישור וגם לא תציב

ערך הקשר הוא החלק שאנשים נוטים לנחש. ‎TPdfAFRelationship ב-‎FPdfAssocFiles ממפה איבר enum אחד אל כל אסימון שם שה-injector יכול לפלוט, ורק חמשת הראשונים שייכים לתת-הקבוצה ש-ISO 19005-3 מכיר:

  • ‎afSource → ‎/Source: המקור המקורי שממנו ה-PDF נוצר, למשל קובץ עיבוד תמלילים או גיליון אלקטרוני
  • ‎afData → ‎/Data: נתונים קריאים למכונה שמהם נגזר התוכן הנראה או שהתוכן הנראה מייצג
  • ‎afAlternative → ‎/Alternative, ‎afSupplement → ‎/Supplement, ‎afUnspecified → ‎/Unspecified
  • ‎afEncryptedPayload, ‎afFormData, ‎afTemplate: תוספות של PDF 2.0 שנופלות מחוץ לתת-הקבוצה של PDF/A-3, ולכן מרחיקים אותן מפלט ארכיוני

למה ‎/Subtype /text/plain שבר את האימות?

באג ה-MIME היה שגיאת אסימון, לא חור תאימות: לפני v3.121.2 ה-injector שרשר את המחרוזת של הקורא ישר אחרי לוכסן, והפיק ‎/Subtype /text/plain. בתחביר ה-PDF הלוכסן השני פותח אובייקט שם חדש (ISO 32000-1 §7.3.5), כך שמילון ה-stream פתאום החזיק את המפתח ‎/Subtype, את השם ‎/text, ושם עודף תלוש ‎/plain שאיזן לרעה את זוגות המפתח-ערך. validator עצמאי של PDF/A דחה את הקובץ עוד בפרסור מילון ה-‎EmbeddedFile, לפני שהגיע בכלל אל כלל PDF/A, ולכן הכישלון נראה כמו קובץ הרוס ולא כמו מאפיין קובץ מצורף חסר

התיקון מעביר את ערך ה-MIME דרך EscapePdfName, שפולט ‎/text#2Fplain: שם אחד שהערך המפוענח שלו הוא ‎text/plain. ה-escaping מכוון רחב יותר מהלוכסן. כל בייט בגובה 32 או מתחתיו (רווח, טאב, CR, LF), כל בייט בגובה 127 ומעלה, המפרידים ‎()<>[]{}/% ותו המילוט ‎# עצמו הופכים ל-‎#XX. escaping של הלוכסן בלבד היה משאיר חור אחר: מחרוזת MIME שמכילה ‎>> או רווח לבן יכלה לסגור את המילון מוקדם או להזריק מפתחות נוספים, ולכן בדיקת הרגרסיה מזרימה ערך עוין עם כל מפריד בתוספת טאב, LF ו-CR ובודקת את הפלט המקודד המדויק

למה תת-הסוג text slash plain של ה-MIME שבר את פרסור ה-PDF A-3 ב-PDFium Component: שרשור הערך אחרי לוכסן הפיק שני אובייקטי שם, /text כערך בתוספת /plain תלוש שאיזן לרעה את מילון ה-EmbeddedFile, והתיקון של v3.121.2 מעביר את הערך דרך EscapePdfName כך ש-/text#2Fplain הוא שם אחד שמתפענח ל-text/plain
הכישלון נראה כמו קובץ הרוס כי קרה במפרסר, לפני כל כלל PDF/A; השם המטופל ב-escaping שומר על איזון הזוגות ועל ה-validator קורא
// מה ה-injector כותב עבור MIMEType = 'text/plain'
//   before v3.121.2:  /Type /EmbeddedFile /Subtype /text/plain     (two names)
//   v3.121.2:         /Type /EmbeddedFile /Subtype /text#2Fplain   (one name)
//
// הקוראים תמיד מעבירים את ערך ה-MIME הרגיל. escaping מוקדם משלכם
// מקודד כפול את ה-'#', מה שהופך 'text#2Fplain' ל-'text#232Fplain'
Options.Files[0].MIMEType := 'text/plain';

בניית קובץ PDF/A-3 עם InjectAssociateFiles

עבור פלט PDF/A-3, מייצרים את מסמך הבסיס התואם עם TPdf.SaveAsPdfAToStream ואז קוראים ל-InjectAssociateFiles על אותו stream; צינור עבודה דו-שלבי זה הוא בדיוק מה ש-fixture האימות מריץ לפני שהוא מאשר PDF/A-3b. TPdf.SaveAsWithAssociateFiles הוא העטיפה הנוחה, אבל הוא שומר דרך הנתיב הרגיל של SaveAs עם saRemoveSecurity ולא דרך הכותב של PDF/A, ולכן הוא לא מוסיף את זיהוי ה-XMP ואת ה-output intent ש-PDF/A דורשת. שימו לב שסוגי הרקורדים יושבים ב-FPdfAssocFiles וב-FPdfPdfa, כך ששתי היחידות צריכות להיכלל בסעיף ה-uses שלכם. החל מ-v3.121.3, FileName ו-Description כבר לא חייבים להיות ASCII טהור: את /UF ו-/Desc כותבים כמחרוזות טקסט של PDF, ASCII ניתן להדפסה כלשונו וכל דבר אחר כ-UTF-16BE עם סימן סדר בייטים, בזמן ששם ה-/F המיושן תמיד ASCII ניתן להדפסה ונייד עם כל תו אחר שהוא מוחלף ב-_, כך שקוראים שמפענחים את /F עם קוד הדף שלהם יראו קו תחתון במקום ג'יבריש. build ישנים המירו את שלושתם דרך קוד הדף ANSI של המערכת בדלפי או כתבו בייטים גולמיים של UTF-8 ב-Free Pascal, ולכן השאירו שמות ASCII בלבד אם build ישנים חייבים להפיק את אותו פלט

uses
  System.SysUtils, System.Classes, System.IOUtils,
  PDFium, FPdfPdfa, FPdfAssocFiles;

procedure SaveWithSourceData(Pdf: TPdf; const XmlPath, OutPath: string);
var
  PdfAOptions: TPdfASaveOptions;
  Options: TAssocFilesOptions;
  Base: TMemoryStream;
  Output: TFileStream;
begin
  PdfAOptions := TPdfASaveOptions.Default;
  PdfAOptions.Conformance := pac3b;

  Options := TAssocFilesOptions.Default;      // TargetPage = 0: ‎/AF ברמת הקטלוג
  SetLength(Options.Files, 1);
  Options.Files[0].FileName := 'invoice-data.xml';
  Options.Files[0].Description := 'Structured invoice data';
  Options.Files[0].Content := TFile.ReadAllBytes(XmlPath);
  Options.Files[0].Relationship := afData;
  Options.Files[0].MIMEType := 'application/xml';  // נכתב כ-‎/application#2Fxml

  Base := TMemoryStream.Create;
  try
    if not Pdf.SaveAsPdfAToStream(Base, PdfAOptions) then
      raise Exception.Create('PDF/A-3 base save failed');
    Output := TFileStream.Create(OutPath, fmCreate);
    try
      InjectAssociateFiles(Base, Output, Options);  // מקפיץ את Base לאחור; מעלה EPdfAssocFilesError בכישלון
    finally
      Output.Free;
    end;
  finally
    Base.Free;
  end;
end;

קטלוג או עמוד: לאן מגיע מערך ה-‎/AF?

‏TAssocFilesOptions.TargetPage מכריע מי הבעלים של מערך ה-‎/AF: 0 מצרף אותו אל הקטלוג כקישור ברמת המסמך, ו-1..N מצרף אותו אל מילון העמוד ההוא, מבוסס-1. ה-injector מצרף הכול כ-update אינקרמנטלי יחיד בפריסה קבועה (ה-streams המוטמעים, ואז מפרטי הקבצים, ואז מערך ה-‎/AF, ואז אובייקט קטלוג או עמוד שנכתב מחדש), כך שאובייקטים קיימים שומרים על ההיסטים שלהם ושום דבר לא נדחס מחדש. רשומת ‎/AF קודמת על מילון היעד מוחלפת ולא ממוזגת, מה שהופך שמירה חוזרת ל-idempotent אבל גם אומר שקריאה שנייה עם רשימת קבצים אחרת היא זו שמנצחת. שתי התנהגויות היו ראויות בעבר להגנה בקוד שלכם, ושתיהן השתנו. לפני v3.122.0 ערך TargetPage מחוץ לטווח לא נכשל; הוא נפל בחזרה אל הקטלוג, כך שטעות הקלדה הפכה קישור ברמת עמוד לקישור ברמת מסמך בלי שום אות. החל מ-v3.122.0, SaveAsWithAssociateFiles ו-SaveAsWithAssociateFilesToStream מעלות EPdfError כש-TargetPage מחוץ ל-0..PageCount, ו-InjectAssociateFiles מעלה את ה-EPdfAssocFilesError החדש עבור TargetPage שלילי או כזה שאינו מציין עמוד קיים, ומשאיר את stream היעד ללא שינוי. לפני v3.121.4 החיפוש אחר העמוד סרק את הבייטים השמורים אחר מילוני ‎/Type /Page בסדר הקובץ, מה שעלול היה לצרף את הקובץ אל עמוד אחר ברגע שאובייקטי עמודים נשמרו בסדר שונה מזה שבו הם מוצגים, למשל אחרי סידור או הוספה של עמודים; החל מ-v3.121.4 TargetPage מציין את העמוד במיקום הזה בסדר עמודי המסמך

לאן מגיע מערך ה-AF ב-PDFium Component: TargetPage אפס מצרף אותו אל הקטלוג, עמודים 1 עד N מצרפים אותו אל מילון העמוד, וערך מחוץ לטווח, שלפני v3.122.0 נפל בשקט בחזרה אל הקטלוג, מעלה עכשיו חריגה, בזמן שה-injector מצרף הכול כ-update אינקרמנטלי אחד בפריסה קבועה ששומרת על היסטים קיימים ומחליפה כל רשומת AF קודמת
לפני v3.122.0 ערך TargetPage מחוץ לטווח הפך בשקט לקישור ברמת המסמך; המהדורות הנוכחיות מעלות חריגה במקום, וקריאה שנייה עם רשימת קבצים אחרת עדיין מנצחת
procedure AttachChartSource(Pdf: TPdf; PageNumber: Integer;
  const CsvBytes: TBytes; const OutPath: string);
var
  Options: TAssocFilesOptions;
begin
  // החל מ-v3.122.0 ערך TargetPage מחוץ לטווח מעלה EPdfError (build ישנים
  // נפלו בשקט בחזרה אל ‎/AF ברמת הקטלוג); בדיקה מראש מזהה את העמוד
  if (PageNumber < 1) or (PageNumber > Pdf.PageCount) then
    raise EArgumentOutOfRangeException.CreateFmt('No page %d', [PageNumber]);

  Options := TAssocFilesOptions.Default;
  Options.TargetPage := PageNumber;
  SetLength(Options.Files, 1);
  Options.Files[0].FileName := 'chart-data.csv';
  Options.Files[0].Content := CsvBytes;
  Options.Files[0].Relationship := afSource;
  Options.Files[0].MIMEType := 'text/csv';

  if not Pdf.SaveAsWithAssociateFiles(OutPath, Options) then
    raise Exception.Create('Associated-file save failed');
end;

איך קוראים AFRelationship בחזרה באופן אמין?

‏TPdf.AttachmentRelationship[Index] מחזיר את שם ה-‎/AFRelationship של קובץ מצורף דרך הייצוא הנייטיבי FPDFAttachment_GetAFRelationship, אבל למחרוזת ריקה יש שתי משמעויות אפשריות, ולכן קוראים קודם ל-AttachmentRelationshipFeaturesAvailable. ה-binding נטען בסבלנות: כשה-DLL של PDFium חסר את הייצוא הזה, כל קשר נקרא ריק, מה שבלתי נבדל ממפרט קובץ שפשוט אין לו ‎/AFRelationship. המאפיין גם חולק את האינדקס שלו עם AttachmentCount, שסופר רשומות בעץ ה-‎/Names /EmbeddedFiles. ה-injector כותב רק את שרשרת ה-‎/AF ולא מוסיף רשומת עץ-שמות, כך שקובץ שצורף דרך InjectAssociateFiles נמצא מחוץ לאינדקס הזה; כדי לאשר את השרשרת המוזרקת, בוחנים את הבייטים השמורים או מריצים validator של PDF/A. הפנימיות של עץ השמות הזה מכוסה ב-עבודה עם קבצים מצורפים של PDF בדלפי עם PDFium Component

procedure ReportRelationships(const FileName: string);
var
  Pdf: TPdf;
  I: Integer;
  Rel: string;
begin
  if not AttachmentRelationshipFeaturesAvailable then
  begin
    Writeln('This PDFium build cannot report /AFRelationship');
    Exit;  // תשובה ריקה הייתה דו-משמעית, ולכן לא שואלים
  end;

  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := FileName;
    Pdf.Active := True;
    for I := 0 to Pdf.AttachmentCount - 1 do
    begin
      Rel := Pdf.AttachmentRelationship[I];
      if Rel = '' then
        Rel := '(no /AFRelationship)';
      Writeln(Pdf.AttachmentName[I], ': ', Rel);
    end;
  finally
    Pdf.Free;
  end;
end;

מה SaveAsWithAssociateFiles לא מבטיח?

‏TPdf.SaveAsWithAssociateFiles מבטיח את המעטפת של פורמט הקובץ ושהקבצים המבוקשים אכן הוזרקו, לא תאימות. חלק ההזרקה חדש: לפני v3.122.0, כשלבייטים השמורים לא היה trailer קריא או שמילון הקטלוג לא היה ניתן לאיתור, InjectAssociateFiles העתיק את הקלט ללא שינוי והשיטה עדיין החזירה True. החל מ-v3.122.0 InjectAssociateFiles מעלה EPdfAssocFilesError במקרים האלה לפני כתיבת מאומה, SaveAsWithAssociateFiles מחזיר False, ומכיוון שהוא בונה עכשיו את הפלט המלא בחנות שמירה לפני פתיחת היעד, שמירה שנדחית או נכשלת כבר לא קוטעת קובץ קיים. מערך Files ריק עדיין מעתיק את המסמך ללא שינוי, בכוונה. גם תוכן המטען באחריותכם: ה-injector לא בודק שקובץ XML תקין במבנהו, שסוג ה-MIME תואם לבייטים, או שמסמך הבסיס הוא PDF/A בכלל. התייחסו אל הקובץ הסופי כאל לא מאומת עד ש-validator ראה אותו, אותה משמעת המתוארת ב-PDFium Component ותאימות ארכיון של PDF/A. אם אתם גם מפרסרים בעצמכם מילונים נכנסים, כללי השמות של ‎#XX תקפים גם בכיוון ההפוך, נושא שמכוסה ב-מלכודות אסימוני שמות בפרסור מילוני PDF

קבצים משויכים, פלט PDF/A, metadata של קבצים מצורפים ואימות מגיעים יחד באותו רכיב, כך שצינור העבודה שלמעלה רץ בלי ספריית PDF שנייה ב-build. ה-API המלא, הורדת ניסיון ואפשרויות הרישוי נמצאים ב-עמוד המוצר של PDFium Component