Bài viết kỹ thuật

Text advance PDF và khôi phục clip q/Q trong renderer Delphi

Renderer trang của HotPDF Delphi Component giờ dịch chuyển văn bản bằng cách tính displacement của từng glyph trong text space, tx = ((w0 − Tj/1000) × Tfs + Tc + Tw) × Th như ISO 32000-1 §9.4.4 định nghĩa, rồi dời text matrix qua phần tuyến tính của nó bằng HPDFTranslateTextMatrix. Clipping được lưu theo từng frame q và khôi phục trên Q, nhưng một GDI region chỉ bị chụp khi frame đó thực sự đổi clip. Cả hai bản sửa đều có mặt trong HotPDF 2.754.0, và đều đến từ những trang thật render ra với các từ dính cục hay region clip rỉ ra ngoài Q của chúng. Bug đầu tiên là phép số học nhìn có vẻ đúng cho tới khi một producer ghi cỡ chữ của mình vào matrix. Bug thứ hai là một bản sửa tính đúng đắn suýt đánh mất phần tăng tốc render song song, và cách chúng tôi giành lại tốc độ đáng để biết nếu bạn viết bất kỳ PDF device nào dựa trên GDI

Vì sao văn bản dính thành cục khi một PDF dùng Tf 1?

Vì code advance cũ cộng một khoảng cách text-space thẳng vào thành phần tịnh tiến của Tm, như thể text space và user space luôn có cùng một tỉ lệ. Rất nhiều producer thật đặt cỡ chữ bằng 1 với Tf và mang cỡ thật trong text matrix. Với /F1 1 Tf và 12 0 0 12 72 700 Tm, một glyph rộng 500 đơn vị dịch chuyển 0.5 trong text space, tức 6 point trên trang một khi Tm scale nó. Renderer cũ chạy Tm.e := Tm.e + Adv và nhích bút 0.5 point. Mỗi glyph đỗ cách glyph trước một phần mươi hai ký tự, nên một dòng văn bản thường render ra thành một vệt đen ở lề trái trong khi cùng tệp đó nhìn hoàn hảo trong mọi trình xem khác

Vì sao văn bản dính cục dưới Tf 1 trong renderer HotPDF: với 12 0 0 12 72 700 Tm, một glyph 500 đơn vị phải dịch 0.5 đơn vị text space, thứ Tm scale thành 6 point, trong khi code cũ cộng thẳng 0.5 vào Tm.e và render một dòng văn bản thường thành vệt mực mỗi glyph chỉ nhích một phần mươi hai ký tự
Các producer mã hóa cỡ chữ trong text matrix khiến mỗi glyph đỗ cách glyph trước một phần mươi hai ký tự, một khiếm khuyết vô hình trên chính output của thư viện
// Content stream từ một producer mã hóa cỡ chữ trong Tm, không phải Tf:
//   BT
//   /F1 1 Tf
//   12 0 0 12 72 700 Tm
//   [(Hel) 30 (lo) -250 (world)] TJ
//   ET

// Advance cũ (lược): khoảng cách cộng thẳng vào Tm.e như thể là user space
Adv := W * FontSize / 1000;                  // 0.5 cho một glyph 500 đơn vị
if (HorizScale <> 0) and (HorizScale <> 100) then
  Adv := Adv * HorizScale / 100;             // Th chỉ tác dụng lên độ rộng
Adv := Adv + CharSpace;                      // Tc không bị scale bởi Th
if Code = 32 then
  Adv := Adv + WordSpace * FontSize / 1000;  // Tw bị scale nhầm bởi Tfs
Tm.e := Tm.e + Adv;                          // bỏ qua Tm.a, Tm.b, Tm.c, Tm.d

// Chỉnh TJ cũ: không có Th, và lại chỉ đụng Tm.e
Tm.e := Tm.e - NumValue * FontSize / 1000;

Phép tắt Tm.e không phải khiếm khuyết duy nhất trong khối đó. Word spacing Tw được diễn đạt bằng đơn vị text space chưa scale, vậy mà code cũ nhân nó với FontSize / 1000, nên dưới Tf 12 một dòng căn đều mất gần hết khoảng cách giữa các từ. Horizontal scaling Th áp lên độ rộng glyph nhưng không áp lên Tc hay Tw, và phép chỉnh kerning TJ bỏ qua nó hoàn toàn. Đường không-vẽ mà dịch chuyển văn bản vô hình render mode 3, loại mà các text layer OCR dùng, và văn bản bên trong optional content ẩn mang một bản copy riêng của cùng phép số học, nên bất cứ thứ gì vẽ sau một lượt chạy vô hình đều xuất phát từ vị trí sai. Bug trạng thái văn bản trong một renderer hiếm khi gục ồn ào: giống các bug chỉ số operand và tên resource từng triệt tiêu Tc, Tw và Tz mà chẳng báo một lỗi nào, chúng cho ra những trang nhìn hợp lý trên chính output của thư viện và chỉ gục trên tệp từ các producer khác

ISO 32000-1 §9.4.4 định nghĩa glyph advance ra sao?

ISO 32000-1 §9.4.4 định nghĩa advance hoàn toàn trong text space và áp nó lên text matrix như một translation matrix, nên đáp án là tính tx trước rồi để Tm lo phần scale, xoay và nghiêng. Với chữ viết ngang, tx bằng ((w0 − Tj/1000) × Tfs + Tc + Tw) × Th, trong đó w0 là độ rộng glyph tính bằng phần nghìn của một em, Tj là phép chỉnh TJ, còn Th là Tz chia 100. Tm mới là [1 0 0 1 tx 0] × Tm, trong HotPDF chính là helper HPDFTranslateTextMatrix: nó cộng X và Y qua các hệ số ma trận a, b, c và d thay vì ghi thẳng vào e và f. Theo §9.3.3, Tw chỉ áp dụng cho mã ký tự một byte là 32, nên các mã CID đa byte chẳng bao giờ dính word spacing trên đường ngang. Cùng helper đó giờ điều khiển Td, TD, T*, các toán tử ' và ", các phép chỉnh TJ và đường văn bản ẩn, nghĩa là một hàm duy nhất sở hữu luật này

Glyph advance theo ISO 32000-1 9.4.4 trong renderer HotPDF: tx được tính trong text space từ w0, Tj, Tfs, Tc, Tw và Th, rồi áp qua HPDFTranslateTextMatrix để displacement đi qua các hệ số ma trận a, b, c và d, còn Td, TD, TJ và đường văn bản ẩn chia sẻ một luật
Cộng advance thẳng vào Tm.e chỉ đúng khi text space trùng user space; đưa nó qua các hệ số ma trận giữ cho văn bản bị scale, xoay, nghiêng vẫn đúng
procedure HPDFTranslateTextMatrix(var Matrix: THPDFAffineMatrix; X, Y: Double);
begin
  Matrix.e := Matrix.e + Matrix.a * X + Matrix.c * Y;
  Matrix.f := Matrix.f + Matrix.b * X + Matrix.d * Y;
end;

// Glyph advance ngang, ISO 32000-1 9.4.4
W   := HPDFFontCharWidth(F, Code);
Adv := W * State.Text.FontSize / 1000 + State.Text.CharSpace;
if (Code = 32) and not F.CID2Byte then
  Adv := Adv + State.Text.WordSpace;         // Tw trong text space, chưa scale
Adv := Adv * State.Text.HorizScale / 100;    // Th áp lên toàn bộ tổng
HPDFTranslateTextMatrix(Tm, Adv, 0);

// Phần tử số TJ: cùng không gian, cùng Th
Adjustment := -Items[I].NumValue * State.Text.FontSize / 1000;
HPDFTranslateTextMatrix(State.Text.Tm, Adjustment * State.Text.HorizScale / 100, 0);

Đặt glyph phải theo cùng logic đó. Khi chẳng có outline nhúng nào và renderer rơi về GDI TextOutW, nó giờ dựng trọn ma trận glyph từ CTM × Tm × rise × em scale, kể cả Th, và cài nó bằng SetWorldTransform ở chế độ GM_ADVANCED trong một cặp SaveDC / RestoreDC. Font GDI được tạo ở chiều cao cố định 1000 đơn vị và transform lo việc cỡ chữ, nên văn bản xoay, nghiêng giữ được hướng của mình thay vì bị vẽ đứng thẳng tại một điểm gốc đã bị biến đổi. Chế độ viết dọc là bất đối xứng duy nhất có chủ đích: một font WMode 1 dịch xuống theo trục y bằng metric dọc của nó, và horizontal scaling không áp lên trục ấy

q/Q thực sự lưu gì trong graphics state của PDF?

ISO 32000-1 §8.4.2 liệt kê clipping path hiện tại là một phần của graphics state, nên Q phải khôi phục clip đúng như nó ở tại q ghép cặp, chứ không chỉ các tham số số học. HotPDF vốn đã giữ một stack graphics state với CTM, màu sắc, tham số đường và trạng thái văn bản, nhưng GDI giữ clip trong device context, ngoài stack ấy. Một bản copy trạng thái số vì thế khôi phục mọi thứ trừ clip, và một clip cài bằng W n bên trong một khối q ... Q cứ tiếp tục xén mọi thao tác về sau trên trang. Form XObject còn thêm một con đường thứ hai tới cùng thất bại, vì §8.10 trao cho một form một cặp save và restore ngầm bao quanh nội dung của nó, và nội dung form ngoài đời thỉnh thoảng để các toán tử q của chính nó lơ lửng dù đặc tả đòi chúng phải ghép cặp. Renderer giờ gọi CaptureClipBeforeChange và SaveDC trước khi chạy một form, rồi sau khi form xong, nó vứt mọi region đã lưu sâu hơn độ sâu lúc vào và gọi RestoreDC, nên mỗi HRGN đã lưu có đúng một đường giải phóng

Chụp clip kiểu lazy với THPDFSavedClipState

Bản sửa được phát hành lưu một record THPDFSavedClipState cho mỗi q, nhưng trì hoãn phần tốn kém cho tới khi frame lần đầu đụng vào clip. Record giữ region handle, độ sâu stack mà nó thuộc về, device context mà nó được chụp từ, và một cờ Captured. DevPushState chỉ điền độ sâu và DC và lớn dần mảng frame bằng cách nhân đôi từ 16, nên một content stream đầy q 1 0 0 1 x y cm ... Q chẳng cấp phát một GDI object nào. Các toán tử sắp đổi clipping, nghĩa là tô path với một W hay W* còn treo, toán tử n, tô pattern và vào form, đều gọi CaptureClipBeforeChange trước

Chụp clip GDI kiểu lazy trong renderer HotPDF: DevPushState chỉ ghi độ sâu và DC cho mỗi q, CaptureClipBeforeChange đọc region ngay trước khi W, n hay vào form đổi clipping, DevPopState khôi phục rồi xóa nó trên Q, còn phiên bản háu ăn chụp ở mọi q đã đánh sập throughput song song xuống khoảng 1.15 lần đơn luồng
Tạo một GDI region ở mọi q đã làm đói các thread render, nên việc chụp giờ chỉ xảy ra khi một toán tử sắp đổi clipping và cổng tăng tốc 1.5 lần lại đạt
procedure THPDFPageRenderer.CaptureClipBeforeChange;
var
  Index, ClipResult: Integer;
  Region: HRGN;
begin
  ClearSavedClipRegions(FGSStack.Count);
  Index := FSavedClipCount - 1;
  if (Index < 0) or FSavedClips[Index].Captured or
     (FSavedClips[Index].StackDepth <> FGSStack.Count) or
     (FSavedClips[Index].DC <> FDC) then Exit;   // đã lưu rồi, hoặc không phải của mình
  Region := CreateRectRgn(0, 0, 0, 0);
  if Region = 0 then RaiseLastOSError;
  ClipResult := GetClipRgn(FDC, Region);         // 0 nghĩa là chẳng có clip nào
  if ClipResult <= 0 then
  begin
    DeleteObject(Region);
    Region := 0;
    if ClipResult < 0 then RaiseLastOSError;
  end;
  FSavedClips[Index].Region := Region;
  FSavedClips[Index].Captured := True;
end;

procedure THPDFPageRenderer.DevPopState;
var
  Index: Integer;
begin
  ClearSavedClipRegions(FGSStack.Count);
  Index := FSavedClipCount - 1;
  if (Index >= 0) and (FSavedClips[Index].StackDepth = FGSStack.Count) then
  begin
    if FSavedClips[Index].Captured and (FSavedClips[Index].DC = FDC) then
      SelectClipRgn(FDC, FSavedClips[Index].Region);  // Region 0 gỡ bỏ clip
    if FSavedClips[Index].Region <> 0 then
      DeleteObject(FSavedClips[Index].Region);
    Dec(FSavedClipCount);
  end;
  FGSStack.Pop;
end;

Chi phí đo được của phiên bản háu ăn chính là lý do thiết kế này tồn tại. Bản cài đặt đúng đầu tiên tạo và đọc một GDI region ở mọi q, và trên những trang gồm chủ yếu các transform số, các thread renderer dành thời gian giành giật GDI region object thay vì rasterize. Pipeline render song song rơi từ mức tăng kỳ vọng xuống khoảng 1.13 tới 1.20 lần throughput đơn luồng và trượt cổng tăng tốc 1.5 lần trong bộ benchmark. Với chụp kiểu lazy và dung lượng frame tái sử dụng, cùng benchmark đó lại qua cổng 1.5 lần gốc. Antialiasing glyph TrueType nhỏ giọt cũng đổ bộ trong cùng bản phát hành và là nghi phạm hiển nhiên, nhưng hồi quy lần ra tận chỗ cấp phát region, một lời nhắc đáng giá rằng hãy đo đạc trước khi đổ tội cho tính năng mới nhất

Giới hạn của cách tiếp cận này nằm ở đâu?

Clip đã lưu là một GDI region tính bằng pixel thiết bị, nên nó chính xác cho bitmap đang được render và vô nghĩa với bất kỳ đích nào khác. Vì thế mỗi frame ghi lại device context của mình và DevPopState bỏ qua phần khôi phục khi DC đã đổi, ví dụ trong lúc một transparency group render vào bitmap layer của riêng nó. GetClipRgn trả về 0 là một kết quả chính đáng nghĩa là chẳng có clip, và khôi phục nó bằng SelectClipRgn(FDC, 0) chính là cách gỡ đúng một clip vốn không tồn tại tại q ghép cặp. Ở phía văn bản, bản sửa chỉnh chỗ mỗi glyph đi, nhưng nó không bịa ra độ rộng: nếu một font bỏ sót mảng /Widths và chương trình nhúng không có mặt, advance vẫn chỉ tốt bằng fallback độ rộng. Khi bạn regression-test khu vực này, hãy giữ ít nhất một fixture với Tf 1 và một Tm bị scale, một với Tz và Tw khác 0, và một với clip bên trong q ... Q rồi tới nội dung ở ngoài, vì chẳng cái nào trong số đó xuất hiện trong các tài liệu do chính thư viện sinh ra

Nếu bạn điều khiển renderer từ code ứng dụng, chẳng gì đổi trong mẫu gọi được mô tả trong bài render một trang PDF ra bitmap, và những trang trước đây hiện dòng chữ nhòe hay nội dung bị xén lẽ ra render đúng trên 2.754.0 trở lên. Chi tiết về component, các phiên bản Delphi và C++Builder được hỗ trợ cùng bản quyền nằm ở trang sản phẩm HotPDF Delphi PDF Component