diff --git a/README.md b/README.md index c72a11b..038352d 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,7 @@

- OpenPrint Logo + OpenPrint Logo

-

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 @@