技术文章

在 Delphi 中用 PDF 输出中日文竖排文本

把「コード」沿页面竖着画,一次一个字符,每次调用比上一次低一个 em,五个字形里有三个会出错。两个直角括号按它们在横排线上的方式立在列里,开口朝向两侧;长音符号 ー 是一根横穿的短划,而日语读者期待的是一小段竖笔。只有コ和ド这两个普通片假名呈现出排版师会选的样子

把整行旋转九十度多少能碰巧修好括号和长音符号,却弄坏其余一切:每个汉字和假名都躺倒了。竖排同时做三件事。字符保持直立,前进方向沿页面下行而不是横穿页面,还有一小撮字符切换成不同的画法。三件事都做对,需要一款带有竖排形态和竖排度量的字体,以及一个配置好使用它们的 PDF 字体

PDF Library for Delphi 示意图:对比旋转的水平文本行与真正的竖排书写模式——后者字形保持直立,前进方向沿页面下行
纵排使用字体自身的竖排度量直立摆放字符,而不是旋转横排文本行,复制的竖排列读回时仍逐字对应

在 PDF Library for Delphi 中打开竖排书写

PDF Library for Delphi 把竖排当作一种绘制模式。SetVerticalWritingMode(1) 打开它,之后的每个 DrawText 调用都从你给的点沿页面向下运行,SetVerticalWritingMode(0) 切回横排文本。setter 返回调用之前生效的模式,GetVerticalWritingMode 读取当前模式,所以一个辅助例程可以切换模式、稍后精确恢复现场

两个坐标的含义与横排文本略有不同。x 值指定列的中线:每个字形以其水平中点挂在这条线上,所以窄的标点和全宽汉字保持同一轴对齐。y 值是第一个字符格的顶部,列从这里向下生长。用 SetOrigin(1) 把 y = 0 放在页面顶边之后,这些算术读起来就跟你在纸上排版一样

var
  Lib: TPDFlib;
  H: Double;
begin
  Lib := TPDFlib.Create;
  try
    Lib.SetOrigin(1);
    Lib.AddTrueTypeFont('MS Mincho', 1);      // 1 = 嵌入字体
    Lib.SetTextSize(12);
    Lib.SetVerticalWritingMode(1);            // 普通 DrawText 现在向下走
    H := Lib.GetVerticalTextHeight('MS Mincho', 12, '第三章 保守点検');
    Lib.DrawText(480, 72, '第三章 保守点検');     // 列从 y = 72 开始
    Lib.SetVerticalWritingMode(0);            // 回到横排
    // Origin 1 把 y = 0 放在页面顶端,所以 72 + H 是列的底部;
    // 横排说明文字放在它下面一行
    Lib.DrawText(456, 72 + H + 18, 'Chapter 3');
    Lib.SaveToFile('manual-ja.pdf');
  finally
    Lib.Free;
  end;
end;

GetVerticalTextHeight 接受字体名、字号和文本,返回列的长度(点)。在示例里,72 + H 是列底,英文说明放在它下方 18 点处,起点再往左偏离列中线 24 点,让这行短的拉丁文本大致落在列的正下方。函数按这个名字打不开字体时返回 0,而建立在零高度上的布局会把说明文字叠在列顶上,所以用这个值之前先测一下

为什么括号在竖列里会侧躺?

因为字体里存着这类字符的两种画法,而横排画法是默认。OpenType 字体把替代形态列在一个叫 vert 的 GSUB 特性里,自带竖排专用旋转字形的字体则用 vrt2。给一行文本做塑形得到的是横排字形,必须有人在其后应用竖排特性,一个字形换一个字形。直角括号、圆括号和长音符号转为沿列排布;小假名和 、 。 这类标点挪到字身框的另一个角落

PDF Library for Delphi 从正在绘制的字体里读这个特性,把每个字形换成字体指定的竖排形态。字体同时带两个特性时,vrt2 优先,vert 被忽略。规范的意图就是让 vrt2 取代 vert,两个都读可能让一个字符拿到两个不同的竖排字形,取决于哪个 lookup 先应答。两个特性都没有的字体保持普通字形。相比内置一张替换表,我们更喜欢这个结果:猜出来的表能对付常见字符,却会悄悄覆盖那些设计者对某字符另有一番考量的字体

把 Windows 自带的那几份字体的 GSUB 表倒出来看看,计数差距悬殊:MS Gothic 的竖排特性映射 979 个字形,Yu Gothic 2,491 个,SimSun 72 个,Arial 一个都没有(确切数字随字体版本浮动)。一款碰巧带了一些假名的拉丁字体会画出一列直立的字符,而每个括号和长音符号都还是横排形态,因为字体里没有任何东西可替换

PDF Library for Delphi 示意图:展示字体的 vert 特性如何提供沿竖列排布的括号与长音符号字形,而普通字符保持不变,全部由 GetVerticalTextHeight 度量
字体自身的纵排特性决定哪些字形在竖排中改变形态,GetVerticalTextHeight 度量的是实际将被绘制的形态

替换同样改变度量。竖排形态可以有自己的前进量,与它替换的横排字形不同,所以 GetVerticalTextHeight 在累加前进量之前应用同一套替换。按横排形态测量、按竖排形态绘制,列就会超出你预留的框,每个括号超几个点

这些都不碰文本本身。从查看器里把这一列复制出来,得到的还是「コード」,因为每个竖排字形都映射回你传入的字符。搜索和文本抽取看到的是你的源字符串,绝不是那些旋转过的呈现形态

竖排度量如何进入 PDF

竖排文本里的每个字符按字体自带竖排度量给出的距离前进。用 PDF 检查器打开输出,你会看到一款带 Identity-V 编码的 Type0 字体,其 CIDFont 为每个用到的字形携带 W2 条目:来自字体 vmtx 表的竖排前进量、字形宽度的一半作为到列中线的偏移,以及竖排原点。ISO 32000-1 §9.7.4.3 定义了这三个数。没有竖排度量的字体没有 W2 数组,DW2 默认值让每个字符正好前进一个 em

竖排原点是第三方字体互相打架的地方。vhea 表里有看起来顺理成章的 ascender 和 descender 字段,但 descender 的符号约定在不同字体之间并不一致。用任意字体表查看器读 Windows 自带副本,MS Gothic 读出 +140,Yu Gothic +120,而 SimSun 是 −144,MingLiU 是 −500,数值还会随字体版本变化。从这个字段派生的任何位置都会按字体各自漂移不同的量。PDF Library for Delphi 取竖排 ascender 作为原点——上面点名的几款 CJK 字体报告得相当一致——字体报告不出合理值时回退到 880 单位,即 PDF 规范给 DW2 的默认值

手工定位竖排字形有自己的陷阱。横排文本常用 TJ 数组里的数字微调间距。在竖排书写模式下,ISO 32000-1 §9.4.4 让这些数字沿列生效,为横排间距算出的修正会把每个字符往页面更下方推。这就是为什么内容流里的竖排文本只是一串没有任何调整的朴素字形编码字符串,所有间距都来自 W2

核对输出时看墨迹,别看字典。在字体里找到 /Identity-V 只能证明编码被声明过;它对文本实际往哪个方向跑一个字都不说。渲染页面并测量所画像素的包围盒才有说服力:七个字符的文本应该画成大约一个字符宽、七个字符高

横排页面上的一段竖排文本

DrawVerticalText 把单独一段文本竖着画出来,完全不碰书写模式。它接受位置、字体名、字号和文本,所以当前字体选择也不受影响。书脊标签、印章、表格旁的一列名单:这些是横排文档里的竖排例外,为它们挨个开关全局模式,状态管理成本比这活儿本身还大

PDF Library for Delphi 示意图:DrawVerticalText 在共享页面上把一段直立竖排文本放在普通水平 DrawText 行旁,而不触碰全局书写模式
DrawVerticalText 自带字体、字号和位置,单个竖排列可以与周围的横排调用共存
// 横排页面里的一段竖排文本
if Lib.DrawVerticalText(520, 96, 'MS Mincho', 14, '保守点検記録') = 0 then
begin
  // 字体打不开,或无法设置这段文本。
  // LogLayoutProblem 是你自己的例程,不属于库
  LogLayoutProblem('vertical label on page ' + IntToStr(Lib.SelectedPage));
end;
// 它周围的横排文本不受影响
Lib.DrawText(72, 96, 'Maintenance inspection record');

一个同时混用两个方向的页面会在资源里携带这份字体两次,一次水平编码、一次 Identity-V,两者由同一个字体程序构建。每个方向保留自己的度量,所以竖排正文配横排书眉的日文书页、竖排标题配横排明细行的中文证书,两个方向都能以正确的间距输出

返回值正是我们在基本竖排的文档里也用 DrawVerticalText 的原因。在基于模式的绘制里,一段无法竖排的文本会改用普通横排方式画出而不是被丢掉,DrawText 无论哪种情况都报告成功。对正文来说这是更好的默认:打样时一条横躺的线很好发现,一段缺失的段落却不容易。方向本身就是重点的标签,用 DrawVerticalText,把 0 当作布局失败处理

从右往左布置列

列序由你决定。日语和繁体中文的竖排文本按列从右往左读,所以页面从右边距开始,每一新列放在前一列左侧。任何书写模式设置都无法从文本推出这一点;右装订书籍的页序、furigana 注音、脚注和图注该放在哪里,也是一样

下面的例程把字符串断成能装进固定高度的列,边走边用 GetVerticalTextHeight 测量,每列向左挪一个半 em:

procedure DrawColumns(Lib: TPDFlib; const Face: WideString; Size: Double;
  const Text: WideString; Right, Top, BoxH: Double);
var
  Col, Trial: WideString;
  I: Integer;
  X: Double;
begin
  // 建议的防护,不是库检查:高度为零意味着字体
  // 打不开,下面的循环永远不会断列
  if Lib.GetVerticalTextHeight(Face, Size, Text) <= 0 then
    Exit;
  X := Right;                                  // 第一列在右边距处
  Col := '';
  for I := 1 to Length(Text) do                // 假设没有代理对
  begin
    Trial := Col + Text[I];
    if (Col <> '') and (Lib.GetVerticalTextHeight(Face, Size, Trial) > BoxH) then
    begin
      Lib.DrawVerticalText(X, Top, Face, Size, Col);
      X := X - Size * 1.5;                     // 下一列在左边
      Col := Text[I];
    end
    else
      Col := Trial;
  end;
  if Col <> '' then
    Lib.DrawVerticalText(X, Top, Face, Size, Col);
end;

每个字符都重新测量试探串比累加前进量慢,但它测量的正是将要画出的东西,字形替换包含在内。这个循环不做的是禁则处理。闭合括号或 。 出现在列顶、开放括号孤零零留在列底,在日语读者眼里就是排版错误,修好它意味着在绘制之前把一个字符推过断点或拉回来。代理对需要同样的小心:按 Text[I] 索引会把基本多文种平面之外的字符切成两半,所以文本可能含有这类字符时请按码点步进

对从数据组装的文档,文本搜索与页面元素枚举演练展示如何读回每一页上落了什么,这是确认每一列都带着你想要的字符的快捷办法

竖排文档该用哪款字体?

一款自带竖排度量、带 vert 或 vrt2 特性的 CJK 字体。没有它们,你得到的是一次前进一个 em 的直立字符,没有任何形态变化。竖排文本看着总觉得不对劲时,先查字体再查代码。一个能列出字体 GSUB 特性和 vhea、vmtx 表的表查看器,一分钟内就能告诉你这款字体干不干得了这活

DrawVerticalText 和 GetVerticalTextHeight 在 Windows 上按名称打开已安装的字体。在其他平台的 Lazarus 构建里,竖排走 SetVerticalWritingMode 和 DrawText,它们基于你加载并选中的字体程序工作,只要那款字体就是你想用的 CJK 字体,同样的竖排形态和度量照样生效

嵌入遵循通常的规则和通常的成本。完整 CJK 字体动辄好几兆,所以对任何要离开你机器的文档,子集化是明智的默认。PDF 文件大小优化与字体子集化的笔记讲预期效果,给既有 PDF 嵌入缺失字体演练则覆盖竖排文档没带字体来的修复场景

竖排 CJK 书写与 PDF Library for Delphi 其余文本函数在同一个绘制 API 里,这是一款面向 Delphi、C++Builder 和 Lazarus 的原生 Pascal PDF 库。PDF Library for Delphi 产品页并列列出全部字体与文本特性