diff --git a/README.md b/README.md
index c72a11b..038352d 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,7 @@
-
+
-
OpenPrint
@@ -26,7 +25,6 @@
部署 ·
FAQ
-
**OpenPrint** 是一款开源、零后端依赖的 Web 打印模板可视化设计器。它让用户通过拖拽即可设计快递面单、发票、标签、报表等打印模板,支持 AI 一句话生成、云打印、C++ 桌面客户端静默打印,3 分钟即可对接 ERP 系统。
diff --git a/env.d.ts b/env.d.ts
index 1c055af..fdd8cde 100644
--- a/env.d.ts
+++ b/env.d.ts
@@ -5,6 +5,12 @@ interface ImportMetaEnv {
readonly VITE_OPENPRINT_API_BASE?: string
/** 可选:Bearer 鉴权 token */
readonly VITE_OPENPRINT_API_TOKEN?: string
+ /**
+ * 可选:本地打印客户端基地址,覆盖出厂默认 http://127.0.0.1:18888。
+ * 例:VITE_OPENPRINT_PRINTER_BASE=http://192.168.1.20:19000
+ * 优先级低于「设置 → 本地打印」里手填的 IP/端口。
+ */
+ readonly VITE_OPENPRINT_PRINTER_BASE?: string
}
interface ImportMeta {
diff --git a/screenshots/logo.png b/screenshots/logo.png
new file mode 100644
index 0000000..243b966
Binary files /dev/null and b/screenshots/logo.png differ
diff --git a/src/config/print-settings.spec.ts b/src/config/print-settings.spec.ts
index c8d6273..8d828f6 100644
--- a/src/config/print-settings.spec.ts
+++ b/src/config/print-settings.spec.ts
@@ -42,7 +42,7 @@ describe('print-settings —— 打印设置读写', () => {
const s = readPrintSettings()
expect(s.local.method).toBe('silent')
expect(s.local.copies).toBe(DEFAULT_PRINT_SETTINGS.local.copies)
- expect(s.local.silent).toEqual({ host: '127.0.0.1', port: 9122 })
+ expect(s.local.silent).toEqual({ host: '127.0.0.1', port: 18888 })
expect(s.remote.enabled).toBe(false)
})
})
diff --git a/src/config/print-settings.ts b/src/config/print-settings.ts
index 8ff8114..b587d4f 100644
--- a/src/config/print-settings.ts
+++ b/src/config/print-settings.ts
@@ -6,11 +6,15 @@
export interface LocalPrintConfig {
/** 打印方式:browser=浏览器打印对话框 / silent=客户端静默打印(需本地客户端服务) */
method: 'browser' | 'silent'
- /** 静默打印客户端连接地址(仅 method=silent 生效) */
+ /**
+ * 本地打印客户端连接地址。
+ * 同时也是 /health、/printers、/print 三个接口的基地址来源,
+ * 解析优先级见 `@/config/printer` 的 resolvePrinterBaseUrl()。
+ */
silent: {
- /** 客户端所在主机 IP(默认本机回环) */
+ /** 客户端所在主机 IP(默认本机回环 127.0.0.1) */
host: string
- /** 客户端服务端口(默认 9122) */
+ /** 客户端服务端口(默认 18888) */
port: number
}
/** 副本数 */
@@ -40,7 +44,7 @@ export const PRINT_SETTINGS_KEY = 'openprint:print-settings'
export const DEFAULT_PRINT_SETTINGS: PrintSettings = {
local: {
method: 'browser',
- silent: { host: '127.0.0.1', port: 9122 },
+ silent: { host: '127.0.0.1', port: 18888 },
copies: 1,
closeAfterPrint: true,
},
diff --git a/src/config/printer.ts b/src/config/printer.ts
new file mode 100644
index 0000000..1618a8f
--- /dev/null
+++ b/src/config/printer.ts
@@ -0,0 +1,93 @@
+/**
+ * 打印客户端服务地址解析 —— 端口/地址覆盖的唯一出口
+ *
+ * 出厂默认:http://127.0.0.1:18888
+ *
+ * 覆盖优先级(高 → 低):
+ * 1. 用户在「设置 → 本地打印」里填的 IP/端口(localStorage `openprint:print-settings`)
+ * 2. 构建期环境变量 `VITE_OPENPRINT_PRINTER_BASE`(项目根 .env)
+ * 3. 出厂默认 http://127.0.0.1:18888
+ *
+ * 主任铁律:无后端全链路可用 —— 这里只读本地存储与构建期常量,绝不请求任何后端。
+ */
+import { DEFAULT_PRINTER_BASE_URL } from '@/core/print-client'
+import { PRINT_SETTINGS_KEY } from './print-settings'
+
+/** 构建期环境变量覆盖(示例:VITE_OPENPRINT_PRINTER_BASE=http://192.168.1.20:19000) */
+const ENV_PRINTER_BASE = (import.meta.env.VITE_OPENPRINT_PRINTER_BASE ?? '').trim()
+
+/** 地址来源,UI 用于提示用户当前生效的是哪一层配置 */
+export type PrinterBaseSource = 'settings' | 'env' | 'default'
+
+/** 出厂默认,供 UI 占位/重置使用 */
+export const FACTORY_PRINTER_BASE_URL = DEFAULT_PRINTER_BASE_URL
+export const FACTORY_PRINTER_HOST = '127.0.0.1'
+export const FACTORY_PRINTER_PORT = 18888
+
+/** 补全协议 + 去掉尾部斜杠 */
+export function normalizePrinterBase(input: string): string {
+ const raw = input.trim().replace(/\/+$/, '')
+ if (!raw) return ''
+ return /^https?:\/\//i.test(raw) ? raw : `http://${raw}`
+}
+
+/** host + port → 基地址。host 可带协议(http://x)或只写 IP/域名 */
+export function buildPrinterBase(host: string, port: number): string {
+ const base = normalizePrinterBase(host)
+ if (!base) return ''
+ // host 里已经自带端口就不再拼(如用户填了 "127.0.0.1:18888")
+ if (/:\d+$/.test(base)) return base
+ const p = Number.isFinite(port) && port > 0 ? Math.floor(port) : FACTORY_PRINTER_PORT
+ return `${base}:${p}`
+}
+
+/**
+ * 读取用户在设置里显式配置的端点。
+ * 只有 localStorage 里确实存在 local.silent.host/port 才算「用户配置过」,
+ * 否则返回 null 让位给环境变量。
+ */
+export function readStoredPrinterEndpoint(): { host: string; port: number } | null {
+ if (typeof window === 'undefined') return null
+ try {
+ const raw = window.localStorage.getItem(PRINT_SETTINGS_KEY)
+ if (!raw) return null
+ const parsed = JSON.parse(raw) as {
+ local?: { silent?: { host?: unknown; port?: unknown } }
+ }
+ const silent = parsed?.local?.silent
+ if (!silent) return null
+ const host = typeof silent.host === 'string' ? silent.host.trim() : ''
+ const port = typeof silent.port === 'number' ? silent.port : Number(silent.port)
+ if (!host || !Number.isFinite(port) || port <= 0) return null
+ return { host, port: Math.floor(port) }
+ } catch {
+ return null
+ }
+}
+
+/** 当前生效的打印客户端基地址 */
+export function resolvePrinterBaseUrl(): string {
+ const stored = readStoredPrinterEndpoint()
+ if (stored) {
+ const base = buildPrinterBase(stored.host, stored.port)
+ if (base) return base
+ }
+ if (ENV_PRINTER_BASE) {
+ const base = normalizePrinterBase(ENV_PRINTER_BASE)
+ if (base) return base
+ }
+ return FACTORY_PRINTER_BASE_URL
+}
+
+/** 当前地址来自哪一层配置(UI 提示用) */
+export function resolvePrinterBaseSource(): PrinterBaseSource {
+ if (readStoredPrinterEndpoint()) return 'settings'
+ if (ENV_PRINTER_BASE) return 'env'
+ return 'default'
+}
+
+/** 是否存在构建期环境变量覆盖(设置面板里给出说明) */
+export const hasEnvPrinterBase = ENV_PRINTER_BASE.length > 0
+
+/** 环境变量原值(仅用于设置面板展示) */
+export const envPrinterBase = ENV_PRINTER_BASE
diff --git a/src/core/chartkit/bar.ts b/src/core/chartkit/bar.ts
index a9d815e..3df37d9 100644
--- a/src/core/chartkit/bar.ts
+++ b/src/core/chartkit/bar.ts
@@ -21,7 +21,7 @@ export function renderBar(model: ChartModel): string {
const titleH = title ? 22 : 0
const mTop = 10 + titleH
const mRight = 16
- const mBottom = (showAxis ? 38 : 22) + legendH
+ const mBottom = (showAxis ? 42 : 22) + legendH
const mLeft = showAxis ? 44 : 16
const px0 = mLeft
@@ -64,7 +64,7 @@ export function renderBar(model: ChartModel): string {
if (showGrid && i > 0) {
parts.push(``)
}
- parts.push(`${fmt(v)}`)
+ parts.push(`${fmt(v)}`)
}
// 基线
parts.push(``)
@@ -83,7 +83,7 @@ export function renderBar(model: ChartModel): string {
const color = seriesColor(model.series[s]!, s, opt)
parts.push(``)
if (valueLabel && h > 0) {
- parts.push(`${fmt(val)}`)
+ parts.push(`${fmt(val)}`)
}
}
// x 轴标签:始终以类目为基准居中于对应条带正下方,并与图表留一点间距
@@ -102,7 +102,7 @@ export function renderBar(model: ChartModel): string {
}
const startX = legendStartX(align, W, totalW, px0)
let lx = startX
- let ly = py1 + (showAxis ? 30 : 16)
+ let ly = py1 + (showAxis ? 40 : 16)
for (let s = 0; s < sCount; s++) {
const name = model.series[s]!.name || `系列${s + 1}`
const color = seriesColor(model.series[s]!, s, opt)
@@ -112,7 +112,7 @@ export function renderBar(model: ChartModel): string {
ly += 16
}
parts.push(``)
- parts.push(`${escapeXml(truncate(name, 10))}`)
+ parts.push(`${escapeXml(truncate(name, 10))}`)
lx += itemW
}
}
diff --git a/src/core/chartkit/line.ts b/src/core/chartkit/line.ts
index 3e1641a..315657c 100644
--- a/src/core/chartkit/line.ts
+++ b/src/core/chartkit/line.ts
@@ -43,7 +43,7 @@ export function renderLine(model: ChartModel): string {
const titleH = title ? 22 : 0
const mTop = 10 + titleH
const mRight = 16
- const mBottom = (showAxis ? 38 : 22) + legendH
+ const mBottom = (showAxis ? 42 : 22) + legendH
const mLeft = showAxis ? 44 : 16
const px0 = mLeft
@@ -95,7 +95,7 @@ export function renderLine(model: ChartModel): string {
if (showGrid && i > 0) {
parts.push(``)
}
- parts.push(`${fmt(v)}`)
+ parts.push(`${fmt(v)}`)
}
parts.push(``)
parts.push(``)
@@ -126,7 +126,7 @@ export function renderLine(model: ChartModel): string {
const series = model.series[si]!
for (let c = 0; c < sp.pts.length; c++) {
const v = series.data[c] ?? 0
- parts.push(`${fmt(v)}`)
+ parts.push(`${fmt(v)}`)
}
}
}
@@ -140,7 +140,7 @@ export function renderLine(model: ChartModel): string {
}
const startX = legendStartX(align, W, totalW, px0)
let lx = startX
- let ly = py1 + (showAxis ? 30 : 16)
+ let ly = py1 + (showAxis ? 40 : 16)
for (let s = 0; s < seriesPts.length; s++) {
const name = seriesPts[s]!.name || `系列${s + 1}`
const color = seriesPts[s]!.color
@@ -150,7 +150,7 @@ export function renderLine(model: ChartModel): string {
ly += 16
}
parts.push(``)
- parts.push(`${escapeXml(truncate(name, 10))}`)
+ parts.push(`${escapeXml(truncate(name, 10))}`)
lx += itemW
}
}
diff --git a/src/core/chartkit/pie.ts b/src/core/chartkit/pie.ts
index ee8f41f..3fe9cfd 100644
--- a/src/core/chartkit/pie.ts
+++ b/src/core/chartkit/pie.ts
@@ -58,7 +58,7 @@ export function renderPie(model: ChartModel): string {
const lr = donut ? (rOuter + rInner) / 2 : rOuter * 0.62
const p = polarL(lr, mid)
const pct = ((v / total) * 100).toFixed(0) + '%'
- parts.push(`${pct}`)
+ parts.push(`${pct}`)
}
}
@@ -81,7 +81,7 @@ export function renderPie(model: ChartModel): string {
ly += 16
}
parts.push(``)
- parts.push(`${escapeXml(truncate(name, 10))}`)
+ parts.push(`${escapeXml(truncate(name, 10))}`)
lx += itemW
}
}
diff --git a/src/core/export-engine/chart-svg-to-path.ts b/src/core/export-engine/chart-svg-to-path.ts
new file mode 100644
index 0000000..c48bd70
--- /dev/null
+++ b/src/core/export-engine/chart-svg-to-path.ts
@@ -0,0 +1,85 @@
+/**
+ * chart-svg-to-path —— 把图表 SVG 里的 转成矢量 字形轮廓
+ *
+ * 背景:jsPDF 只能内嵌 TrueType(glyf) 字体,无法内嵌 CFF/OpenType 字体;
+ * 思源宋体 .ttf 实为 CFF,jsPDF 内嵌会静默失败 → 矢量图表中文标签变空白。
+ * 解决:在导出 PDF 的矢量路径里,用 opentype.js 把每个字形展开为 path,
+ * 文本即成为与字体无关的矢量轮廓,svg2pdf 直接画 path,中文不再丢。
+ *
+ * 仅 export-pdf 的图表矢量路径按需动态调用,不进主包、不影响设计期/预览。
+ */
+const SERIF_TTF_URL = '/fonts/SourceHanSerifCN-Regular.ttf'
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+let fontPromise: Promise | null = null
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+async function loadFont(): Promise {
+ if (!fontPromise) {
+ fontPromise = (async () => {
+ try {
+ const res = await fetch(SERIF_TTF_URL)
+ if (!res.ok) return null
+ const buf = await res.arrayBuffer()
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const ot: any = await import('opentype.js')
+ const parse = ot.parse ?? ot.default?.parse ?? ot.default
+ return parse(buf)
+ } catch {
+ return null
+ }
+ })()
+ }
+ return fontPromise
+}
+
+/**
+ * 把图表 SVG 字符串里的所有 替换为字形轮廓 。
+ * 字体加载失败/解析异常时原样返回(交给 svg2pdf,最坏情况该页走栅格兜底)。
+ */
+export async function outlineChartSvgText(svg: string): Promise {
+ const font = await loadFont()
+ if (!font) return svg
+
+ const doc = new DOMParser().parseFromString(svg, 'image/svg+xml')
+ const svgEl = doc.documentElement
+ if (!svgEl || svgEl.nodeName.toLowerCase() !== 'svg') return svg
+
+ const texts = Array.from(svgEl.getElementsByTagName('text'))
+ for (const t of texts) {
+ const text = t.textContent ?? ''
+ if (!text) {
+ t.remove()
+ continue
+ }
+ const x = parseFloat(t.getAttribute('x') ?? '0') || 0
+ const y = parseFloat(t.getAttribute('y') ?? '0') || 0
+ const fontSize = parseFloat(t.getAttribute('font-size') ?? '10') || 10
+ const fill = t.getAttribute('fill') || '#000000'
+ const anchor = (t.getAttribute('text-anchor') || 'start').toLowerCase()
+
+ const scale = fontSize / font.unitsPerEm
+ const total = font.getAdvanceWidth(text, fontSize)
+ let startX = x
+ if (anchor === 'middle') startX = x - total / 2
+ else if (anchor === 'end') startX = x - total
+
+ const ds: string[] = []
+ let cursor = startX
+ for (const ch of text) {
+ const g = font.charToGlyph(ch)
+ if (!g) continue
+ const gp = g.getPath(cursor, y, fontSize)
+ const d = gp.toPathData(2)
+ if (d && d.trim()) ds.push(d)
+ cursor += (g.advanceWidth || 0) * scale
+ }
+
+ const path = doc.createElementNS('http://www.w3.org/2000/svg', 'path')
+ path.setAttribute('d', ds.join(' '))
+ path.setAttribute('fill', fill)
+ if (t.parentNode) t.parentNode.replaceChild(path, t)
+ }
+
+ return new XMLSerializer().serializeToString(svgEl)
+}
diff --git a/src/core/export-engine/export-pdf.ts b/src/core/export-engine/export-pdf.ts
index eb63628..d50930e 100644
--- a/src/core/export-engine/export-pdf.ts
+++ b/src/core/export-engine/export-pdf.ts
@@ -4,13 +4,15 @@
* ## 图表走矢量(svg2pdf)
* 设计器一切以 SVG 为真相源。为让图表在 PDF 里达到「印刷级矢量」:
* 1. 每页先**剥离 chart 控件** → 栅格化(文本/表格位图底图)→ addImage 作背景;
- * 2. 再遍历该页 chart 节点,用 `svg2pdf` 把其 SVG 以**矢量**注入 jsPDF 对应 mm 盒;
- * 3. 思源宋体 TTF 注册进 jsPDF('SourceHanSerifCN'),图表中文标签才不会变空白/方块。
+ * 2. 再遍历该页 chart 节点,把 SVG 里的 `` 用 opentype.js 展开为**矢量字形轮廓**,
+ * 再用 `svg2pdf` 把其 SVG 以**矢量**注入 jsPDF 对应 mm 盒;
+ * (关键:jsPDF 只能内嵌 TrueType 字体、无法内嵌 CFF/OpenType,而思源宋体恰为 CFF,
+ * 直接注册会静默失败导致中文标签空白——转轮廓后文字与字体完全解耦,中文必现。)
*
* 兜底:
* - 图表被旋转 → 整页退回旧的全栅格化(旋转在矢量叠加里不易对齐);
- * - 单个图表 svg2pdf 失败 → 该图表单独栅格化后叠加(不丢图);
- * - 字体加载失败(极端环境)→ 整页退回全栅格化(仍是位图,但保证有图)。
+ * - 单个图表矢量失败/字形展开失败 → 该图表单独栅格化后叠加(不丢图);
+ * - 整页矢量叠加彻底失败 → 整页退回全栅格化(仍是位图,但保证有图)。
*
* 非图表页(无 chart 控件)仍走原全栅格化位图路径,行为与旧版一致。
*/
@@ -20,14 +22,12 @@ import { embedFontsInSvg, type FontFaceDef } from './fonts'
import type { PageDecoration } from '@/types/template'
import { blobToDataURL } from './util'
import { mmv } from '@/core/renderer-html/css-generator'
+import { outlineChartSvgText } from './chart-svg-to-path'
-/** 思源宋体 —— PDF 唯一内联字体(写死 TTF,位图 PDF 保证中文衬线效果;矢量图表也注册它) */
+/** 思源宋体 —— 位图底图(含非图表文本)内联字体,保证中文衬线效果 */
const PDF_FONTS: FontFaceDef[] = [
{ family: '思源宋体', src: '/fonts/SourceHanSerifCN-Regular.ttf', weight: 400 },
]
-/** 矢量图表文本使用的 jsPDF 字体名(必须与 chartkit SVG 的 font-family 首选项一致) */
-const SERIF_FONT_NAME = 'SourceHanSerifCN'
-const SERIF_TTF_URL = '/fonts/SourceHanSerifCN-Regular.ttf'
export interface PdfOptions {
/** 高清倍率,1=96dpi / 2=192dpi(推荐)/ 3=288dpi,默认 2 */
@@ -76,31 +76,6 @@ function chartsOf(page: LayoutPage): ChartBox[] {
return out
}
-/** ArrayBuffer → base64(分块,避免 call stack 溢出) */
-function arrayBufferToBase64(buf: ArrayBuffer): string {
- const bytes = new Uint8Array(buf)
- let bin = ''
- const chunk = 0x8000
- for (let i = 0; i < bytes.length; i += chunk) {
- bin += String.fromCharCode(...bytes.subarray(i, i + chunk))
- }
- return btoa(bin)
-}
-
-/** 把思源宋体 TTF 注册进 jsPDF,供矢量图表中文标签使用。失败不抛(退化为位图兜底) */
-async function registerSerifFont(doc: { addFileToVFS: (n: string, d: string) => void; addFont: (f: string, n: string, s: string) => void }): Promise {
- try {
- const res = await fetch(SERIF_TTF_URL)
- if (!res.ok) return false
- const b64 = arrayBufferToBase64(await res.arrayBuffer())
- doc.addFileToVFS(`${SERIF_FONT_NAME}.ttf`, b64)
- doc.addFont(`${SERIF_FONT_NAME}.ttf`, SERIF_FONT_NAME, 'normal')
- return true
- } catch {
- return false
- }
-}
-
/** 单图表栅格化(svg2pdf 失败时的兜底,保证不丢图) */
async function rasterizeChart(chartSvg: string, wMm: number, hMm: number, scale: number): Promise {
const inner = `${chartSvg}
`
@@ -127,8 +102,6 @@ export async function documentToPdf(result: LayoutResult, opts: PdfOptions = {})
const doc = new JsPDF({ unit: 'mm', format: [w, h], orientation })
- let serifReady = false
-
for (let i = 0; i < result.pages.length; i++) {
if (i > 0) doc.addPage([w, h], orientation)
@@ -143,8 +116,7 @@ export async function documentToPdf(result: LayoutResult, opts: PdfOptions = {})
continue
}
- // 矢量路径:先栅格化"剥掉图表"的底图,再把图表以矢量叠加
- if (!serifReady) serifReady = await registerSerifFont(doc)
+ // 矢量路径:先栅格化"剥掉图表"的底图,再把图表以矢量叠加(文字已转轮廓,无需内嵌字体)
const strippedResult: LayoutResult = {
...result,
pages: result.pages.map((p, idx) => (idx === i ? stripCharts(p) : p)),
@@ -155,7 +127,9 @@ export async function documentToPdf(result: LayoutResult, opts: PdfOptions = {})
let allVectorOk = true
for (const ch of charts) {
try {
- const el = svgElementFromString(ch.svg)
+ // 字形转轮廓:把 展开为矢量 path,避免 jsPDF 无法内嵌 CFF 字体导致中文空白
+ const outlinedSvg = await outlineChartSvgText(ch.svg)
+ const el = svgElementFromString(outlinedSvg)
el.style.position = 'absolute'
el.style.left = '-99999px'
el.style.top = '0'
diff --git a/src/core/print-client/client.ts b/src/core/print-client/client.ts
new file mode 100644
index 0000000..bd4a0fe
--- /dev/null
+++ b/src/core/print-client/client.ts
@@ -0,0 +1,192 @@
+/**
+ * 本地打印机客户端 HTTP 客户端 —— 纯 TypeScript,零框架依赖
+ *
+ * 所有方法都显式接收 baseUrl(由 `@/config/printer` 解析),
+ * 便于 headless / 单测直接注入,不隐式读全局配置。
+ */
+import {
+ PrintClientError,
+ type PrinterHealth,
+ type PrinterInfo,
+ type PrinterListResponse,
+ type PrintJobRequest,
+ type PrintJobResponse,
+} from './types'
+
+/** 出厂默认地址(用户未配置时使用) */
+export const DEFAULT_PRINTER_BASE_URL = 'http://127.0.0.1:18888'
+
+/** 探测类请求默认超时(ms)—— 本机服务,短超时快速失败 */
+export const PROBE_TIMEOUT_MS = 3000
+
+/** 提交打印任务默认超时(ms)—— 大文档 base64 传输留足时间 */
+export const SUBMIT_TIMEOUT_MS = 30000
+
+/** 去掉尾部斜杠,拼接路径 */
+function joinUrl(base: string, path: string): string {
+ return `${base.replace(/\/+$/, '')}${path}`
+}
+
+/**
+ * 带超时 + 错误分类的 fetch。
+ * 用手工 AbortController 而非 AbortSignal.timeout,才能区分「超时」与「连不上」。
+ */
+async function request(
+ url: string,
+ init: RequestInit,
+ timeoutMs: number,
+): Promise {
+ const ctrl = new AbortController()
+ let timedOut = false
+ const timer = setTimeout(() => {
+ timedOut = true
+ ctrl.abort()
+ }, timeoutMs)
+
+ let res: Response
+ try {
+ res = await fetch(url, { ...init, signal: ctrl.signal })
+ } catch {
+ if (timedOut) {
+ throw new PrintClientError('timeout', `请求超时(${timeoutMs}ms):${url}`)
+ }
+ throw new PrintClientError('network', `无法连接打印客户端:${url}`)
+ } finally {
+ clearTimeout(timer)
+ }
+
+ if (!res.ok) {
+ throw new PrintClientError('http', `HTTP ${res.status} ${res.statusText}`, res.status)
+ }
+
+ let json: unknown
+ try {
+ json = await res.json()
+ } catch {
+ throw new PrintClientError('parse', '响应不是合法 JSON')
+ }
+ if (json === null || typeof json !== 'object') {
+ throw new PrintClientError('parse', '响应结构异常')
+ }
+ return json as T
+}
+
+/* ------------------------------ GET /health ------------------------------ */
+
+/**
+ * 健康检查。成功返回服务版本、打印机数量、运行时长。
+ * @throws PrintClientError 连接失败 / 超时 / ok:false
+ */
+export async function checkHealth(
+ baseUrl: string = DEFAULT_PRINTER_BASE_URL,
+ timeoutMs: number = PROBE_TIMEOUT_MS,
+): Promise {
+ const data = await request>(
+ joinUrl(baseUrl, '/health'),
+ { method: 'GET', headers: { Accept: 'application/json' } },
+ timeoutMs,
+ )
+ if (data.ok !== true) {
+ throw new PrintClientError('service', '打印客户端报告服务异常(ok=false)')
+ }
+ return {
+ app: data.app ?? 'Unknown',
+ ok: true,
+ printers: typeof data.printers === 'number' ? data.printers : 0,
+ time: data.time ?? '',
+ uptimeSec: typeof data.uptimeSec === 'number' ? data.uptimeSec : 0,
+ version: data.version ?? '',
+ }
+}
+
+/* ------------------------------ GET /printers ------------------------------ */
+
+/** 单台打印机字段归一化(服务端字段缺失时给安全默认,避免 UI 崩) */
+function normalizePrinter(raw: Partial, index: number): PrinterInfo {
+ return {
+ driver: raw.driver ?? '',
+ isDefault: raw.isDefault === true,
+ isOnline: raw.isOnline !== false,
+ kind: raw.kind === 'virtual' || raw.kind === 'ticket' ? raw.kind : 'common',
+ maxDpi: typeof raw.maxDpi === 'number' ? raw.maxDpi : 0,
+ name: raw.name ?? `未命名打印机 ${index + 1}`,
+ status: raw.status === 'error' ? 'error' : 'idle',
+ supportsColor: raw.supportsColor === true,
+ supportsDuplex: raw.supportsDuplex === true,
+ trays: Array.isArray(raw.trays) ? raw.trays.filter((t): t is string => typeof t === 'string') : [],
+ }
+}
+
+/**
+ * 获取打印机列表。
+ * 注意:**以 `printers` 数组长度为准**,服务端的 `count` 字段仅供参考(实测可能不一致)。
+ */
+export async function listPrinters(
+ baseUrl: string = DEFAULT_PRINTER_BASE_URL,
+ timeoutMs: number = PROBE_TIMEOUT_MS,
+): Promise {
+ const data = await request>(
+ joinUrl(baseUrl, '/printers'),
+ { method: 'GET', headers: { Accept: 'application/json' } },
+ timeoutMs,
+ )
+ if (data.ok === false) {
+ throw new PrintClientError('service', '打印客户端未能枚举打印机')
+ }
+ if (!Array.isArray(data.printers)) {
+ throw new PrintClientError('parse', '响应缺少 printers 数组')
+ }
+ return data.printers.map((p, i) => normalizePrinter(p ?? {}, i))
+}
+
+/* ------------------------------ POST /print ------------------------------ */
+
+/**
+ * 提交打印任务。
+ * 载荷约定(见《打印机数据交互文档》):
+ * - 单页 → `format:'svg'`,`encoding:'utf8'`,content 为 SVG 原文
+ * - 多页 → `format:'pdf'`,`encoding:'base64'`,content 为 base64(无 data: 前缀)
+ */
+export async function submitPrintJob(
+ job: PrintJobRequest,
+ baseUrl: string = DEFAULT_PRINTER_BASE_URL,
+ timeoutMs: number = SUBMIT_TIMEOUT_MS,
+): Promise {
+ const data = await request>(
+ joinUrl(baseUrl, '/print'),
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
+ body: JSON.stringify(job),
+ },
+ timeoutMs,
+ )
+ if (data.ok !== true) {
+ throw new PrintClientError('service', data.message || '打印客户端拒绝了本次任务')
+ }
+ return { ok: true, jobId: data.jobId, message: data.message }
+}
+
+/* ------------------------------ 工具 ------------------------------ */
+
+/**
+ * Blob → base64(不含 `data:*;base64,` 前缀)。
+ * 用于把 PDF 二进制转成可 JSON 传输的字符串。
+ */
+export function blobToBase64(blob: Blob): Promise {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader()
+ reader.onerror = () => reject(new Error('读取导出文件失败'))
+ reader.onload = () => {
+ const result = String(reader.result ?? '')
+ const comma = result.indexOf(',')
+ resolve(comma >= 0 ? result.slice(comma + 1) : result)
+ }
+ reader.readAsDataURL(blob)
+ })
+}
+
+/** Blob → utf8 文本(用于 SVG 载荷) */
+export function blobToText(blob: Blob): Promise {
+ return blob.text()
+}
diff --git a/src/core/print-client/index.ts b/src/core/print-client/index.ts
new file mode 100644
index 0000000..510e62b
--- /dev/null
+++ b/src/core/print-client/index.ts
@@ -0,0 +1,41 @@
+/**
+ * 本地打印机客户端服务层入口
+ *
+ * 用法:
+ * ```ts
+ * import { checkHealth, listPrinters, submitPrintJob } from '@/core/print-client'
+ * import { resolvePrinterBaseUrl } from '@/config/printer'
+ *
+ * const base = resolvePrinterBaseUrl()
+ * const health = await checkHealth(base)
+ * const printers = await listPrinters(base)
+ * ```
+ */
+export {
+ DEFAULT_PRINTER_BASE_URL,
+ PROBE_TIMEOUT_MS,
+ SUBMIT_TIMEOUT_MS,
+ checkHealth,
+ listPrinters,
+ submitPrintJob,
+ blobToBase64,
+ blobToText,
+} from './client'
+
+export { PrintClientError, describePrintError } from './types'
+
+export { buildPrintPayload, formatPayloadSize } from './payload'
+export type { PrintPayload, BuildPrintPayloadOptions } from './payload'
+
+export type {
+ PrinterHealth,
+ PrinterInfo,
+ PrinterKind,
+ PrinterState,
+ PrinterListResponse,
+ PrintPayloadFormat,
+ PrintPayloadEncoding,
+ PrintJobRequest,
+ PrintJobResponse,
+ PrintClientErrorCode,
+} from './types'
diff --git a/src/core/print-client/payload.ts b/src/core/print-client/payload.ts
new file mode 100644
index 0000000..8b176be
--- /dev/null
+++ b/src/core/print-client/payload.ts
@@ -0,0 +1,67 @@
+/**
+ * 打印载荷构建 —— 模板 + 数据 → 推送给本地打印客户端的文档内容
+ *
+ * 推送格式规则(主任定,2026-08-12):
+ * - **单页** → SVG(矢量原文,utf8)。体积小、矢量无损,客户端可直接送打印或转 EMF。
+ * - **多页** → PDF(base64)。PDF 天然多页,base64 后可走 JSON 传输。
+ *
+ * 只 render 一次:先 `renderDocument()` 拿到 LayoutResult 判定页数,
+ * 再按页数走对应导出器,避免 `exportDocument()` 内部二次分页。
+ */
+import type { RenderRequest } from '@/core/sdk'
+import { renderDocument } from '@/core/sdk'
+import { documentToPdf, documentToSvgString } from '@/core/export-engine'
+import type { FontFaceDef } from '@/core/export-engine'
+import { templateUsedFonts, toExportFontDefs } from '@/core/fonts/loader'
+import { blobToBase64 } from './client'
+import type { PrintPayloadEncoding, PrintPayloadFormat } from './types'
+
+export interface PrintPayload {
+ /** 载荷格式:单页 svg / 多页 pdf */
+ format: PrintPayloadFormat
+ /** 载荷编码:svg=utf8 / pdf=base64 */
+ encoding: PrintPayloadEncoding
+ /** 文档内容 */
+ content: string
+ /** 总页数 */
+ pages: number
+ /** 载荷字符长度(UI 展示体积用) */
+ bytes: number
+}
+
+export interface BuildPrintPayloadOptions {
+ /** PDF 位图底图倍率,默认 2(192dpi) */
+ scale?: number
+ /**
+ * 需要内联进 SVG 的字体。不传则自动从模板里提取用到的内置字体,
+ * 保证客户端渲染 SVG 时中文不掉字(SVG 是隔离上下文,看不到宿主 @font-face)。
+ */
+ fonts?: FontFaceDef[]
+}
+
+/** 构建推送载荷(单页 SVG / 多页 PDF-base64) */
+export async function buildPrintPayload(
+ request: RenderRequest,
+ options: BuildPrintPayloadOptions = {},
+): Promise {
+ const result = await renderDocument(request)
+ const pages = result.pages.length
+ const deco = request.output?.pageDecoration
+
+ if (pages <= 1) {
+ const fonts = options.fonts ?? toExportFontDefs(templateUsedFonts(request.template))
+ const svg = await documentToSvgString(result, fonts.length ? fonts : undefined, deco)
+ return { format: 'svg', encoding: 'utf8', content: svg, pages: Math.max(pages, 1), bytes: svg.length }
+ }
+
+ const blob = await documentToPdf(result, { scale: options.scale ?? 2, pageDecoration: deco })
+ const base64 = await blobToBase64(blob)
+ return { format: 'pdf', encoding: 'base64', content: base64, pages, bytes: base64.length }
+}
+
+/** 人类可读体积(UI 提示用) */
+export function formatPayloadSize(bytes: number): string {
+ if (bytes < 1024) return `${bytes} B`
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
+ return `${(bytes / 1024 / 1024).toFixed(2)} MB`
+}
diff --git a/src/core/print-client/types.ts b/src/core/print-client/types.ts
new file mode 100644
index 0000000..21243ea
--- /dev/null
+++ b/src/core/print-client/types.ts
@@ -0,0 +1,153 @@
+/**
+ * 本地打印机客户端服务 —— 协议类型
+ *
+ * 对接本机常驻的打印客户端(默认 http://127.0.0.1:18888),提供:
+ * GET /health 服务健康与版本
+ * GET /printers 打印机列表
+ * POST /print 提交打印任务(单页 SVG / 多页 PDF-base64)
+ *
+ * 主任铁律:无后端全链路可用 —— 客户端不可达时设计器一切功能照常,只是不能推送打印。
+ */
+
+/* ------------------------------ /health ------------------------------ */
+
+/** GET /health 响应体 */
+export interface PrinterHealth {
+ /** 客户端应用名,如 "OpenPrint" */
+ app: string
+ /** 服务是否正常 */
+ ok: boolean
+ /** 当前可用打印机数量 */
+ printers: number
+ /** 服务端时间(ISO 字符串) */
+ time: string
+ /** 已运行秒数 */
+ uptimeSec: number
+ /** 客户端版本号 */
+ version: string
+}
+
+/* ------------------------------ /printers ------------------------------ */
+
+/** 打印机类型:虚拟打印机 / 普通打印机 / 票据打印机 */
+export type PrinterKind = 'virtual' | 'common' | 'ticket'
+
+/** 打印机运行状态 */
+export type PrinterState = 'idle' | 'error'
+
+/** 单台打印机描述 */
+export interface PrinterInfo {
+ /** 驱动名 */
+ driver: string
+ /** 是否系统默认打印机 */
+ isDefault: boolean
+ /** 是否在线 */
+ isOnline: boolean
+ /** 打印机类型 */
+ kind: PrinterKind
+ /** 最大分辨率(DPI) */
+ maxDpi: number
+ /** 打印机名(提交任务时的唯一标识) */
+ name: string
+ /** 运行状态 */
+ status: PrinterState
+ /** 是否支持彩色 */
+ supportsColor: boolean
+ /** 是否支持双面 */
+ supportsDuplex: boolean
+ /** 纸盒列表 */
+ trays: string[]
+}
+
+/** GET /printers 响应体 */
+export interface PrinterListResponse {
+ /** 服务端声明的数量(可能与 printers.length 不一致,以数组为准) */
+ count: number
+ ok: boolean
+ printers: PrinterInfo[]
+}
+
+/* ------------------------------ /print ------------------------------ */
+
+/** 推送载荷格式:单页 SVG(矢量文本),多页 PDF(base64) */
+export type PrintPayloadFormat = 'svg' | 'pdf'
+
+/** 载荷编码:svg 用 utf8 原文,pdf 用 base64 */
+export type PrintPayloadEncoding = 'utf8' | 'base64'
+
+/** POST /print 请求体 */
+export interface PrintJobRequest {
+ /** 任务名(用于打印队列显示) */
+ taskName: string
+ /** 目标打印机名(取自 /printers 的 name;留空由客户端用系统默认) */
+ printer: string
+ /** 载荷格式 */
+ format: PrintPayloadFormat
+ /** 载荷编码 */
+ encoding: PrintPayloadEncoding
+ /** 文档内容:svg=XML 原文;pdf=base64(不含 data: 前缀) */
+ content: string
+ /** 文档总页数 */
+ pages: number
+ /** 打印份数 */
+ copies: number
+ /** 纸张方向 */
+ orientation: 'portrait' | 'landscape'
+ /** 是否双面 */
+ duplex: boolean
+ /** 是否彩色(false = 黑白) */
+ color: boolean
+}
+
+/** POST /print 响应体 */
+export interface PrintJobResponse {
+ ok: boolean
+ /** 客户端队列任务 ID */
+ jobId?: string
+ /** 服务端消息(失败原因等) */
+ message?: string
+}
+
+/* ------------------------------ 错误 ------------------------------ */
+
+/**
+ * 错误分类:
+ * - `timeout` 请求超时(客户端未响应)
+ * - `network` 连接失败(服务未启动 / 端口不通 / CORS 拦截)
+ * - `http` HTTP 非 2xx
+ * - `parse` 响应不是合法 JSON 或字段缺失
+ * - `service` 服务端返回 ok:false
+ */
+export type PrintClientErrorCode = 'timeout' | 'network' | 'http' | 'parse' | 'service'
+
+/** 打印客户端统一错误 */
+export class PrintClientError extends Error {
+ readonly code: PrintClientErrorCode
+ readonly status?: number
+
+ constructor(code: PrintClientErrorCode, message: string, status?: number) {
+ super(message)
+ this.name = 'PrintClientError'
+ this.code = code
+ this.status = status
+ }
+}
+
+/** 把任意异常转成人话(用于 UI 提示) */
+export function describePrintError(err: unknown): string {
+ if (err instanceof PrintClientError) {
+ switch (err.code) {
+ case 'timeout':
+ return '打印客户端响应超时,请确认服务未卡死'
+ case 'network':
+ return '无法连接打印客户端,请确认本机打印服务已启动'
+ case 'http':
+ return `打印客户端返回错误(HTTP ${err.status ?? '?'})`
+ case 'parse':
+ return '打印客户端返回数据格式异常'
+ case 'service':
+ return err.message || '打印客户端拒绝了本次请求'
+ }
+ }
+ return err instanceof Error ? err.message : String(err)
+}
diff --git a/src/design/composables/usePrinterProbe.ts b/src/design/composables/usePrinterProbe.ts
new file mode 100644
index 0000000..de62dcb
--- /dev/null
+++ b/src/design/composables/usePrinterProbe.ts
@@ -0,0 +1,109 @@
+/**
+ * 打印客户端探测状态 —— 模块级单例,顶栏状态灯与打印弹窗共用一份
+ *
+ * 为什么做成单例:
+ * - 顶栏打印按钮要常驻显示连接状态;打印弹窗打开时也要拿打印机列表。
+ * 两处各自请求会重复打扰本机服务,也会出现状态不同步(顶栏红点 / 弹窗已连接)。
+ * - 这里用模块级 ref 共享,一次探测两处同时更新;并发调用合并为同一个 inflight Promise。
+ */
+import { computed, ref } from 'vue'
+import {
+ checkHealth,
+ describePrintError,
+ listPrinters,
+ type PrinterHealth,
+ type PrinterInfo,
+} from '@/core/print-client'
+import { resolvePrinterBaseUrl } from '@/config/printer'
+
+/** idle=从未探测 / checking=探测中 / connected=已连接 / disconnected=不可达 */
+export type PrinterProbeState = 'idle' | 'checking' | 'connected' | 'disconnected'
+
+const state = ref('idle')
+const health = ref(null)
+const printers = ref([])
+const errorText = ref('')
+const baseUrl = ref(resolvePrinterBaseUrl())
+const checkedAt = ref(0)
+
+let inflight: Promise | null = null
+
+/** 在线且可用的打印机(离线的仍在列表里,只是不可选) */
+const onlinePrinters = computed(() => printers.value.filter((p) => p.isOnline))
+
+/** 默认打印机(服务端标记 isDefault,否则取第一台在线的) */
+const defaultPrinter = computed(
+ () => printers.value.find((p) => p.isDefault) ?? onlinePrinters.value[0] ?? printers.value[0] ?? null,
+)
+
+/** 一句话状态描述(顶栏 tooltip / 弹窗提示复用) */
+const summary = computed(() => {
+ switch (state.value) {
+ case 'idle':
+ return '打印客户端:未检测'
+ case 'checking':
+ return '正在检测打印客户端…'
+ case 'connected': {
+ const h = health.value
+ const v = h?.version ? ` v${h.version}` : ''
+ return `打印客户端已连接(${h?.app ?? 'OpenPrint'}${v} · ${printers.value.length} 台打印机)`
+ }
+ case 'disconnected':
+ return `打印客户端不可达:${errorText.value || '未知原因'}`
+ }
+})
+
+/**
+ * 执行一次探测:/health 判活 → /printers 取列表。
+ * 并发调用共享同一次请求;返回是否连接成功。
+ */
+async function probe(): Promise {
+ if (inflight) return inflight
+ state.value = 'checking'
+ errorText.value = ''
+ const base = resolvePrinterBaseUrl()
+ baseUrl.value = base
+
+ inflight = (async () => {
+ try {
+ health.value = await checkHealth(base)
+ printers.value = await listPrinters(base)
+ state.value = 'connected'
+ return true
+ } catch (e) {
+ health.value = null
+ printers.value = []
+ errorText.value = describePrintError(e)
+ state.value = 'disconnected'
+ return false
+ } finally {
+ checkedAt.value = Date.now()
+ inflight = null
+ }
+ })()
+
+ return inflight
+}
+
+/** 距上次探测超过 ttl(默认 15s)才重新探测,避免频繁打扰本机服务 */
+async function probeIfStale(ttlMs = 15000): Promise {
+ if (state.value === 'connected' && Date.now() - checkedAt.value < ttlMs) return true
+ if (state.value === 'checking' && inflight) return inflight
+ return probe()
+}
+
+export function usePrinterProbe() {
+ return {
+ state,
+ health,
+ printers,
+ onlinePrinters,
+ defaultPrinter,
+ errorText,
+ baseUrl,
+ checkedAt,
+ summary,
+ probe,
+ probeIfStale,
+ }
+}
diff --git a/src/design/modals/PrintDialog.vue b/src/design/modals/PrintDialog.vue
index ae72e0d..8b9a9bc 100644
--- a/src/design/modals/PrintDialog.vue
+++ b/src/design/modals/PrintDialog.vue
@@ -1,13 +1,18 @@