简介:这是一份基于 C# 与 WinForms 的“简单打印设计器”完整项目,面向需要定制打印模板、贴纸或其他可变版式输出的 .NET 开发者与入门学习者。项目以 System.Drawing.Printing 命名空间的 PrintDocument 为核心,在 PrintPage 中通过 Graphics.MeasureString 计算位置,再用 DrawString 绘制文本与图片;同时借助 PictureBox 预览打印效果,并采用 XML 保存/恢复模板布局。压缩包共 38 个文件、约 114KB,主体为 15 个 C# 源码文件,另有 resx/resources 界面资源、exe/pdb 运行调试产物、sln/csproj 工程配置及 settings/xml 等辅助说明,便于直接打开和逐步跟踪。工程包含 Form1、CodeView 等窗体和 LabelExt、TextBoxExt 自定义控件,配套 DrawHelper、XmlHelper、WinHelper 工具类,展示了一个从控件布局、绘制、预览到模板序列化的完整设计流程。目录结构清晰,Bin/Debug 下可直接运行查看效果,适合作为打印模块的起点。已有 913 人学习,适合希望通过精简工程快速掌握 WinForms 打印设计,并在此基础上扩展集成到实际业务系统的开发者。
1. 为什么我重新写了一个 C# 简单打印设计器:票据模板终于不用发版了
公司那个旧的发货单打印功能,一直是我最不想碰的地方。业务方提需求听起来都是小事:表头字号大一点、订单号挪到右侧、加一条分隔线,可在原来代码里,这些硬编码坐标全部散落在各种 paint 方法中,改一处就要重新编译、发版本、让现场同事重新安装。后来我花了两周整理出一个 C# 简单打印设计器,核心是“拖控件 -> 存 JSON -> 打印引擎读文件出纸”,业务方在界面上排好版,保存的模板文件放进配置目录,下一次打印自动生效。它没有做成重型报表平台,只解决 WinForms/WPF 项目里票据、标签、送货单这类需要频繁微调版式的场景。适合被打印格式反复折磨的小团队,也适合想给自己的项目加一块可视化打印模板功能的人。你先把后面这套思路看完,每一段都能直接抄到自己的项目里。
2. 架构与目录:自绘画布加 JSON 模板,为什么丢掉报表控件
2.1 技术选型:WinForms、GDI+ 和“不动态创建控件”的设计
这个设计器最开始就没有选报控件。做打印场景最重要是“所见即所得”,而报表控件学习成本高、模板格式封闭,一旦客户要求做半透明底纹、自定义旋转角度,就得绕很远的 API。用 WinForms + GDI+ 自绘画布,所有元素都直接画在 UserControl 上,插入自定义元素只需要在 Paint 里增加一种类型。
很多人实现打印设计器的第一反应是:把 TextBox、Label 动态 new 出来放到 Panel 上,再去取 Location。这个做法在小模板里能跑,但模板数量大了之后会有两个问题:一是打印输出时为了和屏幕上完全一致,必须维护一套和控件一一对应的渲染逻辑,等于同一个元素写两遍;二是用户把控件拖到负坐标后,序列化出来的数据还要专门做边界矫正。我的做法是把每个元素降级成一条数据,画布上看到的任何方框、图片、文字都是 OnPaint 画出来的。这样 GDI 句柄消耗几乎为零,拖拽几百次也不会出现句柄泄漏。
当然这个取舍也不是白拿,自绘画布带来的反面成本是:选择框、对齐线、网格底、放大镜都要自己实现。好在 WinForms 的 UserControl 自带 DoubleBuffered 属性,打开之后画布刷新不会闪烁,这部分成本大约只要一两天。另一个关键点是坐标系统:设计时和运行时用同一套 TemplateItem 坐标,设计器里存的 X、Y、Width、Height 都是 double,打印时再按打印机 DPI 缩放。这套设计让“预览”和“出纸”走同一条渲染路径,不会出现设计器里正常、打印出来歪掉的问题。
2.2 项目目录:界面层、核心引擎层、模板配置层
整个解决方案按三层拆,目录结构大概长这样:
SimplePrintDesigner/ ├─ SimplePrintDesigner.sln ├─ src/ │ ├─ SimplePrintDesigner.App/ │ │ ├─ MainForm.cs // 宿主窗体:工具栏 + 画布 + 属性框 │ │ ├─ Controls/ │ │ │ ├─ Toolbox.cs // 左侧可拖拽的控件源 │ │ │ ├─ DesignCanvas.cs // 中间画布,负责绘制和拖放 │ │ │ ├─ PropertyGridHost.cs │ │ │ └─ ThumbnailPreview.cs │ │ └─ Program.cs │ ├─ SimplePrintDesigner.Core/ │ │ ├─ Models/ │ │ │ ├─ PrintTemplate.cs │ │ │ └─ TemplateItem.cs │ │ ├─ Serialization/ │ │ │ └─ TemplateSerializer.cs │ │ ├─ Rendering/ │ │ │ ├─ PrintRenderEngine.cs │ │ │ └─ PrintManager.cs │ │ └─ Data/ │ │ └─ IPrintDataProvider.cs └─ templates/ └─ dispatch-note.jsonApp 项目只做交互,Core 不引用任何 UI 控件。TemplateItem 里面一个 WinForms 控件引用都不放,这样 Core 能单独做单元测试,以后想换打印框架也能整包搬走。MainForm 只负责把 Toolbox 的拖拽描述转给 DesignCanvas,DesignCanvas 修改的是内存里的 PrintTemplate 对象,PropertyGridHost 直接绑定当前选中项,保存按钮调用 TemplateSerializer 写出 JSON。这条链路的顺序我在第一次评审时就定死了:界面层永远不能直接影响模板文件的字段命名。
MainForm 布局左侧是一个 ListBox 当作 Toolbox,中间是 UserControl 类型的 DesignCanvas,右侧放 PropertyGrid,底部加一个 StatusStrip 实时显示鼠标在画布里的设计坐标,方便业务方精确对齐到 5 像素以内。程序启动时按下面这个顺序执行:Program.cs 启动 MainForm;MainForm 的 Form_Load 调用 TemplateSerializer.FromJson 读取默认模板;DesignCanvas.LoadTemplate 把模板设置为当前画布数据;PropertyGridHost.SelectItem(null) 清空属性选择。之后用户从 Toolbox 拖一个元素到画布,DragDrop 事件里新建 TemplateItem 追加到 _template.Items;点击保存时,TemplateSerializer.ToJson 输出到 templates 目录。整个过程不碰数据库,模板文件就是配置源头。
从跑起来的角度看,这个方案比接报表控件简单得多:不需要预装任何报表运行库,不依赖服务端授权,一个 exe 加一个 templates 文件夹就能分发。如果现场要临时改格式,直接把新 JSON 覆盖到模板目录,再在程序里加一个文件监听刷新预览即可。
2.3 模板文件长什么样:一个 JSON 就是一张纸
新建一张派工单,模板文件大概长下面这样:
{ "TemplateName": "DispatchNote", "PageWidth": 800, "PageHeight": 600, "Items": [ { "Name": "Header", "Type": "Text", "X": 40, "Y": 20, "Width": 200, "Height": 30, "FontName": "Arial", "FontSize": 18, "Bold": true, "Text": "** 派工单 **" }, { "Name": "Line1", "Type": "Line", "X": 20, "Y": 60, "Width": 760, "Height": 0 }, { "Name": "OrderNo", "Type": "Field", "X": 40, "Y": 80, "Width": 180, "Height": 25, "FontName": "Arial", "FontSize": 10, "Text": "{{OrderNo}}" }, { "Name": "Customer", "Type": "Field", "X": 240, "Y": 80, "Width": 200, "Height": 25, "FontName": "Arial", "FontSize": 10, "Text": "{{Customer}}" } ] }参数只分两组。PageWidth 和 PageHeight 是设计坐标,单位是 1/96 英寸,也就是标准 100% DPI 下的像素;实际打印时,引擎按当前打印机 DpiX / 96 做缩放。Items 数组里的每一项都是 TemplateItem,Type 决定画布和打印引擎分别画什么,Text 里写 {{Field}} 占位符,打印时由业务数据里的字段替换。这个格式刻意不用 XML,因为 JSON 手工修改、版本对比都方便,业务方也敢自己打开改一个坐标。实现上需要注意的是:Line 是一条线,Width 是长度,Height 固定为 0;Rect 是矩形,Width 和 Height 都必须是正数,否则渲染引擎要把不合法的元素过滤掉。
为什么要用 1/96 英寸而不是直接用打印机像素?是因为 Windows 上最常见的屏幕 DPI 就是 96,所有设计器坐标都按这个基准存。真实打印机的 Dpi 可能是 300、600、1200,但业务方不应该感知这个数字。换算公式是:打印机像素 = 设计像素 × 打印机 Dpi / 96。比如设计坐标 100,300 DPI 打印机上画 312 像素,物理尺寸仍然是 25.4 毫米。代码里用 Graphics.DpiX 拿当前打印机分辨率,不要自己写死。
3. 核心模块拆解:从拖控件到打印出纸的完整链路
3.1 数据模型:TemplateItem 与 PrintTemplate
数据模型是整个打印设计器的根,它不包含任何控件对象,只是把“一张纸上有哪些东西”描述清楚:
public class TemplateItem { public string Name { get; set; } public string Type { get; set; } // Text / Field / Line / Rect / Image public double X { get; set; } public double Y { get; set; } public double Width { get; set; } public double Height { get; set; } public string Text { get; set; } public string FontName { get; set; } = "Arial"; public double FontSize { get; set; } = 10; public bool Bold { get; set; } public string DataField { get; set; } // 绑定的业务字段名 }PrintTemplate 只有两个核心属性:
public class PrintTemplate { public string TemplateName { get; set; } public double PageWidth { get; set; } public double PageHeight { get; set; } public List<TemplateItem> Items { get; set; } = new(); }这里要解释三个关键点。第一,FontSize 用 double 而不是 float,是因为序列化成 JSON 后 float 会出现 10.599999 这种脏数据,double 也没完全根治,但至少可读性好很多。第二,X/Y 用 double 是为了给后期做“毫米对齐”和“标尺吸附”留下精度,如果用 int,拖拽到 0.5 像素这种精修场景直接失灵。第三,Type 字段在读取时建议做一次白名单校验,只允许上面的五种类型,防止有人手改 JSON 造出未知类型导致渲染崩溃。
为什么不直接用 Control 的 Tag 存数据?因为 Tag 是 object,序列化时还得反射,而且 Tag 里塞业务对象会让模板文件变得不可移植。数据模型独立出来之后,设计器、预览、打印、单元测试全部复用同一个类,这才是“简单但不简陋”的地方。
3.2 画布的拖放实现:先落数据,再重绘
设计画布的拖放是所有交互的核心。Toolbox 侧只需要在鼠标按下时启动拖拽:
private void Toolbox_MouseDown(object sender, MouseEventArgs e) { var btn = (ToolboxButton)sender; string payload = btn.ItemType; // "Text" / "Field" / "Line" DoDragDrop(payload, DragDropEffects.Copy); }画布侧接收拖拽,并把鼠标的屏幕坐标转换成画布内部坐标:
private void DesignCanvas_DragEnter(object sender, DragEventArgs e) { if (e.Data.GetDataPresent(DataFormats.Text)) e.Effect = DragDropEffects.Copy; else e.Effect = DragDropEffects.None; } private void DesignCanvas_DragDrop(object sender, DragEventArgs e) { string itemType = (string)e.Data.GetData(DataFormats.Text); Point clientPt = PointToClient(new Point(e.X, e.Y)); var item = new TemplateItem { Name = itemType + "_" + DateTime.Now.Ticks, Type = itemType, X = clientPt.X, Y = clientPt.Y, Width = 120, Height = 25 }; _template.Items.Add(item); Invalidate(); }拖拽载荷只是一个字符串,不传真实控件对象,这样 Toolbox 和画布之间没有强耦合。PointToClient 这一步必须做,因为 DragEventArgs 里的 X/Y 是屏幕坐标,画布可能因为窗口移动或者出现滚动条而偏移位置,不转换的后果会在避坑章节详细说。新增元素之后调 Invalidate 触发 OnPaint,而不是动态创建 Control 加到 Controls 集合,这也是自绘画布性能好、逻辑清晰的原因。
每次新增完元素,还要把当前选中项同步到右侧 PropertyGrid:
public event Action<TemplateItem> SelectionChanged; private void DesignCanvas_MouseClick(object sender, MouseEventArgs e) { var hit = FindItemAt(e.Location); _selectedItem = hit; SelectionChanged?.Invoke(hit); Invalidate(); }FindItemAt 按照 Items 倒序遍历,命中某个元素的矩形范围后返回它。倒序是因为后画的元素在视觉上层,鼠标点中的也应该是最上面的那个。
3.3 模板序列化:System.Text.Json 与文件版本
保存功能没什么黑魔法,就是标准 JSON 序列化:
public class TemplateSerializer { public static string ToJson(PrintTemplate template) { var options = new JsonSerializerOptions { WriteIndented = true, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; return JsonSerializer.Serialize(template, options); } public static PrintTemplate FromJson(string json) { return JsonSerializer.Deserialize<PrintTemplate>(json); } }WriteIndented 让模板文件保持人类可读,DefaultIgnoreCondition 把为 null 的属性去掉,模板文件短一点,diff 对比时也更干净。实际项目中,我建议在 PrintTemplate 里加一个 Version 字段,比如 1,后续如果需要迁移字段结构,反序列化后根据 Version 走不同的升级逻辑,避免老模板直接崩掉。这个字段在线资源里没有,但从工程角度讲它一定会出现,越早加越好。
加载模板的路径不应该写死绝对路径,常见做法是:
string templatePath = Path.Combine( AppDomain.CurrentDomain.BaseDirectory, "templates", "dispatch-note.json"); PrintTemplate template = TemplateSerializer.FromJson( File.ReadAllText(templatePath));3.4 打印渲染引擎:DPI 缩放和字段替换
打印核心在一个 PrintPage 事件处理里,它和画布 OnPaint 共用同一套绘制逻辑,区别只有坐标缩放:
private void RenderPrintPage(object sender, PrintPageEventArgs e) { Graphics g = e.Graphics; float sx = g.DpiX / 96f; float sy = g.DpiY / 96f; g.ScaleTransform(sx, sy); g.SmoothingMode = SmoothingMode.AntiAlias; foreach (var item in _currentTemplate.Items) { switch (item.Type) { case "Line": g.DrawLine(Pens.Black, (float)item.X, (float)item.Y, (float)(item.X + item.Width), (float)(item.Y + item.Height)); break; case "Text": case "Field": string text = MergeText(item.Text, _dataProvider); using (var font = BuildFont(item)) { g.DrawString(text, font, Brushes.Black, (float)item.X, (float)item.Y); } break; } } e.HasMorePages = false; }ScaleTransform 很关键。设计坐标按 96 DPI 存储,当前打印机如果是 300 DPI,缩放系数大约是 3.125,所有元素自动变成 300 DPI 下的物理尺寸。注意在 ScaleTransform 之后,字体大小也会被缩放,所以 BuildFont 里不需要再手动乘 DPI,否则会双重放大。BuildFont 的实现就是读 FontName、FontSize、Bold 三个属性,new Font 即可。
字段替换用正则处理 {{Field}} 占位符:
private string MergeText(string raw, Dictionary<string, object> data) { if (string.IsNullOrEmpty(raw)) return raw; return Regex.Replace(raw, @"\{\{(\w+)\}\}", match => { string key = match.Groups[1].Value; return data.TryGetValue(key, out var val) ? val?.ToString() : match.Value; }); }找不到对应字段时保留原占位符,这样能在预览界面上一眼看出数据源漏了哪个字段,而不是打出一张空白的单子。打印管理类每次调用都新建 PrintDocument,避免共用事件处理导致数据串场:
public void Print(PrintTemplate template, Dictionary<string, object> data) { _currentTemplate = template; _dataProvider = data; var printDoc = new PrintDocument(); var paperSize = new PaperSize( "Custom", (int)Math.Round(template.PageWidth * 100 / 96), (int)Math.Round(template.PageHeight * 100 / 96)); printDoc.DefaultPageSettings.PaperSize = paperSize; printDoc.PrintPage += RenderPrintPage; printDoc.Print(); }PaperSize 单位是 1/100 英寸,所以这里要做 100 / 96 换算。如果不做,300 设计像素会被当成 3 英寸而不是约 7.9 厘米,打印出来直接超出纸张。
4. 跑起来再说:环境准备、启动参数和模板接入业务数据
4.1 环境准备与最小启动路径
拿到源码包后,不要先看代码,先看有没有 templates 目录。整个项目不依赖数据库、不依赖服务端,只要开发机能跑 WinForms 就能起来。以 .NET 6 为例,命令行方式启动是这样:
dotnet run --project src/SimplePrintDesigner.App如果习惯用开发环境,打开解决方案文件后按 F5 一样。第一次跑最可能遇到两个问题:一是启动路径不对,模板文件读取不到,画布一片空白;二是电脑装了高分辨率屏幕且缩放比例不是 100%,画布显示和鼠标落点错位。第一个问题用断点检查 AppDomain.CurrentDomain.BaseDirectory 拼接出来的路径是否存在;第二个问题暂时不用处理,它属于避坑章节里说的 DPI 问题,先关掉“替代高 DPI 缩放行为”或把缩放改回 100%,方便定位逻辑。
最小启动路径顺序是这样的:启动后左侧 Toolbox 能拖出一个文本块;拖到画布后右侧属性面板能看到 Name、Text、FontSize 等属性;改完字号点击保存,templates 目录下的 JSON 文件内容发生变化;重新启动程序,画布还原出刚才的内容。把这四步跑通,设计器本体的闭环就建立了。
4.2 把模板接到自己的业务数据
设计器本身不管数据库,通过 IPrintDataProvider 提供数据:
public interface IPrintDataProvider { object GetValue(string fieldName); }业务系统常见的做法是让 User 对象、订单对象实现这个接口,或者更简单,直接把 Web API 返回的 JObject 包一层:
public class JsonDataProvider : IPrintDataProvider { private readonly JObject _data; public JsonDataProvider(JObject data) { _data = data; } public object GetValue(string fieldName) { return _data.TryGetValue(fieldName, StringComparison.OrdinalIgnoreCase, out var token) ? token.ToString() : null; } }调用打印时,模板和数据分开传:
var template = TemplateSerializer.FromJson( File.ReadAllText("templates/dispatch-note.json")); var provider = new JsonDataProvider(new JObject { ["OrderNo"] = "DD-20250412-001", ["Customer"] = "某客户" }); var manager = new PrintManager(); manager.Print(template, provider);MergeText 里对应的参数类型从 Dictionary 改成 IPrintDataProvider,逻辑不变。这样做的好处是业务系统不用关心模板里有哪些字段,数据提供方只需要按字段名取值,模板和业务之间通过字符串契约解耦。如果字段名变化,保持在 JSON 里同步改占位符即可,不用改代码。
4.3 常用配置参数与模板导入导出
运行期可以调整的参数不多,集中在这张表里:
| 配置项 | 推荐值 | 说明 |
|---|---|---|
| PageWidth / PageHeight | 800 / 600 | 设计基准像素,单位按 96 DPI |
| FontSize | 10 ~ 18 | 存 double,打印时统一缩放 |
| PaperSize | 按模板宽高计算 | 不配置会默认 A4,可能出现裁切 |
| 打印机名称 | 留空 | 留空使用默认打印机 |
| 边距 | 0 ~ 5 | 避免贴边打印失败,先设 0 排除干扰 |
| 缩放系数 | 1.0 | 预览需要时单独调,打印用 1.0 |
开发阶段建议装一个打印到 PDF 的虚拟打印机,打印的时候选它,然后打开生成的 PDF 和设计器截图对坐标。这比直接打纸省纸,也方便自动化对比。导出模板直接用文件复制;导入就是反向覆盖 templates 目录。如果做多模板管理,文件名建议用业务单据号命名,比如 dispatch-note.json、invoice.json,不要用“模板1.json”这种名字,不然现场维护的人会疯。
5. 避坑记录:打印偏移、DPI 缩放和画布闪烁
5.1 打印内容整体偏左上,右侧被裁掉
现象:设计器里排版好好的,打印出来内容整体向左上靠,右边空出一块,甚至最右侧元素直接消失。原因:混用了设计像素和打印机物理单位。创建 PaperSize 时直接用 PageWidth 当宽,800 会被当成 800 分之一英寸,换算出来只有约 20 厘米,实际纸张更大,内容自然偏左上。解决:PaperSize 的宽高必须乘以 100 / 96,然后打印机坐标再通过 Graphics.ScaleTransform 缩放。建议在打印前加一个断言:打印页面的可打印区域宽度不能小于模板最大元素的右边界。
5.2 换一台电脑字号变大变小
现象:开发机上模板正常,客户电脑上所有字体放大了一圈,甚至换行位置错乱。原因:客户电脑系统缩放是 125% 或 150%,WinForms 在高 DPI 环境下默认让字体跟随系统缩放,FontSize 被二次放大了。解决:模板里 FontSize 统一按 96 DPI 基础存,打印时只相信 g.DpiY / 96 的缩放系数,不要在 BuildFont 前再手动乘 DpiX。另外,在主窗体的构造函数里建议设置:
AutoScaleMode = AutoScaleMode.Dpi;这样设计器界面自身会随着系统缩放,但画布保存的坐标依然使用设计基准像素,不受影响。
5.3 拖控件时画布闪烁和残影
现象:拖拽一个文本框经过画布,画布疯狂闪烁,松手后还有残影。原因:UserControl 默认不是双缓冲,每次 OnPaint 时背景和前景交错重绘,画布元素多时特别明显。解决:在 DesignCanvas 构造函数里强制打开双缓冲:
public DesignCanvas() { DoubleBuffered = true; ResizeRedraw = true; SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.OptimizedDoubleBuffer, true); }如果画面里绘制了网格,不要直接在 OnPaint 里画整张背景图,把网格预先画到一个 Bitmap 上,OnPaint 时先 DrawImage 再画元素,能进一步减少闪烁。
5.4 保存后重新打开,所有元素挤到左上角
现象:用设计器拖了五个控件,保存 JSON 重新打开,五个点全部集中在 (0,0)。原因:DragDrop 事件里直接把 DragEventArgs.X/Y 当成坐标存进了模板,而这两个值是屏幕坐标,不是画布客户区坐标。画布如果不是位于窗口左上角,落点就会出现整体偏移。解决:落点必须通过 PointToClient 转换:
Point clientPt = PointToClient(new Point(e.X, e.Y));这里也提醒一点:如果未来画布支持缩放和滚动,转换逻辑要更完整,先用 AutoScrollPosition 调整偏移,再除以缩放系数,不能只做 PointToClient 就完事。
5.5 连续打印两张单子,第二张混入第一张的数据
现象:业务系统循环打印十张派工单,发现后面几张的订单号一直是第一单的。原因:PrintManager 里复用了同一个 PrintDocument 实例,RenderPrintPage 事件处理引用类级别的 _currentTemplate,第二次循环时数据没更新。解决:每次打印都新建 PrintDocument,模板和数据作为事件参数捕获,不要依赖类字段。我的经验是打印方法签名就是“模板 + 数据源”两个参数,内部完全不共享可变状态,这样无论多线程还是循环调用都不会串数据。
6. 二次开发:加条码字段和数据源接口,模板改到哪一步才算完成
6.1 给设计器加一个条码类型
如果业务里要打条码,不需要改画布拖拽逻辑,只需要动两个地方。Toolbox 里加一个按钮,ItemType 设为 Barcode;打印引擎的 switch 里增加一个分支:
case "Barcode": using (var bmp = BarcodeFactory.Create(item.Text, (int)item.Width, (int)item.Height)) { g.DrawImage(bmp, (float)item.X, (float)item.Y, (float)item.Width, (float)item.Height); } break;BarcodeFactory 可以封装现有条码生成库,文本内容依然支持 {{Field}} 占位符,打印前先 MergeText 再生成条码图片。这里要注意条码内容只包含数字字符,最好在保存模板时给 Barcode 类型加一个正则校验,内容是纯数字时才允许保存,否则打印出来容易扫不出。
6.2 数据源从 Dictionary 升级成接口
原版为了快速演示用了 Dictionary,接真实数据后建议立刻升级成 IPrintDataProvider 接口。这样打印引擎不再关心数据来自数据库、API 还是 Excel。接口方法只有 GetValue 一个,改动成本很低,但收益很大:业务系统可以传一个延迟加载的 Provider,模板里用到哪些字段才去查询哪些字段,避免每次打印都把整张订单记录全查出来。
6.3 验证方法:打印到 PDF,再和设计截图叠图对比
二次开发每改一个渲染分支,都要带图验证。我的做法是:模板里故意放一个十字辅助线,设计器截图后,把打印 PDF 转成图片,在图像处理工具里把两张图叠起来,透明度调到 50%。偏差不超过 2 毫米就算通过。从那以后我每次改画布或打印引擎,都强制走一遍“预览 -> 打印到 PDF -> 坐标叠图对比”,宁可多花十分钟,也不愿意被业务方说打印件又歪了。希望帮到你。
本文还有配套的精品资源,点击获取