作为 losLab 面向 Delphi 的 PDF 库,PDFlibPas 把 EMF Poly* 记录转成 PDF 路径时,严格按 [MS-EMF] 里每条记录的定义来:32 位的 EMR_POLYBEZIER 从点 0 起笔,折线保持开放、只描边,EMR_POLYDRAW 里的 PT_CLOSEFIGURE 是个标志位,每个点数都要对照记录大小做校验。这些规则分布在 v3.539.39、v3.539.41 和 v3.539.43 三个版本里落地。在此之前,一份报表图表从 ImportEMFFromFile 出来,趋势线下方可能多出一块实心楔形,贝塞尔曲线可能弯向错误的控制点,闭合轮廓也可能缺最后一条边。这些情况没有一个报错,而这些规则对任何 Delphi 的 EMF 转 PDF 转换器或 GDI 记录解析器都适用
EMF Poly* 记录在 PDF 转换中为什么会出错?
EMF Poly* 记录会出错,是因为每条记录都有一部分含义不在点位里:图形是开放还是闭合、是否从当前位置起笔、该用哪支画笔和哪个画刷、点在记录里从哪个字节开始。enhanced metafile 是对 GDI 在设备上下文上调用过程的录制,所以转换器除了坐标还得重放那套设备上下文状态。PDF 没有设备上下文,它只有路径、路径内的当前点,以及一个在描边(S)、填充(f)和两者都做(B)之间做决定的绘制操作符。两个模型之间的每一处错配,都会变成一次无声的渲染差异
Poly* 家族还有宽窄两种版本。每条 32 位记录(如 EMR_POLYLINE)都有个 16 位孪生(如 EMR_POLYLINE16),后者把点存成 SmallInt 对。只要每个坐标都放得下,GDI 通常录的是紧凑的 16 位形式,于是转换器的 32 位处理器可能一连错上好几年,日常测试图却永远碰不到它们。最快的审计办法,是把同一批点分别喂给两种记录,比较产出的路径。本文涉及的记录都在 [MS-EMF] 的绘图记录组里(2.3.5 Drawing Record Types)
| 记录 | 起始于 | 闭合? | 当前位置 |
|---|---|---|---|
EMR_POLYBEZIER | 点 0 | 否 | 不使用、不更新 |
EMR_POLYLINE | 点 0 | 否(仅画笔) | 不使用、不更新 |
EMR_POLYLINETO | 当前位置 | 否(仅画笔) | 使用并更新 |
EMR_POLYPOLYLINE | 每条折线的第一个点 | 否(仅画笔) | 不使用、不更新 |
EMR_POLYDRAW | 第一个 PT_MOVETO,或当前位置 | 仅在置了 PT_CLOSEFIGURE 处 | 使用并更新 |
EMR_POLYBEZIER 曲线到底从哪一点开始?
EMR_POLYBEZIER 曲线从点 0 开始,只有索引 1 之后的点才按三个一组配成控制点、控制点、终点。于是 7 个点的记录画两段三次曲线:0 是起点,1 到 3 是第一段,4 到 6 是第二段。PDFlibPas 的 16 位处理器一直是这么做的;32 位处理器却从点 0 开始分组,起点被当成第一个控制点吃掉,后面每一段都错位一格。曲线照样画得出来,只是画错了。从 v3.539.41 起,两种宽度都在点 0 用 m 开路径,之后的每个完整三元组各发一个 c
给你自己的解析器提个醒:点数若不是 1 加 3 的倍数,就是畸形记录,尾部剩下的点应该直接忽略,而不是缝进曲线里
PolyDraw:PT_CLOSEFIGURE 是标志位,不是点类型
在 EMR_POLYDRAW 里,PT_CLOSEFIGURE(值 1)是一个与 PT_LINETO(2)或 PT_BEZIERTO(4)按位组合的标志,所以合法的类型字节可以是 3 或 5。点类型是把这一位掩掉之后的字节,这个标志的含义是:在以该点收尾的那一段之后闭合图形。旧的 PDFlibPas 处理器用 case 语句把字节与单值逐一比对,类型 3 和 5 什么都匹配不上,整个点就被跳过。用 PolyDraw 画的矩形丢了闭合边,贝塞尔三元组若最后一个点带着标志就丢掉那个点,后面每个三元组也都跟着错位
从 v3.539.39 起,类型按 Types[i] and not PT_CLOSEFIGURE 读取,闭合只在完整的一段之后发出:闭合的 PT_LINETO 在那条线之后,贝塞尔组在第三个点之后。畸形文件若把标志设到三元组的第一或第二个点上,图形不会提前闭合。同一版本还带出两个相关修复:
- 16 位
EMR_POLYDRAW16里的每个PT_MOVETO都会重启整条路径,一条含三个图形的记录最后只剩最后一个;现在第一个 move 开启路径,后续的 move 开子路径 - 不以
PT_MOVETO开头的 PolyDraw 记录按记录定义从当前位置起笔,而不是写出前面没有m的l或c操作符
EMF 折线为什么在 PDF 里绝不能填充?
EMF 折线绝不能填充,因为 EMR_POLYLINE 和 EMR_POLYPOLYLINE 是只拿画笔描的开放图形,而在 PDF 里填充开放路径会隐式闭合它。ISO 32000-1 §8.5.3 写明填充操作符在绘制之前会先闭合所有开放子路径。转换器若给三点折线发出 B 或 f,就会用当前画刷颜色涂出一个实心三角——正是图表趋势线下方那块多余楔形的来历。v3.539.41 之前,PDFlibPas 对两种宽度的折线都拿画刷填充,32 位记录还被显式闭合。现在两种宽度都只以描边收尾,GDI 的区分得以保留:Polygon 闭合并填充,Polyline 永不如此
PolylineTo 从当前位置起笔
EMR_POLYLINETO 从当前位置出发穿过记录里的每个点,保持开放,最后把当前位置留在最后一个点上。旧处理器里还有个特例:前两个点 y 坐标相同时把画笔关掉,而没有任何代码再把它打开,于是文件里后面所有记录都丢了轮廓。画笔状态归 EMR_SELECTOBJECT 和 EMR_CREATEPEN 管,绘图记录处理器没资格动它。这个特例在 v3.539.41 移除,记录的单点形式也不再读越界(v3.539.39 修复)
PolyPolyline 的点从计数数组之后开始
32 位的 EMR_POLYPOLYLINE 先存 nPolys 个计数,再存 cptl 个点,点数组从字节偏移 32 + nPolys * 4 开始。坑在 RTL 里:Windows 单元把 TEMRPolyPolyline 的 aPolyCounts 和 aptl 声明成单元素数组,所以只有 nPolys 为 1 时 aptl[0] 才是第一个点。直接按下标访问 aptl 的代码,碰到每条多折线记录都会把计数值当坐标读。旧 PDFlibPas 处理器的越界检查也是按这个错误布局算的,合法的多折线记录被拒收,单折线的又什么都没画。从 v3.539.41 起,PDFlibPas 按计算出的偏移定位点数组——它的 PolyPolygon 处理器一直是这么干的——并把每条折线画成自己的开放子路径,最后统一描边一次。v3.539.43 给 16 位孪生同样的待遇;它此前逐段绘制,既破坏线段连接,也无视选中的 NULL_PEN
默认画笔与画刷,以及路径括号
v3.539.43 的折线修复还有两条状态规则收尾:
- 新初始化的 GDI 设备上下文已经选中了
BLACK_PEN和WHITE_BRUSH,所以一条不发出任何EMR_SELECTOBJECT的 metafile 照样画黑色轮廓;转换器过去却以无画笔、无填充起步,给这类记录写出n(结束路径,什么也不画) - 在
BeginPath/EndPath括号之内,Polyline既不使用也不更新当前位置,所以它必须在自己的第一个点开新子路径,而不是接上前一个图形;括号被描边或填充之前,什么都不能画
用 TMetafileCanvas 构造 EMF 测试文件
检验转换器是否守住这些规则,最快的办法是用 TMetafileCanvas 把三个有风险的调用录进同一条 enhanced metafile。下面的绘制先用空心画刷录曲线,再故意给折线选上实心黄画刷:正确的转换器对折线必须无视那只画刷,所以输出 PDF 里出现任何黄色都是 bug。PolyDraw 没有 TCanvas 包装,只能拿画布句柄走 Windows API 调用,类型字节特意用 3 和 5 来练闭合标志
uses
Winapi.Windows, System.Types, Vcl.Graphics;
procedure BuildPolyTestEmf(const FileName: string);
const
// 一个闭合正方形(3 = LINETO + CLOSEFIGURE),后面接一个闭合
// 贝塞尔图形,其最后一个控制三元组以 5 = BEZIERTO + CLOSEFIGURE 收尾
DrawPts: array[0..7] of TPoint = (
(X: 300; Y: 40), (X: 380; Y: 40), (X: 380; Y: 120), (X: 300; Y: 120),
(X: 420; Y: 120), (X: 440; Y: 40), (X: 520; Y: 40), (X: 540; Y: 120));
DrawTypes: array[0..7] of Byte = (
PT_MOVETO, PT_LINETO, PT_LINETO, PT_LINETO or PT_CLOSEFIGURE,
PT_MOVETO, PT_BEZIERTO, PT_BEZIERTO, PT_BEZIERTO or PT_CLOSEFIGURE);
var
Mf: TMetafile;
Canvas: TMetafileCanvas;
begin
Mf := TMetafile.Create;
try
Mf.Enhanced := True;
Mf.Width := 600;
Mf.Height := 260;
Canvas := TMetafileCanvas.Create(Mf, 0);
try
Canvas.Pen.Color := clNavy;
Canvas.Pen.Width := 2;
Canvas.Brush.Style := bsClear; // 曲线只画轮廓
// 点 0 是起点;1..3 与 4..6 是两段三次曲线
Canvas.PolyBezier([Point(20, 120), Point(60, 20), Point(100, 220),
Point(140, 120), Point(180, 20), Point(220, 220), Point(260, 120)]);
PolyDraw(Canvas.Handle, DrawPts[0], DrawTypes[0], Length(DrawPts));
// 选着实心画刷画开放的 V 形:只描边,
// 绝不闭合成一个黄色三角形
Canvas.Brush.Style := bsSolid;
Canvas.Brush.Color := clYellow;
Canvas.Polyline([Point(20, 240), Point(120, 160), Point(220, 240)]);
finally
Canvas.Free; // 结束录制
end;
Mf.SaveToFile(FileName);
finally
Mf.Free;
end;
end;
这些坐标都放得进 SmallInt,GDI 通常会存 16 位变体。想命中 32 位处理器,你需要一个会写它们的生成器,或者手工拼记录。手工拼的文件自带一个坑:VCL 的 TMetafile.LoadFromStream 只有在剩余长度严格大于 108 字节的 TEnhMetaHeader 时才把流当 EMF。头短了的最小手写 EMF,或者恰好 108 字节的空文件,都会被当成 WMF,以“Metafile is not valid”拒收。测试记录之前,务必写全 108 字节的头,包括扩展字段
用 PDFlibPas 把 EMF 导入 PDF
PDFlibPas 用 ImportEMFFromFile 或 ImportEMFFromStream 导入 EMF,成功返回非零图像 ID,失败返回 0。GeneralOptions = 0 保留本文讨论的矢量路径;1 则把 metafile 栅格化成位图。FontOptions = 1 会把 metafile 里的字体按非内嵌 TrueType 加入。流变体在加载前会把流回卷到位置 0,所以传进来的流里应该只装 metafile
uses
System.SysUtils, PDFlibrary;
procedure EmfToPdf(const EmfFile, PdfFile: WideString);
var
PDF: TPDFlib;
ImageID: Integer;
PageOps: AnsiString;
begin
PDF := TPDFlib.Create;
try
PDF.SetOrigin(1); // DrawImage 采用左上角原点
PDF.SetMeasurementUnits(0); // 单位:磅
// FontOptions 1 = 字体按非内嵌 TrueType 加入
// GeneralOptions 0 = 矢量导入,1 = 位图
ImageID := PDF.ImportEMFFromFile(EmfFile, 1, 0);
if ImageID = 0 then
raise Exception.Create('The metafile could not be imported');
PDF.SelectImage(ImageID);
// 对 EMF 而言,ImageWidth / ImageHeight 是以磅为单位的边框尺寸
PDF.DrawImage(36, 36, PDF.ImageWidth, PDF.ImageHeight);
// 页面内容只调用导入的表单:q ... cm /Name Do Q
PageOps := PDF.GetPageContentToString;
if Pos(AnsiString(' Do'), PageOps) = 0 then
raise Exception.Create('Expected a form XObject invocation');
if PDF.SaveToFile(PdfFile) <> 1 then
raise Exception.Create('The PDF could not be saved');
finally
PDF.Free;
end;
end;
矢量 EMF 导入会变成一个 form XObject,所以 GetPageContentToString 只返回保存、变换、Do、恢复这一串。由 Poly* 记录产生的 m、l、c、h 和 S 操作符住在 form XObject 流里,而那个流是压缩过的。要审计它们,先在 PDF 对象查看器里解压保存后的文件,再读表单流:对上面的测试文件,你应该看到折线以 S 收尾、前面没有 h,PolyDraw 图形在每个闭合标志处各有一个 h,并且这些子路径上没有任何 f 或 B。DrawImage 还会按 Width 与 Height 中较小者对导入的 EMF 做等比缩放,所以即使你传入的框比例不匹配,图形也不会变形
若目标是 Free Pascal,参见PDFlibPas EMF 矢量导入器如何在 Free Pascal 下构建;无论导入器在哪个平台编译,记录语义都一样
EMF 解析器该如何对待文件里的点数?
EMF 解析器应该把每个点数都当不可信输入,在拷贝第一个点之前先对照记录大小校验。EnumEnhMetaFile 只保证每条记录的 nSize 不越出文件,并不检查 cptl 与 nSize 是否一致,所以用 Move 拷 cptl 个点的处理器,遇到伪造或损坏的计数就会读到后续记录,甚至越过 metafile 末尾。从 v3.539.39 起,PDFlibPas 对 PolyDraw、PolyBezier、PolyBezierTo、Polyline、PolylineTo 和 Polygon 的两种宽度都按「定长头 + 计数 × 每点字节数」对照 nSize 检查,PolyDraw 的类型字节每点再加一个字节。对 PolyPoly 记录,各图形的计数加起来不得超过声明的总数,零点的图形直接跳过
同样的检查短到可以直接抄进你自己的解析器。这一版校验 32 位 EMR_POLYPOLYLINE,并返回指向真实点数组的指针:
uses
Winapi.Windows;
// 除非记录确实装得下它声明的点数,否则返回 nil。
// 点从计数数组之后开始:位于 32 + nPolys * 4 字节处,
// 而不是 RTL 声明成单元素数组的 aptl[0]
function PolyPolylinePoints(Rec: PEnhMetaRecord): PPoint;
var
P: PEMRPolyPolyline;
Count: PDWORD;
PointsOffset, Total: Int64;
I: Cardinal;
begin
Result := nil;
if (Rec^.iType <> EMR_POLYPOLYLINE) or (Rec^.nSize < 32) then
Exit;
P := PEMRPolyPolyline(Rec);
if P^.nPolys = 0 then
Exit;
PointsOffset := 32 + Int64(P^.nPolys) * SizeOf(DWORD);
if PointsOffset + Int64(P^.cptl) * SizeOf(TPoint) > Rec^.nSize then
Exit; // 计数被伪造或截断
Total := 0;
Count := @P^.aPolyCounts[0]; // 按指针遍历:[0..0] 会触发范围检查
for I := 1 to P^.nPolys do
begin
Inc(Total, Count^);
Inc(Count);
end;
if Total > P^.cptl then
Exit; // 各图形的点数声明超出实际
Result := PPoint(NativeUInt(Rec) + NativeUInt(PointsOffset));
end;
点数是否装得下的检查先行,所以循环遍历计数数组之前,它已被确认落在记录内部。算术用 Int64,因为 nPolys * 4 和 cptl * 8 若按 32 位计算可能回绕,反而通过比较
速查:EMF 转 PDF 的 Poly* 记录规则
EMR_POLYBEZIER:点 0 是起点;从点 1 起三个一组;32 位记录的修复在 v3.539.41EMR_POLYLINE/EMR_POLYPOLYLINE:开放图形,用S描边,绝不用h、f或B,因为 PDF 填充会闭合开放子路径EMR_POLYLINETO:从当前位置起笔,保持开放,更新当前位置,绝不碰画笔状态- 32 位
EMR_POLYPOLYLINE:点从字节32 + nPolys * 4开始,不在aptl[0] EMR_POLYDRAW:分发前掩掉PT_CLOSEFIGURE,完整段之后才闭合,第一个点不是PT_MOVETO时从当前位置起笔- 设备上下文默认状态是
BLACK_PEN加WHITE_BRUSH;v3.539.43 及之后遵守它 - 在
BeginPath/EndPath之内,每条折线开自己的子路径,括号被使用之前什么都不画 - 拷贝点之前,用 64 位算术对照
nSize校验每个cptl/cpts - 手工拼的测试 EMF 需要完整的 108 字节头,否则
TMetafile.LoadFromStream会把它们当 WMF 读
如果你的报表走的是另一个组件,记录语义同样适用;HotPDF 的 EMF 与 WMF 矢量导入讲那个组件如何把渐变和阴影线画刷变成 PDF 图案,PDFlibPas 的矢量图形、shader 与渐变则讲不经 metafile、直接用库 API 画同样的形状
PDFlibPas v3.539.43 及之后版本包含上述全部规则。详情与试用下载见 PDFlibPas Delphi PDF 库产品页