מאמר טכני

השטחת הערות PDF ללא זרם /AP ב-Delphi

HotPDF v2.743.0 משטיחה הערות PDF שאין להן זרם מראה /AP במקום לדלג עליהן בשקט. FlattenLoadedAnnotations מנתבת כעת widget ללא מראה דרך EnsureLoadedFieldAppearanceStream ובונה Form XObject לסימון ללא מראה מתוך המאפיינים של ההערה עצמה, כך שהערכים שהוקלדו לטופס מסוג /NeedAppearances שורדים בתוכן העמוד במקום להיעלם בזמן ההשטחה. הכשל שהכריח את השינוי נראה כמו no-op. לקוח מוסר טופס בקשה שמולא והודפס ל-PDF מדפדפן. טוענים אותו ב-HotPDF, קוראים ל-FlattenLoadedAnnotations, מקבלים חזרה 0, שומרים ומפיצים מסמך עם תיבות ריקות במקום שבו המבקש הקליד שם וסכום. שום דבר לא העלה חריגה ושום דבר לא נרשם. הערכים היו בקובץ כל הזמן, ישבו בערך /V של כל שדה, ומעבר ההשטחה חלף על פניהם מפני שאף אחד מה-widgets לא נשא זרם מראה

מדוע השטחת טופס שהודפס מדפדפן מאבדת את הערכים שהוקלדו

מפני שטופס /NeedAppearances שומר את הערך בלי לשמור תמונה של הערך. ISO 32000-1 12.7.2 מאפשר לטופס אינטראקטיבי להגדיר /NeedAppearances true במילון AcroForm, דבר שמורה ל-viewer לבנות את המשטח החזותי של כל שדה בזמן הפתיחה מתוך /V, /DA ו-/Q. יצרנים שמייצרים טפסים בזול — נתיבי הדפסה של דפדפנים, ממלאים בצד השרת וחלק מחזיתות הסריקה — מקבלים את ההצעה הזו וכותבים כלל לא /AP. השטחה, כפי שמוגדרת באלגוריתם המראה של ISO 32000-1 12.5.5, היא עבודת תעתיק: לוקחים את זרם המראה הרגיל של ההערה, ממפים את /BBox שלו ל-/Rect, מפעילים אותו מזרם תוכן העמוד באמצעות אופרטור Do ואז מוחקים את ההערה. בלי זרם מקור אין מה לתעתק. המימוש המקורי של HotPDF, מגרסה 2.386.0, התייחס לכך כאל "skip", החלטה שאפשר להגן עליה בנפרד אך הרסנית במצטבר: המסמכים שסביר ביותר שיזדקקו להשטחה הם אלה שסביר פחות שיישאו appearances. אותו חור בלע גם סימוני markup — Highlight מכלי review, Square ממעבר redline וחתימת Ink — בכל פעם שהיצרן הסתמך על ה-viewer שיצייר אותם

היכן HotPDF מחברת את הסינתזה ל-FlattenLoadedAnnotations

נקודת החיבור מאוחרת בכוונה: אחרי שה-lookup של המראה נכשל ולא לפניו. FlattenLoadedAnnotations עדיין מבקשת תחילה מ-GetLoadedAnnotationAppearanceStream את המראה הרגיל, והערה שכבר יש לה כזה נאפית בדיוק כפי שנאפתה ב-v2.386.0. רק תוצאת nil, על הערה עם /Rect שאינו מנוון וללא דגל hidden, נכנסת לנתיב הסינתזה. הסדר חשוב: מחבר מסמך שטרח לכתוב /AP מקבל את הבתים שלו, לא שחזור שלהם על ידי HotPDF

NStrm:= GetLoadedAnnotationAppearanceStream(Indices[PgI], AnI, aakNormal);
if (NStrm= nil) and (RR> RL) and (RT> RB) and ((FlagsValue and 2)= 0) then
begin
  if Subtype= 'Widget' then
  begin
    FieldIdx:= GetLoadedFormFieldIndexForAnnotation(Indices[PgI], AnI, WidgetIdx);
    if FieldIdx>= 0 then
      EnsureLoadedFieldAppearanceStream(FieldIdx);
    // שאל שוב: המחולל חיבר /AP /N ל-widget
    NStrm:= GetLoadedAnnotationAppearanceStream(Indices[PgI], AnI, aakNormal);
  end
  else
    NStrm:= SynthesizeMarkupAppearance(AnnotDict, Subtype, RL, RB, RR, RT);
end;

מכאן שתי משפחות ההערות מתפצלות. Widget נפתר חזרה לשדה שבבעלותו דרך GetLoadedFormFieldIndexForAnnotation ונמסר ל-EnsureLoadedFieldAppearanceStream, מחולל מראה השדות שקיים בספריית ה-PDF הזו ל-Delphi מאז v2.328.0. שימוש חוזר בו במקום כתיבת renderer שני לשדות הוא כל העניין — הוא כבר מכסה גופני Type0, עטיפת שורות, quadding, מצבי /AS של checkbox ו-radio וסיבוב /MK, אותו מנגנון שעומד מאחורי הוספת שדות AcroForm ל-PDF שכבר נטען. כל השאר עובר ל-synthesizer של markup. עבור ה-caller דבר אינו משתנה: אותה קריאת flatten בשורה אחת מחזירה כעת count שאינו אפס במסמכים שבעבר החזירו אפס

Doc:= THotPDF.Create(nil);
try
  Doc.LoadFromFile('needappearances-form.pdf');
  // v2.743.0: widgets ו-markup ללא AP עוברים סינתזה ואז נאפים
  Flattened:= Doc.FlattenLoadedAnnotations;          // כל העמודים וכל תתי-הסוגים
  // Flattened:= Doc.FlattenLoadedAnnotations('1-3', 'Highlight');
  if Flattened= 0 then
    raise Exception.Create('nothing was flattened');
  Doc.SaveLoadedDocument('flattened.pdf');
finally
  Doc.Free;
end;

מדוע QuadPoints ו-InkList נוחתים במקום הלא נכון

מפני שהקואורדינטות האלה נמצאות ב-page user space, בעוד שזרם המראה המסונתז מצייר במרחב /BBox משלו, ושני המקורות אינם אותה נקודה. טבלה 176 של ISO 32000-1 מגדירה /QuadPoints להערות text markup ב-user space ברירת מחדל, וטבלה 174 עושה אותו דבר עבור נקודות הקצה /L של הערת קו; גם /InkList פועל לפי אותה מוסכמה. HotPDF נותנת ל-form המסונתז /BBox של [0 0 W H], שמקורו יושב בפינה השמאלית-תחתונה של /Rect. לכן כל נקודה שנמשכת מתוך /QuadPoints, /L או /InkList חייבת להיות מוזזת ב-lower-left השלילי של /Rect לפני שהיא נכתבת לזרם התוכן. טועים בזה, ו-highlight על שורה 700 נקודות במעלה העמוד מצויר 700 נקודות מעל התיבה שלו, כלומר בפועל אינו מצויר בשום מקום. התיקון הוא חיסור אחד לכל קואורדינטה, והוא משתלב עם ה-cm שה-bake פולט לאחר מכן — המטריצה ממפה את /BBox חזרה ל-/Rect, ולכן שני השלבים מתבטלים לגיאומטריה מוחלטת נכונה

// נקודות הקצה של /L הן ב-page user space (ISO 32000-1 Table 174); מקור
// ה-BBox יושב ב-lower-left של /Rect, לכן מזיזים ב-(RL-, RB-)
X1:= ArrNum(LA, 0, 0)- RL;
Y1:= ArrNum(LA, 1, 0)- RB;
X2:= ArrNum(LA, 2, 0)- RL;
Y2:= ArrNum(LA, 3, 0)- RB;
StrokeOp:= ColorOp(DArr('C'), true);
if StrokeOp= '' then
  StrokeOp:= '0 G';
Result:= _FloatToStrR(BW)+ ' w '#10+ StrokeOp+ #10+
  _FloatToStrR(X1)+ ' '+ _FloatToStrR(Y1)+ ' m '+
  _FloatToStrR(X2)+ ' '+ _FloatToStrR(Y2)+ ' l S'#10;

מה מראה ה-markup המסונתז באמת מצייר

ה-synthesizer של markup קורא רק את מילון ההערה ולא דבר אחר, מה ששומר על פלט צפוי ועל כנות לגבי מה שאינו יכול לדעת. FreeText ו-Stamp מציירים את /Contents באמצעות הגופן והצבע שנשלפו מ-/DA, מיושרים לפי /Q ועם padding של 2 pt. Square ו-Circle מציירים re או קו מתאר Bezier בעל ארבע קשתות, עם stroke ב-/C ו-fill ב-/IC כאשר קיים, ברוחב של /BS /W. Line ו-Ink מעבירים stroke על הקודקודים שלהם. Highlight ממלא כל quad, בעוד Underline, StrikeOut ו-Squiggly מציירים קו בתחתית ה-quad, באמצעו או כ-zigzag של נקודה אחת. /CA שמתחת ל-1 הופך ל-ExtGState עם ערך ca, שמופנה כ-/GSA gs בראש הזרם

קידוד הטקסט נקבע מתוך ערך /DR /Font של ה-AcroForm ששמו מופיע ב-/DA. אם ה-/Subtype של הגופן הוא Type0, HotPDF כותבת את המחרוזת כ-literal hex של UTF-16BE עם BOM FEFF; אחרת היא כותבת מחרוזת literal עם escaping לסוגריים ול-backslashes ובתים שמעל 126 נכתבים באוקטלי. אופרטור Tf מ-/DA נפלט לפני BT, דבר שחוקי מפני שמצב הטקסט נשמר מעבר לגבול אובייקט הטקסט, וכך נחסך פירוק מחרוזת /DA. שתי מגבלות כדאי לציין במפורש. רוחב השורה לצורך wrapping ו-quadding מוערך לפי heuristic של חצי-em או em מלא ולא לפי metrics אמיתיים של הגופן, לכן יישור בגופן פרופורציונלי קרוב אך לא מדויק. ותת-סוג שאין בו דבר שניתן לסנתז — Popup, Link או Stamp שהתוכן היחיד שלו הוא שם icon — מחזיר nil ונשאר ללא שינוי, בדיוק כמו קודם

החלפת /Annots הזמנית שמענישה ניקוי מועיל

FlattenOneWidget, הנתיב לכל widget שבו משתמש FlattenLoadedFormFields, הוא מלכודת aliasing שכל שינוי בתוך לולאת ההשטחה המשותפת חייב לכבד. הוא מחליף זמנית את ערך /Annots של העמוד במערך בעל איבר אחד כדי שמעבר ההשטחה הכללי יפעל על widget יחיד, ואז משחזר את המצביע המקורי של PHPDFDictionaryItem בתוך בלוק finally. השחזור כותב בחזרה ל-slot של מילון שנלכד לפני הקריאה

DictItem:= PHPDFDictionaryItem(PageObj.Items.Items[AnnotsIndex]);
Item:= DictItem^.Value;
TemporaryAnnots:= THPDFArrayObject.Create(nil);
TemporaryAnnots.AddObject(Target);
DictItem^.Value:= TemporaryAnnots;
try
  Result:= FlattenLoadedAnnotations(IntToStr(PageIndex+ 1), 'Widget')= 1;
finally
  DictItem^.Value:= Item;   // מצביע תלוי אם הלולאה הפנימית שחררה את האיבר
  TemporaryAnnots.Free;
end;

מוסיפים ניקוי שנראה סביר בתוך הלולאה הפנימית המשותפת — DeleteValue('Annots') ברגע שהמערך מתרוקן, כדי שהעמוד השמור לא יישא מערך ריק מיותר — והקריאה משחררת את פריט המילון שאליו DictItem מצביע. ה-finally כותב אז דרך מצביע תלוי והתהליך מת עם "Invalid pointer operation". שתי בדיקות קיימות תפסו זאת מיד, וזו הסיבה שזה הערת שוליים ולא ticket תמיכה. הכלל כללי: לפני שמוסיפים cleanup ללולאה משותפת, בדקו אצל הקוראים חוזי alias או swap. מערך /Annots ריק שנשאר הוא פגם קוסמטי, ואינו שווה החלפת הבטחת lifetime של מצביע

מה נשאר לא-אפוי ומה מחיר ההשטחה

הערות נסתרות מוחרגות בכוונה. הערה שערך ה-/F integer שלה מכיל את bit position 2 מוגדרת hidden לפי ISO 32000-1 12.5.3, וכאשר גם אין לה /AP יש פיתוי אמיתי לסנתז לה אחד ולאפות אותה כמו את השאר. זה יהיה באג עם השלכות אבטחה: אפיית הערה בלתי נראית לתוכן העמוד הופכת אותה לנראית לכל מי שפותח את הקובץ. HotPDF משאירה את ההערות האלה בדיוק במקומן ואינה סופרת אותן בערך ההחזרה. היו ברורים באותה מידה עם המשתמשים שלכם לגבי המחיר של אלה שכן נאפות. ההשטחה בלתי הפיכה — ההערה נמחקת ממערך /Annots של העמוד והמראה שלה הופך לתוכן עמוד, ולכן אין עוד עריכת ערך השדה, אין thread של תגובות, אין החלפת מצב /AS ואין דרך לשחזר את הנתונים המבניים מלבד הקובץ המקורי. השטיחו עותק, שמרו את המקור והשתמשו בזה רק כאשר המסמך מפסיק להיות טופס והופך לרשומה. אם הבעיה שלכם מבוססת XFA ולא appearance-less, נתיב השטחת XFA ל-AcroForm ב-HotPDF הוא המקום להתחיל בו, ואם אתם עדיין בונים את הטופס, ההערות על חיבור פעולות שדה ואימות ב-AcroForm מכסות את צד הכתיבה

הסתייגות אימות אחת, מפני שאחרת היא תעלה לכם אחר צהריים. ExtractLoadedPageGlyphs אינה יורדת לתוך Form XObjects, ומראה אפויה חיה בתוך אחד — זרם תוכן העמוד מחזיק רק רצף q ... cm /FlatAn<n> Do Q. לכן חילוץ גליפים מעמוד שטוח אינו מדווח דבר, וזו התנהגות נכונה ולא bake שאבד. אמתו ברמת הבתים, בבדיקה של שם המשאב /FlatAn, קריאת Do ו-/Subtype /Form, או דרך צינור הרינדור, שכן הוא כן מרחיב XObjects

השטחת הערות נראית כמו שלוש שורות של תעתיק עד שפוגשים את המסמכים שאנשים באמת מייצרים. אם אתם עובדים עם טפסים שמולאו, markup של review או פלט ארכיוני ב-Delphi או C++Builder, כדאי לקרוא כיצד רכיב ה-PDF של HotPDF ל-Delphi מטפל בצד המסמך הטעון של AcroForms והערות לפני שאתם בונים מעליו מחולל מראה משלכם