مقاله فنی

حذف متنی PDF در سطح عملگر در Delphi با PDFiumPas

کسی روی یک نام یک مستطیل سیاه می‌کشد، هیچ چیزی را تخت نمی‌کند، فایل را ارسال می‌کند و بازبینی‌کننده مستطیل را انتخاب و نام را داخل یک ایمیل paste می‌کند. PDFiumPas جواب این را با حذف متنی در سطح عملگر می‌دهد: SaveAsRedacted فقط اسکالرهای Unicode را حذف می‌کند که جعبه کاراکترشان با یک مستطیل حذف تماس دارد، بازمانده‌ها را از فونت و اندازه و ماتریس و مود رندر و رنگ اصلی بازمی‌سازد و به‌جای انداختن کامل، مسیرها و تصاویر هم‌راستا با محورها را crop می‌کند

چرا یک مستطیل کشیده‌شده حذف متنی نیست

یک عملیات ترسیمی که روی یک استریم محتوا اضافه می‌شود هیچ چیزی را پنهان نمی‌کند، چون عملگرهای نمایش متن زیر آن هنوز در استریم هستند و هنوز به code point نگاشت می‌شوند. ISO 32000-1 §9.4 یک شیء متن را دنباله‌ای از عملگرهای موقعیت‌دهی و نمایش داخل BT و ET تعریف می‌کند؛ یک مستطیل توپر که بعداً کشیده شود صرفاً عملگر دیگری در همان استریم است. استخراج روی عملگرها راه می‌رود، نه روی پیکسل‌ها، پس رشته پوشانده‌شده دست‌نخورده برمی‌گردد. حذف متنی واقعی باید عملوند را حذف کند، نه اینکه خروجی را مبهم کند

پیاده‌سازی امن بدیهی بی‌رحمانه است: هر شیء صفحه‌ای را که جعبه محصورش با یک مستطیل حذف تقاطع دارد پیدا کن و کل شیء را حذف کن. این کاری است که نسخه‌های قبلی PDFiumPas می‌کردند و درست است اما پرهزینه است. یک Tj تنها می‌تواند یک سطر کامل جدول را حمل کند، پس سیاه کردن یک شماره حساب تاریخ و توضیح و مبلغ را هم با خودش برد. یک fill مستطیلی که اتفاقاً یک نوار جدول تمام‌عرض بود کل صفحه ناپدید شد. لوگوی یک فاکتور غیبش زد چون حذف فقط یک گوشه‌اش را می‌برید. نسخه 3.101.0 تصمیم را یک سطح پایین می‌برد، از شیء صفحه به عملوند

حذف متنی در سطح عملگر دقیقاً چه چیزی را حذف می‌کند؟

PDFiumPas اسکالرهای Unicode را حذف می‌کند، نه اشیاء متن. در طول SaveAsRedacted کامپوننت یک نگاشت کاراکتر به شیء صفحه از صفحه متنی لودشده می‌سازد، بعد برای هر کاراکتری که متعلق به شیء تحت آزمون است جعبه کاراکتر را می‌خواند و آن جعبه را با هر مستطیل حذف تقاطع می‌دهد. کاراکترهایی که با یک مستطیل تماس دارند برای حذف علامت می‌خورند؛ بقیه به‌عنوان بازمانده علامت می‌خورند. اگر هیچ تقاطعی نباشد، شیء کاملاً به حال خود رها می‌شود. اگر همه کاراکترها تقاطع داشته باشند، شیء درست مثل قبل یک‌جا حذف می‌شود. فقط حالت مختلط split را راه می‌اندازد

حذف متنی سطح عملگر در PDFiumPas در مقایسه با حذف کل شیء در Delphi: مسیر قدیمی وقتی یک شماره حساب پوشانده شود کل شیء متن را می‌اندازد، در حالی که مسیر split فقط کاراکترهای تقاطع‌دار را حذف و هر بازمانده را به‌شکل شیء متن خودش دوباره صادر می‌کند
فقط حالت مختلط split را راه می‌اندازد: بدون هیچ تقاطعی شیء به حال خود می‌ماند، با تقاطع همه‌جانبه یک‌جا حذف می‌شود

هر بازمانده بعد به‌شکل شیء متن خودش دوباره صادر می‌شود که از هندل فونت اصلی، اندازه فونت اصلی، ماتریس متن به ازای هر کاراکتر، مود رندر متن اصلی و وضعیت fill و stroke شیء والد شامل ضخامت stroke و line join و line cap و dash array ساخته می‌شود. استفاده مجدد از هندل فونت به‌جای حل کردن یک هندل جدید همان چیزی است که گلیف‌ها را از نظر متریک یکسان نگه می‌دارد، و استفاده مجدد از ماتریس به ازای هر کاراکتر همان چیزی است که kerning و فاصله کلمه را بدون اجرای مجدد layout سر جای خودش نگه می‌دارد. هزینه آن تعداد اشیاء است: یک کاراکتر نگه‌داشته‌شده یک شیء متن می‌شود، به همین دلیل 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;    // split در سطح عملگر (پیش‌فرض)
    Options.RemoveIntersectingAnnotations := True;
    Options.MaxSplitObjects := 20000;          // سقف روی قطعات تولیدشده

    if not Pdf.SaveAsRedacted(TargetPdf, Options, Report) then
      raise Exception.Create(Report.ErrorMessage);   // fail closed، ارسال نکن
  finally
    Pdf.Free;
  end;
end;

مستطیل‌ها crop می‌شوند، هندسه چرخیده نه

مسیرها فقط وقتی split می‌شوند که PDFiumPas بتواند ثابت کند مسیر یک مستطیل هم‌راستا با محورها است. اثبات عمداً باریک است: ماتریس شیء باید هر دو جمله shear زیر 0.0001 داشته باشد، مسیر باید از چهار تا شش سگمنت تشکیل شده باشد که با MOVETO شروع و فقط با LINETO ادامه پیدا می‌کنند، و نقاط تبدیل‌شده باید درون تحمل 0.01 روی هر چهار گوشه کران‌های شیء فرود بیایند. مسیری که از این بررسی رد شود با تفریق متوالی مستطیل‌ها کاهش می‌یابد، هر مستطیل حذف مجموعه بازمانده را به نوارهای چپ و راست و زیر و رو می‌تراشد، و هر نوار حاصل با مود fill و فلگ stroke و وضعیت paint اصلی دوباره ساخته می‌شود. منحنی‌ها و مثلث‌ها و شکل‌های کلیپ‌شده و هر چیزی که چرخیده باشد از بررسی رد می‌شوند و کل شیء حذف می‌شود

تصاویر از ISO 32000-1 §8.9 پیروی می‌کنند، جایی که نمونه‌های تصویر مربع واحدی را اشغال می‌کنند که از طریق ماتریس تبدیل جاری نگاشت شده است. PDFiumPas آن نگاشت را معکوس می‌کند تا هر قطعه بازمانده در فضای صفحه را به مختصات تصویر نرمال‌شده برگرداند، آن‌ها را به بازه واحد گیره می‌کند و بعد با گرد کردن به داخل به ایندکس پیکسل تبدیل می‌کند: لبه‌های چپ و بالا از Ceil می‌روند، راست و پایین از Floor. جهتش مهم است. گرد کردن به بیرون اجازه می‌داد یک ستون ناقص از پیکسل‌های مبدأ سمت حذف‌شده در لبه قطعه زنده بماند. کران‌های پیکسلی صحیح بعد به مختصات نرمال‌شده تبدیل می‌شوند و برای استخراج ماتریس قطعه استفاده می‌شوند، پس bitmap بریده‌شده دقیقاً روی مرز پیکسلی که بریده شده فرود می‌آید. خود crop یک کپی سطری آگاه از stride در قالب‌های Gray و BGR و BGRx و BGRA است. مثل مسیرها، تصویر چرخیده یا مورب، یا تصویری که ماتریسش جمله scale منحط دارد، یک‌جا حذف می‌شود

چگونگی crop یک تصویر با حذف متنی جزئی در Delphi توسط PDFiumPas: قطعه بازمانده در فضای صفحه از طریق 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
  // هیچ چیزی قابل split نبود: هر شیء تقاطع‌دار یک‌جا انداخته شد
  LogWholeObjectFallback(SourcePdf);

چرا PDFiumPas روی کاراکترهای نگاشت‌نشده fail closed می‌کند؟

چون گلیفی که اسکالر Unicode قابل‌بازتولید ندارد نمی‌تواند به‌طور صادقانه بازسازی شود. بازسازی یک بازمانده یعنی صدا زدن API تنظیم متن با یک رشته، و آن برای هر کاراکتر نگه‌داشته‌شده یک code point پایدار می‌خواهد. فونت‌های زیرمجموعه سمبولیک با داده ToUnicode خراب یا غایب می‌توانند نگاشت خالی بدهند، و بازانکود کردن با حدس خروجی‌ای تولید می‌کند که روی صفحه درست به نظر می‌رسد در حالی که زیرش کاراکتر متفاوتی حمل می‌کند. PDFiumPas امتناع می‌کند: بررسی کاراکتر نگه‌داشته‌شده raise می‌کند، استثنا داخل SaveAsRedacted گرفته می‌شود، TPdfRedactionReport.Succeeded با پیام در ErrorMessage برمی‌گردد False و تابع False برمی‌گرداند. همان قانون برای بودجه split هم صدق می‌کند، که به‌جای کوتاه کردن بی‌سروصدای مجموعه قطعات raise می‌کند. وقتی سندی فونت‌هایی دارد که به آن‌ها اعتماد ندارید و رفتار قطعی قدیمی را می‌خواهید، Options.PreservePartialObjects := False ست کنید و هر شیء تقاطع‌دار یک‌جا می‌رود

هرس منابع در اسکوپ‌های مشترک

split کردن اشیاء یتیم‌هایی جا می‌گذارد، و هرس کردن آن‌ها به‌سادگی diff کردن دیکشنری /Resources سطح صفحه نیست. ISO 32000-1 §7.8.3 اجازه می‌دهد همان دیکشنری منابع هم‌زمان توسط چند صفحه، توسط Form XObjectها، توسط الگوها و توسط استریم‌های ظاهر حاشیه‌نویسی ارجاع شود. حذف یک نام فونت چون یک صفحه استفاده‌اش را قطع کرده، صفحه دیگری را که هنوز استفاده می‌کند می‌شکند. PruneUnusedPdfResources پس per scope کار می‌کند: /Contents را حل می‌کند چه آرایه مستقیم باشد، چه ارجاع غیرمستقیم به آرایه، چه یک استریم تنها، بعد استفاده از منابع را از عملگرهایی که واقعاً منابع را نام می‌برند جمع می‌کند — Tf برای فونت‌ها، Do برای XObjectها، gs برای وضعیت گرافیکی، CS و cs و SCN و scn برای فضاهای رنگی و الگوها، sh برای سایه‌زنی‌ها، BDC و DP برای خصوصیات marked-content، به‌علاوه مدخل /CS تصاویر inline. وقتی یک دیکشنری توسط چند اسکوپ مشترک شده باشد، مجموعه‌های نام استفاده‌شده پیش از حذف هر چیزی per category یکپارچه می‌شوند

هرس منابع در PDFiumPas: سه اسکوپ به یک دیکشنری منابع مشترک ارجاع می‌دهند، مجموعه‌های نام استفاده‌شده‌شان per category یکپارچه می‌شود، و فقط نام‌هایی که هیچ اسکوپی ارجاع نمی‌دهد حذف می‌شوند پیش از آن‌که اشیاء دست‌نیافتنی جارو شوند
یک دیکشنری می‌تواند چند صفحه و چند Form XObject و چند استریم ظاهر را سرویس کند، پس PDFiumPas همه مجموعه‌های نام استفاده‌شده را قبل از انداختن حتی یک نام یکپارچه می‌کند

فقط نام‌هایی انداخته می‌شوند که در همه اسکوپ‌هایی که به دیکشنری اشاره می‌کنند بی‌ارجاع بودنشان تأیید شده باشد. اسکوپی که با اطمینان parse نمی‌شود دست‌نخورده می‌ماند، که جهت محافظه‌کارانه است: فایل هرس‌نشده صرفاً بزرگ‌تر است، فایل بدهرس‌شده خراب است. دیکشنری‌های بازمانده به‌شکل یک به‌روزرسانی افزایشی پراکنده با شماره‌های نسل دقیق برگردانده می‌شوند، و بازنویسی دسترس‌پذیری بعد اشیائی را که با ناپدید شدن نام‌ها دست‌نیافتنی شدند جارو می‌کند. TPdfResourcePruneReport گزارش می‌دهد ScannedScopeCount و UpdatedScopeCount و RemovedNameCount و RemovedObjectCount و تعداد بایت‌ها و یک فلگ Succeeded. SaveAsRedacted این مرحله را خودکار روی خروجی پاک‌سازی‌شده اجرا می‌کند، پس مسیر حذف متنی از قبل شاملش می‌شود، اما تابع در سطح استریم برای pipelineهایی که خودش را می‌خواهند export شده است

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;

وصل کردن آن به یک pipeline سند

مسیر حذف متنی هرگز سندی که لود کردید را جهش نمی‌دهد. SaveAsRedacted یک snapshot مجزا می‌گیرد، حاشیه‌نویسی‌های /Redact را آن‌جا اعمال می‌کند، پیوست‌ها را می‌چیند، گذر پاک‌سازی را اجرا می‌کند که open action و اکشن‌های کاتالوگ و درخت‌های نام و فایل‌های مرتبط و AcroForm و متادیتا را حذف می‌کند، منابع را هرس می‌کند و فقط بعد استریم خروجی را می‌نویسد. باز کردن دوباره آن خروجی به‌شکل یک سند مستقل و استخراج مجدد متن، مرحله راستی‌آزمایی است که ارزش نگه داشتن در test suite خودتان را دارد، چون تنها بررسی‌ای است که به پرسش اصلی جواب می‌دهد — آیا خواننده هنوز می‌تواند رشته را بگیرد. یک پیامد که باید برایش برنامه بریزید: split اشیاء صفحه را جایگزین می‌کند، پس هر هندل FPDF_PAGEOBJECT که در دست داشتید بعدش مُرده است، همان تله عمر که در هندل‌های منقضی شیء صفحه بعد از یک transform توضیح داده شده

دو قطعه همسایه این workflow را کامل می‌کنند. تصمیم اینکه کجا مستطیل‌های حذف می‌روند معمولاً از هندسه استخراج‌شده شروع می‌شود، و مدل بلوک و ترتیب خواندن در بلوک‌های متن ساختاریافته و ترتیب خواندن منبع بهتری برای جعبه‌های نامزد نسبت به ران‌های خام کاراکتر است. سرو کردن نتیجه به بازبینی‌کننده مربوط به قواعد سخت‌سازی در ساختن یک پیش‌نمایش امن PDF است، جایی که پر کردن فرم و JavaScript به‌صورت پیش‌فرض خاموش می‌مانند. با هم حلقه‌ای را پوشش می‌دهند که بیشتر workflowهای انطباق لازم دارند: پیدا کردن، حذف متنی در سطح عملگر، راستی‌آزمایی با باز کردن دوباره، پیش‌نمایش امن. سطح کامل API و دانلود آزمایشی و شرایط لایسنس کامپوننت روی صفحه محصول PDFium Delphi Component است