Technický článek

Mapování textu PDF na bajty content streamu v Delphi

Jeden chybný znak v čísle faktury a jediná dostupná editační primitiva přepíše celý textový run. PDF Library for Delphi tuto mezeru uzavírá: GetTextBlockCharContentLocation mapuje každou extrahovanou pozici UTF-16 zpět na instrukci content streamu, operand a zakódovaný rozsah bajtů, který ji vytvořil, a ReplaceTextBlockCharSourceBytes přepíše pouze tento rozsah. Extrakce textu obvykle zahodí vše, co byste k tomu potřebovali. Získáte Unicode, šířky a geometrii, ale provenance zmizí, takže znak na pozici 7 bloku 3 je prostě znak. Který stream ho vytvořil, která instrukce a který operand, který bajt uvnitř operandu: pryč. Každá strategie bodové úpravy nad tím musí hádat, obvykle hledáním podřetězce v dekódovaném contentu a nadějí, že se vyskytuje právě jednou. Na skutečné stránce tomu tak není

Proč přepis celého textového runu rozbije stránku

Protože run není jen text. Operátory pro vykreslení textu v ISO 32000-1 §9.4.3 zahrnují TJ, jehož operandem je pole střídající řetězce s číselnými úpravami, a právě tato čísla tvoří sazbu. Řádek zapsaný jako [(AB) -120 (CD)] TJ obsahuje kern 120 tisícin em mezi oběma řetězci. Vytvořte nové Tj se spojeným textem, kern zmizí, řádek se nepatrně přeskupí a ve formuláři hodnota uteče z rámečku. Stejná námitka platí pro font: bajty operandu jsou kódy v kódování, které zvolil Tf, nikoli Unicode, a u composite fontu mohou být dvoubajtovými CID bez vztahu ke znaku, který vám vrátil extractor. Když run vygenerujete znovu, musíte správně trefit kódování fontu, jeho mapu /ToUnicode i pokrytí glyfů. Bodová úprava to vše obchází tím, že nikdy neopouští bajtovou doménu

Co vrací GetTextBlockCharContentLocation

Metoda převede jeden znak na record s devíti poli a každé pole je adresa, nikoli hodnota. ContentLayer je index od 1 do pole stránky /Contents, nebo 0, když znak pochází z vnořeného contentu. StreamObjectNumber a StreamGeneration identifikují obsahující stream. InstructionIndex je pozice od 0 v dekódovaném content programu, OperandIndex je operand textového řetězce a ArrayElementIndex je prvek uvnitř pole TJ nebo -1 u přímého string operandu. SourceByteOffset a SourceByteLength potom pojmenují rozsah bajtů uvnitř tohoto dekódovaného řetězce

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 a CharPos pocházejí z vlastního průchodu GetTextBlockText
      If Lib.GetTextBlockCharContentLocation(ListID, Block, CharPos,
        ContentLayer, StreamObjectNumber, StreamGeneration,
        InstructionIndex, OperandIndex, ArrayElementIndex,
        SourceByteOffset, SourceByteLength, Flags)= 1 Then
      Begin
        // ContentLayer = 0 znamená glyph ve vnořeném Form XObject
        // ArrayElementIndex = -1 znamená běžný operand Tj, nikoli pole TJ
      End;
    Finally
      Lib.ReleaseTextBlocks(ListID);
    End;
  Finally
    Lib.Free;
  End;
End;

Vyhledání při dotazu nic nestojí. Když renderer dekóduje každou content layer, registruje logické rozsahy, kterými prochází, takže dotaz na pozici je binární vyhledávání v uspořádaném seznamu intervalů místo lineárního průchodu každým content spanem pro každý znak. Při dotazu se nic znovu neparsuje, mapa vznikla v extrakčním průchodu, který už jste zaplatili. Pokud už vypisujete hity přes PDF text search vracející souřadnice hitů, přidání content location ke každému hitu je téměř zadarmo

Úprava bajtů, ne Unicode

ReplaceTextBlockCharSourceBytes přijímá AnsiString surových náhradních bajtů v aktivním kódování PDF fontu. To je celý návrh a je záměrný. Nic se nepřekóduje, nic se znovu nekóduje a nic se neodhaduje o fontu. Knihovna vloží vaše bajty přes pojmenovaný rozsah cílového řetězce a znovu vypíše obsahující content layer. Sousední řetězce ve stejném poli TJ i číselné kerny mezi nimi zůstanou bajtově identické. Vezměte předchozí rozvržení: nalezení B v [(AB) -120 (CD)] TJ vrátí ArrayElementIndex 0, SourceByteOffset 1 a SourceByteLength 1. Nahraďte ho Z a vypsaný content bude obsahovat (AZ), za ním stále -120 a (CD), obojí beze změny. Regresní sada přesně tohle ověřuje, protože tvrzení „kern jsme zachovali“ je přesně ten druh tvrzení, které potichu přestane platit

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
    // Každá location ve starém seznamu je nyní neplatná. Extrahuj znovu.
    Lib.ReleaseTextBlocks(ListID);
    ListID:= Lib.ExtractPageTextBlocks(3);
  End
  Else If Lib.LastErrorCode= PDFLIB_ERROR_TEXT_LOCATION_STALE Then
    // Vrstva se od extrakce změnila pod rukama
  Else If Lib.LastErrorCode= PDFLIB_ERROR_TEXT_LOCATION_READ_ONLY Then
    // Příznak, který jsme nekontrolovali, nebo příznak přidaný pozdější verzí
End;

Stojí za to zapamatovat si dva provozní detaily. Volání dočasně přepne na stránku, ze které byl textový seznam extrahován, a při úspěchu i při selhání obnoví dříve vybranou stránku, takže vám potichu neposune kurzor. Při úspěchu také vymaže snapshoty prvků stránky, čímž zneplatní všechny handly držené z dřívějšího průchodu enumerací

Které znaky nelze upravit

Šest kategorií a knihovna každou pojmenuje v bitmasce Flags místo vágního selhání. To je důležitější než šťastná cesta, protože v reálných dokumentech jsou nemapovatelné případy běžné a každý má jiný důvod

  • PDF_TEXT_CHAR_CONTENT_LOCATION_LIGATURE: několik extrahovaných pozic UTF-16 se rozvine z jednoho zdrojového glyfu. Položka /ToUnicode, která mapuje jeden kód na fi, vám dá dva znaky sdílející jeden rozsah bajtů, takže s nimi zacházejte jako s jediným zdrojovým glyfem a rozsah upravte jednou
  • PDF_TEXT_CHAR_CONTENT_LOCATION_GENERATED: znak byl syntetizován při layoutu. Obvyklým případem jsou odvozené mezery mezi slovy a ty nemají žádné zdrojové bajty, takže SourceByteOffset vrací -1 a SourceByteLength 0
  • PDF_TEXT_CHAR_CONTENT_LOCATION_ACTUALTEXT: přečtený text pochází z náhrady /ActualText. Neexistuje jednoznačné zpětné mapování nahrazeného řetězce na zdrojové bajty, takže location slouží pouze k diagnostice
  • PDF_TEXT_CHAR_CONTENT_LOCATION_NESTED: glyph leží uvnitř Form XObject. Bajty jsou adresovatelné, ale Form může vykreslovat několik stránek, takže jeho úprava přes high-level API by byla změnou, o kterou jste nežádali
  • PDF_TEXT_CHAR_CONTENT_LOCATION_TRANSCODED: operand byl hexadecimální řetězec s BOM UTF-16BE, který existující extrakční cesta dekóduje před mapováním fontu. Offsety do dekódovaného výsledku už neadresují původní bajty, proto se validní příznak vyčistí
  • PDF_TEXT_CHAR_CONTENT_LOCATION_CROSS_LAYER: string operand a jeho textový zobrazovací operátor leží ve dvou různých streamech

Poslední případ si zaslouží vlastní větu, protože inženýři rutinně předpokládají, že nastat nemůže. ISO 32000-1 §7.8.2 říká, že streamy v poli stránky /Contents se spojují a rozdělení mezi nimi musí spadat jen na lexikální hranici. BT /F1 16 Tf 220 340 Td (CrossLayer) v jednom streamu a Tj ET v dalším je tedy naprosto legální stránka. Mapování si ponechá diagnostickou pozici, ale označí ji jako pouze pro čtení, protože index instrukce operátoru patří jiné layer než bajty operandu a použití jednoho k adresaci druhého by soubor poškodilo

Jak knihovna pozná, že mapa stále platí

Podle fingerprintů kontrolovaných bezprostředně před zápisem. Každý extrakční seznam zaznamená zdrojovou stránku a pro každou content layer její délku a dva nezávislé rolling hashe: hash FNV-1a a hash ve stylu DJB2 s XOR. Než ReplaceTextBlockCharSourceBytes cokoli parsuje, znovu načte cílovou layer a porovná všechny tři hodnoty. Jakákoli změna bajtu kdekoli v této layer vrátí PDFLIB_ERROR_TEXT_LOCATION_STALE a zápis se neprovede. Je to záměrně konzervativní: kontrola je po layer, nikoli po instrukci, takže i nesouvisející úprava jinde ve stejném content streamu zneplatní vaši location. To je správný kompromis: offset do streamu, který se posunul třeba o jediný bajt, není skoro správně, ale tichá korupce. Stejná disciplína řídí i zbytek editační plochy včetně trackeru stavu content streamu pro CTM a clipping. Po každé úspěšné náhradě seznam zahoďte a extrahujte znovu

Mapování pouze pro čtení přes Direct Access

DAGetTextBlockCharContentLocation vám dá identický record pro stránku otevřenou přes cestu Direct Access se stejnou slovní zásobou příznaků. Z konstrukce je pouze diagnostický: ReplaceTextBlockCharSourceBytes pracuje s vybraným editovatelným dokumentem a Direct Access je čtecí cesta. Data location přežijí v seznamu textových bloků i po zavření file handle, takže je lze použít pro offline audit

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 zůstávají čitelné po DACloseFile
  Finally
    Lib.DAReleaseTextBlocks(DirectList);
  End;
Finally
  Lib.DACloseFile(FileHandle);
End;

Využijte to k odpovědím, ne ke změnám. Které stránky obsahují text, který nikdy nepůjde upravit na místě? Kolik z tohoto korpusu přichází s přepsáním /ActualText? Který dodavatel rozděluje operátory přes content layers? Jakmile má každý znak adresu, jsou to levné dotazy a vyplatí se je spustit ještě předtím, než se zavážete ke korekční pipeline

Kde bodová úprava končí

Bodová úprava je skalpel, nikoli textový engine. Mění bajty na místě, takže náhradní text širší nebo užší než původní se nepřelomí, nezabalí na další řádek a neupraví kerny kolem sebe. Nahrazení jedné číslice jinou v monospaced poli je dobré použití. Přepis odstavce ne. A rozhodně nejde o bezpečnostní nástroj: přepsání bajtů glyfu ponechá původní bajty obnovitelné z revision history souboru, takže vše s požadavkem na důvěrnost patří do skutečné redakce, která obsah odstraní místo jeho překrytí. Na oplátku získáte poctivost. Každý znak buď má bajtovou adresu, na kterou lze působit, nebo pojmenovaný příznak vysvětlující, proč ji nemá, a kontrola fingerprintu změní zastaralou mapu v tvrdou chybu místo poškozené stránky. Mapování znaku na bajty contentu a náhrada zdrojových bajtů na místě jsou součástí plochy extrakce textu a úprav contentu v PDF Library for Delphi, nativní Object Pascalové PDF knihovně pro Delphi, C++Builder a Lazarus