Bài viết kỹ thuật

Import annotation FDF trong Delphi: sửa lỗi 0 âm thầm

Trước v3.539.30, TPDFlib.ImportAnnotationsFromFDFString trong losLab PDF Library trả về số entry annotation FDF nó đã parse trong khi chẳng thêm entry nào vào tài liệu: mỗi entry đều được đếm, mỗi entry đều bị vứt. Kể từ v3.539.30, bộ import FDF đọc các khóa theo bất kỳ thứ tự nào, parse /Rect đúng và độc lập với locale, còn exporter khớp với nó ghi /Rect thật của annotation, nên một vòng export, import rồi export lần hai cho ra FDF giống nhau từng byte. Phần còn lại của ghi chú này giải thích một offset khởi đầu sai đã sinh ra một thất bại câm hoàn hảo ra sao, ba khuyết tật khác nào đang nấp sau nó, và cách tự kiểm tra một lần import thay vì tin vào giá trị trả về

Tình huống rất đời thường. Một reviewer chú thích lên hợp đồng, các comment đi lại dưới dạng một tệp FDF (Acrobat gọi là Export Comments), và service Delphi của bạn gộp chúng vào một bản sạch bằng ImportAnnotationsFromFDF. Lệnh gọi trả về 7, log ghi "7 comments imported", job chuyển xanh, còn PDF đầu ra chẳng có lấy một comment. Chẳng có gì được raise, chẳng có gì cảnh báo, và con số nhìn rất hợp lý vì nó đúng là số entry thật trong tệp. Đó là hình dạng tệ nhất một bug có thể có: một hàm mà tín hiệu thành công duy nhất là một bộ đếm được tính độc lập với công việc nó tự nhận là đang báo cáo

Vì sao ImportAnnotationsFromFDFString báo thành công mà chẳng thêm gì vào?

Bộ import đọc mọi /Subtype thành chuỗi rỗng, và helper tạo annotation lại thoát sớm khi gặp subtype rỗng trong khi caller vẫn tăng kết quả. Bộ tìm khóa trả về vị trí ngay sau /Subtype, tức khoảng whitespace phía trước giá trị. ReadName bắt đầu tại dấu cách đó và dừng ở ký tự whitespace đầu tiên, nên nó dừng trước khi đọc được bất cứ gì. AddAnnotationToPage từ chối dựng một annotation thiếu subtype, là lựa chọn phòng thủ đúng đắn nếu xét riêng, nhưng nó là một procedure không có giá trị trả về, còn Inc(Result) lại nằm bên ngoài. Từng chiếc guard đứng riêng đều hợp lý; gộp lại chúng biến "chẳng gì chạy được" thành "mọi thứ đều chạy". Bản sửa làm ReadName nhảy qua whitespace, đòi dấu / mở đầu của một PDF name object, và dừng ở mọi delimiter, kể cả [, ( và ), nên cả /Subtype/Text lẫn /Subtype /Text đều cho ra Text

PDFlibPas ImportAnnotationsFromFDFString tìm thấy /Subtype, cho ReadName bắt đầu ngay khoảng whitespace sau khóa nên nó trả về một tên rỗng, AddAnnotationToPage thoát vì thiếu subtype, còn caller vẫn tăng kết quả, báo bảy comment đã import trong khi chẳng thêm gì vào tài liệu
Từng guard đứng riêng đều hợp lý; gộp lại chúng biến chẳng gì chạy được thành mọi thứ đều chạy, vì thế giá trị trả về không bao giờ được là thứ duy nhất mà một phép test import kiểm tra

Giá trị trả về vẫn đáng để quan tâm ngay cả sau bản sửa đó. Tới v3.539.39, ImportAnnotationsFromFDFString vẫn tăng kết quả cho mỗi dictionary dạng chuẩn trong mảng /Annots, kể cả những entry có /Page 0-based vượt phạm vi hay thiếu /Subtype, những thứ bị bỏ qua. Kể từ PDFlibPas v3.539.40, ImportAnnotationsFromFDFString và ImportAnnotationsFromFDF trả về số annotation thực sự được thêm, giống như import XFDF: helper FDF AddAnnotationToPage giờ trả về Boolean và bộ đếm chỉ nhích khi thành công. Đo trực tiếp tài liệu vẫn là phép kiểm mạnh hơn, vì nó đứng vững cả trên các bản cũ, nên đoạn mã dưới đây so sánh AnnotationCount trên từng trang trước và sau khi import

function TotalAnnotations(Lib: TPDFlib): Integer;
var
  Page, Saved: Integer;
begin
  Result := 0;
  Saved := Lib.SelectedPage;
  for Page := 1 to Lib.PageCount do
    if Lib.SelectPage(Page) = 1 then
      Inc(Result, Lib.AnnotationCount);   // mỗi trang đã chọn, tính cả widget
  Lib.SelectPage(Saved);
end;

var
  Lib: TPDFlib;
  Before, Reported, Added: Integer;
begin
  Lib := TPDFlib.Create;
  try
    Lib.LoadFromFile('contract.pdf', '');
    Before := TotalAnnotations(Lib);
    Reported := Lib.ImportAnnotationsFromFDF('review-comments.fdf');
    Added := TotalAnnotations(Lib) - Before;
    if Added <> Reported then   // bằng nhau kể từ v3.539.40
      Writeln(Format('Importer reported %d, %d landed on a page', [Reported, Added]));
    Lib.SaveToFile('contract-reviewed.pdf');
  finally
    Lib.Free;
  end;
end;

Ba khuyết tật nữa nấp sau khuyết tật đầu tiên

Chỉ sửa subtype thôi cũng sẽ lộ ra ba bug nữa trong cùng hàm đó, mà từng cái đã vô hình chỉ vì chẳng có annotation nào từng chạm tới một trang. Thứ nhất, ReadNumber nhận vị trí của mình như một tham trị, nên đọc bốn số /Rect nối tiếp nhau lại đọc cùng một chỗ bốn lần, và nó không nhảy qua dấu [ mở đầu, nên trên thực tế chẳng đọc được gì cả. Thứ hai, FindKey dùng chung một cursor chỉ đi tới cho mọi phép tra cứu. Exporter ghi /Subtype, /Rect, /Page, /Contents, /T, /Subj, nhưng importer lại tìm theo thứ tự /Subtype, /Contents, /T, /Subj, /Page, /Rect; một khi cursor đã đi qua /Contents, việc tìm /Page và /Rect chạy tràn qua entry hiện tại và hoặc là chẳng tìm thấy gì hoặc khớp nhầm các khóa của annotation kế tiếp. Thư viện không đọc nổi chính output của mình. Thứ ba, các con số đi qua PLStrToFloat, thứ tuân theo dấu thập phân của hệ thống. ISO 32000-1 §12.7.7 định nghĩa FDF là cú pháp object PDF, và các khóa dictionary trong PDF là vô thứ tự (§7.3.7), nên bất kỳ parser FDF nào giả định một thứ tự khóa đều sai ngay từ thiết kế, bất kể công cụ nào đã sinh ra tệp

Bộ import đã sửa chặn biên từng entry trước đã. FindDictEnd đi từ dấu << mở tới dấu >> khớp với nó, lần theo các dictionary lồng nhau và nhảy qua thân chuỗi literal cùng các escape backslash, nên một >> nằm trong một comment kiểu (see section >> 4) không thể chấm dứt entry sớm. Mọi phép tra khóa sau đó đều bắt đầu tại điểm mở đầu của entry và bị giới hạn tới điểm kết thúc của nó, điều khiến thứ tự khóa trở nên bất quan trọng và chặn một annotation mượn /Page của annotation khác. Phép khớp khóa cũng chấp nhận một delimiter đứng ngay sau tên, vì /Contents(Hi) hợp lệ ngang /Contents (Hi), trong khi luật ranh giới từ vẫn giữ /Subj khỏi khớp phần đầu của /Subtype và /T khỏi khớp /Type. ReadNumber giờ nhận vị trí như một tham biến var, nhảy qua whitespace và [, và parse bằng PLTryStrToFloatInvariant, thứ hỏng mềm mại trên một token dị dạng thay vì ném lỗi. Nếu một trong bốn số hình chữ nhật hỏng, cả bốn sẽ rơi về zero thay vì cho ra một hình chữ nhật đọc dở

PDFlibPas FindDictEnd giờ chặn biên mỗi annotation FDF từ dấu << mở tới dấu >> khớp, nên mọi phép tra khóa khởi động lại tại điểm mở đầu của entry và dừng tại điểm kết thúc của nó, còn ReadNumber nhận một vị trí var, nhảy qua dấu ngoặc và parse bằng PLTryStrToFloatInvariant
Cursor dùng chung không đọc nổi chính bản export của thư viện: một khi đã đi qua /Contents, việc tìm /Page và /Rect chui vào các khóa của annotation kế tiếp, nên thứ tự khóa không còn được phép ảnh hưởng gì

Vì sao các vòng round-trip FDF đẩy mọi annotation trôi lên đúng chiều cao của chính nó?

Exporter cũ ghi hình chữ nhật theo một mô hình tọa độ sai. /Rect của một annotation là [llx lly urx ury] trong default user space (ISO 32000-1 §12.5.2, hình chữ nhật được định nghĩa tại §7.9.5), và FDF mang theo đúng mảng đó. ExportAnnotationsToFDFString lại gọi GetAnnotRectEx, thứ báo Left, Top, Width và Height theo tọa độ vẽ của thư viện, không gian mà SetOrigin điều khiển, rồi serialize chúng thành [L T L+W T+H]. Bộ import, một khi đã chạy, ghi lại nguyên văn bốn giá trị đó thành một hình chữ nhật PDF, nên cạnh trên rơi đúng chỗ gốc dưới-trái vốn thuộc về, và mỗi vòng round-trip lại đẩy annotation trồi lên thêm đúng chiều cao của nó. Exporter giờ copy các con số /Rect của chính annotation, ba chữ số thập phân, dấu chấm ngăn cách, không số mũ, và chỉ rơi về hình chữ nhật tính toán khi mảng đã lưu bị thiếu hay không phải bốn con số

PDFlibPas từng serialize FDF /Rect thành left, top, width, height theo tọa độ vẽ, nên import ngược bốn con số đó về llx lly urx ury đặt cạnh trên đúng chỗ gốc dưới-trái vốn thuộc về, và đẩy mọi annotation trồi lên một chiều cao của chính nó sau mỗi vòng round-trip
Exporter giờ copy các con số /Rect của chính annotation — ba chữ số thập phân, dấu chấm ngăn cách, không số mũ — và test hồi quy so sánh bản export thứ hai với bản đầu từng byte một

Test hồi quy ghim chặt chuyện này đáng để bạn copy lại, vì nó assert trên tài liệu và trên một lần export thứ hai, không phải trên giá trị trả về của importer. Để ý số đếm kỳ vọng là 2: AddNoteAnnotation tạo một annotation Text kèm Popup của nó, và cả hai đều đi qua được. Test còn chạy export và import dưới một dấu thập phân phẩy, chính là nơi nửa còn lại của câu chuyện này ngụ

var
  Source, Target: TPDFlib;
  FDF: AnsiString;
  OldSep: Char;
begin
  Source := TPDFlib.Create;
  Target := TPDFlib.Create;
  try
    Source.NewPages(1);                     // giờ là hai trang
    Source.SelectPage(2);
    Source.AddNoteAnnotation(50.5, 60.25, 0, 80, 80, 120, 60,
      'Reviewer', 'Check this', 0.25, 0.5, 0.75, 0);
    Target.NewPages(1);

    OldSep := FormatSettings.DecimalSeparator;
    FormatSettings.DecimalSeparator := ',';   // giả lập một desktop Đức hay Pháp
    try
      FDF := Source.ExportAnnotationsToFDFString;   // vẫn ghi /Rect [50.5 ...
      Target.ImportAnnotationsFromFDFString(FDF);
    finally
      FormatSettings.DecimalSeparator := OldSep;
    end;

    Target.SelectPage(2);
    Assert(Target.AnnotationCount = 2);           // note và popup của nó
    Assert(Target.GetAnnotType(1) = 'Text');
    Assert(Target.ExportAnnotationsToFDFString = Source.ExportAnnotationsToFDFString);
  finally
    Target.Free;
    Source.Free;
  end;
end;

Hãy rõ ràng về những gì con đường FDF mang theo. Importer dựng lại mỗi entry thành một dictionary với /Type, /Subtype, /Rect, /Contents, /T và /Subj; màu, cờ, kiểu viền, liên kết popup và appearance stream không thuộc tuyến này, và exporter bỏ qua các annotation Widget vì form field thuộc về các phương thức form-data. Bản đồ rộng hơn về dữ liệu nào đi qua phương thức nào nằm trong bài tổng quan trao đổi dữ liệu biểu mẫu FDF, XFDF và XFA, còn nếu bạn cần soi xem thực sự cái gì đến nơi, các bộ đọc theo index như GetAnnotType, GetAnnotTitle và GetAnnotContentsEx được đề cập trong introspection outline, annotation và action

Đọc các tệp FDF và XFDF thập phân phẩy từ các bản export cũ thế nào?

Với FDF câu trả lời không mơ hồ: dấu phẩy không phải là một delimiter trong cú pháp PDF, nên một token số chứa đúng một dấu phẩy và không có dấu chấm chỉ có thể là một số thập phân viết trên máy locale-phẩy. Các bản cũ trước đây có ghi những tệp như vậy, ví dụ /Rect [10,500 20,250 40,750 60,125], và ReadNumber mới biến dấu phẩy đơn lẻ đó thành dấu chấm trước khi parse. Một token có hai dấu phẩy, hay một dấu phẩy cộng một dấu chấm, bị từ chối thay vì bị phỏng đoán. Bộ đọc cũng không nhận ký hiệu số mũ, điều khớp với ISO 32000-1 §7.3.3: số PDF không bao giờ dùng nó

XFDF khó hơn, vì trong thuộc tính XML dấu phẩy là ký tự ngăn cách. XFDF chuẩn (ISO 19444-1) ghi rect="50.5,80.25,70.75,100.125" và dashes="4,2", trong khi v3.539.28 và trước đó, trên hệ thống locale-phẩy, ghi rect="50,500 80,250 70,750 100,125" và opacity="0,600", đồng thời văng EConvertError khi đọc một opacity="0.6" chuẩn. Kể từ v3.539.29 cả hai chiều đều invariant, và hình dạng legacy chỉ được XFDFNormalizeLegacyDecimals công nhận khi thuộc tính tách theo whitespace ra đúng số token kỳ vọng (bốn cho rect, một cho opacity và width) và mọi token đều có dạng chữ số-phẩy-chữ số. Một rect chuẩn chẳng bao giờ khớp: hoặc là một token với ba dấu phẩy, hoặc là các token kết thúc bằng dấu phẩy. dashes được cố tình bỏ qua, vì 4,2 có thể là hai độ dài nét đứt hoặc một số legacy 4.2, và chẳng luật nào phân biệt nổi chúng

const
  // Khóa lộn xộn khỏi thứ tự exporter, cùng số thập phân phẩy từ máy locale-phẩy cũ
  LegacyFDF: AnsiString = '%FDF-1.2'#10'1 0 obj'#10'<< /FDF << /Annots ['#10 +
    '<< /Rect [10,500 20,250 40,750 60,125] /Page 0 /Contents (First) ' +
    '/Subtype /Text /T (Alpha) /Type /Annot >>'#10 +
    '] >> >>'#10'endobj'#10'trailer'#10'<< /Root 1 0 R >>'#10'%%EOF'#10;
var
  Lib: TPDFlib;
begin
  Lib := TPDFlib.Create;               // một tài liệu mới có một trang
  try
    Lib.ImportAnnotationsFromFDFString(LegacyFDF);
    Assert(Lib.AnnotationCount = 1);
    Assert(Lib.GetAnnotTitle(1) = 'Alpha');
    // Export lại dưới dạng XFDF với thập phân chấm: rect="10.500 20.250 40.750 60.125"
    Writeln(Lib.ExportAnnotationsToXFDFString);
  finally
    Lib.Free;
  end;
end;

Một phép test import annotation nên assert những gì?

Một test import hữu ích assert trên trạng thái của tài liệu đích, không bao giờ chỉ trên những gì importer nói về chính nó. Chẳng gì trong bộ test từng kiểm tra AnnotationCount sau một lần import FDF, còn giá trị trả về, con số duy nhất mà ai cũng nhìn, lại chính là con số duy nhất mà bug giữ nguyên vẹn. Ba phép assert sẽ bắt được mọi khuyết tật mô tả trong bài: số annotation trên trang kỳ vọng, một trường đọc lại qua GetAnnotType hay GetAnnotContentsEx, và một lần export thứ hai so với lần đầu từng byte một. Kỷ luật tương tự áp dụng cho mọi API ghi đè cấu trúc tài liệu hàng loạt, kể cả việc gộp trường được mô tả trong gộp các form field trùng lặp: hãy kiểm tra cây kết quả, đừng nhìn một tổng được trả về. Các phương thức annotation FDF và XFDF, với các biến thể theo tệp và theo chuỗi, đi kèm trong losLab PDF Library for Delphi và C++Builder, và nếu comment phải sống sót qua chuyến đi thì hãy chạy v3.539.30 trở lên, còn nếu số đếm trả về phải khớp với cái đã thêm thì cần v3.539.40 trở lên