技術文章

用 PDFium Component 在 Delphi 中審閱 PDF 註記

PDF 註記是一個掛在頁面上的字典,不是畫在頁面上的一個痕跡。ISO 32000-1 §12.5 定義了大約兩打子類型,每一種都帶著 /Subtype、一個以頁面座標表示的矩形、一組旗標,以及通常還有一個決定檢視器實際畫出什麼的外觀串流。對一位正在審閱文件的人來說,這些子類型的意義並不相同。Highlight 與 Ink 筆劃是註解;Link 是導覽;Popup 是您點開便利貼時跳出來的那個小視窗,以自己的物件存在,由一個父節點指著它。回覆則是完整的 Text 註記,透過一個 in-reply-to 條目參照它所回應的那則註解。所以頁面層級的註記陣列,並不是審閱者眼中的註解清單。它是一個扁平的袋子,裡面裝著註解、串起它們的管線,以及好幾樣審閱者根本不會稱為註解的東西。一個把那個陣列當成註解清單的面板,會和客戶手上每一個其他檢視器意見不合

在 PDFium Component(PDFium 為 Delphi、C++Builder 與 Lazarus 打造的 VCL/LCL 元件)之上建立註記審閱流程,意味著把心力集中在原始陣列與人眼視角之間那道落差所引起的麻煩上:計數、建索引、替引擎早已凍結的痕跡改色、刪除時不留殘影,以及加上您自己的痕跡

示意圖:Delphi 的 PDFium 審閱面板如何把註解、快顯、回覆與連結混雜的原始頁面註記陣列,過濾成審閱者看到的那份精選註解清單
頁面註記陣列把註解與快顯、回覆、連結和隱藏痕跡混在一起,所以審閱面板得先有一條計數規則,才能顯示總數

為什麼您的數字永遠對不上 Acrobat 的註解窗格

把一份標註過的合約在您的檢視器與 Acrobat 中並排打開,總數很少一致。Acrobat 顯示的是一份精選過的視角:標註被歸成回覆討論串、快顯被摺進它們所屬的筆記裡,連結與表單小工具則被排除在外。原始陣列則不加區分地全裝著,所以一份天真的計數會同時在某些方面偏高、在另一些方面偏低

快顯會灌大總數,因為每張便利貼都附帶一個獨立的 Popup 物件,兩個都算就把筆記算成兩倍。回覆則會壓低總數——如果您以可見痕跡為過濾條件的話,因為回覆是一則在有人展開討論串之前什麼都不畫的 Text 註記,把它丟掉就丟掉了整段討論。Hidden 與 NoView 旗標會把註記從螢幕上拿掉,卻不會把它從陣列裡拿掉,所以一份對旗標視而不見的計數,會把使用者看不到的痕跡也算進去。Link 註記和註解坐在同一個陣列裡,但它既不該進計數,也不該進清單。請在寫迴圈之前就定好計數規則,並把這個決定寫下來,因為「為什麼你們面板的數字和 Acrobat 不一樣」是審閱功能會招來的第一張工單

全部索引一次,之後再也不重新剖析頁面

有一條設計規則貫穿後面的一切:依作者、型別或頁面過濾時,絕不可以重新剖析頁面物件。在一份帶著大量標註的 300 頁文件上,每次改下拉選項就重新剖析,會讓面板一次卡上好幾秒。元件暴露 AnnotationCount 與索引式的 Annotation[] 屬性,兩者的範圍都限定在目前載入的頁面,而它們交回的 TPdfAnnotation 記錄帶著清單檢視所需要的東西:SubtypeFlagsColorRectangleContentsTextAuthorText。正確的作法,是在開檔時把每一頁掃過一遍,並保有您自己的扁平索引:

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];
      // 只留下與審閱者相關的子類型;記下頁碼與索引這一對,
      // 因為之後所有編輯都以它定址
      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;

值得畫線強調的那一對是 (PageNo, i)。之後每一次變更,不論是改色還是刪除,都以頁碼加註記索引來定址,而索引很脆弱:移除一個註記會讓該頁後面的每一個都重新編號。所以請規劃在任何刪除之後重建受影響那一頁的條目,而不是就地修補索引號碼。重建花掉一毫秒。相對地,一份過期的索引會刪掉錯誤那位審閱者的註解,而那種臭蟲會侵蝕使用者對整個功能的信任

即使您的第一版只計算回覆而不顯示它們,討論串仍值得在索引裡佔一格。趁著頁面還開著,就依父節點參照把項目分組,這樣面板日後才能像 Acrobat 那樣把討論串摺起來。等到捲動時才延遲重建那份分組,會徹底破壞「只索引一次」的用意,因為那會重新開啟您已經付過剖析代價的頁面。幾何也要同樣的紀律。每筆記錄中的 Rectangle 是頁面空間的,把它換算成檢視座標的工作,該放在一個共用的輔助函式裡,而不是散落在程式碼各處。當選取、命中測試與繪製各自發明自己的縮放與旋轉算式時,面板就會長出座標臭蟲;把這三者都導進同一個轉換,一個標示、它在清單中的那一列,以及它的點擊目標,就會始終釘在同一筆墨跡上

替標註改色,以及外觀串流的否決權

把螢光標示從黃色改成琥珀色,聽起來像一行程式的事,有時候也的確是。癥結在 ISO 32000-1 §12.5.5。當註記帶著 /AP 外觀串流時,相容的檢視器會畫那個預先建好的串流,並把字典裡的色彩條目當成死掉的中繼資料。Acrobat 基本上會為它建立的每一樣東西寫出外觀串流,所以從客戶那裡收進來的註記大多已經處於這個狀態,而您那麼有自信設下去的顏色,永遠到不了螢幕。改色是一趟透過 Annotation[] 屬性的讀取、修改、寫回,而元件對這個衝突很誠實:當引擎拒絕讓字典色彩覆蓋已烘焙的外觀時,寫入會擲出 EPdfError

示意圖:Delphi PDFium 元件中讀取、修改、寫回的改色路徑,已烘焙的外觀串流否決了字典色彩並擲出 EPdfError
當註記帶著預先建好的 /AP 串流時,引擎會拒絕字典色彩並擲出 EPdfError,於是面板改為替自己的覆蓋層改色,或把那一列標示為外觀已鎖定
A := Pdf.Annotation[Item.Index];
A.HasColor := True;
A.Color := $0000B0FF;       // 琥珀色
A.ColorAlpha := 160;
try
  Pdf.Annotation[Item.Index] := A;
except
  on EPdfError do
  begin
    // 這個註記擁有預先呈現好的 /AP 串流;光靠字典裡的
    // 色彩,改變不了檢視器畫出來的東西
    Item.AppearanceLocked := True;
    StatusBar.SimpleText := 'Color is fixed by the annotation appearance';
  end;
end;

請每次都捕捉那個例外,並把它當成資訊而不是失敗。省掉那道防護,您的面板會在自己的清單裡開心地顯示琥珀色,而頁面照樣畫著黃色;使用者幾週後把它報成「你們的檢視器忽略我的編輯」,然後您花一個下午,在一份剛好沒有外觀串流的檔案上重現不出來。一旦知道外觀被鎖定,您有兩種誠實的回應:替您自己的選取覆蓋層改色而不是改註記,讓審閱者至少看得到他挑的那個標示;或者把那一列標示為外觀已鎖定,讓沒有人期待這項變更會留得住

刪除註記而不留下殘影

DeleteAnnotation 會把物件從目前頁面的註記樹中移除,但它不動已快取的頁面點陣圖。呼叫完立刻繪製,被刪掉的螢光標示還在螢幕上,坐在一張已經與它背後文件模型不符的點陣圖裡。解法是把重新呈現當成刪除的一部分,而不是呼叫端可能忘記的一個步驟:

示意圖:Delphi PDFium 三步驟的刪除循環,移除註記、以 reAnnotations 重新呈現頁面,再重建該頁索引
刪除只碰註記樹,所以面板必須以 reAnnotations 重新呈現並重建該頁條目,顯示與索引才會重新誠實
Pdf.PageNumber := Item.PageNo;
Pdf.DeleteAnnotation(Item.Index);   // 失敗時擲出 EPdfError
Bmp := Pdf.RenderPage(0, 0, ViewWidth, ViewHeight, ro0, [reAnnotations]);
try
  PaintPageBitmap(Bmp);
finally
  Bmp.Free;  // RenderPage 把點陣圖的所有權交給呼叫端
end;
RebuildPageEntries(Item.PageNo);  // Item.Index 之後的索引都位移了

那段區塊裡有兩個細節很容易做錯。reAnnotations 選項必須在場,否則新的點陣圖會丟掉剩下的每一個註記,頁面看起來就像您把整組註解通通抹掉,而不是只刪了一個痕跡。而 Bmp.Free 不是可有可無的:函式形式的 RenderPage 多載會把點陣圖的所有權交給呼叫端,所以少了那次釋放,每刪一次就漏掉一張整頁的點陣圖,而一位翻過長篇文件的審閱者,幾分鐘內就會把它變成真實的記憶體壓力

從您自己的 UI 加上審閱痕跡

建立註記走的是 CreateAnnotation,它接受一筆填好的 TPdfAnnotation 記錄(子類型、矩形、色彩、內容、作者),並把它掛到目前頁面上。便利貼,也就是子類型 anText,是簡單的那種情況:設好位置、內容與作者就完事了。Ink 註記才是大家踩雷的地方。記錄中的矩形只框住繪圖範圍;筆劃本身是一個個點陣列,必須另外透過引擎的墨跡筆劃呼叫掛上去,也就是餵給 FPDFAnnot_AddInkStrokeFS_POINTF 資料,一筆一劃地從滑鼠或手寫筆輸入擷取而來。只用一個矩形、別的什麼都沒有就建出 Ink 註記,您得到的是一團呈現成空白的塗鴉,看起來像引擎的臭蟲,實際上是一個做到一半的註記

順口把作者身分政策也定下來。您的 UI 建立的每個痕跡都該帶著一致的 AuthorText,因為您下個月要做的審閱者過濾器,好不好用完全取決於您今天蓋在註解上的那些名字。空白或前後不一的作者字串,不重開每一份檔案就無法追溯修補

把審閱結果帶出檢視器

審閱資料要能離開檢視器,才算物有所值——化為專案負責人不必開檔就讀得懂的摘要,或化為餵進追蹤表的 CSV。請從您已經建好的索引匯出,絕不要從一次全新的剖析匯出,並挑一種穩定的方式回頭指涉每個痕跡。頁碼配上註記的矩形,撐得過陣列索引撐不過的往返,因為下一次刪除會悄悄替索引重新編號,而您的 CSV 就開始指向錯誤的註解

一列值得留下的資料,會帶著頁碼、子類型、作者、檔案有記錄時的建立時間戳記、內容文字,以及一個由您自己掌握、而非 PDF 提供的狀態欄。同樣一趟索引在更早的階段也派得上用場:當一份文件從團隊外部送進來,而您想在任何人審閱之前先知道裡面有什麼時。PDF 收件工作臺一文走過那趟分流,而表單欄位導覽則涵蓋鏡像般的另一個問題:審閱那些為蒐集資料而非蒐集註解而生的文件

有一種情況,陣列不會顯示給您看

有一種失效模式值得插旗,因為它看起來像您程式碼的缺陷,其實不是。客戶回報整頁都是看得見的螢光標示,但您的面板一項都沒列出來,而 AnnotationCount 回傳零。通常的解釋是那些痕跡在上游某處被平面化了。平面化會把註記外觀烘焙進普通的頁面內容,於是螢光標示成了頁面圖形的一部分,徹底不再以註記物件存在。註記 API 沒有任何東西可以列舉、改色或刪除。當您看到畫出來的標註卻拿到零計數時,別再到列舉迴圈裡找臭蟲,改去問這份檔案是怎麼產生的

本文用到的註記介面,從列舉與建立,到改色、刪除,以及讓顯示保持誠實的那些呈現選項,都隨適用於 Delphi、C++Builder 與 Lazarus/FPC 的 PDFium Component 出貨