Один неверный символ в номере счёта — и единственный доступный примитив редактирования переписывает весь текстовый фрагмент. PDF Library for Delphi закрывает этот пробел: GetTextBlockCharContentLocation связывает каждую позицию извлечённого UTF-16 с инструкцией content stream, операндом и диапазоном закодированных байт, которые её породили, а ReplaceTextBlockCharSourceBytes перезаписывает только этот диапазон. При обычном извлечении текста всё необходимое для такой операции обычно теряется. Вы получаете Unicode, ширины и геометрию, а provenance исчезает, поэтому символ в позиции 7 блока 3 остаётся просто символом. Какой поток его породил, какая инструкция, какой операнд, какой байт внутри операнда — всё потеряно. Любая стратегия точечного редактирования поверх этого вынуждена угадывать, обычно ища декодированный фрагмент в content stream и надеясь, что он встречается ровно один раз. На реальной странице это не так
Почему переписывание всего текстового фрагмента портит страницу?
Потому что фрагмент — это не только текст. Операторы показа текста в ISO 32000-1 §9.4.3 включают TJ, чей операнд представляет собой массив, чередующий строки с числовыми корректировками, а именно эти числа отвечают за typesetting. Строка, разложенная как [(AB) -120 (CD)] TJ, содержит kern в 120 тысячных em между двумя строками. Если вывести новый Tj со склеенным текстом, kern исчезнет, строка чуть переплывёт, а в форме значение уйдёт за границы своего поля. То же возражение относится к шрифту: байты операнда — это коды в кодировке, выбранной через Tf, а не Unicode, и в composite font это могут быть двухбайтные CID, никак не связанные с символом, который вернул extractor. При генерации фрагмента заново нужно безошибочно угадать кодировку шрифта, его карту /ToUnicode и набор glyph. Точечное редактирование обходит всё это, потому что вообще не покидает байтовый домен
Что возвращает GetTextBlockCharContentLocation?
Метод разрешает один символ в запись из девяти полей, и каждое поле является адресом, а не значением. ContentLayer — индекс с 1 в массиве страницы /Contents или 0, если символ пришёл из вложенного контента. StreamObjectNumber и StreamGeneration идентифицируют содержащий поток. InstructionIndex — позиция с 0 в декодированной content-программе, OperandIndex — текстовый операнд, а ArrayElementIndex — элемент внутри массива TJ или -1 для прямого строкового операнда. Затем SourceByteOffset и SourceByteLength задают диапазон байт внутри этой декодированной строки
Var
Lib: TPDFlib;
ListID, Block, CharPos: Integer;
ContentLayer, StreamObjectNumber, StreamGeneration: Integer;
InstructionIndex, OperandIndex, ArrayElementIndex: Integer;
SourceByteOffset, SourceByteLength, Flags: Integer;
Begin
Lib:= TPDFlib.Create;
Try
Lib.LoadFromFile('invoice.pdf', '');
Lib.SelectPage(1);
ListID:= Lib.ExtractPageTextBlocks(3);
Try
// Block и CharPos получены при собственном обходе GetTextBlockText
If Lib.GetTextBlockCharContentLocation(ListID, Block, CharPos,
ContentLayer, StreamObjectNumber, StreamGeneration,
InstructionIndex, OperandIndex, ArrayElementIndex,
SourceByteOffset, SourceByteLength, Flags)= 1 Then
Begin
// ContentLayer = 0 означает, что glyph находится во вложенном Form XObject
// ArrayElementIndex = -1 означает обычный операнд Tj, а не массив TJ
End;
Finally
Lib.ReleaseTextBlocks(ListID);
End;
Finally
Lib.Free;
End;
End;
Во время запроса поиск ничего не стоит. Пока renderer декодирует каждый content layer, он регистрирует логические диапазоны, по которым проходит, поэтому запрос позиции выполняется двоичным поиском по упорядоченному списку интервалов, а не линейным просмотром каждого content span для каждого символа. При запросе ничего не разбирается заново: карта была построена во время extraction pass, за который вы уже заплатили. Если вы уже перечисляете совпадения через поиск текста PDF с возвратом координат совпадений, добавление content location для каждого совпадения почти ничего не стоит
Редактирование байтов, а не Unicode
ReplaceTextBlockCharSourceBytes принимает AnsiString с raw-байтами замены в активной кодировке PDF-шрифта. В этом и состоит весь замысел, и он преднамеренный. Ничего не транскодируется, ничего не перекодируется, библиотека ничего не угадывает о шрифте. Она подставляет ваши байты вместо указанного диапазона целевой строки и заново выводит содержащий её content layer. Соседние строки в том же массиве TJ и числовые kern между ними остаются побайтно неизменными. Возьмём приведённую выше раскладку: поиск B в [(AB) -120 (CD)] TJ даёт ArrayElementIndex 0, SourceByteOffset 1, SourceByteLength 1. Замените его на Z, и выведенный content будет содержать (AZ), за которым по-прежнему следуют -120 и (CD), оба нетронутые. Регрессионный набор проверяет именно это, потому что утверждение «мы сохранили kerning» — из тех, что незаметно перестают быть правдой
Function EditableHere(Flags: Integer): Boolean;
Begin
Result:= ((Flags and PDF_TEXT_CHAR_CONTENT_LOCATION_VALID)<> 0)and
((Flags and (PDF_TEXT_CHAR_CONTENT_LOCATION_GENERATED or
PDF_TEXT_CHAR_CONTENT_LOCATION_ACTUALTEXT or
PDF_TEXT_CHAR_CONTENT_LOCATION_NESTED or
PDF_TEXT_CHAR_CONTENT_LOCATION_CROSS_LAYER or
PDF_TEXT_CHAR_CONTENT_LOCATION_TRANSCODED))= 0);
End;
// ...
If EditableHere(Flags) Then
Begin
If Lib.ReplaceTextBlockCharSourceBytes(ListID, Block, CharPos, 'Z')= 1 Then
Begin
// Все location в старом списке теперь недействительны. Выполнить извлечение заново.
Lib.ReleaseTextBlocks(ListID);
ListID:= Lib.ExtractPageTextBlocks(3);
End
Else If Lib.LastErrorCode= PDFLIB_ERROR_TEXT_LOCATION_STALE Then
// Слой изменился после извлечения
Else If Lib.LastErrorCode= PDFLIB_ERROR_TEXT_LOCATION_READ_ONLY Then
// Флаг, который мы не проверили, или флаг, добавленный более новой версией
End;
Стоит запомнить две эксплуатационные детали. Вызов временно переключается на страницу, из которой был извлечён текстовый список, и при успехе и при ошибке восстанавливает ранее выбранную страницу, поэтому ваш курсор не перемещается незаметно. После успешного вызова он также очищает snapshots элементов страницы, делая недействительными все handle, которые вы удерживали после предыдущего прохода enumeration
Какие символы нельзя редактировать?
Шесть категорий, и библиотека называет каждую в битовой маске Flags, вместо того чтобы завершаться расплывчатой ошибкой. Это важнее счастливого пути, потому что в реальных документах немаппируемые случаи распространены, и у каждого своя причина
PDF_TEXT_CHAR_CONTENT_LOCATION_LIGATURE: несколько позиций извлечённого UTF-16 разворачиваются из одного исходного glyph. Запись/ToUnicode, сопоставляющая один код сfi, даёт два символа, разделяющих один диапазон байт, поэтому считайте их одним исходным glyph и редактируйте диапазон один разPDF_TEXT_CHAR_CONTENT_LOCATION_GENERATED: символ синтезирован во время layout. Обычный пример — выведенные по предположению пробелы между словами, у них вообще нет исходных байт, поэтомуSourceByteOffsetвозвращается как -1, аSourceByteLengthкак 0PDF_TEXT_CHAR_CONTENT_LOCATION_ACTUALTEXT: прочитанный текст пришёл из замены/ActualText. Однозначного обратного отображения подставленной строки на исходные байты нет, поэтому location годится только для диагностикиPDF_TEXT_CHAR_CONTENT_LOCATION_NESTED: glyph находится внутри Form XObject. Байты адресуемы, но Form может рисоваться несколькими страницами, поэтому редактирование через high-level API стало бы изменением, которого вы не просилиPDF_TEXT_CHAR_CONTENT_LOCATION_TRANSCODED: операнд был hex-строкой с маркером порядка байт UTF-16BE, которую существующий extraction path декодирует до font mapping. Смещения в декодированном результате больше не адресуют исходные байты, поэтому valid-флаг сбрасываетсяPDF_TEXT_CHAR_CONTENT_LOCATION_CROSS_LAYER: строковый операнд и его оператор показа текста находятся в разных потоках
Последний случай заслуживает отдельной фразы, потому что инженеры регулярно считают его невозможным. ISO 32000-1 §7.8.2 говорит, что потоки в массиве страницы /Contents конкатенируются, а граница между ними обязана проходить лишь на лексической границе. Поэтому BT /F1 16 Tf 220 340 Td (CrossLayer) в одном потоке и Tj ET в следующем — совершенно допустимая страница. Mapping сохраняет диагностическую позицию, но помечает её как read-only, поскольку индекс инструкции оператора принадлежит другому layer, чем байты операнда, и использование одного для адресации другого повредило бы файл
Как библиотека понимает, что карта всё ещё действительна?
По fingerprint, который проверяется непосредственно перед записью. Каждый extraction list сохраняет исходную страницу, а для каждого content layer — длину слоя и два независимых rolling hash: хеш FNV-1a и XOR-хеш в стиле DJB2. Перед тем как ReplaceTextBlockCharSourceBytes что-либо разбирает, он повторно читает целевой layer и сравнивает все три значения. Любое изменение байта в этом layer возвращает PDFLIB_ERROR_TEXT_LOCATION_STALE, и запись не выполняется. Это намеренно консервативная проверка: она действует на уровне layer, а не инструкции, поэтому даже несвязанное редактирование в том же content stream делает вашу location недействительной. Это правильный компромисс: смещение в потоке, который сдвинулся хотя бы на один байт, — не почти правильный адрес, а тихая порча. Та же дисциплина управляет остальной поверхностью редактирования, включая tracker состояния content stream для CTM и clipping. После любой успешной замены удалите list и извлеките его заново
Read-only mapping через Direct Access
DAGetTextBlockCharContentLocation даёт идентичную запись для страницы, открытой через Direct Access path, с тем же набором флагов. По конструкции это только диагностика: ReplaceTextBlockCharSourceBytes работает с выбранным редактируемым документом, а Direct Access является путём чтения. Данные location сохраняются в списке текстовых блоков после закрытия file handle, поэтому пригодны для offline-аудита
FileHandle:= Lib.DAOpenFileReadOnly('audit.pdf', '');
Try
PageRef:= Lib.DAFindPage(FileHandle, 1);
DirectList:= Lib.DAExtractPageTextBlocks(FileHandle, PageRef, 3);
Try
Lib.DAGetTextBlockCharContentLocation(DirectList, Block, 1,
ContentLayer, StreamObjectNumber, StreamGeneration,
InstructionIndex, OperandIndex, ArrayElementIndex,
SourceByteOffset, SourceByteLength, Flags);
// Location остаются доступными для чтения после DACloseFile
Finally
Lib.DAReleaseTextBlocks(DirectList);
End;
Finally
Lib.DACloseFile(FileHandle);
End;
Используйте эту возможность, чтобы отвечать на вопросы, а не менять данные. На каких страницах есть текст, который невозможно отредактировать на месте? Какая доля этого корпуса приходит с переопределениями /ActualText? Вывод какого поставщика разделяет операторы по разным content layer? После того как у каждого символа появился адрес, такие запросы стоят дёшево, и их стоит выполнить до того, как вы свяжете себя с pipeline исправления
Где заканчивается точечное редактирование
Точечное редактирование — скальпель, а не текстовый движок. Оно меняет байты на месте, поэтому замена, которая шире или уже исходной, не переразложит строку, не перенесёт её и не обновит окружающие kern. Замена одной цифры другой в моноширинном поле подходит хорошо. Набирать заново абзац — нет. И это решительно не инструмент безопасности: перезапись байтов glyph оставляет исходные байты восстанавливаемыми из истории ревизий файла, поэтому всё, что требует конфиденциальности, относится к настоящей redaction, удаляющей контент, а не закрывающей его. Взамен этих ограничений вы получаете честность. У каждого символа либо есть байтовый адрес, по которому можно действовать, либо именованный флаг, объясняющий, почему адреса нет, а fingerprint-проверка превращает устаревшую карту в жёсткую ошибку, а не в испорченную страницу. Отображение символов на байты content stream и замена исходных байтов на месте входят в поверхность извлечения текста и редактирования контента PDF Library for Delphi — нативной библиотеки PDF на Object Pascal для Delphi, C++Builder и Lazarus