Один неправильний символ у номері рахунка — і єдиний доступний примітив редагування переписує весь text run. PDF Library for Delphi закриває цю прогалину: GetTextBlockCharContentLocation повертає кожну витягнуту UTF-16 position до content-stream instruction, operand і encoded byte range, який її породив, а ReplaceTextBlockCharSourceBytes перезаписує лише цей range. Звичайне text extraction викидає все, що для цього потрібно. Ви отримуєте Unicode, widths і geometry, а provenance зникає, тому character у position 7 block 3 — просто character. Який stream його створив, яка instruction, який operand, який byte всередині operand — усе втрачено. Кожна стратегія point edit, побудована поверх цього, змушена вгадувати, зазвичай шукаючи substring у decoded content і сподіваючись, що він трапляється рівно один раз. На реальній сторінці це не так
Чому переписування всього text run руйнує сторінку?
Тому що run — це не лише text. Оператори показу text в ISO 32000-1 §9.4.3 включають TJ, чий operand є array, де strings чергуються з numeric adjustments, і саме ці числа задають typesetting. Рядок, складений як [(AB) -120 (CD)] TJ, має kern у 120 тисячних em між двома strings. Створіть новий Tj зі з’єднаним text — і kern зникне, рядок трохи перетече, а у form значення вийде за межі свого box. Та сама проблема стосується font: operand bytes є codes у тому encoding, який вибрав Tf, а не Unicode, і для composite font це можуть бути двобайтові CIDs, ніяк не пов’язані з character, який повернув extractor. Перегенеруйте run — і доведеться правильно відтворити encoding font, його /ToUnicode map та glyph coverage. Point editing обходить усе це, ніколи не виходячи з byte domain
Що повертає GetTextBlockCharContentLocation?
Method перетворює один character на record із дев’яти fields, і кожне field є address, а не value. ContentLayer — 1-based index у page /Contents array або 0, якщо character походить із nested content. StreamObjectNumber і StreamGeneration ідентифікують stream, що його містить. InstructionIndex — 0-based position у decoded content program, OperandIndex — text-string operand, а ArrayElementIndex — element усередині TJ array або -1 для direct string operand. SourceByteOffset і SourceByteLength називають byte range всередині decoded string
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 походять із вашого власного scan GetTextBlockText
If Lib.GetTextBlockCharContentLocation(ListID, Block, CharPos,
ContentLayer, StreamObjectNumber, StreamGeneration,
InstructionIndex, OperandIndex, ArrayElementIndex,
SourceByteOffset, SourceByteLength, Flags)= 1 Then
Begin
// ContentLayer = 0 означає, що glyph живе у nested Form XObject
// ArrayElementIndex = -1 означає plain Tj operand, а не TJ array
End;
Finally
Lib.ReleaseTextBlocks(ListID);
End;
Finally
Lib.Free;
End;
End;
Lookup не має вартості під час query. Поки renderer декодує кожен content layer, він реєструє logical spans, які проходить, тому position query є binary search по впорядкованому interval list, а не linear scan усіх content spans для кожного character. Під час запиту нічого не парситься повторно: map створено під час extraction pass, за який ви вже заплатили. Якщо ви вже перелічуєте hits за допомогою PDF text search, що повертає hit coordinates, додати content location для кожного hit майже безкоштовно
Редагування bytes, а не Unicode
ReplaceTextBlockCharSourceBytes приймає AnsiString raw replacement bytes в active PDF font encoding. У цьому й полягає вся конструкція, і це навмисно. Нічого не transcode-иться, нічого не re-encode-иться, нічого не вгадується про font. Library вставляє ваші bytes замість названого range target string і повторно виводить containing content layer. Сусідні strings у тому самому TJ array та numeric kerns між ними залишаються byte-identical. У наведеному вище layout пошук B у [(AB) -120 (CD)] TJ дає ArrayElementIndex 0, SourceByteOffset 1, SourceByteLength 1. Замініть його на Z — і emitted content міститиме (AZ), за яким і далі йдуть -120 та (CD), обидва без змін. Regression suite перевіряє саме це, бо твердження "ми зберегли 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
// Усі locations у старому list тепер stale. Виконайте extraction знову
Lib.ReleaseTextBlocks(ListID);
ListID:= Lib.ExtractPageTextBlocks(3);
End
Else If Lib.LastErrorCode= PDFLIB_ERROR_TEXT_LOCATION_STALE Then
// Layer змінився після extraction
Else If Lib.LastErrorCode= PDFLIB_ERROR_TEXT_LOCATION_READ_ONLY Then
// Прапорець не перевірили або його додала пізніша version
End;
Варто засвоїти дві operational details. Call тимчасово перемикається на page, з якої було extracted text list, і відновлює раніше selected page як у разі success, так і в разі failure, тому непомітно не пересуває ваш cursor. А після success він очищає page element snapshots, що invalidates будь-які handles, які ви тримали від попереднього enumeration pass
Які characters не можна редагувати?
Шість категорій, і library називає кожну в Flags bitmask замість нечіткої failure. Це важливіше за happy path, бо в реальних documents unmappable cases поширені, і кожен має іншу причину
PDF_TEXT_CHAR_CONTENT_LOCATION_LIGATURE: кілька extracted UTF-16 positions розгортаються з одного source glyph. Entry/ToUnicode, який зіставляє один code ізfi, дає два characters, що ділять один byte range, тому ставтеся до них як до одного source glyph і редагуйте range один разPDF_TEXT_CHAR_CONTENT_LOCATION_GENERATED: character синтезовано під час layout. Inferred word spaces — звичайний випадок, і в них немає source bytes, томуSourceByteOffsetповертається як -1, аSourceByteLengthяк 0PDF_TEXT_CHAR_CONTENT_LOCATION_ACTUALTEXT: text, який ви читаєте, походить із/ActualTextreplacement. Однозначного reverse mapping із substituted string до source bytes немає, тому location лише діагностичнийPDF_TEXT_CHAR_CONTENT_LOCATION_NESTED: glyph перебуває всередині Form XObject. Bytes адресуються, але Form може малюватися кількома pages, тож редагування через high-level API було б зміною, якої ви не просилиPDF_TEXT_CHAR_CONTENT_LOCATION_TRANSCODED: operand був hex string із UTF-16BE byte order mark, який наявний extraction path декодує до font mapping. Offsets у decoded result більше не адресують original bytes, тому valid flag очищуєтьсяPDF_TEXT_CHAR_CONTENT_LOCATION_CROSS_LAYER: string operand і його text-showing operator живуть у двох різних streams
Останній випадок заслуговує окремого речення, бо інженери регулярно вважають його неможливим. ISO 32000-1 §7.8.2 каже, що streams у page /Contents array конкатенуються, а межа між ними має припадати лише на lexical boundary. Тому BT /F1 16 Tf 220 340 Td (CrossLayer) в одному stream і Tj ET у наступному — цілком легальна page. Mapping зберігає diagnostic position, але позначає її як read-only, бо instruction index operator-а належить іншому layer, ніж operand bytes, а використання одного для адресації іншого пошкодило б file
Як library знає, що map ще дійсна?
Fingerprints, перевірені безпосередньо перед write. Кожен extraction list записує source page плюс для кожного content layer його length і два незалежні rolling hashes: FNV-1a hash та DJB2-style xor hash. Перед тим як ReplaceTextBlockCharSourceBytes щось парсить, він повторно читає target layer і порівнює всі три values. Будь-яка зміна bytes у цьому layer повертає PDFLIB_ERROR_TEXT_LOCATION_STALE, і write не виконується. Це навмисно консервативно: check відбувається per layer, а не per instruction, тому навіть unrelated edit в іншому місці того самого content stream invalidates location. Це правильний trade-off: offset у stream, який зсунувся хоча б на один byte, — не майже правильний, а silent corruption. Та сама дисципліна керує рештою editing surface, включно з content-stream state tracker для CTM і clipping. Після будь-якого успішного replacement викиньте list і виконайте extraction знову
Read-only mapping через Direct Access
DAGetTextBlockCharContentLocation дає ідентичний record для page, відкритої через Direct Access path, з ідентичним словником flags. За конструкцією він лише diagnostic: ReplaceTextBlockCharSourceBytes працює з selected editable document, а Direct Access є read path. Location data зберігаються в text block list після закриття file handle, що робить їх придатними для offline auditing
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);
// Locations залишаються доступними після DACloseFile
Finally
Lib.DAReleaseTextBlocks(DirectList);
End;
Finally
Lib.DACloseFile(FileHandle);
End;
Використовуйте це, щоб відповідати на питання, а не змінювати щось. На яких pages є text, який неможливо редагувати in place? Яка частка цього corpus приходить із /ActualText overrides? Output якого vendor-а розділяє operators між content layers? Після того як кожен character має address, це дешеві queries, і їх варто виконати до того, як ви затвердите correction pipeline
Де завершується point editing
Point editing — це scalpel, а не text engine. Він змінює bytes in place, тому replacement text, ширший або вужчий за оригінал, не reflow-иться, не rewrap-иться і не оновлює kerns навколо. Заміна однієї digit на іншу в monospaced field — хороший fit. Передрук paragraph — ні. І це категорично не security tool: перезапис glyph bytes залишає original bytes доступними з revision history file, тому все, що має вимогу confidentiality, належить до true redaction, яка видаляє content, а не накриває його. Натомість ви отримуєте чесність. Кожен character або має byte address, на який можна діяти, або named flag, що пояснює, чому його немає, а fingerprint check перетворює stale map на hard error, а не на corrupted page. Character-to-content-byte mapping і in-place source byte replacement постачаються як частина text extraction та content editing surface у PDF Library for Delphi, native Object Pascal PDF library для Delphi, C++Builder і Lazarus