Bài viết kỹ thuật

PDFlibPas giữ độ chính xác thập phân đã đọc khi lưu PDF

PDFlibPas, tức losLab PDF Developer Library, giữ nguyên đoạn văn bản thập phân chính xác mà nó đã đọc cho mọi số thực trong tài liệu và ghi lại y nguyên đoạn văn bản đó mỗi khi giá trị chưa từng bị sửa. Từ v3.539.19, thiết lập SetPrecision chỉ chi phối những số do thư viện tạo ra hoặc sửa, nên một lần nạp rồi lưu thông thường không còn làm tròn /Gamma của CalRGB từ 2.22221 xuống 2.2222 và làm lệch màu của một trang không ai đụng tới. Thay đổi này nhỏ về mặt mã nguồn nhưng lớn về điều nó nói về các parser: giá trị bạn giải mã và literal bạn phát ra là hai thứ khác nhau, và một vòng round trip qua Double không phải là phép biến đổi đồng nhất

Vì sao một lần lưu không đổi gì lại làm lệch màu trang?

Vì các tham số color space bị định dạng lại, chứ không phải bản thân ảnh. Tệp phơi ra chuyện này là một tài liệu văn phòng 35 trang trong kho mẫu hồi quy nội bộ, với một ảnh header được dùng lại trên mọi trang. Nạp nó rồi lưu thẳng trở lại cho ra các image stream giống hệt đầu vào từng byte, và một phép so sánh hash của stream báo rằng tài liệu không đổi. Một phép so sánh hình ảnh thì nói ngược lại: cả 35 trang đều cho thấy khác biệt pixel ở phần header, và không chỗ nào khác

Ảnh header vẽ qua một color space CalRGB, thứ mà ISO 32000-1 §8.6.5.3 định nghĩa bằng một /WhitePoint, một mảng /Gamma ba phần tử tùy chọn và một /Matrix chín phần tử tùy chọn. Những mảng đó là các object số bình thường trong dictionary color space. TPDFNumeric chỉ lưu mỗi giá trị dưới dạng một Double và không lưu gì thêm, rồi TPDFNumeric.Output định dạng Double đó qua PDFPrecNum, vốn mặc định bốn chữ số thập phân. Thành ra /Gamma đi từ 2.22221 thành 2.2222, một phần tử ma trận đi từ 0.71519 thành 0.7152, và bộ hiển thị trung thành tạo ra màu hơi khác từ thông số hiệu chỉnh hơi khác. Các byte của ảnh vô can; những con số quanh nó thì không. Phần khó chịu là chuyện này vô hình đến mức nào. So sánh byte của stream đã giải mã không thấy được, vì các con số nằm trong dictionary chứ không nằm trong stream. So sánh payload của tệp đính kèm cũng không thấy được. Ngay cả phép so sánh revision mô tả trong bài về các mức sửa đổi cũng chỉ lấy dấu vân tay của phần thân object đã chuẩn hóa, nên cả hai revision băm ra cùng một giá trị và phép so sánh báo chúng giống hệt nhau. Chỉ có hiển thị mới bắt được, và đó là lý do baseline của kho mẫu hiển thị mọi trang thay vì chỉ tin vào các phép kiểm tra cấu trúc

Nơi PDFlibPas mất độ chính xác CalRGB trong một lần lưu không đổi gì ở Delphi: /Gamma 2.22221 đã đọc và một phần tử ma trận 0.71519 nằm trong TPDFNumeric dưới dạng Double, Output định dạng chúng qua PLDoubleToStr với PDFPrecNum ở bốn chữ số thập phân, mọi phép kiểm tra cấu trúc đều báo tài liệu không đổi, và chỉ phép so sánh hình ảnh mới cho thấy cả 35 ảnh header bị lệch
Các byte của ảnh vô can: TPDFNumeric định dạng lại những con số hiệu chỉnh quanh nó qua PDFPrecNum, nên hash của stream lẫn phép so sánh vân tay đều báo hai revision giống hệt nhau trong khi bộ hiển thị tạo ra màu hơi khác trên mọi trang

Giá trị bạn đã đọc không phải literal bạn nên ghi

Một số thực trong PDF là một chuỗi thập phân, và ISO 32000-1 §7.3.3 nói rõ nó chỉ là chuỗi thập phân: không có ký hiệu cơ số, không có dạng mũ. Annex C sau đó liệt kê độ chính xác mà một bản cài đặt được kỳ vọng tôn trọng, khoảng năm chữ số thập phân có nghĩa ở phần phân số. Độ chính xác đầu ra mặc định là bốn vốn đã dưới mức đó, và còn tệ hơn ở gần không: PLDoubleToStr lấy thang giá trị, làm tròn thành số nguyên và phát ra 0 khi kết quả bằng không, nên một phần tử ma trận -0.000012345 không mất một chữ số, nó biến mất hoàn toàn

Nâng mặc định lên chỉ làm dịch chỗ rơi xuống vực chứ không xóa nó. Cách sửa là thôi giả vờ rằng một Double chính là con số. Khi bộ tokenizer trong TPDFStructure.Decode nhận ra một số thực chuẩn, nghĩa là token có dấu chấm thập phân và không có dấu mũ, nó lưu văn bản nguồn vào trường FOriginalText mới bên cạnh giá trị đã chuyển đổi. Output sau đó ưu tiên đoạn văn bản đó và chỉ quay về định dạng khi không có gì để ưu tiên

Cách PDFlibPas giữ đoạn văn bản thập phân đã đọc trong Delphi: tokenizer trong TPDFStructure.Decode giữ literal nguồn trong FOriginalText với mọi token có dấu chấm thập phân và không có mũ, Output ghi nguyên văn đoạn văn bản đó thay vì gọi PLDoubleToStr, còn SetTo xóa nó vì một số đã bị sửa là một số mới
Giá trị bạn giải mã và literal bạn phát ra là hai thứ khác nhau: ưu tiên đoạn văn bản đã đọc giữ 2.22221 chính xác nguyên vẹn, còn số do thư viện tạo ra và số đã bị sửa vẫn theo PDFPrecNum và thiết lập này không bao giờ chạm tới dữ liệu đầu vào chưa bị đụng
// Lib/PDFlibStruct.pas — toàn bộ bản sửa ở phía đầu ra
Function TPDFNumeric.Output: AnsiString;
Begin
  If FOriginalText<> '' Then
    Result:= FOriginalText
  Else
    Result:= PLDoubleToStr(FValue, Owner.PDFPrecNum);
End;

Procedure TPDFNumeric.SetTo(Const Value: Double);
Begin
  FOriginalText:= '';   // một số đã bị sửa là một số mới
  FValue:= Value;
  FChanged:= True;
End;

Hai ranh giới là có chủ đích. Số nguyên không được giữ nguyên, vì định dạng số nguyên vốn đã không mất mát. Các dạng mũ như 6.02E23 được chấp nhận ở đầu vào vì thương xót những producer hỏng nhưng không được giữ ở đầu ra, vì ghi chúng trở lại sẽ duy trì một cú pháp mà §7.3.3 cấm; chúng đi qua bộ định dạng như mọi số do thư viện tạo ra. Bộ tokenizer cũng áp dụng phép sửa tối thiểu thường lệ trước khi lưu văn bản, nên một literal bắt đầu bằng dấu chấm như .5 được giữ thành 0.5 và một literal kết thúc bằng dấu chấm như 5. thành 5.0. Cả hai đều là cùng một con số với mọi bộ đọc và được chấp nhận rộng rãi hơn nhiều

SetPrecision bảo đảm điều gì sau v3.539.19?

TPDFlib.SetPrecision giờ kiểm soát số chữ số thập phân của những số do chính thư viện tạo ra: giá trị được vẽ qua painter, số tạo từ một Double chẳng hạn qua NewNumeric, và mọi giá trị đã đọc mà sau đó bị sửa bằng SetTo. Lưu ý rằng văn bản được giải mã qua object API, ví dụ một literal truyền cho SetObjectFromString, cũng đi qua đúng bộ tokenizer đó và được giữ nguyên theo cùng cách. Một số thập phân đã đọc mà chưa từng bị sửa sẽ giữ độ chính xác đầu vào bất kể thiết lập, và đổi thiết lập sau khi nạp không chạm ngược vào nó. Mục tham chiếu của SetPrecision đã được cập nhật trong cùng bản phát hành để nói đúng điều này, vì cách diễn đạt cũ ngụ ý thiết lập đó áp cho mọi con số trong tệp

Việc xóa đoạn văn bản gốc diễn ra ngay trong SetTo chứ không được suy ra từ cờ Changed, và sự phân biệt đó quan trọng. Đường lưu đặt lại Changed trên các object sau khi chúng đã được ghi, nên một phép kiểm tra kiểu “phát văn bản gốc trừ khi đã thay đổi” sẽ bắt đầu phát ra văn bản cũ cho một giá trị đã bị sửa, đã lưu, rồi bị sửa lại trong cùng phiên. Buộc đoạn văn bản gốc gắn với chính phép gán khiến hai thứ đó không thể lệch nhau. Bài kiểm thử hồi quy chốt lại từng hành vi này bằng các giá trị lấy từ chính tệp gốc

uses
  PDFlibStruct;

var
  Structure: TPDFStructure;
  Values: TPDFArray;
  Number: TPDFNumeric;
begin
  Structure := TPDFStructure.Create;
  try
    Structure.PDFPrecNum := 4;
    Values := TPDFArray(Structure.Decode('[2.22221 0.71519 -0.000012345 1 0.12567]'));
    // Đầu vào chưa bị sửa sống sót nguyên văn, kể cả giá trị mà
    // định dạng bốn chữ số sẽ thu gọn thành 0
    Assert(Values.Output = '[ 2.22221 0.71519 -0.000012345 1 0.12567 ]');

    // Một lần sửa vứt bỏ văn bản gốc và đi theo PDFPrecNum
    Number := TPDFNumeric(Values.Item[0]);
    Number.SetTo(0.123456);
    Assert(Number.Output = '0.1235');
    Assert(Structure.NewNumeric(0.123456).Output = '0.1235');

    // Hạ độ chính xác sau đó không chạm tới đầu vào chưa bị sửa
    Structure.PDFPrecNum := 2;
    Assert(TPDFNumeric(Values.Item[1]).Output = '0.71519');
  finally
    Structure.Free;
  end;
end;

Vì sao content model vẫn chuẩn hóa các con số?

Vì TPDFContentProgram cam kết cung cấp các toán hạng số ở dạng chuẩn tắc, và lời hứa đó đáng giá hơn việc giữ nguyên văn bên trong một content stream. Content model có thể chỉnh sửa được, cũng chính là nền tảng của bộ theo dõi graphics state, tồn tại để NormalizeContentStreams, bộ tối ưu hóa và Emit tạo ra đầu ra ổn định, so sánh được từ dữ liệu đầu vào tùy ý. Nếu một toán hạng đã đọc mang theo văn bản gốc vào model, một chuỗi toán tử như 0.50000 0 0 RG sẽ phát ra khác với 0.5 0 0 RG, và mọi phép so sánh ở hạ nguồn sẽ trôi theo thói quen định dạng của từng producer

Thế nên model gỡ văn bản gốc ngay tại hai điểm vào của nó. NormalizeContentNumbers chạy trên từng toán hạng khi parser đẩy nó vào và chạy lần nữa bên trong SetOperand khi văn bản nguồn do bên gọi cung cấp được giải mã, đồng thời đệ quy qua mảng và dictionary để bao được cả dash pattern, mảng TJ và các property dictionary của marked content. Gọi SetTo(AsDouble) trên từng số là đủ, vì đó chính xác là thao tác xóa đoạn văn bản gốc. Dữ liệu ảnh inline thô vẫn được để yên như trước giờ

Vì sao content model của PDFlibPas vẫn chuẩn hóa các con số: NormalizeContentNumbers chạy tại chỗ parser đẩy từng toán hạng vào và chạy lần nữa bên trong SetOperand, đệ quy qua mảng và dictionary để bao được dash pattern, mảng TJ cùng các property dictionary của marked content, và SetTo AsDouble xóa văn bản gốc nên 0.50000 và 0.5 phát ra giống hệt nhau
Toán hạng số chuẩn tắc là lời hứa của content model: dữ liệu ảnh inline thô vẫn được để yên, và những con số trong dictionary nằm ngoài content stream mà chưa bị đụng thì vẫn được bảo đảm nguyên văn, nên một cặp LoadFromFile rồi SaveToFile thông thường vẫn giữ được chúng
// Lib/PDFlibContentModel.pas — content model giữ đúng khế ước của nó
Procedure NormalizeContentNumbers(Obj: TPDFObject);
Var
  K: Integer;
Begin
  If Obj is TPDFNumeric Then
    TPDFNumeric(Obj).SetTo(TPDFNumeric(Obj).AsDouble)
  Else If Obj is TPDFArray Then
    For K:= 0 To TPDFArray(Obj).Count- 1 Do
      NormalizeContentNumbers(TPDFArray(Obj).Item[K])
  Else If Obj is TPDFDictionary Then
    For K:= 0 To TPDFDictionary(Obj).Count- 1 Do
      NormalizeContentNumbers(TPDFDictionary(Obj).Entry[K].Value);
End;

Quy tắc thực dụng cho bên gọi vì thế rất đơn giản. Một lệnh LoadFromFile rồi SaveToFile thông thường để nguyên những content stream chưa bị đụng và những con số trong dictionary chưa bị đụng. Một trang đi qua NormalizeContentStreams, hay bất kỳ chỉnh sửa nào thực hiện qua content model, sẽ ra theo dạng chuẩn tắc đúng như thiết kế, còn phần còn lại của tài liệu vẫn được giữ nguyên. Đó là hai yêu cầu khác nhau, và chúng giờ làm hai việc khác nhau

Cái giá phải trả, và bảo đảm dừng ở đâu

Mỗi TPDFNumeric giờ mang thêm một tham chiếu AnsiString, và mỗi số thập phân đã đọc giữ đoạn văn bản nguồn của nó sống suốt vòng đời object. Với một tài liệu có hàng triệu số thực thì đó là bộ nhớ thật, và nó cần được tính vào mọi phép đo tài liệu lớn thay vì bị gạt đi. Bảo đảm này cũng chỉ trong phạm vi tài liệu của chính con số đó: copy object giữa các tài liệu hay dựng lại giá trị qua object API đều tạo ra những con số mới, và chúng theo độ chính xác đầu ra như mọi con số mới khác. Đáng để nói chính xác bản phát hành này khẳng định và không khẳng định điều gì. Một lần nạp rồi lưu trên tài liệu chưa bị đụng giờ giữ được những con số hiệu chỉnh mà bộ hiển thị thật sự tiêu thụ, và đó là tính chất mà baseline của kho mẫu kiểm tra. Nó không khẳng định đầu ra giống hệt từng byte, vì điều đó còn phụ thuộc vào cách đánh số object, cách nén stream và định danh trong trailer được bàn trong bài về PDF ID tất định. Và nó cũng không làm cho phép so sánh vân tay nhìn ra khác biệt làm tròn trong những tệp do phần mềm khác tạo ra, vì những tệp đó vẫn băm phần thân object đã chuẩn hóa. Bài học này đúng rộng hơn CalRGB nhiều: khi một parser chỉ giữ lại giá trị đã chuyển đổi, mọi lần lưu đều là một lần sửa, và cách duy nhất để nhận ra là nhìn vào kết quả hiển thị. Cách xử lý số và ngữ nghĩa của SetPrecision được ghi lại trên trang sản phẩm losLab PDF Developer Library