Odborný článok

Mapovanie textu PDF na bajty content streamu v Delphi

Jeden nesprávny znak v čísle faktúry a jediný dostupný primitív úpravy prepíše celý textový beh. PDF Library for Delphi túto medzeru vypĺňa: GetTextBlockCharContentLocation mapuje každú pozíciu extrahovaného UTF-16 späť na inštrukciu content streamu, operand a rozsah zakódovaných bajtov, ktoré ju vytvorili, a ReplaceTextBlockCharSourceBytes prepíše iba tento rozsah. Extrakcia textu za bežných okolností zahodí všetko, čo by ste na to potrebovali. Dostanete Unicode, šírky a geometriu a pôvod znaku zmizne, takže znak na pozícii 7 bloku 3 je jednoducho znak. Ktorý stream ho vytvoril, ktorá inštrukcia, ktorý operand, ktorý bajt v operande: preč. Každá stratégia bodovej úpravy postavená nad tým musí hádať, zvyčajne hľadaním podreťazca v dekódovanom obsahu a nádejou, že sa vyskytuje presne raz. Na skutočnej stránke sa to nestáva

Prečo prepísanie celého textového behu rozbije stránku?

Pretože beh nie je iba text. Operátory zobrazujúce text v ISO 32000-1 §9.4.3 zahŕňajú TJ, ktorého operand je pole prekladajúce stringy s číselnými úpravami a tieto čísla sú sadzba. Riadok rozložený ako [(AB) -120 (CD)] TJ nesie medzi dvoma stringami kerning 120-tisícin em. Vytvorte nový Tj so zreťazeným textom a kerning zmizne, riadok sa o vlas preformátuje a vo formulári sa hodnota posunie mimo svojho poľa. Rovnaká námietka platí pre font: bajty operandu sú kódy v kódovaní, ktoré vybral Tf, nie Unicode, a pri composite fonte môžu byť dvojbajtové CID bez vzťahu k znaku, ktorý ste dostali z extractora. Pri regenerovaní behu musíte správne poznať kódovanie fontu, jeho mapu /ToUnicode aj pokrytie glyfmi. Bodová úprava to všetko obíde tým, že nikdy neopustí bajtovú doménu

Čo vracia GetTextBlockCharContentLocation?

Metóda preloží jeden znak na deväťprvkový record a každé pole je adresa, nie hodnota. ContentLayer je index od 1 do poľa stránky /Contents alebo 0, keď znak pochádza z vnoreného obsahu. StreamObjectNumber a StreamGeneration identifikujú obsahujúci stream. InstructionIndex je pozícia od 0 v dekódovanom content programe, OperandIndex je operand textového stringu a ArrayElementIndex je prvok vo vnútri poľa TJ alebo -1 pre priamy string operand. SourceByteOffset a SourceByteLength potom pomenujú rozsah bajtov vo vnútri tohto dekódovaného stringu

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ádzajú z vašej vlastnej prehliadky GetTextBlockText
      If Lib.GetTextBlockCharContentLocation(ListID, Block, CharPos,
        ContentLayer, StreamObjectNumber, StreamGeneration,
        InstructionIndex, OperandIndex, ArrayElementIndex,
        SourceByteOffset, SourceByteLength, Flags)= 1 Then
      Begin
        // ContentLayer = 0 znamená, že glyf žije vo vnorenom Form XObject
        // ArrayElementIndex = -1 znamená obyčajný operand Tj, nie pole TJ
      End;
    Finally
      Lib.ReleaseTextBlocks(ListID);
    End;
  Finally
    Lib.Free;
  End;
End;

Vyhľadanie nestojí v čase dotazu nič. Kým renderer dekóduje každú content layer, zaregistruje logické rozsahy, ktorými prechádza, takže dotaz na pozíciu je binárne vyhľadanie v usporiadanom zozname intervalov, nie lineárny prechod každým obsahovým rozsahom pre každý znak. Pri dotaze sa nič neparsuje znova; mapa vznikla počas extrakčného prechodu, ktorý ste už zaplatili. Ak už enumerujete hity pomocou vyhľadávania textu PDF vracajúceho súradnice hitov, pridať ku každému hitu content location je takmer zadarmo

Úprava bajtov, nie Unicode

ReplaceTextBlockCharSourceBytes prijíma AnsiString so surovými náhradnými bajtmi v aktívnom kódovaní PDF fontu. To je celý návrh a je zámerný. Nič sa netranskóduje, nič sa nekóduje znova, nič sa neháda o fonte. Knižnica vloží vaše bajty cez pomenovaný rozsah cieľového stringu a znovu emituje obsahujúcu content layer. Susedné stringy v rovnakom poli TJ aj číselné kerningy medzi nimi zostanú bajtovo identické. Vezmite predchádzajúce rozloženie: nájdenie B v [(AB) -120 (CD)] TJArrayElementIndex 0, SourceByteOffset 1 a SourceByteLength 1. Nahraďte ho Z a emitovaný obsah obsahuje (AZ), po ktorom stále nasledujú -120 a (CD), obe bez zásahu. Regresná suite presne toto overuje, pretože tvrdenie „kerning sme zachovali“ je presne ten typ tvrdenia, ktoré potichu prestane platiť

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 v starom zozname je teraz zastaraná. Extrahujte znova.
    Lib.ReleaseTextBlocks(ListID);
    ListID:= Lib.ExtractPageTextBlocks(3);
  End
  Else If Lib.LastErrorCode= PDFLIB_ERROR_TEXT_LOCATION_STALE Then
    // Vrstva sa od extrakcie zmenila pod našimi rukami
  Else If Lib.LastErrorCode= PDFLIB_ERROR_TEXT_LOCATION_READ_ONLY Then
    // Príznak, ktorý sme zabudli skontrolovať, alebo príznak pridaný neskoršou verziou
End;

Dva prevádzkové detaily sa oplatí vryť si do pamäti. Volanie dočasne prepne na stránku, z ktorej bol textový zoznam extrahovaný, a pri úspechu aj pri zlyhaní obnoví predtým vybranú stránku, takže ticho neposunie váš kurzor. Pri úspechu zároveň vymaže snapshoty elementov stránky, čím zneplatní všetky handle, ktoré ste držali z predchádzajúceho enumeračného prechodu

Ktoré znaky nemožno upraviť?

Šesť kategórií a knižnica každú pomenúva v bitovej maske Flags namiesto vágneho zlyhania. Je to dôležitejšie než happy path, pretože v reálnych dokumentoch sú nemapovateľné prípady bežné a každý má iný dôvod

  • PDF_TEXT_CHAR_CONTENT_LOCATION_LIGATURE: niekoľko pozícií extrahovaného UTF-16 sa rozvinie z jedného zdrojového glyfu. Záznam /ToUnicode, ktorý mapuje jeden kód na fi, vám dá dva znaky zdieľajúce jeden rozsah bajtov, takže s nimi zaobchádzajte ako s jedným zdrojovým glyfom a rozsah upravte raz
  • PDF_TEXT_CHAR_CONTENT_LOCATION_GENERATED: znak bol syntetizovaný počas layoutu. Typickým prípadom sú odvodené medzery medzi slovami a tie nemajú vôbec žiadne zdrojové bajty, takže SourceByteOffset sa vráti ako -1 a SourceByteLength ako 0
  • PDF_TEXT_CHAR_CONTENT_LOCATION_ACTUALTEXT: text, ktorý čítate, pochádza z náhrady /ActualText. Neexistuje jednoznačné spätné mapovanie nahradeného stringu na zdrojové bajty, takže location je iba diagnostická
  • PDF_TEXT_CHAR_CONTENT_LOCATION_NESTED: glyf je vo Form XObject. Bajty sú adresovateľné, ale Form môže kresliť viacero stránok, takže jeho úprava cez high-level API by bola úpravou, o ktorú ste nežiadali
  • PDF_TEXT_CHAR_CONTENT_LOCATION_TRANSCODED: operand bol hex string s byte-order markom UTF-16BE, ktorý existujúca extrakčná cesta dekóduje pred mapovaním fontu. Offsety v dekódovanom výsledku už neadresujú pôvodné bajty, preto sa platný príznak zruší
  • PDF_TEXT_CHAR_CONTENT_LOCATION_CROSS_LAYER: string operand a jeho text-showing operator žijú v dvoch rôznych streamoch

Posledný prípad si zaslúži vlastnú vetu, pretože inžinieri pravidelne predpokladajú, že nemôže nastať. ISO 32000-1 §7.8.2 hovorí, že streamy v poli stránky /Contents sa zreťazia a hranica medzi nimi musí spadnúť iba na lexikálnu hranicu. Preto BT /F1 16 Tf 220 340 Td (CrossLayer) v jednom streame a Tj ET v ďalšom predstavujú úplne legálnu stránku. Mapovanie ponechá diagnostickú pozíciu, ale označí ju iba na čítanie, pretože index inštrukcie operátora patrí inej vrstve než bajty operandu a použitie jedného na adresovanie druhého by poškodilo súbor

Ako knižnica vie, že mapovanie je stále platné?

Pomocou fingerprintov kontrolovaných bezprostredne pred zápisom. Každý extrakčný zoznam zaznamená zdrojovú stránku a pre každú content layer dĺžku vrstvy aj dva nezávislé rolling hashe: hash FNV-1a a XOR hash v štýle DJB2. Skôr než ReplaceTextBlockCharSourceBytes čokoľvek parsuje, znovu načíta cieľovú vrstvu a porovná všetky tri hodnoty. Akákoľvek zmena bajtu kdekoľvek v tejto vrstve vráti PDFLIB_ERROR_TEXT_LOCATION_STALE a zápis sa neuskutoční. Je to zámerne konzervatívne: kontrola je na úrovni vrstvy, nie inštrukcie, takže aj nesúvisiaca úprava inde v tom istom content streame zneplatní vašu location. To je správny trade-off: offset do streamu, ktorý sa posunul čo i len o jeden bajt, nie je takmer správny, ale tichá korupcia. Rovnaká disciplína riadi zvyšok editačnej plochy vrátane state trackera content streamu pre CTM a clipping. Po každej úspešnej náhrade zoznam zahodťe a extrahujte znova

Mapovanie iba na čítanie cez Direct Access

DAGetTextBlockCharContentLocation vám dá identický record pre stránku otvorenú cez Direct Access path s identickou slovnou zásobou flagov. Je diagnostický už svojou konštrukciou: ReplaceTextBlockCharSourceBytes pracuje s vybraným editovateľným dokumentom a Direct Access je read path. Lokalizačné údaje prežijú v zozname textových blokov aj po zatvorení file handle, vďaka čomu sa dajú použiť na 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);
    // Location zostávajú čitateľné po DACloseFile
  Finally
    Lib.DAReleaseTextBlocks(DirectList);
  End;
Finally
  Lib.DACloseFile(FileHandle);
End;

Použite to na odpovede na otázky, nie na zmenu vecí. Ktoré stránky obsahujú text, ktorý by ste nikdy nemohli upraviť na mieste? Koľko z tohto korpusu prichádza s override /ActualText? Ktorý vendor rozdeľuje operátory medzi content layers? Keď má každý znak adresu, tieto otázky sú lacné a oplatí sa ich položiť skôr, než sa zaviažete ku korekčnému pipeline

Kde sa bodová úprava končí

Bodová úprava je skalpel, nie textový engine. Mení bajty na mieste, takže náhradný text širší alebo užší než originál sa nepreformátuje, nezalomí a neaktualizuje kerny okolo seba. Nahradenie jednej číslice inou v monospaced poli je dobrý kandidát. Prepis odseku nie. A rozhodne to nie je bezpečnostný nástroj: prepísanie bajtov glyfov ponechá pôvodné bajty obnoviteľné z histórie revízií súboru, takže všetko s požiadavkou dôvernosti patrí do skutočnej redakcie, ktorá obsah odstraňuje namiesto jeho prekrytia. Za tieto obmedzenia dostanete poctivosť. Každý znak má buď bajtovú adresu, na ktorej môžete konať, alebo pomenovaný flag vysvetľujúci, prečo ju nemá, a kontrola fingerprintu zmení zastaranú mapu na tvrdú chybu namiesto poškodenej stránky. Mapovanie znaku na content bytes a náhrada zdrojových bajtov na mieste sú súčasťou plochy extrakcie textu a úprav obsahu v PDF Library for Delphi, natívnej Object Pascal PDF knižnici pre Delphi, C++Builder a Lazarus