FEATURED · 精选文章

虚拟打印机源码解析:从GDI回调到EMF转换的关键实现

发布时间 / 2026/9/16 1:32:48
来源 / 创域科博编辑部
栏目 / 资讯中心
虚拟打印机源码解析:从GDI回调到EMF转换的关键实现 简介一套基于C与Delphi开发的虚拟打印机完整源码适用于Windows 2000/XP/2003系统核心功能是将打印任务内容暂存于内存便于转换为PDF、JPEG等格式。压缩包共161个文件、约362KB内含C语言源文件、Delphi工程文件dpr/dof、窗体定义dfm、头文件与资源脚本以及编译好的exe、dll和驱动安装inf可对照查看GDI调用、打印队列管理和格式转换的具体实现。已有631人学习适合希望深入理解Windows打印子系统、或打算自研虚拟打印功能的开发者研读。通过分析这份代码既能掌握虚拟打印机从驱动模拟到文件输出的完整流程也能借鉴C与Delphi混合编程的工程组织方式用于实际项目集成或功能扩展。源码包含完整的驱动程序框架与内存处理逻辑对于从事打印开发、文档转换工具研发的技术人员尤为实用。1. 虚拟打印机源码包从GDI回调到EMF转储的关键路径这份虚拟打印机源码下载.7z解压之后只有十几个文件LIBINIT.ASM、emf.c、text.c、几个 bat 脚本和两张 BMP 位图乍看不像能编译出一个完整驱动的东西。但拆过打印驱动的人一眼就能看出这是一个 2000 年前后为 Windows 2000/XP/2003 设计的虚拟打印驱动骨架用 ASM 写核心初始化用 C 实现 EMF 解释和格式转换用 Delphi 做界面配置批处理负责把驱动文件复制到系统目录。它解决的问题很具体——把应用程序发给打印机的 GDI 绘图指令截获下来在内存里转成 EMF 或位图而不是真的送到物理设备。适合正在学习 Windows 打印子系统、想理解 GDI 与驱动交互、或者需要维护旧打印虚拟化项目的开发人员。源码本身的编译环境已经过时但它的分层思路和错误处理方式放到现在的 PDF 虚拟打印机实现里依然成立。2. 打印驱动入口与 LIBINIT.ASM 的初始化逻辑2.1 虚拟打印驱动在 Windows 打印链路上的位置物理打印机驱动要实现一组 DDIsDevice Driver Interfaces比如DrvEnableDriver、DrvEnablePDEV、DrvStartDoc等由打印池spooler加载后调用。虚拟打印机驱动则把这组 DDI 当成“接收端”应用程序调用CreateDC创建打印设备上下文时GDI 会通过驱动入口建立会话随后所有绘制指令都以DrvTextOut、DrvBitBlt、DrvLineTo等回调形式进入我们的代码。这份源码里的LIBINIT.ASM就是干这个入口活的它负责导出必需的回调函数表并把自己注册到 GDI 的驱动表里。; LIBINIT.ASM 中典型的驱动函数表导出结构 ; 实际代码因编译器和平台 SDK 版本不同有所调整 DrvFnTable: dd offset DrvEnableDriver ; 驱动启动入口 dd offset DrvEnablePDEV ; 创建物理设备 dd offset DrvStartDoc ; 文档开始 dd offset DrvStartPage ; 页面开始 dd offset DrvTextOut ; 文本输出 dd offset DrvBitBlt ; 位块传输 dd offset DrvEndDoc ; 文档结束这段汇编的关键不是语法而是顺序。GDI 通过EngDeviceIoControl和函数表索引来调用驱动函数表的每一项偏移必须是固定顺序。如果你把DrvStartPage和DrvBitBlt位置对调GDI 会把位图绘制命令当成页面切换来解析轻则打印出黑白颠倒的乱码重则直接蓝屏。2000 年那个年代没有 PDB 一步步调只能靠OutputDebugString和串口调试器看调用序列。2.2 DrvEnableDriver 中的版本协商与能力声明入口函数DrvEnableDriver会接收一个DRVENABLEDATA结构里面包含 GDI 的版本号和函数表指针。驱动需要判断 GDI 版本是否大于等于DDI_DRIVER_VERSION_500对应 Win2000否则拒绝加载。这套源码里用 ASM 而不是 C 来实现这个入口主要是因为老的 DDK 链接库对 C 运行时依赖强而虚拟打印机驱动常被部署在没有 VC 运行时的机器上用汇编可以做到零外部依赖。// 用 C 语言表达同样的版本协商逻辑便于理解 BOOL DrvEnableDriver(ULONG iEngineVersion, ULONG cj, DRVENABLEDATA *pded) { if (iEngineVersion DDI_DRIVER_VERSION_NT5) { return FALSE; // 拒绝在 NT4 上加载 } pded-pdrvfn DrvFnTable; // 指向汇编中定义的函数表 pded-c sizeof(DrvFnTable) / sizeof(pfn); // 导出函数个数 return TRUE; }这里有三个容易踩的坑第一iEngineVersion是 GDI 传给我们的不是我们自己声明的能力不要对它做大于判断时漏掉等号第二函数表必须放在可读写的内存段不能是常量段因为 GDI 会在加载后回填一些可选函数指针第三如果同时导出DrvScribble和DrvStrokePathGDI 会优先使用更高效的光栅化路径这会影响之后 EMF 记录的类型分布。2.3 PDEV 与 Surface 建立的调试技巧DrvEnablePDEV返回一个驱动私有的设备句柄这个句柄会被 GDI 用来关联后续的绘图调用。在这个源码中LIBINIT.ASM里花了很多指令在初始化一个固定大小的内存池供DrvEnableSurface使用——因为虚拟打印机不需要真正的屏幕位图而是把 GDI 命令记录到缓冲区所以表面只是一个逻辑场景。调试时最直接的办法是在每个 DDI 回调入口加一个无条件的EngDebugBreak然后挂上内核调试器查看栈回溯确认回调顺序是否与预期一致。提示XP 和 2003 的 GDI 在回调失败时行为不同XP 会重试三次2003 直接返回错误给应用程序。如果你的驱动在 2003 上表现为“打印到 99% 就报错”多半是在某个DrvStartBand回调中返回了FALSE而不是调用EngSetLastError。3. 批处理脚本与驱动组件布局cops.bat / copw.bat / copyhere.bat 的作用3.1 四个批处理的分工推断与验证方法源码包里的cops.bat、copw.bat、copyhere.bat、copd.bat都是 2000 年留下的构建与安装脚本。从命名规律看cops 可能是指从公共位置复制可选的资源文件例如 MSG00001.bin、两张 BMPcopw 是复制 Windows 系统目录相关文件copd 是复制驱动文件本身copyhere 则是把编译产物复制到当前目录方便打包。这类脚本往往依赖相对路径解压到不同于作者机器的路径就会失效。echo off rem cops.bat - 复制驱动组件到目标目录 rem 参数1: Windows 系统目录例如 C:\WINNT\System32 rem 参数2: 驱动安装目录 if %1 goto usage if %2 goto usage copy /Y emf.c.dll %2\emf.dll copy /Y text.c.dll %2\text.dll copy /Y MSG00001.bin %1\spool\drivers\color\MSG00001.bin copy /Y iqmc.bmp %1\spool\drivers\color\iqmc.bmp copy /Y bw.bmp %1\spool\drivers\color\bw.bmp echo Files copied. goto end :usage echo Usage: cops.bat ^system32_dir^ ^driver_dir^ :end这段脚本的逻辑是把不同的模块复制到不同位置其中MSG00001.bin和 BMP 文件通常被 GDI 用于 ICMImage Color Management配置。emf.c.dll和text.c.dll是动态加载的格式插件驱动主体只通过函数指针调用它们好处是调整 EMF 解析逻辑时不需要重新安装驱动替换 DLL 即可。但 Windows 驱动签名机制出现之前这个方案很常见XP SP2 之后会因找不到签名而拒绝加载。所以如果你在 2003 上能跑在 XP SP2 上蓝屏先检查这些 DLL 是否收到验证。3.2 文件布局对虚拟打印机行为的影响下面表格是我从源码包文件推断的组件职责实际使用时应以编译后的安装日志为准文件推测职责依赖关系LIBINIT.ASM驱动入口、GDI 回调表无emf.cEMF 记录解析与输出调用 GDI32.DLLtext.c文本处理可能处理 TrueType 字体轮廓依赖 emf.c 提供上下文cops.bat / copw.bat / copd.bat构建后复制脚本依赖编译产物MSG00001.bin消息资源或 ICM 配置文件被驱动资源加载iqmc.bmp / bw.bmp打印机图标或测试图像被安装脚本引用安装时最常见的失误是把emf.c.dll覆盖到系统目录而不是驱动目录导致 GDI 通过LoadLibrary时按系统路径找到旧版本路径不同但模块同名引用计数错乱后打印任务反复重试。排查方式是在copyhere.bat里加一行echo %cd%输出当前目录确保脚本始终在源码包根目录执行。3.3 构建脚本在 64 位系统上的重写思路源码里的批处理不识别WOW64文件系统重定向在 64 位 Windows 上执行copy /Y emf.c.dll %2\emf.dll时如果%2是C:\Windows\System32会被重定向到SysWOW64。现代重写思路是显式调用%SystemRoot%\Sysnative\cmd.exe /c copy ...来操作真实系统目录。但这种做法只适用于调试生产环境应改用 INF 文件配合PrintConfig安装。rem 在 64 位系统上强制复制到物理 System32 %SystemRoot%\Sysnative\cmd.exe /c copy /Y emf.c.dll %SystemRoot%\System32\spool\drivers\x64\3\emf.dll注意Sysnative仅在 32 位进程内可用批处理由 cmd.exe 执行时通常被认为是 32 位因此需要使用完整路径而不是copy本身。如果脚本是在 64 位 cmd 中运行则直接使用System32就行这个差异是许多“脚本在我机器上能用换台机器就失败”的根源。4. emf.c 与 text.c 的格式转换核心从打印数据到 EMF/DIB4.1 EMF 记录流的捕获与解析虚拟打印机把应用程序通过 GDI 发出的绘制指令记录成 EMFEnhanced Metafile每个绘图动作变成一条记录比如EMR_TEXT、EMR_BITBLT、EMR_STRETCHDIBITS。emf.c的主要工作就是维护一个ENHMETARECORD链表在DrvStartDoc时初始化在DrvEndDoc时把它序列化为标准 EMF 文件。要理解这段源码关键是先知道 GDI 记录的格式typedef struct tagENHMETARECORD { DWORD iType; // 记录类型如 EMR_TEXT 82 DWORD nSize; // 记录总大小包含这个头 BYTE rclBounds[16]; // 边界矩形由 GDI 计算 // 之后是每条记录私有数据 } ENHMETARECORD;emf.c在解析时不能只读iType来判断记录边界必须用nSize跳到下一条记录。源码中如果有用固定sizeof(ENHMETARECORD)递进的地方就是 bug。正确做法如下// emf.c 中遍历 EMF 记录的简化逻辑 void ParseEmfRecords(const BYTE *pBuffer, DWORD cbBuffer) { const ENHMETARECORD *pRec (const ENHMETARECORD *)pBuffer; while ((const BYTE*)pRec sizeof(ENHMETARECORD) pBuffer cbBuffer) { if (pRec-nSize sizeof(ENHMETARECORD)) { break; // 异常记录防止死循环 } switch (pRec-iType) { case EMR_TEXT: // 调用 text.c 里的处理函数传入 pRec 和 nSize HandleTextRecord(pRec); break; case EMR_BITBLT: HandleBitBltRecord(pRec); break; } pRec (const ENHMETARECORD *)((const BYTE*)pRec pRec-nSize); } }这段代码有两个值得留意的点一是pBuffer来自 GDI 的GetEnhMetaFileBits是内存镜像不能直接修改二是必须用nSize而不是iType来步进因为EMR_TEXT之后还跟着按DWORD对齐的实际字符数据长度是变长的。text.c里真正把字符轮廓转换成贝塞尔曲线的部分依赖 GDI 的GetGlyphOutline这一步在 2000 年比较耗时所以源码作者很可能用了缓存机制按字体句柄和字符码缓存轮廓数据。4.2 位图数据的两种出口DIB 与位图文件当程序执行BitBlt时虚拟打印机会收到EMR_BITBLT记录里面包含一个压缩或未压缩的 DIBDevice-Independent Bitmap。emf.c处理这段时通常有两种选择直接保存为 BMP 文件或者转成 TIFF 以减小体积。源码包里出现的bw.bmp、iqmc.bmp可能是测试用的黑白和彩色位图用于验证转换逻辑。在 Intel 机器上踩坑最多的是 DIB 的扫描行对齐BMP 每一行必须是 4 字节对齐很多手写代码会忽略(width * bitcount 31) / 32 * 4这个公式导致生成的图片右边多出几条斜拉线。// 计算 BMP 扫描行字节数保证每行 DWORD 对齐 int GetStride(int width, int bitsPerPixel) { return ((width * bitsPerPixel 31) / 32) * 4; }如果把bitsPerPixel为 24 时算出的行宽直接用于memcpy到目标缓冲内存布局会缺对齐填充生成的 BMP 在画图工具里能打开但图像偏移严重。针对这个问题我通常在写完文件后用GetDIBits做一次回读校验对比像素数组中第一个非零像素的位置是否符合预期。4.3 text.c 中的字体映射与代码页处理打印子系统里文本处理的难点不在轮廓提取而在字符代码页的映射。2000 年代的中文 Windows 环境默认使用 GBK 代码页应用程序调用TextOutW输出 Unicode而text.c需要把 Unicode 字符按打印机当前代码页转回多字节字符串。如果驱动没有实现代码页转换表中文全变成?。源码可能用MultiByteToWideChar的反向函数WideCharToMultiByte做转换并依赖MSG00001.bin中的代码页映射资源。另一个常见问题是字体嵌入。EMF 记录本身不包含字体数据只包含字体名称、大小和样式目标机器若没有对应字体打印结果会被替换成宋体或 Arial。要解决这个问题text.c可以在DrvEndDoc时调用GetFontData提取 TrueType 字体块写入 EMF 的私有没有公开的EMR_GDICOMMENT记录。Windows 自带的 EMF 查看器会忽略 GDICOMMENT而虚拟打印机自己的解析器可以识别并渲染。这一点是 PDF/A 规范中字体嵌入的雏形值得借鉴。// 用一个自定义私有记录保存字体数据 const DWORD EMR_GDICOMMENT 70; // 来自 wingdi.h BYTE comment[] { 0x43, 0x54, 0x58, 0x54 }; // CTXT // 写入时使用 asComment 参数扩展读时按 GDICOMMENT 处理注意EMR_GDICOMMENT中有安全风险如果直接信任注释内容中的指针恶意打印任务可以构造nSize超过实际缓冲导致堆溢出。所以不管是解析别人的 EMF还是在自己的虚拟打印机里处理收到的打印数据要严格校验每个记录的实际读取长度。5. 内存打印作业管理队列、事件与 UI 的无缝衔接5.1 自定义打印队列的数据结构虚拟打印机不像物理打印机那样有硬件缓冲必须在内存中模拟一个 FIFO 队列。源码可能定义了一个双向链表每个节点保存一个打印作业的状态文档名、页数、当前 EMF 记录指针。在DrvStartDoc时分配一个作业节点DrvEndDoc时把节点从队列头部摘走交给 UI 线程做后续的格式转换。// 内存打印作业节点简化自源码风格 typedef struct _PRINT_JOB { struct _PRINT_JOB *prev; struct _PRINT_JOB *next; HANDLE hHeap; // 作业内存堆 PBYTE pEmfBuffer; // EMF 数据缓冲 DWORD cbEmfSize; // 实际字节数 DWORD dwJobId; // spooler 分配的作业编号 DWORD dwFlags; // 状态标志 } PRINT_JOB;这个结构的核心是pEmfBuffer用什么方式增长。源码中常见做法是先用固定大小比如 4KB分配溢出时用HeapReAlloc倍增扩容。这里要注意HeapReAlloc会移动内存块所以队列节点里不能保存指向缓冲内部的指针否则节点里的pCurrentRecord会变成野指针。我一般会在作业节点里只保存偏移量每次用的时候重新计算地址。5.2 事件驱动与 UI 线程的同步打印作业由 spooler 进程触发驱动运行在spoolsv.exe或应用程序进程的打印上下文中而 UI 界面Delphi 写的配置对话框跑在用户桌面。两者不能直接共享指针需要通过命名事件或窗口消息通信。源码里可能在DrvEnablePDEV时创建了一个隐藏窗口接收打印完成的消息。// 打开或创建命名事件用于通知 UI 有作业完成 HANDLE hEvent CreateEvent(NULL, FALSE, FALSE, Local\\VirtualPrinterJobDone); if (hEvent) { // 驱动线程里在写完 EMF 后 SetEvent(hEvent) SetEvent(hEvent); CloseHandle(hEvent); }这里的关键是事件名的命名空间。如果驱动安装在服务进程中事件名应该用Global\前缀否则 UI 进程从会话 0 隔离中无法收到通知。但在 XP 时代没有会话隔离很多代码直接写Local\到了 Vista 之后才暴露问题。假如你要在 2003 上调试可以先用OpenEvent确认句柄有效再用WaitForSingleObject看超时时间。Delphi 部分通常负责显示“打印到文件”对话框让用户选择输出目录和格式。这部分代码不直接调用 GDI 打印 API而是通过启动一个控制台程序或注册 COM 接口与驱动通信。源码包里的iqmc.bmp很可能是 UI 里显示的图标bw.bmp是黑白预览图说明界面里可能有输出预览功能。事件驱动的好处是 UI 不需要轮询打印队列但需要额外处理作业被系统取消的情况——CancelDoc事件被触发时如果 UI 线程还在等待队列事件就会死锁。6. 在旧版系统验证驱动与移植到现代系统的实用技巧6.1 用批处理快速搭建测试环境测试虚拟打印机最怕污染开发机建议直接准备一台装着 Windows XP SP3 的虚拟机。解压虚拟打印机源码下载.7z时现代 Windows 对 7z 压缩包解压没问题可以用 7-Zip 或7z x命令行工具解压到无空格路径下避免路径空格导致批处理变量截断。7z x 虚拟打印机源码下载.7z -oC:\vprint在虚拟机里以管理员身份打开 cmd先运行copyhere.bat把文件复制到当前目录检查各文件是否齐全再按以下顺序执行cops.bat C:\WINNT\System32 C:\vprint\output copd.bat C:\WINNT\System32 C:\vprint\output copw.bat C:\WINNT\System32 C:\vprint\output如果cops.bat里写死的是C:\WINNT而虚拟机是 Windows XP 在C:\Windows会复制失败。所以先echo %SystemRoot%确认实际路径。安装完驱动后在“打印机和传真”里添加打印机端口选择FILE:联机打印一页测试页。如果打印无反应打开事件查看器看Print服务是否有LoadLibrary failed的记录。6.2 从源码改造出 PDF 虚拟打印机的基本构架现在要把这套代码迁移到 Windows 10 是不现实的因为打印驱动模型已经从 GDI 驱动切换为 V4 打印驱动但我们可以保留其核心逻辑用Microsoft Print to PDF作为接管层。具体做法是写一个格式转换模块通过IPrintDocumentPackageStatusEvent接口从打印管线中抓取 XPS 数据流再解析其中的 FixedPage 并转成 PDFXPS 管线在概念上就是 EMF 记录的现代版。旧源码角色现代替代方案LIBINIT.ASM 的 DDI 回调表Print Driver V4 的IPrintOemUIMXpsemf.c 记录解析XPS 迭代器IXpsOMObjectFactorytext.c 字体处理DirectWrite 字体嵌入 API批处理安装脚本INF 驱动包 Add-PrinterDriver 命令这个迁移思路的价值在于你不需要再纠结 GDI 时代的内存对齐和代码页转换但emf.c里对记录边界的严格校验逻辑仍然可以复用到 XPS 文档结构检查上防止恶意构造的打印数据消耗大量内存。6.3 最快验证虚拟打印机输出正确性的手段直接阅读 EMF 文件用 Windows 自带的System32\spool\drivers\color下工具不直观最快捷的方式是写一个十几行的 Python 脚本用pywin32调用 GDI 的枚举函数检查记录数量import win32print import win32ui # 创建一个指向虚拟打印机的 DC printer_name Virtual Printer dc win32ui.CreateDC() dc.CreatePrinterDC(printer_name) dc.StartDoc(test) dc.StartPage() dc.TextOut(100, 100, EMF check) dc.EndPage() dc.EndDoc() dc.DeleteDC()然后打开该打印任务生成的 EMF 文件用 Python 的struct解析前几个字节验证第一条记录是否是EMR_HEADER。如果第一条记录类型不是 1说明驱动返回的数据没有按标准头部开头问题出在 LIBINIT.ASM 的DrvStartDoc没有正确初始化记录流。这个方法比人工查看输出文件快得多且能在没有用户界面的命令行环境里做自动化测试。最后补充一个测试时容易忽略的参数打印机的分辨率不要设成默认 96 DPI而是在驱动配置里强制设为 300 DPI然后输出一个边长 1 英寸的矩形用图像处理库检查生成的 BMP 中矩形像素宽度是否在 298302 之间。这样能验证 GDI 比例尺与 EMF 转换模块是否保持了一致比看文字内容是否可读更容易定位伸缩问题。本文还有配套的精品资源点击获取
RELATED — 相关阅读

相关资讯

LATEST — 最新资讯

最新发布

TODAY — 本日精选

新闻

WEEKLY — 本周精选

新闻

MONTHLY — 本月精选

新闻