Bài viết kỹ thuật

Page label PDF trong Delphi: sửa number tree /Kids

PDF Library for Delphi ghi các phạm vi page label bằng AddPageLabels, và kể từ v3.539.10, lệnh gọi này cũng chạy được trên các tệp đã nạp mà number tree /PageLabels bị tách thành các node /Kids: root được làm phẳng thành một lá /Nums duy nhất trước khi phạm vi mới đi vào, nhờ đó nhãn thực sự hiện lên trong trình xem thay vì bị bỏ qua trong im lặng. Nạn nhân điển hình là một PDF kiểu sách đến từ công cụ dàn trang, với số La Mã ở phần mở đầu, đánh số Ả Rập ở phần thân và một phụ lục gắn nhãn A-1, A-2, nơi bạn chỉ muốn dán lại nhãn cho phụ lục mà chẳng có gì thay đổi

Page label PDF là gì và được lưu trữ ra sao?

Page label là chuỗi ký tự mà trình xem hiển thị trong ô trang thay cho chỉ số trang vật lý, và ISO 32000-1 §12.4.2 lưu chúng dưới dạng number tree dưới khóa catalog /PageLabels. Mỗi khóa là một chỉ số trang đếm từ 0 mở đầu một phạm vi gắn nhãn, và mỗi giá trị là một dictionary page label với tối đa ba entry: /S cho phong cách đánh số (D, R, r, A hay a), /P cho chuỗi prefix, và /St cho giá trị số của trang đầu trong phạm vi, mặc định là 1. Một phạm vi chạy cho tới khóa kế tiếp, và đặc tả đòi hỏi cây phải chứa giá trị cho chỉ số trang 0, nên mọi trang đều thuộc về một phạm vi nào đó

Cách lưu page label theo thuật ngữ PDFlibPas: number tree /PageLabels lấy khóa cho mỗi phạm vi theo trang bắt đầu đếm từ 0, mỗi giá trị là một label dictionary với kiểu /S, prefix /P và số đầu /St, và ví dụ cuốn sách ánh xạ phần mở đầu La Mã, các trang thân Ả Rập và phụ lục A- vào ba phạm vi
Một phạm vi chạy cho tới khóa kế tiếp, đặc tả đòi hỏi giá trị cho chỉ số trang 0, và GetPageLabel áp dụng phạm vi cuối cùng có khóa nhỏ hơn hoặc bằng trang, nên mọi trang đều phân giải ra được thứ gì đó
var
  Lib: TPDFlib;
begin
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile('handbook.pdf', '') <> 1 then
      Exit;
    // Trang 1-4: i, ii, iii, iv (La Mã thường)
    Lib.AddPageLabels(1, 3, 1, '');
    // Trang 5-120: 1, 2, 3 ... (thập phân)
    Lib.AddPageLabels(5, 1, 1, '');
    // Trang 121 trở đi: A-1, A-2 ... (thập phân có prefix)
    Lib.AddPageLabels(121, 1, 1, 'A-');
    WriteLn(Lib.GetPageLabel(5));    // 1
    WriteLn(Lib.GetPageLabel(122));  // A-2
    Lib.SaveToFile('handbook-labeled.pdf');
  finally
    Lib.Free;
  end;
end;

TPDFlib.AddPageLabels(Start, Style, Offset, Prefix) ánh xạ các tham số của nó vào dictionary ấy mà không có gì bất ngờ một khi bạn thuộc ba luật. Start đếm từ 1 như mọi tham số trang khác trong thư viện và được ghi vào cây dưới dạng Start - 1. Style chạy từ 0 tới 5, trong đó 0 nghĩa là chỉ có prefix và 1 tới 5 thành các giá trị /S D, R, r, A và a; giá trị ngoài khoảng đó trả về 0 và chẳng đụng vào gì. Offset chỉ trở thành /St khi lớn hơn 0, nên truyền 0 đơn giản là bỏ qua khóa và trình xem quay về mặc định 1. Vì page label xuất hiện từ PDF 1.3, lệnh gọi còn chạy EnsureMinVersion('1.3', '/PageLabels'), nâng phiên bản output của tệp cũ hơn trừ khi bạn đã khóa cố định phiên bản lưu

Vì sao page label mới biến mất khi cây có /Kids?

Nhãn mới biến mất vì ISO 32000-1 §7.9.7 (Bảng 37) buộc root của một number tree phải mang hoặc /Kids hoặc /Nums, chưa bao giờ cả hai, mà helper NumTreeSet trước đây chỉ biết tìm /Nums. Các producer phát ra tài liệu dài thường tách cây thành các node trung gian, mỗi node một cặp /Limits, rồi treo chúng vào một root chỉ có /Kids. Code cũ không thấy /Nums trên root đó, dựng một cái mới cạnh /Kids sẵn có, và chèn phạm vi mới vào đó. Kết quả là một root với hai cửa vào loại trừ lẫn nhau. Trình xem đi xuống theo /Kids và chẳng bao giờ liếc tới mảng lạc lõng, EnumNumTree của chính thư viện cũng kiểm tra /Kids trước, còn NumTreeLookup từ chối node mà HasKids xor HasNums là false. AddPageLabels vẫn trả về 1 và tệp lưu ra vẫn mở ngon lành, đúng kiểu thất bại tệ nhất: chẳng thứ nào kêu than, nhãn cứ nguyên như cũ

Bản sửa trong NumTreeSet biến root thành một lá trước khi chèn bất cứ thứ gì. Khi root mang /Kids, EnumNumTree đi qua từng lá theo thứ tự và thu thập mỗi cặp khóa-giá trị, một mảng /Nums phẳng mới được dựng từ danh sách đó, còn /Kids, /Limits và bất kỳ /Nums cũ kỹ nào cũng bị quét sạch khỏi root trước khi mảng phẳng được gắn vào. Bỏ /Limits không phải để đẹp, vì Bảng 37 chỉ cho phép entry đó trên node trung gian và node lá, chưa bao giờ trên root. Từ thời điểm đó, việc chèn là một phép chèn có sắp xếp bình thường vào một mảng duy nhất, và các phạm vi sẵn có sống sót với dictionary nhãn gốc của chúng. Sự đánh đổi là có chủ đích: cây không được dựng lại thành các node /Kids cân bằng sau đó. Với page label thì điều này chẳng tốn gì, vì ngay cả một sổ tay tra cứu dày cộp cũng hiếm khi có quá vài chục phạm vi, và một lá duy nhất vốn là thứ đa số producer viết ra

Sửa chữa number tree trong PDFlibPas: một root mang /Kids cùng mảng /Nums lạc lõng là vô hình với trình xem vì ISO 32000-1 chỉ cho phép một trong hai, nên NumTreeSet làm phẳng mọi lá thành một mảng /Nums duy nhất và quét sạch /Kids cùng /Limits, những thứ Bảng 37 chưa bao giờ cho phép trên root
Chẳng thứ nào kêu than vì mọi phép kiểm tra đều đạt: AddPageLabels trả về 1, tệp lưu ra mở ngon lành, và chỉ có trình đọc đi xuống /Kids trước — cách mà cả trình xem lẫn thư viện đều làm — mới không bao giờ tìm thấy phạm vi mới
// Dán lại nhãn phụ lục trong tệp có root /PageLabels dùng /Kids
if Lib.LoadFromFile('vendor-manual.pdf', '') = 1 then
begin
  WriteLn('Before: ', Lib.GetPageLabel(121));  // ví dụ A-1
  // Thay phạm vi bắt đầu ở trang 121: App-a, App-b ...
  if Lib.AddPageLabels(121, 5, 1, 'App-') = 1 then
    Lib.SaveToFile('vendor-manual-relabeled.pdf');
  // Các phạm vi La Mã và thập phân sẵn có vẫn nằm trong lá đã làm phẳng
  WriteLn('After: ', Lib.GetPageLabel(121));   // App-a
  WriteLn('Front: ', Lib.GetPageLabel(2));     // ii, không đổi
end;

Một mảng /Nums có thể bị đọc nhầm thành khóa ra sao?

Một mảng /Nums bị đọc nhầm khi code đi qua nó từng phần tử một, vì mảng là một dãy phẳng các cặp so le, [key0 value0 key1 value1 ...], và chỉ những vị trí chẵn mới là khóa. Vòng lặp NumTreeSet cũ thử kiểu số trên mọi phần tử, nên một giá trị tình cờ là số bị đem ra so sánh như thể nó là khóa; một cú nhỏ hơn trúng đắn có thể đặt điểm chèn vào một chỉ số lẻ và thả cặp mới vào giữa một cặp có sẵn, đẩy mọi cặp phía sau lệch pha. EnumNumTree mang cùng kiểu đi từng bước một. Cả hai giờ lặp theo cặp với bước nhảy hai, đọc khóa ở X * 2 và giá trị ở X * 2 + 1, và một khóa trùng khớp chính xác sẽ thay giá trị rồi thoát bằng Break. Nói công bằng, giá trị page label là dictionary, nên lỗi thứ hai này hiếm khi nổ ra trên /PageLabels đích thân, nhưng một helper number tree đọc sai bước nhảy thì hỏng ngay khi có bất kỳ giá trị nào là số, và nó được sửa trong cùng đợt

Sửa bước nhảy cặp trong number tree của PDFlibPas: mảng /Nums là một dãy phẳng các entry khóa và giá trị so le, nên kiểu đi thử mọi phần tử có thể chèn cặp mới vào chỉ số lẻ và đẩy các cặp phía sau lệch pha, trong khi kiểu đi đã sửa đọc khóa ở X*2 và giá trị ở X*2+1
Lỗi hiếm khi nổ trên /PageLabels vì giá trị nhãn là dictionary, nhưng helper number tree đọc sai bước nhảy thì hỏng ngay khi có giá trị nào là số, nên cả hai kiểu đi giờ bước theo cặp

Đọc lại nhãn và round-trip chúng

TPDFlib.GetPageLabel(Page) trả về nhãn cho trang đếm từ 1 và có hai đường lùi đáng biết. Khi chẳng có entry /PageLabels nào, nó trả về số trang thập phân, nên bên gọi có thể dùng nó vô điều kiện. Khi cây có mặt nhưng không phạm vi nào phủ trang đó, nó trả về chuỗi rỗng, chính là điều xảy ra khi một tệp bỏ sót entry chỉ số 0 bắt buộc; tài liệu tham chiếu nói rằng một phạm vi bắt đầu ở trang 1 phải tồn tại để nhãn hiển thị đúng, và code biến yêu cầu đó thành có thể nhìn thấy. Kiểu chữ cái theo đặc tả chứ không theo cột bảng tính: sau Z là AA, rồi BB, lặp lại chữ cái thay vì carry

var
  P: Integer;
  Data: WideString;
begin
  // Kiểm tra nhanh trình xem sẽ hiển thị gì trong ô trang
  for P := 1 to Lib.PageCount do
    WriteLn(P, ' -> ', Lib.GetPageLabel(P));

  // Giá trị option 4 chỉ xuất các phạm vi nhãn thành record PageLabelBegin
  Data := Lib.ExportDocumentData(4);
  // Khi import, chúng được chạy lại qua ClearPageLabels + AddPageLabels
  Lib.ImportDocumentData(Data, 0);
end;

Với sửa hàng loạt, ExportDocumentData với giá trị option 4 ghi mỗi phạm vi thành một khối PageLabelBegin với các dòng PageLabelNewIndex, PageLabelStart, PageLabelPrefix và PageLabelNumStyle, còn ImportDocumentData coi record nhãn đầu tiên nó thấy là một sự thay thế trọn vẹn: gọi ClearPageLabels một lần rồi nạp từng record cho AddPageLabels. Điều đó khiến một round trip văn bản mang tính tất định ngay cả khi tệp gốc dùng cây /Kids, vì việc xóa bỏ dọn sạch entry catalog và cây dựng lại ngay từ đầu đã là một lá duy nhất

Bản sửa vẫn không đảm bảo điều gì?

Việc làm phẳng là một chiều và tin vào thứ tự nó tìm thấy. EnumNumTree thu thập các cặp theo thứ tự tệp, và GetPageLabel áp dụng phạm vi cuối cùng có khóa nhỏ hơn hoặc bằng chỉ số trang, nên một tệp lạ có các lá không theo thứ tự — điều §7.9.7 cấm nhưng vẫn lưu hành ngoài kia — vẫn có thể cho ra nhãn sai cho tới khi bạn dựng lại các phạm vi bằng ClearPageLabels và những lệnh gọi AddPageLabels mới. Nhãn còn gắn với chỉ số trang chứ không gắn với object trang, nên bất kỳ thao tác nào đổi số trang hay thứ tự trang đều để nguyên các phạm vi ở chỗ cũ. Một phép hoán đổi tại chỗ như thay trang mà giữ nguyên số object giữ được số lượng và do đó các nhãn vẫn khớp, trong khi một phép gộp như collate các bản scan duplex cài xen tạo ra một trình tự trang mới xứng đáng có một bộ phạm vi được viết tươi

Các lệnh gọi page label, phần xử lý number tree và xuất/nhập document data mô tả trong bài đều có mặt trong PDF Library for Delphi cho Delphi, C++Builder và Lazarus, với entry tham chiếu của AddPageLabels ghi chú các giá trị style và mã trả về