PDFium 元件透過 TPdf.CreateAnnotation 建立文字標記註解,這意指螢光筆 (highlight)、底線 (underline)、刪除線 (strikeout) 和波浪線 (squiggly):您在 TPdfAnnotation 紀錄 (record) 上設定 HasAttachmentPoints := True 並填寫它的 AttachmentPoints 四邊形 (quadrilateral),然後元件就會寫入 ISO 32000-1 §12.5.6.10 中定義的 QuadPoints 條目。這就是整個 API 介面。這篇文章之所以存在,是因為在它底下發生的事情,因為原始的 PDFium 呼叫鏈有一個失敗模式,它會產生工具包中最沒有幫助的症狀:在一個剛建立的註解上呼叫 FPDFAnnot_SetAttachmentPoints 每次都會傳回 false,而且沒有錯誤碼也沒有任何提示。這是我們關於讀取和審查現有註解的文章在建立端 (creation-side) 的配套內容,那一篇則是透過相同的結構往另一個方向走
除錯的場景總是一樣的。您建立了一個螢光筆註解,用索引 0 呼叫了 attachment-points 的設定器 (setter),函式傳回 false,然後您開始懷疑自己的座標。您轉置 (transpose) 了那些點,翻轉了 Y 軸,把頁面空間 (page space) 換成了裝置空間 (device space)。這些都沒用,因為座標從來就不是問題。問題在於 C API 的索引語意,一旦您看懂了,修復只需要兩行程式碼
QuadPoints 在 ISO 32000-1 中的意義
QuadPoints 是一個由 8×n 個數字組成的陣列,描述了 n 個四邊形,而 ISO 32000-1 §12.5.6.10 規定每個文字標記註解都需要它:每個四邊形標記了一個單字或一組連續的單字,這是螢光筆、底線或刪除線所套用的對象。註解的 Rect 條目仍然存在,但對於標記子類型 (markup subtypes),它只用來框出該區域;四邊形才是渲染器實際繪製的內容。使用四邊形而不是矩形,是因為文字可能會被旋轉或傾斜,因此四個角會被儲存為四個獨立的點:x1 y1 x2 y2 x3 y3 x4 y4
這四個點的順序,就是規範 (specification) 與現有安裝基礎 (installed base) 分道揚鑣的地方。規範文字描述這些點是逆時針方向描繪四邊形,但 Adobe 自家的渲染器卻一直以 Z 字形模式來解釋它們:先是上邊緣由左至右,然後是下邊緣由左至右。因為每個作者都是針對 Acrobat 測試,實際上每個渲染器,包含 PDFium 在內,都遵循 Z 字形模式,而那些遵循規範字面意思的檔案,在某些檢視器中會渲染成擠在一起或扭曲的螢光筆。PDFium 的 FS_QUADPOINTSF 結構 (struct) 正好編碼了這個慣例:(x1,y1) 是左上角,(x2,y2) 是右上角,(x3,y3) 是左下角,(x4,y4) 是右下角,座標系統使用的是 Y 軸向上的頁面座標。遵循那個順序就可以了;渲染器對很多事情都很寬容,但一個順序混亂的四邊形並不在其中
為什麼 FPDFAnnot_SetAttachmentPoints 會傳回 false?
FPDFAnnot_SetAttachmentPoints 在新的註解上會失敗,因為它的合約是替換給定索引處的四邊形,而一個剛建立的註解有零個四邊形可以替換。其簽章 (signature) 接收一個註解的控點 (handle)、一個 quad_index 以及那些點;索引 0 並不意味著「第一個插槽 (slot),如果需要的話就建立它」,它的意思是「現有的第 0 號四邊形」,而當 FPDFAnnot_CountAttachmentPoints 回報為 0 時,就沒有這樣的四邊形,於是該呼叫就會傳回 false。負責建立插槽的函式是 FPDFAnnot_AppendAttachmentPoints。透過 FPDFPage_CreateAnnot 建立的每個註解,其計數都從零開始,所以建立路徑必須先呼叫 Append,而且只有後續的更新才可以呼叫 Set
這反咬了 PDFium 元件本身。直到 v1.79.0 為止,CreateAnnotation 和 SetAnnotation 共用的內部常式寫死了 FPDFAnnot_SetAttachmentPoints(Annotation, 0, ...),這對於更新現有的標記註解來說是正確的,但對於新的註解則保證會失敗,並以一條帶有 'Cannot set attachment points' 訊息的 EPdfException 呈現出來。在 v1.79.1 中釋出的修復,會根據計數 (count) 來做分支
// 在元件的註解寫入器內部 (v1.79.1+):
// 一個新的註解還沒有任何 quad 插槽,所以 Append 會建立
// 第一個;Set 只會替換已經存在的插槽
if FPDFAnnot_CountAttachmentPoints(Annotation) = 0 then
Check(FPDFAnnot_AppendAttachmentPoints(Annotation, QuadPoints) <> 0,
'Cannot set attachment points')
else
Check(FPDFAnnot_SetAttachmentPoints(Annotation, 0, QuadPoints) <> 0,
'Cannot set attachment points');
如果您直接呼叫匯出的 C 函式,同樣的模式也適用,元件允許您這麼做,因為所有的 FPDFAnnot_* 進入點都已經在 PDFium.pas 中公開。每當您持有一個 FPDF_ANNOTATION 控點並想要寫入四邊形時,先詢問 FPDFAnnot_CountAttachmentPoints,然後再據此選擇路徑。如果您正在搜尋 "FPDFAnnot_SetAttachmentPoints returns false",這個先計算後附加 (count-then-append) 的分支幾乎肯定就是您的答案
使用 TPdf.CreateAnnotation 建立螢光筆
因為元件已經為您處理了 Append 與 Set 的路由,所以建立一個螢光筆就被簡化為填寫一個紀錄 (record) 的過程。下面的範例建立了一個 A4 頁面,並在一個 200×20 點的區域上放了一個半透明黃色的螢光筆;請注意,四邊形遵循了前面描述的 Z 順序,而且 Rectangle 被設定為包圍這個四邊形,這能讓那些對 Rect 進行點擊測試 (hit-test) 的檢視器保持合理的行為
var
Pdf: TPdf;
A: TPdfAnnotation;
begin
Pdf := TPdf.Create(nil);
try
Pdf.CreateDocument;
Pdf.AddPage(0, 595, 842);
FillChar(A, SizeOf(A), 0);
A.Subtype := anHighlight;
A.HasColor := True;
A.Color := clYellow;
A.ColorAlpha := $80; // 50% 不透明度
A.HasAttachmentPoints := True;
A.AttachmentPoints[1].X := 50; A.AttachmentPoints[1].Y := 700; // 左上
A.AttachmentPoints[2].X := 250; A.AttachmentPoints[2].Y := 700; // 右上
A.AttachmentPoints[3].X := 50; A.AttachmentPoints[3].Y := 680; // 左下
A.AttachmentPoints[4].X := 250; A.AttachmentPoints[4].Y := 680; // 右下
A.Rectangle.Left := 50; A.Rectangle.Top := 700;
A.Rectangle.Right := 250; A.Rectangle.Bottom := 680;
A.ContentsText := 'Highlighted region';
Pdf.CreateAnnotation(A);
Pdf.SaveAs('highlighted.pdf');
finally
Pdf.Free;
end;
end;
切換子類型 (subtypes) 只需要改一行程式碼。anUnderline、anStrikeout 和 anSquiggly 都採用完全相同的紀錄形狀 (record shape),包含四邊形和所有東西,因為 ISO 32000-1 將這四種視為同一個註解家族,唯一的區別在於四邊形區域的裝飾方式。非文字標記的子類型,例如 anSquare、anCircle 和 anText,則僅透過 Rectangle 來定位;對這些類型來說,請將 HasAttachmentPoints 保持為 False,這樣處理四邊形的機制就永遠不會執行
為什麼 AttachmentPoints[0] 在 Delphi 中能編譯但在 FPC 中會失敗?
TQuadrilateralPoint 被宣告為 array [1..4] of TPdfPoint,這是一個以 1 為基底 (1-based) 的陣列,這會絆倒任何習慣以零為基底 (zero-based) 索引的人。寫入 A.AttachmentPoints[0],Delphi 的 dcc32 會毫無怨言地編譯它,因為範圍檢查預設是關閉的;在執行階段,這個表達式會默默地讀取或寫入剛好在陣列前面的記憶體,在一個 TPdfAnnotation 紀錄中,那剛好是一個相鄰的欄位。您的螢光筆會得到一個垃圾角落 (garbage corner),或者相鄰的欄位被破壞了,而且不會引發任何例外。Free Pascal 在 Lazarus 移植期間,在我們自己的展示原始碼中抓到了這個確切的錯誤:fpc 會對常數索引執行編譯期的範圍檢查,並直接拒絕了 AttachmentPoints[0..3],這也是為什麼「差一錯誤 (off-by-one)」和 Set-versus-Append 程式庫錯誤會一起被發現的原因
由此產生了兩個習慣。將四邊形的索引編為 1 到 4,以配合上面程式碼中的角落順序,並且在信任您的註解程式碼之前,至少要開啟範圍檢查進行一次建置 (build),這可以是 Delphi 中的 {$R+} 或是任何 fpc 建置。預設的 dcc32 建置通過,並不代表索引是正確的;這只能證明剛好在那裡的記憶體上沒有發生崩潰
從真實文字取得四邊形座標
寫死的矩形對於示範來說沒問題,但正式生產環境中的螢光筆是要追蹤實際字形的,而且這些座標應該來自 PDFium 的文字頁面幾何 (text page geometry),而不是靠猜測。我們關於使用 PDFium 元件進行文字提取的指南中所涵蓋的常式,會為您提供每一個字元的邊界方塊 (bounding box),且使用的是與四邊形相同的頁面座標空間,因此一次搜尋命中可以直接轉換為角落的點:第一個字元的左邊,最後一個字元的右邊,頂端和底端則來自該行的範圍。如果您自己正在產生文字,並且需要知道這些行在建立出來之前會落在哪裡,文字測量與自動換行文章會介紹如何預先計算這些範圍
一條誠實的邊界:TPdfAnnotation 紀錄帶有單一一個 TQuadrilateralPoint,所以一次 CreateAnnotation 呼叫只能寫入一個四邊形。一個橫跨三行的選取範圍需要三個四邊形,每一行一個(根據 §12.5.6.10),而您有兩種方式可以達成。最簡單的方法是一行一個註解,這樣在所有地方都能正確渲染,並保留了元件層級的 API。緊湊的方法是,一個註解帶有三個四邊形,這意味著透過元件建立註解,然後自己呼叫匯出的 FPDFAnnot_AppendAttachmentPoints 來處理第二個和第三個四邊形,這之所以可行,正是因為 Append 會建立插槽而不是替換它們。不要試圖透過重複呼叫 SetAttachmentPoints 來達成多個四邊形 (multi-quad);因為超出當前計數的每一個索引都會直接傳回 false,原因和新註解上的索引 0 會失敗一樣
寫入之後,請在一個真實的檢視器中驗證,而不是信任回傳碼:在 Acrobat 或任何基於 PDFium 的檢視器中開啟檔案,確認標記落在文字上、以預期的不透明度讀取,並且能在儲存後重新載入的來回測試中存活下來。這裡展示的註解類型、四邊形的處理,以及具有計數意識的寫入器,都是適用於 Delphi、C++Builder 和 Lazarus 的標準 PDFium 元件 的一部分;產品頁面提供了完整的註解 API 參考文件,以及程式庫其餘部分的說明