Кто-то рисует чёрный прямоугольник поверх имени, ничего не флэттенирует, отправляет файл, и рецензент выделяет прямоугольник и вставляет имя в письмо. 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 может доказать, что путь — прямоугольник, выровненный по осям. Доказательство сознательно узкое: матрица объекта обязана иметь оба члена сдвига ниже 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, паттернов и потоков внешнего вида аннотаций. Удалить имя шрифта потому, что одна страница перестала его использовать, — значит сломать другую, которая всё ещё его использует. Поэтому 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