Технічна стаття

Зіставлення тексту PDF з байтами content stream у Delphi

Один неправильний символ у номері рахунка — і єдиний доступний примітив редагування переписує весь 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 як 0
  • PDF_TEXT_CHAR_CONTENT_LOCATION_ACTUALTEXT: text, який ви читаєте, походить із /ActualText replacement. Однозначного 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