Bài viết kỹ thuật

Ánh xạ text PDF về byte content stream trong Delphi

Chỉ một ký tự sai trong số hóa đơn, nhưng primitive edit duy nhất có sẵn lại viết lại cả text run. PDF Library for Delphi lấp khoảng trống đó: GetTextBlockCharContentLocation ánh xạ từng vị trí UTF-16 đã extract về instruction, operand và byte range được encode trong content stream, còn ReplaceTextBlockCharSourceBytes chỉ overwrite range đó. Text extraction thường vứt bỏ mọi thứ bạn cần cho việc này. Bạn có Unicode, width và geometry, còn provenance biến mất, nên ký tự ở vị trí 7 của block 3 chỉ còn là một ký tự. Stream nào tạo ra nó, instruction nào, operand nào, byte nào trong operand đó: tất cả biến mất. Mọi chiến lược point-edit xây trên dữ liệu này đều phải đoán, thường bằng cách tìm substring trong content đã decode rồi hy vọng nó xuất hiện đúng một lần. Trên page thật thì không như vậy

Vì sao viết lại cả text run làm hỏng page?

Vì run không chỉ là text. Các operator hiển thị text trong ISO 32000-1 §9.4.3 gồm TJ, có operand là array xen kẽ string với các điều chỉnh số, và chính các số đó là typesetting. Một dòng viết theo dạng [(AB) -120 (CD)] TJ mang theo kern bằng 120 phần nghìn em giữa hai string. Emit một Tj mới với text đã nối sẽ làm mất kern, dòng reflow lệch một chút, còn trên form thì value trượt khỏi box. Font cũng tạo ra cùng vấn đề: operand byte là code theo encoding mà Tf chọn, không phải Unicode, và với composite font chúng có thể là CID hai byte không liên quan tới character extractor trả về. Regenerate run buộc bạn phải đúng về encoding của font, map /ToUnicode và glyph coverage. Point edit tránh toàn bộ việc đó bằng cách không rời khỏi byte domain

GetTextBlockCharContentLocation trả về gì?

Method resolve một character thành record chín field, và mỗi field là một địa chỉ thay vì một value. ContentLayer là index 1-based vào array /Contents của page, hoặc 0 khi character đến từ content lồng nhau. StreamObjectNumberStreamGeneration xác định stream chứa nó. InstructionIndex là vị trí 0-based trong content program đã decode, OperandIndex là text-string operand, còn ArrayElementIndex là element bên trong array TJ hoặc -1 với direct string operand. Sau đó SourceByteOffsetSourceByteLength chỉ ra byte range trong decoded 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 và CharPos đến từ lần scan GetTextBlockText của bạn
      If Lib.GetTextBlockCharContentLocation(ListID, Block, CharPos,
        ContentLayer, StreamObjectNumber, StreamGeneration,
        InstructionIndex, OperandIndex, ArrayElementIndex,
        SourceByteOffset, SourceByteLength, Flags)= 1 Then
      Begin
        // ContentLayer = 0 nghĩa là glyph nằm trong Form XObject lồng nhau
        // ArrayElementIndex = -1 nghĩa là operand Tj thuần, không phải array TJ
      End;
    Finally
      Lib.ReleaseTextBlocks(ListID);
    End;
  Finally
    Lib.Free;
  End;
End;

Lookup không tốn thêm gì tại thời điểm query. Trong lúc renderer decode từng content layer, nó đăng ký các logical span đang đi qua, nên query position chỉ là binary search trên interval list đã sắp xếp thay vì scan tuyến tính mọi content span cho mỗi character. Khi bạn hỏi thì không có gì được parse lại; map đã được dựng trong extraction pass mà bạn đã trả phí. Nếu bạn đã enumerate hit bằng PDF text search trả về tọa độ hit, thêm content location cho mỗi hit gần như miễn phí

Sửa byte chứ không sửa Unicode

ReplaceTextBlockCharSourceBytes nhận một AnsiString chứa raw replacement byte theo active PDF font encoding. Đó là toàn bộ thiết kế, và nó có chủ ý. Không transcode, không re-encode, không đoán về font. Library splice byte của bạn đè lên range được chỉ ra trong target string rồi emit lại content layer chứa nó. String kề bên trong cùng array TJ và các kern số giữa chúng vẫn giữ nguyên từng byte. Với layout ở trên, locate B trong [(AB) -120 (CD)] TJ cho ArrayElementIndex 0, SourceByteOffset 1, SourceByteLength 1. Thay bằng Z, content emit ra có (AZ), vẫn theo sau bởi -120(CD), cả hai không đổi. Regression suite assert đúng điều đó, vì câu "đã giữ nguyên kerning" là loại khẳng định âm thầm không còn đúng

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
    // Mọi location trong list cũ giờ đã stale. Hãy extract lại
    Lib.ReleaseTextBlocks(ListID);
    ListID:= Lib.ExtractPageTextBlocks(3);
  End
  Else If Lib.LastErrorCode= PDFLIB_ERROR_TEXT_LOCATION_STALE Then
    // Layer đã thay đổi từ sau lúc extraction
  Else If Lib.LastErrorCode= PDFLIB_ERROR_TEXT_LOCATION_READ_ONLY Then
    // Một flag ta quên kiểm tra, hoặc flag do version sau thêm vào
End;

Có hai chi tiết vận hành cần ghi nhớ. Lời gọi tạm thời chuyển sang page nơi text list được extract rồi khôi phục page đã chọn trước đó cả khi thành công lẫn thất bại, nên nó không âm thầm đổi cursor của bạn. Khi thành công, nó xóa page element snapshot, làm invalid mọi handle bạn đang giữ từ pass enumeration trước đó

Những character nào không thể sửa?

Có sáu nhóm, và library ghi tên từng nhóm trong bitmask Flags thay vì thất bại mơ hồ. Điều này quan trọng hơn happy path, vì các trường hợp không map được khá phổ biến trong document thật và mỗi trường hợp có lý do khác nhau

  • PDF_TEXT_CHAR_CONTENT_LOCATION_LIGATURE: nhiều vị trí UTF-16 đã extract mở rộng từ một source glyph. Một entry /ToUnicode map một code thành fi sẽ cho hai character dùng chung một byte range, nên hãy coi chúng là một source glyph và sửa range đúng một lần
  • PDF_TEXT_CHAR_CONTENT_LOCATION_GENERATED: character được tổng hợp trong layout. Inferred word space là trường hợp thường gặp, và chúng hoàn toàn không có source byte, nên SourceByteOffset trả về -1 còn SourceByteLength là 0
  • PDF_TEXT_CHAR_CONTENT_LOCATION_ACTUALTEXT: text bạn đọc đến từ replacement /ActualText. Không có reverse mapping duy nhất từ string thay thế về source byte, nên location chỉ dùng để chẩn đoán
  • PDF_TEXT_CHAR_CONTENT_LOCATION_NESTED: glyph nằm trong Form XObject. Byte vẫn addressable, nhưng Form có thể được nhiều page vẽ, vì vậy sửa nó qua high-level API sẽ thành một edit bạn không yêu cầu
  • PDF_TEXT_CHAR_CONTENT_LOCATION_TRANSCODED: operand là hex string mang byte order mark UTF-16BE, được extraction path hiện tại decode trước khi font mapping. Offset trong kết quả decoded không còn địa chỉ được byte gốc, nên valid flag bị xóa
  • PDF_TEXT_CHAR_CONTENT_LOCATION_CROSS_LAYER: string operand và text-showing operator của nó nằm trong hai stream khác nhau

Trường hợp cuối đáng có một câu riêng, vì engineer thường cho rằng nó không thể xảy ra. ISO 32000-1 §7.8.2 nói các stream trong array /Contents của page được nối lại, còn ranh giới giữa chúng chỉ cần nằm ở lexical boundary. Vì vậy BT /F1 16 Tf 220 340 Td (CrossLayer) trong một stream và Tj ET trong stream kế tiếp là page hoàn toàn hợp lệ. Mapping vẫn giữ diagnostic position nhưng đánh dấu chỉ đọc, vì instruction index của operator thuộc layer khác với byte của operand, dùng cái này để address cái kia sẽ corrupt file

Library biết map còn hợp lệ bằng cách nào?

Fingerprint, được kiểm tra ngay trước khi ghi. Mỗi extraction list lưu source page, cùng length của từng content layer và hai rolling hash độc lập: hash FNV-1a và hash kiểu XOR DJB2. Trước khi ReplaceTextBlockCharSourceBytes parse bất kỳ thứ gì, nó đọc lại target layer và so sánh cả ba giá trị. Bất kỳ byte nào thay đổi ở bất kỳ đâu trong layer đều trả về PDFLIB_ERROR_TEXT_LOCATION_STALE và không thực hiện write. Cách này cố ý conservative: check theo layer chứ không theo instruction, nên một edit không liên quan ở nơi khác trong cùng content stream cũng invalidate location. Đó là trade-off đúng: offset vào stream đã dịch dù chỉ một byte không phải một sai số nhỏ, mà là corruption im lặng. Cùng kỷ luật đó chi phối phần còn lại của editing surface, gồm cả state tracker cho CTM và clipping trong content stream. Sau mọi replacement thành công, bỏ list và extract lại

Mapping chỉ đọc qua Direct Access

DAGetTextBlockCharContentLocation cho bạn đúng record đó với đúng vocabulary flag khi page được mở qua Direct Access path. Nó chỉ dùng để chẩn đoán theo thiết kế: ReplaceTextBlockCharSourceBytes hoạt động trên selected editable document, còn Direct Access là read path. Location data vẫn sống trong text block list sau khi file handle đóng, nên có thể dùng cho audit offline

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);
    // Location vẫn đọc được sau DACloseFile
  Finally
    Lib.DAReleaseTextBlocks(DirectList);
  End;
Finally
  Lib.DACloseFile(FileHandle);
End;

Dùng nó để trả lời câu hỏi thay vì thay đổi thứ gì. Page nào mang text mà bạn không bao giờ có thể sửa tại chỗ? Bao nhiêu corpus đến với override /ActualText? Output của vendor nào tách operator giữa các content layer? Một khi mỗi character có một địa chỉ, những query đó rất rẻ và đáng chạy trước khi bạn cam kết với correction pipeline

Point editing dừng ở đâu

Point editing là dao mổ chứ không phải text engine. Nó thay đổi byte tại chỗ, nên replacement text rộng hoặc hẹp hơn bản gốc sẽ không reflow, không rewrap và không cập nhật kern xung quanh. Đổi một digit này sang digit khác trong field monospace là trường hợp phù hợp. Gõ lại cả paragraph thì không. Và đây rõ ràng không phải security tool: overwrite glyph byte vẫn để byte gốc có thể khôi phục từ revision history của file, nên mọi yêu cầu confidentiality thuộc về true redaction loại bỏ content thay vì che nó. Đổi lại các giới hạn đó là tính trung thực. Mỗi character hoặc có byte address để bạn tác động, hoặc có flag nêu rõ vì sao không thể; fingerprint check biến map stale thành hard error thay vì page bị corrupt. Character-to-content-byte mapping và in-place source byte replacement là một phần của bề mặt text extraction và content editing trong PDF Library for Delphi, native Object Pascal PDF library cho Delphi, C++Builder và Lazarus