Bài viết kỹ thuật

Chọn dòng văn bản PDF bằng char box PDFium trong Delphi

Một text page PDF phơi bày ký tự và box, không bao giờ là dòng. PDFium Component xây một dòng trực quan bằng cách gom cụm các box ký tự có tâm dọc rơi trong phạm vi nửa chiều cao ký tự gốc, quét ra ngoài từ ký tự được click cho đến khi vượt quá dung sai. Mọi đường chọn trong trình xem đều gọi cùng một helper đó, nên chuột, bàn phím, và code đều thống nhất

Triệu chứng khiến bạn đi tìm bài này rất cụ thể và khó chịu. Người dùng triple-click một đoạn văn trong một báo cáo hai cột và chọn được nửa trang. Hoặc họ triple-click một ô bảng và vùng chọn nuốt luôn cả hàng cộng với số trang ở chân trang. Trình xem không hỏng; nó đang trả lời một câu hỏi mà file không thể trả lời. Không có dòng nào trong một PDF để chọn, và bất kỳ triển khai nào giả vờ ngược lại đều đang đoán mò. Bài này nói về việc làm cho phép đoán đó có chủ đích và nhất quán. Nếu điều bạn thực sự cần là kéo văn bản ra khỏi một tài liệu, xem trích xuất văn bản từ tài liệu PDF với PDFium; nếu bạn đang dàn trang văn bản và cần độ rộng, xem đo văn bản và ngắt dòng tự động. Ở đây chủ đề hẹp hơn: quyết định nơi một dòng trực quan bắt đầu và kết thúc, và chọn đúng chính xác điều đó

Vì sao một text page PDF không có object dòng?

Vì một content stream PDF mô tả việc vẽ, không phải cấu trúc. ISO 32000-1 §9.4 định nghĩa một text object là một cặp BT / ET chứa các toán tử định vị và hiển thị. Các toán tử định vị của §9.4.2 (Td, TD, Tm, T*) di chuyển một ma trận văn bản quanh trang, và các toán tử hiển thị của §9.4.3 (Tj, TJ, ', ") vẽ các glyph tại bất cứ nơi ma trận đó đang trỏ tới. Không gì trong mô hình đó nói "chuỗi glyph này là một dòng". Một dòng là thứ con người thấy sau khi việc vẽ đã hoàn tất

Các trình sinh khiến điều này tệ hơn theo những cách bạn không thể kiểm soát. Một đoạn văn căn đều có thể được phát ra như một mảng TJ cho mỗi dòng, hoặc như một Tj cho mỗi từ với một Tm tường minh trước mỗi từ, hoặc như một thao tác hiển thị đơn lẻ với các điều chỉnh kerning mang theo khoảng cách. Một bố cục hai cột có thể phát ra cột trái từ trên xuống dưới rồi tới cột phải, hoặc nó có thể xen kẽ chúng nếu trình sinh duyệt danh sách object nội bộ của riêng nó theo một thứ tự khác. Chuỗi ký tự mà PDFium đưa cho bạn theo content stream, và content stream theo bất cứ gì ứng dụng sinh ra cảm thấy muốn làm. Vậy nên hai hàm bạn thực sự có là FPDFText_CountChars, báo cáo trang giữ bao nhiêu ký tự, và FPDFText_GetCharBox, trả về hộp giới hạn của một ký tự trong page space. Đó là toàn bộ từ vựng thô. Mọi thứ bên trên nó, từ, dòng, đoạn văn, cột, là suy luận bạn thực hiện trên hình học

Vì sao phát hiện CR và LF là phép kiểm tra sai?

Vì các ký tự bạn sẽ kiểm tra không hiện diện đáng tin cậy, và khi chúng hiện diện, chúng không đáng tin cậy là của bạn. PDFium tiêm các ký tự tổng hợp vào text page để văn bản được trích xuất dễ đọc: một dấu cách nơi hai đoạn được tách biệt trực quan, một CR hoặc LF nơi đoạn tiếp theo bắt đầu trên một baseline mới. FPDFText_IsGenerated tồn tại chính xác để bạn có thể phân biệt chúng với các ký tự đến từ file, và PDFium Component phơi bày nó như thuộc tính CharacterGenerated

Tách theo những ký tự đó và bạn thừa hưởng mọi phán đoán mà PDFium đã đưa ra khi tổng hợp chúng. Một ngắt dòng cứng bên trong một đoạn văn được bọc mềm và một lần xuống dòng mềm trông giống hệt nhau sau khi tổng hợp. Một hàng bảng mà trình sinh phát ra theo từng ô có thể không có ngắt nào cả giữa ô cuối cùng và ô đầu tiên của hàng tiếp theo, vì các baseline tình cờ đủ gần nhau. Trong khi đó một tiêu đề theo sau bởi văn bản thân bài ở kích thước khác có thể nhận hai ngắt nơi con người thấy một. Các ký tự được sinh ra là một tiện ích render cho việc trích xuất toàn trang; chúng không phải một mô hình dòng, và chúng suy giảm chính xác trong những tài liệu mà việc chọn quan trọng nhất

Gom cụm box ký tự theo tâm dọc

Tín hiệu đáng tin cậy là hình học. Lấy ký tự người dùng đã click làm hạt giống, tính tâm dọc của box của nó, và đi ra ngoài theo cả hai hướng trong khi các box lân cận giữ tâm dọc của chúng trong dung sai. PDFium Component dùng nửa chiều cao box hạt giống làm dung sai đó, với một sàn 0,5 đơn vị trang để các box suy biến, một dấu chấm, một khoảng trắng mảnh, một glyph có box chiều cao gần bằng không, không làm sụp dung sai về không và cắt dòng sau một ký tự

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;

Ba chi tiết trong vòng lặp đó xứng đáng có mặt. Dung sai suy ra từ hạt giống thay vì từ một hằng số, nên một tiêu đề 24pt nhận một dải rộng và văn bản footnote 7pt nhận một dải hẹp, và không cái nào cướp ký tự từ cái kia. Phép so sánh dùng tâm dọc thay vì baseline hay đỉnh box, giữ một chỉ số trên, một đoạn cỡ khác chèn giữa dòng, hoặc một câu pha trộn font trên cùng một dòng với các lân cận của nó. Và một lần FPDFText_GetCharBox thất bại kết thúc lượt quét thay vì bị bỏ qua, vì một ký tự không có hình học truy xuất được không cho bạn bằng chứng nào theo cả hai hướng, và tiếp tục vượt qua nó sẽ cho phép lượt quét nhảy qua một ranh giới thật dựa trên sức mạnh của một ký tự xa hơn

Vì sao mọi đường chọn phải chia sẻ một helper?

Vì ba đường code mỗi cái tự triển khai "dòng" sẽ phân kỳ, và chúng sẽ phân kỳ trong im lặng. Trong PDFium Component, mở rộng triple-click, Shift+Home, Shift+End, và phương thức công khai SelectLineAt đều phân giải ranh giới của chúng qua cùng lệnh gọi LineRangeAt. Triple-click gieo hạt nó từ điểm neo vùng chọn; các phím shift gieo hạt nó từ con trỏ vùng chọn và chỉ di chuyển đầu đó; SelectLineAt gieo hạt nó từ một chỉ số ký tự do bên gọi cung cấp và đưa kết quả cho SelectTextRange, cùng trình xác thực range mà đường chuột dùng. Trùng lặp logic thay vào đó và thất bại không phải một crash, đó là một sự trôi dạt chậm rãi. Ai đó tinh chỉnh dung sai triple-click để sửa một báo cáo có leading chặt, và giờ Shift+End dừng lại thiếu một ký tự so với nơi triple-click dừng trên cùng đoạn văn. Người dùng chọn một dòng bằng chuột, mở rộng nó bằng bàn phím, và nhìn vùng chọn co lại. Vì SelectLineAt cấp dữ liệu cho pipeline chọn thông thường, việc chọn theo chương trình cũng vẫn độc lập với việc đầu vào chuột có được bật hay không, và vẫn nhận xác thực range, vẽ lại, và thông báo OnSelectionChange miễn phí

// 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;

Lưu ý các tham số dung sai trên CharacterIndexAtPos. Kiểm tra trúng đích có độ lỏng riêng của nó, biểu diễn bằng đơn vị trang, và đó là một mối quan tâm riêng biệt với dung sai dòng. Một cú click rơi vào khoảng leading giữa hai dòng phân giải về bất kỳ ký tự nào gần nhất trong box đó; lượt quét dòng sau đó chạy từ bất cứ ký tự nào hóa ra là vậy. Đưa một dung sai trúng đích quá rộng rãi vào hạt giống là một trong những cách dễ dàng hơn để chọn một dòng mà người dùng không thực sự chỉ vào

Hai không gian chỉ số: chỉ số ký tự và chỉ số văn bản

Một khi bạn có một range, hãy cưỡng lại ý muốn dùng nó như một offset chuỗi. FPDFText_GetText trả về văn bản trang dưới dạng một buffer UTF-16, nhưng chỉ số của nó không phải cùng không gian chỉ số với chỉ số ký tự dùng bởi FPDFText_GetCharBoxFPDFText_CountChars. Các ký tự được sinh ra thảo luận trước đó nằm trong buffer văn bản trong khi chiếm các vị trí ký tự không có hình học khả dụng, và hai cách đánh số trôi dạt xa nhau qua trang. Các cầu nối là FPDFText_GetTextIndexFromCharIndexFPDFText_GetCharIndexFromTextIndex, được PDFium Component bọc thành CharacterIndexToTextIndexTextIndexToCharacterIndex

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;

Hướng cắn đau nhất là hướng ngược lại. Một tìm kiếm triển khai trên chuỗi đã trích xuất cho bạn chỉ số văn bản, và truyền chúng thẳng vào một API box hoặc chọn âm thầm định địa chỉ sai ký tự, với một lỗi lớn dần khi bạn đi càng xa xuống trang. Chuyển đổi bằng TextIndexToCharacterIndex trước khi bất cứ thứ gì hình học chạm vào con số. Cặp surrogate thêm một vấn đề offset thứ hai, độc lập lên trên vấn đề này, được trình bày trong bài về emoji, CJK và cặp surrogate

Nơi phép suy nghiệm bị bẻ cong

Hãy trung thực với bản thân về các giới hạn, vì chúng là thật và có thể chạm tới. Văn bản xoay là trường hợp rõ ràng nhất: một box ký tự là một hình chữ nhật theo trục trong page space, nên với văn bản xoay 90 độ, các box của một dòng trực quan có tâm dọc trải khắp trang, và lượt quét dừng gần như ngay lập tức. Những gì bạn nhận được là một vùng chọn ngắn thay vì một vùng chọn sai, đó là kiểu thất bại tốt hơn, nhưng nó vẫn là một thất bại. Chế độ viết dọc hành xử tương tự vì cùng lý do. Bố cục hai cột hoạt động khi các cột lệch nhau theo chiều dọc và hỏng khi chúng không lệch. Nếu cả hai cột chia sẻ cùng một lưới baseline, các ký tự từ cột phải nằm trong dung sai của dòng cột trái, và lượt quét sẽ chạy thẳng qua rãnh giữa cột, vì trong hình học thuần túy không có gì ở đó để dừng lại. Phát hiện điều đó cần một phép kiểm tra khoảng cách ngang trên nền gom cụm dọc, và chọn ngưỡng khoảng cách là một phán đoán riêng về những tài liệu bạn sẵn sàng chấp nhận sai. Kích thước font pha trộn là trường hợp mà dung sai tương đối theo hạt giống xử lý tốt: một đoạn code 8pt chèn giữa trong văn bản thân bài 11pt giữ tâm của nó bên trong dải, và một tiêu đề 24pt trên baseline tiếp theo không kéo dòng thân bài vào chính nó

Ngữ nghĩa chọn dòng mô tả ở đây đi kèm trong PDFium Component cho Delphi và C++Builder, cùng với các API kiểm tra trúng đích, range chọn và chỉ số văn bản dùng trong các ví dụ; trang sản phẩm mang tài liệu tham chiếu đầy đủ cho mô hình text page và chọn