Хтось малює чорну коробку поверх прізвища, нічого не зводить в один шар, відправляє файл, а рецензент виділяє прямокутник і вставляє прізвище в електронний лист. PDFiumPas відповідає на це redaction на рівні операторів: SaveAsRedacted видаляє лише Unicode-скаляри, чиї коробки символів торкаються прямокутника редагування, перебудовує вцілілих з оригінального шрифту, розміру, матриці, режиму рендера і кольору, а осьові шляхи та зображення обрізає, замість викидати ціликом
Чому намальований прямокутник — не редагування
Операція малювання, додана поверх потоку вмісту, нічого не ховає, бо оператори показу тексту під нею все ще в потоці і все ще маплять на code points. ISO 32000-1 §9.4 визначає текстовий обʼект як послідовність операторів позиціювання і показу всередині BT і ET; заповнений прямокутник, намальований після, — просто ще один оператор у тому самому потоці. Екстракція ходить операторами, а не пікселями, тож вкритий рядок повертається цілим. Справжнє редагування мусить видалити операнд, а не затемнити вихід
Очевидна безпечна імплементація брутальна: знайти кожен обʼект сторінки, чия bounding box перетинає прямокутник редагування, і видалити обʼект ціликом. Так робили раніші випуски PDFiumPas, і це коректно, але дорого. Один Tj може нести цілий рядок таблиці, тож замальовка одного номера рахунку забрала з собою дату, опис і суму. Прямокутна заливка, що виявилася смугою таблиці на всю ширину, зникла по всій сторінці. Логотип інвойсу зник, бо редагування чіпало лише один його кут. Версія 3.101.0 опускає рішення на рівень нижче — з обʼекта сторінки на операнд
Що насправді видаляє redaction на рівні операторів?
PDFiumPas видаляє Unicode-скаляри, а не текстові обʼекти. Під час SaveAsRedacted компонент будує мапування символ-обʼект сторінки з завантаженої текстової сторінки, потім для кожного символу, що належить обʼекту під тестом, читає коробку символу і перетинає її з кожним прямокутником редагування. Символи, що торкаються прямокутника, позначаються на видалення; решта позначаються як вцілілі. Якщо нічого не перетинається, обʼект лишається повністю спокійний. Якщо кожен символ перетинається, обʼект видаляється ціликом, як і раніше. Лише змішаний випадок запускає розрізання
Кожен вцілілий потім перевипромінюється як власний текстовий обʼект, збудований з оригінального хендла шрифту, оригінального розміру шрифту, на-символьної текстової матриці, оригінального режиму рендера тексту та стану заливки і штриха батьківського обʼекта, включно з товщиною штриха, line join, line cap і dash array. Повторне вживання хендла шрифту, замість розвʼязувати новий, — те, що тримає гліфи метрично ідентичними, а повторне вживання на-символьної матриці — те, що тримає кернінг і міжсловинні проміжки на місці без повторного прогону компонування. Ціна — кількість обʼєктів: один утриманий символ стає одним текстовим обʼектом, тому 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); // fail closed, не відвантажувати
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-скаляра, не може бути чесно перебудований. Реконструкція вцілілого означає кликати text-setting API з рядком, а це вимагає стабільної кодової точки для кожного утриманого символу. Символьні subset-шрифти з поламаними чи відсутніми даними ToUnicode можуть дати порожнє мапування, а перекодування навмання дало б вихід, що виглядає правильно на екрані, несучи під ним інший символ. PDFiumPas відмовляє: перевірка утриманих символів кидає, виняток ловиться всередині SaveAsRedacted, TPdfRedactionReport.Succeeded повертається False з повідомленням у ErrorMessage, і функція повертає False. Те саме правило стосується бюджету розрізання, який кидає, замість мовчки обрізати набір фрагментів. Коли документ має шрифти, яким ви не довіряєте, і ви хочете детерміністичну стару поведінку, поставте Options.PreservePartialObjects := False, і кожен обʼект, що перетинався, піде ціликом
Чистка ресурсів крізь спільні скоупи
Розрізання обʼєктів лишає сиріт по собі, і чистити їх не так просто, як diff-ити сторінковий словник /Resources. ISO 32000-1 §7.8.3 дозволяє тому самому ресурсному словнику посилатися з кількох сторінок, Form XObjects, патернів та потоків вигляду анотацій водночас. Видалити назву шрифту, бо одна сторінка перестала його вживати, — зламає іншу, що досі вживає. PruneUnusedPdfResources тому працює на скоуп: він розвʼязує /Contents — чи то прямий масив, непряме посилання на масив, чи одиночний потік — потім збирає вживання ресурсів з операторів, що справді називають ресурси: Tf для шрифтів, Do для XObjects, gs для графічного стану, CS, cs, SCN і scn для колірних просторів і патернів, sh для shadings, BDC і DP для властивостей marked-content, плюс запис /CS inline-зображень. Коли один словник спільний для кількох скоупів, набори вживаних назв обʼєднуються за категоріями, перш ніж щось видаляється
Видаляються лише назви, непосланість яких підтверджено в кожному скоупі, що вказує на словник. Скоуп, який не вдається надійно розпарсити, лишається недоторканим — це консервативний напрямок: нечищений файл просто більший, а неправильно чистений — пошкоджений. Вцілілі словники записуються назад як розріджене інкрементне оновлення з точними номерами поколінь, а перезапис досяжності потім вимітає обʼекти, що стали недосяжними, щойно назви зникли. 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, знімає вкладення, виконує прохід санітизації, що видаляє open action, дії каталогу, name trees, асоційовані файли, AcroForm і метадані, чистить ресурси і лише тоді пише вихідний потік. Перевідкрити той вихід як незалежний документ і повторно екстрагувати текст — крок верифікації, вартий місця у вашому тестовому наборі, бо це єдина перевірка, що відповідає на початкове питання: чи може читач ще дістати рядок. Один наслідок, який варто запланувати: розрізання замінює обʼекти сторінки, тож будь-який хендл FPDF_PAGEOBJECT, який ви тримали, мертвий після — та сама пастка часу життя, описана в застарілих хендлах обʼєктів сторінки після трансформації
Дві сусідні штуки роблять робочий процес повним. Вирішити, куди йдуть прямокутники редагування, зазвичай стартує з екстрактованої геометрії, і модель блоків та порядку читання в структурованих текстових блоках і порядку читання — краще джерело кандидатних коробок, ніж сирі символьні пробіги. Подача результату рецензентові належить до правил зміцнення в побудові безпечного PDF-превʼю, де заповнення форм і JavaScript лишаються вимкненими за замовчуванням. Разом вони покривають цикл, якого потребує більшість комплаєнс-процесів: знайти, відредагувати на рівні операторів, верифікувати перевідкриттям, безпечно превʼювати. Повна API-поверхня, пробне завантаження і ліцензійні умови компонента живуть на продуктовій сторінці PDFium Delphi Component