Bài viết kỹ thuật

Ghép trang scan 2 mặt trong Delphi: Interleave PDF

CollateDocumentsEx trong thư viện PDF Delphi PDFlibPas gộp nhiều tài liệu đang mở thành một tài liệu xen kẽ. Hàm này nối thêm GroupSize trang từ mỗi nguồn theo từng vòng, chấp nhận danh sách phạm vi trang cho từng nguồn, và coi một phạm vi giảm dần như 3-1 là đảo ngược thứ tự của nguồn đó. Chỉ một lệnh gọi biến chồng trang mặt trước và chồng trang mặt sau bị đảo ngược thành thứ tự đọc đúng

Kịch bản đứng sau API này rất bình thường và cực kỳ phổ biến. Một máy scan nạp giấy chỉ quét một mặt sẽ chạy cả chồng giấy úp xuống, sau đó người vận hành lật chồng giấy và chạy lại lần nữa. Kết quả là hai file PDF: mặt trước theo thứ tự đúng, mặt sau theo thứ tự ngược. Người dùng muốn có một file duy nhất, trang 1 mặt trước, trang 1 mặt sau, trang 2 mặt trước, cứ thế tiếp tục. Bài này nói về bài toán sắp thứ tự và cái bẫy nhân bản tài nguyên nằm bên dưới nó. Nếu bạn quan tâm đến thông lượng nối file thô thay vì việc này, xem gộp PDF nhanh bằng dịch chuyển tham chiếu ở mức byte; nếu file đầu vào quá lớn không thể giữ hết trong bộ nhớ, xem gộp và tách PDF cỡ gigabyte với truy cập trực tiếp

Máy scan tạo ra hai chồng trang, một trong số đó bị ngược

Ghép trang (collation) không phải là gộp file. Gộp file là nối các phạm vi trang lại; ghép trang xen kẽ chúng, và kiểu xen kẽ đó phụ thuộc vào đặc tính vật lý của thiết bị tạo ra dữ liệu đầu vào. Sai kiểu xen kẽ thì file không chỉ sai một chút, nó trở nên không đọc được: cứ mỗi trang thứ hai lại thuộc về một tờ giấy khác. Ba biến số mô tả gần như mọi trường hợp thực tế: có bao nhiêu nguồn tham gia vòng xoay, mỗi vòng lấy bao nhiêu trang từ mỗi nguồn, và có nguồn nào cần đọc ngược hay không. CollateDocuments xử lý hai điều đầu tiên bằng một mảng handle tài liệu đơn giản và một số nguyên GroupSize. CollateDocumentsEx bổ sung điều thứ ba bằng cách nhận một danh sách phạm vi trang phân tách bởi dấu chấm phẩy, mỗi đoạn ứng với một nguồn, trong đó đoạn rỗng nghĩa là lấy toàn bộ trang của nguồn đó và một phạm vi giảm dần thì đảo ngược nguồn đó. Cả hai hàm đều nối thêm vào cuối tài liệu đang được chọn và trả về 1 khi thành công, 0 khi bị từ chối vì bất kỳ lý do gì

Vì sao cách ghép trang ngây thơ lại nhân file lên gấp nhiều lần?

Vì bảng ánh xạ nhập (import map), vốn ánh xạ số hiệu object nguồn sang số hiệu object đích, được xây lại từ đầu ở mỗi lệnh gọi copy, và bất cứ thứ gì được nhiều đoạn tham chiếu tới đều bị nhập lại một lần cho mỗi đoạn. Bên trong PDFlibPas, TPDFDocument.CopyPagesFromDoc reset NewIndObjList ở đầu mỗi lần gọi. Danh sách đó là bộ nhớ duy nhất mà bộ copy có để biết những gì đã được mang sang. Gọi một lần với phạm vi mười trang thì một font dùng chung cho cả mười trang được nhúng một lần. Gọi mười lần, mỗi lần một trang, thì cùng font đó bị nhúng mười lần. Điều này quan trọng hơn nhiều đối với file scan so với tài liệu văn bản, vì một trang scan là một image XObject lớn và những object dùng chung mới là thứ nặng thật sự: một ICC profile được nhúng, một chuỗi /DecodeParms dùng chung, một form XObject đóng dấu hoặc watermark áp cho mọi tờ, font của lớp text OCR. Cách viết vòng lặp round-robin theo bản năng là một vòng lặp qua từng vòng, và vòng lặp đó chính xác là trường hợp bệnh lý

// Do not do this. Each CopyPageRanges call rebuilds the import map,
// so anything the two sources share internally is imported once per
// round instead of once per source.
var
  RoundIndex: Integer;
begin
  for RoundIndex := 1 to 12 do
  begin
    PDF.CopyPageRanges(Fronts, IntToStr(RoundIndex));
    PDF.CopyPageRanges(Backs, IntToStr(13 - RoundIndex));
  end;
end;

Mười hai vòng, hai nguồn, hai mươi bốn bảng ánh xạ nhập. Không có cảnh báo nào cả. Thứ tự trang vẫn đúng, mọi trang vẫn render được, và triệu chứng duy nhất là file lớn hơn tổng kích thước các nguồn nhiều lần. Với một tác vụ xử lý 300 trang, hệ số nhân này không phải sai số làm tròn, nó là ranh giới giữa một kho lưu trữ vừa ngân sách lưu trữ và một kho không vừa

Nhập một lần, rồi sắp lại cây trang

Cách sửa là tách hai mối quan tâm mà vòng lặp ngây thơ đã trộn lẫn vào nhau. Copy quyết định object nào tồn tại trong tài liệu đích; sắp thứ tự quyết định trang đặt ở đâu trong cây trang. CollateDocumentsEx copy mỗi nguồn đúng một lần, trong một lệnh gọi CopyPagesFromDoc duy nhất với toàn bộ phạm vi của nguồn đó, nên mỗi nguồn chỉ có một bảng ánh xạ nhập và tài nguyên dùng chung chỉ được ghi một lần. Chỉ sau khi mọi nguồn đã được đưa vào thì việc xen kẽ mới diễn ra, và nó diễn ra hoàn toàn thông qua TPDFPageTree.MovePage

Việc di chuyển trang là miễn phí theo nghĩa quan trọng ở đây. ISO 32000-1 §7.7.3 định nghĩa cây trang là một cấu trúc cân bằng gồm các dictionary node mà mảng /Kids của chúng chứa các tham chiếu gián tiếp, với /Count mang tổng số lá tại mỗi node. Di dời một trang nghĩa là gỡ một tham chiếu gián tiếp khỏi một mảng /Kids, chèn nó vào mảng khác, chỉnh cả hai giá trị /Count, và trỏ lại /Parent của trang. Không content stream nào bị đụng vào, không tài nguyên nào bị nhân bản, không object nào bị tạo mới. Object trang giữ nguyên số hiệu, và đó cũng là lý do vì sao số hiệu object vẫn ổn định như trong thay thế trang mà vẫn giữ nguyên số hiệu object. Còn một chi tiết nữa mà một cách di chuyển trang ngây thơ sẽ làm sai còn MovePage thì không. ISO 32000-1 §7.7.3.4 cho phép /Resources, /MediaBox, /CropBox/Rotate được thừa hưởng từ một node tổ tiên thay vì khai báo trực tiếp trên trang. Một trang thừa hưởng tài nguyên từ node A rồi bị di chuyển sang node B sẽ âm thầm thừa hưởng thứ khác, hoặc không thừa hưởng gì cả. Vì vậy MovePage phân giải giá trị thừa hưởng và ghi nó vào dictionary của trang trước khi di dời, để trang mang theo thuộc tính của chính nó qua bước di chuyển

Bước sắp lại thứ tự thực sự làm gì?

Nó chạy một selection sort dựa trên ngữ nghĩa chèn-vào-vị-trí. Thứ tự tương đối mong muốn trong khối được tính trước: duyệt các nguồn theo vòng xoay, lấy tối đa GroupSize chỉ số từ mỗi nguồn, bỏ qua nguồn đã cạn, lặp lại cho đến khi mọi trang được đặt xong. Kết quả là một hoán vị trên khối vừa được nối thêm. Áp dụng hoán vị đó mới là phần rắc rối, vì MovePage là một thao tác chèn chứ không phải hoán đổi, nên mỗi lần di chuyển làm dịch mọi thứ giữa vị trí cũ và vị trí mới đi một bậc

Phần cài đặt giữ một mảng Current mô hình hóa vị trí hiện tại của từng trang vừa nối thêm, quét tới trước từ vị trí K để tìm trang thuộc về vị trí K, thực hiện di chuyển, rồi trượt các phần tử trong mảng để phản ánh đúng những gì thao tác di chuyển đã làm với cây trang. Đây là O(n bình phương) về số thao tác mảng và bằng không về số object bị copy, đúng là sự đánh đổi hợp lý cho khối lượng công việc này: ghép 500 trang là một phần tư triệu lần xáo trộn số nguyên và không một byte dữ liệu ảnh nào bị nhân bản. Các phạm vi giảm dần và trang lặp lại không cần xử lý đặc biệt ở bước này vì PLParsePageRangeList được gọi với tính năng sắp xếp tắt và cho phép trùng lặp, nên thứ tự yêu cầu được giữ nguyên qua bước phân tích

Phạm vi đảo ngược và lệnh gộp duplex một bước

Khi đảo ngược được biểu diễn bằng một phạm vi, trường hợp scan hai lượt trên máy phẳng gói gọn thành một lệnh gọi duy nhất. Mặt trước muốn giữ thứ tự tự nhiên còn mặt sau muốn 12-1, và đoạn đầu tiên rỗng trước dấu chấm phẩy nghĩa là nguồn đầu đóng góp toàn bộ trang của nó

var
  PDF: TPDFlib;
  Target, Fronts, Backs: Integer;
begin
  PDF := TPDFlib.Create;
  try
    Target := PDF.NewDocument;
    if PDF.LoadFromFile('fronts.pdf', '') <> 1 then
      Exit;
    Fronts := PDF.SelectedDocument;
    if PDF.LoadFromFile('backs.pdf', '') <> 1 then
      Exit;
    Backs := PDF.SelectedDocument;
    PDF.SelectDocument(Target);
    // fronts 1..12 in order, backs scanned in reverse: F1 B12 F2 B11 ...
    if PDF.CollateDocumentsEx([Fronts, Backs], ';12-1', 1) = 1 then
      PDF.SaveToFile('duplex.pdf');
  finally
    PDF.Free;
  end;
end;

Hai hành vi trong đoạn mã đó đáng nói rõ ra. Các trang đã ghép được nối vào cuối tài liệu đang chọn, nên một tài liệu tạo bằng NewDocument sẽ đóng góp trang trắng khởi tạo của nó ở phía trước, và bạn nên xóa nó nếu không muốn giữ. Và các nguồn có thể không đều nhau: với GroupSize là 2 trên một nguồn ba trang và một nguồn năm trang, các vòng sẽ ra A1 A2 B1 B2, rồi A3 B3 B4 khi A gần cạn, rồi B5 đứng một mình, vì một nguồn đã cạn chỉ đơn giản bị bỏ qua chứ không được đệm thêm

Rollback, trường biểu mẫu, và những gì không đi theo

Mọi tham số đều được kiểm tra trước khi tài liệu đích bị đụng vào. Một handle tài liệu bị thiếu, tài liệu đang chọn bị liệt kê là nguồn của chính nó, GroupSize nhỏ hơn một, số lượng đoạn không khớp số lượng nguồn, một phạm vi trỏ tới trang mà nguồn không có: tất cả những trường hợp này đều trả về 0 và tài liệu đích không đổi. Lỗi xảy ra trong lúc copy là trường hợp khó hơn, và nó được xử lý qua DeletePages công khai thay vì PageTree.DeletePages ở mức thấp. Lý do rất cụ thể. Việc copy chạy với MergeFormData bật, nên các trường biểu mẫu của nguồn đã được nối vào mảng /AcroForm /Fields của tài liệu đích trước khi một nguồn sau đó gặp lỗi. Xóa trang ở mức cây trang sẽ bóc mất các trang widget và để lại những tham chiếu trường bị treo; đường đi công khai gỡ liên kết trường, outline và tham chiếu chuỗi bài viết cùng với các trang

if PDF.CollateDocumentsEx([Fronts, Backs], ';12-1', 1) = 0 then
  // Nothing was appended and the target is byte-identical to before.
  // 412 is the copy failure; 0 means the arguments were rejected
  // during validation, before any page was touched.
  Log(Format('collate rejected, LastErrorCode=%d', [PDF.LastErrorCode]));

Hãy trung thực với người dùng của bạn về giới hạn của tính năng này. Ghép trang mang theo các trang, chú thích và trường biểu mẫu của chúng, và nó gộp danh sách trường AcroForm, mảng thứ tự tính toán và dictionary tài nguyên mặc định. Nó không mang theo bookmark của nguồn: cây outline của một chồng trang mặt trước quét ra gần như luôn rỗng, nên không mất gì trong trường hợp duplex, nhưng nếu bạn ghép hai tài liệu đã được biên soạn thì outline của chúng sẽ bị bỏ lại và bạn phải tự xây dựng lại phần điều hướng. Các đích danh (named destination) chỉ tồn tại trong catalog của nguồn cũng ở tình trạng tương tự. Hãy tính đến điều này trước khi hứa với khách hàng về một phép ghép không mất mát

PDFlibPas cung cấp các hàm ghép trang cùng với phần còn lại của bề mặt lắp ghép trang, nên quy trình máy scan, trích xuất theo phạm vi và các đường xử lý file lớn đều nằm dưới một component duy nhất trong Delphi và C++Builder. Tài liệu tham chiếu API đầy đủ và bản dùng thử có tại trang sản phẩm thư viện PDF Delphi losLab