Bài viết kỹ thuật

Cập nhật lũy tiến PDF trong Delphi: Hướng dẫn AppendToStream

Cập nhật lũy tiến (incremental updates) PDF cho phép ứng dụng Delphi sửa đổi tài liệu bằng cách chỉ chèn thêm các đối tượng thay đổi, giữ nguyên từng byte gốc của tệp tin. losLab PDF Library triểnkai tính năng này thông qua AppendToStream, chỉ ghi phần lũy tiến được định nghĩa bởi tiêu chuẩn ISO 32000-1 §7.5.6, nhờ đó việc chỉnh sửa một thẻ đánh dấu (bookmark) duy nhất trên một tệp dung lượng 2 GB chỉ tốn vài kilobyte dữ liệu đầu ra thay vì phải ghi lại toàn bộ tệp. Đây cũng chính là cơ chế giúp các tài liệu đã ký số có thể được cập nhật mà không làm mất hiệu lực của chữ ký số

Khó khăn mà giải pháp này giải quyết là rất cụ thể. Việc lưu toàn bộ (full save) sẽ ghi lại toàn bộ tệp tin: mọi đối tượng được tuần tự hóa lại, mọi khoảng lệch tham chiếu chéo (cross-reference offset) được tính toán lại, và tệp đầu ra không còn mối liên hệ cấp byte nào với tệp đầu vào. Điều này hoàn toàn ổn với một hóa đơn 40 KB. Nhưng đối với một kho lưu trữ quét tài liệu dung lượng 2 GB mà bạn chỉ sửa một lỗi chính tả trong tiêu đề tài liệu, việc ghi lại hai gigabyte dữ liệu chỉ để thay đổi hai mươi byte là một điều vô lý — và nếu tệp đó có chứa chữ ký số, việc ghi lại toàn bộ này sẽ phá hủy chữ ký đó ngay lập tức

Tại sao việc lưu tệp PDF lại làm hỏng chữ ký số của nó?

Chữ ký số PDF không ký trên nội dung logic của tài liệu; nó ký trên các phạm vi byte của tệp tin vật lý. Mục nhập /ByteRange trong từ điển chữ ký ghi nhận chính xác các phân đoạn của tệp tin mà mã hóa băm (cryptographic digest) bao phủ. Bất kỳ thao tác lưu nào thực hiện tuần tự hóa lại các byte đó — ngay cả khi tạo ra một tài liệu giống hệt về mặt ngữ nghĩa — cũng sẽ làm thay đổi mã băm, và mọi trình xác thực sẽ báo cáo chữ ký đó bị hỏng. Điều này là do thiết kế: chữ ký xác nhận các byte mà người ký đã thấy, chứ không phải một mô hình tài liệu trừu tượng nào đó

Cập nhật lũy tiến là lối thoát mà đặc tả kỹ thuật PDF cung cấp. Bởi vì việc lưu lũy tiến chỉ thêm dữ liệu mới sau ký tự kết thúc tệp %%EOF ban đầu và không bao giờ chạm vào các phạm vi byte đã được ký, chữ ký hiện tại vẫn tiếp tục hợp lệ đối với các byte mà nó bao phủ. Sau đó, các trình xác thực sẽ phân loại riêng các thay đổi được thêm vào — một chữ ký thứ hai, một biểu mẫu điền thông tin, một chú thích — và quyết định xem đó có phải là những sửa đổi được cho phép hay không. Mọi quy trình làm việc đa chữ ký đều phụ thuộc vào điều này: mỗi người ký sẽ thêm một phần lũy tiến lên trên phần trước đó. Nếu bạn đang xây dựng các luồng ký số, bài viết đi kèm về ký và xác thực PAdES trong Delphi trình bày chi tiết cách thức các phạm vi byte chữ ký và các phần lũy tiến tương tác với nhau

Cơ chế hoạt động của cập nhật lũy tiến theo tiêu chuẩn ISO 32000-1 §7.5.6

Tiêu chuẩn ISO 32000-1 §7.5.6 định nghĩa mô hình này qua ba quy tắc. Thứ nhất, nội dung tệp tin gốc được giữ nguyên hoàn toàn — không một byte nào bị di dời. Thứ hai, các đối tượng thay đổi và mới tạo được thêm vào sau ký tự %%EOF cuối cùng, mỗi đối tượng giữ nguyên số định danh đối tượng mà nó có trước đó (các đối tượng thay đổi chỉ đơn giản là nhận được một định nghĩa mới hơn che phủ định nghĩa cũ). Thứ ba, một phần tham chiếu chéo và phần trailer mới được chèn thêm; mục nhập /Prev của trailer trỏ ngược về khoảng lệch byte của phần tham chiếu chéo trước đó, tạo thành một chuỗi liên kết mà trình đọc sẽ duyệt từ mới nhất đến cũ nhất để phân giải mỗi đối tượng về định nghĩa gần nhất của nó

Hai đặc tính hữu ích được rút ra từ cấu trúc này. Thứ nhất, chi phí cập nhật tỷ lệ thuận với những gì thay đổi chứ không phải kích thước tài liệu — dung lượng ghi thêm bằng kích thước của các đối tượng sửa đổi cộng với một lượng nhỏ chi phí bổ sung cho xref/trailer. Thứ hai, bản thân tệp tin trở thành lịch sử phiên bản của chính nó: mọi bản sửa đổi trước đó vẫn hiện diện vật lý, do đó người kiểm toán có thể cắt ngắn tệp tin tại bất kỳ ký tự %%EOF nào trước đó và phục hồi chính xác tài liệu tồn tại ở thời điểm đó. Đối với các quy trình tuân thủ yêu cầu chứng minh tài liệu trông như thế nào trước mỗi lần sửa đổi, chuỗi vết kiểm toán tích hợp sẵn này thường là lý luận quyết định cho việc áp dụng lưu lũy tiến

Ghi bản cập nhật lũy tiến với AppendToStream

losLab PDF Library cung cấp đầu ra lũy tiến thông qua hàm AppendToStream(AppendMode: Integer; OutStream: TStream): Integer, trả về 1 nếu thành công và 0 nếu thất bại. Tham số AppendMode lựa chọn những gì được ghi vào luồng đích. Chế độ (Mode) 0 ghi một tệp hoàn chỉnh: các byte nguồn ban đầu được sao chép vào luồng trước, sau đó phần lũy tiến được thêm vào. Chế độ 1 chỉ ghi duy nhất phần lũy tiến — phần chênh lệch (delta) — và bỏ qua hoàn toàn các byte nguồn. Chế độ 2 trước tiên ghi một tiền tố do người gọi cung cấp được đăng ký thông qua SetAppendInputFromString, sau đó chèn phần cập nhật lên trên nó

var
  Doc: TPDFlib;
  Delta: TMemoryStream;
begin
  Doc := TPDFlib.Create;
  try
    if Doc.LoadFromFile('contract.pdf', '') <= 0 then
      Exit;

    // Small edit: the kind of change that should not
    // trigger a rewrite of the whole file
    Doc.SetInformation(3, 'Amended 2026-07-04');  // key 3 = /Subject

    Delta := TMemoryStream.Create;
    try
      // AppendMode = 1: write only the incremental section.
      // Original bytes + Delta = a complete, valid PDF.
      if Doc.AppendToStream(1, Delta) = 1 then
        Delta.SaveToFile('contract.delta.bin');
    finally
      Delta.Free;
    end;
  finally
    Doc.Free;
  end;
end;

Chế độ 1 là chế độ thú vị đối với việc thiết kế hệ thống. Bởi vì phần delta độc lập, bạn có thể truyền tải nó độc lập với tệp gốc: lưu trữ các bản sửa đổi dưới dạng các blob riêng biệt trong kho lưu trữ đối tượng, chỉ sao chép các phần delta sang một máy chủ từ xa, hoặc dựng lại bất kỳ bản sửa đổi nào bằng cách ghép nối tệp cơ sở với chuỗi cập nhật lũy tiến của nó. Quy tắc ghép nối này là phép nối byte đơn giản — tệp gốc trước, tiếp theo là từng phần delta theo thứ tự — vì đó chính là bố cục mà phần §7.5.6 quy định cho một tệp được cập nhật lũy tiến

Thư viện tính toán các khoảng lệch xref như thế nào mà không cần sao chép tệp gốc?

Các mục nhập tham chiếu chéo bên trong một phần lũy tiến phải chứa các khoảng lệch byte tuyệt đối — vị trí được tính từ điểm đầu của tệp hoàn chỉnh, chứ không phải từ điểm đầu của phần delta. Điều này tạo ra một bài toán khó cho chế độ 1: trình ghi không bao giờ xuất các byte gốc, nhưng mọi khoảng lệch mà nó ghi lại phải giả định rằng chúng có tồn tại ở đó. losLab PDF Library giải quyết vấn đề này bằng một bộ điều hợp luồng nội bộ, TPDFAppendSectionStream, cung cấp một không gian tọa độ ảo cho bộ tuần tự hóa. Bộ điều hợp được tạo với độ dài byte của tệp gốc làm khoảng lệch cơ sở, báo cáo vị trí và kích thước của nó bằng khoảng lệch cơ sở đó cộng với bất kỳ thứ gì đã được ghi thêm cho đến nay, và chỉ chuyển tiếp các byte mới ghi tới luồng đích của người gọi

Hệ quả là chế độ 1 không bao giờ tạo ra một bản sao vật lý của tài liệu nguồn — cả trên đĩa cứng lẫn trong bộ nhớ. Cách triển khai thô sơ (ghi toàn bộ tệp vào một bộ đệm tạm thời, sau đó cắt bỏ phần đuôi) sẽ mang theo một bản sao tạm thời của toàn bộ tệp PDF gốc, điều này đối với các dữ liệu đầu vào quy mô gigabyte chính là chi phí mà cập nhật lũy tiến được sinh ra để tránh. Kỹ thuật ảo hóa khoảng lệch này là một người họ hàng gần của kỹ thuật dịch chuyển tham chiếu byte được sử dụng ở những nơi khác trong thư viện; bài viết về ghép nhanh PDF bằng cách dịch chuyển tham chiếu byte chỉ ra cùng một ý tưởng được áp dụng để kết hợp các tài liệu, và hướng dẫn về ghép và chia tệp PDF lớn bằng cách truy cập tệp trực tiếp trình bày cấu trúc I/O xung quanh cho các tệp tin không thể chứa vừa trong RAM

Ghi luồng toàn bộ dữ liệu lưu với SaveToStream

Đầu ra lũy tiến mới chỉ là một nửa câu chuyện của việc truyền luồng; nửa còn lại là những gì xảy ra trong một lần lưu đầy đủ. Lệnh gọi SaveToStream trong losLab PDF Library điều khiển trực tiếp bộ tuần tự hóa tài liệu đối với luồng đích, thay vì trước tiên kết xuất toàn bộ tài liệu vào một chuỗi trung gian AnsiString rồi mới ghi bộ đệm đó ra trong một lệnh gọi duy nhất. Cách tiếp cận cũ hoạt động tốt nhưng nó đồng nghĩa với việc mỗi lần lưu đầy đủ sẽ giữ tạm thời một bản sao hoàn chỉnh thứ hai của dữ liệu đầu ra trong bộ nhớ — vô hại ở mức 10 MB, nhưng cực kỳ tốn tài nguyên ở mức 500 MB, và là một rào cản cứng đối với các dữ liệu đầu ra nhiều gigabyte trên các tiến trình 32-bit. Tuần tự hóa trực tiếp giúp bộ nhớ đỉnh theo sát cấu trúc đối tượng của tài liệu thay vì chiều dài tuần tự hóa của nó

var
  Doc: TPDFlib;
  Output: TFileStream;
begin
  Doc := TPDFlib.Create;
  try
    if Doc.LoadFromFile('archive.pdf', '') <= 0 then
      Exit;

    // ... edits that justify a full rewrite ...

    Output := TFileStream.Create('archive-rewritten.pdf', fmCreate);
    try
      if Doc.SaveToStream(Output) = 0 then
        Writeln('Save failed, error ', Doc.LastErrorCode);
    finally
      Output.Free;
    end;
  finally
    Doc.Free;
  end;
end;

Bài học về chế độ chia sẻ (share-mode): khi AppendToFile trả về 0

Một lỗi suy thoái (regression) trong lĩnh vực này rất đáng được kể lại vì mô hình thất bại của nó có tính tổng quát cao. AppendToFile(FileName) thêm một bản cập nhật lũy tiến trực tiếp vào một tệp PDF hiện có trên đĩa — một lệnh gọi tự nhiên cho quy trình lưu vết kiểm toán tại chỗ: tải một tệp tin, thực hiện thay đổi, chèn thêm vào cùng một đường dẫn. Trong phiên bản v3.71.2, trình tự chính xác đó đã bắt đầu trả về kết quả là 0. Nguyên nhân gốc rễ nằm ở trình tải, chứ không phải trình ghi: để hỗ trợ đọc tài liệu lớn theo yêu cầu, LoadFromFile giữ cho handle tệp nguồn mở trong suốt vòng đời của đối tượng tài liệu, và handle đó đã được mở với chế độ fmShareDenyWrite. Khi AppendToFile cố gắng mở lại cùng tệp đó để ghi, chính chế độ chia sẻ của trình tải đã từ chối nó, và API thất bại trước khi kịp ghi một byte nào

Giải pháp khắc phục là nới lỏng chế độ chia sẻ của trình tải thành fmShareDenyNone, điều này hoàn toàn an toàn do tính chất của việc ghi lũy tiến: nó chỉ thêm các byte nghiêm ngặt sau phần cuối của tệp và không bao giờ ghi lại vùng dữ liệu mà handle tồn tại lâu dài của trình đọc đang phục vụ. Bài học chung cho bất kỳ ai đóng gói thư viện này — hoặc xây dựng các trình tải luồng tương tự — là các trình đọc lười (lazy reader) giữ handle và các trình ghi trên cùng một tệp luôn có sự xung đột, và chế độ chia sẻ bạn chọn tại thời điểm mở tệp là một cam kết API, chứ không phải một chi tiết triển khai. Nếu AppendToFile trả về kết quả là 0 trong mã của bạn, trước tiên hãy kiểm tra xem liệu có thứ gì khác trong tiến trình của bạn vẫn đang giữ tệp đích với chế độ chia sẻ hạn chế hay không

Chi phí thực tế: khi cập nhật lũy tiến không phải là công cụ phù hợp

Cập nhật lũy tiến đánh đổi kích thước tệp lấy hiệu suất ghi, và sự đánh đổi này không phải lúc nào cũng có lợi. Mỗi bản sửa đổi sẽ chèn thêm các đối tượng thay đổi của nó trong khi các định nghĩa bị thay thế vẫn nằm lại trong tệp tin, do đó một tài liệu được chỉnh sửa hàng trăm lần sẽ tích tụ các đối tượng rác và một chuỗi /Prev dài dòng mà mọi trình đọc đều phải duyệt qua. Tệ hơn nữa, nội dung "đã xóa" không thực sự biến mất: văn bản bị xóa ở bản sửa đổi thứ năm vẫn hiện diện vật lý trong các byte của bản sửa đổi thứ tư, và bất kỳ ai cũng có thể khôi phục bằng cách cắt ngắn tệp tin. Việc loại bỏ thông tin nhạy cảm (redaction), vệ sinh dữ liệu (sanitization) hoặc bất kỳ thao tác xóa nội dung bảo mật nào do đó đòi hỏi phải ghi lại toàn bộ tài liệu — việc lưu lũy tiến cho thao tác xóa thông tin nhạy cảm thực chất chỉ là một vụ rò rỉ dữ liệu với các bước phức tạp hơn

Việc lưu toàn bộ cũng là lựa chọn đúng đắn khi mục tiêu là nén dung lượng (loại bỏ các phần lũy tiến tích tụ và các đối tượng không sử dụng), khi thay đổi các thuộc tính trên toàn bộ tài liệu như mã hóa — việc mã hóa lại chạm vào mọi chuỗi và luồng dữ liệu, do đó không còn gì mang tính chất "lũy tiến" trong thay đổi đó nữa — hoặc khi tạo ra một sản phẩm bàn giao sạch sẽ mà lịch sử chỉnh sửa không được đi kèm theo tệp tin. Một quy tắc hợp lý: sử dụng AppendToStream hoặc AppendToFile khi tài liệu đang hoạt động và liên tục thay đổi, đặc biệt là khi nó đã có chữ ký số; sử dụng việc ghi lại toàn bộ qua SaveToStream ở các ranh giới vòng đời của tài liệu, khi tài liệu rời khỏi hệ thống của bạn hoặc lịch sử của nó cần phải được xóa sạch

Cập nhật lũy tiến, đầu ra chênh lệch khoảng lệch ảo, và tuần tự hóa trực tiếp vào luồng đều là một phần của tiêu chuẩn losLab PDF Library dành cho Delphi, C# và VB.NET; trang sản phẩm liệt kê đầy đủ các API lưu và chèn lũy tiến cùng với các tính năng ký số và xử lý tệp lớn đã được thảo luận ở trên