HotPDF 通过 THPDFHeadlessDocument.SetTextFieldValueWithFamilies 填写含阿拉伯语、希伯来语、结合符号和混合方向文本的 AcroForm comb 字段。comb 网格按 Unicode 标量计数,连字或带符号的基础字母作为一个 cluster 在其标量占有的 k 个格子上居中,整行在分配任何格子之前先过一遍 Unicode 双向算法
这三条规则各自取代了一个在拉丁文演示里看起来没问题、一碰真实表单就散架的捷径。想象一份政府申请表,有一个十格 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 字段标志一文解释了为什么最先丢的就是这个位
规范说了“字符”就止步于此。它写的时候想的是等宽拉丁格子,没说当一对阿拉伯字母变成一个 glyph、或元音符号自身没有宽度时,字符到底是什么。HotPDF 必须选定一个单元,并在选定单元的同时严格执行结构性规则:既是 comb 又是 multiline 的字段、或不带正 MaxLen 的字段,都会带诊断信息被拒绝;密码字段根本不允许是 comb 字段
为什么按 Unicode 标量计数,而不是 UTF-16 单元、glyph 或字素?
HotPDF 按 Unicode 标量数 comb 格子,因为它执行 MaxLen 用的也是这个单元;网格和长度检查必须一致,否则通过校验的值会装不进它的格子。三个诱人的替代方案各自以不同方式破坏这种一致性
- UTF-16 码元正是 Delphi
Length返回的东西,所以 U+20BB7 这样的补充字符会吃掉两个格子和字段预算里的两个名额 - glyph 计数取决于字体:大多数阿拉伯字体把 lam 加 alef 变成一个 glyph,一换字体格子数就变
- 字素计数把基础字母和它的符号合并,网格就会跟按用户键入的每个标量计数的
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 cluster 占据 k 个连续格子,并作为单一单元在其中居中。shaping 之后,每个 cluster 都映射回它来源的那段确切源文本,HotPDF 因此知道它拥有哪些标量、进而哪些格子。上面的 lam-alef 连字拿到两个格子并横跨两者居中;带着 shadda 和 fatha 的 beh 是三个标量,所以拿三个格子,符号依然附着在字母上
居中正是想当然的实现出错的地方。把每个 glyph 在自己的格子里居中,会把符号从它的基础字母上扯开,因为符号的位置只有相对它装饰的字母才有意义。HotPDF 让 cluster 内的每个 glyph 都保持 shaped advance 和 X、Y 偏移,然后用自然 advance、定位后的 glyph 原点和 glyph 实际墨迹的合成水平范围来居中整个 cluster。cluster 整体平移,内部什么都不重排
有一个后果容易忽视。U+200F RIGHT-TO-LEFT MARK、U+061C ARABIC LETTER MARK 以及 embedding 和 isolate 控制符这类方向控制符,是没有任何 glyph 的 Unicode 标量。它们计入 MaxLen,占着一个保持空白的格子。如果你的源数据来自一个喜欢往姓名里撒这些标记的系统,填写前请先把它们剥掉,否则字段会显示一个空洞,而且比用户预期的提前一个字符拒绝取值
为什么双向重排必须发生在分配格子之前?
HotPDF 先让整行跑一遍 Unicode 双向算法(UAX #9),然后才按视觉位置给每个标量分配格子。按逻辑顺序分配格子再把结果翻转,对纯阿拉伯语字符串有效,但行里只要出现数字或拉丁 token 就失效,因为这些片段在从右到左的行内必须保持从左到右阅读。像“阿拉伯语前缀加数字”这样的引用编号,只有重排时一次看到整行,数字才能保持正确顺序。复杂文本 shaping 总览更深入地讲了逻辑序与视觉序的问题
对齐随后决定整片已用格子在字段内的位置。字段继承的 /Q 条目存在时说了算;表单没有时才轮到 Options.Alignment。这一点对从右到左的表单很重要:左对齐 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 // 第 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 为零表示自动字号,它会把每个 cluster 的范围与 k 个格子的宽度以及全部参与字体的公共度量和墨迹高度比对;固定字号则简单裁剪进 widget。CharacterSpacing 和 WordSpacing 永远不会挪动 comb 位置,因为 cluster 落在哪只由网格决定。HotPDF 只在取值确实需要 shaping 时才加载 HarfBuzz 和 FriBidi 运行时,无需 shaping 的取值保持既有的直接 comb 布局不变。已加载 PDF 的通用填写流程(含权限与保存)见 在已加载 PDF 中设置表单字段值
shaped comb 外观不保证什么?
shaped comb 外观保证格子分配正确、cluster 完整、逻辑值原样保留;它不保证连笔无缝,也不保证在每个工具里文本提取都完美。两条限制都源于 comb 字段的本性,向客户承诺任何事之前你应当先知道它们
第一是跨格子的连笔。阿拉伯字母仍以整行为上下文做 shaping,每个字母拿到正确的词首、词中或词尾形态,但固定的格子间距让彼此独立的 cluster 之间存在空隙,连接笔画无法像连续文本里那样接上。把带连笔的文字画进格子里,这个后果是天生注定的,再多居中也遮不住
第二是文本提取。字段的 /V 保留纯逻辑值,外观的父 Form XObject 携带一个含完整输入的 /ActualText span(ISO 32000-1 §14.9.4);子 Form 不带。尊重 ActualText 的提取器能还原原始字符串;无视它的提取器按 glyph 位置重建文本,可能返回不同顺序或重复的 cluster 文本,因为多 glyph cluster 没有单一明确的逐 glyph Unicode 值。下游系统需要值时,请读字段,而不是读页面文本
其余边界是机械性的。comb 文本必须是单行、不含制表符,标量多于格子的取值直接失败而不截断。失败是事务性的:缺 glyph、字体无效、预算超限或取消,都让原值与原外观原封不动,被拒绝的填写绝不会留下画了一半的字段
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 里保留原始值、让一套网格统辖所有字体,并用一个混合从右到左字母、数字和结合符号的取值来测试——这一个字符串就能触发本文的每条规则。这里描述的 headless 文档 API、comb shaping 和字体回退都是 HotPDF Delphi PDF component 的一部分