Bài viết kỹ thuật

Đo lường văn bản PDF để bố cục và ngắt từ trong Delphi

Lệnh gọi đưa văn bản lên một trang PDF là trực tiếp. Bạn đưa cho AddText một chuỗi, một phông chữ, kích thước và vị trí, và các glyph sẽ xuất hiện. Nhưng điều nó không làm là không cho bạn biết chiều rộng của chuỗi đó sẽ là bao nhiêu sau khi nó được vẽ xong, và nó không chia một chuỗi dài ra thành nhiều dòng. Một lệnh gọi duy nhất vẽ ra một đoạn văn bản ở một vị trí. Nếu đoạn đó rộng hơn so với cột mà bạn muốn nó vừa với, nó đơn giản là tràn ra mép, và không có gì trong lệnh vẽ cảnh báo bạn. Ngay khi bạn muốn một đoạn văn chứ không phải là một nhãn đơn lẻ, phần còn thiếu chính là chiều rộng của một chuỗi bằng phông chữ và kích thước đã chọn, được đo trước khi bạn gửi nó lên trang

Đây là bài toán kinh điển về bố cục (layout). Để ngắt một đoạn văn (word wrap) vào một cột, bạn phải biết từng từ một, không gian ngang mà mỗi dòng ứng cử viên sẽ chiếm, và bạn phải biết điều đó trước khi vẽ ra bất cứ thứ gì. Việc ngắt từ là một vòng lặp đo lường được bọc quanh một lệnh vẽ, và một binding chỉ làm nhiệm vụ vẽ mang cho bạn một nửa thứ hai. Tính năng hỗ trợ đo lường văn bản trong PDFium component khỏa lấp khoảng trống đó bằng hai hàm, MeasureTextMeasureTextWidth, báo cáo độ rộng kết xuất của một chuỗi mà không đặt bất cứ dấu vết nào lên bất kỳ trang nào

Tại sao đo lường là một class helper, chứ không phải là phương thức mới trên TPdf

Tính năng hỗ trợ đo lường xuất hiện dưới dạng một class helper của Delphi cho TPdf, nằm trong unit riêng của nó, chứ không phải dưới dạng các phương thức mới gắn thẳng vào lớp TPdf. Class helper là một tính năng của ngôn ngữ cho phép bạn gắn các phương thức vào một kiểu dữ liệu hiện có từ bên ngoài phần khai báo của nó. Một khi unit này có trong phạm vi, các phương thức mới được gọi y như thể chúng thuộc về lớp đó, do đó một phương thức helper sẽ được đọc là Pdf.MeasureTextWidth(...) mà không cần khởi tạo hay truyền một đối tượng riêng biệt nào

Lý do xếp lớp theo cách này là sự tách biệt. Kiểu TPdf cốt lõi vẫn giữ nguyên, không thêm trường nào, không sửa chữ ký nào, do đó một dự án không bao giờ cần bố cục sẽ không bao giờ mang theo mã đo lường. Một dự án cần nó sẽ thêm một unit vào mệnh đề uses và các phương thức sẽ sáng lên. Khả năng (capability) trở thành một lựa chọn (opt-in) ở mức chi tiết của một unit duy nhất, đó là cách gọn gàng nhất để mở rộng một kiểu dữ liệu mà bạn không sở hữu hoặc không muốn làm ảnh hưởng

uses
  PDFium, FPdfView, FPdfEdit,
  FPdfMeasure;   // the helper unit; brings MeasureText into scope on TPdf

// With the unit in scope the methods read as members of TPdf:
var
  W, H: Double;
begin
  Pdf.MeasureText('Subtotal', 'Helvetica', 11, W, H);
  // W and H are now the rendered width and height in PDF user units
end;

Đo lường mà không chạm vào trang

Phép đo phải không có tác dụng phụ (side effects). Nó phải báo cáo một chiều rộng mà không để lại gì phía sau, bởi vì bạn gọi nó rất nhiều lần trong khi quyết định bố cục và trang đó trông phải chính xác giống như khi bạn chưa bao giờ đo lường. Kỹ thuật giúp việc này trở nên khả thi là tạo ra một đối tượng văn bản, hỏi nó về kích thước, rồi vứt nó đi trước khi nó được đính kèm vào trang

Trình tự này gồm bốn lệnh gọi PDFium. FPDFPageObj_NewTextObj tạo một đối tượng văn bản đối với tài liệu, dựa vào tên và kích thước phông chữ. FPDFText_SetText đặt chuỗi mà đối tượng đó mang theo. FPDFPageObj_GetBounds đọc lại bounding box (hộp giới hạn) của đối tượng. FPDFPageObj_Destroy giải phóng đối tượng. Quan trọng nhất là, không có bước nào trong trình tự đó gọi đến API chèn trang (page-insertion API). Đối tượng được tạo ra, truy vấn và hủy bỏ một cách độc lập, vì vậy tài liệu không bị thay đổi khi hàm trả về. Đó là một đầu dò dùng một lần mà đầu ra duy nhất là bốn con số của hộp giới hạn của nó

Đây là cách làm mạnh mẽ bởi vì PDFium không để lộ một kích thước tiến từng glyph thuận tiện mà bạn có thể tự mình tính tổng lại. Kích thước (metrics) glyph phụ thuộc vào font program, vào encoding, và vào cách PDFium tải kiểu chữ (face), và không có lệnh gọi công khai nào giao cho bạn mức tiến bộ của từng ký tự trong một chuỗi. Trái lại, hộp giới hạn của một đối tượng văn bản thực tế được tính toán bởi cùng một cỗ máy sẽ xếp các glyph ra để vẽ, do đó nó phản ánh phạm vi kết xuất thực tế thay vì một sự xấp xỉ. Việc xây dựng một đối tượng dùng một lần và đọc các giới hạn của nó là phép đo đáng tin cậy nhất mà thư viện có thể cung cấp

// The shape of MeasureText, expressed against the verified PDFium calls.
// A text object is built, measured, and destroyed; no page is involved.
procedure TPdfMeasureHelper.MeasureText(const Text, Font: WString;
  FontSize: Single; out Width, Height: Double);
var
  TextObject: FPDF_PAGEOBJECT;
  L, B, R, T: Single;
begin
  Width  := 0;
  Height := 0;
  if Self.Document = nil then
    Exit;
  TextObject := FPDFPageObj_NewTextObj(Self.Document,
    FPDF_BYTESTRING(AnsiString(Font)), FontSize);
  if TextObject = nil then
    Exit;
  try
    if FPDFText_SetText(TextObject, FPDF_WIDESTRING(WideString(Text))) = 0 then
      Exit;
    if FPDFPageObj_GetBounds(TextObject, L, B, R, T) <> 0 then
    begin
      Width  := R - L;
      Height := T - B;
    end;
  finally
    FPDFPageObj_Destroy(TextObject);   // probe discarded, page untouched
  end;
end;

Tọa độ và đơn vị của kết quả

Hộp giới hạn (bounding box) trả về dưới dạng bốn cạnh, left, bottom, right và top, và hai chiều có được bằng phép trừ. Chiều rộng là right trừ left và chiều cao là top trừ bottom. Cả hai đều được biểu diễn trong đơn vị người dùng PDF (user units), trong đó một đơn vị là 1/72 inch, cùng một không gian tọa độ mà bạn dùng để định vị văn bản trên trang. Không có đơn vị thiết bị ẩn và không có pixel nào tham gia ở giai đoạn này. Chiều rộng là 36 có nghĩa là nửa inch của trang, bất kể độ phân giải kết xuất cuối cùng là bao nhiêu

Trục dọc chạy theo cách PDF định nghĩa nó, với Y tăng dần lên phía trên, đó là lý do tại sao chiều cao là top trừ bottom chứ không phải ngược lại. Chi tiết này rất quan trọng khi bạn đưa một con trỏ đi xuống dưới một cột. Bạn đo chiều cao của một dòng, sau đó trừ nó khỏi đường cơ sở (baseline) hiện tại để tìm đường cơ sở tiếp theo, vì di chuyển xuống dưới trang có nghĩa là di chuyển về Y nhỏ hơn. Nếu đích của bạn là một màn hình thay vì giấy, bạn chuyển đổi đơn vị người dùng thành pixel thiết bị bằng độ phân giải màn hình: giá trị theo đơn vị người dùng nhân với DPI và chia cho 72 sẽ cho ra pixel, do đó chiều rộng cột mà bạn đặt theo point có thể được so khớp với đoạn đo được trước khi bạn quyết định chỗ nào sẽ ngắt

Điều gì xảy ra ở đầu vào thoái hóa (degenerate input)

Các hàm này được viết để thất bại một cách im lặng. Nếu không có tài liệu nào được mở, hoặc nếu không thể tạo đối tượng văn bản, kết quả sẽ là một phạm vi bằng 0 thay vì ném ra ngoại lệ. Chiều rộng và chiều cao được khởi tạo bằng 0 ở trên cùng và chỉ bị ghi đè sau khi hộp giới hạn đã được đọc lại thành công. Một chuỗi rỗng, tài liệu bị thiếu, một phông chữ mà thư viện không thể chuyển đổi thành một đối tượng, mỗi trường hợp này sẽ trả về 0 thay vì ném ngoại lệ

Lựa chọn đó giữ cho một vòng lặp đo lường đơn giản, bởi vì một vòng lặp chạy qua hàng nghìn từ không phải là nơi để xử lý ngoại lệ trên mỗi vòng lặp. Chi phí là người gọi sẽ mang việc kiểm tra. Chiều rộng 0 là một lính canh (sentinel), không phải là thông tin về văn bản, do đó mã lấy chiều rộng chia ra hoặc giả sử một giá trị dương phải cẩn thận với số không trước khi tin tưởng nó. Hãy xem số 0 là "không thể đo được" và hợp đồng đã rõ ràng; nếu bỏ qua nó thì đầu vào bị thoái hóa sẽ âm thầm trở thành bố cục với một cột chứa các glyph xếp chồng chéo lên nhau

Tính năng ngắt từ (word wrap) tham lam dựa trên việc đo lường

Với hàm lấy chiều rộng trong tay, việc ngắt từ (word wrap) là một vòng lặp tham lam ngắn gọn (short greedy loop). Bạn tách đoạn văn thành các từ, giữ một dòng hiện tại, và đối với mỗi từ, bạn đo xem dòng đó sẽ ra sao nếu bạn thêm từ đó vào. Trong khi dòng thử nghiệm (trial line) vẫn vừa với chiều rộng của cột, bạn tiếp tục thêm; khi nó sắp tràn, bạn xả (flush) dòng hiện tại bằng AddText và bắt đầu một dòng mới bằng từ không vừa. Việc tích lũy này được thực hiện hoàn toàn bằng MeasureTextWidth, và thứ duy nhất từng chạm tới trang là một dòng mà bạn đã xác nhận là vừa vặn

procedure WrapParagraph(Pdf: TPdf; const Para, Font: WString;
  FontSize: Single; X, TopY, ColumnWidth, LineHeight: Double);
var
  Words: TArray<string>;
  Line, Trial: WideString;
  I: Integer;
  Y: Double;
begin
  Words := string(Para).Split([' ']);
  Line  := '';
  Y     := TopY;
  for I := 0 to High(Words) do
  begin
    if Line = '' then
      Trial := Words[I]
    else
      Trial := Line + ' ' + Words[I];
    // Measure the candidate line before drawing anything.
    if (Line <> '') and (Pdf.MeasureTextWidth(Trial, Font, FontSize) > ColumnWidth) then
    begin
      Pdf.AddText(Line, Font, FontSize, X, Y);   // flush the line that fit
      Y    := Y - LineHeight;                    // Y decreases going down
      Line := Words[I];                          // overflowing word starts next line
    end
    else
      Line := Trial;
  end;
  if Line <> '' then
    Pdf.AddText(Line, Font, FontSize, X, Y);      // flush the final line
end;

Vòng lặp đo lường dòng thử nghiệm thay vì đo lường mỗi từ rồi cộng lại, bởi vì chiều rộng của một dòng không bằng tổng chiều rộng của các từ trong nó. Khoảng trắng giữa các từ cũng có tác dụng, và một lượt đo lường đoạn thẳng nắm bắt trực tiếp điều đó. Nguyên tắc tham lam, nhồi nhét càng nhiều từ càng tốt vào cột cho phép và ngắt từ ở từ cuối cùng vừa vặn, chính là quy tắc lấp đầy khoảng trống giữa một AddText thô và một đoạn văn thực sự. Việc gọi vẽ chưa bao giờ là phần khó. Khó là đo lường trước khi vẽ, và đó chính là những gì mà hàm trợ giúp (helper) này cung cấp

Phần này phù hợp ở đâu

Đo lường là lớp nằm giữa việc tạo ra nội dung và kết xuất nó, vì vậy nó kết hợp một cách tự nhiên với phần còn lại của một quy trình làm việc (workflow) với tài liệu từ con số không. Nếu bạn đang thiết lập các trang và đặt văn bản vào đó, nền tảng của nó được trình bày trong tạo tài liệu PDF từ đầu với PDFium component trong Delphi, nơi AddText và thiết lập trang được nói đến một cách đầy đủ. Khi phông chữ mà bạn đang đo cũng quan trọng như chuỗi ký tự, vì các metrics phụ thuộc vào face, phân tích các thuộc tính phông chữ PDF với PDFium component trong Delphi chỉ ra cách thư viện báo cáo thông tin phông chữ để đẩy vào các hộp giới hạn (bounding boxes) đó. Cả hai đều được xây dựng trên cùng một binding, PDFium Component cho Delphi và Lazarus, nơi có các trình trợ giúp đo lường (measurement helper) bên cạnh các API cho tài liệu, trang và văn bản được mô tả trên toàn bộ blog này