מאמר טכני

השחרת PDF ברמת אופרטור ב־Delphi עם PDFiumPas

מישהו מצייר תיבה שחורה מעל שם, לא משטיח דבר, שולח את הקובץ, והסוקר בוחר את המלבן ומדביק את השם לתוך מייל. PDFiumPas עונה על זה עם השחרה ברמת אופרטור: SaveAsRedacted מוחק רק את הסקלרים של Unicode שתיבות התווים שלהם נוגעות במלבן ההשחרה, בונה מחדש את השורדים מהגופן, הגודל, המטריצה, מצב הרינדור והצבע המקוריים, וחותך נתיבים מיושרי־ציר ותמונות במקום להשליך אותם שלמים

למה מלבן מצויר אינו השחרה

פעולת ציור שנוספת מעל זרם תוכן לא מסתירה דבר, כי אופרטורי הצגת הטקסט שתחתיה עדיין בזרם ועדיין ממפים אל נקודות קוד. ISO 32000-1 §9.4 מגדיר אובייקט טקסט כרצף של אופרטורי מיקום והצגה בתוך BT ו־ET; מלבן ממולא שמצויר אחריו הוא פשוט עוד אופרטור באותו זרם. חילוץ הולך על האופרטורים, לא על הפיקסלים, ולכן המחרוזת המכוסה חוזרת שלמה. השחרה אמיתית חייבת להסיר את האופרנד, לא להעלים את הפלט

המימוש הבטוח המובן מאליו הוא אכזרי: מוצאים כל אובייקט עמוד שהתיבה התוחמת שלו חותכת מלבן השחרה ומוחקים את האובייקט כולו. זה מה שגרסאות PDFiumPas קודמות עשו, וזה נכון אבל יקר. Tj אחד יכול לשאת שורת טבלה שלמה, ולכן השחרת מספר חשבון אחת לקחה איתה את התאריך, התיאור והסכום. מילוי מלבני שבמקרה היה פס טבלה ברוחב מלא נעלם על פני כל העמוד. לוגו של חשבונית נעלם כי ההשחרה חתכה פינה אחת שלו. גרסה 3.101.0 מורידה את ההחלטה רמה אחת למטה, מאובייקט העמוד אל האופרנד

מה השחרה ברמת אופרטור באמת מוחקת?

PDFiumPas מוחק סקלרים של Unicode, לא אובייקטי טקסט. במהלך SaveAsRedacted הרכיב בונה מיפוי מתו לאובייקט עמוד מתוך עמוד הטקסט הטעון, ואז לכל תו שהאובייקט הנבדק מחזיק בו הוא קורא את תיבת התו וחותך אותה מול כל מלבן השחרה. תווים שנוגעים במלבן מסומנים להסרה; השאר מסומנים כשורדים. אם כלום לא חותך, האובייקט נשאר לגמרי במנוחה. אם כל תו חותך, האובייקט מוסר שלם, בדיוק כקודם. רק המקרה המעורב מפעיל פיצול

השחרה ברמת אופרטור של PDFiumPas בהשוואה למחיקת אובייקט שלם ב־Delphi: הנתיב הישן משליך אובייקט טקסט שלם כשמספר חשבון אחד מכוסה, ואילו נתיב הפיצול מוחק רק את התווים החותכים ופולט כל שורד מחדש כאובייקט טקסט עצמאי
רק המקרה המעורב מפעיל פיצול: אין חיתוך — האובייקט נשאר במנוחה, הכול חותך — האובייקט מוסר שלם

כל שורד נפלט אז מחדש כאובייקט טקסט עצמאי שנבנה מהידית של הגופן המקורי, מגודל הגופן המקורי, ממטריצת הטקסט לכל תו, ממצב הרינדור של הטקסט המקורי, וממצב המילוי והקו של אובייקט האב כולל רוחב קו, חיבור קו, קצה קו ומערך קווים מקווקוים. שימוש חוזר בידית הגופן במקום פתרון ידית חדשה הוא מה ששומר על הגליפים זהים מטרית, ושימוש חוזר במטריצה לכל תו הוא מה ששומר על הקרנינג וריווח המילים במקום בלי להריץ פריסה מחדש. המחיר הוא מספר אובייקטים: תו שנשמר הופך לאובייקט טקסט אחד, ולכן TPdfRedactionOptions.MaxSplitObjects קיים כתקרה נוקשה למקטעים שנוצרים

procedure RedactDocument(const SourcePdf, TargetPdf: string);
var
  Pdf: TPdf;
  Options: TPdfRedactionOptions;
  Report: TPdfRedactionReport;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := SourcePdf;   // הקובץ כבר נושא הערות /Redact
    Pdf.Active := True;

    Options := TPdfRedactionOptions.Default;
    Options.PreservePartialObjects := True;    // פיצול ברמת אופרטור (ברירת המחדל)
    Options.RemoveIntersectingAnnotations := True;
    Options.MaxSplitObjects := 20000;          // תקרה למקטעים שנוצרים

    if not Pdf.SaveAsRedacted(TargetPdf, Options, Report) then
      raise Exception.Create(Report.ErrorMessage);   // נכשל בסגירה, לא שולחים
  finally
    Pdf.Free;
  end;
end;

מלבנים נחתכים, גיאומטריה מסובבת לא

נתיבים מפוצלים רק כאשר PDFiumPas יכול להוכיח שהנתיב הוא מלבן מיושר צירים. ההוכחה מכוונת להיות צרה: מטריצת האובייקט חייבת ששני איברי הגזירה (shear) שלה מתחת ל־0.0001, הנתיב חייב להיות מורכב מארבעה עד שישה מקטעים שמתחילים ב־MOVETO וממשיכים ב־LINETO בלבד, והנקודות המומרות חייבות לנחות על כל ארבע הפינות של תחומי האובייקט בסובלנות של 0.01. נתיב שעובר את הבדיקה הזאת מצטמצם באמצעות חיסור מלבנים עוקב, כל מלבן השחרה חורץ את קבוצת השורדים לרצועות משמאל, מימין, מלמטה ומלמעלה, וכל רצועה מתקבלת נוצרת מחדש עם מצב המילוי, דגל הקו ומצב הצבע המקוריים. עקומות, משולשים, צורות חתוכות וכל דבר מסובב נכשלים בבדיקה והאובייקט כולו מוסר

תמונות עוקבות אחרי ISO 32000-1 §8.9, שבו דגימות התמונה תופסות את ריבוע היחידה הממופה דרך מטריצת ההתמרה הנוכחית. PDFiumPas הופך את המיפוי הזה כדי להחזיר כל מקטע שורד במרחב העמוד אל קואורדינטות תמונה מנורמלות, מחסם אותן למרווח היחידה, ואז ממיר לאינדקסי פיקסלים בעיגול פנימה: הקצוות השמאלי והעליון עוברים דרך Ceil, הימני והתחתון דרך Floor. הכיוון הזה חשוב. עיגול כלפי חוץ היה מאפשר לעמודת פיקסלים חלקית מהצד המושחר לשרוד בקצה המקטע. תחומי הפיקסלים השלמים מומרים אז בחזרה לקואורדינטות מנורמלות ומשמשים לגזירת מטריצת המקטע, כך שהמפה הסיבית החתוכה נוחתת בדיוק על גבול הפיקסל שנחתך בו. החיתוך עצמו הוא העתקת שורות מודעת מרווח (stride) על פני הפורמטים Gray, BGR, BGRx ו־BGRA. כמו בנתיבים, תמונה מסובבת או מוטה, או כזאת שהמטריצה שלה כוללת איבר קנה מידה ניווני, מוסרת כולה

איך PDFiumPas חותך תמונה שהושחרה חלקית ב־Delphi: המקטע השורד במרחב העמוד ממופה בחזרה דרך ה־CTM ההפוכה אל קואורדינטות תמונה מנורמלות, נחסם למרווח היחידה ומעוגל פנימה כך ששום עמודת פיקסלים מושחרת לא שורדת
Ceil בשמאל ובחלק העליון, Floor בימין ובחלק התחתון, כך שהחיתוך נוחת על גבול פיקסל שלם
// אחרי קריאת SaveAsRedacted מוצלחת
Writeln(Format('applied %d redaction(s) on %d page(s)',
  [Report.RedactionCount, Report.RedactedPageCount]));
Writeln(Format('scanned %d object(s), removed %d',
  [Report.ScannedObjectCount, Report.RemovedObjectCount]));
Writeln(Format('split text/path/image: %d / %d / %d',
  [Report.SplitTextObjectCount, Report.SplitPathObjectCount,
   Report.SplitImageObjectCount]));
Writeln(Format('preserved %d fragment(s)', [Report.PreservedFragmentCount]));
Writeln(Format('pruned %d resource name(s), swept %d object(s)',
  [Report.ResourcePruneReport.RemovedNameCount,
   Report.ResourcePruneReport.RemovedObjectCount]));

if Report.PreservedFragmentCount = 0 then
  // אי אפשר היה לפצל כלום: כל אובייקט חותך הושלך שלם
  LogWholeObjectFallback(SourcePdf);

למה PDFiumPas נכשל בסגירה על תווים ללא מיפוי?

כי גליף שאין לו סקלר Unicode שניתן לשחזור לא יכול להיבנה מחדש ביושר. שחזור שורד פירושו קריאה ל־API של הצבת טקסט עם מחרוזת, וזה דורש נקודת קוד יציבה לכל תו שנשמר. גופני תת־קבוצה סימבוליים עם נתוני ToUnicode שבורים או חסרים יכולים להניב מיפוי ריק, וקידוד מחדש בניחוש היה מייצר פלט שנראה נכון על המסך בזמן שמתחתיו תו אחר. PDFiumPas מסרב: בדיקת התווים השמורים מרימה חריגה, החריגה נתפסת בתוך SaveAsRedacted, TPdfRedactionReport.Succeeded חוזר כ־False עם ההודעה ב־ErrorMessage, והפונקציה מחזירה False. אותו כלל חל על תקציב הפיצול, שמרים חריגה במקום לקצץ בשקט את קבוצת המקטעים. כשלמסמך יש גופנים שאתם לא סומכים עליהם ואתם רוצים את ההתנהגות הישנה הדטרמיניסטית, קבעו Options.PreservePartialObjects := False וכל אובייקט חותך ייעלם שלם

צמצום משאבים על פני תחומים משותפים

פיצול אובייקטים משאיר מאחור יתומים, וצמצומם אינו פשוט כמו השוואת מילון ה־/Resources ברמת העמוד. ISO 32000-1 §7.8.3 מאפשר שאותו מילון משאבים יופנה בבת אחת על ידי כמה עמודים, על ידי Form XObjects, על ידי תבניות (patterns) ועל ידי זרמי מראה של הערות. מחיקת שם גופן כי עמוד אחד הפסיק להשתמש בו תשבור עמוד אחר שעדיין משתמש. לכן PruneUnusedPdfResources פועל לכל תחום: הוא פותר את /Contents בין אם זה מערך ישיר, הפניה עקיפה למערך או זרם בודד, ואז אוסף שימוש במשאבים מהאופרטורים שבאמת מציינים משאבים — Tf לגופנים, Do ל־XObjects, gs למצב גרפי, CS, cs, SCN ו־scn למרחבי צבע ותבניות, sh להצללות, BDC ו־DP לתכונות תוכן מסומן, וכן רשומת ה־/CS של תמונות מקוונות. כשמילון אחד משותף לכמה תחומים, קבוצות השמות בשימוש מאוחדות לפי קטגוריה לפני שמשהו מוסר

צמצום משאבים ב־PDFiumPas: שלושה תחומים מפנים למילון משאבים משותף אחד, קבוצות השמות בשימוש שלהם מאוחדות לפי קטגוריה, ורק השמות שאף תחום לא מפנה אליהם מוסרים לפני שהאובייקטים הבלתי נגישים נסרקים
מילון אחד יכול לשרת כמה עמודים, form XObjects וזרמי מראה, ולכן PDFiumPas מאחד כל קבוצת שמות בשימוש לפני שהוא משליך שם אחד

מוסרים רק שמות שאושרו כחסרי הפניה בכל תחום שמפנה אל המילון. תחום שאי אפשר לנתח בביטחון נשאר ללא פגע, וזה הכיוון השמרני: קובץ לא מצומצם הוא רק גדול יותר, קובץ שצומצם שגוי הוא פגום. המילונים ששרדו נכתבים בחזרה כעדכון מצטבר דליל שנושא את מספרי הדור המדויקים, ואז שכתוב של נגישות סורק את האובייקטים שהפכו בלתי נגישים ברגע שהשמות נעלמו. TPdfResourcePruneReport מדווח על ScannedScopeCount, UpdatedScopeCount, RemovedNameCount, RemovedObjectCount, ספירות הבתים ודגל Succeeded. SaveAsRedacted מריץ את הצעד הזה אוטומטית על הפלט המחוטא, כך שנתיב ההשחרה כבר כולל אותו, אבל הפונקציה מיוצאת ברמת זרם לצינורות שרוצים אותה לבד

uses
  FPdfCompress;

procedure PruneResourceNames(const SourcePdf, TargetPdf: string);
var
  Source, Dest: TFileStream;
  Report: TPdfResourcePruneReport;
begin
  Source := TFileStream.Create(SourcePdf, fmOpenRead or fmShareDenyWrite);
  try
    Dest := TFileStream.Create(TargetPdf, fmCreate);
    try
      // AllowSignedDocument נשאר False: שכתוב מצטבר יפסול
      // את טווחי הבתים שהחתימה מכסה
      PruneUnusedPdfResources(Source, Dest, Report);
      if not Report.Succeeded then
        raise Exception.Create(Report.ErrorMessage);
      Writeln(Format('%d name(s) removed from %d scope(s), %d -> %d bytes',
        [Report.RemovedNameCount, Report.UpdatedScopeCount,
         Report.SourceByteCount, Report.OutputByteCount]));
    finally
      Dest.Free;
    end;
  finally
    Source.Free;
  end;
end;

חיבור זה אל צינור מסמכים

נתיב ההשחרה לעולם לא משנה את המסמך שטענתם. SaveAsRedacted לוכד תמונת מצב מבודדת, מיישם שם את הערות ה־/Redact, מפשיט קבצים מצורפים, מריץ את מעבר החיטוי שמסיר את פעולת הפתיחה, פעולות קטלוג, עצי שמות, קבצים נלווים, את ה־AcroForm ואת המטא־נתונים, מצמצם משאבים, ורק אז כותב את זרם הפלט. פתיחה מחדש של הפלט הזה כמסמך עצמאי וחילוץ הטקסט שוב הוא צעד האימות ששווה לשמור במקבץ הבדיקות שלכם, כי זו הבדיקה היחידה שעונה על השאלה המקורית — האם קורא עדיין יכול להשיג את המחרוזת. תוצאה אחת שכדאי לתכנן לה: פיצול מחליף אובייקטי עמוד, ולכן כל ידית FPDF_PAGEOBJECT שהחזקתם מתה אחר כך — אותה מלכודת משך חיים שמתוארת בידיות אובייקט עמוד שנפגמו אחרי התמרה

שני חלקים שכנים משלימים את זרימת העבודה. ההחלטה היכן מלבני ההשחרה ישבו מתחילה בדרך כלל מגיאומטריה שחולצה, והמודל של בלוקים וסדר קריאה בבלוקי טקסט מובנים וסדר קריאה הוא מקור טוב יותר לתיבות מועמדות מאשר ריצות תווים גולמיות. הגשת התוצאה לסוקר שייכת לכללי החיזוק בבניית תצוגה מקדימה מאובטחת של PDF, שבה מילוי טפסים ו־JavaScript נשארים כבויים כברירת מחדל. יחד הם מכסים את המעגל שרוב זרימות העמידה בתקנים צריכות: איתור, השחרה ברמת אופרטור, אימות בפתיחה מחדש, תצוגה מקדימה בטוחה. משטח ה־API המלא, הורדת הניסיון ותנאי הרישוי של הרכיב חיים בעמוד המוצר PDFium Delphi Component