Bài viết kỹ thuật

Resample ảnh PDF thích ứng trong Delphi với PDFiumPas

Hai lời phàn nàn đến trong tuần sau khi một tính năng nén ra mắt: bản hợp đồng quét giờ có các nét chữ bậc thang lởm chởm, và logo trong suốt trên trang bìa ngồi trong một quầng nhạt. PDFiumPas trả lời cả hai ở một nơi. TPdf.OptimizeImages đo từng ảnh trước khi thu nó lại, rồi chọn một kernel resample và cộng dồn màu theo dạng biết alpha

Điều đó chưa từng đúng từ trước. Trước v3.100.0, cùng method đó thu nhỏ mọi ảnh không phải bilevel bằng một bước nearest-neighbour cố định — chính là thuật toán sinh ra cả hai lời phàn nàn: nó point-sample một pixel nguồn cho mỗi pixel đầu ra, và nó coi RGB nằm dưới một pixel trong suốt hoàn toàn như thể người đọc sẽ nhìn thấy nó. Lần viết lại trong v3.100.0 thay đường đi đơn lẻ đó bằng năm kernel, một quy tắc chọn dựa trên phép đo, và một ngân sách bộ nhớ làm việc tường minh

Vì sao thu nhỏ làm văn bản quét trông lởm chởm?

Vì point sampling trả lời một câu hỏi sai. Khi một bản quét 300 DPI được nhắm lại về 150 DPI, mỗi pixel đích đại diện cho một khối hai-nhì của các pixel nguồn, và nearest neighbour giữ lại một trong bốn rồi bỏ phần còn lại. Cái nào sống sót phụ thuộc vào làm tròn, nên một mép nét vốn được antialias mượt mà trong nguồn trở thành một cái tung xu theo từng pixel. Kết quả là chiếc bậc thang aliased kinh điển dọc mép chữ glyph, cộng thêm moire trên các vùng halftone nơi các mẫu bị bỏ tình cờ mang theo hoành tiết. Chuyện này quan trọng hơn trong PDF hơn là trên màn hình vì tổn hại là vĩnh viễn. Một ảnh XObject mang dữ liệu mẫu cùng /Width, /Height/BitsPerComponent (ISO 32000-1 §8.9.5), và resample viết lại cả ba bên trong tệp. Một cú zoom tệ trong viewer là một khung hình bạn có thể vẽ lại, và PDFiumPas có máy móc riêng cho việc đó trong render cache và hiệu năng zoom. Một cú downsample tệ là một tài liệu mới bạn trao cho khách hàng

Vì sao downsample nearest-neighbour phá văn bản quét trong PDFiumPas cho Delphi: mỗi pixel đầu ra giữ một trong bốn pixel nguồn và bỏ phần còn lại, sinh mép glyph aliased và moire, thứ mà năm kernel resample thay thế
Point sampling giữ một pixel nguồn cho mỗi pixel đầu ra và vứt ba pixel kia, nên PDFiumPas giờ cung cấp năm kernel thay vì một

PDFiumPas đo chi tiết và chọn kernel thế nào

PDFiumPas quyết định theo từng ảnh, không theo từng tài liệu. Trước khi chọn kernel, nó tính một điểm chi tiết độ sáng đã chuẩn hóa từ một lưới lấy mẫu có chặn: các bước ngang và dọc là (Width + 63) div 64(Height + 63) div 64, nên một bản quét 12000 pixel và một thumbnail 300 pixel tốn xấp xỉ cùng một lượt quét 64 trên 64. Tại mỗi vị trí lấy mẫu, nó cộng giá trị tuyệt đối của hiệu tới người hàng xóm bên phải và hàng xóm bên dưới, trên tối đa ba kênh, rồi chia cho số mẫu nhân 255. Điểm nằm trong khoảng 0 đến 1, nơi đồ họa văn phòng phẳng nằm gần 0 và kết cấu ảnh dày đặc leo lên

Chiếc thang chọn sau đó chạy theo một thứ tự cố định. Nếu ResampleFilter là bất cứ thứ gì khác pirfAdaptive, filter đó được dùng nguyên văn. Nếu không: nội dung 1-bit nhận pirfBilevel; một ContentClasspiccLineArt nhận pirfBox; hệ số tỉ lệ từ 4 trở lên cũng nhận pirfBox, vì ở mức thu nhỏ đó trung bình diện tích vừa rẻ nhất vừa đúng nhất; piccPhoto, điểm chi tiết từ 0.08 trở lên, hay PreferredQuality từ 0.9 trở lên nhận pirfLanczos với kernel ba thùy của nó; tỉ lệ từ 2 trở lên hay chất lượng từ 0.7 trở lên nhận pirfBicubic ở bán kính 2; phần còn lại nhận pirfBilinear. Vì TPdfImageOptimizeOptions.Default đặt PreferredQuality ở 0.85, một lượt chạy mặc định không bao giờ rơi về bilinear trừ khi mức thu nhỏ nhẹ và nội dung phẳng

PDFiumPas chọn kernel resample trong Delphi thế nào: một lượt quét 64 trên 64 có chặn tạo ra điểm chi tiết chuẩn hóa, rồi một thang điều kiện cố định đưa từng ảnh tới filter bilevel, box, Lanczos, bicubic hay bilinear
Điểm chi tiết tốn như nhau trên bản quét 12000 pixel lẫn trên một thumbnail, và chiếc thang bên dưới dừng ở điều kiện đầu tiên khớp
uses
  PDFium;

procedure ShrinkScannedPdf(const InputFile, OutputFile: string);
var
  Pdf: TPdf;
  Options: TPdfImageOptimizeOptions;
  Report: TPdfImageOptimizeReport;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := InputFile;
    // Default: TargetDpi 150, MinDpiRatio 1.5, PreserveBilevel True,
    // MinDimension 8, pirfAdaptive, piccAuto, chất lượng 0.85, ngân sách 64 MiB.
    Options := TPdfImageOptimizeOptions.Default;
    Options.TargetDpi := 150;
    Options.MinDpiRatio := 1.5;
    Options.ContentClass := piccAuto;
    Options.PreferredQuality := 0.85;
    if Pdf.OptimizeImages(Options, Report) and (Report.OptimizedCount > 0) then
      Pdf.SaveAs(OutputFile);
  finally
    Pdf.Free;
  end;
end;

Một ảnh chỉ bị đụng tới khi DPI đặt vị trí lớn hơn trong hai chiều ngang và dọc, chia cho TargetDpi, đạt tới MinDpiRatio. Guard đó tồn tại để một ảnh 160 DPI nhắm vào mục tiêu 150 DPI không bị encode lại vì một mức lợi nhuận sáu phần trăm mà trả giá một thế hệ chất lượng. Các ảnh dưới MinDimension trên bất kỳ trục nào, mặc định 8, bị bỏ qua như icon hay kẻ đường

Vì sao logo trong suốt dính một quầng trắng?

Vì màu nằm dưới một pixel trong suốt hoàn toàn là tùy hỉ, và một trung bình có trọng số trơn cho nó bỏ phiếu. Xuất một logo từ công cụ thiết kế và lề vô hình thường là trắng, hay đen, hay bất cứ thứ gì canvas từng là; kênh alpha che nó đi, và một tổng trơn trên footprint kernel lập tức trộn nó ngược lại mép hiển thị. PDFiumPas tránh điều này bằng cách cộng dồn các mẫu BGRA ở dạng premultiplied và chỉ hủy premultiplication tại pixel đích

Cụ thể, mỗi mẫu góp phần cộng channel * alpha * weight vào bộ cộng màu, alpha * weight vào bộ cộng alpha, và weight vào tổng trọng số. Màu đích sau đó bị chia cho bộ cộng alpha thay vì cho tổng trọng số, và đó là bước quan trọng: chia cho tổng trọng số sẽ kéo màu về phía các pixel vô hình, trong khi chia cho alpha đã cộng dồn tái dựng màu mà các mẫu hiển thị thực sự đồng thuận. Alpha đích là một đại lượng riêng, 255 * AlphaSum / WeightSum. Các định dạng không alpha chia cho tổng trọng số như thường lệ, byte đệm của một đích FPDFBitmap_BGRx được ghi là hằng 255, và mọi kênh bị kẹp vào khoảng 0 đến 255 trước khi lưu. Alpha đó bình thường bắt nguồn từ một mục soft mask trong từ điển ảnh (ISO 32000-1 §11.4), thứ mà PDFium đã composite sẵn vào bộ đệm BGRA mà bộ resample nhận được

PDFiumPas xóa quầng trắng khỏi ảnh PDF trong suốt trong Delphi thế nào: các mẫu được cộng dồn ở dạng premultiplied, và màu đích bị chia cho alpha cộng dồn thay vì tổng trọng số nên pixel vô hình không thể bỏ phiếu
Chia màu premultiplied cho alpha cộng dồn tái dựng thứ mà các mẫu hiển thị đồng thuận, trong khi chia cho tổng trọng số kéo mép về phía các pixel vô hình
// Hình dạng của vòng cộng dồn trong, cho mỗi mẫu nguồn góp phần
if SrcFormat = FPDFBitmap_BGRA then
  Alpha := PByte(PAnsiChar(Pixel) + 3)^ / 255
else
  Alpha := 1;
for Channel := 0 to Min(BytesPerPixel, 3) - 1 do
  Accumulated[Channel] := Accumulated[Channel] +
    PByte(PAnsiChar(Pixel) + Channel)^ * Alpha * Weight;
AlphaSum := AlphaSum + Alpha * Weight;
WeightSum := WeightSum + Weight;

// ... và tại pixel đích, unpremultiply theo tổng alpha
if SrcFormat = FPDFBitmap_BGRA then
begin
  if Abs(AlphaSum) > 1E-12 then
    ValueSum := Accumulated[Channel] / AlphaSum
  else
    ValueSum := 0;
end
else
  ValueSum := Accumulated[Channel] / WeightSum;

Giữ line art 1-bit ngoài vùng xám

Bất kỳ kernel liên tục nào áp lên một bản quét bilevel đều sinh ra xám, và xám chính là thứ một ảnh kiểu fax không được phép chứa. Vì vậy PDFiumPas mặc định bỏ qua ảnh 1-bit: PreserveBilevelTrue trong TPdfImageOptimizeOptions.Default, và các ảnh đó rơi vào SkippedCount nguyên vẹn. Đặt nó thành False và đường pirfBilevel tiếp quản thay cho một kernel làm mượt. Nó đi qua đúng hình chữ nhật nguồn phủ mỗi pixel đích, lấy trung bình độ sáng với các trọng số 0.114, 0.587 và 0.299 theo thứ tự bộ nhớ BGR, và ngưỡng hóa kết quả tại 127.5 thành phẳng 0 hay 255. Không có gì trung gian có thể được ghi, nên mép giữ sắc và không quầng xám nào hình thành quanh nét mảnh; kênh alpha của nguồn BGRA được trung bình bình thường, và đích BGRx nhận hằng 255. Nếu bạn cần các pixel nền thay vì một tài liệu nhỏ hơn, trích xuất ảnh từ tài liệu PDF là đường riêng

Chuyện gì xảy ra khi một ảnh vượt ngân sách bộ nhớ làm việc?

Nó được để lại đúng nguyên trạng, và được đếm. MaxWorkingBytes mặc định 64 MiB và được thực thi hai lần. Trước khi bitmap đích được tạo, PDFiumPas từ chối ảnh nếu chiều rộng nhân chiều cao nhân byte mỗi pixel vượt ngân sách. Sau khi FPDFBitmap_CreateEx thành công, nó kiểm tra lại bằng stride thật nhân chiều cao, vì padding hàng có thể đẩy một lần cấp phát vượt qua một ngưỡng mà tích thô đã qua mặt. Bất kỳ lần từ chối nào cũng phá hủy đích và không trả lại gì. Hãy nói rõ về kiểu suy giảm điều này ngụ ý: một ảnh vượt ngân sách không được resample ở chất lượng thấp hơn, và không được cắt thành tile. Bản gốc ở lại trong tài liệu, BudgetExceededCountSkippedCount cùng tăng, và một lượt chạy vì thế có thể báo thành công trong khi tài liệu chỉ được tối ưu một phần. Đó là hành vi fail-safe có chủ đích, nhưng nó có nghĩa report không phải thứ đọc tùy hứng. Một kiểu thất bại khác cũng tồn tại: các ảnh mà PDFium không thể tạo bitmap, như CMYK, JPX, JBIG2 hay nguồn có mask, tăng FailedCount thay thế và cũng được để nguyên vẹn

procedure OptimizeBatch(const Files: array of string);
var
  Pdf: TPdf;
  Options: TPdfImageOptimizeOptions;
  Report: TPdfImageOptimizeReport;
  I: Integer;
begin
  Options := TPdfImageOptimizeOptions.Default;
  Options.PreserveBilevel := False;              // dùng bình chọn diện tích bilevel
  Options.ContentClass := piccPhoto;             // ép Lanczos cho bộ ảnh chụp
  Options.MaxWorkingBytes := 256 * 1024 * 1024;  // chỗ dự phòng cho bản quét lớn
  Pdf := TPdf.Create(nil);
  try
    for I := Low(Files) to High(Files) do
    begin
      Pdf.FileName := Files[I];
      if not Pdf.OptimizeImages(Options, Report) then
      begin
        WriteLn('optimize failed: ', Report.ErrorMessage);
        Continue;
      end;
      if Report.BudgetExceededCount > 0 then
        WriteLn(Files[I], ': ', Report.BudgetExceededCount,
          ' image(s) over budget and kept at full size');
      if Report.FailedCount > 0 then
        WriteLn(Files[I], ': ', Report.FailedCount,
          ' image(s) could not be decoded to a bitmap');
      if Report.OptimizedCount > 0 then
        Pdf.SaveAs(ChangeFileExt(Files[I], '.opt.pdf'));
    end;
  finally
    Pdf.Free;
  end;
end;

Đọc report trước khi đóng gói tệp

TPdfImageOptimizeReport được dựng để chẩn đoán, không chỉ để ghi log. Bên cạnh OptimizedCount, SkippedCountFailedCount, nó phơi một bộ đếm mỗi kernel, nên BoxFilterCount, BilinearFilterCount, BicubicFilterCount, LanczosFilterCountBilevelFilterCount cho bạn biết quy tắc thích ứng thực sự kết luận gì về kho ảnh của bạn. Một kết quả toàn box nghĩa là các mức thu nhỏ dốc hoặc nội dung bị phân loại là line art; một kết quả toàn Lanczos trên tài liệu bạn tin là line art là dấu hiệu nên đặt ContentClass tường minh. AverageDetailScore là con số để so với ngưỡng Lanczos 0.08 khi tinh chỉnh PreferredQuality, và PeakWorkingBytes cho thấy lượt chạy thực cần bao nhiêu trong MaxWorkingBytes. Các tùy chọn không hợp lệ fail ầm ĩ thay vì im lặng: một TargetDpi không dương, một MinDpiRatio dưới 1, một PreferredQuality ngoài khoảng 0 đến 1, hay một MaxWorkingBytes không dương raise EPdfError trước khi bất kỳ trang nào bị đụng tới. Và OptimizeImages chỉ sửa tài liệu trong bộ nhớ; mỗi trang đã sửa được commit bằng FPDFPage_GenerateContent, sau đó bạn vẫn phải tự gọi SaveAs. Để nhìn bằng mắt những gì đã đổi, render hai tài liệu trước và sau ra bitmap như mô tả trong chuyển trang PDF thành ảnh JPEG rồi so chúng ở mức zoom toàn phần

Resample thích ứng thuộc dạng tính năng vô hình khi nó chạy đúng và sinh ticket hỗ trợ khi nó không, đó là lý do phép đo, xử lý alpha và ngân sách bộ nhớ phải hạ cánh cùng nhau thay vì ba lần tinh chỉnh riêng lẻ. Nếu bạn đang đánh giá nó cho một sản phẩm Delphi, C++Builder hay Lazarus, bề mặt API đầy đủ và chi tiết licensing nằm trên trang PDFiumPas Delphi PDFium component