Teknisk artikel

Knyt PDF-tekst til content-stream-bytes i Delphi

Ét forkert tegn i et fakturanummer, og det eneste redigeringsprimitiv, du har ved hånden, omskriver hele tekst-runnet. PDF Library for Delphi lukker dette hul: GetTextBlockCharContentLocation knytter hver ekstraheret UTF-16-position tilbage til content-stream-instruktionen, operanden og det kodede byteområde, der skabte den, og ReplaceTextBlockCharSourceBytes overskriver kun dette område. Tekstekstraktion smider normalt alt det væk, du ville have brug for. Du får Unicode, bredder og geometri, mens proveniensen forsvinder, så tegnet på position 7 i blok 3 bare er et tegn. Hvilken stream skabte det, hvilken instruktion, hvilken operand, hvilken byte inde i operanden: væk. Enhver strategi til punktredigering oven på det må gætte, som regel ved at søge efter en substring i den afkodede content og håbe, at den forekommer præcis én gang. På en rigtig side gør den ikke

Hvorfor ødelægger omskrivning af et helt tekst-run siden?

Fordi runnet ikke kun er tekst. De operatorer, der viser tekst, i ISO 32000-1 §9.4.3 omfatter TJ, hvis operand er et array, der fletter strenge sammen med numeriske justeringer, og det er disse tal, der står for typografien. En linje, der er layoutet som [(AB) -120 (CD)] TJ, indeholder en kerning på 120 tusindedele af en em mellem de to strenge. Udsender du en ny Tj med den sammenkædede tekst, er kerningen væk, linjen flytter sig en anelse, og på en formular glider værdien ud af sin boks. Den samme indvending gælder fonten: Operandbytes er koder i den encoding, som Tf valgte, ikke Unicode, og for en composite font kan de være tobytes-CIDer uden forbindelse til det tegn, du læser ud af ekstraktoren. Regenererer du runnet, skal du ramme rigtigt med fontens encoding, dens /ToUnicode-map og dens glyph-dækning. Punktredigering går uden om alt dette ved aldrig at forlade byte-domænet

Hvad returnerer GetTextBlockCharContentLocation?

Metoden opløser ét tegn til en record med ni felter, og hvert felt er en adresse snarere end en værdi. ContentLayer er det 1-baserede indeks i sidens /Contents-array eller 0, når tegnet kom fra nested content. StreamObjectNumber og StreamGeneration identificerer den omsluttende stream. InstructionIndex er den 0-baserede position i det afkodede content-program, OperandIndex er tekststrengsoperanden, og ArrayElementIndex er elementet inde i et TJ-array eller -1 for en direkte strengoperand. SourceByteOffset og SourceByteLength angiver derefter byteområdet inde i den afkodede streng

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 og CharPos kommer fra din egen gennemgang af GetTextBlockText
      If Lib.GetTextBlockCharContentLocation(ListID, Block, CharPos,
        ContentLayer, StreamObjectNumber, StreamGeneration,
        InstructionIndex, OperandIndex, ArrayElementIndex,
        SourceByteOffset, SourceByteLength, Flags)= 1 Then
      Begin
        // ContentLayer = 0 betyder, at glyphen ligger i en nested Form XObject
        // ArrayElementIndex = -1 betyder en almindelig Tj-operand, ikke et TJ-array
      End;
    Finally
      Lib.ReleaseTextBlocks(ListID);
    End;
  Finally
    Lib.Free;
  End;
End;

Opslaget koster ingenting på forespørgselstidspunktet. Mens rendereren afkoder hvert content-lag, registrerer den de logiske spans, den går igennem, så en positionsforespørgsel er en binær søgning over en ordnet interval-liste i stedet for en lineær scanning af hvert content-span for hvert tegn. Intet parses igen, når du spørger; mappet blev bygget under den ekstraktionspassage, du allerede har betalt for. Hvis du allerede enumererer hits med PDF-tekstsøgning, der returnerer hit-koordinater, koster det næsten ingenting at tilføje en content-location pr. hit

Redigér bytes, ikke Unicode

ReplaceTextBlockCharSourceBytes tager en AnsiString med rå erstatningsbytes i den aktive PDF-fontencoding. Det er hele designet, og det er med vilje. Intet transkoder, intet enkoder igen, og intet gætter på fonten. Biblioteket splejser dine bytes ind over det navngivne område i målstrengen og udsender det omsluttende content-lag igen. Nabostrengene i det samme TJ-array og de numeriske kerningsværdier mellem dem forbliver byte-identiske. Tag layoutet ovenfor: Når B i [(AB) -120 (CD)] TJ lokaliseres, får du ArrayElementIndex 0, SourceByteOffset 1 og SourceByteLength 1. Erstat det med Z, og det udsendte content indeholder (AZ), stadig efterfulgt af -120 og (CD), begge urørte. Regressionspakken hævder præcis det, fordi udsagnet om, at vi bevarede kerningen, er den slags påstand, der stille holder op med at være sand

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
    // Alle locations i den gamle liste er nu forældede. Ekstrahér igen.
    Lib.ReleaseTextBlocks(ListID);
    ListID:= Lib.ExtractPageTextBlocks(3);
  End
  Else If Lib.LastErrorCode= PDFLIB_ERROR_TEXT_LOCATION_STALE Then
    // Laget blev ændret under os siden ekstraktionen
  Else If Lib.LastErrorCode= PDFLIB_ERROR_TEXT_LOCATION_READ_ONLY Then
    // Et flag, vi ikke kontrollerede, eller et flag tilføjet i en senere version
End;

To operationelle detaljer er værd at tage til sig. Kaldet skifter midlertidigt til den side, tekstlisten blev ekstraheret fra, og gendanner den tidligere valgte side både ved succes og ved fejl, så det ikke lydløst flytter din cursor. Ved succes rydder det også sidens element-snapshots, hvilket ugyldiggør alle handles, du holdt fra en tidligere enumeration-pass

Hvilke tegn kan ikke redigeres?

Seks kategorier, og biblioteket navngiver hver af dem i Flags-bitmasken i stedet for at fejle uklart. Det betyder mere end happy path, fordi de unmappable tilfælde er almindelige i virkelige dokumenter, og hvert tilfælde har sin egen årsag

  • PDF_TEXT_CHAR_CONTENT_LOCATION_LIGATURE: Flere ekstraherede UTF-16-positioner foldes ud fra én kildeglyph. En /ToUnicode-post, der mapper én kode til fi, giver dig to tegn, som deler ét byteområde, så behandl dem som én kildeglyph, og redigér området én gang
  • PDF_TEXT_CHAR_CONTENT_LOCATION_GENERATED: Tegnet blev syntetiseret under layout. Udledte ordmellemrum er det typiske tilfælde, og de har slet ingen kildebytes, så SourceByteOffset kommer tilbage som -1 og SourceByteLength som 0
  • PDF_TEXT_CHAR_CONTENT_LOCATION_ACTUALTEXT: Den tekst, du læste, kom fra en /ActualText-erstatning. Der findes ingen entydig reverse mapping fra den substituerede streng til kildebytes, så locationen er kun diagnostisk
  • PDF_TEXT_CHAR_CONTENT_LOCATION_NESTED: Glyphen ligger inde i et Form XObject. Bytes er adresserbare, men Form kan tegnes af flere sider, så redigering gennem high-level-APIet ville være en redigering, du ikke bad om
  • PDF_TEXT_CHAR_CONTENT_LOCATION_TRANSCODED: Operanden var en hexstreng med en UTF-16BE byte order mark, som den eksisterende ekstraktionsvej afkoder før fontmapping. Offsets i det afkodede resultat adresserer ikke længere de oprindelige bytes, så valid-flaget ryddes
  • PDF_TEXT_CHAR_CONTENT_LOCATION_CROSS_LAYER: String-operanden og dens text-showing-operator ligger i to forskellige streams

Det sidste tilfælde fortjener en sætning for sig, fordi ingeniører rutinemæssigt antager, at det ikke kan ske. ISO 32000-1 §7.8.2 siger, at streams i et sides /Contents-array sammenkædes, og at opdelingen mellem dem kun behøver at falde ved en leksikalsk grænse. Derfor er BT /F1 16 Tf 220 340 Td (CrossLayer) i én stream og Tj ET i den næste en helt lovlig side. Mappet bevarer den diagnostiske position, men markerer den som skrivebeskyttet, fordi instruktionens indeks hører til et andet lag end operandens bytes, og at bruge det ene til at adressere det andet ville beskadige filen

Hvordan ved biblioteket, at mappet stadig er gyldigt?

Fingeraftryk, kontrolleret umiddelbart før skrivningen. Hver ekstraktionsliste registrerer kildesiden plus for hvert content-lag lagets længde og to uafhængige rullende hashes: en FNV-1a-hash og en DJB2-lignende XOR-hash. Før ReplaceTextBlockCharSourceBytes parser noget, læser den mållaget igen og sammenligner alle tre værdier. Enhver byteændring hvor som helst i laget returnerer PDFLIB_ERROR_TEXT_LOCATION_STALE, og skrivningen finder ikke sted. Det er med vilje konservativt: Kontrollen sker pr. lag og ikke pr. instruktion, så en uvedkommende ændring et andet sted i den samme content-stream også ugyldiggør din location. Det er den korrekte afvejning: En offset ind i en stream, der har flyttet sig bare én byte, er ikke næsten rigtig, men stille korruption. Den samme disciplin styrer resten af redigeringsfladen, herunder content-stream-state-trackeren til CTM og clipping. Efter enhver vellykket erstatning skal du kassere listen og ekstrahere igen

Skrivebeskyttet mapping gennem Direct Access

DAGetTextBlockCharContentLocation giver dig den identiske record for en side, der er åbnet gennem Direct Access-vejen, med det identiske flag-vokabular. Den er kun diagnostisk af konstruktion: ReplaceTextBlockCharSourceBytes arbejder på det valgte redigerbare dokument, og Direct Access er en læsevej. Locationdataene overlever i tekstbloklisten, efter at filhandle er lukket, hvilket gør dem brugbare til 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 er stadig læsbare efter DACloseFile
  Finally
    Lib.DAReleaseTextBlocks(DirectList);
  End;
Finally
  Lib.DACloseFile(FileHandle);
End;

Brug det til at besvare spørgsmål i stedet for at ændre ting. Hvilke sider indeholder tekst, som du aldrig kunne redigere på stedet? Hvor stor en del af dette korpus kommer med /ActualText-overrides? Hvilken leverandørs output splitter operatorer på tværs af content-lag? Det er billige forespørgsler, når hvert tegn har en adresse, og de er værd at køre, før du binder dig til en korrektionspipeline

Hvor stopper punktredigering?

Punktredigering er en skalpel, ikke en tekstmotor. Den ændrer bytes på stedet, så erstatningstekst, der er bredere eller smallere end originalen, får ikke teksten til at flyde om, pakker den ikke om og opdaterer ikke kerningen omkring den. At erstatte ét ciffer med et andet i et monospace-felt passer godt. At skrive et afsnit om gør ikke. Og det er udtrykkeligt ikke et sikkerhedsværktøj: Når glyphbytes overskrives, kan de oprindelige bytes stadig gendannes fra filens revisionshistorik, så alt med et fortrolighedskrav hører hjemme i ægte redaction, der fjerner content i stedet for at dække det. Til gengæld får du ærlighed. Hvert tegn har enten en byteadresse, du kan handle på, eller et navngivet flag, der fortæller, hvorfor det ikke har det, og fingerprint-kontrollen gør et forældet map til en hård fejl i stedet for en ødelagt side. Mapping fra tegn til content-bytes og in-place-erstatning af kildebytes leveres som en del af tekstekstraktions- og content-redigeringsfladen i PDF Library for Delphi, det native Object Pascal PDF-bibliotek til Delphi, C++Builder og Lazarus