技术文章

使用 HotPDF 将 EMF 和 WMF 矢量导入 Delphi PDF

HotPDF 是原生 Delphi 和 C++Builder PDF 组件,它通过直接将每条 GDI 记录解释为 PDF 操作符来导入 Windows EMF 和 WMF 图元文件,而不是将文件扁平化为位图:渐变填充会变成 PDF 轴向渐变图案,阴影画刷会变成 PDF 平铺图案,集中式路径状态闸门则会阻止格式错误的记录破坏输出。凡是 TChart、GDI+ 表面或普通 TCanvas 能导出为增强型图元文件的图表,都可能使用这条路径,其差异会在放大页面或将其发送到高分辨率打印机时立即显现

大多数 Delphi 开发者默认采用的替代方案,是先将图元文件栅格化为位图,再把它放到页面上,但代价往往要到之后才会显现:屏幕上原本清晰的柱状图,在以 600 DPI 打印 PDF 或投影到会议室屏幕上时会立刻变得明显块状;如果没有保留填充样式,使用阴影填充的 CAD 区域也会塌缩成一块单调的灰色矩形。将图元文件作为程序而不是图片读取,可以避免这两个问题,而要正确实现这条路径更困难,所以在报告发布前了解下面这些陷阱很有价值

为什么要解释图元文件,而不是将其扁平化为位图

HotPDF 让 EMF 和 WMF 导入保持矢量路径,是因为 Windows 图元文件是记录下来的 GDI 绘图调用序列,而不是一张图片;将这些调用重放为 PDF 路径、文本和渐变操作符,才能让结果像页面其他内容一样缩放。THPDFPage.ShowMetafile 及其对应的 ShowMetafileEx 是应用程序调用的入口,两者都会将图元文件交给 THPDFWmf,由这个类遍历并转换每条 GDI 记录。这种区分并非绝对,HotPDF 也不会假装如此:真正属于栅格数据的图元文件记录,例如 StretchDIBits 位图传输,会通过 AddImageShowImage 嵌入为真正的 PDF Image XObject;页面上的其他图片也会经过同一对调用,而不会被强行转换成无法表达照片的路径操作符。线条、填充和文本保持矢量;源文件中本来就是像素的内容,在输出中仍然是像素。最简单的调用只需要已加载的图元文件:

var
  Pdf: THotPDF;
  Chart: TMetafile;
begin
  Pdf := THotPDF.Create(nil);
  Chart := TMetafile.Create;
  try
    Chart.LoadFromFile('quarterly-revenue.emf');  // exported from TChart or GDI+
    Pdf.FileName := 'quarterly-report.pdf';
    Pdf.BeginDoc;
    Pdf.CurrentPage.ShowMetafile(Chart);
    Pdf.EndDoc;
  finally
    Chart.Free;
    Pdf.Free;
  end;
end;

解释器如何将 GDI 坐标转换为 PDF 页面空间

HotPDF 通过单次遍历图元文件自身的记录流来完成这一点,而不是重新实现一套 GDI。THPDFWmf.Analyse 通过 Win32 GetEnhMetaFileHeader 调用读取图元文件头,重置内部绘图状态,并调用 EnumEnhMetafile,这正是图元文件查看器使用的枚举 API,因此每条 EMR_* 记录都会按照原始记录顺序到达 THPDFWmf.ExecuteRecord。GDI 按图元文件自身映射模式选择的设备单位或逻辑单位从上到下表达坐标;PDF 页面则在用户空间点中从下到上表达坐标,这正是HotPDF 路径和填充画布绘制模型所涵盖的坐标系。每个记录处理器通过 ScaleXScaleY 解决这种差异,而这两个方法会调用 ProjectXProjectY,重放 GDI 自身针对各向异性和等向性映射模式的窗口到视口公式,因此无论源应用设置了什么窗口和视口范围,按五个逻辑单位宽度记录的形状都能以正确宽度落在 PDF 点中

GDI 渐变填充如何变成 PDF 渐变图案

EMR_GRADIENTFILL 记录只要是 GDI 以两种矩形模式之一记录的,就会变成真正的 PDF Type 2 轴向渐变图案(ISO 32000-1 §8.7.4.5)。THPDFWmf.VEMRGradientFill 直接从原始字节缓冲区读取记录自身的布局,并遵循 MS-EMF §2.3.1.6 结构:先是由 16 位 RGBA 角点组成的顶点数组,随后是每个引用其中两个顶点的矩形列表。对于 GRADIENT_FILL_RECT_H,颜色沿矩形水平中线从左向右渐变;对于 GRADIENT_FILL_RECT_V,颜色沿垂直中线从上向下渐变。无论哪种情况,两个角点颜色和投影后的矩形坐标都会直接传给 THotPDF.RegisterAxialGradient,该方法返回图案名称;页面随后绘制矩形并通过该图案(SetFillPattern)填充,而不是调用单色的 SetRGBFillColor,因此电子表格风格的带状表头或图表的渐变绘图区能够保留混合效果,而不会塌缩为一种平均颜色

Gouraud 三角形模式是一个明确存在的缺口。当记录的 ulMode 字段报告 GRADIENT_FILL_TRIANGLE 时,VEMRGradientFill 会识别该模式,记录三角形模式尚未实现,然后跳过该矩形,而不是猜测一个双色近似。任意三角形网格上的逐顶点、逐像素插值无法化简为双色轴向或径向渐变;要正确表达它,就需要输出 PDF Type 4 或 Type 5 网格渐变,这与HotPDF 页面渲染器回读 PDF 时同样留白的渐变类别相同。两个彼此无关的代码路径最终落在同一边界:网格渐变既是写入侧的缺口,也是读取侧的缺口;使用 Gouraud 三角形实现平滑径向光晕的源图表,会回退到上一个纯色画刷,而不是生成渲染近似效果

阴影画刷变成平铺图案,而不是扁平化的灰色

GDI 阴影画刷之所以能在 PDF 中保留纹理,是因为 THPDFWmf.SetBrushColor 会先检查 CurrentBrush.lbStyle 是否为 BS_HATCHED,然后才回退到纯色填充,并将这种情况转交给 SetHatchBrushPattern。该方法会写入由描边线操作符 mlS 组成的 8×8 单位 PDF 内容流,具体线条由 GDI 阴影样式选择:HS_HORIZONTALHS_VERTICAL 对应单条水平或垂直线,HS_FDIAGONALHS_BDIAGONAL 对应三条平行对角线,HS_CROSSHS_DIAGCROSS 对应水平加垂直或双对角线组合。THotPDF.RegisterTilingPattern 将该内容流注册为带颜色的平铺图案(PaintType 1、ISO 32000-1 §8.7.3.1),并以 8 单位的 XStepYStep 进行平铺;页面也会像使用轴向渐变一样通过 SetFillPattern 填充。依靠阴影填充区分材料的 CAD 平面图或工程图,能够在 PDF 中保留这种视觉语言,而不会让所有区域都变成相同的灰色

并非每种画刷都能获得这种处理,在 CAD 导入发布前了解这个缺口很重要。EMR_CREATEDIBPATTERNBRUSHPT 是自定义位图图像图案画刷的记录,而不是 GDI 六种标准阴影样式之一;它只会注册自己的句柄,以保持后续 SELECTOBJECTDELETEOBJECT 记录的一致性。HotPDF 目前还没有为任意平铺图像提供 PDF Pattern 资源管线,因此选择该画刷时会回退到纯色,而不是使用源纹理。如果原图明显使用了重复图像纹理,但填充却呈现为纯色,那么源画刷几乎肯定是自定义 DIB 图案,而不是标准阴影;这是唯一值得先手动检查的情况。为这类图形配置导入时,仍然使用同一个选项对象:

var
  Pdf: THotPDF;
  Drawing: TMetafile;
  Options: THPDFEmfOptions;
begin
  Pdf := THotPDF.Create(nil);
  Drawing := TMetafile.Create;
  Options := THPDFEmfOptions.Create;
  try
    Drawing.LoadFromFile('floor-plan.emf');
    Options.Assign(Pdf.EmfOptions);   // start from the document-wide defaults
    Options.Redraw := False;          // interpret the original EMF bytes, no GDI re-record pass
    Options.ShowNullBrush := True;    // keep explicitly unfilled CAD regions visible
    Options.UseFrame := True;         // clip output to the frame the EMF header declares
    Pdf.FileName := 'floor-plan.pdf';
    Pdf.BeginDoc;
    Pdf.CurrentPage.ShowMetafileEx(Drawing, Options);
    Pdf.EndDoc;
  finally
    Options.Free;
    Drawing.Free;
    Pdf.Free;
  end;
end;

如何阻止格式错误的图元文件破坏页面

HotPDF 的做法是在 ExecuteRecord 顶部设置一个统一闸门,而不是在大约八十个记录处理器中重复防御性检查。由 EMR_BEGINPATH 开始、由 EMR_ENDPATHEMR_ABORTPATH 结束的 GDI 路径括号,由私有的 PathContinue 属性跟踪,该属性由 FPathContinue 字段支持。当括号打开时,ExecuteRecord 只允许路径构造记录通过,包括移动、直线、多段线、多边形、polybezier 和 polydraw 变体,以及 CLOSEFIGURE 和少量变换与 DC 状态记录,例如 SETWORLDTRANSFORMSAVEDCRESTOREDC。当括号打开时到达 ExecuteRecord 的其他所有记录类型,例如异常的 EXTTEXTOUT 或位图传输,都会在到达的瞬间由一个统一的 Exit 集中丢弃

设置这个闸门,是因为手工编写、工具生成或单纯损坏的图元文件,其路径括号不一定只包含格式良好的文件会放在开始和结束记录之间的内容。没有闸门时,落在 EMR_BEGINPATHEMR_ENDPATH 之间的文本输出记录,可能污染正在构建的路径几何,或者在本应只进行路径构造的序列中输出 PDF 文本显示操作符;这两种故障都可能只在第三方工具生成的一份格式错误输入上出现,而不是普通测试套件恰好覆盖的情况。将检查集中在 ExecuteRecord 中,意味着各个 VEMR* 处理器不必分别防止自己在错误时机被调用;闸门会在分派前统一做出判断,而不是在分派后重复八十次

在同一页面上将矢量图表与文本和图像并排放置

报告页面很少只包含图表,ShowMetafile 与 HotPDF 的其他页面操作符组合起来时,和其他绘图调用完全一样。使用 TextOut 绘制的标题、作为 EMF 导入的阴影填充柱状图,以及通过 ShowImage 放置的徽标,都可以在同一页面、同一内容流中呈现,并各自保持原生保真度,这就是HotPDF 报告中文本、字体和图像布局指南所介绍的组合模式:

Pdf.CurrentPage.SetFont('Arial', [fsBold], 14);
Pdf.CurrentPage.TextOut(50, 760, 0, 'Q2 Regional Sales');
Pdf.CurrentPage.ShowMetafile(RegionChart);   // hatch-filled bars, still vector
Pdf.CurrentPage.ShowImage(LogoIndex, 450, 760, 90, 30, 0);

本文介绍的 EMF 和 WMF 解释器、为渐变填充注册的轴向渐变图案,以及阴影画刷的平铺图案映射,都是适用于 Delphi 和 C++Builder 的标准 HotPDF Component 的组成部分;这是一个原生 VCL 库,整个过程无需任何外部 DLL 依赖