Bài viết kỹ thuật

Tra cứu PDF trong Delphi với tọa độ điểm khớp: PDFlibPas

Trích xuất văn bản của một trang là nửa việc dễ. Ngay khi người dùng gõ một từ vào ô tìm kiếm và mong trình xem nhảy đến nó rồi vẽ một khung vàng quanh nó, bạn cần thứ mà chuỗi văn bản phẳng không thể cho bạn: trang chứa mỗi lần khớp, và hình chữ nhật mà nó chiếm trong tọa độ PDF. Một chuỗi đã nối qua cả trang đã mất hình học đó. Bạn có thể tìm được chuỗi con, nhưng không thể chỉ đúng vào nó

PDFlibPas là thư viện PDF Object Pascal thuần gốc cho Delphi và C++Builder, và từ v3.78.0 nó trả lời đúng câu hỏi đó. Ba API truy vấn nằm trên bộ trích xuất khối văn bản hiện có: SearchText duyệt một phạm vi trang và trả về từng điểm khớp cùng trang và hình chữ nhật song song trục, EnumPageElements liệt kê mọi thứ trên một trang, từ khối văn bản đến ảnh nhúng, còn GetTextInAreaEx báo lại hình chữ nhật của từng khối nằm trong một vùng thay vì ép chúng thành danh sách chuỗi. Không API nào đụng đến đường ghi; đây chỉ là các bổ sung phía đọc trên chính bộ máy mà thư viện vốn đã có

Vì sao hình học nằm trong danh sách khối văn bản, không nằm trong phễu trích xuất

Bản năng tự nhiên là tái dùng bất cứ thứ gì GetPageText chạy bên trong. Đường đi đó qua một phễu trích xuất tạm thời tạo chuỗi của trang rồi tự giải phóng trước khi lời gọi trả về. Đến lúc bạn cầm được kết quả, tọa độ của từng khối đã biến mất. Chúng vốn chưa từng là thứ để giữ lại

Các tọa độ thật ra vẫn tồn tại trong một cấu trúc khác. ExtractPageTextBlocks(3) trả về một handle danh sách khối văn bản, trong đó mỗi mục mang một quad bao bốn điểm gồm tám số double, tên font, cỡ font và nội dung của khối. Handle đó là nơi duy nhất giữ hình học sau khi trích xuất, nên mọi API truy vấn mới đều dựa vào nó thay vì phễu. Dùng lại danh sách khối nghĩa là tìm kiếm, liệt kê và truy vấn vùng đều dùng chung một lần trích xuất và cùng một định nghĩa về vị trí của khối

Vì thế hình dạng của SearchText xuất phát từ ràng buộc đó. Với mỗi trang trong phạm vi, nó trích xuất danh sách khối, đọc văn bản của từng khối bằng GetTextBlockText, so khớp với truy vấn, và với những khối khớp nó rút quad về hình chữ nhật. Điểm khớp trả về là một bản ghi nhỏ:

type
  TPDFlibSearchHit = record
    Page: Integer;                       // 1-based page of the match
    Left, Top, Right, Bottom: Double;    // axis-aligned hit rectangle
    MatchText: WideString;               // the block text that contained the query
  end;

Mảng bound là X/Y xen kẽ, không phải bốn góc

Đây là chi tiết vấp đầu tiên. GetTextBlockBound(ListID, Index, BoundIndex) nhận một BoundIndex từ 1 đến 8, và tám giá trị đó không phải kiểu "góc 1, góc 2, góc 3, góc 4" với hai trường cho mỗi góc như bạn có thể đoán. Chúng là X, Y, X, Y, X, Y, X, Y: chỉ số lẻ là tọa độ X, chỉ số chẵn là tọa độ Y, tổng cộng bốn điểm. Ghép sai cặp là hình chữ nhật sẽ vô nghĩa

Lý do có quad thay vì hình chữ nhật đơn giản là do xoay. Một khối văn bản đặt nghiêng có đa giác bao bốn điểm thực sự, và tám số double mô tả chính xác nó. Với tình huống tô sáng và nhảy tới vị trí, bạn gần như luôn muốn một khung thẳng đứng thay thế, nên thư viện rút quad về hình chữ nhật song song trục bằng cách quét bốn điểm để lấy X và Y nhỏ nhất, lớn nhất. Văn bản xoay sẽ co lại thành khung thẳng đứng bao lấy nó, đúng thứ một lớp highlight cần:

var
  Pdf: TPDFlib;
  Hits: array[0..255] of TPDFlibSearchHit;
  Found, I: Integer;
begin
  Pdf := TPDFlib.Create(nil);
  try
    Pdf.LoadFromFile('contract.pdf', '');
    // Search pages 1 to 10, case-insensitive, substring match.
    Found := Pdf.SearchText('indemnity', [], '1-10', Hits);
    for I := 0 to Found - 1 do
      if I <= High(Hits) then
        WriteLn(Format('p%d: [%.1f %.1f %.1f %.1f] %s',
          [Hits[I].Page, Hits[I].Left, Hits[I].Top,
           Hits[I].Right, Hits[I].Bottom, Hits[I].MatchText]));
  finally
    Pdf.Free;
  end;
end;

Lưu ý hình chữ nhật nằm trong điểm PDF user-space với gốc ở góc dưới bên trái của trang, cùng hệ tọa độ bạn dùng cho các lời gọi vẽ và chú thích. Điều đó là chủ ý: hình chữ nhật bạn nhận từ một điểm khớp tìm kiếm chính là hình chữ nhật bạn có thể đưa thẳng vào một chú thích highlight hoặc lệnh "scroll here" mà không cần chuyển đổi gì

Phân biệt hoa thường, từ nguyên vẹn, và chỗ CJK khác đi

Tham số thứ hai là một tập TPDFlibSearchOptions lấy từ soCaseSensitivesoWholeWord. Tập rỗng [] là trường hợp phổ biến: tìm chuỗi con không phân biệt hoa thường. Thêm soCaseSensitive để phân biệt Indemnityindemnity, thêm soWholeWord để ngăn sign khớp bên trong signature, hoặc kết hợp cả hai

Khớp toàn từ cần một định nghĩa về ranh giới từ, và ở đây quy tắc đáng nói thẳng vì được thiết kế theo ASCII. Một ký tự được tính là thuộc từ khi nó là chữ cái ASCII, chữ số ASCII, hoặc dấu gạch dưới: lớp [A-Za-z0-9_] quen thuộc từ quy tắc định danh. Một lần khớp chỉ được xem là toàn từ khi các ký tự ngay trước và ngay sau nó không phải ký tự từ, hoặc điểm khớp nằm ở mép khối

Hệ quả với các hệ chữ không phải Latin là điều nên biết trước khi bạn phát hành một ô tìm kiếm đa ngôn ngữ. Vì chữ Hán, kana và các chữ không phải ASCII khác nằm ngoài lớp đó, mọi ranh giới sát bên chúng đều được xem là mép không phải từ. Thực tế là tìm kiếm toàn từ trên văn bản CJK sẽ hành xử như thể mọi vị trí đều là ranh giới từ hợp lệ, nên cờ này gần như chỉ còn tìm chuỗi con ở đó. Đó là giới hạn đã được ghi nhận, không phải lỗi, và nó khớp với hành vi mà tính năng này được mô phỏng theo. Nếu kho nội dung của bạn chủ yếu là CJK, chế độ toàn từ sẽ không cho bạn phân đoạn như một bộ tách từ chuyên dụng; hãy thiết kế quanh giới hạn đó thay vì dựa vào nó

Một ghi chú nhỏ về triển khai giải thích một lớp lỗi tinh vi ở chỗ khác: so sánh không phân biệt hoa thường dùng UpperCase trên WideString, không phải AnsiUpperCase. Biến thể Ansi trả về AnsiString, không khớp với WideString mà phần còn lại của đường đi dùng, và trộn hai kiểu này sẽ gây lệch kiểu, tệ hơn là làm mất dữ liệu khi ép chữ ngoài code page hiện hành. Unicode vào, Unicode ra, xuyên suốt

Một bộ phân tích phạm vi trang cho toàn thư viện

Tham số thứ ba là chuỗi phạm vi trang như "1,3,5-9". Cách phân tích nó không có gì tùy biến: chính PLParsePageRangeList, thứ đứng sau PrintPages và các routine sao chép trang, cũng xử lý nó ở đây, nên phạm vi in đúng thì phạm vi tìm cũng đúng. Chuỗi phạm vi rỗng là ký hiệu cho "mọi trang", khi đó SearchText tự dựng toàn bộ danh sách

Phạm vi quyết định chi phí. Tìm một lát 10 trang trong tài liệu 1.000 trang chỉ trích xuất khối cho 10 trang chứ không phải 1.000, vì vòng lặp chỉ chọn và trích xuất những trang được nêu trong phạm vi. Khi bạn đã biết điều khoản nằm ở phần phụ lục, hãy nói điều đó trong phạm vi và bỏ qua phần còn lại của tệp

Bên trong, cả tìm kiếm lẫn liệt kê đều thay đổi trang đang chọn khi chúng lặp qua, nên mỗi hàm sẽ lưu trang đang chọn của người gọi lúc vào và khôi phục lại trong một khối finally. Gọi SearchText giữa lúc đang dựng một trang và trạng thái chọn của bạn sẽ đúng chỗ bạn đã để lại khi lời gọi kết thúc. Cam kết lưu rồi phục hồi đó là kiểu thứ bạn chỉ nhận ra khi nó thiếu, và chính vì vậy nó ở đó

Liệt kê toàn bộ một trang: văn bản và ảnh trong cùng một danh sách

Tìm kiếm trả lời câu hỏi "từ này ở đâu". Nửa còn lại của việc soi cấu trúc là "trên trang này có gì", và đó là EnumPageElements. Hàm này trả về một danh sách thống nhất, trong đó mỗi phần tử hoặc là một khối văn bản hoặc là một ảnh nhúng, được phân biệt bằng trường Kind:

type
  TPDFlibPageElementKind = (ekText, ekImage);

  TPDFlibPageElement = record
    Kind: TPDFlibPageElementKind;
    Page: Integer;
    Left, Top, Right, Bottom: Double;
    Text: WideString;        // ekText
    FontName: WideString;    // ekText
    FontSize: Double;        // ekText
    ImageID: Integer;        // ekImage; usable with SelectImage / GetImageID
  end;

Các phần tử văn bản đến từ cùng một lần ExtractPageTextBlocks, nên mỗi phần tử đã có sẵn hình chữ nhật, tên font và cỡ font. Các phần tử ảnh đến từ danh sách ảnh nhúng của trang qua FindImagesGetImageID; ImageID mà chúng mang theo là handle bạn đưa vào SelectImage để soi ảnh sâu hơn. Hai loại cùng nằm trong một mảng để chỉ cần một lượt quét trang là thấy mọi thứ trên đó

var
  Pdf: TPDFlib;
  Elems: array[0..511] of TPDFlibPageElement;
  Total, I: Integer;
begin
  Pdf := TPDFlib.Create(nil);
  try
    Pdf.LoadFromFile('report.pdf', '');
    Total := Pdf.EnumPageElements(1, Elems);
    for I := 0 to Total - 1 do
      if I <= High(Elems) then
        if Elems[I].Kind = ekText then
          WriteLn(Format('text  %s/%.1f  "%s"',
            [Elems[I].FontName, Elems[I].FontSize, Elems[I].Text]))
        else
          WriteLn(Format('image id=%d', [Elems[I].ImageID]));
  finally
    Pdf.Free;
  end;
end;

Ở đây có một quy ước đếm phải giữ đúng, nếu không bạn sẽ đọc vào vùng nhớ chưa khởi tạo. Giá trị trả về là tổng số phần tử, có thể lớn hơn mảng bạn truyền vào. Hàm chỉ điền số ô vừa đủ và tiếp tục đếm phần còn lại, đúng như cách liệt kê chữ ký hoạt động. Vì thế, luôn khóa vòng lặp ở giá trị nhỏ hơn giữa số lượng trả về và High(array), không bao giờ lặp mù đến hết số đếm. Các ví dụ ở trên dùng kiểm tra I <= High(...) vì lý do đó. Nếu giá trị trả về vượt buffer của bạn, hãy cấp một mảng lớn hơn rồi gọi lại

Nếu bạn đã dùng các lời gọi khối văn bản cấp thấp của thư viện, đây là lớp có kiểu rõ ràng và hiểu hình học phía trên chúng; phần trích xuất nền tảng chính là cái được mô tả trong Trích xuất văn bản, ảnh và font PDF trong Delphi với PDFlibPas. Và khi mục tiêu không phải "văn bản này ở đâu" mà là "tài liệu này được tổ chức thế nào cho công nghệ hỗ trợ", câu chuyện đọc song song là cây cấu trúc tagged PDF, thứ lộ ra thứ tự đọc logic thay vì bố cục khối vật lý

Truy vấn vùng khi bạn đã biết chỗ cần xem

Đôi khi bạn không có từ khóa tìm kiếm nào cả, bạn chỉ có một hình chữ nhật. Mẫu biểu luôn đặt số hóa đơn ở góc trên bên phải, hoặc bố cục quét sẵn dành một dải cố định cho bảng. GetTextInAreaEx dùng cho trường hợp đó. Nó là bản tương ứng có kèm tọa độ của GetTextInArea: nơi lời gọi cũ trả về một danh sách chuỗi phẳng cho một vùng, lời gọi mới trả về hình chữ nhật của từng khối được giữ lại cùng với văn bản của nó, nên bạn không chỉ biết trong khung có gì mà còn biết mỗi dòng nằm ở đâu bên trong

var
  Pdf: TPDFlib;
  Hits: array[0..63] of TPDFlibSearchHit;
  Found, I: Integer;
begin
  Pdf := TPDFlib.Create(nil);
  try
    Pdf.LoadFromFile('invoice.pdf', '');
    Pdf.SelectPage(1);
    // Left, Top, Width, Height in PDF points on the selected page.
    Found := Pdf.GetTextInAreaEx(360, 720, 180, 60, Hits);
    for I := 0 to Found - 1 do
      if I <= High(Hits) then
        WriteLn(Hits[I].MatchText);
  finally
    Pdf.Free;
  end;
end;

Có hai điểm cần giữ cho rõ. GetTextInAreaEx làm việc trên trang đang được chọn, nên hãy gọi SelectPage trước; khác với SearchText, nó không nhận phạm vi. Và một khối sẽ được giữ lại khi nó giao cắt hình chữ nhật truy vấn, không chỉ khi nó nằm trọn bên trong, nên một dòng cắt ngang biên vẫn đi qua. Đó thường là điều bạn muốn cho một khung chọn vẽ tay, nhưng nếu cần chứa chặt thì bạn có thể tự lọc các hình chữ nhật trả về, vì giờ bạn đã có chúng

Đưa nó vào thực tế

Mạch xuyên suốt của cả ba lời gọi là hình học không còn là thứ bạn tự dựng lại sau sự thật. Điểm khớp tìm kiếm biết trang của nó và hộp của nó. Phần tử trang biết hình chữ nhật của nó và, với văn bản, cả font của nó. Truy vấn vùng báo cho bạn biết từng dòng rơi vào đâu. Chỉ vậy đã đủ để dựng một tính năng tìm rồi tô sáng thực sự, một chỉ mục bấm để tới vị trí, hoặc một bộ trích xuất hiểu bố cục mà không cần đi xuống dưới API công khai hay tự viết lại toàn bộ đường ống trích xuất văn bản

Các API truy vấn này được phát hành cùng Thư viện PDF Delphi PDFlibPas, đi kèm toàn bộ lớp trích xuất khối văn bản làm nền cho chúng và phần còn lại của mặt đọc chuyên khảo sát dành cho Delphi và C++Builder