מאמר טכני

round trips של מראות הערות בדלפי עם PDFium

ב-PDFium Component לפני v3.121.1, קריאת הערה דרך TPdf.Annotation[] והצבת הרקורד חזרה עלולה הייתה להוסיף רשומות /R ו-/D ריקות אל מילון ה-‎/AP של המראה שלה, גם כשהמקור נשא רק את /N. validators של PDF/A דוחים את המילון הזה. מאז v3.121.1 ה-getter מדווח רק על מראה שהוא באמת קרא, כך ש-round trip ללא שינוי לא כותב דבר חדש. שווה להבין את הכשל הזה לעומק, כי הטריגר הרגיל הוא תיקון שנועד להפוך קובץ לתואם יותר, לא פחות

דיאגרמה של round trip ההערות של PDFium Component שבו הוספת afPrint דרך TPdf.Annotation[] ו-SetAnnotationData כותבת גם streams ריקים של /R ו-/D דרך FPDFAnnot_SetAP, והופכת מילון מראות נקי של PDF A לכזה ש-veraPDF דוחה, עד ש-v3.121.1 מדווחת רק על המראות שהיא באמת קראה
קריאת הערה וכתיבתה חזרה ללא שינוי הוסיפו בעבר streams מראה ריקים של rollover ו-down, וזה מה שמפיל PDF/A, לא דגל ה-Print שהתכוונת להוסיף

מה משתבש כשכותבים הערה חזרה ללא שינוי?

התשובה הקצרה: ההערה מקבלת streams מראה שמעולם לא היו לה, וקובץ שעבר אימות PDF/A לפני העריכה שלך נכשל בו אחריה. התרחיש הטיפוסי נראה כך. ארכיון של לקוח מגיע עם הערות square ו-text שחסרות את דגל ה-Print, PDF/A דורש שכל הערה תודפס, ולכן עוברים בלולאה על העמודים, מוסיפים afPrint ומציבים כל רקורד חזרה. שום דבר בקוד הזה לא נוגע במראות. הרקורד מ-TPdf.Annotation[] הוא TPdfAnnotation, ו-SetAnnotationData כותבת כל שדה שה-sentinel שלו מסוג Has* מוצב, וזה בדיוק איך זוגות ה-HasContents / ContentsText אמורים לעבוד. הבעיה הייתה שה-getter הציב את HasAppearanceRollover ו-HasAppearanceDown ל-True עם מחרוזות ריקות עבור מצבים שלא היו קיימים, וה-setter כתב בדיווקין שני streams ריקים:

procedure MarkAnnotationsPrintable(const FileName: string);
var
  Pdf: TPdf;
  PageNo, I: Integer;
  A: TPdfAnnotation;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := FileName;
    Pdf.Active := True;
    for PageNo := 1 to Pdf.PageCount do
    begin
      Pdf.PageNumber := PageNo;
      for I := 0 to Pdf.AnnotationCount - 1 do
      begin
        A := Pdf.Annotation[I];
        if not (afPrint in A.Flags) then
        begin
          A.Flags := A.Flags + [afPrint] - [afHidden, afInvisible, afNoView];
          // לפני v3.121.1 ההצבה הזאת גם כתבה /AP/R ריק ו
          // streams של /AP/D כשלהערת המקור היה רק /AP/N
          Pdf.Annotation[I] := A;
        end;
      end;
    end;
    Pdf.SaveAs(ChangeFileExt(FileName, '.printable.pdf'));
  finally
    Pdf.Free;
  end;
end;

ISO 32000-1 §12.5.5 מגדיר את מילון המראה עם שלוש רשומות: /N עבור המראה הרגילה, /R עבור rollover, ו-/D עבור down. ה-/R וה-/D אופציונליים, וכשהם נעדרים viewer חוזר אל /N. אבל stream /R ריק אינו נעדר. זה stream תקין שלא מצייר כלום, כך ש-viewer שמכבד מראות rollover מציג מלבן ריק ברגע שהמצביע זז מעל ההערה. PDF/A מחמיר עוד יותר: ISO 19005-1 (עם Corrigendum 2) ו-ISO 19005-2 / 19005-3 מרשים רק /N במילון מראה של הערה. veraPDF מדווח על הקובץ שעבר round trip תחת כלל 6.5.3-4 עבור PDF/A-1 וכלל 6.3.3-2 עבור PDF/A-2 ו-PDF/A-3, וה-TPdf.ValidatePdfA המובנה רושם אותו כ-pvaiAnnotationApDictViolation. העריכה שהוסיפה את דגל ה-Print כדי לעמוד בסעיף אחד של התקן שברה סעיף אחר

מילון המראה של הערה מ-ISO 32000-1 עם רשומות normal, rollover ו-down: PDFium מחזיר 2 בייטים גם עבור stream חסר וגם עבור ריק קיים, כך ששניהם נקראים חזרה כחסרי תוכן דרך TPdf, בזמן שרק בדיקה ברמת בייט כמו TPdf.ValidatePdfA מוצאת את ה-stream הריק ש-PDF/A אוסרת
‏/R חסר חוזר אל /N; /R ריק מצייר מלבן ריק ועדיין נכשל ב-PDF/A, ודרך הרקורד שניהם בלתי נבדלים

למה FPDFAnnot_GetAP מחזיר 2 עבור מראה חסרה?

‏PDFium לעולם לא מחזיר אפס מ-FPDFAnnot_GetAP, גם כשה-appearance stream המבוקש לא קיים. הפונקציה עוקבת אחרי תבנית שתי-הקריאות הרגילה של PDFium: מעבירים buffer nil כדי לקבל את הגודל הנדרש בבייטים, מקצים, ואז קוראים שוב כדי להעתיק טקסט UTF-16LE. הגודל תמיד כולל את ה-terminator של ה-UTF-16, כך ש-stream חסר מדווח 2 בייטים, מחרוזת ריקה בתוספת ה-terminator שלה. ה-getter שלפני v3.121.1 בדק ByteLength >= SizeOf(FPDF_WCHAR), בדיקה שכל קריאה עוברת, כך שכל שלושת דגלי ה-HasAppearance* חזרו True עבור כל הערה עם כל מראה שהיא. round trip דרך הרקורד ביקש אז מ-FPDFAnnot_SetAP לאחסן מחרוזת ריקה עבור כל מצב, ו-PDFium יצר את ה-stream שיחזיק אותה. בלי חריגה, בלי אזהרה, והעמוד הנראה נותר זהה, ולכן הפגם התגלה ב-fixture של veraPDF ולא ב-viewer

איך FPDFAnnot_GetAP מדווח על appearance stream חסר ב-PDFium: תבנית שתי הקריאות תמיד מחזירה לפחות שני בייטים עבור ה-terminator של ה-UTF-16, השער הישן שהשווה מול SizeOf(FPDF_WCHAR) העביר כל קריאה והציב את כל ה-sentinels של HasAppearance ל-true, והשער של v3.121.1 דורש יותר מה-terminator בתוספת מספר בייטים זוגי
שני בייטים הם המחרוזת הריקה המקודדת, לא הוכחה שמראה קיימת; ה-getter המתוקן מתייחס לכל דבר באורך ה-terminator או מתחת כאל חוסר תוכן והכתיבה חזרה נשארת שקטה

איך v3.121.1 מחליטה שמראה קיימת

‏ReadAppearance, ה-helper בתוך GetPageAnnotation שממלא את AppearanceNormal, AppearanceRollover ו-AppearanceDown, מתייחס עכשיו לתוצאה כאל תוכן רק כשהיא נושאת לפחות תו אחד מעבר ל-terminator. הקריאה הראשונה חייבת להחזיר יותר מ-SizeOf(FPDF_WCHAR) בייטים ומספר בייטים זוגי, כי אורך אי-זוגי אינו יכול להיות UTF-16. הקריאה השנייה, שמעתיקה בפועל את הטקסט, נבדקת שוב: אורך שמוחזר של 2 או פחות, או כזה שגדול מה-buffer שהוקצה, מאפס את HasValue ל-False ומשאיר את המחרוזת ריקה. בצד הכתיבה לא השתנה דבר. SetAnnotationData עדיין קוראת ל-FPDFAnnot_SetAP רק עבור מצבים שהדגל שלהם מסוג HasAppearance* הוא True, כך שרקורד שנקרא מהערה שיש לה רק /N כותב חזרה רק /N. fixture הרגרסיה מכסה את שני הכיוונים: הערת square עם מראה רגילה, שנקראת ונכתבת חזרה ללא שינוי, עוברת PDF/A-1b, PDF/A-2b ו-PDF/A-3b, בזמן שאותה הערה עם דגל ה-Print שהוסר נכשלת על כלל הדגל הצפוי ועל לא על שום דבר אחר

streams חסרים וריקים נראים זהים, ולכן ה-getter נשאר שמרן

ה-API הנייטיבי לא מסוגל להבחין בין appearance stream חסר לאחד שקיים אך ריק, ו-PDFium Component לא מעמידה פנים אחרת. שני המקרים מחזירים את אותם 2 בייטים מ-FPDFAnnot_GetAP, כך ששניהם נקראים חזרה כ-HasAppearanceRollover = False עם AppearanceRollover ריק. לזה שתי השלכות שכדאי לתכנן סביבן. ראשון, sentinel של False משמעו "לא נקרא תוכן, ולכן כתיבה חזרה תשאיר את המצב הזה לבדו", ולא "המפתח /R נעדר מהמילון". שני, הרקורד לא מסוגל לזהות stream ריק שכבר נמצא בקובץ: מסמך שניזוק על ידי build ישן או כלי אחר נקרא חזרה נקי, והצבת הרקורד חזרה לא מתקנת ולא מחמירה. כדי למצוא את הקבצים האלה צריך בדיקה ברמת בייט, ולשם כך נועדו TPdf.ValidatePdfA וזרימת אימות מקדים של PDF/A עם PDFium Component

איך מוחקים מראה במכוון?

מציבים את ה-sentinel במפורש ומעבירים מחרוזת ריקה; ה-setter כותב אותה. חסימת מחרוזות ריקות ב-SetAnnotationData הייתה התיקון הגס עבור הבאג הזה, אבל היא גם הייתה שוברת קוראים שמוחקים מראה במכוון, אותו חוזה ש-HasContents ו-HasAuthor מקיימים עבור טקסט. ולכן התיקון יושב כולו ב-getter, וה-setter ממשיך לכבד כל בקשה של הקורא:

// החלפת מראת ה-rollover, ואז מחיקתה שוב
A := Pdf.Annotation[0];
A.HasAppearanceRollover := True;
A.AppearanceRollover := 'q Q';
Pdf.Annotation[0] := A;

A := Pdf.Annotation[0];
// A.HasAppearanceRollover הוא True והטקסט עושה round trip כ-'q Q'
A.HasAppearanceRollover := True;   // מאשר מחדש את הכוונה במפורש
A.AppearanceRollover := '';        // כותב stream ריק במכוון
Pdf.Annotation[0] := A;

A := Pdf.Annotation[0];
// נקרא חזרה כ-HasAppearanceRollover = False עם מחרוזת ריקה:
// stream ריק וחסר אינם נבדלים כאן

זכרו ש-/R או /D שרוקנו במפורש עדיין נספרים כמפתח נוסף תחת כללי ה-PDF/A שצוטטו למעלה. אם היעד הוא פרופיל ארכיון, כתיבת /N לא ריק והשארת שני המצבים האחרים ללא מגע היא הצורה היחידה שעוברת אימות. כל זרימת עבודה שמזיזה הערות בין מסמכים, כמו ייצוא וייבוא XFDF עם PDFium Component, צריכה לדבוק באותו כלל: להעתיק את המצבים שהמקור באמת החזיק ולהשאיר את שאר ה-sentinels על False

תבנית קריאה-שינוי-כתיבה שנשארת בטוחה ל-PDF/A

שדרגו ל-v3.121.1 או מאוחר יותר, השאירו את sentinels המראה בדיוק כפי שה-getter החזיר אותם, ואמתו את הקובץ השמור לפני שאתם שולחים אותו. מכיוון ש-stream ריק מיושן נקרא חזרה כנעדר, שלב האימות חייב להביט במסמך המסודר ולא ברקורד, והוא זול מספיק כדי להריץ אחרי כל אצווה:

uses
  PDFium, FPdfPdfa;  // FPdfPdfa מכריזה על TPdfAValidationIssue

function AnnotationAppearancesAreClean(Pdf: TPdf): Boolean;
var
  Report: TPdfAValidationResult;
begin
  // מאמת את המסמך הטעון כרגע ב-Pdf, כולל עריכות
  // שנעשו דרך Pdf.Annotation[] מאז שנפתח
  Report := Pdf.ValidatePdfA;
  Result := not (pvaiAnnotationApDictViolation in Report.Issues);
end;

אותו משמעת חלה על כל פאנל שצובע מחדש או מעיר עמודים לסקירה, זרימת עבודה שמכוסה בבניית זרימת סקירת הערות בדלפי עם PDFium Component: הרקורד הוא תמונת מצב של מה שהמנוע הצליח לקרוא, ו-sentinel שלא הצבת בעצמך אמור לנסוע חזרה ללא שינוי. ה-API המלא להערות, preflight של PDF/A ומנוע ה-PDFium הנייטיבי מגיעים יחד בPDFium Component עבור Delphi, C++Builder ו-Lazarus