Technisch artikel

PDF-tekst naar content-streambytes mappen in Delphi

Eén verkeerd teken in een factuurnummer en het enige edit-primitief dat je bij de hand hebt, schrijft de hele textrun opnieuw. PDF Library for Delphi dicht dat gat: GetTextBlockCharContentLocation koppelt elke geëxtraheerde UTF-16-positie terug aan de content-streaminstructie, operand en gecodeerde byte-range die het teken heeft geproduceerd, en ReplaceTextBlockCharSourceBytes overschrijft alleen die range. Tekstextractie gooit normaal alles weg wat je hiervoor nodig zou hebben. Je krijgt Unicode, breedtes en geometrie, maar de herkomst verdampt, zodat het teken op positie 7 van block 3 gewoon een teken is. Welke stream het heeft voortgebracht, welke instructie, welke operand en welke byte binnen die operand: weg. Elke point-editstrategie die daarop wordt gebouwd moet raden, meestal door in de gedecodeerde content naar een substring te zoeken en te hopen dat die precies één keer voorkomt. Op een echte pagina is dat niet zo

Waarom ruïneert het herschrijven van een hele textrun de pagina?

Omdat de run niet alleen uit tekst bestaat. De text-showing-operators in ISO 32000-1 §9.4.3 omvatten TJ, waarvan de operand een array is die strings afwisselt met numerieke aanpassingen, en juist die getallen verzorgen de typesetting. Een regel die is opgemaakt als [(AB) -120 (CD)] TJ bevat een kern van 120 duizendsten van een em tussen de twee strings. Geef je een nieuwe Tj met de samengevoegde tekst uit, dan is de kern weg, reflowt de regel een fractie en verschuift op een form de waarde uit zijn vak. Hetzelfde bezwaar geldt voor het font: de operandbytes zijn codes in de encoding die Tf heeft geselecteerd, geen Unicode, en bij een composite font kunnen het two-byte CIDs zijn zonder relatie tot het teken dat je uit de extractor leest. Genereer je de run opnieuw, dan moet je goed zitten met de encoding van het font, de /ToUnicode-map en de glyph coverage. Met point editing omzeil je dat alles door nooit het bytedomein te verlaten

Wat retourneert GetTextBlockCharContentLocation?

De methode resolveert één teken naar een record met negen velden, en elk veld is een adres en geen waarde. ContentLayer is de 1-based index in de /Contents-array van de pagina, of 0 wanneer het teken uit nested content komt. StreamObjectNumber en StreamGeneration identificeren de omvattende stream. InstructionIndex is de 0-based positie in het gedecodeerde contentprogramma, OperandIndex de operand van de text string en ArrayElementIndex het element binnen een TJ-array, of -1 voor een directe stringoperand. SourceByteOffset en SourceByteLength benoemen vervolgens de byte-range binnen die gedecodeerde 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 en CharPos komen uit je eigen scan van GetTextBlockText
      If Lib.GetTextBlockCharContentLocation(ListID, Block, CharPos,
        ContentLayer, StreamObjectNumber, StreamGeneration,
        InstructionIndex, OperandIndex, ArrayElementIndex,
        SourceByteOffset, SourceByteLength, Flags)= 1 Then
      Begin
        // ContentLayer = 0 betekent dat de glyph in een geneste Form XObject zit
        // ArrayElementIndex = -1 betekent een gewone Tj-operand, geen TJ-array
      End;
    Finally
      Lib.ReleaseTextBlocks(ListID);
    End;
  Finally
    Lib.Free;
  End;
End;

De lookup kost niets op querymoment. Terwijl de renderer elke contentlaag decodeert, registreert hij de logische spans waar hij doorheen loopt, zodat een positiequery een binary search over een geordende intervalslijst is in plaats van een lineaire scan van elke contentspan per teken. Er wordt niets opnieuw geparsed wanneer je ernaar vraagt; de map is opgebouwd tijdens de extractiepass waarvoor je al hebt betaald. Als je al hits opsomt met PDF-tekstsearch die hitcoördinaten retourneert, kost het bijna niets om per hit een contentlocatie toe te voegen

Bytes bewerken, geen Unicode

ReplaceTextBlockCharSourceBytes neemt een AnsiString met raw replacement-bytes in de actieve PDF-fontencoding. Dat is het hele ontwerp, en het is bewust zo. Er wordt niets getranscodeerd, niets opnieuw gecodeerd en niets over het font geraden. De library splice’t jouw bytes over de benoemde range van de targetstring en emit de omvattende contentlaag opnieuw. Aangrenzende strings in dezelfde TJ-array en de numerieke kerns ertussen blijven byte-identiek. Neem de layout hierboven: als je de B in [(AB) -120 (CD)] TJ lokaliseert, krijg je ArrayElementIndex 0, SourceByteOffset 1 en SourceByteLength 1. Vervang je die door Z, dan bevat de uitgegeven content (AZ), nog steeds gevolgd door -120 en (CD), beide onaangeraakt. De regressiesuite controleert precies dat, want "we hebben de kerning behouden" is het soort bewering dat ongemerkt ophoudt waar te zijn

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
    // Elke locatie in de oude lijst is nu stale. Extraheer opnieuw
    Lib.ReleaseTextBlocks(ListID);
    ListID:= Lib.ExtractPageTextBlocks(3);
  End
  Else If Lib.LastErrorCode= PDFLIB_ERROR_TEXT_LOCATION_STALE Then
    // De laag is sinds de extractie onder ons gewijzigd
  Else If Lib.LastErrorCode= PDFLIB_ERROR_TEXT_LOCATION_READ_ONLY Then
    // Een flag die we niet hebben gecontroleerd, of een flag uit een latere versie
End;

Twee operationele details moet je goed onthouden. De call schakelt tijdelijk naar de pagina waarop de text list is geëxtraheerd en herstelt de eerder geselecteerde pagina zowel bij succes als bij falen, zodat je cursor niet ongemerkt wordt verplaatst. Bij succes worden bovendien de snapshots van page elements gewist, waardoor alle handles die je uit een eerdere enumeratiepass vasthield ongeldig worden

Welke tekens kunnen niet worden bewerkt?

Zes categorieën, en de library benoemt ze stuk voor stuk in de Flags-bitmasker in plaats van vaag te falen. Dat is belangrijker dan het happy path, want in echte documenten komen de niet-mappable gevallen vaak voor en elk geval heeft een andere oorzaak

  • PDF_TEXT_CHAR_CONTENT_LOCATION_LIGATURE: meerdere geëxtraheerde UTF-16-posities komen voort uit één source glyph. Een /ToUnicode-entry die één code naar fi mapt, geeft je twee tekens met dezelfde byte-range. Behandel ze dus als één source glyph en bewerk de range één keer
  • PDF_TEXT_CHAR_CONTENT_LOCATION_GENERATED: het teken is tijdens de layout gesynthetiseerd. Afgeleide woordspaties zijn het gebruikelijke geval en hebben helemaal geen sourcebytes, dus SourceByteOffset komt terug als -1 en SourceByteLength als 0
  • PDF_TEXT_CHAR_CONTENT_LOCATION_ACTUALTEXT: de tekst die je leest komt uit een /ActualText-replacement. Er bestaat geen unieke terugmapping van de vervangen string naar sourcebytes, dus de locatie is alleen diagnostisch
  • PDF_TEXT_CHAR_CONTENT_LOCATION_NESTED: de glyph zit in een Form XObject. De bytes zijn adresseerbaar, maar de Form kan door meerdere pagina's worden getekend, dus hem via de high-level API bewerken zou een edit zijn waar je niet om hebt gevraagd
  • PDF_TEXT_CHAR_CONTENT_LOCATION_TRANSCODED: de operand was een hexstring met een UTF-16BE byte-order mark, die de bestaande extractieroute decodeert vóór de fontmapping. Offsets in het gedecodeerde resultaat adresseren de oorspronkelijke bytes niet meer, dus de valid-flag wordt gewist
  • PDF_TEXT_CHAR_CONTENT_LOCATION_CROSS_LAYER: de stringoperand en zijn text-showing-operator staan in twee verschillende streams

Dat laatste verdient een eigen zin, omdat engineers er geregeld van uitgaan dat het niet kan gebeuren. ISO 32000-1 §7.8.2 zegt dat de streams in een /Contents-array van een pagina worden samengevoegd, en dat de scheiding ertussen alleen op een lexicale grens hoeft te vallen. BT /F1 16 Tf 220 340 Td (CrossLayer) in één stream en Tj ET in de volgende is dus een volkomen legale pagina. De mapping houdt de diagnostische positie bij maar markeert die als read-only, omdat de instructie-index van de operator bij een andere laag hoort dan de bytes van de operand en de file beschadigd zou raken als je de ene gebruikt om de andere te adresseren

Hoe weet de library dat de map nog geldig is?

Met fingerprints die onmiddellijk vóór het schrijven worden gecontroleerd. Elke extractielijst registreert de bronpagina plus per contentlaag de laaglengte en twee onafhankelijke rolling hashes: een FNV-1a-hash en een DJB2-achtige xor-hash. Voordat ReplaceTextBlockCharSourceBytes iets parseert, leest het de targetlaag opnieuw en vergelijkt het alle drie waarden. Elke bytewijziging ergens in die laag retourneert PDFLIB_ERROR_TEXT_LOCATION_STALE en de write vindt niet plaats. Dat is bewust conservatief: de check gebeurt per laag en niet per instructie, dus ook een ongerelateerde edit elders in dezelfde contentstream maakt je locatie ongeldig. Dat is de juiste trade-off: een offset in een stream die zelfs maar één byte is verschoven, is geen bijna-goed resultaat maar stille corruptie. Dezelfde discipline geldt voor de rest van het editoppervlak, inclusief de content-stream state tracker voor CTM en clipping. Verwijder na elke geslaagde replacement de lijst en extraheer opnieuw

Read-only mapping via Direct Access

DAGetTextBlockCharContentLocation geeft je hetzelfde record voor een pagina die via het Direct Access-pad is geopend, met exact dezelfde vlagwoordenschat. Dat is by construction alleen diagnostisch: ReplaceTextBlockCharSourceBytes werkt op het geselecteerde bewerkbare document, en Direct Access is een read path. De locatiedata blijft in de text block list bestaan nadat de file handle is gesloten, waardoor je die voor offline auditing kunt gebruiken

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);
    // Locaties blijven leesbaar na DACloseFile
  Finally
    Lib.DAReleaseTextBlocks(DirectList);
  End;
Finally
  Lib.DACloseFile(FileHandle);
End;

Gebruik dit om vragen te beantwoorden en niet om dingen te veranderen. Welke pagina's bevatten tekst die je nooit in place zou kunnen bewerken? Hoeveel van dit corpus komt binnen met /ActualText-overrides? Welke leverancier splitst operators over contentlagen? Dat zijn goedkope queries zodra elk teken een adres heeft, en ze zijn de moeite waard voordat je je vastlegt op een correctiepipeline

Waar stopt point editing?

Point editing is een scalpel en geen text engine. Het verandert bytes in place, dus replacementtekst die breder of smaller is dan het origineel reflowt niet, wrapt niet opnieuw en werkt de kerns eromheen niet bij. Eén cijfer door een ander vervangen in een monospaced veld past goed. Een alinea opnieuw typen niet. En het is nadrukkelijk geen securitytool: glyphbytes overschrijven laat de oorspronkelijke bytes terughaalbaar uit de revision history van het bestand, dus alles met een vertrouwelijkheidseis hoort thuis in echte redactie die content verwijdert in plaats van bedekt. Daarvoor krijg je eerlijkheid terug. Elk teken heeft óf een byteadres waarop je kunt handelen óf een benoemde flag die vertelt waarom dat niet kan, en de fingerprintcheck maakt een verouderde map tot een harde fout in plaats van een beschadigde pagina. Character-to-content-byte mapping en in-place source byte replacement worden meegeleverd als onderdeel van het text-extractie- en content-editoppervlak van PDF Library for Delphi, de native Object Pascal PDF-library voor Delphi, C++Builder en Lazarus