Teknisk artikel

Mappa PDF-text till innehållsbyte i Delphi

Ett enda felaktigt tecken i ett fakturanummer, och det enda redigeringsprimitivet som finns till hands skriver om hela textrun. PDF Library for Delphi täpper till luckan: GetTextBlockCharContentLocation mappar varje extraherad UTF-16-position tillbaka till innehållsströmsinstruktionen, operanden och det kodade byteintervallet som skapade den, och ReplaceTextBlockCharSourceBytes skriver över bara det intervallet. Textextrahering kastar normalt bort allt du skulle behöva för detta. Du får Unicode, bredder och geometri, och ursprunget försvinner, så tecknet på position 7 i block 3 är bara ett tecken. Vilken ström skapade det, vilken instruktion, vilken operand, vilket byte i operanden: borta. Varje strategi för punktredigering ovanpå det måste gissa, vanligtvis genom att söka efter en avkodad delsträng i innehållet och hoppas att den förekommer exakt en gång. På en verklig sida gör den inte det

Varför förstör en omskrivning av en hel textrun sidan?

För att runen inte bara är text. Textvisningsoperatorerna i ISO 32000-1 §9.4.3 innehåller TJ, vars operand är en array som varvar strängar med numeriska justeringar, och de talen är typografin. En rad som läggs ut som [(AB) -120 (CD)] TJ bär på en knipning på 120 tusendelar av en em mellan de två strängarna. Skriv ut en ny Tj med den sammanfogade texten och knipningen är borta, raden flyter en aning och på ett formulär glider värdet ut ur sin ruta. Samma invändning gäller typsnittet: operandbytena är koder i den kodning som Tf valde, inte Unicode, och i ett sammansatt typsnitt kan de vara tvåbyte-CID:n utan relation till tecknet som extraheraren gav dig. Återskapa runen och du måste ha rätt om typsnittets kodning, dess /ToUnicode-karta och dess glyftäckning. Punktredigering kringgår allt detta genom att aldrig lämna byte-domänen

Vad returnerar GetTextBlockCharContentLocation?

Metoden löser ett tecken till en post med nio fält, och varje fält är en adress snarare än ett värde. ContentLayer är det 1-baserade indexet i sidans /Contents-array, eller 0 när tecknet kom från nästlat innehåll. StreamObjectNumber och StreamGeneration identifierar den innehållande strömmen. InstructionIndex är den 0-baserade positionen i det avkodade innehållsprogrammet, OperandIndex textsträngsoperanden och ArrayElementIndex elementet inne i en TJ-array eller -1 för en direkt strängoperand. SourceByteOffset och SourceByteLength namnger sedan byteintervallet inne i den avkodade strängen

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 och CharPos kommer från din egen genomgång av GetTextBlockText
      If Lib.GetTextBlockCharContentLocation(ListID, Block, CharPos,
        ContentLayer, StreamObjectNumber, StreamGeneration,
        InstructionIndex, OperandIndex, ArrayElementIndex,
        SourceByteOffset, SourceByteLength, Flags)= 1 Then
      Begin
        // ContentLayer = 0 betyder att glyfen finns i ett nästlat Form XObject
        // ArrayElementIndex = -1 betyder en vanlig Tj-operand, inte en TJ-array
      End;
    Finally
      Lib.ReleaseTextBlocks(ListID);
    End;
  Finally
    Lib.Free;
  End;
End;

Uppslagningen kostar ingenting vid frågetillfället. Medan renderaren avkodar varje innehållslager registrerar den de logiska intervall den går igenom, så en positionsfråga är en binärsökning över en ordnad intervallista i stället för en linjär skanning av varje innehållsintervall per tecken. Ingenting parsas om när du frågar; kartan byggdes under den extraheringspassage du redan betalat för. Om du redan räknar upp träffar med PDF-textsökning som returnerar träffkoordinater är det nästan gratis att lägga till en innehållsplats per träff

Redigera byte, inte Unicode

ReplaceTextBlockCharSourceBytes tar en AnsiString med råa ersättningsbyte i det aktiva PDF-typsnittets kodning. Det är hela konstruktionen, och den är avsiktlig. Ingenting transkodar, ingenting kodar om, ingenting gissar på typsnittet. Biblioteket skarvar in dina byte över det namngivna intervallet i målsträngen och skriver ut det innehållslager som innehåller den. Intilliggande strängar i samma TJ-array och de numeriska knipningarna mellan dem lämnas byteidentiska. Ta layouten ovan: när B i [(AB) -120 (CD)] TJ lokaliseras blir ArrayElementIndex 0, SourceByteOffset 1 och SourceByteLength 1. Ersätt det med Z och det utskrivna innehållet innehåller (AZ), fortfarande följt av -120 och (CD), båda orörda. Regressionssviten hävdar exakt det, eftersom påståendet "vi bevarade kerningen" är av den sort som tyst slutar vara sant

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
    // Alla platser i den gamla listan är nu föråldrade. Extrahera igen.
    Lib.ReleaseTextBlocks(ListID);
    ListID:= Lib.ExtractPageTextBlocks(3);
  End
  Else If Lib.LastErrorCode= PDFLIB_ERROR_TEXT_LOCATION_STALE Then
    // Lagret ändrades under oss sedan extraheringen
  Else If Lib.LastErrorCode= PDFLIB_ERROR_TEXT_LOCATION_READ_ONLY Then
    // En flagga vi missade att kontrollera, eller en flagga som lagts till i en senare version
End;

Två praktiska detaljer är värda att ta till sig. Anropet växlar tillfälligt till sidan som textlistan extraherades från och återställer den tidigare valda sidan både vid framgång och vid fel, så det flyttar inte markören i tysthet. Vid framgång rensar det dessutom ögonblicksbilderna av sidelementen, vilket ogiltigförklarar alla handtag du höll från en tidigare uppräkningspassage

Vilka tecken kan inte redigeras?

Sex kategorier, och biblioteket namnger var och en i bitmasken Flags i stället för att misslyckas vagt. Det betyder mer än den lyckliga vägen, eftersom de omappningsbara fallen är vanliga i riktiga dokument och vart och ett har sin egen orsak

  • PDF_TEXT_CHAR_CONTENT_LOCATION_LIGATURE: flera extraherade UTF-16-positioner expanderar från en enda källglyf. En /ToUnicode-post som mappar en kod till fi ger dig två tecken som delar ett byteintervall, så behandla dem som en enda källglyf och redigera intervallet en gång
  • PDF_TEXT_CHAR_CONTENT_LOCATION_GENERATED: tecknet syntetiserades under layouten. Härledda ordmellanslag är det vanliga fallet, och de har inga källbyte alls, så SourceByteOffset kommer tillbaka som -1 och SourceByteLength som 0
  • PDF_TEXT_CHAR_CONTENT_LOCATION_ACTUALTEXT: texten du läste kom från en /ActualText-ersättning. Det finns ingen unik omvänd mappning från den ersatta strängen till källbyte, så platsen är endast diagnostisk
  • PDF_TEXT_CHAR_CONTENT_LOCATION_NESTED: glyfen finns inne i ett Form XObject. Bytena är adresserbara, men Form kan ritas av flera sidor, så att redigera det genom högnivå-API:et skulle vara en redigering du inte bad om
  • PDF_TEXT_CHAR_CONTENT_LOCATION_TRANSCODED: operanden var en hexsträng med en UTF-16BE-byteordningsmarkör, som den befintliga extraheringsvägen avkodar före typsnittsmappningen. Offset i det avkodade resultatet adresserar inte längre originalbytena, så giltighetsflaggan rensas
  • PDF_TEXT_CHAR_CONTENT_LOCATION_CROSS_LAYER: strängoperanden och dess textvisande operator finns i två olika strömmar

Det sista fallet förtjänar en egen mening, eftersom utvecklare rutinmässigt antar att det inte kan hända. ISO 32000-1 §7.8.2 säger att strömmarna i en sidas /Contents-array sammanfogas, och gränsen mellan dem behöver bara hamna vid en lexikal gräns. Så BT /F1 16 Tf 220 340 Td (CrossLayer) i en ström och Tj ET i nästa är en helt laglig sida. Mappningen behåller den diagnostiska positionen men markerar den som skrivskyddad, eftersom instruktionens index tillhör ett annat lager än operandens byte och att använda det ena för att adressera det andra skulle förstöra filen

Hur vet biblioteket att kartan fortfarande är giltig?

Fingeravtryck, kontrollerade omedelbart före skrivningen. Varje extraheringslista registrerar källsidan plus, för varje innehållslager, lagerlängden och två oberoende rullande hashar: en FNV-1a-hash och en xor-hash av typen DJB2. Innan ReplaceTextBlockCharSourceBytes parsar något läser den målskiktet på nytt och jämför alla tre värden. Varje byteändring någonstans i lagret returnerar PDFLIB_ERROR_TEXT_LOCATION_STALE och skrivningen genomförs inte. Det är avsiktligt konservativt: kontrollen är per lager, inte per instruktion, så en orelaterad redigering någon annanstans i samma innehållsström ogiltigförklarar också din plats. Det är rätt avvägning: en offset i en ström som har flyttats ens ett byte är inte ett nästan rätt träffläge, utan en tyst korruption. Samma disciplin styr resten av redigeringsytan, inklusive innehållsströmmens tillståndsspårare för CTM och klippning. Efter varje lyckad ersättning ska listan kasseras och extraheringen göras om

Skrivskyddad mappning genom Direct Access

DAGetTextBlockCharContentLocation ger dig samma post för en sida som öppnats genom Direct Access-vägen, med samma flagguppsättning. Den är diagnostisk av konstruktionen: ReplaceTextBlockCharSourceBytes arbetar på det valda redigerbara dokumentet, och Direct Access är en läsväg. Platsdata överlever i textblocklistan efter att filhandtaget stängts, vilket gör den användbar för offlinegranskning

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);
    // Platserna förblir läsbara efter DACloseFile
  Finally
    Lib.DAReleaseTextBlocks(DirectList);
  End;
Finally
  Lib.DACloseFile(FileHandle);
End;

Använd det för att besvara frågor, inte för att ändra saker. Vilka sidor innehåller text som du aldrig kan redigera på plats? Hur mycket av detta korpus kommer med /ActualText-överskrivningar? Vilken leverantörs utdata delar operatorer över innehållslager? Det är billiga frågor när varje tecken har en adress, och de är värda att köra innan du binder dig till en korrigeringspipeline

Var punktredigering slutar

Punktredigering är en skalpell, inte en textmotor. Den ändrar byte på plats, så ersättningstext som är bredare eller smalare än originalet flyter inte om, radbryts inte på nytt och uppdaterar inte knipningen runt sig. Att byta en siffra mot en annan i ett monospacefält passar bra. Att skriva om ett stycke gör det inte. Och det är uttryckligen inte ett säkerhetsverktyg: att skriva över glyfbyte lämnar originalbytena möjliga att återvinna från filens revisionshistorik, så allt med ett konfidentialitetskrav hör hemma i riktig redigering som tar bort innehåll i stället för att täcka över det. I utbyte mot dessa begränsningar får du ärlighet. Varje tecken har antingen en byteadress du kan agera på eller en namngiven flagga som säger varför det inte har det, och fingeravtryckskontrollen gör en gammal karta till ett hårt fel i stället för en förstörd sida. Mappning från tecken till innehållsbyte och ersättning av källbyte på plats levereras som en del av ytan för textextrahering och innehållsredigering i PDF Library for Delphi, det inbyggda Object Pascal PDF-biblioteket för Delphi, C++Builder och Lazarus