แค่ตัวอักษรเดียวในเลข invoice ผิด แต่ primitive สำหรับแก้ไขที่มีอยู่กลับเขียน text run ทั้งก้อนใหม่ PDF Library for Delphi ปิดช่องว่างนี้ด้วย GetTextBlockCharContentLocation ซึ่งแมปตำแหน่ง UTF-16 ที่ extract ได้แต่ละตำแหน่งกลับไปยัง instruction, operand และ encoded byte range ใน content stream ที่สร้างมันขึ้นมา แล้ว ReplaceTextBlockCharSourceBytes จะเขียนทับเฉพาะ range นั้น การ extract text โดยปกติทิ้งข้อมูลทุกอย่างที่จำเป็นสำหรับงานนี้ไป คุณได้ Unicode, width และ geometry แต่ provenance หายไป ดังนั้นอักขระตำแหน่ง 7 ของ block 3 จึงเหลือแค่ตัวอักษรตัวหนึ่ง stream ไหนสร้างมัน instruction ไหน operand ไหน และเป็น byte ที่เท่าไรใน operand ล้วนหายหมด กลยุทธ์ point edit ทุกแบบที่สร้างบนข้อมูลนี้จึงต้องเดา โดยมักค้น substring ใน content ที่ decode แล้วแล้วหวังว่าจะพบเพียงครั้งเดียว แต่บน page จริงมันไม่เป็นอย่างนั้น
ทำไมการเขียน text run ทั้งก้อนใหม่จึงทำให้ page พัง
เพราะ run ไม่ใช่แค่ text text-showing operator ใน ISO 32000-1 §9.4.3 มี TJ ซึ่ง operand เป็น array ที่สลับ string กับ numeric adjustment และตัวเลขเหล่านั้นคือ typesetting บรรทัดที่จัดวางเป็น [(AB) -120 (CD)] TJ มี kern ขนาด 120 ส่วนหนึ่งในพันของ em คั่นระหว่าง string สองตัว หาก emit Tj ใหม่ด้วย text ที่ต่อกัน kern จะหาย บรรทัดจะ reflow เล็กน้อย และใน form ค่าอาจเลื่อนออกจากกล่องได้ ปัญหาเดียวกันใช้กับ font ด้วย byte ใน operand เป็น code ตาม encoding ที่ Tf เลือก ไม่ใช่ Unicode และสำหรับ composite font อาจเป็น CID สอง byte ที่ไม่เกี่ยวข้องกับ character ที่ extractor คืนมา หาก regenerate run คุณต้องถูกต้องทั้ง encoding ของ font, /ToUnicode map และ glyph coverage การแก้แบบ point editหลบปัญหาเหล่านี้ทั้งหมดด้วยการไม่ออกจาก byte domain
GetTextBlockCharContentLocation คืนค่าอะไร
method นี้ resolve character หนึ่งตัวเป็น record เก้าฟิลด์ และทุกฟิลด์เป็น address ไม่ใช่ value ContentLayer คือ index แบบเริ่มที่ 1 ใน array /Contents ของ page หรือเป็น 0 เมื่อ character มาจาก nested content StreamObjectNumber และ StreamGeneration ระบุ stream ที่ครอบอยู่ InstructionIndex คือ position แบบเริ่มที่ 0 ใน decoded content program ส่วน OperandIndex คือ text-string operand และ ArrayElementIndex คือ element ภายใน array TJ หรือเป็น -1 สำหรับ direct string operand จากนั้น SourceByteOffset และ SourceByteLength จะระบุ byte range ภายใน string ที่ decode แล้ว
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 มาจากการ scan GetTextBlockText ของคุณเอง
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 หมายถึง plain Tj operand ไม่ใช่ TJ array
End;
Finally
Lib.ReleaseTextBlocks(ListID);
End;
Finally
Lib.Free;
End;
End;
การ lookup ไม่เสียต้นทุนตอน query ระหว่างที่ renderer decode content layer แต่ละชั้น มันจะ register logical span ที่กำลังเดินอยู่ ดังนั้น position query จึงเป็น binary search บน ordered interval list แทนการ scan content span ทุกตัวแบบ linear ต่อ character เมื่อคุณถามจะไม่มีการ reparse เพราะ map ถูกสร้างไว้แล้วใน extraction pass ที่คุณจ่ายต้นทุนไป หากคุณกำลัง enumerate hit ด้วย PDF text search ที่คืนพิกัดของ hit อยู่แล้ว การเพิ่ม content location ให้แต่ละ hit แทบไม่มีต้นทุน
แก้ byte ไม่ใช่ Unicode
ReplaceTextBlockCharSourceBytes รับ AnsiString ที่เป็น raw replacement byte ใน active PDF font encoding นี่คือการออกแบบทั้งหมดและตั้งใจให้เป็นเช่นนั้น ไม่มีการ transcode ไม่มีการ re-encode และไม่มีการเดาเรื่อง font ไลบรารีจะ splice byte ของคุณทับ named range ของ target string แล้ว emit content layer ที่ครอบอยู่ใหม่ string ที่อยู่ติดกันใน array TJ เดียวกันและ numeric kern ระหว่างมันจะคง byte เดิมทุกประการ จาก layout ด้านบน หาก locate B ใน [(AB) -120 (CD)] TJ จะได้ ArrayElementIndex เป็น 0, SourceByteOffset เป็น 1 และ SourceByteLength เป็น 1 แทนที่ด้วย Z แล้ว content ที่ emit จะมี (AZ) ตามด้วย -120 และ (CD) เหมือนเดิม regression suite ยืนยันเรื่องนี้ตรง ๆ เพราะคำกล่าวว่า "เรารักษา 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
// location ทุกตัวใน list เก่ากลายเป็น stale แล้ว ต้อง extract ใหม่
Lib.ReleaseTextBlocks(ListID);
ListID:= Lib.ExtractPageTextBlocks(3);
End
Else If Lib.LastErrorCode= PDFLIB_ERROR_TEXT_LOCATION_STALE Then
// layer ถูกเปลี่ยนหลังจาก extraction
Else If Lib.LastErrorCode= PDFLIB_ERROR_TEXT_LOCATION_READ_ONLY Then
// เป็น flag ที่เราไม่ได้ตรวจ หรือเป็น flag ที่เพิ่มใน version ภายหลัง
End;
มีรายละเอียดเชิงปฏิบัติสองอย่างที่ควรจำ การ call จะสลับไปยัง page ที่ text list ถูก extract มาชั่วคราว และ restore page ที่เลือกไว้ก่อนหน้าทั้งเมื่อสำเร็จและเมื่อ failure ดังนั้นมันจะไม่ย้าย cursor ของคุณเงียบ ๆ และเมื่อสำเร็จมันจะล้าง page element snapshot ซึ่งทำให้ handle ที่คุณถือจาก enumeration pass ก่อนหน้าทั้งหมดใช้ไม่ได้
อักขระใดบ้างที่แก้ไม่ได้
มีหก category และไลบรารีระบุแต่ละ category ใน Flags bitmask แทนการ fail แบบคลุมเครือ เรื่องนี้สำคัญกว่า happy path เพราะเอกสารจริงมีกรณีที่ map ไม่ได้อยู่บ่อย และแต่ละกรณีก็มีเหตุผลต่างกัน
PDF_TEXT_CHAR_CONTENT_LOCATION_LIGATURE: UTF-16 position ที่ extract ได้หลายตำแหน่งขยายมาจาก source glyph เดียว entry ใน/ToUnicodeที่ map code หนึ่งตัวเป็นfiจะทำให้ character สองตัวแชร์ byte range เดียวกัน ให้ถือว่าเป็น source glyph เดียวและแก้ range เพียงครั้งเดียวPDF_TEXT_CHAR_CONTENT_LOCATION_GENERATED: character ถูกสังเคราะห์ระหว่าง layout กรณีที่พบบ่อยคือ inferred word space ซึ่งไม่มี source byte เลย ดังนั้นSourceByteOffsetจะกลับมาเป็น -1 และSourceByteLengthเป็น 0PDF_TEXT_CHAR_CONTENT_LOCATION_ACTUALTEXT: text ที่อ่านมาจาก replacement ของ/ActualTextไม่มี reverse mapping ที่เป็นเอกลักษณ์จาก string ที่ถูกแทนกลับไปยัง source byte ดังนั้น location ใช้เพื่อวินิจฉัยเท่านั้นPDF_TEXT_CHAR_CONTENT_LOCATION_NESTED: glyph อยู่ใน Form XObject byte ยังระบุตำแหน่งได้ แต่ Form อาจถูกวาดโดยหลาย page การแก้ผ่าน high-level API จึงอาจแก้สิ่งที่คุณไม่ได้ขอPDF_TEXT_CHAR_CONTENT_LOCATION_TRANSCODED: operand เป็น hex string ที่มี UTF-16BE byte order mark และ extraction path เดิม decode ก่อนทำ font mapping offset ในผลลัพธ์ที่ decode แล้วจึงไม่ชี้ไปยัง byte เดิมอีก valid flag จึงถูกล้างPDF_TEXT_CHAR_CONTENT_LOCATION_CROSS_LAYER: string operand กับ text-showing operator อยู่คนละ stream
ข้อสุดท้ายควรมีประโยคของตัวเอง เพราะ engineer มักคิดว่าเกิดไม่ได้ ISO 32000-1 §7.8.2 ระบุว่า stream ใน array /Contents ของ page ถูกต่อเข้าด้วยกัน และจุดแบ่งระหว่าง stream ต้องอยู่ที่ lexical boundary เท่านั้น ดังนั้น BT /F1 16 Tf 220 340 Td (CrossLayer) ใน stream หนึ่งกับ Tj ET ใน stream ถัดไปจึงเป็น page ที่ถูกต้องตาม spec mapping จะเก็บ diagnostic position ไว้แต่ mark เป็น read-only เพราะ instruction index ของ operator อยู่คนละ layer กับ byte ของ operand และการใช้ address หนึ่งไปอ้างอีก address จะทำให้ไฟล์เสีย
ไลบรารีรู้ได้อย่างไรว่า map ยังใช้ได้
ด้วย fingerprint ที่ตรวจทันทีก่อนเขียน extraction list แต่ละรายการบันทึก source page และสำหรับ content layer ทุกชั้นจะบันทึกความยาวของ layer กับ rolling hash อิสระสองแบบ ได้แก่ FNV-1a hash และ DJB2-style xor hash ก่อนที่ ReplaceTextBlockCharSourceBytes จะ parse ใด ๆ มันจะอ่าน target layer ใหม่และเทียบค่าทั้งสาม หาก byte ที่ใดก็ตามใน layer เปลี่ยน จะคืนค่า PDFLIB_ERROR_TEXT_LOCATION_STALE และไม่ทำการเขียน นี่เป็นการตรวจแบบ conservative ตั้งใจให้เป็นเช่นนั้น check ทำต่อ layer ไม่ใช่ต่อ instruction ดังนั้น edit ที่ไม่เกี่ยวกันใน content stream เดียวกันก็ทำให้ location ของคุณใช้ไม่ได้ นี่คือ trade-off ที่ถูกต้อง offset ใน stream ที่ขยับแม้เพียงหนึ่ง byte ไม่ใช่ความคลาดเคลื่อนเล็กน้อย แต่มันคือ silent corruption หลักการเดียวกันควบคุม editing surface ที่เหลือ รวมถึง content-stream state tracker สำหรับ CTM และ clipping หลัง replacement สำเร็จทุกครั้ง ให้ทิ้ง list แล้ว extract ใหม่
แมปแบบอ่านอย่างเดียวผ่าน Direct Access
DAGetTextBlockCharContentLocation ให้ record เดียวกันสำหรับ page ที่เปิดผ่าน Direct Access path พร้อม vocabulary ของ flag เดิมทุกประการ มันเป็น diagnostic-only โดยโครงสร้าง ReplaceTextBlockCharSourceBytes ทำงานกับ editable document ที่ถูกเลือก ส่วน Direct Access เป็น read path ข้อมูล location ยังอยู่ใน text block list หลังปิด file handle ทำให้ใช้ทำ 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);
// location ยังอ่านได้หลัง DACloseFile
Finally
Lib.DAReleaseTextBlocks(DirectList);
End;
Finally
Lib.DACloseFile(FileHandle);
End;
ใช้มันเพื่อตอบคำถาม ไม่ใช่เพื่อเปลี่ยนสิ่งต่าง ๆ page ไหนมี text ที่คุณแก้ in-place ไม่ได้เลย corpus นี้มี /ActualText override มากแค่ไหน output จาก vendor รายใดแยก operator ข้าม content layer คำถามเหล่านี้ query ได้ถูกเมื่อ character ทุกตัวมี address และคุ้มค่าที่จะตอบก่อน commit correction pipeline
จุดที่ point editing ต้องหยุด
Point editing เป็น scalpel ไม่ใช่ text engine มันเปลี่ยน byte แบบ in-place ดังนั้น replacement text ที่กว้างหรือแคบกว่าของเดิมจะไม่ reflow ไม่ rewrap และไม่ update kern รอบข้าง การแทน digit หนึ่งตัวด้วยอีกตัวใน field แบบ monospaced เหมาะกับงานนี้ การพิมพ์ paragraph ใหม่ไม่เหมาะ และมันไม่ใช่ security tool อย่างเด็ดขาด การเขียนทับ glyph byte ยังทำให้ byte เดิมกู้คืนได้จาก revision history ของไฟล์ ดังนั้นสิ่งที่ต้องการ confidentiality ควรใช้ true redaction ที่ลบ content แทนการปิดทับ สิ่งที่ได้แลกกับข้อจำกัดเหล่านี้คือความตรงไปตรงมา character ทุกตัวมี byte address ที่ลงมือได้ หรือมี flag ที่บอกชื่อเหตุผลว่าเหตุใดจึงทำไม่ได้ และ fingerprint check ทำให้ stale map เป็น hard error แทนที่จะกลายเป็น page ที่เสีย การแมป character-to-content-byte และการแทนที่ source byte แบบ in-place เป็นส่วนหนึ่งของ text extraction และ content editing surface ของ PDF Library for Delphi ซึ่งเป็น native Object Pascal PDF library สำหรับ Delphi, C++Builder และ Lazarus