מישהו מצייר תיבה שחורה מעל שם, לא משטיח דבר, שולח את הקובץ, והסוקר בוחר את המלבן ומדביק את השם לתוך מייל. PDFiumPas עונה על זה עם השחרה ברמת אופרטור: SaveAsRedacted מוחק רק את הסקלרים של Unicode שתיבות התווים שלהם נוגעות במלבן ההשחרה, בונה מחדש את השורדים מהגופן, הגודל, המטריצה, מצב הרינדור והצבע המקוריים, וחותך נתיבים מיושרי־ציר ותמונות במקום להשליך אותם שלמים
למה מלבן מצויר אינו השחרה
פעולת ציור שנוספת מעל זרם תוכן לא מסתירה דבר, כי אופרטורי הצגת הטקסט שתחתיה עדיין בזרם ועדיין ממפים אל נקודות קוד. ISO 32000-1 §9.4 מגדיר אובייקט טקסט כרצף של אופרטורי מיקום והצגה בתוך BT ו־ET; מלבן ממולא שמצויר אחריו הוא פשוט עוד אופרטור באותו זרם. חילוץ הולך על האופרטורים, לא על הפיקסלים, ולכן המחרוזת המכוסה חוזרת שלמה. השחרה אמיתית חייבת להסיר את האופרנד, לא להעלים את הפלט
המימוש הבטוח המובן מאליו הוא אכזרי: מוצאים כל אובייקט עמוד שהתיבה התוחמת שלו חותכת מלבן השחרה ומוחקים את האובייקט כולו. זה מה שגרסאות PDFiumPas קודמות עשו, וזה נכון אבל יקר. Tj אחד יכול לשאת שורת טבלה שלמה, ולכן השחרת מספר חשבון אחת לקחה איתה את התאריך, התיאור והסכום. מילוי מלבני שבמקרה היה פס טבלה ברוחב מלא נעלם על פני כל העמוד. לוגו של חשבונית נעלם כי ההשחרה חתכה פינה אחת שלו. גרסה 3.101.0 מורידה את ההחלטה רמה אחת למטה, מאובייקט העמוד אל האופרנד
מה השחרה ברמת אופרטור באמת מוחקת?
PDFiumPas מוחק סקלרים של Unicode, לא אובייקטי טקסט. במהלך SaveAsRedacted הרכיב בונה מיפוי מתו לאובייקט עמוד מתוך עמוד הטקסט הטעון, ואז לכל תו שהאובייקט הנבדק מחזיק בו הוא קורא את תיבת התו וחותך אותה מול כל מלבן השחרה. תווים שנוגעים במלבן מסומנים להסרה; השאר מסומנים כשורדים. אם כלום לא חותך, האובייקט נשאר לגמרי במנוחה. אם כל תו חותך, האובייקט מוסר שלם, בדיוק כקודם. רק המקרה המעורב מפעיל פיצול
כל שורד נפלט אז מחדש כאובייקט טקסט עצמאי שנבנה מהידית של הגופן המקורי, מגודל הגופן המקורי, ממטריצת הטקסט לכל תו, ממצב הרינדור של הטקסט המקורי, וממצב המילוי והקו של אובייקט האב כולל רוחב קו, חיבור קו, קצה קו ומערך קווים מקווקוים. שימוש חוזר בידית הגופן במקום פתרון ידית חדשה הוא מה ששומר על הגליפים זהים מטרית, ושימוש חוזר במטריצה לכל תו הוא מה ששומר על הקרנינג וריווח המילים במקום בלי להריץ פריסה מחדש. המחיר הוא מספר אובייקטים: תו שנשמר הופך לאובייקט טקסט אחד, ולכן 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. כמו בנתיבים, תמונה מסובבת או מוטה, או כזאת שהמטריצה שלה כוללת איבר קנה מידה ניווני, מוסרת כולה
// אחרי קריאת 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 של תמונות מקוונות. כשמילון אחד משותף לכמה תחומים, קבוצות השמות בשימוש מאוחדות לפי קטגוריה לפני שמשהו מוסר
מוסרים רק שמות שאושרו כחסרי הפניה בכל תחום שמפנה אל המילון. תחום שאי אפשר לנתח בביטחון נשאר ללא פגע, וזה הכיוון השמרני: קובץ לא מצומצם הוא רק גדול יותר, קובץ שצומצם שגוי הוא פגום. המילונים ששרדו נכתבים בחזרה כעדכון מצטבר דליל שנושא את מספרי הדור המדויקים, ואז שכתוב של נגישות סורק את האובייקטים שהפכו בלתי נגישים ברגע שהשמות נעלמו. 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