【EMR 编辑器 · 静默打印对接指南】
不弹预览直接出纸 · 打印代理协议 · Windows 可运行示例 · 无 UI 静默流水线
静默打印 = 选好打印机点一下,纸就出来了——没有浏览器打印预览、没有额外点击。适合病区护士站、门诊医生站这种一天打几百次的高频场景。本篇讲清楚原理、协议和可运行的代理示例。
【一、为什么需要一个「打印代理」】
浏览器运行在沙箱里,出于安全不允许网页直接操作打印机。所以静默打印必须有一个「桥梁」——部署在打印电脑上的本地小程序(HTTP 服务 / 打印控件 / Electron 内嵌 webview 都行)。整体链路:
编辑器(网页) 打印代理(打印电脑本地) 打印机
renderPrintPages() 逐页位图 → POST /print → 系统API出纸
打印机下拉列表 ← GET /printers
流程四步:① 编辑器把文档渲染成整页 PNG(含全部内容与页眉页脚);② 通过「适配器」把位图发给代理;③ 代理按纸张尺寸整页出纸;④ 成功后编辑器自动记录续打标记(失败则不记,可安全重试)。
【二、适配器接口(前端侧)】
打印面板通过 printer 属性接收适配器(不在组件内写死),你只需提供一个实现两方法的对象:
interface SilentPrintAdapter {
listPrinters(): Promise<string[]>;
print(job, onProgress?): Promise<void>;
}
job(打印任务)含:images 页位图 URL 列表、printer 目标打印机名、copies 份数(1-99)、paperWidthMm / paperHeightMm 纸张尺寸、title 任务标题;
print 失败必须 reject(Error.message 用可读中文,直接展示给用户)——编辑器因此跳过续打标记,用户可安全重打;
onProgress(done, total) 按页数×份数计;HTTP 代理无法回传出纸进度时,提交成功后一次性上报到 total 即可。
随包提供现成的 HTTP 适配器(createHttpSilentPrinter):内置超时控制、blob→base64 转码、网络错误中文化、逐页转码进度。注入方式(React 示例,Vue / HTML 同理):
import { createHttpSilentPrinter } from '...silentPrint';
const printer = {
adapter: createHttpSilentPrinter({
baseUrl: 'http://127.0.0.1:18600', // 代理根地址
printTimeoutMs: 120000, // 打印超时(默认120s)
encoding: 'dataUrl', // 位图编码方式
}),
label: '病区打印代理', // 通道显示名
};
非 HTTP 通道(WebSocket、Electron IPC、C-Lodop 等)实现同接口即可,UI 无需改动。开发期没有代理也能跑通全流程:随包附带 Mock 适配器 createMockSilentPrinter({ printers, failRate, pageDelayMs }),模拟打印机枚举与逐页下发。
【三、HTTP 代理协议(后端侧)】
代理只需实现两个 JSON over HTTP 接口:
GET /printers → 200 返回打印机名数组:
["HP LaserJet Pro M404dn", "Canon LBP-2900+"]
POST /print 请求体(JSON):
{
"printer": "HP LaserJet Pro M404dn", // 目标打印机
"copies": 1, // 份数(代理重发实现)
"title": "EMR 静默打印", // 任务标题(日志/队列)
"paper": { "widthMm": 210, "heightMm": 297 },
// 按此整页输出、页边距 0——勿再套打印机默认边距
"pages": [
"data:image/png;base64,iVBORw0KGgo..." // 逐页 PNG
]
}
成功返回任意 2xx(建议 204);失败返回非 2xx,body 文本会拼进用户提示;
encoding='base64' 时 pages 为纯 base64(去掉了 data: 前缀);
多页 base64 体积大,代理需放开请求体限制(示例里设了 512MB)。
【四、Windows 最小代理(Node + Express,可直接跑)】
保存为 server.mjs,先 npm i express cors,再 node server.mjs(仅本机调试用,勿暴露公网)。打印机枚举与出纸都调用 Windows 自带 PowerShell:
import express from 'express';
import cors from 'cors';
import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path';
import { execFile } from 'node:child_process';
const app = express();
app.use(cors()); // 页面与代理不同源时必须
app.use(express.json({ limit: '512mb' }));// 放开 base64 体积限制
app.get('/printers', (req, res) => {
execFile('powershell', ['-NoProfile', '-Command',
'Get-Printer | Select-Object -ExpandProperty Name'], { shell: true },
(err, out) => err ? res.status(500).send(String(err))
: res.json(out.trim().split(/\r?\n/)));
});
app.post('/print', async (req, res) => {
const { printer, copies = 1, paper, pages } = req.body;
if (!printer || !Array.isArray(pages) || !pages.length)
return res.status(400).send('参数不完整');
// 1) base64 → 临时 PNG 文件
const files = pages.map((p, i) => {
const b64 = p.slice(p.indexOf(',') + 1);
const f = path.join(os.tmpdir(), `emr-${Date.now()}-${i}.png`);
fs.writeFileSync(f, Buffer.from(b64, 'base64'));
return f; });
try {
// 2) 份数 × 页数 串行下发(避免乱序)
for (let c = 0; c < copies; c++)
for (const f of files)
await printPng(printer, f, paper);
res.sendStatus(204);
} catch (e) {
res.status(500).send(`出纸失败:${e.message}`);
} finally { files.forEach(f => fs.rm(f, { force: true })); }
});
// Windows 出纸:System.Drawing 按纸张毫米尺寸整页绘制(无缩放)
function printPng(printer, file, paper) {
const q = (s) => String(s).replace(/'/g, "''");
const w = paper.widthMm / 25.4 * 100, h = paper.heightMm / 25.4 * 100;
const script = `
Add-Type -AssemblyName System.Drawing
$doc = New-Object System.Drawing.Printing.PrintDocument
$doc.PrinterSettings.PrinterName = '${q(printer)}'
$img = [System.Drawing.Image]::FromFile('${q(file)}')
$doc.add_PrintPage({ $args[0].Graphics.DrawImage($img, 0, 0, ${w}, ${h}) })
$doc.Print(); $img.Dispose()`;
return new Promise((ok, no) =>
execFile('powershell', ['-NoProfile', '-Command', script], { shell: true },
(e) => e ? no(new Error(String(e))) : ok()));
}
app.listen(18600, '127.0.0.1', () => console.log('打印代理: http://127.0.0.1:18600'));
启动后前端 baseUrl 指向 http://127.0.0.1:18600 即可真实出纸;
macOS / Linux:printPng 换成 lpr -P <printer> -o media=<W>x<H>mm <file>;枚举打印机解析 lpstat -p;
需要发行方提供 / 代为部署打印代理,联系技术支持。
【五、附:无 UI 静默流水线】
「打开 → 回填 → 导出 / 打印」可全程无界面执行(编辑器可隐藏或离屏挂载),适合定时归档、批量出单:
api.setDoc(templateDoc); // 1. 加载模板
api.setDataValues({ patient: {...} }); // 2. 回填数据
const bytes = await api.exportPDF({ fonts }); // 3a. 导出 PDF
// 或静默打印:
const urls = await api.renderPrintPages([0, 1], { scale: 2 }); // 3b. 渲染位图
await adapter.print({ images: urls, printer, copies: 1,
paperWidthMm: 210, paperHeightMm: 297 });
api.markPrinted(); // 4. 记录续打标记
urls.forEach(u => URL.revokeObjectURL(u)); // 5. 释放位图
【六、常见问题速查】
「无法连接打印代理」:代理没启动或地址错——确认 baseUrl 可达、GET /printers 返回 200;
「请求超时」:页数多或代理慢——调大 printTimeoutMs(默认 120s,0 = 不限);
HTTPS 页面连 HTTP 代理失败:浏览器混合内容限制——代理也用 HTTPS(自签证书需装信任)或页面降 HTTP;
跨域报错:代理响应加 CORS 头(express 用 cors 中间件);
打印空白 / 缺字:渲染时字体未就绪——静默打印前先确保字体加载(IndexedDB 缓存预热);
打印偏小 / 留白:代理没按 paper 尺寸整页输出或套了打印机默认边距——对照第四节示例修正;
份数不生效:份数靠代理按 copies 重发实现——确认代理实现了该逻辑;
内存占用高:位图较大,适配器 resolve/reject 后编辑器即释放 blob URL——不要在适配器外长期持有 job.images。