HotPDF 透過 THPDFHeadlessDocument.SetTextFieldValueWithFamilies 填入帶阿拉伯文、希伯來文、結合符號與混合方向的 AcroForm comb 欄位。comb 格線以 Unicode 純量計數,連字或帶記號的基礎字母作為一個叢集、跨它們的純量所擁有的 k 個儲存格置中,整行文字在分配任何儲存格之前先過一遍 Unicode 雙向演算法
這三條規則各自取代了一個在拉丁文 demo 裡看起來沒問題、一遇真實表單就瓦解的捷徑。想像一份政府申請表,有個十格的 comb 欄位要填阿拉伯文姓名:用逐字元邏輯去填,你會得到以分離形由左向右行進的字母、被塞進同一格而鄰格空著的 lam-alef 連字,還有飄在錯格上方的 fatha;這些缺陷每一個都來自數錯單位,或在錯誤的時機排列文字
Comb 旗標到底要求 PDF 寫出器做什麼?
Comb 旗標要求寫出器把欄位切成正好 MaxLen 個等距位置,每個位置放一個字元。ISO 32000-1 §12.7.4.3 把 Comb 定義為 Table 228 文字欄位旗標的第 25 位元,而且只有在 Table 229 的 MaxLen 條目存在、Multiline、Password 與 FileSelect 旗標都清空時才有意義;該位元在 32 位元 /Ff 值中是 $1000000。如果你見過 comb 欄位在經過浮點變數一趟往返之後行為失常,32 位元 AcroForm 欄位旗標一文解釋了為什麼這個位元總是第一個消失
規格只說了「character」三個字就停了。它寫作時想的是等寬拉丁方格,完全沒說當一對阿拉伯字母合成一個字形、或母音符號沒有自己的寬度時,字元該算什麼。HotPDF 必須挑一個單位,並且在挑的同時嚴格執行結構規則:同時是 multiline 的 comb 欄位、或沒有正 MaxLen 的 comb 欄位會連同診斷訊息一起被拒,password 欄位則根本不能是 comb 欄位
為什麼以 Unicode 純量計數,而不是 UTF-16 單位、字形或字素?
HotPDF 以 Unicode 純量計算 comb 格數,因為這正是它執行 MaxLen 時用的單位,而格線與長度檢查必須一致,否則通過驗證的值會塞不進它的格子。三個誘人的替代方案各自以不同方式破壞這份一致
- UTF-16 code unit 是 Delphi
Length回傳的東西,所以 U+20BB7 這類補充字元會吃掉兩格、也吃掉欄位預算的兩個名額 - 字形數取決於字型:多數阿拉伯字型把 lam 加 alef 合成一個字形,一換字體格數就變
- 字素數把基礎字母與它的記號合併,格線就會和逐一計數使用者所輸入每個純量的
MaxLen檢查相牴觸
THPDFHeadlessDocument 的 MaxLen 驗證只在字串的 UTF-16 長度超限時才走訪字串,走訪時把有效的代理對算一次,未配對的代理則直接拒絕;如果你的 UI 想在呼叫前提出警告、而不是事後接例外,請用同樣方式計數
// 以 Comb 格線的方式計數 Unicode 純量:
// 有效的代理對算一個純量
function CountScalars(const S: UnicodeString): Integer;
var
I: Integer;
begin
Result := 0;
I := 1;
while I <= Length(S) do
begin
if (Ord(S[I]) >= $D800) and (Ord(S[I]) <= $DBFF) and
(I < Length(S)) and
(Ord(S[I + 1]) >= $DC00) and (Ord(S[I + 1]) <= $DFFF) then
Inc(I, 2)
else
Inc(I);
Inc(Result);
end;
end;
幾個值就能看出各單位差多遠,以及為什麼只有純量欄符合 comb 欄位的預算
| 值 | UTF-16 單位 | 純量(格數) | 一般字形 |
|---|---|---|---|
| Lam + alef(لا) | 2 | 2 | 1 個連字 |
| Beh + fatha(بَ) | 2 | 2 | 2,記號附著於基礎字母 |
| U+20BB7(𠮷) | 2 | 1 | 1 |
| 分解形 e + U+0301 | 2 | 2 | 1 或 2,依字型而定 |
連字與結合符號怎麼共用儲存格?
涵蓋 k 個純量的 shaped 叢集佔用 k 個連續儲存格,並作為單一單元跨這些格子置中。shaping 之後,每個叢集都會映射回它來源文字的確切區間,所以 HotPDF 知道它擁有哪些純量、因而哪些格子。前面的 lam-alef 連字拿到兩個格子並跨兩格置中;帶 shadda 與 fatha 的 beh 是三個純量,拿到三個格子,記號仍然附在字母上
置中正是顯而易見的實作出錯之處。把每個字形在自己格子裡置中,會把記號拉離它的基礎字母,因為記號的位置只有相對於它所裝飾的字母才有意義。HotPDF 讓叢集內每個字形保持 shaped 前進量與 X、Y 偏移,再用自然前進量、定位後的字形原點與實際字形墨跡的合計水平範圍對叢集置中;叢集整塊移動,內部不做任何重排
有一個後果容易漏看。方向控制字元——U+200F RIGHT-TO-LEFT MARK、U+061C ARABIC LETTER MARK,以及各種 embedding 與 isolate 控制——是沒有字形的 Unicode 純量;它們計入 MaxLen,並佔住一個保持空白的格子。如果你的來源資料來自會把這些記號撒進姓名的系統,填表前先清掉它們,否則欄位會出現空隙,而且比使用者預期早一個字元就拒絕輸入值
為什麼雙向重排必須發生在分配儲存格之前?
HotPDF 先讓整行跑完 Unicode 雙向演算法(UAX #9),然後才按視覺位置給每個純量分配格子。按邏輯順序分配、之後再翻轉結果的做法,對純阿拉伯字串行得通,但行裡一旦出現數字或拉丁 token 就失效,因為這些 run 必須在由右至左的行內保持由左至右閱讀;帶阿拉伯前綴與數字的參考代碼這類值,只有在重排能看到整行的前提下,數字順序才保得住。複雜文字 shaping 總覽更深入地談邏輯順序與視覺順序的問題
接著對齊把用到的格線整體放進欄位。欄位繼承的 /Q 條目存在時以它為準;Options.Alignment 只在表單沒有 /Q 時生效。這對由右至左的表單很關鍵:靠左對齊的 comb 欄位裡,短的阿拉伯值會填最左邊的格子——合乎規格,卻幾乎從來不是表單設計者想要的;請修正表單的 /Q,或在欄位沒有自己的 quadding 時設 htaRight。widget 旋轉來自 /MK 的 /R 條目,在 90 或 270 度時,邏輯文字視埠的寬高互換,儲存格邏輯則不變
從 Delphi 填混合字型的 comb 欄位
混合字型的 comb 文字對每個提供字型共用同一套格線、同一個字級與同一條基準線。FallbackFontFamilies 設為 True 時,HotPDF 按設定的順序從提供的字族為每個完整字素挑字型,所以阿拉伯字體可以扛字母、拉丁字體扛內嵌的 ASCII。每個字型區段都以整行為上下文做 shaping,記號絕不會跨字型與基礎字母拆開,也不會跨字型邊界發明連字;讓每個區段用自己的局部格線排版、或縮放到自己的外框會更簡單,但 fallback 在行中切換字型的瞬間,格線位置與旋轉欄位就都會壞
uses
System.SysUtils, System.IOUtils,
HPDFHeadlessDocument, HPDFHeadlessAppearance;
procedure FillArabicComb(const SourceFile, TargetFile, Value: string);
var
Doc: THPDFHeadlessDocument;
Field: THPDFHeadlessFormField;
Families: THPDFHeadlessRichTextFontFamilies;
Options: THPDFHeadlessTextAppearanceOptions;
FieldIndex: Integer;
begin
Doc := THPDFHeadlessDocument.Create;
try
Doc.LoadFromFile(SourceFile);
FieldIndex := Doc.FindFormField('applicant.name_ar');
if FieldIndex < 0 then
raise Exception.Create('Field not found');
Field := Doc.GetFormField(FieldIndex);
if (Field.Flags and $1000000) = 0 then // bit 25:Comb
raise Exception.Create('Not a comb field');
SetLength(Families, 2); // 一到十六個字族
Families[0].FamilyName := 'Noto Naskh Arabic';
Families[0].Fonts[hrfsRegular] :=
TFile.ReadAllBytes('NotoNaskhArabic-Regular.ttf');
Families[1].FamilyName := 'Noto Sans';
Families[1].Fonts[hrfsRegular] :=
TFile.ReadAllBytes('NotoSans-Regular.ttf');
Options := THPDFHeadlessTextAppearanceOptions.Default;
Options.ShapeComplexText := True; // 本來就是預設值
Options.FallbackFontFamilies := True; // 預設為 False
Options.FontSize := 0; // 自動適配,上限 12
Options.Alignment := htaRight; // 僅在沒有 /Q 時使用
Doc.SetTextFieldValueWithFamilies(FieldIndex, Value, Families, Options);
Doc.SaveToFile(TargetFile); // 必須與 SourceFile 不同
finally
Doc.Free;
end;
end;
注意這個範例沒設什麼。文件填寫期間,Width、Height、Rotation 與 CombCells 由每個 widget 矩形、其 /MK 條目與欄位的 MaxLen 推導,你塞進這些成員的值會被覆寫;FontSize 為 0 要求自動 sizing,它對每個叢集的範圍核對其 k 格寬度與所有參與字型共用的度量與墨跡高度,固定字級則直接被裁進 widget。CharacterSpacing 與 WordSpacing 永遠動不了 comb 位置,因為格線獨自決定叢集落在哪裡。HotPDF 只在值真的需要 shaping 時才載入它的 HarfBuzz 與 FriBidi runtime,不需要 shaping 的值保持既有直連 comb 版面不變。已載入 PDF 的一般填表流程(含權限與存檔)見在已載入的 PDF 設定表單欄位值
shaped comb 外觀不保證什麼?
shaped comb 外觀保證儲存格分配正確、叢集完整、邏輯值原樣保留;它不保證每個工具裡都有無縫的連筆接合與完美的文字擷取。兩個限制都源於 comb 欄位的本質,承諾客戶之前你該先知道
第一個是跨格的連筆接合。阿拉伯字母仍以整行為上下文做 shaping,每個字母拿到正確的詞首、詞中或詞尾形,但固定的格子間距會在相鄰叢集之間留下空隙,連接筆畫碰不到一起,和在連續文字裡不一樣;把會連筆的文字畫進方格,這件事是本質性的,再多的置中也藏不住
第二個是文字擷取。欄位的 /V 保留純邏輯值,外觀的父 Form XObject 帶一個(ISO 32000-1 §14.9.4)/ActualText 區段記載完整輸入,子 Form 則沒有;尊重 ActualText 的擷取器能還原原始字串,忽略它的擷取器從字形位置重建文字,可能回傳不同順序或重複的叢集文字,因為多字形的叢集對每個字形沒有單一顯而易見的 Unicode 值。下游系統需要這個值時,讀欄位,別讀頁面文字
其餘邊界是機械性的。comb 文字必須是不帶 tab 的單行,純量數超過格數的值會失敗而不是被截斷;失敗是交易式的——缺字形、字型無效、預算超支或取消都會留下原值與原外觀,被拒的填寫絕不會留下畫到一半的欄位
try
Doc.SetTextFieldValueWithFamilies(FieldIndex, Value, Families, Options);
except
on E: EHPDFHeadlessDocumentError do
if E.Code = hdLimitExceeded then
ShowFieldError('The value has more characters than the field allows')
else
raise;
on E: EHPDFHeadlessAppearanceError do
if E.Failure = haMissingGlyph then
ShowFieldError('None of the supplied fonts covers this text')
else
raise;
end;
// 兩種錯誤下,欄位都保留原值與原外觀
shaped comb 欄位在表單管線中的位置
shaped comb 支援對已經存在的表單最有價值:以方格欄位設計、如今要以阿拉伯文、希伯來文、波斯文或烏爾都文填寫的報稅表、簽證申請、銀行開戶包與身分登記。以純量計數、把原始值留在 /V、讓一條格線治理所有字型,並用一個混合由右至左字母、數字與結合符號的值測試——這一個字串就能把本文每條規則都跑一遍。這裡描述的無頭文件 API、comb shaping 與字型 fallback 都是HotPDF Delphi PDF component 的一部分