text page ของ PDF เปิดเผยตัวอักษรและกล่อง (box) ไม่เคยเปิดเผยบรรทัดเลย PDFium Component สร้างบรรทัดแสดงผลด้วยการจัดกลุ่ม character box ที่จุดกึ่งกลางแนวตั้งอยู่ภายในครึ่งหนึ่งของความสูงตัวอักษรเมล็ดพันธุ์ สแกนออกไปจากตัวอักษรที่ถูกคลิกจนกว่าความคลาดเคลื่อนที่ยอมรับได้จะเกิน ทุก selection path ในตัวแสดงผลเรียก helper ตัวเดียวนั้น ดังนั้นเมาส์ คีย์บอร์ด และโค้ดจึงตรงกัน
อาการที่ทำให้คุณมาหาบทความนี้เจาะจงและไม่น่าพึงใจ ผู้ใช้ triple-click ย่อหน้าในรายงานสองคอลัมน์แล้วได้ครึ่งหน้า หรือ triple-click cell ตารางแล้ว selection กลืนทั้งแถวบวกกับหมายเลขหน้าใน footer ตัวแสดงผลไม่ได้พัง มันกำลังถามคำถามที่ไฟล์ตอบไม่ได้ ไม่มีบรรทัดใน PDF ให้เลือก และการทำงานใดที่แสร้งทำเป็นว่ามีคือการเดา บทความนี้พูดถึงการทำให้การเดานั้นตั้งใจและสม่ำเสมอ ถ้าสิ่งที่คุณต้องการจริง ๆ คือการดึงข้อความออกจากเอกสาร ดูที่ การดึงข้อความจากเอกสาร PDF ด้วย PDFium ถ้าคุณกำลังจัด layout ข้อความและต้องการความกว้าง ดูที่ การวัดข้อความและการตัดคำ ที่นี่หัวข้อแคบกว่า คือการตัดสินว่าบรรทัดแสดงผลเริ่มและจบตรงไหน แล้วเลือกพอดีตรงนั้น
ทำไม text page ของ PDF ถึงไม่มี line object
เพราะ PDF content stream อธิบายการวาด ไม่ใช่โครงสร้าง ISO 32000-1 §9.4 กำหนด text object เป็นคู่ BT / ET ที่มี positioning operator กับ showing operator อยู่ข้างใน positioning operator ของ §9.4.2 (Td, TD, Tm, T*) เลื่อน text matrix ไปรอบหน้า และ showing operator ของ §9.4.3 (Tj, TJ, ', ") วาด glyph ตรงตำแหน่งที่ matrix นั้นชี้อยู่ในขณะนั้น ไม่มีอะไรในโมเดลนั้นบอกว่า "ชุด glyph ชุดนี้คือบรรทัด" บรรทัดคือสิ่งที่มนุษย์เห็นหลังจากการวาดเสร็จสิ้นแล้ว
Producer ทำให้เรื่องนี้แย่ลงในแบบที่คุณควบคุมไม่ได้ ย่อหน้าที่จัดชิดขอบทั้งสองด้าน (justified) อาจถูกส่งออกเป็น TJ array หนึ่งชุดต่อหนึ่งบรรทัด หรือเป็น Tj หนึ่งครั้งต่อคำพร้อม Tm ชัดเจนก่อนแต่ละคำ หรือเป็นการ show operation เดียวที่พก kerning adjustment แทนระยะห่าง layout สองคอลัมน์อาจส่งออกคอลัมน์ซ้ายจากบนลงล่างแล้วคอลัมน์ขวา หรืออาจสลับกันถ้า producer เดินตาม object list ภายในของตัวเองในลำดับที่ต่างออกไป ลำดับตัวอักษรที่ PDFium ส่งให้คุณเดินตาม content stream และ content stream เดินตามสิ่งที่แอปพลิเคชันที่สร้างมันรู้สึกอยากทำในขณะนั้น ดังนั้นสองฟังก์ชันที่คุณได้จริง ๆ คือ FPDFText_CountChars ซึ่งรายงานว่าหน้ามีตัวอักษรกี่ตัว และ FPDFText_GetCharBox ซึ่งคืน bounding box ของตัวอักษรหนึ่งตัวใน page space นั่นคือคำศัพท์ดิบทั้งหมดที่มีให้ ทุกอย่างที่อยู่เหนือมัน คำ บรรทัด ย่อหน้า คอลัมน์ ล้วนเป็นการอนุมานที่คุณต้องทำเหนือเรขาคณิตเอง
ทำไมการตรวจจับ CR กับ LF ถึงเป็นการทดสอบที่ผิด
เพราะตัวอักษรที่คุณจะทดสอบเทียบด้วยไม่ได้มีอยู่อย่างเชื่อถือได้ และเมื่อมันมีอยู่จริง มันก็ไม่ได้เป็นของคุณอย่างเชื่อถือได้ PDFium ฉีดตัวอักษรสังเคราะห์เข้าไปใน text page เพื่อให้ข้อความที่ดึงออกมาอ่านได้ ช่องว่างตรงที่สองรันแยกกันด้วยสายตา CR หรือ LF ตรงที่รันถัดไปเริ่มบน baseline ใหม่ FPDFText_IsGenerated มีอยู่พอดีเพื่อให้คุณแยกมันออกจากตัวอักษรที่มาจากไฟล์จริง ๆ ได้ และ PDFium Component เปิดให้ใช้เป็น property CharacterGenerated
ถ้าแบ่งตามตัวอักษรเหล่านั้น คุณจะสืบทอดทุกการตัดสินใจที่ PDFium ทำระหว่างการสังเคราะห์มัน line break แบบแข็งภายในย่อหน้าที่ wrap แล้วกับ soft wrap ดูเหมือนกันเป๊ะหลังการสังเคราะห์ แถวตารางที่ producer ส่งออกทีละ cell อาจไม่ได้รับ break เลยระหว่าง cell สุดท้ายกับ cell แรกของแถวถัดไป เพราะ baseline บังเอิญใกล้กันพอ ในขณะเดียวกัน heading ตามด้วยเนื้อหาที่ขนาดต่างกันอาจได้ break สองตัวในที่ที่มนุษย์เห็นแค่หนึ่ง ตัวอักษรสังเคราะห์คือความสะดวกในการเรนเดอร์สำหรับการดึงข้อความทั้งหน้า มันไม่ใช่โมเดลบรรทัด และมันเสื่อมสภาพพอดีในเอกสารที่ selection สำคัญที่สุด
การจัดกลุ่ม character box ตามจุดกึ่งกลางแนวตั้ง
สัญญาณที่เชื่อถือได้คือเรขาคณิต เอาตัวอักษรที่ผู้ใช้คลิกเป็นเมล็ดพันธุ์ คำนวณจุดกึ่งกลางแนวตั้งของ box ของมัน แล้วเดินออกไปทั้งสองทิศทางในขณะที่ box ข้างเคียงยังคงมีจุดกึ่งกลางแนวตั้งอยู่ภายในความคลาดเคลื่อน PDFium Component ใช้ครึ่งหนึ่งของความสูง box เมล็ดพันธุ์เป็นความคลาดเคลื่อนนั้น พร้อมพื้นล่างที่ 0.5 หน่วยหน้า เพื่อไม่ให้ box ที่เสื่อมสภาพ เช่น จุด ช่องว่างบาง glyph ที่มี box เกือบเป็นศูนย์ ยุบความคลาดเคลื่อนให้เหลือความว่างเปล่าและตัดบรรทัดหลังจากตัวอักษรตัวเดียว
function TPdfView.LineRangeAt(TxtPage: FPDF_TEXTPAGE; CharIndex: Integer;
out StartIndex, Count: Integer): Boolean;
var
Lo, Hi, Total: Integer;
SeedBox, Box: TPdfRectangle;
SeedYMid, BoxYMid, HalfH: Double;
begin
Result := False;
StartIndex := -1;
Count := 0;
Total := FPDFText_CountChars(TxtPage);
if (CharIndex < 0) or (CharIndex >= Total) then
Exit;
if FPDFText_GetCharBox(TxtPage, CharIndex, SeedBox.Left, SeedBox.Right,
SeedBox.Bottom, SeedBox.Top) = 0 then
Exit;
SeedYMid := (SeedBox.Top + SeedBox.Bottom) / 2;
HalfH := Abs(SeedBox.Top - SeedBox.Bottom) / 2;
if HalfH < 0.5 then // floor for degenerate boxes
HalfH := 0.5;
Lo := CharIndex;
Hi := CharIndex;
while Lo > 0 do
begin
if FPDFText_GetCharBox(TxtPage, Lo - 1, Box.Left, Box.Right,
Box.Bottom, Box.Top) = 0 then
Break;
BoxYMid := (Box.Top + Box.Bottom) / 2;
if Abs(BoxYMid - SeedYMid) > HalfH then
Break;
Dec(Lo);
end;
while Hi < Total - 1 do
begin
if FPDFText_GetCharBox(TxtPage, Hi + 1, Box.Left, Box.Right,
Box.Bottom, Box.Top) = 0 then
Break;
BoxYMid := (Box.Top + Box.Bottom) / 2;
if Abs(BoxYMid - SeedYMid) > HalfH then
Break;
Inc(Hi);
end;
StartIndex := Lo;
Count := Hi - Lo + 1;
Result := True;
end;
มีสามรายละเอียดในลูปนั้นที่ควรค่าแก่การมีอยู่ ความคลาดเคลื่อนมาจากเมล็ดพันธุ์แทนที่จะมาจากค่าคงที่ ดังนั้น heading 24pt จะได้แถบกว้าง และข้อความ footnote 7pt จะได้แถบแคบ และไม่มีตัวไหนขโมยตัวอักษรจากเพื่อนบ้าน การเปรียบเทียบใช้จุดกึ่งกลางแนวตั้งแทนที่จะเป็น baseline หรือด้านบนของ box ซึ่งทำให้ superscript, run ที่มีขนาดต่างกันแบบ inline หรือประโยคที่ผสมฟอนต์ยังอยู่บนบรรทัดเดียวกับเพื่อนบ้านของมัน และการเรียก FPDFText_GetCharBox ที่ล้มเหลวจะยุติการสแกนแทนที่จะถูกข้าม เพราะตัวอักษรที่ไม่มีเรขาคณิตที่ดึงได้ไม่ให้หลักฐานอะไรเลยไม่ว่าทางไหน และการเดินต่อผ่านมันจะปล่อยให้การเดินกระโดดข้ามขอบเขตจริงได้ด้วยแรงของตัวอักษรที่อยู่ไกลออกไปอีก
ทำไมทุก selection path ต้องแชร์ helper ตัวเดียว
เพราะสาม code path ที่แต่ละอันสร้าง "บรรทัด" ของตัวเองจะเบี่ยงเบนออกจากกัน และมันจะเบี่ยงเบนอย่างเงียบ ๆ ใน PDFium Component การขยาย triple-click, Shift+Home, Shift+End และเมธอดสาธารณะ SelectLineAt ล้วน resolve ขอบเขตของมันผ่านการเรียก LineRangeAt เดียวกัน Triple-click เพาะจากจุดยึดของ selection ปุ่ม shift เพาะจากตำแหน่ง cursor ของ selection และย้ายแค่ปลายนั้น SelectLineAt เพาะจากดัชนีตัวอักษรที่ caller จัดหาให้และส่งผลลัพธ์ไปยัง SelectTextRange ตัวตรวจสอบช่วงเดียวกับที่ mouse path ใช้ ถ้าสร้างตรรกะซ้ำแทน ความล้มเหลวจะไม่ใช่การ crash มันคือการเบี่ยงเบนอย่างช้า ๆ มีคนปรับความคลาดเคลื่อนของ triple-click เพื่อแก้รายงานที่มี leading แน่น แล้วตอนนี้ Shift+End หยุดสั้นกว่าที่ triple-click หยุดในย่อหน้าเดียวกันหนึ่งตัวอักษร ผู้ใช้เลือกบรรทัดด้วยเมาส์ ขยายด้วยคีย์บอร์ด แล้วเห็น selection หดตัว เพราะ SelectLineAt ป้อนเข้าไปยัง selection pipeline ปกติ การเลือกแบบเป็นโปรแกรมจึงยังคงเป็นอิสระจากว่าการป้อนข้อมูลด้วยเมาส์เปิดใช้งานอยู่หรือไม่ และยังได้รับการตรวจสอบช่วง repaint และการแจ้งเตือน OnSelectionChange ฟรี
// Select the visual line under a client-space point, then read it back
procedure TForm1.SelectLineUnderCursor(X, Y: Integer);
var
CharIndex: Integer;
begin
CharIndex := PdfView1.CharacterIndexAtPos(X, Y, 6.0, 6.0);
if CharIndex < 0 then
Exit;
if PdfView1.SelectLineAt(PdfView1.CurrentPage, CharIndex) then
Memo1.Lines.Add(PdfView1.SelectedText);
end;
สังเกตอาร์กิวเมนต์ความคลาดเคลื่อนบน CharacterIndexAtPos การทดสอบการชน (hit testing) มีความหย่อนของตัวเองที่แสดงเป็นหน่วยหน้า และมันเป็นเรื่องแยกจากความคลาดเคลื่อนของบรรทัด การคลิกที่ตกในช่องว่างระหว่างสองบรรทัดจะ resolve ไปยังตัวอักษรที่ใกล้ที่สุดภายใน box นั้น การสแกนบรรทัดจะรันจากตัวอักษรที่ได้นั้น การป้อนความคลาดเคลื่อนของ hit ที่ใจกว้างเกินไปเข้าไปในเมล็ดพันธุ์คือวิธีที่ง่ายกว่าวิธีอื่นในการเลือกบรรทัดที่ผู้ใช้ไม่ได้ตั้งใจชี้
สองพื้นที่ดัชนี: character index กับ text index
เมื่อคุณมีช่วงแล้ว ต้านทานการเอามันไปใช้เป็น string offset FPDFText_GetText คืนข้อความหน้าเป็น buffer UTF-16 แต่ดัชนีของมันไม่ใช่พื้นที่ดัชนีเดียวกับ character index ที่ FPDFText_GetCharBox และ FPDFText_CountChars ใช้ ตัวอักษรสังเคราะห์ที่กล่าวถึงก่อนหน้านี้อยู่ใน text buffer ในขณะที่ครอบครองช่อง character ที่ไม่มีเรขาคณิตที่ใช้งานได้ และตัวเลขทั้งสองระบบจะเบี่ยงเบนออกจากกันข้ามหน้า สะพานเชื่อมคือ FPDFText_GetTextIndexFromCharIndex และ FPDFText_GetCharIndexFromTextIndex ที่ PDFium Component ห่อเป็น CharacterIndexToTextIndex และ TextIndexToCharacterIndex
var
TextStart, TextEnd: Integer;
begin
// char-index range from LineRangeAt -> offsets into the page text buffer
TextStart := Pdf.CharacterIndexToTextIndex(StartIndex);
TextEnd := Pdf.CharacterIndexToTextIndex(StartIndex + Count - 1);
if (TextStart >= 0) and (TextEnd >= TextStart) then
Caption := Pdf.Text(TextStart, TextEnd - TextStart + 1);
end;
ทิศทางที่กัดหนักที่สุดคือทิศทางย้อนกลับ การค้นหาที่ทำเหนือ string ที่ดึงออกมาให้ text index กับคุณ และการส่ง index เหล่านั้นตรงเข้าไปยัง API เรื่อง box หรือ selection จะระบุตัวอักษรผิดอย่างเงียบ ๆ ด้วย error ที่ยิ่งใหญ่ขึ้นเรื่อย ๆ ยิ่งลงไปในหน้าลึกเท่าไร แปลงด้วย TextIndexToCharacterIndex ก่อนที่อะไรก็ตามที่เกี่ยวกับเรขาคณิตจะแตะตัวเลขนั้น surrogate pair เพิ่มปัญหา offset ที่สอง เป็นอิสระซ้อนทับเข้าไปอีก ครอบคลุมในบทความเรื่อง emoji, CJK และ surrogate pairs
จุดที่ heuristic นี้โค้งงอ
ควรตรงไปตรงมากับตัวเองเรื่องขีดจำกัด เพราะมันมีจริงและเข้าถึงได้จริง ข้อความที่หมุนคือกรณีที่ชัดเจนที่สุด character box คือสี่เหลี่ยมแบบ axis-aligned ใน page space ดังนั้นสำหรับข้อความที่หมุน 90 องศา box ของบรรทัดแสดงผลหนึ่งบรรทัดจะมีจุดกึ่งกลางแนวตั้งกระจายไปทั่วหน้า และการสแกนจะหยุดเกือบทันที สิ่งที่คุณได้คือ selection สั้นแทนที่จะเป็น selection ผิด ซึ่งเป็นรูปแบบความล้มเหลวที่ดีกว่า แต่มันก็ยังเป็นความล้มเหลวอยู่ดี โหมดการเขียนแนวตั้งมีพฤติกรรมเดียวกันด้วยเหตุผลเดียวกัน layout สองคอลัมน์ทำงานได้เมื่อคอลัมน์ถูกเลื่อนแนวตั้งออกจากกัน และพังเมื่อไม่เป็นเช่นนั้น ถ้าทั้งสองคอลัมน์ใช้ baseline grid ร่วมกัน ตัวอักษรจากคอลัมน์ขวาจะอยู่ในความคลาดเคลื่อนของบรรทัดคอลัมน์ซ้าย และการสแกนจะวิ่งตรงข้ามช่องว่างไปเลย เพราะในเรขาคณิตล้วน ๆ ไม่มีอะไรตรงนั้นที่จะหยุดมัน การตรวจจับสิ่งนั้นต้องการการทดสอบช่องว่างแนวนอนซ้อนทับบนการจัดกลุ่มแนวตั้ง และการเลือก threshold ของช่องว่างเป็นการตัดสินใจของตัวมันเองว่าคุณยอมให้ผิดกับเอกสารแบบไหน ขนาดฟอนต์ผสมคือกรณีที่ความคลาดเคลื่อนแบบสัมพัทธ์กับเมล็ดพันธุ์จัดการได้ดี code span 8pt แบบ inline ภายในเนื้อหา 11pt ยังคงมีจุดกึ่งกลางอยู่ในแถบ และ heading 24pt บน baseline ถัดไปจะไม่ดึงบรรทัดเนื้อหาเข้ามาในตัวมันเอง
ความหมายของการเลือกบรรทัดที่กล่าวถึงในบทความนี้มาพร้อมกับ PDFium Component สำหรับ Delphi และ C++Builder ควบคู่ไปกับ hit testing, selection range และ text index API ที่ใช้ในตัวอย่าง หน้าผลิตภัณฑ์มีเอกสารอ้างอิงฉบับเต็มสำหรับ text page และโมเดล selection