技術記事

DelphiでPDFテキストをcontent stream byteへ対応付ける

invoice numberの1文字が間違っているのに、手元にあるedit primitiveがtext run全体を書き換えるものしかないことがあります。PDF Library for Delphiはこの隙間を埋めます。GetTextBlockCharContentLocationは、抽出したUTF-16 positionごとに、それを生成したcontent-stream instruction、operand、encoded byte rangeへ戻る対応を作り、ReplaceTextBlockCharSourceBytesはそのrangeだけを上書きします。通常のtext extractionは、このために必要な情報を捨てます。Unicode、width、geometryは得られますが、provenanceは消えるため、block 3のposition 7にあるcharacterはただのcharacterです。どのstream、どのinstruction、どのoperand、operand内のどのbyteから来たのかは消えています。上に構築するpoint-edit戦略は、decode済みcontentからsubstringを探し、ちょうど1回だけ出ることを祈るしかありません。実際のpageではそうなりません

text run全体を書き換えるとpageが壊れる理由

runはtextだけではないからです。ISO 32000-1 §9.4.3のtext-showing operatorにはTJが含まれ、そのoperandはstringとnumeric adjustmentを交互に持つarrayです。これらのnumberがtypesettingです。[(AB) -120 (CD)] TJというline layoutには、2つのstringの間に120-thousandths-of-an-emのkernがあります。連結したtextで新しいTjを出力するとkernが消え、lineがわずかにreflowし、formではvalueがboxからずれます。fontにも同じ問題があります。operandのbyteはTfが選んだencodingのcodeであり、Unicodeではありません。composite fontでは、extractorが返すcharacterと関係のない2-byte CIDかもしれません。runを再生成するにはfontのencoding、/ToUnicode map、glyph coverageのすべてを正しく扱う必要があります。point editingはbyte domainから出ないことで、そのすべてを迂回します

GetTextBlockCharContentLocationが返すもの

このmethodは1文字を9 fieldのrecordへ解決し、各fieldはvalueではなくaddressです。ContentLayerはpageの/Contents array内の1-based indexで、nested contentから来たcharacterなら0です。StreamObjectNumberStreamGenerationは格納されたstreamを識別します。InstructionIndexはdecode済みcontent program内の0-based position、OperandIndexはtext-string operand、ArrayElementIndexTJ array内のelementで、direct string operandなら-1です。SourceByteOffsetSourceByteLengthが、decode済みstring内のbyte rangeを示します

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とCharPosはGetTextBlockTextを自分でscanして得る
      If Lib.GetTextBlockCharContentLocation(ListID, Block, CharPos,
        ContentLayer, StreamObjectNumber, StreamGeneration,
        InstructionIndex, OperandIndex, ArrayElementIndex,
        SourceByteOffset, SourceByteLength, Flags)= 1 Then
      Begin
        // ContentLayer = 0はglyphがnested Form XObject内にあることを示す
        // ArrayElementIndex = -1はTJ arrayではなくplain Tj operandを示す
      End;
    Finally
      Lib.ReleaseTextBlocks(ListID);
    End;
  Finally
    Lib.Free;
  End;
End;

lookupのコストはquery時にはありません。rendererが各content layerをdecodeするとき、たどっているlogical spanを登録するため、position queryはcharacterごとにすべてのcontent spanをlinear scanするのではなく、ordered interval listへのbinary searchになります。質問された時点で再parseは行いません。mapは、すでに支払ったextraction passの中で構築済みです。hit coordinateを返すPDF text searchでhitをすでに列挙しているなら、hitごとにcontent locationを追加してもほぼ無料です

Unicodeではなくbyteを編集する

ReplaceTextBlockCharSourceBytesは、active PDF font encodingのraw replacement byteをAnsiStringで受け取ります。これが設計のすべてであり、意図的です。transcodeも、re-encodeも、fontについての推測もありません。libraryはtarget stringの指定rangeへあなたのbyteをspliceし、含んでいるcontent layerを再出力します。同じTJ array内の隣接stringと、その間のnumeric kernはbyte-identicalのままです。先ほどのlayoutで[(AB) -120 (CD)] TJBを探すと、ArrayElementIndexは0、SourceByteOffsetは1、SourceByteLengthは1になります。これをZへ置き換えると、出力contentは(AZ)になり、その後ろの-120(CD)はどちらもそのまま残ります。regression suiteがまさにそれをassertします。「kerningを保持した」という主張は、気づかないうちに真でなくなりやすいからです

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
    // 旧listのlocationはすべてstaleになるため、再extractする
    Lib.ReleaseTextBlocks(ListID);
    ListID:= Lib.ExtractPageTextBlocks(3);
  End
  Else If Lib.LastErrorCode= PDFLIB_ERROR_TEXT_LOCATION_STALE Then
    // extraction後にlayerが変更された
  Else If Lib.LastErrorCode= PDFLIB_ERROR_TEXT_LOCATION_READ_ONLY Then
    // 確認し忘れたflag、または後のversionで追加されたflag
End;

運用上覚えておくべき細部が2つあります。このcallはtext listをextractしたpageへ一時的に切り替え、successでもfailureでも以前にselectされていたpageへ戻すため、cursorを黙って移動させません。success時にはpage element snapshotもclearするため、以前のenumeration passで保持していたhandleは無効になります

編集できないcharacter

6つのcategoryがあり、libraryは曖昧にfailureさせるのではなく、それぞれをFlags bitmaskで名前に出します。happy pathよりこちらが重要です。実際のdocumentではunmappable caseが多く、それぞれ理由が異なるためです

  • PDF_TEXT_CHAR_CONTENT_LOCATION_LIGATURE:複数のextracted UTF-16 positionが1つのsource glyphから展開されています。1つのcodeをfiへmapする/ToUnicode entryでは、2文字が同じbyte rangeを共有します。1つのsource glyphとして扱い、rangeを1回だけ編集してください
  • PDF_TEXT_CHAR_CONTENT_LOCATION_GENERATED:layout中にcharacterがsynthesizeされました。inferred word spaceが通常の例で、source byteがないためSourceByteOffsetは-1、SourceByteLengthは0になります
  • PDF_TEXT_CHAR_CONTENT_LOCATION_ACTUALTEXT:読んだtextが/ActualText replacementから来ています。substituted stringからsource byteへの一意なreverse mappingはないため、locationはdiagnostic専用です
  • PDF_TEXT_CHAR_CONTENT_LOCATION_NESTED:glyphがForm XObject内にあります。byteはaddressableですが、Formは複数pageからdrawされることがあります。high-level APIで編集すると、求めていないeditになってしまいます
  • PDF_TEXT_CHAR_CONTENT_LOCATION_TRANSCODED:operandがUTF-16BE byte order markを持つhex stringでした。既存のextraction pathはfont mappingの前にこれをdecodeするため、decode結果へのoffsetはoriginal byteを指せず、valid flagはclearされます
  • PDF_TEXT_CHAR_CONTENT_LOCATION_CROSS_LAYER:string operandとtext-showing operatorが2つの異なるstreamにあります

最後の項目には、それだけの説明が必要です。engineerは、これは起こり得ないと考えがちだからです。ISO 32000-1 §7.8.2ではpage /Contents array内のstreamは連結され、stream間の分割はlexical boundaryにあればよいとしています。そのため、あるstreamにBT /F1 16 Tf 220 340 Td (CrossLayer)があり、次のstreamにTj ETがあるpageは完全に合法です。mappingはdiagnostic positionを保持しますが、read-onlyにマークします。operatorのinstruction indexはoperandのbyteとは異なるlayerに属し、一方を使って他方をaddressするとfileを壊すためです

mapがまだ有効だとlibraryが判断する方法

write直前にfingerprintをcheckします。各extraction listはsource pageに加えて、各content layerについてlayer lengthと2つの独立したrolling hash、FNV-1a hashとDJB2-style xor hashを記録します。ReplaceTextBlockCharSourceBytesは何かをparseする前にtarget layerを再readし、3つすべてのvalueを比較します。そのlayerのどこかのbyteが変わっていればPDFLIB_ERROR_TEXT_LOCATION_STALEを返し、writeは行いません。これは意図的にconservativeです。checkはinstruction単位ではなくlayer単位なので、同じcontent stream内の無関係なeditもlocationを無効にします。それが正しいtradeです。1 byteでもずれたstreamへのoffsetは「惜しい一致」ではなく、silent corruptionだからです。同じ規律は、CTMとclippingのcontent-stream state trackerを含む残りのediting surfaceにも適用されます。replacementが成功したらlistを捨て、再びextractしてください

Direct Accessによるread-only mapping

DAGetTextBlockCharContentLocationはDirect Access pathで開いたpageに対して、同じrecordと同じflag vocabularyを返します。構造上diagnostic専用です。ReplaceTextBlockCharSourceBytesはselected editable documentを操作しますが、Direct Accessはread pathだからです。location dataはfile handleを閉じた後もtext block listに残るため、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);
    // DACloseFile後もlocationはread可能
  Finally
    Lib.DAReleaseTextBlocks(DirectList);
  End;
Finally
  Lib.DACloseFile(FileHandle);
End;

変更ではなく質問に使ってください。in-place editできないtextを持つpageはどれか。このcorpusのどれだけが/ActualText overrideで到着するか。どのvendorのoutputがoperatorをcontent layer間で分割するか。各characterにaddressがあれば、これらは安価なqueryです。correction pipelineにcommitする前に実行する価値があります

point editingの限界

point editingはtext engineではなくscalpelです。byteをin-placeで変更するため、replacement textがoriginalより広くても狭くてもreflowせず、rewrapせず、周囲のkernも更新しません。monospaced fieldで1桁を別の桁に置き換えるのは適しています。paragraphのretypeには向きません。そして明確にsecurity toolではありません。glyph byteを上書きしても、fileのrevision historyからoriginal byteを復元できるため、confidentialityが必要なものはcoverではなくcontentを削除するtrue redactionに委ねるべきです。代わりに得られるのは正直さです。各characterには操作できるbyte addressがあるか、なぜないかを示す名前付きflagがあります。fingerprint checkはstale mapを、壊れたpageではなくhard errorにします。character-to-content-byte mappingとin-place source byte replacementは、PDF Library for Delphiのtext extractionとcontent editing surfaceに含まれています。これはDelphi、C++Builder、Lazarus向けのnative Object Pascal PDF libraryです