Bài viết kỹ thuật

Xem lại chú thích PDF trong Delphi bằng PDFium Component

Một annotation PDF là một dictionary gắn vào một trang, không phải một vết vẽ lên trang đó. ISO 32000-1 §12.5 định nghĩa khoảng hai chục subtype, và mỗi cái mang theo một /Subtype, một hình chữ nhật theo tọa độ trang, một tập cờ, và thường là một appearance stream quyết định viewer thực sự vẽ ra cái gì. Các subtype đó không mang cùng một ý nghĩa với một người đang review tài liệu. Một Highlight và một nét Ink là comment; một Link là điều hướng; một Popup là cửa sổ nhỏ mở ra khi bạn click vào một sticky note, được lưu như một object riêng và được một parent trỏ tới. Reply là các annotation Text đầy đủ tham chiếu tới comment mà chúng trả lời thông qua một mục in-reply-to. Vậy nên mảng annotation ở cấp trang không phải là danh sách comment của người review. Đó là một cái túi phẳng chứa comment, phần ống dẫn nối chúng lại, và vài thứ mà chẳng người review nào gọi là comment cả. Một panel coi cả mảng đó là danh sách comment sẽ bất đồng với mọi viewer khác mà khách hàng chạy

Dựng một quy trình review annotation trên PDFium Component, component VCL/LCL dựa trên PDFium cho Delphi, C++Builder, và Lazarus, nghĩa là tập trung vào những điểm mà khoảng cách giữa mảng thô và góc nhìn con người gây rắc rối: đếm, đánh chỉ mục, đổi màu các vết mà engine đã đóng băng sẵn, xóa mà không để lại bóng ma, và thêm các vết của riêng bạn

Sơ đồ cho thấy một bảng phản duyệt Delphi PDFium lọc mảng annotation trang thô gồm comment, popup, trả lời và liên kết thành danh sách comment tinh chọn mà người phản duyệt nhìn thấy
Mảng annotation của trang trộn lẫn comment với popup, trả lời, liên kết và các dấu ẩn, nên một panel rà soát cần một quy tắc đếm trước khi hiển thị tổng số

Vì sao số đếm của bạn không bao giờ khớp với ô comment của Acrobat

Mở một hợp đồng đã đánh dấu trong viewer của bạn và trong Acrobat cạnh nhau và tổng số hiếm khi khớp nhau. Acrobat hiển thị một góc nhìn đã được biên soạn: markup được gom vào các luồng reply, popup được gấp vào ghi chú mà chúng thuộc về, link và form widget bị loại ra. Mảng thô giữ tất cả những thứ đó không phân biệt, nên một lượt đếm ngây thơ vừa cao hơn ở chỗ này vừa thấp hơn ở chỗ khác cùng một lúc

Popup thổi phồng tổng số, vì mỗi sticky note đi kèm một object Popup riêng và đếm cả hai sẽ nhân đôi ghi chú đó. Reply lại làm giảm tổng số nếu bạn lọc theo các vết hiển thị, vì một reply là một annotation Text không vẽ ra gì cả cho tới khi ai đó mở rộng luồng, và bỏ nó đi sẽ làm mất cả cuộc thảo luận. Các cờ Hidden và NoView đưa một annotation ra khỏi màn hình mà không đưa nó ra khỏi mảng, nên một lượt đếm mù cờ sẽ tính cả những vết mà người dùng không thấy được. Các annotation Link nằm trong cùng mảng với comment và không thuộc về cả số đếm lẫn danh sách. Hãy quyết định quy tắc đếm trước khi viết vòng lặp, và ghi lại quyết định đó, vì "tại sao panel của bạn hiện một con số khác với Acrobat" là ticket đầu tiên mà một tính năng review kiếm được

Đánh chỉ mục mọi thứ một lần, rồi không bao giờ phân tích lại một trang

Một quy tắc thiết kế chi phối mọi thứ theo sau: lọc theo tác giả, theo loại, hay theo trang không bao giờ được phân tích lại các page object. Trên một tài liệu 300 trang với markup dày đặc, phân tích lại mỗi lần đổi dropdown biến panel thành thứ khựng lại từng giây một. Component này phơi ra AnnotationCount và thuộc tính đã đánh chỉ mục Annotation[], cả hai đều giới hạn trong phạm vi trang đang nạp, và bản ghi TPdfAnnotation mà chúng trả về mang theo những gì một list view cần: Subtype, Flags, Color, Rectangle, ContentsText, AuthorText. Nước đi đúng là quét qua mọi trang một lần duy nhất lúc mở tệp rồi giữ chỉ mục phẳng của riêng bạn:

procedure TReviewPanel.BuildIndex;
var
  PageNo, i: Integer;
  A: TPdfAnnotation;
begin
  FItems.Clear;
  for PageNo := 1 to Pdf.PageCount do
  begin
    Pdf.PageNumber := PageNo;
    for i := 0 to Pdf.AnnotationCount - 1 do
    begin
      A := Pdf.Annotation[i];
      // Chỉ giữ các subtype liên quan tới người review; ghi lại cặp
      // trang và chỉ mục vì mọi chỉnh sửa sau này đều định vị theo cặp đó
      if A.Subtype in [anText, anHighlight, anInk] then
        FItems.Add(TReviewItem.Create(PageNo, i,
          A.AuthorText, A.ContentsText, A.Rectangle, A.Color));
    end;
  end;
end;

Cặp đáng gạch chân là (PageNo, i). Mọi lần chỉnh sửa sau này, dù là đổi màu hay xóa, đều được định vị bằng số trang cộng với chỉ mục annotation, và chỉ mục đó mong manh: xóa một annotation sẽ đánh số lại mọi thứ đứng sau nó trên trang đó. Vậy nên hãy lên kế hoạch dựng lại các mục của trang bị ảnh hưởng sau mỗi lần xóa thay vì vá số chỉ mục tại chỗ. Việc dựng lại đó tốn một mili giây. Ngược lại, một chỉ mục đã cũ sẽ xóa nhầm comment của người review khác, đây là kiểu lỗi bào mòn lòng tin vào cả tính năng

Threading xứng đáng có một chỗ trong chỉ mục dù bản phát hành đầu tiên của bạn chỉ đếm reply thay vì hiển thị chúng. Hãy gom các mục theo tham chiếu parent của chúng trong lúc bạn đang mở trang đó, để panel sau này có thể gấp một luồng lại theo cách Acrobat làm. Dựng lại việc gom nhóm đó một cách lười biếng trong lúc cuộn đánh bại toàn bộ mục đích của việc đánh chỉ mục một lần, vì nó mở lại những trang bạn đã trả giá để phân tích rồi. Hình học cũng đòi hỏi cùng một kỷ luật như vậy. Rectangle trong mỗi bản ghi là theo không gian trang, và việc chuyển nó sang tọa độ view nên nằm trong một helper dùng chung duy nhất, không rải rác khắp code. Panel sinh ra lỗi tọa độ khi việc chọn, hit-testing, và vẽ mỗi thứ tự phát minh ra toán zoom và xoay riêng của mình; hãy dẫn cả ba qua một phép chuyển đổi duy nhất và một highlight, dòng của nó trong danh sách, và mục tiêu click của nó sẽ luôn gắn chặt vào cùng một nét mực

Đổi màu markup và quyền phủ quyết của appearance stream

Đổi một highlight từ vàng sang hổ phách nghe có vẻ như một dòng lệnh đơn giản, và đôi khi đúng là vậy. Cái bẫy nằm ở ISO 32000-1 §12.5.5. Khi một annotation mang theo một appearance stream /AP, một viewer tuân thủ chuẩn sẽ vẽ chính stream đã dựng sẵn đó và coi mục màu trong dictionary như metadata đã chết. Acrobat viết appearance stream cho gần như mọi thứ nó tạo ra, nên hầu hết annotation đến từ khách hàng đã ở trạng thái này sẵn, và màu bạn tự tin đặt vào chẳng bao giờ chạm tới màn hình cả. Đổi màu là một lượt đọc-sửa-ghi qua thuộc tính Annotation[], và component này trung thực về xung đột đó: khi engine từ chối để một màu trong dictionary ghi đè lên một appearance đã nướng sẵn, lượt ghi đó sẽ ném ra EPdfError

Sơ đồ đường tái màu đọc-sửa-ghi trong một component Delphi PDFium, nơi một appearance stream đã nướng cứng phủ quyết màu của từ điển và ném EPdfError
Khi một annotation mang sẵn stream /AP, engine từ chối màu trong dictionary và raise EPdfError, nên panel tự tô màu lại lớp phủ của mình hoặc đánh dấu hàng là appearance-locked
A := Pdf.Annotation[Item.Index];
A.HasColor := True;
A.Color := $0000B0FF;       // hổ phách
A.ColorAlpha := 160;
try
  Pdf.Annotation[Item.Index] := A;
except
  on EPdfError do
  begin
    // Annotation sở hữu một appearance stream /AP đã dựng sẵn; chỉ riêng
    // màu trong dictionary không thể thay đổi những gì viewer vẽ ra
    Item.AppearanceLocked := True;
    StatusBar.SimpleText := 'Color is fixed by the annotation appearance';
  end;
end;

Hãy bắt exception đó mỗi lần, và coi nó như thông tin chứ không phải thất bại. Bỏ qua lớp bảo vệ đó và panel của bạn sẽ vô tư hiện màu hổ phách trong danh sách riêng của nó trong khi trang vẫn cứ vẽ màu vàng; người dùng sẽ báo lỗi đó vài tuần sau với nội dung "viewer của bạn phớt lờ chỉnh sửa của tôi," và bạn tốn cả một buổi chiều thất bại trong việc tái hiện nó trên một tệp tình cờ không có appearance stream nào. Một khi bạn biết appearance đã bị khóa, bạn có hai phản ứng trung thực: đổi màu lớp overlay chọn của riêng bạn thay vì đổi màu annotation, để người review ít nhất cũng thấy được highlight họ đã chọn, hoặc đánh dấu dòng đó là appearance-đã-khóa để không ai kỳ vọng thay đổi đó sẽ giữ nguyên

Xóa annotation mà không để lại bóng ma

DeleteAnnotation gỡ object đó khỏi cây annotation của trang hiện tại, nhưng nó để nguyên raster trang đã cache. Vẽ ngay sau lệnh gọi đó và highlight đã xóa vẫn còn trên màn hình, nằm trong một bitmap không còn khớp với model tài liệu đứng sau nó nữa. Cách sửa là coi việc render lại như một phần của việc xóa, không phải một bước mà bên gọi có thể quên:

Sơ đồ chu trình xóa Delphi PDFium ba bước gỡ annotation, render lại trang với reAnnotations và dựng lại chỉ mục trang
Xóa chỉ chạm tới cây annotation, nên panel phải render lại với reAnnotations và dựng lại các entry trang trước khi màn hình và chỉ mục trung thực trở lại
Pdf.PageNumber := Item.PageNo;
Pdf.DeleteAnnotation(Item.Index);   // ném EPdfError nếu thất bại
Bmp := Pdf.RenderPage(0, 0, ViewWidth, ViewHeight, ro0, [reAnnotations]);
try
  PaintPageBitmap(Bmp);
finally
  Bmp.Free;  // RenderPage trao quyền sở hữu bitmap cho bên gọi
end;
RebuildPageEntries(Item.PageNo);  // các chỉ mục sau Item.Index đã dịch chuyển

Hai chi tiết trong khối đó dễ làm sai. Tùy chọn reAnnotations phải có mặt, nếu không raster mới sẽ làm rơi mọi annotation còn lại và trang trông như thể bạn đã xóa sạch cả bộ comment thay vì một vết duy nhất. Và Bmp.Free không phải là tùy chọn: overload kiểu hàm của RenderPage trao quyền sở hữu bitmap cho bên gọi, nên thiếu một lệnh free sẽ rò rỉ một raster toàn trang ở mỗi lần xóa, và một người review làm việc xuyên suốt một tài liệu dài sẽ biến điều đó thành áp lực bộ nhớ thực sự chỉ trong vài phút

Thêm các vết của người review từ UI của riêng bạn

Tạo annotation đi qua CreateAnnotation, nhận một bản ghi TPdfAnnotation đã điền sẵn (subtype, hình chữ nhật, màu, nội dung, tác giả) rồi gắn nó vào trang hiện tại. Một sticky note, subtype anText, là trường hợp dễ: đặt vị trí, nội dung, và tác giả là xong. Annotation Ink mới là chỗ người ta hay mắc kẹt. Hình chữ nhật của bản ghi chỉ giới hạn phạm vi vẽ; bản thân các nét vẽ là các mảng điểm phải được gắn riêng thông qua lệnh gọi ink-stroke của engine, FPDFAnnot_AddInkStroke nhận dữ liệu FS_POINTF, được thu thập từ đầu vào chuột hay bút từng nét một. Dựng một annotation Ink chỉ từ một hình chữ nhật mà không có gì khác và bạn sẽ nhận về một nét nguệch ngoạc trống rỗng render ra như một khoảng trống, trông giống một lỗi trong engine nhưng thực ra là một annotation làm dở dang

Hãy chốt luôn chính sách về tác giả trong cùng một hơi. Mọi vết mà UI của bạn tạo ra nên mang theo một AuthorText nhất quán, vì bộ lọc theo người review mà bạn dựng tháng sau chỉ tốt bằng những cái tên bạn đóng dấu lên comment hôm nay. Các chuỗi tác giả trống rỗng hay không nhất quán không thể sửa lại hồi tố mà không mở lại từng tệp một

Đưa review ra khỏi viewer

Dữ liệu review chỉ xứng đáng công sức khi nó có thể rời khỏi viewer, dưới dạng một bản tóm tắt mà trưởng dự án đọc mà không cần mở tệp, hoặc một CSV nuôi một sheet theo dõi. Hãy export từ chỉ mục bạn đã dựng sẵn, không bao giờ từ một lượt phân tích mới, và chọn một cách ổn định để trỏ ngược lại từng vết. Một số trang kết hợp với hình chữ nhật của annotation sống sót qua các vòng round-trip mà một chỉ mục mảng không sống sót nổi, vì lần xóa kế tiếp sẽ âm thầm đánh số lại các chỉ mục và CSV của bạn bắt đầu trỏ nhầm sang comment khác

Một dòng đáng giữ lại mang theo số trang, subtype, tác giả, dấu thời gian tạo khi tệp có ghi lại, văn bản nội dung, và một cột trạng thái do bạn sở hữu chứ không phải cột mà PDF cung cấp sẵn. Cùng lượt đánh chỉ mục đó hữu ích từ sớm hơn, trong lúc tiếp nhận, khi một tài liệu đến từ ngoài đội của bạn và bạn muốn biết nó chứa gì trước khi ai đó review nó. Bài viết về workbench tiếp nhận PDF đi qua bước sàng lọc đó, còn điều hướng trường form trình bày vấn đề đối xứng ngược: review các tài liệu được dựng để thu thập dữ liệu thay vì comment

Một trường hợp mà mảng đó sẽ không cho bạn thấy

Một kiểu thất bại đáng được gắn cờ vì nó trông như một lỗi trong code của bạn nhưng thực ra không phải. Một khách hàng báo cáo highlight hiện rõ khắp một trang, nhưng panel của bạn liệt kê không có gì cả, và AnnotationCount trả về 0. Lời giải thích thường gặp là các vết đó đã bị flatten ở đâu đó phía trước. Flattening nướng các appearance của annotation vào thẳng nội dung trang thông thường, nên highlight trở thành một phần của đồ họa trang và hoàn toàn không còn tồn tại như các object annotation nữa. Chẳng còn gì để một API annotation liệt kê, đổi màu, hay xóa cả. Khi bạn thấy markup đã vẽ ra nhưng số đếm bằng 0, đừng tìm lỗi trong vòng lặp liệt kê của bạn nữa mà hãy hỏi xem tệp đó được tạo ra như thế nào

Bề mặt annotation dùng ở đây, từ liệt kê và tạo mới cho tới đổi màu, xóa, và các tùy chọn render giữ cho hiển thị luôn trung thực, đi kèm sẵn trong PDFium Component cho Delphi, C++Builder, và Lazarus/FPC