更新打印机连接

This commit is contained in:
海明
2026-08-13 22:14:07 +08:00
parent 71c2bc5d40
commit 588e90e760
22 changed files with 349 additions and 118 deletions
+44 -4
View File
@@ -15,6 +15,7 @@
<a href="https://vuejs.org/"><img src="https://img.shields.io/badge/Vue_3-4FC08D?style=flat-square&logo=vue.js&logoColor=white" alt="Vue 3" /></a>
<a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-3178C6?style=flat-square&logo=typescript&logoColor=white" alt="TypeScript" /></a>
<a href="https://github.com/fabricjs/fabric.js"><img src="https://img.shields.io/badge/Fabric.js-7.x-blue?style=flat-square" alt="Fabric.js" /></a>
<a href="https://www.npmjs.com/package/openprint26"><img src="https://img.shields.io/npm/v/openprint26?style=flat-square&logo=npm&logoColor=white" alt="npm" /></a>
</p>
<p align="center">
@@ -120,12 +121,38 @@ AI 输出: 自动创建模板 → 布局文本控件 → 添加条码 → 一
## 快速开始
### 环境要求
### npm 安装(推荐)
- Node.js >= 22.18.0
- pnpm >= 9.x
```bash
# npm
npm i openprint26
### 安装与运行
# pnpm
pnpm add openprint26
# yarn
yarn add openprint26
```
#### 在项目中使用
```ts
import { createApp } from 'vue'
import OpenPrint from 'openprint26'
import 'openprint26/dist/style.css'
const app = createApp(App)
app.use(OpenPrint)
app.mount('#app')
```
```vue
<template>
<OpenPrintDesigner />
</template>
```
### 从源码运行
```bash
# 克隆仓库
@@ -145,6 +172,11 @@ pnpm build
pnpm preview
```
### 环境要求
- Node.js >= 22.18.0
- pnpm >= 9.x
### 在线演示
访问 [https://openprint.yy360.space](https://openprint.yy360.space) 在线体验设计器。
@@ -356,6 +388,14 @@ Content-Type: application/json
## FAQ
### 如何安装 OpenPrint
```bash
npm i openprint26
```
详细用法见 [快速开始](#快速开始) 章节。
### OpenPrint 需要后端服务吗?
不需要。OpenPrint 是纯前端应用,零后端依赖。模板存储在浏览器本地(IndexedDB),AI 功能通过直接调用 LLM API 实现。如需对接打印机或 ERP,可部署可选的桌面客户端。
Vendored
-6
View File
@@ -5,12 +5,6 @@ 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 {
+108
View File
@@ -30,3 +30,111 @@ svg2pdf 的矢量渲染、中文 TTF 注入、旋转页回退逻辑**无法在
1. 图表在 PDF 中随缩放保持清晰(矢量);
2. 中文标签正常显示(非方块/空白);
3. 含旋转图表的页面正确回退为位图且不丢图。
---
# 导出/打印空白纸修复
## 问题
用户反馈:导出 PDF、推送本地打印都是空白纸。
## 根因
`src/core/renderer-html/css-generator.ts``tableCss()` 里有一段 CSS 注释包含未转义的 `<table>` 字符串:
```css
/* ---------- 表格样式预设(Excel 式快速切换;class 由渲染端挂在 <table> 上) ---------- */
```
导出/打印时,整页 CSS 被塞进 SVG 的 `<style>` 元素中。XML 解析器把注释里的 `<table>` 当成真实的 HTML 开标签,随后遇到 `</style>` 时抛出:
```
Opening and ending tag mismatch: table line 180 and style
```
整张 SVG 无法作为 `<img>` 加载,`rasterize.ts``svgToCanvas` 触发超时/错误兜底,返回**白底空 canvas**,最终 PDF / 打印推送都是白纸。
## 修复
将注释改为无尖括号的描述:
```css
/* ---------- 表格样式预设(Excel 式快速切换;class 由渲染端挂在 table 元素上) ---------- */
```
改动仅一行:`src/core/renderer-html/css-generator.ts`
## 验证
- 用真实 Chromium 无头复现修复前:`<img>` 加载 SVG `onerror`canvas/iframe 空白。
- 修复后:真实 Chromium 截图显示小票内容完整;`pageToImageBlob` JPEG blob 约 77KB`documentToPdf` 底层栅格化路径恢复。
- `vue-tsc --build`0 错误。
- `vitest run`253 passed31 文件)。
---
# 导出/打印「模糊」修复
## 问题
空白纸修好后,用户反馈:导出 PDF、本地打印都**模糊**,"之前的版本很清晰"。
## 根因
`src/core/export-engine/rasterize.ts``svgToCanvas` 走的是「**低分渲染 + drawImage 插值放大**」:
1. `buildPageSvg` 生成的 SVG 宽高是 96dpi 像素值(58mm 小票仅 **220×454 px**);
2. `svgToCanvas` 把这个低分 SVG 渲染成位图 img,再 `drawImage` **放大**到 `×scale` 的 canvas。
`drawImage` 放大只是**像素插值**,不产生任何新细节 —— 文字在 220px 宽上光栅化后被拉伸,边缘发虚。这就是模糊的根源(JPEG 有损进一步放大这种感觉)。
> 关键认知:两种做法的 canvas 尺寸完全相同(都是 440×908),区别只在**内容来源**(插值 vs 原生)。所以单看尺寸测不出来,必须真实浏览器看文字边缘。
## 修复
新增 `scaleSvgViewport()`**加载前**把 SVG 根节点的 `width/height` 直接 `×scale``viewBox` 不动)。这样 foreignObject 内容以高分辨率**原生渲染**成位图,img 本身就是高分图;canvas 尺寸 = img 尺寸、`drawImage(img, 0, 0)` 不再缩放插值。
改动:`src/core/export-engine/rasterize.ts``svgToCanvas` + 新增 `scaleSvgViewport`)。
## scale 决策(保持不变 = 2
对照 `scale=2`76KB / 192dpivs `scale=3`138KB / 288dpi):修复后两者文字都清晰、差异很小。故**保持 `scale=2` 默认**——清晰度足够且体积友好,不为提 scale 让体积反弹。需要更高清晰度时 `documentToPdf(result,{scale:3})` / 推送 `buildPrintPayload(req,{scale:3})` 可调。
## 验证
- 真实 Chromium 对照:修复前文字边缘锯齿毛糙,修复后平滑锐利。
- 真实导出 PDF`documentToPdf` scale=2 jpeg)落盘 **81301 B**`pdftoppm`**300 DPI** 渲染后,小票标题/地址/电话/明细/合计全部清晰锐利(见 `临时/pdf-300dpi渲染.png``临时/export-test-清晰版.pdf`)。
- `vue-tsc --build`0 错误;`vitest run`253 passed31 文件)。
---
# 导出/打印清晰度策略:PNG 无损 + 默认最高清 + 移除清晰度选择器
## 问题
模糊修复后,用户实测仍觉得不如之前清晰;复盘认为「底图从 PNG 切到 JPEG」是主因,要求:
- 换回 **PNG 无损底图**
- 导出/打印默认**最高清**,不要再让用户选清晰度。
## 决策
1. **底图默认从 JPEG 改回 PNG(无损)**
- `export-pdf.ts``export-engine/index.ts``print-client/payload.ts``rasterize.ts` 的默认 `imageType` 统一改为 `'png'`
- 思源宋体仍以 data-URI 内联;PNG 底图对中文/细线/条码边缘无 DCT 伪影。
2. **默认 scale 从 2192dpi)提到 3288dpi·最高清)**
- PDF 导出、打印推送、底层 `pageToImageBlob` 的默认 scale 全部提到 `3`
- 程序/headless 仍可传 `scale: 2/4` 覆盖;只是不再暴露 UI 选择。
3. **移除导出弹窗的「清晰度」选择器**
- `ExportDialog.vue` 删除 `scale` ref、`scaleOptions``NSelect` 行,以及 `onExport` 里的 `scale` 参数;用户不再面对清晰度选择。
## 文件改动
- `src/core/export-engine/export-pdf.ts`:默认 `scale=3``imageType='png'`;注释更新。
- `src/core/export-engine/index.ts`:默认 `scale=3``pdfImageType='png'`;注释更新。
- `src/core/export-engine/rasterize.ts``pageToImageBlob` 默认 `scale=3`
- `src/core/print-client/payload.ts`:默认 `scale=3``imageType='png'`
- `src/core/print-client/client.ts``src/design/modals/PrintDialog.vue`:注释同步为 PNG 无损。
- `src/design/toolbar/ExportDialog.vue`:移除「清晰度」选择器及 `NSelect` 导入。
## 验证
- `vue-tsc --build`**0 错误**
- `vitest run`**253 passed31 文件)**
- 真实 Chromium 导出:
- 收银小票(58mm)默认 PNG scale=3 导出 PDF**2.57 MB**
-`pdftoppm -r 300` 打印级渲染后文字/条码全部锐利(见 `临时/receipt-300dpi-png-scale3.png`)。
- 量化对比(同页同 scale=3):PNG 拉普拉斯高频能量 / JPEG ≈ **0.998**,说明清晰度的主要决定因素是前一轮的「高分原生渲染」修复;PNG 在此基础上彻底消除 JPEG 的 DCT 块伪影,与「最清晰」目标一致。
## 体积提示
- PNG 无损清晰度最高,但体积显著大于 JPEG:
- 收银小票 PNG scale=3 ≈ 2.6 MB
- 销售出库单/数据报表类 A4 页 PNG scale=3 可达 **20+ MB/页**
- 若本地打印客户端对 base64 载荷敏感,可在调用点显式传 `scale: 2``imageType: 'jpeg'`
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "open-print",
"name": "openprint26",
"version": "0.0.0",
"private": true,
"private": false,
"type": "module",
"scripts": {
"dev": "vite",
+5 -17
View File
@@ -5,19 +5,18 @@
*
* 覆盖优先级(高 → 低):
* 1. 用户在「设置 → 本地打印」里填的 IP/端口(localStorage `openprint:print-settings`
* 2. 构建期环境变量 `VITE_OPENPRINT_PRINTER_BASE`(项目根 .env
* 3. 出厂默认 http://127.0.0.1:18888
* 2. 出厂默认 http://127.0.0.1:18888
*
* 客户端已支持局域网打印:手填局域网地址(如 http://192.168.1.20:19000)即可直接推送,
* 手填地址优先级最高。
*
* 主任铁律:无后端全链路可用 —— 这里只读本地存储与构建期常量,绝不请求任何后端。
*/
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'
export type PrinterBaseSource = 'settings' | 'default'
/** 出厂默认,供 UI 占位/重置使用 */
export const FACTORY_PRINTER_BASE_URL = DEFAULT_PRINTER_BASE_URL
@@ -72,22 +71,11 @@ export function resolvePrinterBaseUrl(): string {
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
+36 -12
View File
@@ -1,6 +1,12 @@
/**
* PDF 导出 —— jsPDF 动态 import(不进主包)
*
* ## 位图底图默认 PNG(无损 · 最清晰)
* 早期为压体积改过 JPEG,但纯文字/线条在白底上即使 quality=1 也会被 8×8 DCT
* 引入极轻环状伪影,300dpi 打印放大后边缘发虚、不如 PNG 锐利。
* 现回归 PNG(无损):以体积换清晰度,导出/打印文字边缘真正锐利。
* 体积敏感场景可传 `imageType: 'jpeg'`,但默认走 PNG 保清晰。
*
* ## 图表走矢量(svg2pdf
* 设计器一切以 SVG 为真相源。为让图表在 PDF 里达到「印刷级矢量」:
* 1. 每页先**剥离 chart 控件** → 栅格化(文本/表格位图底图)→ addImage 作背景;
@@ -11,7 +17,7 @@
*
* 兜底:
* - 图表被旋转 → 整页退回旧的全栅格化(旋转在矢量叠加里不易对齐);
* - 单个图表矢量失败/字形展开失败 → 该图表单独栅格化后叠加(不丢图);
* - 单个图表矢量失败/字形展开失败 → 该图表单独栅格化后叠加(图表保持 PNG 以保边缘锐利);
* - 整页矢量叠加彻底失败 → 整页退回全栅格化(仍是位图,但保证有图)。
*
* 非图表页(无 chart 控件)仍走原全栅格化位图路径,行为与旧版一致。
@@ -30,10 +36,24 @@ const PDF_FONTS: FontFaceDef[] = [
]
export interface PdfOptions {
/** 高清倍率,1=96dpi / 2=192dpi(推荐)/ 3=288dpi默认 2 */
/** 高清倍率,1=96dpi / 2=192dpi / 3=288dpi默认·最高清),可传 4 更锐 */
scale?: number
/** 页面装饰(背景色 + 水印),使 PDF 与预览一致 */
pageDecoration?: PageDecoration
/**
* 位图底图的压缩格式(默认 `'png'`,无损最清晰):
* - `'png'`:无损,文字/线条边缘真正锐利,打印推荐;体积较大(纯文字页 ~10+ MB)。
* - `'jpeg'`:体积小(~1-2 MB),仅体积敏感场景使用,清晰度略逊于 PNG。
* 图表矢量叠加路径不受影响(图表本身走 svg2pdf 矢量注入)。
*/
imageType?: 'jpeg' | 'png'
}
/** 把 PdfOptions 的 imageType 解析成 canvas.toBlob 的 MIME 与 addImage 的格式名 */
function imgSpec(imageType: 'jpeg' | 'png'): { mime: 'image/jpeg' | 'image/png'; addImageFmt: 'JPEG' | 'PNG' } {
return imageType === 'png'
? { mime: 'image/png', addImageFmt: 'PNG' }
: { mime: 'image/jpeg', addImageFmt: 'JPEG' }
}
function isChartNode(n: PlacedNode): n is PlacedControl {
@@ -98,7 +118,9 @@ export async function documentToPdf(result: LayoutResult, opts: PdfOptions = {})
const JsPDF = (mod as unknown as { jsPDF?: typeof mod.default; default: typeof mod.default }).jsPDF ?? mod.default
const { pageWidth: w, pageHeight: h } = result.metrics
const orientation = w > h ? 'landscape' : 'portrait'
const scale = opts.scale ?? 2
const scale = opts.scale ?? 3
const imageType: 'jpeg' | 'png' = opts.imageType ?? 'png'
const { addImageFmt } = imgSpec(imageType)
const doc = new JsPDF({ unit: 'mm', format: [w, h], orientation })
@@ -111,8 +133,8 @@ export async function documentToPdf(result: LayoutResult, opts: PdfOptions = {})
const useVector = charts.length > 0 && charts.every((c) => c.angle === 0)
if (!useVector) {
const dataUrl = await rasterizePageFull(result, i, scale, opts.pageDecoration)
doc.addImage(dataUrl, 'PNG', 0, 0, w, h)
const dataUrl = await rasterizePageFull(result, i, scale, opts.pageDecoration, imageType)
doc.addImage(dataUrl, addImageFmt, 0, 0, w, h)
continue
}
@@ -121,8 +143,8 @@ export async function documentToPdf(result: LayoutResult, opts: PdfOptions = {})
...result,
pages: result.pages.map((p, idx) => (idx === i ? stripCharts(p) : p)),
}
const baseUrl = await rasterizePageFull(strippedResult, i, scale, opts.pageDecoration)
doc.addImage(baseUrl, 'PNG', 0, 0, w, h)
const baseUrl = await rasterizePageFull(strippedResult, i, scale, opts.pageDecoration, imageType)
doc.addImage(baseUrl, addImageFmt, 0, 0, w, h)
let allVectorOk = true
for (const ch of charts) {
@@ -141,7 +163,7 @@ export async function documentToPdf(result: LayoutResult, opts: PdfOptions = {})
document.body.removeChild(el)
}
} catch {
// 该图表矢量失败 → 单独栅格化兜底
// 该图表矢量失败 → 单独栅格化兜底(图表保持 PNG 以保边缘锐利,体积可控)
try {
const img = await rasterizeChart(ch.svg, ch.width, ch.height, scale)
doc.addImage(img, 'PNG', ch.left, ch.top, ch.width, ch.height)
@@ -158,8 +180,8 @@ export async function documentToPdf(result: LayoutResult, opts: PdfOptions = {})
const pageCount = doc.getNumberOfPages()
doc.deletePage(pageCount)
doc.addPage([w, h], orientation)
const fallback = await rasterizePageFull(result, i, scale, opts.pageDecoration)
doc.addImage(fallback, 'PNG', 0, 0, w, h)
const fallback = await rasterizePageFull(result, i, scale, opts.pageDecoration, imageType)
doc.addImage(fallback, addImageFmt, 0, 0, w, h)
}
}
@@ -171,10 +193,12 @@ async function rasterizePageFull(
result: LayoutResult,
index: number,
scale: number,
pageDecoration?: PageDecoration,
pageDecoration: PageDecoration | undefined,
imageType: 'jpeg' | 'png',
): Promise<string> {
const { mime } = imgSpec(imageType)
const blob = await pageToImageBlob(result, index, {
type: 'image/png',
type: mime,
scale,
background: pageDecoration?.backgroundColor ?? '#ffffff',
fonts: PDF_FONTS,
+13 -3
View File
@@ -25,7 +25,7 @@ export type { FontFaceDef } from './fonts'
export type ExportFormat = 'pdf' | 'jpg' | 'svg'
export interface ExportOptions {
/** 高清倍率 1/2/3默认 2 */
/** 高清倍率 1/2/3默认 3·288dpi·最高清),可传 4 更锐 */
scale?: number
/** 文件名(不含扩展名);多页 JPG 会追加 -页码 */
filename?: string
@@ -33,6 +33,12 @@ export interface ExportOptions {
background?: string
/** 自定义字体(同源 URL),嵌入 JPG/SVG 栅格化产物使导出不丢字体 */
fonts?: FontFaceDef[]
/**
* PDF 位图底图压缩格式(仅 PDF 导出生效,默认 `'png'` 无损最清晰):
* - `'png'`:无损,文字/线条边缘锐利,打印推荐;
* - `'jpeg'`:体积小,仅体积敏感场景。
*/
pdfImageType?: 'jpeg' | 'png'
}
export interface ExportOutcome {
@@ -53,13 +59,17 @@ export async function exportDocument(
const res = await render(request)
const result: LayoutResult = res.result
const name = (options.filename ?? 'openprint-document').trim() || 'openprint-document'
const scale = options.scale ?? 2
const scale = options.scale ?? 3
const bg = options.background ?? '#ffffff'
// 页面装饰(背景色 + 水印)统一来自渲染请求 output,三格式导出共享
const deco = request.output?.pageDecoration
if (format === 'pdf') {
const blob = await documentToPdf(result, { scale, pageDecoration: deco })
const blob = await documentToPdf(result, {
scale,
imageType: options.pdfImageType ?? 'png',
pageDecoration: deco,
})
return { blobs: [blob], filenames: [`${name}.pdf`], mime: 'application/pdf' }
}
+33 -8
View File
@@ -108,10 +108,27 @@ function loadImage(src: string): Promise<HTMLImageElement> {
* "Tainted canvases may not be exported"。同一 SVG 改用 `data:` URL 加载则不污染。
* (实测对照:blob+FO=TAINTEDblob+纯矢量=cleandata+FO=cleandata+纯矢量=clean
*/
/** 把 SVG 根节点的显示宽高放大 scale 倍(viewBox 不变),让 foreignObject 以高分原生渲染,
* 而不是低分渲染后再插值放大 —— 这是打印/导出文字清晰的关键。
* 血泪:低分 SVG 渲染成位图后再 drawImage 放大 = 只是像素插值,文字发虚;
* 放大 SVG 的 width/heightviewBox 不动)则内容矢量重排到高分画布,边缘锐利。 */
function scaleSvgViewport(svg: string, scale: number): string {
if (!Number.isFinite(scale) || scale === 1) return svg
return svg.replace(
/<svg([^>]*?)\bwidth="([\d.]+)"([^>]*?)\bheight="([\d.]+)"/,
(m, p1: string, w: string, p2: string, h: string) =>
`<svg${p1}width="${Math.round(parseFloat(w) * scale)}"${p2}height="${Math.round(parseFloat(h) * scale)}"`,
)
}
export async function svgToCanvas(svg: string, scale: number, bg: string): Promise<HTMLCanvasElement> {
const url = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg)
const cw = Math.max(1, Math.round(794 * scale))
const ch = Math.max(1, Math.round(1123 * scale))
// 先放大 SVG 显示尺寸,再加载:img 本身就是高分位图,canvas 无需再插值放大
const scaled = scaleSvgViewport(svg, scale)
const url = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(scaled)
// fallback 尺寸从放大后的 SVG 宽高推导(取不到再用 A4×scale 兜底)
const dim = scaled.match(/<svg[^>]*\bwidth="([\d.]+)"[^>]*\bheight="([\d.]+)"/)
const cw = Math.max(1, Math.round(dim ? parseFloat(dim[1]!) : 794 * scale))
const ch = Math.max(1, Math.round(dim ? parseFloat(dim[2]!) : 1123 * scale))
const fallback = (): HTMLCanvasElement => {
const canvas = document.createElement('canvas')
canvas.width = cw
@@ -126,13 +143,14 @@ export async function svgToCanvas(svg: string, scale: number, bg: string): Promi
try {
const img = await loadImage(url)
const canvas = document.createElement('canvas')
canvas.width = Math.max(1, Math.round(img.width * scale))
canvas.height = Math.max(1, Math.round(img.height * scale))
// img.width/height 已是高分目标尺寸,drawImage 原样铺绘,不再缩放插值
canvas.width = Math.max(1, img.width)
canvas.height = Math.max(1, img.height)
const ctx = canvas.getContext('2d')
if (!ctx) return fallback()
ctx.fillStyle = bg
ctx.fillRect(0, 0, canvas.width, canvas.height)
ctx.drawImage(img, 0, 0, canvas.width, canvas.height)
ctx.drawImage(img, 0, 0)
return canvas
} catch {
// 环境不支持 foreignObject 光栅化(如 happy-dom)→ 降级白底空 canvas,不阻塞导出
@@ -144,6 +162,12 @@ export interface PageImageOptions {
scale?: number
background?: string
type?: 'image/png' | 'image/jpeg'
/**
* JPEG 编码质量 0-1,默认 **1(最高清)**。
* 注:quality=1 比 0.92 体积大约 30-60%,但仍是 PNG 的零头;
* 对票据/报表这类对清晰度敏感的打印件值得。
*/
quality?: number
/** 自定义字体(同源 URL),嵌入 SVG 使栅格化产物不丢字体 */
fonts?: FontFaceDef[]
/** 页面装饰(背景色 + 水印),使导出与预览一致 */
@@ -243,13 +267,14 @@ export async function pageToImageBlob(
const pageHtml = await inlinePageImages(renderPage(page, deco?.watermark))
let svg = buildPageSvg(pageHtml, css, result.metrics.pageWidth, result.metrics.pageHeight)
if (opts.fonts?.length) svg = await embedFontsInSvg(svg, opts.fonts)
const canvas = await svgToCanvas(svg, opts.scale ?? 2, opts.background ?? '#ffffff')
const canvas = await svgToCanvas(svg, opts.scale ?? 3, opts.background ?? '#ffffff')
const type = opts.type ?? 'image/png'
const quality = type === 'image/jpeg' ? opts.quality ?? 1 : undefined
return new Promise((resolve, reject) => {
canvas.toBlob(
(b) => (b ? resolve(b) : reject(new Error('canvas.toBlob 失败'))),
type,
type === 'image/jpeg' ? 0.92 : undefined,
quality,
)
})
}
+21 -3
View File
@@ -139,13 +139,31 @@ export async function listPrinters(
return data.printers.map((p, i) => normalizePrinter(p ?? {}, i))
}
/* ------------------------------ 任务号生成 ------------------------------ */
/**
* 生成打印任务号(Web 端生成,随请求下发给本地客户端)。
* 格式:`MMDD`(今日月日,如 0813)+ 6 位随机数字,共 10 位。
* 例:`0813` + `482913` → `0813482913`。
* @param now 可选,默认当前时间(便于单测注入)
*/
export function generateJobId(now: Date = new Date()): string {
const mm = String(now.getMonth() + 1).padStart(2, '0')
const dd = String(now.getDate()).padStart(2, '0')
const rand = String(Math.floor(Math.random() * 1_000_000)).padStart(6, '0')
return `${mm}${dd}${rand}`
}
/* ------------------------------ POST /print ------------------------------ */
/**
* 提交打印任务。
* 载荷约定(见《打印机数据交互文档》):
* - 单页 → `format:'svg'``encoding:'utf8'`content 为 SVG 原文
* - 多页 → `format:'pdf'``encoding:'base64'`content 为 base64(无 data: 前缀)
* - **统一** `format:'pdf'``encoding:'base64'`content 为 PDF base64(无 data: 前缀)。
* PDF 内部位图底图默认 PNG(无损·最高清),文字/线条边缘真正锐利,打印推荐。
* 本地客户端用 `QPrinter` 打印位图 PDF。
* - `'svg'` 已废弃(Qt QSvgRenderer 不支持 foreignObject/HTML/CSS),类型保留仅作向后兼容。
* - `job.jobId` 由 Web 端生成(generateJobId);服务端若回传自己的 jobId 则以服务端为准。
*/
export async function submitPrintJob(
job: PrintJobRequest,
@@ -164,7 +182,7 @@ export async function submitPrintJob(
if (data.ok !== true) {
throw new PrintClientError('service', data.message || '打印客户端拒绝了本次任务')
}
return { ok: true, jobId: data.jobId, message: data.message }
return { ok: true, jobId: data.jobId ?? job.jobId, message: data.message }
}
/* ------------------------------ 工具 ------------------------------ */
+1
View File
@@ -18,6 +18,7 @@ export {
checkHealth,
listPrinters,
submitPrintJob,
generateJobId,
blobToBase64,
blobToText,
} from './client'
+39 -27
View File
@@ -1,27 +1,28 @@
/**
* 打印载荷构建 —— 模板 + 数据 → 推送给本地打印客户端的文档内容
*
* 推送格式规则(主任定,2026-08-12):
* - **单页** → SVG(矢量原文,utf8)。体积小、矢量无损,客户端可直接送打印或转 EMF
* - **多页** → PDFbase64)。PDF 天然多页,base64 后可走 JSON 传输。
* 推送格式规则(2026-08-13 最终定案):
* - **统一推 PDF(base64)**,不论单页或多页
*
* 只 render 一次:先 `renderDocument()` 拿到 LayoutResult 判定页数,
* 再按页数走对应导出器,避免 `exportDocument()` 内部二次分页
* 演进历程:
* - 早期单页走 SVG 原文 → Qt `QSvgRenderer` 不解析 foreignObject/HTML/CSS,打不出内容(已废弃)
* - 中间试过单页走 JPG → 但 JPG 是单张位图、没有页面尺寸/DPI 元数据,不如 PDF 适合打印;
* 且用户希望打印格式统一便于客户端处理。最终回到统一 PDF。
*
* 清晰度优先:PDF 内部位图底图默认 **PNG(无损·最高清)**,文字/线条边缘真正锐利,
* 打印推荐。体积较大(纯文字页 ~10+ MB);体积敏感场景可传 `imageType: 'jpeg'`。
*/
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 { documentToPdf } from '@/core/export-engine'
import { blobToBase64 } from './client'
import type { PrintPayloadEncoding, PrintPayloadFormat } from './types'
export interface PrintPayload {
/** 载荷格式:单页 svg / 多页 pdf */
format: PrintPayloadFormat
/** 载荷编码:svg=utf8 / pdf=base64 */
encoding: PrintPayloadEncoding
/** 文档内容 */
/** 载荷格式:恒为 pdf */
format: 'pdf'
/** 载荷编码:恒为 base64 */
encoding: 'base64'
/** PDF 的 base64 字符串(不含 data: 前缀) */
content: string
/** 总页数 */
pages: number
@@ -30,16 +31,23 @@ export interface PrintPayload {
}
export interface BuildPrintPayloadOptions {
/** PDF 位图底图倍率,默认 2(192dpi) */
/** 位图倍率,默认 3(288dpi·最高清);要更锐可传 4 */
scale?: number
/**
* 需要内联进 SVG 的字体。不传则自动从模板里提取用到的内置字体,
* 保证客户端渲染 SVG 时中文不掉字(SVG 是隔离上下文,看不到宿主 @font-face)。
* 多页 PDF 的位图底图压缩格式(默认 `'png'`,无损最清晰):
* - `'png'`:无损,文字/线条边缘锐利,打印推荐;
* - `'jpeg'`:体积小,仅体积敏感场景。
*/
fonts?: FontFaceDef[]
imageType?: 'jpeg' | 'png'
}
/** 构建推送载荷(单页 SVG / 多页 PDF-base64 */
/**
* 构建推送载荷(统一 PDF-base64)。
*
* 不论单页或多页,都先 `renderDocument()` → `documentToPdf()`(浏览器内用 Chromium
* 栅格化 foreignObject 内容成位图底图,默认 PNG 无损最高清)→ `blobToBase64()`。
* 本地客户端拿到的就是可直接 `QPrinter` 打印的 PDF 字节流。
*/
export async function buildPrintPayload(
request: RenderRequest,
options: BuildPrintPayloadOptions = {},
@@ -48,15 +56,19 @@ export async function buildPrintPayload(
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 blob = await documentToPdf(result, {
scale: options.scale ?? 3,
imageType: options.imageType ?? 'png',
pageDecoration: deco,
})
const base64 = await blobToBase64(blob)
return { format: 'pdf', encoding: 'base64', content: base64, pages, bytes: base64.length }
return {
format: 'pdf',
encoding: 'base64',
content: base64,
pages: Math.max(pages, 1),
bytes: base64.length,
}
}
/** 人类可读体积(UI 提示用) */
@@ -0,0 +1,25 @@
import { describe, it, expect } from 'vitest'
import { generateJobId } from './client'
describe('generateJobId', () => {
it('格式为 MMDD + 6 位随机,共 10 位数字', () => {
const id = generateJobId(new Date('2026-08-13T10:00:00'))
expect(id).toHaveLength(10)
expect(id).toMatch(/^\d{10}$/)
// 今日 08-13 → 前缀 0813
expect(id.startsWith('0813')).toBe(true)
})
it('随机尾段为 6 位(不足前补 0)', () => {
const id = generateJobId(new Date('2026-01-05T00:00:00'))
expect(id.startsWith('0105')).toBe(true)
expect(id.slice(4)).toMatch(/^\d{6}$/)
})
it('多次调用尾段大概率不同', () => {
const seen = new Set<string>()
for (let i = 0; i < 50; i++) seen.add(generateJobId().slice(4))
// 50 次抽样几乎必然出现碰撞以外的情况;极端碰撞属概率事件,放宽到 >=49 唯一
expect(seen.size).toBeGreaterThanOrEqual(49)
})
})
+10 -3
View File
@@ -69,14 +69,21 @@ export interface PrinterListResponse {
/* ------------------------------ /print ------------------------------ */
/** 推送载荷格式:单页 SVG(矢量文本),多页 PDF(base64) */
/**
* 推送载荷格式。
* **2026-08-13 最终定案**:统一推 `'pdf'`base64)。
* - 单页/多页都走浏览器栅格化的位图 PDF,本地客户端 `QPrinter` 打印无兼容性问题。
* - `'svg'` 保留在枚举里仅供向后兼容(已废弃:Qt QSvgRenderer 不解析 foreignObject/HTML)。
*/
export type PrintPayloadFormat = 'svg' | 'pdf'
/** 载荷编码:svg 用 utf8 原文,pdf 用 base64 */
/** 载荷编码:jpg/pdf 用 base64utf8 仅供向后兼容(对应已废弃的 SVG 原文路径) */
export type PrintPayloadEncoding = 'utf8' | 'base64'
/** POST /print 请求体 */
export interface PrintJobRequest {
/** 任务号:由 Web 端生成(MMDD + 6 位随机,共 10 位);服务端若回传则以其为准,否则沿用此值 */
jobId?: string
/** 任务名(用于打印队列显示) */
taskName: string
/** 目标打印机名(取自 /printers 的 name;留空由客户端用系统默认) */
@@ -85,7 +92,7 @@ export interface PrintJobRequest {
format: PrintPayloadFormat
/** 载荷编码 */
encoding: PrintPayloadEncoding
/** 文档内容:svg=XML 原文;pdf=base64(不含 data: 前缀) */
/** 文档内容:pdf=PDF base64(不含 data: 前缀)svg=XML 原文(已废弃) */
content: string
/** 文档总页数 */
pages: number
+1 -1
View File
@@ -151,7 +151,7 @@ ${s}.op-table tr.is-striped td { background: ${TABLE_STRIPE_BG}; }
单元格视觉以内联样式为准(与设计期 overlay 同一套 resolveCellStyleFor 取值) */
${s}.op-table tr.is-static td { /* 占位:保持类名与设计期对齐,避免样式遗漏 */ }
/* ---------- 表格样式预设(Excel 式快速切换;class 由渲染端挂在 <table> 上) ---------- */
/* ---------- 表格样式预设(Excel 式快速切换;class 由渲染端挂在 table 元素上) ---------- */
/* 默认(none):仅表头加粗,无任何背景色(含标题行)。无需规则,class 仅作占位。 */
/* 表头高亮:表头浅灰底 */
+1
View File
@@ -472,6 +472,7 @@ watch(
.ai-header-title {
font-size: 15px;
font-weight: 600;
margin-bottom: 5px;
color: var(--brand-text-1, #1f2329);
}
.ai-header-sub {
+7 -2
View File
@@ -7,7 +7,10 @@
* GET /printers 打印机列表(能力/状态/纸盒)
* POST /print 推送任务
*
* 推送格式(主任定):**单页 → SVG 原文;多页 → PDFbase64**。
* 推送格式(2026-08-13 最终定案):**统一推 PDFbase64**。
* PDF 内部位图底图默认 PNG(无损·最高清),文字/线条边缘真正锐利,打印推荐。
* 本地客户端用 `QPrinter` 打印位图 PDF。
* 演进:SVGQt 不支持 foreignObject,废弃)→ 统一 PDF(最终)。
* 载荷由 `buildPrintPayload()` 统一构建,与预览/导出共用同一条 render 链路,保证三者一致。
*
* 主任铁律:无后端全链路可用 —— 客户端不可达时只是不能出纸,设计器功能不受影响。
@@ -34,6 +37,7 @@ import {
buildPrintPayload,
describePrintError,
formatPayloadSize,
generateJobId,
submitPrintJob,
type PrinterInfo,
} from '@/core/print-client'
@@ -300,6 +304,7 @@ async function doPrint(): Promise<void> {
printingHint.value = `正在推送 ${payload.format.toUpperCase()}${payload.pages} 页 · ${formatPayloadSize(payload.bytes)})…`
const res = await submitPrintJob(
{
jobId: generateJobId(),
taskName: taskName.value,
printer: selectedPrinter.value,
format: payload.format,
@@ -498,7 +503,7 @@ function close(): void {
<!-- 推送格式说明 -->
<div class="print-format-note">
<div class="i-carbon-information text-13px" />
<span>推送规则单页文档以 <b>SVG</b> 矢量原文推送多页文档转 <b>PDFbase64</b> 推送</span>
<span>推送规则所有文档统一转 <b>PDFbase64</b> 推送本地客户端用 QPrinter 直接打印</span>
</div>
<template #footer>
+2 -7
View File
@@ -23,9 +23,7 @@ import {
} from '@/config/print-settings'
import {
buildPrinterBase,
envPrinterBase,
FACTORY_PRINTER_BASE_URL,
hasEnvPrinterBase,
} from '@/config/printer'
import { checkHealth, listPrinters, describePrintError } from '@/core/print-client'
import {
@@ -279,11 +277,8 @@ function copyToClipboard(value: string, label: string): void {
打印机探测/health/printers与任务推送/print都走这个地址
</NText>
<NText depth="3" class="block text-12px mt-1">
出厂默认 {{ FACTORY_PRINTER_BASE_URL }}这里填的地址优先级最高会覆盖构建期环境变量
<code>VITE_OPENPRINT_PRINTER_BASE</code>
<template v-if="hasEnvPrinterBase">
当前环境变量为 <b>{{ envPrinterBase }}</b>仅在此处留空时生效
</template>
出厂默认 {{ FACTORY_PRINTER_BASE_URL }}可手动改为局域网地址客户端已支持局域网打印
手动填写的地址优先级最高
</NText>
</div>
-3
View File
@@ -13,9 +13,6 @@ const ui = useUiStore()
<template>
<div class="flex h-full flex-col bg-brand-surface">
<div class="flex items-center justify-between border-b border-brand-border px-3 py-2">
<span class="text-13px font-medium">组件库</span>
</div>
<NTabs v-model:value="ui.leftTab" type="segment" size="small" class="flex-1 px-2" animated>
<NTabPane name="components" tab="组件">
<ControlLibrary />
+1 -20
View File
@@ -18,7 +18,6 @@ import {
NModal,
NRadio,
NRadioGroup,
NSelect,
useMessage,
} from 'naive-ui'
import { exportDocument, downloadBlob, type ExportFormat } from '@/core/export-engine'
@@ -34,7 +33,6 @@ const dsStore = useDataSourceStore()
const message = useMessage()
const format = ref<ExportFormat>('pdf')
const scale = ref(2)
const rowCount = ref(30)
const filename = ref('销售出库单')
const exporting = ref(false)
@@ -44,11 +42,6 @@ const formatOptions = [
{ label: 'JPG(每页一张)', value: 'jpg' },
{ label: 'SVG(矢量 · 单文件多页)', value: 'svg' },
]
const scaleOptions = [
{ label: '1x96dpi', value: 1 },
{ label: '2x192dpi · 推荐)', value: 2 },
{ label: '3x288dpi · 最高清)', value: 3 },
]
function close(): void {
emit('update:show', false)
@@ -72,7 +65,7 @@ async function onExport(): Promise<void> {
},
},
format.value,
{ scale: scale.value, filename: filename.value.trim() || 'openprint-document' },
{ filename: filename.value.trim() || 'openprint-document' },
)
res.blobs.forEach((b, i) => downloadBlob(b, res.filenames[i]!))
message.success(`已导出 ${res.blobs.length} 个文件(${res.filenames[0]} 等)`)
@@ -111,18 +104,6 @@ async function onExport(): Promise<void> {
</NRadioGroup>
</div>
<!-- 清晰度 -->
<div class="row">
<span class="row-label">清晰度</span>
<NSelect
v-model:value="scale"
:options="scaleOptions"
size="small"
style="width: 200px"
:consistent-menu-width="false"
/>
</div>
<!-- 明细行数验证多页导出 -->
<div class="row">
<span class="row-label">明细行数</span>
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB