Bài viết kỹ thuật

Kết xuất trang PDF sang đơn sắc 1 bit trong Delphi

Một cổng fax không muốn nhận bản render trang 24 bit của bạn. Chuỗi xử lý lưu trữ đang giữ hàng triệu hóa đơn quét cũng vậy, và tầng tiền xử lý OCR thì ngưỡng toàn bộ nội dung sang đen trắng trước cả khi nó nhìn vào một ký tự. Cả ba đều muốn cùng một thứ: một bitmap 1 bit sạch sẽ, một bit cho mỗi pixel, nơi mỗi chấm chỉ là mực hoặc giấy. Đưa cho chúng một BMP màu đầy đủ, chúng vẫn sẽ bỏ đi 23 bit trên mỗi pixel, thường bằng một bước dithering tệ hơn nhiều so với thứ bạn có thể tự làm. Câu hỏi đáng quan tâm là bước giảm màu đó nên diễn ra ở đâu, và câu trả lời trong PDFlibPas hóa ra nói khá nhiều về cách mở rộng một bộ kết xuất mà bạn không muốn viết lại

PDFlibPas là một thư viện PDF Object Pascal gốc cho Delphi và C++Builder. Lõi kết xuất của nó raster hóa một trang thành bitmap và có thể xuất BMP, PNG, JPEG, WMF cùng một số định dạng khác. Điều nó chưa làm cho tới gần đây là trả về một bitmap đơn sắc thực sự, hoặc chỉ kết xuất một phần trang. Cả hai đã xuất hiện trong v3.83.0, và cả hai đều được xây như những lớp tiện ích mỏng nằm trên bộ kết xuất hiện có thay vì sửa chính bộ rasterizer. Ràng buộc đó chính là toàn bộ câu chuyện

Vì sao nên giảm màu sau khi kết xuất, chứ không làm ngay trong bộ kết xuất

Cách hiển nhiên để tạo ảnh 1 bit là bảo bộ rasterizer vẽ ở 1 bit. Đó cũng là cách phá hỏng mọi thứ khác. Bitmap nội bộ của bộ kết xuất được khởi tạo bằng PixelFormat := pf24bit cứng trong hàm dựng PDFlibRenderer, và bề mặt 24 bit đó được dùng chung cho mọi đường kết xuất: xuất PNG, xem trước qua device context, xuất JPEG, tất cả. Nếu đổi nó sang pf1bit từ gốc, bạn không hề thêm tính năng đơn sắc, bạn chỉ làm giảm độ trung thực màu cho mọi nơi gọi trong thư viện và tự nhận thêm một đống lỗi hồi quy ở phía sau

Vì vậy RenderPageToMonochromeFile đi theo hướng ngược lại. Nó kết xuất trang theo cách bình thường, ra một BMP 24 bit tạm thời, rồi mới ép xuống 1 bit như một bước hậu xử lý. Bộ kết xuất không bị đụng tới. Hành vi đơn sắc chỉ nằm trong phương thức tiện ích, nên nó không thể ảnh hưởng đến bất kỳ ai không gọi nó. Đây là kiểu đánh đổi đáng nói thẳng ra: hậu xử lý phải trả thêm một lần cấp phát bitmap và một file tạm, đổi lại nó giữ cho lõi đang gánh tải hoàn toàn ngoài phạm vi thay đổi. Với một tính năng chỉ phục vụ các trường hợp biên như fax và lưu trữ, đó là lựa chọn hợp lý

var
  Pdf: TPDFlib;
begin
  Pdf := TPDFlib.Create(nil);
  try
    Pdf.LoadFromFile('invoice.pdf');
    // 200 DPI is the classic Group 4 fax resolution; page index is 1-based
    Pdf.RenderPageToMonochromeFile(200, 1, 'invoice-page1.bmp');
  finally
    Pdf.Free;
  end;
end;

Quá trình ép xuống 1 bit diễn ra như thế nào

Việc giảm màu dựa vào GDI thay vì một vòng lặp ngưỡng hóa tự viết, và lựa chọn đó ảnh hưởng trực tiếp đến chất lượng đầu ra. Bên trong phương thức, bitmap tạm 24 bit được nạp vào một TBitmap, một TBitmap thứ hai được tạo với PixelFormat := pf1bit cùng kích thước, rồi các pixel được chuyển qua chỉ bằng một lệnh blit

// inside RenderPageToMonochromeFile, after loading the 24-bit ColorBmp
MonoBmp.PixelFormat := pf1bit;
MonoBmp.Width  := ColorBmp.Width;
MonoBmp.Height := ColorBmp.Height;
// HALFTONE tells GDI to dither the 24-bit source down to 1-bit
SetStretchBltMode(MonoBmp.Canvas.Handle, HALFTONE);
StretchBlt(MonoBmp.Canvas.Handle, 0, 0, MonoBmp.Width, MonoBmp.Height,
  ColorBmp.Canvas.Handle, 0, 0, ColorBmp.Width, ColorBmp.Height, SRCCOPY);
MonoBmp.SaveToFile('out.bmp');

Mấu chốt là SetStretchBltMode với HALFTONE. Dù nguồn và đích có cùng kích thước nên không hề có co giãn thực sự, chế độ stretch vẫn quyết định cách GDI ánh xạ màu vào bảng màu 1 bit. HALFTONE bắt nó dùng dithering halftone, biến các vùng xám và viền chữ khử răng cưa thành các mẫu chấm đen trắng thay vì cắt cứng về màu gần nhất trong hai màu. Bỏ lời gọi chế độ này, hoặc dùng mặc định BLACKONWHITE, thì nội dung thang xám sẽ bị posterize thành những mảng ngưỡng hóa rất thô. Với đầu ra cho tài liệu quét và tiền xử lý OCR, kết quả có dithering gần như luôn là thứ bạn muốn

Một chi tiết là bắt buộc và rất dễ làm sai: ảnh render tạm phải là BMP. RenderPageToMonochromeFile gọi bộ kết xuất chung với mã tùy chọn 0, tức BMP. Tham số tùy chọn của RenderPageToFile là một enum số nguyên nhỏ, và các giá trị không thể thay thế cho nhau trong mục đích này: 0 là BMP, 1 là JPEG, 2 là WMF, 3 là EMF, 5 là PNG, v.v. Sau đó bộ chuyển đổi xuống sẽ gọi TBitmap.LoadFromStream trên file tạm. Nếu đưa vào WMF bằng cách truyền 2, lần nạp đó sẽ ném lỗi "Bitmap image is not valid", vì Windows Metafile là luồng bản ghi vector, không phải DIB. Chuyển đổi đơn sắc là một thao tác raster từ đầu đến cuối, nên đầu vào trung gian cũng phải là định dạng raster

Chỉ kết xuất một vùng con của trang

Phương thức thứ hai, RenderPageRegionToFile, chỉ kết xuất một hình chữ nhật của trang thay vì toàn bộ trang. Trường hợp sử dụng rất quen thuộc nếu bạn từng xây dựng bất kỳ trình xem tài liệu nào: cắt một khối chữ ký ra khỏi hợp đồng, tạo một tile cho bản đồ phóng lớn của bản vẽ lớn, hoặc lấy một vùng đóng dấu để làm thumbnail mà không phải raster hóa cả trang ở DPI cao. Cách gọi khá rõ ràng

// Clip is "Left,Top,Width,Height" in PDF points (72 pt = 1 inch)
// Here: a 2.5in x 1in box, one inch in from the top-left of the page
Pdf.RenderPageRegionToFile(150, 1, '72,72,180,72', 'sig-block.bmp');

Chuỗi clip là bốn số thực phân tách bằng dấu phẩy, tính theo PDF points, và được phân tích thủ công ngay trong phương thức để tránh các rắc rối của locale và DelimitedText. Từ chiều rộng và chiều cao, phương thức tính kích thước bitmap đầu ra thành Round(Width * DPI / 72) nhân Round(Height * DPI / 72), cấp phát một bitmap pf24bit trong bộ nhớ đúng kích thước đó, rồi kết xuất vào device context của nó thông qua RenderPageToDCClip. File kết quả chỉ chứa đúng hình chữ nhật đã cắt, được định cỡ theo vùng chứ không phải theo toàn bộ trang

Tham số clip từng không làm gì cả

Đây là chỗ công việc sắc hơn vẻ ngoài của nó. RenderPageToDCClip đã mang một tham số Clip từ rất lâu, nhưng nó chỉ là hình thức. Lời gọi nhận tham số, truyền nó xuống TPDFPageTree.RenderPageToDC, và phần cài đặt đó bỏ qua hoàn toàn, không bao giờ chuyển nó cho bộ kết xuất. Bạn có thể đưa vào bất kỳ hình chữ nhật nào và vẫn nhận lại cả trang. Bất kỳ ai nối RenderPageToDCClip với kỳ vọng cắt vùng đều thực ra nhận bản render toàn trang và, tùy bố cục, có thể không hề nhận ra

v3.83.0 đã nối lại đường dây đó. RenderPageToDC hiện phân tích cùng một hình chữ nhật điểm "Left,Top,Width,Height" và áp dụng nó như một vùng clip GDI thực sự lên device context đích trước khi bộ kết xuất vẽ. Việc đổi từ points sang pixel thiết bị vẫn dùng hệ số tỷ lệ quen thuộc DPI / 72, áp cho cả bốn cạnh. Trình tự quanh lần render là kiểu save/clip/restore tiêu chuẩn

// inside TPDFPageTree.RenderPageToDC, when Clip is non-empty
ScaleFactor := DPI / 72;
SaveDC(TargetDC);
IntersectClipRect(TargetDC,
  Round(ClipLeft * ScaleFactor),
  Round(ClipTop * ScaleFactor),
  Round((ClipLeft + ClipWidth) * ScaleFactor),
  Round((ClipTop + ClipHeight) * ScaleFactor));
// ... renderer draws the page here ...
// in the finally block:
RestoreDC(TargetDC, -1);

Cặp SaveDC / RestoreDC(-1) là thứ giúp phương thức này an toàn khi gọi lặp đi lặp lại: vùng clip được đẩy lên ngăn xếp trạng thái của DC, trang được vẽ, rồi clip gốc được kéo trở lại bất kể lần render kết thúc ra sao. RestoreDC(TargetDC, -1) khôi phục trạng thái được lưu gần nhất, đó là mẫu chuẩn cho save/restore cân bằng. Bỏ bước restore đi, và một nơi tái sử dụng cùng DC cho lần render toàn trang sau đó sẽ thấy nó bị cắt một cách khó hiểu theo vùng cuối cùng. Sửa tham số chết đó cũng đồng thời sửa luôn RenderPageRegionToFile miễn phí, vì phương thức mới này đi đúng qua con đường đó

Điểm hành vi cần ghi nhớ là clip chỉ cắt, nó không co giãn. Trang vẫn được raster hóa ở đúng DPI bạn yêu cầu, ở vị trí bình thường của nó, và vùng clip chỉ đơn giản loại bỏ mọi thứ bên ngoài hình chữ nhật. Bạn không phóng to vùng đó để lấp đầy đầu ra, bạn đang cắt một khung cửa sổ ra khỏi bản render độ phân giải đầy đủ. Nếu muốn vùng đó được phóng lớn, hãy tăng DPI. Tọa độ của hình chữ nhật được hiểu trong không gian thiết bị sau khi quy đổi từ points sang pixels, đo từ góc trên trái của bề mặt đã render, vì vậy hãy tính LeftTop từ phía trên trang đi xuống. Để xem sâu hơn cách PDFlibPas điều khiển device context cho đầu ra trên màn hình, bài viết đi kèm về xem trước in và đầu ra device context sẽ lần theo cùng lớp plumbing DC từ phía hiển thị

Ranh giới rõ ràng: BMP 1 bit, không phải TIFF G4

Sẽ rất dễ phóng đại nó thành "đầu ra sẵn cho fax", nên đây là giới hạn nói thẳng. RenderPageToMonochromeFile tạo ra một BMP pf1bit. Nó không tạo ra TIFF CCITT Group 4, vốn là định dạng mà một quy trình fax thực thụ hoặc kho lưu trữ TIFF thường mong đợi. Lý do là cụ thể chứ không phải sơ suất: unit CCITT của PDFlibPas hiện giải mã được luồng G4 nhưng chưa có bộ encoder G4. Không có encoder thì không có chỗ nào để ghi các dải đơn sắc đã nén, nên đường đơn sắc dừng lại ở một DIB 1 bit chưa nén

Thực tế thì nó vẫn rất hữu ích. BMP 1 bit là định dạng pixel đúng, đã được dithering và sẵn sàng, và hầu hết chuỗi công cụ fax, lưu trữ hoặc OCR sẽ nhập nó hoặc tự đổi sang G4 ở một bước sau đó. Nhưng nếu yêu cầu của bạn là TIFF Group 4 đi thẳng ra từ thư viện, thì đây chưa phải là nó, và bạn nên tính thêm một bước nén của riêng mình. Biết một tính năng dừng ở đâu cũng quan trọng không kém việc biết nó làm được gì

Cả hai phương thức đều cố ý nhỏ, và đó chính là bài học thiết kế đáng mang đi từ trang này: một API tiện ích nằm trên bộ kết xuất có thể bổ sung năng lực thực sự, đầu ra đơn sắc, cắt vùng, mà không phải chạm vào bộ rasterizer và làm bất ổn mọi nơi gọi khác. Khi bạn thật sự phải chọn giữa các engine kết xuất cho phần rasterization nền, phần tổng quan về kết xuất PDF đa engine trong Delphi sẽ phân tích kỹ các đánh đổi. Để xem toàn bộ bề mặt kết xuất và phần còn lại của API, trang sản phẩm PDFlibPas Delphi PDF Library cho bạn bức tranh đầy đủ