@sv-print/printer
v0.1.6
Published
Cross-platform printer and custom paper-size capability bridge for Electron, built on Koffi and a thin native C ABI (Winspool / Core Printing / CUPS). Ships prebuilt binaries per platform and CPU architecture.
Maintainers
Readme
@sv-print/printer
给 Electron 应用用的跨平台打印机能力包:列出打印机、查询驱动公布的纸张尺寸、校验并构造自定义纸张(标签纸、收银小票、条码纸这类非标尺寸)。
Windows / macOS / Linux 三平台同一套 API。平台细节——Windows 的 DEVMODE、macOS 的 opaque object、Linux 的 CUPS struct——都不暴露到 JS 层,只返回统一 JSON。
为 web 可视化打印设计器 sv-print 配套打印客户端提供的打印机能力包。
文档体验:sv-print.ibujian.cn
特性
- 一个 tarball 装所有平台,不用按平台挑依赖,也没有平台专属的安装脚本
- 没有
postinstall、没有运行时下载、不需要 node-gyp、不需要 electron-rebuild:预编译动态库直接跟在包里 - Koffi 走 N-API,与 Electron 内置 Node 的 ABI 兼容,不需要按 Electron 版本重编
- 尺寸统一用微米(μm),避开毫米 / 英寸浮点换算的精度抖动
- TypeScript 类型随包发布,由源码直接生成,不存在手写声明与实现漂移
环境要求
- 运行时 Node.js >= 16:koffi 走 N-API,与 Electron 内置 Node 的 ABI 无关(Electron 21 内置的 Node 16 也可以)
- 从源码构建 / 跑测试需要 Node.js >= 20
- 只能在 Electron 主进程调用(渲染进程请用 preload 转发,见快速开始)
- 平台:Windows / macOS / Linux
随包发布的动态库
预编译产物按 <platform>-<arch> 分目录存放,运行时自动挑选,不需要你配置:
| target | 对应平台 |
| ---------------- | -------------------------------- |
| darwin-arm64 | macOS Apple Silicon |
| darwin-x64 | macOS Intel |
| win32-x64 | Windows x64 |
| win32-ia32 | Windows x86(32 位) |
| win32-arm64 | Windows ARM64 |
| linux-x64 | Linux x64(glibc) |
| linux-arm64 | Linux ARM64(glibc) |
| linux-x64-musl | Alpine 等 musl libc 的 Linux x64 |
platform / arch 用的是 Node 的命名(darwin / win32 / linux,x64 / arm64 / ia32 / arm)。Linux 额外区分 glibc 与 musl,同样由运行时自动判定。
确认一下当前进程会用哪个 target:
const { resolveTarget } = require("@sv-print/printer");
console.log(resolveTarget()); // "darwin-arm64"安装
npm install @sv-print/printer快速开始
主进程
必须在主进程调用,包依赖 Koffi 的 native addon。所有方法都返回 Promise。
// main.js
const { app, BrowserWindow, ipcMain } = require("electron");
const printer = require("@sv-print/printer");
function registerPrinterIpc() {
ipcMain.handle("printer:getPrinters", () => printer.getPrinters());
ipcMain.handle("printer:getPaperSizes", (_e, printerId) =>
printer.getPaperSizes(printerId)
);
ipcMain.handle("printer:validatePaper", (_e, printerId, w, h) =>
printer.validatePaper(printerId, w, h)
);
ipcMain.handle("printer:customPaper", (_e, printerId, w, h) =>
printer.customPaper(printerId, w, h)
);
}
app.whenReady().then(async () => {
console.log(printer.binaryInfo()); // 启动自检,见下文
const list = await printer.getPrinters();
if (!list.ok || list.printers.length === 0) return;
const printerId = list.printers[0].id;
// 驱动公布的纸张尺寸
const sizes = await printer.getPaperSizes(printerId);
console.log(sizes.papers); // [{ name: "A4", widthMm: 210, heightMm: 297, widthMicrons: 210000, ... }]
// 校验一个自定义尺寸:80mm × 50mm 标签纸
const check = await printer.validatePaper(printerId, 80000, 50000);
console.log(check.supported, check.reason);
// 构造一个打印任务可用的自定义纸张描述
const custom = await printer.customPaper(printerId, 80000, 50000);
console.log(custom.custom, custom.validatedWidthMicrons);
});渲染进程
渲染进程不直接持有 FFI 能力,用 preload 暴露一个安全接口:
// preload.js
const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("printerAPI", {
getPrinters: () => ipcRenderer.invoke("printer:getPrinters"),
getPaperSizes: (printerId) =>
ipcRenderer.invoke("printer:getPaperSizes", printerId),
validatePaper: (printerId, w, h) =>
ipcRenderer.invoke("printer:validatePaper", printerId, w, h),
customPaper: (printerId, w, h) =>
ipcRenderer.invoke("printer:customPaper", printerId, w, h),
});// 渲染进程里
const list = await window.printerAPI.getPrinters();引入方式
TypeScript / ESM / CommonJS 三种写法都成立,不需要挑构建产物:
// TypeScript
import { getPrinters, binaryInfo, PrinterNativeError } from "@sv-print/printer";// ESM(含具名导入)
import { getPrinters } from "@sv-print/printer";// CommonJS
const printer = require("@sv-print/printer");包是 CommonJS 产物,ESM 的具名导入由 Node 的 CJS 互操作层解析,所以不需要单独的 ESM 构建产物。
API
尺寸单位统一是微米(μm),1 mm = 1000 μm。
| 方法 | 说明 |
| --------------------------------- | -------------------------------------------------------- |
| getPrinters() | 列出系统当前可见的打印机 |
| getPaperSizes(printerId) | 查询驱动公布的纸张尺寸列表 |
| validatePaper(printerId, w, h) | 校验操作系统打印驱动 / CUPS 是否接受该自定义尺寸 |
| customPaper(printerId, w, h) | 同上,语义上表示「构造一个打印任务可用的自定义纸张描述」 |
| binaryInfo() | 当前会加载哪个预编译产物(启动自检 / 排障) |
| resolveTarget(platform?, arch?) | 只算 target 三元组,不加载动态库 |
微米换算
| 尺寸 | μm |
| --------------- | ------------------- |
| 80 mm | 80000 |
| 58 mm | 58000 |
| 40 × 20 mm | 40000 × 20000 |
| A4 210 × 297 mm | 210000 × 297000 |
用微米而不是毫米,是为了避开 JS number 在毫米 / 英寸浮点换算里的精度抖动。
类型
interface Printer {
id: string; // 平台内唯一标识:Windows 打印机名、CUPS destination 名
name: string;
driver?: string; // macOS: make-and-model / Windows: 驱动名 / CUPS: printer-make-and-model
remote?: boolean; // macOS
default?: boolean; // macOS
port?: string; // Windows: USB001、192.168.1.10
network?: boolean; // Windows
shared?: boolean; // Windows
instance?: string; // CUPS
uri?: string; // CUPS: printer-uri-supported
state?: string; // CUPS: printer-state,如 idle、processing
isDefault?: boolean; // CUPS
}三个平台返回的字段并不完全一致,缺失的就是 undefined。
interface PaperSize {
id?: string; // macOS paper id / Linux CUPS media 名;Windows 不返回,用 paperCode
name: string; // 平台本地化名称,如 "A4"、"iso_a4_210x297mm"
paperCode?: number; // Windows
widthMm: number;
heightMm: number;
widthMicrons: number;
heightMicrons: number;
leftMarginMicrons?: number; // CUPS 公布的页边距
rightMarginMicrons?: number;
topMarginMicrons?: number;
bottomMarginMicrons?: number;
}
interface PaperValidation {
ok: true;
supported: boolean; // 驱动 / CUPS 是否接受该尺寸
custom?: boolean; // customPaper 为 true;validatePaper 仅 macOS / Windows 为 true
source?: "advertised" | "not-advertised"; // Linux:来自 CUPS 公布的媒体,还是没公布
media?: string; // Linux:匹配到的 CUPS 媒体名
orientation?: "portrait" | "landscape";
reason?: string; // supported 为 false 时的原因说明
requestedWidthMicrons: number;
requestedHeightMicrons: number;
validatedWidthMicrons?: number; // macOS / Linux:校验通过的尺寸
validatedHeightMicrons?: number;
driverWidthMicrons?: number; // Windows:驱动归一化后的 DEVMODE 尺寸
driverHeightMicrons?: number;
}完整类型见包内的 dist/types.d.ts。
语义边界
这几条容易误解,用之前请先看一眼:
validatePaper/customPaper校验的是操作系统打印驱动 / CUPS 所报告或接受的能力,不是对真实纸张、纸盒、物理进纸的保证。customPaper是「构造并验证一个打印任务可用的自定义媒体描述」,不会修改系统全局打印机配置。真正打印时,仍需把对应尺寸传给打印 API(例如webContents.print()的pageSize)。本包不提供打印动作,只做查询与校验。
Linux 上严格区分两种情况:
- exact advertised media —— CUPS destination 明确公布的尺寸,此时
source为advertised; - arbitrary custom —— destination 没有公布 exact media 时,本包不会假装它支持任意自定义尺寸,而是返回
supported: false并在reason里说明,此时source为not-advertised。
这比「尺寸看着合理就返回 true」可靠。
- exact advertised media —— CUPS destination 明确公布的尺寸,此时
错误处理
所有失败都是 PrinterNativeError,带稳定的 code:
try {
await printer.getPaperSizes("不存在的打印机");
} catch (error) {
error.name; // "PrinterNativeError"
error.code; // "PRINTER_NOT_FOUND"
error.message; // "Printer not found"
error.details; // native 返回的原始 JSON
}参数在进入 native 之前就会校验,抛的是 INVALID_ARGUMENT:
printer.validatePaper("id", 0, 50000); // 抛 INVALID_ARGUMENT
printer.getPaperSizes(""); // 抛 INVALID_ARGUMENTERROR_CODES 导出了全部 code 到中文说明的映射:
const { ERROR_CODES } = require("@sv-print/printer");
console.log(ERROR_CODES.PRINTER_NOT_FOUND); // "找不到指定的打印机"| code | 含义 |
| ----------------------------------------------- | ------------------------------------------------- |
| PRINTER_NOT_FOUND | 找不到指定的打印机 |
| PRINTER_LIST_FAILED / PAPER_QUERY_FAILED | 枚举打印机 / 查询纸张失败(系统打印服务返回错误) |
| INVALID_DIMENSION | 尺寸非法,必须是正数且不超过 10000000 微米 |
| ENUM_PRINTERS_FAILED | Windows 枚举打印机失败 |
| CUPS_DESTS_FAILED / CUPS_INFO_FAILED | Linux CUPS 目标列表 / 目标信息获取失败 |
| PRINTER_NATIVE_BINARY_NOT_FOUND | 当前平台 / 架构没有对应的预编译动态库 |
| PRINTER_NATIVE_ARCH_MISMATCH | 预编译动态库架构与当前进程不一致 |
| PRINTER_NATIVE_UNSUPPORTED_PLATFORM / _ARCH | 不支持的平台 / CPU 架构 |
| PRINTER_NATIVE_LOAD_FAILED | 动态库加载失败 |
| PRINTER_KOFFI_MODULE_NOT_FOUND | Koffi 的 native addon 没被打进安装包 |
| PRINTER_NATIVE_BAD_RESPONSE | native 返回了无法解析的内容 |
| NATIVE_ERROR / PRINTER_NATIVE_ERROR | native 层内部错误 |
| INVALID_ARGUMENT | 调用参数不合法 |
打包成 Electron 安装包
预编译动态库不是 Node addon,不需要 electron-rebuild,但必须让 asar 把它解包出来。
一个 npm 包里带着全部 8 个 target(约 1 MB),所以还要顺手把用不上的删掉:
// electron-builder.js —— 文件名必须是这个,不能写成 electron-builder.config.js
// electron-builder 只认 electron-builder.{yml,yaml,json,json5,toml,js,cjs,ts},
// 写成 .config.js 会被静默忽略,整个配置都不生效
module.exports = {
npmRebuild: false,
asarUnpack: [
"**/node_modules/@sv-print/printer/**",
"**/node_modules/koffi/**",
"**/node_modules/@koromix/**",
],
// 删掉用不上的 prebuilds,并把 Koffi 的 native addon 换成目标平台那一份
afterPack: "@sv-print/printer/after-pack",
};不加这一行的话,打 win32 的包也会带上 3 个 darwin 和 3 个 linux 的动态库, 某些杀软还会对「不属于本平台的 .dll」直接报警。
Koffi 的 native addon 必须跟着目标平台走
Koffi 的 native addon 不是编译进 koffi 包里的,而是拆成一堆
@koromix/koffi-<platform>-<arch> 的 npm 包(koffi 的 optionalDependencies)。
运行时它按 process.platform-process.arch 去相邻目录找对应那一个:
node_modules/koffi/src/koffi/index.cjs → node_modules/@koromix/koffi-win32-x64npm 安装时会按 os/cpu 跳过用不上的那些。于是 macOS 上 npm install 之后
node_modules/@koromix/ 里只有 koffi-darwin-arm64,而 electron-builder 打包只是把
node_modules 原样抄进安装包 —— 打出来的 win32 包里依然只有 darwin 那一份,
Windows 用户第一次调用就撞上 Koffi 自己那句没什么信息量的
Cannot find the native Koffi module; did you bundle it correctly?。
afterPack 把这件事也一并做了:
| 情况 | 处理 |
| ------------------------------------------------ | --------------------------------------------------------------------------------- |
| 目标平台的包已经装在应用自己的 node_modules 里 | 直接拷进产物,不联网 |
| 没有 | 从 npm 装上与当前 koffi 完全同版本的 tarball(版本不一致会被 koffi 拒绝加载) |
| 产物里还有别的平台 | 删掉,打 win32 的包不再夹带 darwin 的 .node |
| 认不出的平台/架构,或 koffi 压根没发布这个平台 | 一个字节都不动 |
| 补不上(离线、装不上、404) | 直接让打包失败,并把三条补救办法一起打出来 |
公司内网源用 npm_config_registry 指定。完全离线的机器可以自己装好,但要注意
装在哪里会被打进安装包:electron-builder 默认只收生产依赖树,放在 devDependencies
里的包除非被你的 files 通配(如 electron-egg 常见的 "files": ["**/*"])扫到,否则不会进产物。
稳妥的做法是放进依赖而不是 devDependencies:
npm i --save-prod --force @koromix/koffi-win32-x64也可以打包时设 SV_PRINT_KOFFI_OFFLINE=1,让钩子只从本机已装好的包里取、不联网。
没配 asarUnpack 也能用,但会换一条落点
asarUnpack 里的 **/node_modules/koffi/** 仍然建议配上——它让安装包结构保持常规。
但很多项目只配了 afterPack 没配 asarUnpack,这时 electron-builder 只会自动解包它认得出的
原生文件(版本不同策略还不同:26 会解包带 .node 的包,23 只解含原生二进制的整个包),
koffi 的 JS 于是留在 app.asar 里,它的 __dirname 也在 asar 内,
${__dirname}/../../../@koromix/... 指向 asar 里一个空目录,往 app.asar.unpacked 里放包它看不见。
这种情况钩子会改用 koffi 自己的备用查找位置(loadDynamic 里 process.resourcesPath 那一支):
resources/koffi/build/win32_x64/koffi.node判据不是"解包目录里有没有痕迹"——electron-builder 23 的产物里一个痕迹都不留,
只看目录会误判成"这个应用根本没带 koffi"。钩子直接读 app.asar 的索引(那是打包结果的
权威清单),顺带还能从里面读出 koffi 的版本号,所以连 projectDir 拿不到也能补包。
构建日志里会带一句 koffi is inside app.asar, used its fallback path,便于确认走了哪条路。
两个例外仍然需要 asarUnpack:32 位 ARM(koffi 按 ELF 浮点 ABI 决定目录名,猜不出来),
以及 mac-universal 合并阶段(那时 @electron/universal 只认 asar 索引里的路径)。
代价:asar 里那几份别的平台的 .node 已经压进去了,删不掉(要重新打 asar),
所以这种布局的安装包会比配了 asarUnpack 时大几百 KB 到几 MB。
mac-universal 的每个 slice 会把两个 darwin 包都放本 slice 架构的 addon,
与 prebuilds 的处理同源。原因是 @electron/universal 合并两个 slice 时有三条硬约束:
| 约束 | 违反的后果 | slice 阶段怎么做 |
| ------------------------------------------------------- | ------------------------------------------------------ | ---------------------------------------------------- |
| 同一路径两侧都要存在,且 Mach-O 内容必须不同 | that's the same in both x64 and arm64 builds | 两个 darwin 包都放本 slice 架构的 addon |
| 非 Mach-O 文件(ELF/PE/JS)两侧必须逐字节一致 | Expected all non-binary files to have identical SHAs | linux/win32 的包原样不动 |
| mergeASARs 照 app.asar 的索引去磁盘取 unpacked 文件 | ENOENT | 一个字节都不删,裁剪推迟到合并之后那次 afterPack |
第三条踩过一次:app.asar 的索引里仍然登记着那些 unpacked 文件,合并时会照着索引去读,
slice 阶段删掉的目录当场就会让整个 universal 合并失败。
prunePrebuilds({ root, platform, arch }) 只管预编译动态库;
ensureKoffiNative({ root, platform, arch }) 只管 Koffi 的包,自研流程可以分开调用。
prebuilds 的裁剪规则
afterPack 对预编译动态库做的事:按 electron-builder 当前打包的平台与架构,只留下对应的 target 目录。
几个细节是刻意的:
| 场景 | 保留 | 原因 |
| ----------------- | ----------------------------- | -------------------------------------------------------- |
| win32 + x64 | win32-x64 | |
| mac-universal | darwin-arm64、darwin-x64 | 合并后两个目录里都是通用二进制,任一架构的进程都加载得了 |
| linux + x64 | linux-x64、linux-x64-musl | 打包时无从知道目标机的 libc,两个都留 |
| 认不出的平台/架构 | 一个都不删 | 删错的后果是应用装完第一次调用就崩 |
它同时支持 asar 打包与 asar: false 两种布局。找不到本包、或 target 编译缺失时,
要么原样保留,要么直接让打包失败并说明缺哪个 target,不会安静地产出一个装完就崩的包。
用 electron-forge / @electron/packager / 自研流程的话,规则等价:所有 *.dll、*.dylib、*.so,以及 node_modules/koffi、node_modules/@koromix,必须位于 asar 之外;并且把 prebuilds/ 里不属于目标平台与架构的目录删掉(也可以直接调用本包导出的 prunePrebuilds({ root, platform, arch }))。
Koffi 走的是 N-API,Electron 内置 Node 与它 ABI 兼容,不需要按 Electron 版本重编。
包里已经处理了 asar 路径:包内 JS 仍在 app.asar 内时,解析器会把动态库路径自动重映射到 app.asar.unpacked。如果没配 asarUnpack,报错信息里会直接给出该加哪几行。
排障
启动自检
console.log(printer.binaryInfo());
// {
// target: 'darwin-arm64',
// path: '/app/Contents/Resources/app.asar.unpacked/node_modules/@sv-print/printer/prebuilds/darwin-arm64/libprinter_native.dylib',
// format: 'macho',
// arch: 'arm64',
// source: 'asar-unpacked',
// insideAsar: false
// }binaryInfo() 是同步的,不触发 native 调用,适合放在启动时打一行日志。insideAsar 为 true 说明路径还落在 asar 虚拟目录里,dlopen 必然失败——把 asarUnpack 补上即可。
解析器会读文件头核对真实架构(Mach-O / PE / ELF 都支持),所以「目录名写着 arm64、内容其实是 x86_64」这种问题会直接报 PRINTER_NATIVE_ARCH_MISMATCH,而不是等到 dlopen 抛一个含糊的错误。
缺产物时会发生什么
不会在 require 时就炸,而是在第一次调用 API 时抛出带 code 的错误,并把当前安装里已有的目标和补救办法一起打出来:
PrinterNativeError [PRINTER_NATIVE_BINARY_NOT_FOUND]: No native printer library for "linux-arm64".
Looked for: .../prebuilds/linux-arm64/libprinter_native.so
Prebuilt targets present in this install: darwin-arm64, darwin-x64.
Add this to your electron-builder config: asarUnpack: [...] # 路径在 asar 内时才是这条
All supported targets: darwin-x64, darwin-arm64, ..., linux-arm64-musl.临时指向自己的动态库
不想改包内文件时,用环境变量覆盖:
export ELECTRON_PRINTER_KOFFI_BINARY=/path/to/libprinter_native.dylib适合验证「是不是我本地这个库的问题」。
常见问题
渲染进程能直接 require 这个包吗?
不能。包依赖 Koffi 的 native addon,只能在主进程用。渲染进程通过 preload + contextBridge 转发,见快速开始。
装完必须重编吗?
不需要。包内已带对应平台的预编译动态库,npm install 之后直接用。没有 postinstall,不会在安装时下载或编译任何东西。
需要 electron-rebuild 吗? 不需要。预编译动态库不是 Node addon,不参与 N-API 编译;Koffi 是 N-API,与 Electron 内置 Node 的 ABI 兼容,按 Electron 版本重编反而多余。
打包后启动就崩,报 dlopen 或 PRINTER_NATIVE_BINARY_NOT_FOUND?
九成是 asar 没解包。prebuilds/** 里的动态库必须落在 app.asar.unpacked,加上 asarUnpack 规则(见打包成 Electron 安装包)重新打包即可。用 binaryInfo() 看 insideAsar 是不是 true 可以立刻确认。
报 PRINTER_NATIVE_ARCH_MISMATCH 怎么办?
说明库文件头里的真实架构和当前进程对不上。常见于 Rosetta 下跑 x64 进程、或者手工替换过 prebuilds/ 里的文件。binaryInfo() 会报出文件头读到的真实架构,按它重装对应 target 的包即可。
报 Cannot find the native Koffi module; did you bundle it correctly??
Koffi 自己的 native addon(@koromix/koffi-<platform>-<arch>)没跟着目标平台走,
九成是在一台机器上 npm install、又拿去打另一个平台的包。本包会把它换成
PRINTER_KOFFI_MODULE_NOT_FOUND,并直接写清缺哪个包、那个目录里现在有什么:
PrinterNativeError [PRINTER_KOFFI_MODULE_NOT_FOUND]: Koffi's native addon is not in this build:
it loads node_modules/@koromix/koffi-win32-x64 next to the koffi package
(D:\app\resources\app.asar.unpacked\node_modules\@koromix).
Packages found there: koffi-darwin-arm64.
...对着这个目录确认一眼就能定性:只有别的平台的包,就是本文档上一节说的那件事;
目录是空的或者压根没有 @koromix,则是 asarUnpack 没配。
重新打包前把 asarUnpack 与 afterPack 两行配上(见打包成 Electron 安装包)即可。
getPrinters() 返回空数组?
三个平台都要求打印子系统本身是就绪的:Linux 上 CUPS 服务没启动(systemctl status cups)就会空;Windows 上机器里没有任何已安装的打印机会空;macOS 上没有配置过打印机会空。也可以先用系统自带的打印面板确认能看到打印机。
validatePaper 返回 supported: false,是这台打印机不支持自定义纸张吗?
不一定。Linux 上分两种情况:CUPS 明确公布了该尺寸(source: "advertised")才可能为 true;没公布时本包不会假装支持,直接返回 false 并在 reason 里说明。macOS / Windows 上返回的是驱动是否接受该尺寸。建议先调 getPaperSizes() 看驱动公布了什么,再决定怎么填这个尺寸。
customPaper 会把纸张尺寸加进系统打印机设置吗?
不会。它只构造并校验一个打印任务可用的媒体描述,不修改系统全局配置。要真正按这个尺寸打印,仍需把它传给打印 API,例如 webContents.print({ pageSize: { width, height } })。
尺寸该传毫米还是微米?
微米。80mm 传 80000,58mm 传 58000,A4 传 210000 × 297000。必须是 1 到 10000000 之间的正整数,否则抛 INVALID_ARGUMENT。
这个包能帮我打印吗?
不能。它只提供查询与校验能力,打印动作请用 Electron 的 webContents.print() / printToPDF(),或把拿到的自定义纸张描述传给自己的打印流程。
