Bài viết Kỹ thuật

Trích xuất ảnh từ PDF đã tải trong Delphi: HotPDF

Bạn có một tệp PDF trên đĩa, khách hàng đã quét nó từ một chồng hóa đơn, và nhiệm vụ của bạn là lấy lại các hình ảnh trang dưới dạng bitmap để đưa qua bước OCR. Bạn tải tệp, tìm các image XObject, rồi phát hiện ra phần không ai cảnh báo trước: các byte trong những luồng đó không phải pixel. Chúng có thể là một luồng JPEG, hoặc một khối JPEG 2000 nén bằng wavelet, hoặc một chuỗi fax Group 4, hoặc một raster đã lập chỉ mục nằm sau một bảng màu rồi sau lớp lọc Flate. Đối tượng ảnh biết chiều rộng và chiều cao của nó, nhưng các mẫu thực sự bị niêm kín bên trong bộ lọc mà nhà tạo PDF đã chọn. Để có được một TBitmap dùng được, bạn phải gỡ bộ lọc đó, và PDF cho bạn khoảng tám cách khác nhau để những byte ấy bị niêm kín.

Đó chính là khoảng trống mà ExtractLoadedImage lấp đầy trong HotPDF, thành phần PDF VCL gốc cho Delphi và C++Builder. Nó liệt kê các image XObject trong tài liệu bạn đã tải, báo cho bạn biết từng ảnh là gì, và giải mã những ảnh nó có thể đưa trở lại bitmap 24 bit. Điểm đáng chú ý không phải bề mặt API, vì nó chỉ có ba phương thức. Điều quan trọng là vì sao phải có một nhánh giải mã riêng ngay từ đầu, và nó có thể, hoặc không thể, đưa điều gì trở lại thành pixel.

Vì sao ảnh đã tải chưa được giải mã sẵn

Bộ nạp của HotPDF được xây dựng theo kiểu giữ nguyên dữ liệu đi qua. Khi bạn gọi LoadFromFile, các luồng ảnh được giữ đúng như trong tệp nguồn: bộ lọc gốc, byte nén gốc, từ điển gốc. Đó là chủ ý. Mục đích của việc tải một tài liệu thường là sao chép trang, ghép tệp, đóng dấu, đổi quyền, rồi ghi lại ra ngoài, và với tất cả những việc đó, cách rẻ và an toàn nhất là để nguyên từng luồng ảnh. Giải mã mọi ảnh thành raster ngay lúc tải sẽ tiêu tốn bộ nhớ và CPU cho công việc mà đa số người dùng không bao giờ cần, còn mã hóa lại khi lưu sẽ làm suy giảm những ảnh lẽ ra phải được sao chép nguyên vẹn.

Hệ quả là đồ thị đối tượng sau khi tải không mang theo pixel nào. Một image XObject có /Filter/DCTDecode chỉ giữ byte JPEG; HotPDF chưa bao giờ chạy bộ giải mã JPEG trên nó, vì không có gì trong đường sao chép và ghi lại cần đến việc đó. Vì vậy khi bạn thực sự muốn pixel, API trích xuất phải tự làm việc giải mã, từ đầu, cho bất kỳ bộ lọc nào mà ảnh đó đang dùng. Đây cũng là lý do các bộ mã hóa ở phía ghi tách biệt với bộ nạp: bài viết về thêm ảnh JPEG 2000 vào PDF trong Delphi mô tả cách công cụ JPX gắn vào phía tạo, và công cụ đó đơn giản là chưa được nối vào đường đọc cho tới khi API trích xuất cần nó.

API ba phương thức

Bề mặt API khá nhỏ. GetLoadedImageCount trả về số lượng image XObject mà tài liệu đã tải chứa. GetLoadedImageInfo điền một bản ghi mô tả cho một phần tử theo chỉ số. ExtractLoadedImage trả về bitmap đã giải mã, hoặc nil khi không thể giải mã ảnh đó. Việc liệt kê dựa trên chỉ số và ổn định đối với một lần tải cụ thể: bên trong, nó đi qua bảng đối tượng gián tiếp và thu thập mọi stream có /Subtype trỏ tới /Image, vì vậy chỉ số bạn truyền cho GetLoadedImageInfo chính là chỉ số bạn truyền cho ExtractLoadedImage.

var
  Pdf: THotPDF;
  Info: THPDFLoadedImageInfo;
  Bmp: TBitmap;
  I, Count: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('scanned-invoices.pdf', '') <= 0 then
      Exit;
    Count := Pdf.GetLoadedImageCount;
    for I := 0 to Count - 1 do
    begin
      if not Pdf.GetLoadedImageInfo(I, Info) then
        Continue;
      if not Info.Decodable then
        Continue;                       // filter or colour space not supported
      Bmp := Pdf.ExtractLoadedImage(I);
      if Bmp <> nil then
      try
        Bmp.SaveToFile(Format('img_%d.bmp', [I]));
      finally
        Bmp.Free;                       // caller owns the bitmap
      end;
    end;
  finally
    Pdf.Free;
  end;
end;

Hai chi tiết của hợp đồng rất quan trọng ở đây. Thứ nhất, TBitmap mà hàm trả về thuộc về bạn và bạn phải giải phóng; tài liệu không cache hay sở hữu nó. Thứ hai, hãy kiểm tra Decodable trước khi gọi, và kiểm tra kết quả có khác nil sau đó. Phương thức không ném lỗi khi gặp bộ lọc không được hỗ trợ, nó trả về nil, và một nil im lặng trong vòng lặp xử lý hàng loạt chính là kiểu thứ dễ nuốt mất cả một trang trong công việc ngàn trang mà không ai nhận ra.

Đọc descriptor trước khi giải mã

THPDFLoadedImageInfo cho bạn biết một ảnh là gì mà không cần giải mã đầy đủ. Các trường của nó lấy thẳng từ từ điển ảnh: WidthHeight tính theo mẫu, BitsPerComponent, ColorComponentsColorSpace mô tả cách diễn giải sau giải mã (1 cho xám, 3 cho RGB, 4 cho CMYK), Filter là tên nén, IsImageMask cho mặt nạ stencil, ObjectNumber cho đối tượng gián tiếp bên dưới, và Decodable.

Cờ cuối cùng mới là cờ nói thật. Decodable chỉ là True khi bản dựng đang chạy thực sự có thể biến tổ hợp bộ lọc và không gian màu cụ thể này thành bitmap. Nó phản ánh ma trận hỗ trợ thật, không phải mong muốn: một ảnh có Filter mà bản dựng hiện tại không hiểu sẽ báo Decodable = False, và bạn có thể rẽ nhánh theo đó để ghi log, bỏ qua, hoặc quay lại tự trích xuất luồng thô. Hãy xem nó như một điều kiện tiên quyết, không phải một gợi ý.

// Triage every image before committing to a decode.
var
  Pdf: THotPDF;
  Info: THPDFLoadedImageInfo;
  I: Integer;
begin
  // ... Pdf loaded ...
  for I := 0 to Pdf.GetLoadedImageCount - 1 do
  begin
    if not Pdf.GetLoadedImageInfo(I, Info) then
      Continue;
    if Info.Decodable then
      // ExtractLoadedImage(I) will return a TBitmap
    else
      // unsupported filter/colour space: log the object and skip
      Writeln(Format('Image %d obj %d: %dx%d %s/%s not decodable',
        [I, Info.ObjectNumber, Info.Width, Info.Height,
         String(Info.Filter), String(Info.ColorSpace)]));
  end;
end;

Một chi tiết triển khai hay làm người viết tay bản ghi vấp phải. THPDFLoadedImageInfo có hai trường AnsiString, FilterColorSpace. Đây là các kiểu được quản lý có đếm tham chiếu, nên thói quen xóa sạch bản ghi bằng FillChar(Info, SizeOf(Info), 0) là sai ở đây: nó ghi đè tham chiếu chuỗi mà không giảm đếm, gây rò rỉ hoặc làm hỏng dữ liệu. HotPDF khởi tạo bản ghi theo từng trường chính xác vì lý do đó, và nếu bạn từng sao chép mẫu này trong mã của mình, hãy làm như vậy.

Một bộ chuyển hướng, tám đường lọc

Lý do tính năng này phải ra theo nhiều bản phát hành chứ không phải một lần là vì PDF không có định dạng ảnh. Nó có các bộ lọc, và §8.9.5 của ISO 32000-1 cho phép một image XObject đặt tên bất kỳ bộ lọc nào trong /Filter, còn cách diễn giải mẫu được quyết định riêng bởi /ColorSpace, /BitsPerComponent và một mảng /Decode tùy chọn. ExtractLoadedImage đọc tên bộ lọc rồi chuyển hướng sang một bộ giải mã chuyên dụng cho từng trường hợp. Tập hợp được hỗ trợ, được tích lũy qua v2.229 đến v2.231, giờ bao phủ tám đường đi khác nhau.

  • Raster thô (FlateDecode, LZWDecode, hoặc không có bộ lọc) ở DeviceRGB hoặc DeviceGray 8 bit. Dữ liệu được bung ra thành raster đóng gói, và biến đổi duy nhất là hoán đổi kênh, sẽ nói bên dưới.
  • DCTDecode (JPEG). Luồng dữ liệu được đưa cho TJPEGImage của VCL, đối tượng này xử lý hình học và màu sắc, rồi kết quả được gán vào bitmap 24 bit.
  • JPXDecode (JPEG 2000). Được giải mã qua backend OpenJPEG, cùng công cụ được mô tả trong bài về JPEG 2000, với các thành phần có độ sâu bit cao được lấy mẫu xuống 8 bit.
  • Màu chỉ mục. Bảng màu được đọc từ mảng [/Indexed base hival lookup] và mỗi mẫu được mở rộng qua bảng tra thành màu thực.
  • DeviceCMYK. Các mẫu bốn kênh được chuyển sang RGB bằng công thức mực trên nền trắng chuẩn.
  • DeviceGray và Indexed dưới 8 bit ở mức 1, 2 hoặc 4 bit mỗi thành phần, được bung theo từng mẫu và co giãn về dải 0-255.
  • CCITTFaxDecode, các bộ lọc fax Group 3 và Group 4, được giải mã bởi backend T.4/T.6 chuyên dụng.
  • JBIG2Decode, bộ lọc nhị phân tỉ lệ nén cao, được giải mã qua backend JBIG2 đã đăng ký mà bài viết về nén JBIG2 gốc trình bày từ phía mã hóa.

Mọi thứ đều đi tới cùng một nơi: bitmap BGR 24 bit, vì đó là định dạng mà TBitmap của VCL lưu nguyên bản và cũng là thứ mọi thành phần phía sau mong đợi.

Các phép biến đổi âm thầm đổi pixel

Trong hai đường đi này có một phép biến đổi rất dễ làm sai một cách tinh vi, và đáng hiểu ngay cả khi bạn không đụng vào bộ giải mã. Thứ nhất là hoán đổi thứ tự màu. Một raster DeviceRGB trong PDF lưu các mẫu theo thứ tự đỏ-xanh lá-xanh dương, từ hàng trên cùng xuống. Một scanline 24 bit của VCL lưu chúng theo thứ tự xanh dương-xanh lá-đỏ. Vì vậy giải mã một ảnh RGB thuần không phải là memcpy; byte đầu và byte thứ ba của mỗi pixel được đổi chỗ khi đi vào scanline. Làm ngược lại thì đỏ và xanh dương sẽ đổi chỗ cho nhau, trông có vẻ ổn trên ảnh thử màu xám nhưng sai nghiêm trọng trên ảnh màu. Về thứ tự hàng, nếu cần nói cho đủ, thì khớp thẳng: raster top-down của PDF khớp với ScanLine[0] của VCL như hàng hiển thị trên cùng, nên không cần lật dọc.

Đường thứ hai là CMYK. Ảnh DeviceCMYK của PDF mang bốn mực, và phép chuyển sang RGB là tính toán theo từng kênh, không phải tra bảng: mỗi kênh đầu ra là (255 - ink) * (255 - K) / 255. Đây là một phép xấp xỉ theo thiết bị, không phải chuyển đổi có quản lý màu qua hồ sơ ICC, nên kết quả đủ đúng cho hiển thị và tái raster hóa nhưng không phải đường đi đúng nếu bạn cần màu chính xác để in. Nếu quy trình của bạn đòi hỏi độ trung thực, hãy coi bitmap đã trích xuất như bản xem trước và giữ luồng CMYK gốc cho chuỗi xử lý có quản lý màu.

Đường Indexed ẩn một cái bẫy phân tích riêng. Bảng màu trong không gian màu /Indexed có thể được lưu dưới dạng chuỗi nguyên văn hoặc chuỗi thập lục phân, và HotPDF lưu giá trị của chuỗi hex như chính văn bản hex, chứ không phải byte đã giải mã. Vì thế khi bảng màu là chuỗi hex, bảng tra phải được chạy qua bước giải mã hex sang byte trước; chuỗi nguyên văn thì vốn đã là byte thô. Bỏ sót nhánh đó thì ảnh Indexed bốn màu sẽ ra rác, vì mọi mục trong bảng màu đều bị đọc sai ranh giới byte.

Chuỗi bộ lọc: bộ lọc cuối mới là bộ lọc của ảnh

Một tên /Filter đơn lẻ là trường hợp dễ. PDF cũng cho phép một chuỗi bộ lọc, khi luồng đã được đi qua nhiều bước liên tiếp, liệt kê theo thứ tự trong mảng /Filter như [/ASCII85Decode /FlateDecode] hoặc [/ASCIIHexDecode /DCTDecode] (ISO 32000-1 §7.4). Ngữ nghĩa rất rõ: khi mã hóa thì các bộ lọc áp dụng từ trái sang phải, nên khi giải mã bạn tháo ngược lại từ phải sang trái, và bộ lọc cuối cùng trong mảng mới là thứ thực sự định nghĩa định dạng ảnh. Các bộ lọc phía trước chỉ là các lớp mã hóa vận chuyển bao quanh nó.

Trình trích xuất xử lý việc này bằng cách bóc lớp. Trước khi bất kỳ bộ giải mã ảnh nào chạy, mọi bộ lọc trong chuỗi trừ bộ cuối cùng đều được áp dụng để tạo ra dữ liệu đầu vào mà bộ lọc cuối cần, rồi lúc đó mới chuyển hướng theo bộ lọc cuối. Vì vậy [/ASCII85Decode /DCTDecode] sẽ giải ASCII85 của luồng trước, rồi chuyển kết quả sang đường JPEG; [/FlateDecode] bọc quanh một raster thô thì bung nén rồi chạy đường raster. Đây là lý do tám bộ giải mã có thể giữ đơn giản. Không bộ nào phải biết về các lớp bọc ASCII85 hay hex, vì đến lúc bộ giải mã nhìn thấy byte thì các lớp bọc đã bị gỡ rồi. Nó cũng có nghĩa là một chuỗi mà bộ lọc cuối không được hỗ trợ vẫn thất bại gọn ở bước chuyển hướng, chứ không chết dở giữa chừng.

Khi nào việc trích xuất dừng lại

Hãy thành thật với chính mình về giới hạn. Một ảnh có bộ lọc cuối nằm ngoài tập được hỗ trợ sẽ trả về nil, và ảnh có không gian màu mà bản dựng không thể diễn giải cũng vậy. Soft mask và alpha không được tái dựng vào bitmap; bạn chỉ nhận được ảnh nền, không phải kết quả đã ghép. Độ sâu bit trên 8 từ JPEG 2000 sẽ bị lấy mẫu xuống, cố ý là mất dữ liệu và là lựa chọn sai nếu bạn đang lưu trữ lại chứ không chỉ hiển thị. Còn image mask, một stencil 1 bit không có màu riêng, được mô tả bởi descriptor nhưng là thứ khác với một ảnh hình học; nếu giải mã nó mà mong đợi một tấm ảnh chụp, bạn sẽ bất ngờ.

Khi trích xuất vẫn chưa đủ, luồng thô vẫn nằm nguyên trong đồ thị đối tượng đã tải, kèm theo mọi bộ lọc, và bạn có thể kéo nó ra byte bằng byte rồi đưa cho bộ mã hóa chuyên dụng của riêng mình. Đó là phương án dự phòng mà thiết kế giữ nguyên dữ liệu cố ý bảo toàn: các byte gốc không bao giờ bị vứt đi, nên trường hợp xấu nhất là bạn tự giải mã chúng chứ không phải dữ liệu đã mất. Với đa số công việc thực tế, tám bộ lọc được hỗ trợ đã bao phủ thứ mà scanner, bộ ứng dụng văn phòng và công cụ báo cáo thực sự sinh ra, và một vòng lặp qua GetLoadedImageCount với một kiểm tra Decodable sẽ biến một PDF đã tải thành cả thư mục bitmap chỉ trong vài dòng.

API trích xuất ảnh đã tải, cùng toàn bộ tập bộ lọc giải mã được mô tả ở đây, được cung cấp trong HotPDF Component cho Delphi và C++Builder.