Bài viết kỹ thuật

Ghép PDF nhanh trong Delphi: Dịch tham chiếu mức byte

Ghép nối PDF nghe ra đáng lẽ phải rẻ. Nội dung trang đã được dàn sẵn, phông đã nhúng, ảnh đã nén. Về nguyên tắc, một lần ghép chỉ là việc ghi sổ: đánh lại số object để hai không gian số của hai tệp không còn đụng nhau, nối các cây trang lại, sửa bảng cross-reference, rồi ghi ra. Trên thực tế, đa số mã ghép ném mất phần rẻ đó. Với mỗi object trong mỗi tệp đầu vào, nó parse đầy đủ thành cây object đã tách token, sửa vài tham chiếu gián tiếp, rồi serialize cây đó về byte. Phần parse và serialize lại là hai nửa tốn kém nhất, và với phần lớn object chúng tạo ra chuỗi byte gần như giống hệt dữ liệu ban đầu

PDFlibPas là một engine PDF Object Pascal thuần native cho Delphi và C++Builder, và đường ghép nhanh của nó tồn tại để bỏ qua vòng lặp đó ở mọi nơi có thể chứng minh là an toàn. Ý tưởng này hẹp nhưng hiệu quả trên cả bộ tài liệu: với một object không bị sửa và không phải stream, lấy nguyên byte nguồn gốc rồi viết lại đúng một lần ở mức byte cho các tham chiếu gián tiếp bên trong nó, biến mọi N G R thành (N+Offset) G R. Không tokenizer, không cây object, không serializer. Bài viết này đi qua những chỗ mà thủ thuật đó hợp lệ, máy trạng thái parse được dùng để viết lại byte mà không làm hỏng gì, vì sao ghép bookmark cần một cơ chế khác hoàn toàn, và cách đường ghép thông thường cũng được xây lại từ bậc hai xuống tuyến tính cùng lúc

Vì sao đánh lại số object mới là chi phí thật của một lần ghép

Mỗi PDF đều mang không gian đánh số object riêng. Tệp A có object 1, object 2, rồi tiếp tục như vậy; tệp B cũng có object 1, object 2, và cứ thế. Bạn không thể ném object của B vào nguyên xi trong tệp A, vì các số sẽ đụng nhau và mọi tham chiếu gián tiếp bên trong B sẽ trỏ nhầm object. Cách sửa là dùng offset: nếu A kết thúc ở số lượng object Offset, thì object N của B sẽ thành object N+Offset trong đầu ra, và mọi tham chiếu N G R xuất hiện ở bất kỳ đâu trong object của B đều phải được dịch thành (N+Offset) G R cho khớp

Phép dịch đó chính là toàn bộ công việc ngữ nghĩa của việc ghép phần thân. Các sửa chữa cây trang và ghép AcroForm chỉ là những chỉnh sửa nhỏ, có phạm vi giới hạn trên vài object. Phần việc lớn là viết lại tham chiếu trên hàng nghìn object, và cách ngây thơ để làm là parse từng object để tìm tham chiếu theo cấu trúc. MergeFileListFast của PDFlibPas đi theo hướng ngược lại: tham chiếu cũng có thể tìm được ngay trong byte thô nếu bạn cẩn thận với những ngữ cảnh mà một chuỗi số-khoảng trắng-số-khoảng trắng-R không phải là tham chiếu. Bỏ parse, dịch tại chỗ, và chi phí theo từng object co lại còn một lần quét tuyến tính trên chính các byte mà bạn vốn định sao chép

Khi tái dùng byte nguồn thì an toàn một cách có thể chứng minh

Đường byte chỉ được chọn khi cả ba điều kiện sau đều đúng với object đang được sao chép ra từ một tài liệu phía sau. Chỉ cần một điều kiện không đạt, object sẽ quay về đường decode rồi serialize lại đầy đủ, nên độ đúng luôn thắng tốc độ:

  • Doc2.IsChangedObject(X) là False. Nếu engine ghép đã sửa object đó trong bộ nhớ, ví dụ một object trang có /Parent đã bị trỏ sang nơi khác, thì cây trong bộ nhớ mới là nguồn sự thật và byte gốc đã lỗi thời. Chỉ những object chưa đụng tới mới đủ điều kiện
  • Dữ liệu nguồn không chứa từ khóa stream. Phần thân của một stream object là dữ liệu nhị phân được bao bởi stream/endstream, và nếu quét tham chiếu thô trên dữ liệu stream đã nén hoặc đã mã hóa thì rất dễ "tìm" nhầm rồi làm hỏng những mẫu byte trông giống tham chiếu. Stream object giữ nguyên đường xử lý có hiểu stream
  • Dữ liệu nguồn không chứa cả /StructTreeRoot lẫn /StructElem. Trong cấu hình fast, cây cấu trúc tagged-PDF bị bỏ chứ không ghép, nên các object đó phải đi qua đường decode để engine có thể chủ động đặt chúng về null

Quyết định này nằm trong vòng lặp sao chép từng object. Khi cả ba kiểm tra đều qua, byte của object đi thẳng vào ShiftIndRefsInSource rồi sang writer; nếu không, byte bị bỏ và object được dựng lại bằng GetObject, dịch bằng ShiftIndRef, rồi serialize. Cấu trúc của nhánh đó đáng xem, vì thứ tự các kiểm tra chính là điều giữ nó an toàn:

ObjectData := '';
if not Doc2.IsChangedObject(X) then
begin
  ObjectData := FastMergeObjectSource(Reader2, X);
  if (PLPos('stream', ObjectData) > 0) or
     ((not PreserveStructTree) and (PLPos('/StructTreeRoot', ObjectData) > 0)) or
     ((not PreserveStructTree) and (PLPos('/StructElem', ObjectData) > 0)) then
    ObjectData := ''                                  // fall back to decode
  else
    ObjectData := ShiftIndRefsInSource(ObjectData, Offset);
end;

if ObjectData <> '' then
  Writer.AddObject(X + Offset, Doc2.GetGenNum(X), ObjectData)
else
begin
  Obj := Doc2.GetObject(X, TempStruct);              // full parse path
  // ... null out struct-tree objects, ShiftIndRef, Obj.Output ...
end;

ObjectData rỗng là tín hiệu cho biết đường byte đã từ chối object đó. Một sentinel duy nhất như vậy giữ cho đường nhanh và đường chậm không trôi lệch nhau: chỉ có một chỗ ra quyết định, và chỉ có một đường dự phòng

Máy trạng thái dịch tham chiếu và các trường hợp biên

Viết lại byte cho tham chiếu gián tiếp tưởng dễ nhưng lại rất dễ sai, vì R và các dãy chữ số xuất hiện khắp nơi trong một object PDF ở những ngữ cảnh không phải là tham chiếu. ShiftIndRefsInSource là một scanner viết tay nhỏ, quét byte đúng một lượt và chỉ viết lại một số khi phía sau nó, với khoảng trắng PDF giữa các token, là một số khác rồi đến một dấu phân tách R. Các lối thoát rẻ được xử lý trước: nếu offset bằng 0 hoặc source rỗng, byte được trả về nguyên vẹn mà không vào scanner

Độ đúng của scanner phụ thuộc vào việc nhận ra các ngữ cảnh mà một chuỗi có hình dạng giống tham chiếu phải được để yên. Đây là những ranh giới dễ bỏ sót nhất, và mỗi ranh giới đều được xử lý rõ ràng:

  • Chuỗi literal được phân định bởi () được chép nguyên văn, đồng thời theo dõi độ sâu lồng nhau và tôn trọng ký tự thoát bằng backslash để một dấu ngoặc đơn đã escape không làm lệch bộ đếm độ sâu. Một chuỗi như (see object 3 0 R for details) chứa đúng mẫu tham chiếu trong sách giáo khoa nhưng thật ra chỉ là văn xuôi, và nó phải đi qua nguyên byte
  • Chuỗi hexadecimal được phân định bởi <> được chuyển qua mà không diễn giải. Các byte 52 bên trong chuỗi hex là mã ASCII của R, và một scanner coi payload hex như text có thể tự tạo ra một tham chiếu ma. Dấu mở << của một dictionary được nhận ra trước để dictionary không bị nhầm với chuỗi hex
  • Object tên bắt đầu bằng / được nuốt trọn, từ dấu gạch chéo đến khoảng trắng hoặc dấu phân tách tiếp theo. Nếu không có bước này, một tên như /R vốn là key tài nguyên rất thường gặp có thể bị đọc nhầm thành chữ R của một tham chiếu
  • Comment bắt đầu bằng % chạy đến hết dòng và được bỏ qua như text mù
  • Phép kiểm tra số rồi đến R là rất chặt. Một tham chiếu chỉ được nhận ra khi có dạng N khoảng trắng G khoảng trắng R, và R phải kết thúc bằng khoảng trắng, một dấu phân tách, hoặc hết dữ liệu. Nếu thiếu số thế hệ, hoặc sau R là một chữ cái, các chữ số sẽ được phát ra nguyên trạng. Đây là điều bảo vệ số nguyên trong /Length 1234 và bốn số của MediaBox khỏi bị tăng thầm lặng

Phần lõi của phép kiểm tra nghiêm ngặt đó gần như đúng y như câu đặc tả mô tả:

if (P <= N) and (Source[P] = 'R') and
   ((P = N) or PLIsPdfWhite(Source[P + 1]) or PLIsPdfDelimiter(Source[P + 1])) then
  Obj1 := PLStrToIntDef(PLCopy(Source, I, E1 - I), -1);

if Obj1 >= 0 then
begin
  AppendStr(PLIntToStr(Obj1 + Offset));   // shifted object number
  AppendBytes(E1, P - E1);                 // original whitespace + generation
  AppendBytes(P, 1);                       // the 'R'
end;

Chỉ số object được viết lại; số thế hệ và đúng nguyên khoảng trắng giữa các token được chép qua, nên đầu ra giống byte với đầu vào ở mọi thứ trừ đúng số nguyên duy nhất cần đổi. Độ chính xác đó là toàn bộ mục tiêu, nó làm cho việc tái dùng byte nguồn tương đương với serialize lại đầy đủ, chứ không chỉ gần giống. Hành vi này được bao bởi một bộ unit test tập trung vào tham chiếu trần, tham chiếu trong mảng, số không phải tham chiếu, chuỗi literal, chuỗi hex, và số thế hệ khác 0 có áp dụng offset

Vì sao bookmark không thể dùng lại AppendOutline

Ghép bookmark của nhiều tài liệu vào một cây outline nghe có vẻ là việc của helper AppendOutline vốn đã biết cách gắn bookmark cấp cao nhất của một tài liệu sang tài liệu khác. Nhưng ở đây nó là công cụ sai, vì có một lệch lớp rất tinh vi. AppendOutline tìm bookmark cấp cao nhất cuối cùng hiện tại bằng cách lần reader trên byte gốc của tệp. Còn fast merge lại xếp các chỉnh sửa của nó trong một buffer object mới thông qua ChangeObject; reader không bao giờ nhìn thấy các chỉnh sửa đó. Nối ba tài liệu trở lên và mỗi lần nối sẽ trỏ lại bookmark cuối cùng nguyên thủy của tài liệu đầu tiên sang tài liệu mới nhất, nên toàn bộ bookmark của các tài liệu ở giữa rơi khỏi chuỗi, chỉ có /Count cộng dồn còn đúng, khiến lỗi này rất dễ bị bỏ qua cho đến khi ai đó mở bảng bookmark

Đường nhanh giải quyết bằng một cách chèn siêu dữ liệu theo hai pha, không bao giờ quét lại reader. Pha đầu quét tất cả input để thu thập, cho từng tài liệu, object root của outline cùng số thế hệ, số bookmark cấp cao nhất đầu và cuối, và /Count của root. Từ bản tóm tắt đó, code tính ra các số object toàn cục cho mọi liên kết cần tạo ra, mỗi tài liệu gắn /Parent cấp cao nhất về root chung, /Prev của bookmark đầu trỏ tới bookmark cuối của tài liệu trước, /Next của bookmark cuối trỏ tới bookmark đầu của tài liệu sau, hoàn toàn bằng số học object. Phía sau có một ràng buộc về thứ tự ghi: object của tài liệu đầu tiên được ghi ra trước khi bất kỳ tài liệu nào phía sau được mở, nên mọi chỉnh sửa outline của tài liệu đầu tiên, gồm /Count/Last của root cùng /Next của bookmark cuối cũ, phải biểu diễn được bằng số học, không cần cầm tài liệu sau trong tay. Mỗi chỉnh sửa của các tài liệu tiếp theo được áp vào chỗ của nó sau khi mở nhưng trước khi ghi, nên chúng đi ra qua cùng đường change-object

Bất biến canh chỉnh offset gắn kết toàn bộ quy trình

Cả phép dịch tham chiếu lẫn phép chèn bookmark đều phụ thuộc vào một bất biến số học, và đó là giả định mong manh nhất trong toàn bộ thiết kế. Một tham chiếu được chèn vào tài liệu phía sau được ghi dưới dạng số object toàn cục đích trừ Offset của tài liệu đó, để khi object sau này bị dịch bởi ShiftIndRef(Offset) thì giá trị sẽ rơi đúng vào số toàn cục mong muốn. Tài liệu đầu tiên nhận Offset = 0 và dùng trực tiếp số toàn cục. Để phép trừ đó đúng, chuỗi offset đang chạy trong lúc chèn phải khớp với chuỗi offset được dùng khi các object được ghi ra cuối cùng

Nó khớp, vì cách ghép trang và form hoạt động có một tính chất quan trọng: AddPages, AddFieldsAddFieldFonts chỉ sửa những object sẵn có của tài liệu đầu tiên, chúng không bao giờ thêm object mới. Vì vậy, số object của tài liệu đầu tiên không đổi qua giai đoạn ghép trang, và offset của từng tài liệu phía sau, tức tổng số object của tất cả tài liệu đứng trước nó, vẫn ổn định từ lúc chèn cho tới lúc ghi ra. Chỉ cần phá vỡ điều đó, ví dụ đưa vào một giai đoạn tạo object mới giữa đường ghép, là mọi tham chiếu trang và bookmark phía sau sẽ lệch đúng bằng số object đã thêm. Bất biến này im lặng, nhưng nó là thứ gánh cả hệ thống

Ba điểm vào trên một engine

Đường nhanh không phải là một nhánh rẽ của mã ghép. Trong cùng nhánh công việc đó, engine cấp byte được tách thành một routine nội bộ duy nhất, MergeFileListInternal(ListName, OutputFileName, PreserveStructTree, StrictMode), và các API công khai trở thành những wrapper mỏng chỉ chọn hai cờ:

  • MergeFileListFast gọi engine với việc bảo toàn cây cấu trúc tắt đi, đây là đường nhẹ nhất, bỏ cây tagged-PDF để đường byte áp dụng cho nhiều object nhất
  • MergeFileList gọi nó với bảo toàn bật, nên cây cấu trúc được giữ lại và kết quả vẫn là một tagged PDF dùng được. Đường thông thường này cũng kế thừa phần ghép bookmark và form đa tài liệu
  • MergeFileListStrict bật strict mode: lượt metadata đầu tiên dừng ngay ở input đầu tiên không báo ghép sạch, nên chỉ những tài liệu đã thu thập trước file lỗi mới được đưa vào, thay vì bỏ qua file lỗi rồi tiếp tục

Việc gom các đường này lại cũng cho phép xây lại đường ghép thông thường từ một vòng lặp ghép cặp O(N²), ghép file một với hai, ghép kết quả đó với ba, rồi cứ thế tiếp tục, parse lại bộ tích lũy đang lớn dần ở mỗi bước, thành một lượt tuyến tính duy nhất mở mỗi input đúng một lần. Hai điểm vào hai-tệp và hai-stream lâu đời, MergeFilesMergeStreams, không bị động vào và vẫn còn cho những ai thật sự muốn ghép theo cặp

Một ghi chú trung thực về hành vi của cây cấu trúc, vì nó đã làm bộ test vấp. Phần "bỏ" của đường nhanh không phải là bỏ hoàn toàn: nó xóa tham chiếu của catalog ở tài liệu đầu tiên tới /StructTreeRoot, nhưng chính object cây cấu trúc vẫn được ghi ra như một object mồ côi. Vì vậy, byte đầu ra nhanh vẫn chứa chuỗi /StructTreeRoot, và bạn không thể phân biệt đầu ra nhanh với đầu ra thường bằng cách tìm chuỗi đó; khác biệt thật sự là catalog còn chạm tới cây cấu trúc hay không, và đó là điều quyết định file còn là một PDF có gắn thẻ có thể điều hướng hay không

Khi nào nên chọn đường nào

Đường byte là tối ưu thông lượng cho việc ghép nhiều tài liệu khi bạn không cần giữ cây cấu trúc tagged-PDF, như ghép báo cáo, chạy sao kê, ghép nối theo lô. Khi đo trên các lần ghép lặp lại của bộ đầu vào vừa đến lớn, việc tái dùng byte đã cắt khoảng bốn đến mười ba phần trăm thời gian thực thi tùy theo hỗn hợp object, và không phát sinh lỗi mới trên các đầu vào nhỏ hoặc lỗi định dạng, vì bất kỳ object nào scanner không chứng minh được là an toàn đều rơi về parse đầy đủ. Nếu bạn thật sự cần giữ nguyên cây cấu trúc để hỗ trợ khả năng tiếp cận, hãy dùng đường ghép tagged-PDF thông thường, đường này giữ nó lại; còn nếu bạn đang làm với những tệp đơn lẻ rất lớn thay vì nhiều đầu vào, thì các kỹ thuật sao chép byte được mô tả trong bài đi kèm về ghép và tách PDF lớn với truy cập tệp trực tiếp cũng áp dụng cùng triết lý "sao chép byte, tránh cây object đầy đủ" ở quy mô tệp

Các routine ghép cùng các biến thể fast và strict của chúng là một phần của PDFlibPas Delphi PDF Library, nơi tài liệu có đầy đủ tham chiếu cho API file-list và các tùy chọn ghép được mô tả ở đây