Техническа статия

PDF текст към content-stream bytes в Delphi

Един сгрешен character във invoice number и единственият наличен edit primitive пренаписва целия text run. PDF Library for Delphi запълва тази празнина: GetTextBlockCharContentLocation връща всяка извлечена UTF-16 позиция към content-stream instruction-а, operand-а и encoded byte range-а, които са я породили, а ReplaceTextBlockCharSourceBytes overwrite-ва само този range. Text extraction-ът обикновено изхвърля всичко, което би ви трябвало за това. Получавате Unicode, widths и geometry, а provenance-ът се изпарява, така че character-ът на position 7 в block 3 е просто character. Кой stream го е породил, коя instruction, кой operand, кой byte в този operand: изчезнали са. Всяка point-edit strategy, построена върху това, трябва да гадае, обикновено като търси substring в decoded content и се надява да се среща точно веднъж. На истинска страница това не се случва

Защо пренаписването на целия text run поврежда страницата?

Защото run-ът не е само текст. Text-showing operator-ите в ISO 32000-1 §9.4.3 включват TJ, чийто operand е array, в който string-ове се редуват с numeric adjustments, а тези числа са typesetting-ът. Ред, оформен като [(AB) -120 (CD)] TJ, носи kern от 120 хилядни от em между двата string-а. Emit-нете нов Tj със слепен текст и kern-ът изчезва, редът се reflow-ва с малко, а във form стойността се измества извън кутията си. Същото възражение важи за font-а: operand bytes са codes в encoding-а, избран от Tf, а не Unicode, и при composite font може да са двубайтови CIDs без никаква връзка с character-а, който extractor-ът ви е върнал. Regenerate-нете run-а и трябва да сте прави за font encoding-а, /ToUnicode map-а и glyph coverage-а. Point editing избягва всичко това, като никога не напуска byte domain-а

Какво връща GetTextBlockCharContentLocation?

Method-ът разрешава един character до record от девет полета и всяко поле е address, а не value. ContentLayer е 1-based index в page /Contents array или 0, когато character-ът идва от nested content. StreamObjectNumber и StreamGeneration идентифицират съдържащия stream. InstructionIndex е 0-based позиция в 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-ът decode-ва всеки content layer, той регистрира logical span-овете, които обхожда, така че query за position е binary search върху подреден interval list, а не linear scan на всеки content span за всеки character. Нищо не се reparse-ва, когато поискате данните; map-ът е построен в extraction pass-а, за който вече сте платили. Ако вече enumerate-вате 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-то splice-ва вашите bytes върху назования range на target string-а и re-emit-ва съдържащия content layer. Съседните string-ове в същия TJ array и numeric kern-овете между тях остават byte-identical. Вземете горния layout: намирането на B в [(AB) -120 (CD)] TJ дава ArrayElementIndex 0, SourceByteOffset 1 и SourceByteLength 1. Replace-нете го с 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
    // Всеки location в стария list вече е stale. Извлечете отново
    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
    // Flag, който не сме проверили, или flag, добавен от по-нова версия
End;

Два оперативни детайла си струва да се запомнят. Call-ът временно превключва към страницата, от която е извлечен text list-ът, и възстановява преди това избраната page както при успех, така и при failure, така че не мести cursor-а ви мълчаливо. При success той изчиства page element snapshot-ите, което инвалидира всички handles, които сте държали от по-ранен enumeration pass

Кои characters не могат да се редактират?

Шест категории, като library-то назовава всяка в Flags bitmask-а, вместо да се проваля неясно. Това е по-важно от happy path-а, защото в реални document-и unmappable случаите са чести и всеки има различна причина

  • PDF_TEXT_CHAR_CONTENT_LOCATION_LIGATURE: няколко извлечени UTF-16 позиции се разгръщат от един source glyph. /ToUnicode entry, който map-ва един 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: прочетеният текст е дошъл от /ActualText replacement. Няма уникално reverse mapping от substituted string към source bytes, затова location-ът е само диагностичен
  • PDF_TEXT_CHAR_CONTENT_LOCATION_NESTED: glyph-ът е във Form XObject. Bytes са addressable, но Form-ът може да се рисува от няколко pages, така че editing през high-level API би било edit, който не сте поискали
  • PDF_TEXT_CHAR_CONTENT_LOCATION_TRANSCODED: operand-ът е бил hex string с UTF-16BE byte order mark, който съществуващият extraction path decode-ва преди font mapping. Offset-ите към decoded result вече не адресират original bytes, затова valid flag-ът е изчистен
  • PDF_TEXT_CHAR_CONTENT_LOCATION_CROSS_LAYER: string operand-ът и неговият text-showing operator живеят в два различни stream-а

Последният случай заслужава собствено изречение, защото инженерите редовно приемат, че не може да се случи. ISO 32000-1 §7.8.2 казва, че stream-овете в page /Contents array се concatenat-ват и разделянето между тях трябва да пада само на lexical boundary. Следователно BT /F1 16 Tf 220 340 Td (CrossLayer) в един stream и Tj ET в следващия са напълно законна page. Mapping-ът запазва diagnostic position-а, но го маркира read-only, защото instruction index-ът на operator-а принадлежи на различен layer от bytes на operand-а и използването на едното за адресиране на другото би повредило файла

Как library-то разбира дали map-ът още е валиден?

Чрез fingerprints, проверени непосредствено преди write. Всеки extraction list записва source page-а плюс за всеки content layer дължината на layer-а и два независими rolling hash-а: FNV-1a hash и DJB2-style xor hash. Преди ReplaceTextBlockCharSourceBytes да parse-не каквото и да е, той прочита target layer-а отново и сравнява и трите стойности. Всяка byte промяна където и да е в layer-а връща PDFLIB_ERROR_TEXT_LOCATION_STALE и write-ът не се извършва. Това е умишлено консервативно: проверката е per layer, не per instruction, така че дори несвързан edit на друго място в същия content stream инвалидира location-а ви. Това е правилният trade: offset към stream, изместен дори с един byte, не е почти попадение, а silent corruption. Същата дисциплина управлява останалата editing surface, включително content-stream state tracker-а за CTM и clipping. След всяка успешна replacement изхвърлете list-а и извлечете отново

Read-only mapping през Direct Access

DAGetTextBlockCharContentLocation ви дава идентичния record за page, отворена през Direct Access path-а, с идентичния flag vocabulary. По конструкция той е само диагностичен: ReplaceTextBlockCharSourceBytes работи върху избрания 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);
    // Location-ите остават readable след DACloseFile
  Finally
    Lib.DAReleaseTextBlocks(DirectList);
  End;
Finally
  Lib.DACloseFile(FileHandle);
End;

Използвайте го, за да отговаряте на въпроси, а не за да променяте неща. Кои pages съдържат text, който никога не бихте могли да редактирате на място? Каква част от този corpus пристига с /ActualText overrides? Output-ът на кой vendor разделя operator-и между content layers? Това са евтини query-та, когато всеки character има address, и си струва да ги пуснете, преди да се ангажирате с correction pipeline

Къде спира point editing-ът

Point editing е scalpel, не text engine. Той променя bytes на място, така че replacement text, който е по-широк или по-тесен от оригинала, няма да reflow-не, няма да rewrap-не и няма да обнови kern-овете около себе си. Замяната на един digit с друг в monospaced field е добър fit. Препечатването на paragraph не е. И категорично не е security tool: overwrite-ването на glyph bytes оставя оригиналните bytes recoverable от revision history на файла, затова всичко с изискване за 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