invoice number의 문자 하나가 잘못되었는데 손에 든 유일한 edit primitive가 전체 text run을 다시 쓰는 상황이 있습니다. PDF Library for Delphi는 이 간극을 메웁니다. GetTextBlockCharContentLocation은 추출한 각 UTF-16 position을 그것을 만든 content-stream instruction, operand와 encoded byte range로 되돌려 매핑하고 ReplaceTextBlockCharSourceBytes는 그 범위만 덮어씁니다. Text extraction은 보통 이 작업에 필요한 모든 것을 버립니다. Unicode, width와 geometry는 얻지만 provenance는 사라지므로 block 3의 position 7에 있는 문자는 그저 하나의 문자일 뿐입니다. 어느 stream이 만들었는지, 어느 instruction인지, 어느 operand인지, 그 operand 안의 어느 byte인지가 모두 사라집니다. 그 위에 세운 point-edit 전략은 모두 추측해야 하며 대개 decoded content에서 substring을 찾고 정확히 한 번 나타나기를 기대합니다. 실제 페이지에서는 그렇지 않습니다
전체 text run을 다시 쓰면 페이지가 망가지는 이유
run은 단순한 text가 아니기 때문입니다. ISO 32000-1 §9.4.3의 text-showing operator에는 string과 numeric adjustment를 교차 배치하는 array를 operand로 가지는 TJ가 포함되며 그 숫자가 typesetting입니다. [(AB) -120 (CD)] TJ로 배치된 line은 두 string 사이에 120-thousandths-of-an-em kern을 담습니다. 연결된 text로 새 Tj를 내보내면 kern이 사라지고 line이 아주 조금 reflow하며 form에서는 value가 box 밖으로 밀려납니다. font에도 같은 문제가 적용됩니다. operand byte는 Unicode가 아니라 Tf가 선택한 encoding의 code이며 composite font에서는 extractor에서 읽은 문자와 아무 관계가 없는 2-byte CID일 수 있습니다. run을 다시 생성하려면 font encoding, /ToUnicode map과 glyph coverage를 모두 정확히 알아야 합니다. Point editing은 byte domain을 벗어나지 않으므로 이 모든 문제를 우회합니다
GetTextBlockCharContentLocation이 반환하는 것
이 method는 문자 하나를 아홉 field의 record로 resolve하며 각 field는 value가 아니라 address입니다. ContentLayer는 page /Contents array의 1-based index이며 문자가 nested content에서 왔으면 0입니다. StreamObjectNumber와 StreamGeneration은 포함하는 stream을 식별합니다. InstructionIndex는 decoded content program에서 0-based position이고 OperandIndex는 text-string operand이며 ArrayElementIndex는 TJ array 안 element이고 direct string operand라면 -1입니다. 이어서 SourceByteOffset과 SourceByteLength가 decoded 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는 모든 content span을 문자마다 linear scan하는 대신 정렬된 interval list에 대한 binary search가 됩니다. query할 때 다시 parse하지 않습니다. 이미 비용을 낸 extraction pass 중에 map이 만들어졌기 때문입니다. 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 위에 caller의 byte를 splice하고 포함하는 content layer를 다시 내보냅니다. 같은 TJ array 안의 인접 string과 그 사이의 numeric kern은 byte-identical하게 유지됩니다. 위 layout을 예로 들면 [(AB) -120 (CD)] TJ에서 B를 찾을 때 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;
운영상 기억할 세부 사항이 두 가지 있습니다. call은 text list를 추출한 page로 잠시 전환했다가 success와 failure 모두에서 이전에 선택된 page를 복원하므로 cursor를 조용히 움직이지 않습니다. 그리고 성공하면 page element snapshot을 지워 이전 enumeration pass에서 들고 있던 handle이 모두 무효화됩니다
편집할 수 없는 문자
여섯 category가 있으며 library는 막연히 실패하는 대신 Flags bitmask에서 각각의 이름을 제공합니다. 실제 문서에서는 mapping할 수 없는 경우가 흔하고 이유도 각각 다르므로 happy path보다 이 부분이 더 중요합니다
PDF_TEXT_CHAR_CONTENT_LOCATION_LIGATURE: 여러 extracted UTF-16 position이 하나의 source glyph에서 확장됩니다. 하나의 code를fi로 매핑하는/ToUnicodeentry는 두 문자가 하나의 byte range를 공유하게 하므로 하나의 source glyph로 보고 range를 한 번만 편집해야 합니다PDF_TEXT_CHAR_CONTENT_LOCATION_GENERATED: layout 중 합성된 문자입니다. 추론된 word space가 일반적인 예이며 source byte가 전혀 없으므로SourceByteOffset은 -1,SourceByteLength는 0으로 돌아옵니다PDF_TEXT_CHAR_CONTENT_LOCATION_ACTUALTEXT: 읽은 text가/ActualTextreplacement에서 왔습니다. substituted string에서 source byte로 되돌리는 고유 mapping이 없으므로 location은 diagnostic 용도일 뿐입니다PDF_TEXT_CHAR_CONTENT_LOCATION_NESTED: glyph가 Form XObject 안에 있습니다. byte에는 address가 있지만 Form은 여러 page에서 그려질 수 있으므로 high-level API로 편집하면 요청하지 않은 edit가 됩니다PDF_TEXT_CHAR_CONTENT_LOCATION_TRANSCODED: operand가 UTF-16BE byte order mark를 담은 hex string이며 기존 extraction path가 font mapping 전에 이를 decode합니다. decoded result의 offset은 더 이상 원본 byte를 가리키지 않으므로 valid flag가 해제됩니다PDF_TEXT_CHAR_CONTENT_LOCATION_CROSS_LAYER: string operand와 그 text-showing operator가 서로 다른 stream에 있습니다
마지막 항목은 따로 한 문장으로 설명할 가치가 있습니다. engineer는 이것이 일어날 수 없다고 흔히 가정하기 때문입니다. ISO 32000-1 §7.8.2는 page /Contents array의 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을 손상시키기 때문입니다
library가 map이 여전히 유효한지 아는 방법
write 직전에 fingerprint를 확인합니다. 각 extraction list는 source page와 함께 모든 content layer의 layer length 및 서로 독립적인 두 rolling hash인 FNV-1a hash와 DJB2-style xor hash를 기록합니다. ReplaceTextBlockCharSourceBytes는 무엇이든 parse하기 전에 target layer를 다시 읽고 세 값을 모두 비교합니다. 그 layer의 어디에서든 byte가 바뀌면 PDFLIB_ERROR_TEXT_LOCATION_STALE을 반환하고 write는 수행하지 않습니다. 이것은 의도적으로 보수적입니다. check가 instruction 단위가 아니라 layer 단위이므로 같은 content stream의 관계없는 위치를 편집해도 location이 무효화됩니다. 이것이 올바른 trade입니다. 한 byte라도 이동한 stream의 offset은 거의 맞는 것이 아니라 silent corruption이기 때문입니다. 같은 discipline은 CTM과 clipping을 위한 content-stream state tracker를 포함한 나머지 editing surface에도 적용됩니다. replacement가 성공할 때마다 list를 버리고 다시 extract해야 합니다
Direct Access를 통한 read-only mapping
DAGetTextBlockCharContentLocation은 Direct Access path로 연 page에 대해 동일한 flag vocabulary와 동일한 record를 제공합니다. 구조상 diagnostic 용도일 뿐입니다. ReplaceTextBlockCharSourceBytes는 선택된 editable document에서 작동하고 Direct Access는 read path이기 때문입니다. file handle을 닫은 뒤에도 location data가 text block list에 남아 offline auditing에 사용할 수 있습니다
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을 읽을 수 있습니다
Finally
Lib.DAReleaseTextBlocks(DirectList);
End;
Finally
Lib.DACloseFile(FileHandle);
End;
변경하기보다 질문에 답하는 데 사용하세요. 어떤 page에 in-place로 절대 편집할 수 없는 text가 있는가? 이 corpus에서 얼마나 많은 text가 /ActualText override로 들어오는가? 어느 vendor의 output이 operator를 content layer 사이에서 분할하는가? 모든 문자에 address가 생기면 이런 query는 저렴해지며 correction pipeline을 결정하기 전에 실행할 가치가 있습니다
point editing이 멈추는 곳
Point editing은 text engine이 아니라 scalpel입니다. byte를 in place로 바꾸므로 replacement text가 원본보다 넓거나 좁아도 reflow하지 않고 rewrap하지 않으며 주변 kern도 갱신하지 않습니다. monospaced field의 한 digit을 다른 digit으로 바꾸는 데는 잘 맞지만 paragraph를 다시 입력하는 데는 맞지 않습니다. 그리고 보안 도구도 절대 아닙니다. glyph byte를 덮어써도 file revision history에서 원본 byte를 복구할 수 있으므로 confidentiality requirement가 있는 것은 가리는 대신 content를 제거하는 true redaction에 맡겨야 합니다. 그 제한의 대가로 얻는 것은 정직함입니다. 모든 문자에는 조작할 수 있는 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입니다