Bài viết kỹ thuật

Trích xuất văn bản từ tệp PDF bằng PDFium Component trong Delphi

Việc trích xuất văn bản PDF có vẻ đơn giản cho đến khi bạn gặp một tài liệu mà lớp văn bản bị thiếu, bị hỏng, hoặc bị chia thành hàng chục đoạn ký tự nhỏ mà không có thứ tự có ý nghĩa. PDFium Component cung cấp cho bạn hai điểm nhập: mảng Character[] để truy cập dựa trên chỉ mục thô vào từng glyph trên một trang, và ReadablePageContent cho một dạng xem có cấu trúc tái tạo lại các đoạn văn và tiêu đề từ cây thẻ của PDF hoặc phân tích suy luận. Không phải cái nào cũng luôn là lựa chọn đúng, vì vậy việc hiểu rõ từng cái tiết lộ điều gì là rất quan trọng

Mở tài liệu và bẫy lỗi im lặng

TPdf mở một tệp bằng cách đặt FileName và bật Active := True. Chi tiết quan trọng: Active := True không bao giờ ném ra ngoại lệ. Nếu tệp bị thiếu, được bảo vệ bằng mật khẩu hoặc bị hỏng, PDFium sẽ bắt lỗi bên trong và Active chỉ đơn giản là vẫn ở trạng thái False. Điều đó có nghĩa là mọi vòng lặp trích xuất đều phải đề phòng điều này:

Pdf := TPdf.Create(nil);
try
  Pdf.FileName := 'report.pdf';
  Pdf.Active := True;
  if not Pdf.Active then
  begin
    ShowMessage('Could not open PDF (damaged or wrong password)');
    Exit;
  end;
  // extraction follows here
finally
  Pdf.Active := False;
  Pdf.Free;
end;

Các tệp được bảo vệ bằng mật khẩu cần thiết lập Pdf.Password := '...' trước Active := True. Không có cơ hội thứ hai: một khi Active thất bại, bạn đóng lại và mở lại bằng mật khẩu đúng

Trích xuất theo từng trang bằng Character[]

Cách tiếp cận cấp thấp nhất là duyệt qua mọi ký tự trên mỗi trang. Đặt Pdf.PageNumber để tải lớp văn bản cho trang đó, sau đó lặp qua các mục nhập CharacterCount bằng cách sử dụng thuộc tính Character[]. Có hai cờ trên mỗi mục nhập đáng để kiểm tra: CharacterGenerated[i] đánh dấu các glyph tổng hợp được chèn vào bởi bộ kết xuất (ví dụ như dấu gạch nối mềm tại các ngắt dòng) không có giá trị Unicode thực sự, và CharacterMapError[i] báo hiệu rằng PDFium không thể ánh xạ glyph đó tới một điểm mã (code point), điều này xảy ra với các mã hóa phông chữ thiếu bảng ToUnicode

procedure ExtractAllText(Pdf: TPdf; Output: TStrings);
var
  Page, I: Integer;
  Line: string;
  Ch: WideChar;
begin
  for Page := 1 to Pdf.PageCount do
  begin
    Pdf.PageNumber := Page;
    Line := '';
    for I := 0 to Pdf.CharacterCount - 1 do
    begin
      if Pdf.CharacterGenerated[I] or Pdf.CharacterMapError[I] then
        Continue;
      Ch := Pdf.Character[I];
      if Ch = #13 then
        Ch := #10;   // normalize CR to LF
      Line := Line + Ch;
    end;
    Output.Add(Line);
  end;
end;

Kết quả là một chuỗi phẳng của các điểm mã Unicode theo thứ tự PDFium liệt kê chúng, đó là thứ tự chúng xuất hiện trong luồng nội dung, không nhất thiết phải là thứ tự đọc từ trái sang phải. Đối với hầu hết các tài liệu kịch bản chữ Latinh do các công cụ văn phòng tiêu chuẩn tạo ra, điều này là ổn. Đối với các tệp PDF được quét đã được OCR với các chuỗi glyph bất thường, hoặc cho văn bản từ phải sang trái, thứ tự có thể sai. Đó là lúc ReadablePageContent trở nên hữu ích hơn

Trích xuất có cấu trúc bằng ReadablePageContent

ReadablePageContent đi lên một cấp độ cao hơn: nó trả về một bản ghi TPdfReadableContent mà mảng Fragments của nó mang các phân đoạn nội dung được gắn thẻ, mỗi cái có một Kind xác định đó là đoạn văn, tiêu đề, mục danh sách, ô trong bảng, và v.v. Khi PDF mang một cây cấu trúc (kiểm tra Pdf.IsTagged), nguồn là rosStructure và thứ tự đọc là có thẩm quyền. Đối với các tệp không được gắn thẻ, PDFium rơi vào trường hợp rosHeuristic, nhóm các ký tự theo hộp bao quanh (bounding box) của chúng thành các đơn vị đọc hợp lý nhưng không thể đảm bảo độ chính xác

procedure ExtractStructured(Pdf: TPdf; Output: TStrings);
var
  Page: Integer;
  Content: TPdfReadableContent;
  Fragment: TPdfContentFragment;
begin
  for Page := 1 to Pdf.PageCount do
  begin
    Content := Pdf.ReadablePageContent(Page);
    for Fragment in Content.Fragments do
    begin
      case Fragment.Kind of
        cfHeading   : Output.Add('# ' + Fragment.Text);
        cfParagraph : Output.Add(Fragment.Text);
        cfListItem  : Output.Add('- ' + Fragment.Text);
      else
        Output.Add(Fragment.Text);
      end;
    end;
  end;
end;

Nếu Content.Source = rosHeuristic và đầu ra của bạn trông lộn xộn, lớp văn bản của tài liệu có lẽ không được viết với tâm trí về thứ tự đọc. Lúc đó cách khắc phục đáng tin cậy duy nhất là xuất lại từ ứng dụng nguồn với cấu trúc thẻ thích hợp, hoặc chạy một bước xử lý hậu kỳ sắp xếp nguồn gốc ký tự theo trục Y rồi tới trục X

CharacterOrigin và CharacterRectangle cung cấp cho bạn điều gì

Cả hai thuộc tính đều trả về vị trí của một ký tự trong không gian trang (point, gốc tọa độ ở góc dưới cùng bên trái, Y tăng dần lên trên). CharacterOrigin[i] là điểm neo đường cơ sở (baseline) của glyph; CharacterRectangle[i] là toàn bộ hộp bao quanh. Đây là những khối xây dựng cho bất kỳ thứ gì vượt ra ngoài văn bản thuần túy: phát hiện ranh giới cột, nhóm các ký tự thành các dòng bằng cách so sánh các tọa độ Y trong một khoảng dung sai, hoặc xây dựng một bản đồ kiểm tra chạm (hit-test) cho việc chọn văn bản trong trình xem. Nếu bạn cần tìm ký tự nào nằm dưới một cú nhấp chuột, CharacterIndexAtPos(X, Y, ToleranceX, ToleranceY) sẽ thực hiện việc tra cứu đó trực tiếp mà bạn không cần phải lặp qua các hình chữ nhật

Đưa DLL vào đúng vị trí

PDFium Component ủy quyền toàn bộ việc phân tích PDF cho một DLL gốc (native), có thể là pdfium32.dll hoặc pdfium64.dll tùy thuộc vào nền tảng mục tiêu của bạn. Thành phần đi kèm với một tập lệnh CopyDlls.bat để sao chép đúng tệp vào thư mục hệ thống Windows. Việc chạy nó bằng quyền Administrator một lần trên máy phát triển là đủ; để triển khai, bạn sao chép DLL dọc theo tập tin thực thi của ứng dụng thay thế. Các biến thể có kích hoạt V8 (pdfium32v8.dll, pdfium64v8.dll) có kích thước lớn hơn đáng kể và chỉ cần thiết nếu các PDF của bạn chứa JavaScript cần được thực thi. Đối với việc trích xuất văn bản thuần túy, bản dựng tiêu chuẩn là sự lựa chọn đúng đắn

Nếu DLL bị thiếu tại thời điểm chạy, Active := True sẽ thất bại một cách im lặng giống như đối với một tệp bị thiếu, bởi vì thành phần này tự bắt lỗi tải nội bộ. Luôn luôn kiểm tra trên một máy tính sạch trước khi giao hàng

Sử dụng FontSize[] cùng với Character[] cho phân tích bố cục

Vượt qua văn bản thuần túy, API mức ký tự phơi bày FontSize[i], trả về kích thước điểm (point size) đã kết xuất của mỗi glyph. Kết hợp với CharacterOrigin[i]CharacterRectangle[i], điều này cho phép bạn phân biệt văn bản phần thân với các tiêu đề mà không cần dựa vào cây cấu trúc. Một đoạn ký tự nơi kích thước phông chữ tăng vọt lên trên một ngưỡng gần như chắc chắn là một tiêu đề trong một tài liệu không được gắn thẻ. Cùng kỹ thuật này áp dụng cho việc phát hiện các chú thích (caption - văn bản nhỏ bên dưới hộp giới hạn hình ảnh) hoặc các chú thích cuối trang (footnote - văn bản nhỏ gần cuối trang). Không cái nào trong số này đòi hỏi kết xuất đồ họa; cả ba thuộc tính đọc trực tiếp từ lớp văn bản mà PDFium xây dựng trong lúc gọi Active := True

Một sắc thái nhỏ: FontSize[i] phản ánh kích thước sau khi ma trận biến đổi hiện tại (CTM) của trang được áp dụng, vì vậy một tài liệu mà tác giả đã mở rộng toàn bộ trang sẽ báo cáo kích thước được điều chỉnh theo tỷ lệ. Nếu bạn đang so sánh kích thước trên các trang có kích thước trang khác nhau, hãy chuẩn hóa theo chiều cao MediaBox của mỗi trang trước khi đưa ra quyết định ngưỡng

Ghi đầu ra ra tệp

TStringList của Delphi xử lý đầu ra UTF-8 sạch sẽ kể từ XE. Đặt WriteBOM := False nếu bạn cần một tệp không có BOM (nhiều bộ xử lý phía sau (downstream consumers) bị nghẽn bởi BOM ở đầu):

var
  Lines: TStringList;
begin
  Lines := TStringList.Create;
  try
    ExtractAllText(Pdf, Lines);
    Lines.WriteBOM := False;
    Lines.SaveToFile('output.txt', TEncoding.UTF8);
  finally
    Lines.Free;
  end;
end;

Đối với các tài liệu rất lớn nơi bộ nhớ là một mối quan tâm, hãy ghi trực tiếp vào một TStreamWriter với TEncoding.UTF8 bên trong vòng lặp trang thay vì tích lũy mọi thứ vào một danh sách trước

Các API Character[], CharacterCount, CharacterOrigin[], CharacterRectangle[], ReadablePageContent, và CharacterIndexAtPos được trình bày ở đây là một phần của PDFium Component cho Delphi và C++Builder