Bài viết kỹ thuật

Số PDF độc lập locale cho ứng dụng Delphi máy thập phân phẩy

PDFlibPas, losLab PDF Developer Library for Delphi, ghi mọi con số nó đưa vào một content stream bằng dấu thập phân chấm và không số mũ, bất kể regional settings của Windows nói gì. Kể từ v3.539.26, AddPageMatrix, ScalePage, DeskewPage, RedactRegion, output text-to-path và việc đổi màu đều format toán hạng qua PLDoubleToStrConst, và kể từ v3.539.33, các parser đọc ngược những con số đó dùng PLTryStrToFloatInvariant thay cho system locale. Trên một máy Đức, Pháp hay Brazil, cùng đoạn code giờ cho ra cùng những byte như trên một máy Mỹ, và đó là hành vi duy nhất mà một định dạng tệp có thể chấp nhận

Vì sao một locale thập phân phẩy làm hỏng PDF mà không văng lỗi?

Locale thập phân phẩy làm hỏng PDF một cách câm lặng vì dấu phẩy không phải là ký tự số trong cú pháp PDF, nên thiệt hại đọc ra vẫn như những token hợp lệ nhưng mang nghĩa sai. Trước bản sửa, PLFloatToStr chẳng qua chỉ là một lệnh gọi FloatToStr trần trụi, còn FloatToStr thì nghe theo FormatSettings.DecimalSeparator. Với dấu phẩy làm separator, AddPageMatrix(0.5, 0.5, 0, 0) ghi ra 0,5 0 0 0,5 0 0 cm. ISO 32000-1 §7.3.3 chỉ cho phép chữ số, một dấu chấm và một dấu trước trong một con số và không hơn, nên parser content đọc dòng đó là số 0 theo sau bởi một token lạ ,5, và toán tử cm kết thúc với toán hạng sai. Chẳng gì văng, chẳng gì log. Trang đơn giản render với một transformation matrix đã trôi, và lần ngược từ một bản vẽ lệch chỗ về một thiết lập locale là một buổi chiều khổ sở

Khuyết tật thứ hai nấp sau cái thứ nhất. FloatToStr dùng format ffGeneral, thứ chuyển sang ký hiệu số mũ một khi độ lớn rơi dưới 1E-4, nên một offset bé xíu lại ra thành 1E-5. Chính §7.3.3 đó còn nói PDF không hỗ trợ dạng số mũ, nghĩa là kể cả một máy locale Mỹ cũng có thể ghi một toán hạng vô hiệu nếu giá trị đủ nhỏ. Các test hồi quy cho bản phát hành này ghim cả hai hình dạng thất bại: chúng lật separator sang dấu phẩy, gọi API và quét content kết quả tìm bất kỳ token nào chứa dấu phẩy hay số mũ

uses
  System.SysUtils, PDFlibrary;

var
  Lib: TPDFlib;
  OldSeparator: Char;
begin
  Lib := TPDFlib.Create;
  try
    Lib.SetPageDimensions(300, 200);
    Lib.DrawBox(10, 10, 20, 20, 1);
    OldSeparator := FormatSettings.DecimalSeparator;
    try
      FormatSettings.DecimalSeparator := ',';   // giả lập một desktop de-DE
      Lib.AddPageMatrix(0.5, 0.25, 1E-5, 12.75);
      // v3.539.26 trở đi ghi: 0.5 0 0 0.25 0.00001 12.75 cm
      // các bản cũ ghi:       0,5 0 0 0,25 1E-5 12,75 cm
      Writeln(Lib.GetPageContentToString);
    finally
      FormatSettings.DecimalSeparator := OldSeparator;
    end;
  finally
    Lib.Free;
  end;
end;
PDFlibPas AddPageMatrix trên desktop thập phân phẩy ghi 0,5 0 0 0,25 1E-5 12,75 cm trước bản sửa, thứ parser PDF đọc là số 0 cộng các token lạ, để cm với toán hạng sai và trang bị biến đổi thầm lặng, trong khi PLDoubleToStrConst ghi số thập phân chấm hợp lệ
Dấu phẩy không phải ký tự số trong cú pháp PDF, nên thiệt hại đọc ra như những token hợp lệ mang nghĩa sai — còn số mũ ffGeneral kiểu 1E-5 thì vô hiệu trên mọi locale, không chỉ máy phẩy

Hai loại số, hai họ helper

Bản sửa trong PDFlibPas là một sự chia tách nghiêm ngặt: số đưa cho người xem có thể theo locale, còn số ghi cho máy thì không bao giờ. PLFloatToStr và PLStrToFloat ở lại trong PDFlibExtra.pas cho văn bản hướng người dùng, và phần khai báo của chúng giờ mang một comment nói đúng điều đó. Mọi thứ kết thúc thành cú pháp PDF đều đi qua PLDoubleToStrConst với số chữ số thập phân cố định chọn theo việc: sáu cho matrix, bốn cho tọa độ và các bước nhảy TJ, ba cho màu và hình chữ nhật FDF. Cuộc audit cho v3.539.26 chạm nhiều call site hơn bản báo cáo bug gốc gợi ý:

  • AddPageMatrix, ScalePage và DeskewPage, những phương thức đều đặt một cm lên đầu content sẵn có của trang
  • Các bộ dựng phần tử trang phát ra những phép reset Tm, bước nhảy TJ và phép biến đổi cm
  • Các matrix đặt glyph và các điểm outline trong bộ chuyển text-to-path
  • Hộp tô đen mà RedactRegion đặt lên đầu, các giá trị /Rect trong export FDF và các toán hạng mà việc đổi màu ghi ra

PLDoubleToStrConst là một formatter viết tay thay vì một lớp bọc quanh FloatToStrF, và ba tính chất của nó quan trọng ở đây. Nó luôn ghi dấu chấm và cắt các zero đuôi, nên 0.5 vẫn là 0.5 chứ không phải 0.500000. Nó không bao giờ ghi số mũ cho input hữu hạn. Và một giá trị khác zero nhỏ hơn độ chính xác được yêu cầu giữ lại các chữ số có nghĩa thay vì sụp về zero, nên PLDoubleToStrConst(1E-9, 6) trả về 0.000000001; chỉ các giá trị dưới cỡ 5E-16 mới thành 0. Luật cuối cùng tồn tại vì làm tròn một hệ số scale bé xíu về zero biến một matrix hợp lệ thành matrix suy biến, một bug tệ hơn cái đang được sửa

PDFlibPas chia định dạng số làm hai: PLFloatToStr và PLStrToFloat bám locale cho văn bản hướng người dùng, còn PLDoubleToStrConst và PLTryStrToFloatInvariant format mọi thứ thành cú pháp PDF bằng dấu chấm, không số mũ, độ chính xác cố định theo việc sáu, bốn hay ba chữ số
Formatter invariant được viết tay một cách có chủ đích: nó cắt zero đuôi, không bao giờ ghi số mũ, và giữ các chữ số có nghĩa của giá trị bé, vì làm tròn hệ số scale về zero sẽ biến một matrix hợp lệ thành suy biến
uses
  PDFlibExtra;

procedure CheckNumberHelpers;
var
  V: Double;
begin
  // Output máy: thập phân chấm, không số mũ, zero đuôi bị cắt
  Assert(PLDoubleToStrConst(1E-5, 6) = '0.00001');
  Assert(PLDoubleToStrConst(-0.5, 6) = '-0.5');
  Assert(PLDoubleToStrConst(0.000012346, 4) = '0.00001235');  // giữ 4 chữ số có nghĩa
  Assert(PLDoubleToStrConst(12345.25, 4) = '12345.25');

  // Input máy: hỏng mềm thay vì EConvertError
  Assert(PLTryStrToFloatInvariant('0.5', V) and (V = 0.5));
  Assert(not PLTryStrToFloatInvariant('0,5', V));   // số content không bao giờ dùng dấu phẩy
end;

Vì sao phía parse nguy hiểm hơn phía ghi?

Phía parse nguy hiểm hơn vì một parser bám locale không sinh ra một con số sai, nó ném lỗi. PLStrToFloat gọi StrToFloat, thứ văng EConvertError khi văn bản không khớp separator của hệ thống. Trên hệ thống thập phân phẩy, điều đó có nghĩa RecolorPage bỏ cuộc ngay lúc gặp một toán tử 0.5 g tầm thường, nên mọi trang ngoài đời thực đều thất bại, chứ chẳng phải chỉ những trang kỳ dị. RenderPageRegionToFile từ chối chính định dạng clip mà tài liệu của nó mô tả, "10.5,20.5,50.5,40.5", còn các thuộc tính độ dài SVG, màu export SVG, danh sách đỉnh annotation và giá trị solidity của output intent hoặc bị từ chối hoặc bị thay thầm lặng bằng mặc định. Một thư viện chạy hoàn hảo trên máy developer và hỏng ngay khách hàng đầu tiên ở Munich chính là kiểu code, như các ca trong bài viết về code Delphi chạy được một cách tình cờ, chỉ có vẻ đúng nhờ nơi nó được test

v3.539.33 phân loại mọi lệnh gọi StrToFloat và TryStrToFloat theo nguồn gốc input của nó. Toán hạng content stream, thuộc tính SVG, chuỗi màu painter và các danh sách clip cùng đỉnh phân tách bằng phẩy đều có cú pháp chấm cố định, nên chúng giờ đi qua PLTryStrToFloatInvariant, thứ cắt khoảng trắng hai đầu, parse bằng PLInvariantFormatSettings và trả về False với input rỗng, dị dạng hay không hữu hạn, thay vì ném lỗi. Một danh sách phân tách bằng phẩy chẳng chừa chỗ cho thỏa hiệp nào, vì dấu phẩy không thể vừa là delimiter danh sách vừa là dấu thập phân. Cùng đợt đó còn sửa một lỗi ghi tràn bộ đệm: RenderPageRegionToFile từng lưu một giá trị clip thứ năm vượt quá buffer bốn phần tử của nó. Với pipeline đổi màu được mô tả trong hướng dẫn chuyển một PDF về một color space, kết quả thực dụng là RecolorPage và RecolorDocument không còn bỏ cuộc trên hệ thống thập phân phẩy. Các giá trị luật mà caller gõ vào CheckDocumentPolicy là ca parse duy nhất dùng helper khoan dung thay vào, vì lý do mục kế tiếp giải thích

Chỉ sửa một đầu của vòng round-trip thì chuyện gì xảy ra?

Chỉ sửa một đầu của một vòng round-trip locale sẽ làm hỏng code vốn chạy tốt, nên thay đổi thuộc tính structure trong v3.539.32 dời writer và reader cùng nhau. Các wrapper SetStructElem* chuyền số đi dưới dạng chuỗi: SetStructElemBBox format bốn giá trị vào một chuỗi, lưu nó qua AddTagAttribute, và writer /A sau đó parse chuỗi đó để quyết định nó thành một số, một mảng hay một name. Cả hai đầu đều dùng system locale, nên trên hệ thống thập phân phẩy vòng round-trip tự nhất quán. Bug chỉ lộ khi một caller nghe theo tài liệu và truyền "0.5" vào AddTagAttribute: reader không parse nổi và phát ra PDF name /0.5. Placeholder PDF/VCR gặp vấn đề hình ảnh đối xứng, vì thư viện sinh GTS_BBox bằng dấu chấm rồi lại xác thực nó bằng locale trước khi lưu

Đổi một mình writer sang dấu chấm đã tệ hơn là không làm gì, vì mọi giá trị SetStructElem* khi đó sẽ rớt reader bám locale và thoái hóa thành name. Nên writer giờ dùng PLDoubleToStrConst(v, 6), còn reader dùng PLTryStrToFloatLenient mới, thứ thử dạng chấm trước rồi mới rơi về system locale. Một caller máy locale-phẩy từng truyền "1,25" trong quá khứ vẫn nhận được số 1.25. Sự đánh đổi là có chủ đích và đã được ghi lại: trên hệ thống Đức, "1.500" từng thành name vì StrToFloat từ chối dấu ngăn hàng nghìn, và giờ nó đọc là 1.5, còn các chuỗi literal NAN và INF không còn được nhận làm số

PDFlibPas SetStructElemBBox và các anh em chuyền số đi dưới dạng chuỗi qua AddTagAttribute, và writer /A parse lại những chuỗi đó, nên v3.539.32 dời cả hai đầu cùng nhau: PLDoubleToStrConst ghi bằng dấu chấm, PLTryStrToFloatLenient đọc chấm trước rồi mới fallback về locale, nên 1,25 từ máy locale-phẩy vẫn đọc là 1.25
Chỉ sửa writer sẽ thoái hóa mọi thuộc tính phần tử cấu trúc thành PDF name, nên một vòng round-trip phải dời cả hai đầu cùng nhau hoặc đừng dời
uses
  System.SysUtils, PDFlibrary;

var
  Lib: TPDFlib;
begin
  FormatSettings.DecimalSeparator := ',';   // caller máy thập phân phẩy
  Lib := TPDFlib.Create;
  try
    Lib.BeginTag('Figure', 'Sales chart', '');
    Lib.SetStructElemBBox(10.5, 20.25, 200.5, 100.75); // /BBox [ 10.5 20.25 200.5 100.75 ]
    Lib.AddTagAttribute('Layout', 'SpaceAfter', '0.5');   // /SpaceAfter 0.5, was /0.5
    Lib.AddTagAttribute('Layout', 'StartIndent', '1,25'); // still /StartIndent 1.25
    Lib.DrawText(20, 20, 'figure');
    Lib.EndTag;
    Lib.SaveToFile('tagged.pdf');
  finally
    Lib.Free;
  end;
end;

Nơi NaN và vô cùng bị chặn lại

AddPageMatrix, ScalePage và RedactRegion giờ từ chối NaN và các tham số vô cùng ngay từ đầu và trả về 0, vì chẳng con số PDF nào biểu diễn được chúng. ScalePage vốn đã từ chối hệ số bằng 0 hay âm, nhưng NaN vượt qua phép thử <= 0, nên một scale NaN từng đi hết đường tới formatter. Ở v3.539.26 formatter đó vẫn gọi Round trên NaN, thứ văng EInvalidOp trên Win32 nơi đơn vị x87 không mask các phép toán vô hiệu; v3.539.31 làm PLDoubleToStrConst ghi 0 cho NaN như một tuyến phòng thủ cuối, nhưng một số 0 trong matrix là một phép biến đổi suy biến, nên phép kiểm ở tầng API mới là bản sửa thật sự. Hai biên giới được giữ nguyên một cách có chủ đích. Chuỗi trạng thái metafile được ghi và đọc bằng locale bên trong một process và không bao giờ rời khỏi nó, nên chúng được để yên. Và một test format 1E-5 qua đường phần tử trang phải đọc content trước khi layer được viết lại, vì phát lại các toán hạng ở độ chính xác tài liệu một cách hợp pháp biến giá trị đó thành 0

Nếu ứng dụng của bạn đến tay khách hàng ngoài thế giới thập phân chấm, thói quen an toàn nhất là cái mà bộ test PDFlibPas giờ dùng: chạy các đường sinh PDF một lần với FormatSettings.DecimalSeparator đặt thành dấu phẩy và quét output tìm dấu phẩy cùng số mũ. Bài viết về giữ độ chính xác thập phân đã parse bàn nửa còn lại của cùng câu chuyện, cách những con số đọc từ tệp có sẵn giữ nguyên văn bản chính xác của chúng khi lưu. Tải xuống, tài liệu API đầy đủ và bản dùng thử nằm trên trang sản phẩm PDFlibPas Delphi PDF library