לפני v3.539.30, ה-TPDFlib.ImportAnnotationsFromFDFString ב-losLab PDF Library החזיר את מספר רשומות ההערות של ה-FDF שפרסר בזמן שלא הוסיף אף אחת מהן למסמך: כל רשומה נמנתה, כל רשומה נזרקה. מאז v3.539.30 מייבא ה-FDF קורא מפתחות בכל סדר, מפרסר את ה-/Rect נכון ובאופן בלתי תלוי ב-locale, וה-exporter הנלווה כותב את ה-/Rect האמיתי של ההערה, כך שייצוא, ייבוא וייצוא שני מניבים FDF זהה בייט-בייט. שאר הרשומה הזאת מסבירה איך offset התחלה שגוי אחד הניב כישלון שקט מושלם, אילו שלושה פגמים נוספים הסתתרו מאחוריו, ואיך לבדוק ייבוא בעצמכם במקום לסמוך על ערך ההחזרה
התרחיש פשוט. סוקר מסמן חוזה, ההערות נוסעות כקובץ FDF (Acrobat קורא לזה Export Comments), והשירות שלכם בדלפי ממזג אותן לעותק נקי עם ImportAnnotationsFromFDF. הקריאה מחזירה 7, ה-log אומר "7 comments imported", המשימה מתירה ירוק, וה-PDF בפלט לא מכיל אף הערה. שום חריגה, שום אזהרה, והמספר נראה סביר כי הוא היה המניין האמיתי של הרשומות בקובץ. זו הצורה הגרועה שבאג יכול לקחת: פונקציה שהאות היחידה שלה להצלחה הוא מונה שמחושב באופן בלתי תלוי בעבודה שהוא טוען לדווח עליה
למה ImportAnnotationsFromFDFString דיווח הצלחה ולא הוסיף כלום?
המייבא קרא כל /Subtype כמחרוזת ריקה, וה-helper שבונה את ההערה יוצא מוקדם על subtype ריק בזמן שהקורא מגדיל את התוצאה בכל זאת. מאתר המפתחות החזיר את המיקום מיד אחרי /Subtype, שהוא הרווח שלפני הערך. ה-ReadName התחיל באותו רווח ועצר בתו הרווח הראשון, ולכן עצר לפני שקרא משהו. AddAnnotationToPage מסרבת לבנות הערה בלי subtype, שזו בחירה הגנתית נכונה בפני עצמה, אבל היא הייתה procedure בלי ערך החזרה, וה-Inc(Result) ישב מחוצה לה. כל שומר היה סביר בפני עצמו; יחד הם הפכו את "שום דבר לא עבד" ל-"הכול עבד". התיקון גורם ל-ReadName לדלג על רווחים, לדרוש את ה-/ הפותח של אובייקט שם של PDF, ולעצור בכל delimiter, כולל [, ( ו-), כך שגם /Subtype/Text וגם /Subtype /Text מניבים Text
ערך ההחזרה הצריך טיפול גם אחרי התיקון הזה. עד v3.539.39, ה-ImportAnnotationsFromFDFString עדיין הגדיל את התוצאה עבור כל מילון תקין במערך ה-/Annots, כולל רשומות שה-/Page המבוסס-אפס שלהן היה מחוץ לטווח או שה-/Subtype שלהן חסר — שניהם מדולגים. מאז PDFlibPas v3.539.40, ה-ImportAnnotationsFromFDFString וה-ImportAnnotationsFromFDF מחזירים את מספר ההערות שנוספו בפועל, כמו ייבוא ה-XFDF: ה-helper של ה-FDF, AddAnnotationToPage, מחזיר עכשיו Boolean והמונה זז רק בהצלחה. מדידת המסמך נשארת הבדיקה החזקה יותר, כי היא תקפה גם על גרסאות ישנות, ולכן הסקיצה למטה משווה את ה-AnnotationCount בכל עמוד לפני ואחרי הייבוא
function TotalAnnotations(Lib: TPDFlib): Integer;
var
Page, Saved: Integer;
begin
Result := 0;
Saved := Lib.SelectedPage;
for Page := 1 to Lib.PageCount do
if Lib.SelectPage(Page) = 1 then
Inc(Result, Lib.AnnotationCount); // לכל עמוד נבחר, widgets כלולים
Lib.SelectPage(Saved);
end;
var
Lib: TPDFlib;
Before, Reported, Added: Integer;
begin
Lib := TPDFlib.Create;
try
Lib.LoadFromFile('contract.pdf', '');
Before := TotalAnnotations(Lib);
Reported := Lib.ImportAnnotationsFromFDF('review-comments.fdf');
Added := TotalAnnotations(Lib) - Before;
if Added <> Reported then // שווים מאז v3.539.40
Writeln(Format('Importer reported %d, %d landed on a page', [Reported, Added]));
Lib.SaveToFile('contract-reviewed.pdf');
finally
Lib.Free;
end;
end;
שלושה פגמים נוספים מאחורי הראשון
תיקון ה-subtype לבדו היה חושף שלושה באגים נוספים באותה פונקציה, שכל אחד מהם היה בלתי נראה רק כי אף הערה לא הגיעה לעמוד. ראשית, ה-ReadNumber קיבל את המיקום שלו כפרמטר ערך, כך שקריאת ארבעת מספרי ה-/Rect ברצף קראה את אותה נקודה ארבע פעמים, והוא לא דילג על ה-[ הפותח, כך שבפועל הוא לא קרא כלום בכלל. שנית, ה-FindKey חלק cursor אחד נע-קדימה בין כל החיפושים. ה-exporter כותב /Subtype, /Rect, /Page, /Contents, /T, /Subj, אבל המייבא חיפש בסדר /Subtype, /Contents, /T, /Subj, /Page, /Rect; ברגע שה-cursor עבר את ה-/Contents, החיפוש אחר /Page ו-/Rect רץ מעבר לרשומה הנוכחית ואו שלא מצא כלום או שהתאים את המפתחות של ההערה הבאה. הספרייה לא הצליחה לקרוא את הפלט של עצמה. שלישית, המספרים עברו דרך PLStrToFloat, שהולך אחר מפריד העשרונות של המערכת. ISO 32000-1 §12.7.7 מגדיר FDF כתחביר אובייקטים של PDF, ומפתחות מילון ב-PDF הם חסרי סדר (§7.3.7), ולכן כל פרסר FDF שמניח סדר מפתחות שגוי מיסודו, בכל כלי שיהיה שהפיק את הקובץ
המייבא המתוקן מגביל כל רשומה קודם. ה-FindDictEnd הולך מה-<< הפותח אל ה->> התואם לו, עוקב אחר מילונים מקוננים ומדלג על גופי מחרוזות literal עם בריחות ה-backslash שלהן, כך ש->> בתוך הערה כמו (see section >> 4) לא יכול לסיים את הרשומה מוקדם. כל חיפוש מפתח מתחיל אז בתחילת הרשומה עצמה ומוגבל לסופה, מה שהופך את סדר המפתחות ללא רלוונטי ומונע מהערה אחת ללוות את ה-/Page של אחרת. התאמת המפתח גם מקבלת delimiter ישירות אחרי השם, כי /Contents(Hi) תקף בדיוק כמו /Contents (Hi), בזמן שכלל גבול-המילה מונע מ-/Subj להתאים את תחילת ה-/Subtype ומ-/T להתאים את ה-/Type. ה-ReadNumber מקבל עכשיו את המיקום שלו כפרמטר var, מדלג על רווחים ועל [, ומפרסר עם PLTryStrToFloatInvariant, שנכשל ברכות על token משובש במקום להעלות חריגה. אם אחד מארבעת מספרי המלבן נכשל, כל ארבעתם חוזרים לאפס במקום להניב מלבן חצי-נקרא
למה הקפות FDF הזיזו כל הערה בגובה שלה עצמה?
ה-exporter הישן כתב מלבן במודל קואורדינטות שגוי. ה-/Rect של הערה הוא [llx lly urx ury] במרחב משתמש ברירת המחדל (ISO 32000-1 §12.5.2, עם מלבנים מוגדרים ב-§7.9.5), וה-FDF נושא את אותו מערך. ה-ExportAnnotationsToFDFString, לעומת זאת, קרא ל-GetAnnotRectEx, שמדווח Left, Top, Width ו-Height בקואורדינטות הציור של הספרייה, המרחב ש-SetOrigin שולט בו, וסידר אותם כ-[L T L+W T+H]. המייבא, ברגע שעבד, כתב את ארבעת הערכים האלה חזרה כמות שהם כמלבן PDF, ולכן הקצה העליון נחת היכן שהפינה השמאלית התחתונה הייתה אמורה לשבת וכל הקפה (round trip) הזיזה את ההערה מעלה בגובה שלה עצמה. ה-exporter מעתיק עכשיו את מספרי ה-/Rect של ההערה עצמה — שלוש ספרות עשרוניות, מפריד נקודה, בלי exponent — ונופל חזרה אל המלבן המחושב רק כשהמערך המאוחסן חסר או שאינו ארבעה מספרים
בדיקת הרגרסיה שקובעת את זה שווה העתקה, כי היא מצהירה על המסמך ועל ייצוא שני, לא על ערך ההחזרה של המייבא. שימו לב למניין הצפוי 2: ה-AddNoteAnnotation בונה הערת Text ועוד את ה-Popup שלה, ושניהם נוסעים. הבדיקה גם מריצה את הייצוא והייבוא תחת מפריד עשרונות פסיק, ושם חיה החצי השני של הסיפור הזה
var
Source, Target: TPDFlib;
FDF: AnsiString;
OldSep: Char;
begin
Source := TPDFlib.Create;
Target := TPDFlib.Create;
try
Source.NewPages(1); // עכשיו שני עמודים
Source.SelectPage(2);
Source.AddNoteAnnotation(50.5, 60.25, 0, 80, 80, 120, 60,
'Reviewer', 'Check this', 0.25, 0.5, 0.75, 0);
Target.NewPages(1);
OldSep := FormatSettings.DecimalSeparator;
FormatSettings.DecimalSeparator := ','; // מדמה שולחן עבודה גרמני או צרפתי
try
FDF := Source.ExportAnnotationsToFDFString; // עדיין כותב /Rect [50.5 ...
Target.ImportAnnotationsFromFDFString(FDF);
finally
FormatSettings.DecimalSeparator := OldSep;
end;
Target.SelectPage(2);
Assert(Target.AnnotationCount = 2); // ההערה וה-popup שלה
Assert(Target.GetAnnotType(1) = 'Text');
Assert(Target.ExportAnnotationsToFDFString = Source.ExportAnnotationsToFDFString);
finally
Target.Free;
Source.Free;
end;
end;
כדאי להיות ברורים לגבי מה שנתיב ה-FDF נושא. המייבא בונה כל רשומה מחדש כמילון עם /Type, /Subtype, /Rect, /Contents, /T ו-/Subj; צבע, דגלים, סגנון גבול, קישורי popup וזרמי מראה אינם חלק מהמסלול הזה, וה-exporter מדלג על הערות Widget כי שדות טופס שייכים למתודות ה-form-data. המפה הרחבה של איזה נתון נוסע דרך איזו מתודה נמצאת בסקירה של חילופי נתוני טפסים FDF, XFDF ו-XFA, ואם אתם צריכים לבחון מה הגיע בפועל, הקוראים לפי אינדקס כמו GetAnnotType, GetAnnotTitle ו-GetAnnotContentsEx מכוסים באינטרוספקציה של outline, הערות ופעולות
איך קוראים קבצי FDF ו-XFDF עם עשרון פסיק מייצוא ישן?
עבור FDF התשובה חד-משמעית: פסיק אינו delimiter בתחביר PDF, ולכן token של מספר שמכיל בדיוק פסיק אחד ובלי נקודה יכול להיות רק עשרון שנכתב על מכונה עם locale של פסיק. גרסאות קודמות כן כתבו קבצים כאלה, למשל /Rect [10,500 20,250 40,750 60,125], וה-ReadNumber החדש הופך את הפסיק הבודד הזה לנקודה לפני הפרסור. token עם שני פסיקים, או פסיק ונקודה, נדחה במקום לנחש. הקורא גם לא צורך כתיב exponent, מה שתואם את ISO 32000-1 §7.3.3: מספרי PDF לעולם לא משתמשים בו
XFDF מסובך יותר, כי בתכונות XML הפסיק הוא המפריד. XFDF תקני (ISO 19444-1) כותב rect="50.5,80.25,70.75,100.125" ו-dashes="4,2", בזמן ש-v3.539.28 ומוקדם יותר, על מערכת עם locale של פסיק, כתבו rect="50,500 80,250 70,750 100,125" ו-opacity="0,600", וגם נכשלו עם EConvertError בקריאת opacity="0.6" תקני. מאז v3.539.29 שני הכיוונים אינווריאנטיים, והצורה הישנה מזוהה על ידי XFDFNormalizeLegacyDecimals רק כשהתכונה מתפצלת על רווחים לבדיוק מספר ה-token-ים הצפוי (ארבעה עבור rect, אחד עבור opacity ו-width) וכל token בצורת ספרות-פסיק-ספרות. rect תקני לעולם לא תואם: הוא או token אחד עם שלושה פסיקים או token-ים שנגמרים בפסיק. את ה-dashes משאירים בכוונה לבד, כי 4,2 יכול להיות שני אורכי קו מקווקו או 4.2 ישן, ושום כלל לא יכול להפריד ביניהם
const
// מפתחות מחוץ לסדר ה-exporter, ועוד עשרוני-פסיק מייצוא ישן של סביבת פסיק
LegacyFDF: AnsiString = '%FDF-1.2'#10'1 0 obj'#10'<< /FDF << /Annots ['#10 +
'<< /Rect [10,500 20,250 40,750 60,125] /Page 0 /Contents (First) ' +
'/Subtype /Text /T (Alpha) /Type /Annot >>'#10 +
'] >> >>'#10'endobj'#10'trailer'#10'<< /Root 1 0 R >>'#10'%%EOF'#10;
var
Lib: TPDFlib;
begin
Lib := TPDFlib.Create; // מסמך טרי יש בו עמוד אחד
try
Lib.ImportAnnotationsFromFDFString(LegacyFDF);
Assert(Lib.AnnotationCount = 1);
Assert(Lib.GetAnnotTitle(1) = 'Alpha');
// מיוצא מחדש כ-XFDF עם עשרוני נקודה: rect="10.500 20.250 40.750 60.125"
Writeln(Lib.ExportAnnotationsToXFDFString);
finally
Lib.Free;
end;
end;
מה באמת כדאי שבדיקת ייבוא הערות תצהיר?
בדיקת ייבוא שימושית מצהירה על מצב מסמך היעד, ולעולם לא רק על מה שהמייבא אומר על עצמו. שום דבר בסוויטת הבדיקות לא בדק AnnotationCount אחרי ייבוא FDF, וערך ההחזרה, המספר היחיד שמישהו הביט בו, היה המספר היחיד שהבאג השאיר שלם. שלוש הצהרות היו תופסות כל פגם שתואר כאן: מניין ההערות בעמוד הצפוי, שדה אחד שנקרא חזרה דרך GetAnnotType או GetAnnotContentsEx, וייצוא שני שמושווה בייט-בייט מול הראשון. אותה משמעת חלה על כל API שכותב מחדש מבנה מסמך בהמונים, כולל איחוד השדות המתואר במיזוג שדות טופס כפולים: בדקו את העץ המתקבל, לא סך מוחזר. מתודות ההערות של FDF ו-XFDF, עם גרסאות הקובץ והמחרוזת שלהן, מגיעות בlosLab PDF Library for Delphi and C++Builder, ו-v3.539.30 ומעלה היא הגרסה לרוץ איתה אם הערות חייבות לשרוד את הדרך, v3.539.40 ומעלה אם המניין המוחזר חייב להתאים למה שנוסף