更新打印机连接
This commit is contained in:
@@ -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://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://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://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>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -120,12 +121,38 @@ AI 输出: 自动创建模板 → 布局文本控件 → 添加条码 → 一
|
|||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
### 环境要求
|
### npm 安装(推荐)
|
||||||
|
|
||||||
- Node.js >= 22.18.0
|
```bash
|
||||||
- pnpm >= 9.x
|
# 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
|
```bash
|
||||||
# 克隆仓库
|
# 克隆仓库
|
||||||
@@ -145,6 +172,11 @@ pnpm build
|
|||||||
pnpm preview
|
pnpm preview
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 环境要求
|
||||||
|
|
||||||
|
- Node.js >= 22.18.0
|
||||||
|
- pnpm >= 9.x
|
||||||
|
|
||||||
### 在线演示
|
### 在线演示
|
||||||
|
|
||||||
访问 [https://openprint.yy360.space](https://openprint.yy360.space) 在线体验设计器。
|
访问 [https://openprint.yy360.space](https://openprint.yy360.space) 在线体验设计器。
|
||||||
@@ -356,6 +388,14 @@ Content-Type: application/json
|
|||||||
|
|
||||||
## FAQ
|
## FAQ
|
||||||
|
|
||||||
|
### 如何安装 OpenPrint?
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm i openprint26
|
||||||
|
```
|
||||||
|
|
||||||
|
详细用法见 [快速开始](#快速开始) 章节。
|
||||||
|
|
||||||
### OpenPrint 需要后端服务吗?
|
### OpenPrint 需要后端服务吗?
|
||||||
|
|
||||||
不需要。OpenPrint 是纯前端应用,零后端依赖。模板存储在浏览器本地(IndexedDB),AI 功能通过直接调用 LLM API 实现。如需对接打印机或 ERP,可部署可选的桌面客户端。
|
不需要。OpenPrint 是纯前端应用,零后端依赖。模板存储在浏览器本地(IndexedDB),AI 功能通过直接调用 LLM API 实现。如需对接打印机或 ERP,可部署可选的桌面客户端。
|
||||||
|
|||||||
@@ -5,12 +5,6 @@ interface ImportMetaEnv {
|
|||||||
readonly VITE_OPENPRINT_API_BASE?: string
|
readonly VITE_OPENPRINT_API_BASE?: string
|
||||||
/** 可选:Bearer 鉴权 token */
|
/** 可选:Bearer 鉴权 token */
|
||||||
readonly VITE_OPENPRINT_API_TOKEN?: string
|
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 {
|
interface ImportMeta {
|
||||||
|
|||||||
+108
@@ -30,3 +30,111 @@ svg2pdf 的矢量渲染、中文 TTF 注入、旋转页回退逻辑**无法在
|
|||||||
1. 图表在 PDF 中随缩放保持清晰(矢量);
|
1. 图表在 PDF 中随缩放保持清晰(矢量);
|
||||||
2. 中文标签正常显示(非方块/空白);
|
2. 中文标签正常显示(非方块/空白);
|
||||||
3. 含旋转图表的页面正确回退为位图且不丢图。
|
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 passed(31 文件)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 导出/打印「模糊」修复
|
||||||
|
|
||||||
|
## 问题
|
||||||
|
空白纸修好后,用户反馈:导出 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 / 192dpi)vs `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 passed(31 文件)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 导出/打印清晰度策略: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 从 2(192dpi)提到 3(288dpi·最高清)**
|
||||||
|
- 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 passed(31 文件)**
|
||||||
|
- 真实 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
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "open-print",
|
"name": "openprint26",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"private": true,
|
"private": false,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
+5
-17
@@ -5,19 +5,18 @@
|
|||||||
*
|
*
|
||||||
* 覆盖优先级(高 → 低):
|
* 覆盖优先级(高 → 低):
|
||||||
* 1. 用户在「设置 → 本地打印」里填的 IP/端口(localStorage `openprint:print-settings`)
|
* 1. 用户在「设置 → 本地打印」里填的 IP/端口(localStorage `openprint:print-settings`)
|
||||||
* 2. 构建期环境变量 `VITE_OPENPRINT_PRINTER_BASE`(项目根 .env)
|
* 2. 出厂默认 http://127.0.0.1:18888
|
||||||
* 3. 出厂默认 http://127.0.0.1:18888
|
*
|
||||||
|
* 客户端已支持局域网打印:手填局域网地址(如 http://192.168.1.20:19000)即可直接推送,
|
||||||
|
* 手填地址优先级最高。
|
||||||
*
|
*
|
||||||
* 主任铁律:无后端全链路可用 —— 这里只读本地存储与构建期常量,绝不请求任何后端。
|
* 主任铁律:无后端全链路可用 —— 这里只读本地存储与构建期常量,绝不请求任何后端。
|
||||||
*/
|
*/
|
||||||
import { DEFAULT_PRINTER_BASE_URL } from '@/core/print-client'
|
import { DEFAULT_PRINTER_BASE_URL } from '@/core/print-client'
|
||||||
import { PRINT_SETTINGS_KEY } from './print-settings'
|
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 用于提示用户当前生效的是哪一层配置 */
|
/** 地址来源,UI 用于提示用户当前生效的是哪一层配置 */
|
||||||
export type PrinterBaseSource = 'settings' | 'env' | 'default'
|
export type PrinterBaseSource = 'settings' | 'default'
|
||||||
|
|
||||||
/** 出厂默认,供 UI 占位/重置使用 */
|
/** 出厂默认,供 UI 占位/重置使用 */
|
||||||
export const FACTORY_PRINTER_BASE_URL = DEFAULT_PRINTER_BASE_URL
|
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)
|
const base = buildPrinterBase(stored.host, stored.port)
|
||||||
if (base) return base
|
if (base) return base
|
||||||
}
|
}
|
||||||
if (ENV_PRINTER_BASE) {
|
|
||||||
const base = normalizePrinterBase(ENV_PRINTER_BASE)
|
|
||||||
if (base) return base
|
|
||||||
}
|
|
||||||
return FACTORY_PRINTER_BASE_URL
|
return FACTORY_PRINTER_BASE_URL
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 当前地址来自哪一层配置(UI 提示用) */
|
/** 当前地址来自哪一层配置(UI 提示用) */
|
||||||
export function resolvePrinterBaseSource(): PrinterBaseSource {
|
export function resolvePrinterBaseSource(): PrinterBaseSource {
|
||||||
if (readStoredPrinterEndpoint()) return 'settings'
|
if (readStoredPrinterEndpoint()) return 'settings'
|
||||||
if (ENV_PRINTER_BASE) return 'env'
|
|
||||||
return 'default'
|
return 'default'
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 是否存在构建期环境变量覆盖(设置面板里给出说明) */
|
|
||||||
export const hasEnvPrinterBase = ENV_PRINTER_BASE.length > 0
|
|
||||||
|
|
||||||
/** 环境变量原值(仅用于设置面板展示) */
|
|
||||||
export const envPrinterBase = ENV_PRINTER_BASE
|
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* PDF 导出 —— jsPDF 动态 import(不进主包)
|
* PDF 导出 —— jsPDF 动态 import(不进主包)
|
||||||
*
|
*
|
||||||
|
* ## 位图底图默认 PNG(无损 · 最清晰)
|
||||||
|
* 早期为压体积改过 JPEG,但纯文字/线条在白底上即使 quality=1 也会被 8×8 DCT
|
||||||
|
* 引入极轻环状伪影,300dpi 打印放大后边缘发虚、不如 PNG 锐利。
|
||||||
|
* 现回归 PNG(无损):以体积换清晰度,导出/打印文字边缘真正锐利。
|
||||||
|
* 体积敏感场景可传 `imageType: 'jpeg'`,但默认走 PNG 保清晰。
|
||||||
|
*
|
||||||
* ## 图表走矢量(svg2pdf)
|
* ## 图表走矢量(svg2pdf)
|
||||||
* 设计器一切以 SVG 为真相源。为让图表在 PDF 里达到「印刷级矢量」:
|
* 设计器一切以 SVG 为真相源。为让图表在 PDF 里达到「印刷级矢量」:
|
||||||
* 1. 每页先**剥离 chart 控件** → 栅格化(文本/表格位图底图)→ addImage 作背景;
|
* 1. 每页先**剥离 chart 控件** → 栅格化(文本/表格位图底图)→ addImage 作背景;
|
||||||
@@ -11,7 +17,7 @@
|
|||||||
*
|
*
|
||||||
* 兜底:
|
* 兜底:
|
||||||
* - 图表被旋转 → 整页退回旧的全栅格化(旋转在矢量叠加里不易对齐);
|
* - 图表被旋转 → 整页退回旧的全栅格化(旋转在矢量叠加里不易对齐);
|
||||||
* - 单个图表矢量失败/字形展开失败 → 该图表单独栅格化后叠加(不丢图);
|
* - 单个图表矢量失败/字形展开失败 → 该图表单独栅格化后叠加(图表保持 PNG 以保边缘锐利);
|
||||||
* - 整页矢量叠加彻底失败 → 整页退回全栅格化(仍是位图,但保证有图)。
|
* - 整页矢量叠加彻底失败 → 整页退回全栅格化(仍是位图,但保证有图)。
|
||||||
*
|
*
|
||||||
* 非图表页(无 chart 控件)仍走原全栅格化位图路径,行为与旧版一致。
|
* 非图表页(无 chart 控件)仍走原全栅格化位图路径,行为与旧版一致。
|
||||||
@@ -30,10 +36,24 @@ const PDF_FONTS: FontFaceDef[] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
export interface PdfOptions {
|
export interface PdfOptions {
|
||||||
/** 高清倍率,1=96dpi / 2=192dpi(推荐)/ 3=288dpi,默认 2 */
|
/** 高清倍率,1=96dpi / 2=192dpi / 3=288dpi(默认·最高清),可传 4 更锐 */
|
||||||
scale?: number
|
scale?: number
|
||||||
/** 页面装饰(背景色 + 水印),使 PDF 与预览一致 */
|
/** 页面装饰(背景色 + 水印),使 PDF 与预览一致 */
|
||||||
pageDecoration?: PageDecoration
|
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 {
|
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 JsPDF = (mod as unknown as { jsPDF?: typeof mod.default; default: typeof mod.default }).jsPDF ?? mod.default
|
||||||
const { pageWidth: w, pageHeight: h } = result.metrics
|
const { pageWidth: w, pageHeight: h } = result.metrics
|
||||||
const orientation = w > h ? 'landscape' : 'portrait'
|
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 })
|
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)
|
const useVector = charts.length > 0 && charts.every((c) => c.angle === 0)
|
||||||
|
|
||||||
if (!useVector) {
|
if (!useVector) {
|
||||||
const dataUrl = await rasterizePageFull(result, i, scale, opts.pageDecoration)
|
const dataUrl = await rasterizePageFull(result, i, scale, opts.pageDecoration, imageType)
|
||||||
doc.addImage(dataUrl, 'PNG', 0, 0, w, h)
|
doc.addImage(dataUrl, addImageFmt, 0, 0, w, h)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,8 +143,8 @@ export async function documentToPdf(result: LayoutResult, opts: PdfOptions = {})
|
|||||||
...result,
|
...result,
|
||||||
pages: result.pages.map((p, idx) => (idx === i ? stripCharts(p) : p)),
|
pages: result.pages.map((p, idx) => (idx === i ? stripCharts(p) : p)),
|
||||||
}
|
}
|
||||||
const baseUrl = await rasterizePageFull(strippedResult, i, scale, opts.pageDecoration)
|
const baseUrl = await rasterizePageFull(strippedResult, i, scale, opts.pageDecoration, imageType)
|
||||||
doc.addImage(baseUrl, 'PNG', 0, 0, w, h)
|
doc.addImage(baseUrl, addImageFmt, 0, 0, w, h)
|
||||||
|
|
||||||
let allVectorOk = true
|
let allVectorOk = true
|
||||||
for (const ch of charts) {
|
for (const ch of charts) {
|
||||||
@@ -141,7 +163,7 @@ export async function documentToPdf(result: LayoutResult, opts: PdfOptions = {})
|
|||||||
document.body.removeChild(el)
|
document.body.removeChild(el)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// 该图表矢量失败 → 单独栅格化兜底
|
// 该图表矢量失败 → 单独栅格化兜底(图表保持 PNG 以保边缘锐利,体积可控)
|
||||||
try {
|
try {
|
||||||
const img = await rasterizeChart(ch.svg, ch.width, ch.height, scale)
|
const img = await rasterizeChart(ch.svg, ch.width, ch.height, scale)
|
||||||
doc.addImage(img, 'PNG', ch.left, ch.top, ch.width, ch.height)
|
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()
|
const pageCount = doc.getNumberOfPages()
|
||||||
doc.deletePage(pageCount)
|
doc.deletePage(pageCount)
|
||||||
doc.addPage([w, h], orientation)
|
doc.addPage([w, h], orientation)
|
||||||
const fallback = await rasterizePageFull(result, i, scale, opts.pageDecoration)
|
const fallback = await rasterizePageFull(result, i, scale, opts.pageDecoration, imageType)
|
||||||
doc.addImage(fallback, 'PNG', 0, 0, w, h)
|
doc.addImage(fallback, addImageFmt, 0, 0, w, h)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,10 +193,12 @@ async function rasterizePageFull(
|
|||||||
result: LayoutResult,
|
result: LayoutResult,
|
||||||
index: number,
|
index: number,
|
||||||
scale: number,
|
scale: number,
|
||||||
pageDecoration?: PageDecoration,
|
pageDecoration: PageDecoration | undefined,
|
||||||
|
imageType: 'jpeg' | 'png',
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
|
const { mime } = imgSpec(imageType)
|
||||||
const blob = await pageToImageBlob(result, index, {
|
const blob = await pageToImageBlob(result, index, {
|
||||||
type: 'image/png',
|
type: mime,
|
||||||
scale,
|
scale,
|
||||||
background: pageDecoration?.backgroundColor ?? '#ffffff',
|
background: pageDecoration?.backgroundColor ?? '#ffffff',
|
||||||
fonts: PDF_FONTS,
|
fonts: PDF_FONTS,
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export type { FontFaceDef } from './fonts'
|
|||||||
export type ExportFormat = 'pdf' | 'jpg' | 'svg'
|
export type ExportFormat = 'pdf' | 'jpg' | 'svg'
|
||||||
|
|
||||||
export interface ExportOptions {
|
export interface ExportOptions {
|
||||||
/** 高清倍率 1/2/3,默认 2 */
|
/** 高清倍率 1/2/3(默认 3·288dpi·最高清),可传 4 更锐 */
|
||||||
scale?: number
|
scale?: number
|
||||||
/** 文件名(不含扩展名);多页 JPG 会追加 -页码 */
|
/** 文件名(不含扩展名);多页 JPG 会追加 -页码 */
|
||||||
filename?: string
|
filename?: string
|
||||||
@@ -33,6 +33,12 @@ export interface ExportOptions {
|
|||||||
background?: string
|
background?: string
|
||||||
/** 自定义字体(同源 URL),嵌入 JPG/SVG 栅格化产物使导出不丢字体 */
|
/** 自定义字体(同源 URL),嵌入 JPG/SVG 栅格化产物使导出不丢字体 */
|
||||||
fonts?: FontFaceDef[]
|
fonts?: FontFaceDef[]
|
||||||
|
/**
|
||||||
|
* PDF 位图底图压缩格式(仅 PDF 导出生效,默认 `'png'` 无损最清晰):
|
||||||
|
* - `'png'`:无损,文字/线条边缘锐利,打印推荐;
|
||||||
|
* - `'jpeg'`:体积小,仅体积敏感场景。
|
||||||
|
*/
|
||||||
|
pdfImageType?: 'jpeg' | 'png'
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExportOutcome {
|
export interface ExportOutcome {
|
||||||
@@ -53,13 +59,17 @@ export async function exportDocument(
|
|||||||
const res = await render(request)
|
const res = await render(request)
|
||||||
const result: LayoutResult = res.result
|
const result: LayoutResult = res.result
|
||||||
const name = (options.filename ?? 'openprint-document').trim() || 'openprint-document'
|
const name = (options.filename ?? 'openprint-document').trim() || 'openprint-document'
|
||||||
const scale = options.scale ?? 2
|
const scale = options.scale ?? 3
|
||||||
const bg = options.background ?? '#ffffff'
|
const bg = options.background ?? '#ffffff'
|
||||||
// 页面装饰(背景色 + 水印)统一来自渲染请求 output,三格式导出共享
|
// 页面装饰(背景色 + 水印)统一来自渲染请求 output,三格式导出共享
|
||||||
const deco = request.output?.pageDecoration
|
const deco = request.output?.pageDecoration
|
||||||
|
|
||||||
if (format === 'pdf') {
|
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' }
|
return { blobs: [blob], filenames: [`${name}.pdf`], mime: 'application/pdf' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -108,10 +108,27 @@ function loadImage(src: string): Promise<HTMLImageElement> {
|
|||||||
* "Tainted canvases may not be exported"。同一 SVG 改用 `data:` URL 加载则不污染。
|
* "Tainted canvases may not be exported"。同一 SVG 改用 `data:` URL 加载则不污染。
|
||||||
* (实测对照:blob+FO=TAINTED,blob+纯矢量=clean,data+FO=clean,data+纯矢量=clean)
|
* (实测对照:blob+FO=TAINTED,blob+纯矢量=clean,data+FO=clean,data+纯矢量=clean)
|
||||||
*/
|
*/
|
||||||
|
/** 把 SVG 根节点的显示宽高放大 scale 倍(viewBox 不变),让 foreignObject 以高分原生渲染,
|
||||||
|
* 而不是低分渲染后再插值放大 —— 这是打印/导出文字清晰的关键。
|
||||||
|
* 血泪:低分 SVG 渲染成位图后再 drawImage 放大 = 只是像素插值,文字发虚;
|
||||||
|
* 放大 SVG 的 width/height(viewBox 不动)则内容矢量重排到高分画布,边缘锐利。 */
|
||||||
|
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> {
|
export async function svgToCanvas(svg: string, scale: number, bg: string): Promise<HTMLCanvasElement> {
|
||||||
const url = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg)
|
// 先放大 SVG 显示尺寸,再加载:img 本身就是高分位图,canvas 无需再插值放大
|
||||||
const cw = Math.max(1, Math.round(794 * scale))
|
const scaled = scaleSvgViewport(svg, scale)
|
||||||
const ch = Math.max(1, Math.round(1123 * 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 fallback = (): HTMLCanvasElement => {
|
||||||
const canvas = document.createElement('canvas')
|
const canvas = document.createElement('canvas')
|
||||||
canvas.width = cw
|
canvas.width = cw
|
||||||
@@ -126,13 +143,14 @@ export async function svgToCanvas(svg: string, scale: number, bg: string): Promi
|
|||||||
try {
|
try {
|
||||||
const img = await loadImage(url)
|
const img = await loadImage(url)
|
||||||
const canvas = document.createElement('canvas')
|
const canvas = document.createElement('canvas')
|
||||||
canvas.width = Math.max(1, Math.round(img.width * scale))
|
// img.width/height 已是高分目标尺寸,drawImage 原样铺绘,不再缩放插值
|
||||||
canvas.height = Math.max(1, Math.round(img.height * scale))
|
canvas.width = Math.max(1, img.width)
|
||||||
|
canvas.height = Math.max(1, img.height)
|
||||||
const ctx = canvas.getContext('2d')
|
const ctx = canvas.getContext('2d')
|
||||||
if (!ctx) return fallback()
|
if (!ctx) return fallback()
|
||||||
ctx.fillStyle = bg
|
ctx.fillStyle = bg
|
||||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height)
|
ctx.drawImage(img, 0, 0)
|
||||||
return canvas
|
return canvas
|
||||||
} catch {
|
} catch {
|
||||||
// 环境不支持 foreignObject 光栅化(如 happy-dom)→ 降级白底空 canvas,不阻塞导出
|
// 环境不支持 foreignObject 光栅化(如 happy-dom)→ 降级白底空 canvas,不阻塞导出
|
||||||
@@ -144,6 +162,12 @@ export interface PageImageOptions {
|
|||||||
scale?: number
|
scale?: number
|
||||||
background?: string
|
background?: string
|
||||||
type?: 'image/png' | 'image/jpeg'
|
type?: 'image/png' | 'image/jpeg'
|
||||||
|
/**
|
||||||
|
* JPEG 编码质量 0-1,默认 **1(最高清)**。
|
||||||
|
* 注:quality=1 比 0.92 体积大约 30-60%,但仍是 PNG 的零头;
|
||||||
|
* 对票据/报表这类对清晰度敏感的打印件值得。
|
||||||
|
*/
|
||||||
|
quality?: number
|
||||||
/** 自定义字体(同源 URL),嵌入 SVG 使栅格化产物不丢字体 */
|
/** 自定义字体(同源 URL),嵌入 SVG 使栅格化产物不丢字体 */
|
||||||
fonts?: FontFaceDef[]
|
fonts?: FontFaceDef[]
|
||||||
/** 页面装饰(背景色 + 水印),使导出与预览一致 */
|
/** 页面装饰(背景色 + 水印),使导出与预览一致 */
|
||||||
@@ -243,13 +267,14 @@ export async function pageToImageBlob(
|
|||||||
const pageHtml = await inlinePageImages(renderPage(page, deco?.watermark))
|
const pageHtml = await inlinePageImages(renderPage(page, deco?.watermark))
|
||||||
let svg = buildPageSvg(pageHtml, css, result.metrics.pageWidth, result.metrics.pageHeight)
|
let svg = buildPageSvg(pageHtml, css, result.metrics.pageWidth, result.metrics.pageHeight)
|
||||||
if (opts.fonts?.length) svg = await embedFontsInSvg(svg, opts.fonts)
|
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 type = opts.type ?? 'image/png'
|
||||||
|
const quality = type === 'image/jpeg' ? opts.quality ?? 1 : undefined
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
canvas.toBlob(
|
canvas.toBlob(
|
||||||
(b) => (b ? resolve(b) : reject(new Error('canvas.toBlob 失败'))),
|
(b) => (b ? resolve(b) : reject(new Error('canvas.toBlob 失败'))),
|
||||||
type,
|
type,
|
||||||
type === 'image/jpeg' ? 0.92 : undefined,
|
quality,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -139,13 +139,31 @@ export async function listPrinters(
|
|||||||
return data.printers.map((p, i) => normalizePrinter(p ?? {}, i))
|
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 ------------------------------ */
|
/* ------------------------------ POST /print ------------------------------ */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 提交打印任务。
|
* 提交打印任务。
|
||||||
* 载荷约定(见《打印机数据交互文档》):
|
* 载荷约定(见《打印机数据交互文档》):
|
||||||
* - 单页 → `format:'svg'`,`encoding:'utf8'`,content 为 SVG 原文
|
* - **统一** `format:'pdf'`,`encoding:'base64'`,content 为 PDF base64(无 data: 前缀)。
|
||||||
* - 多页 → `format:'pdf'`,`encoding:'base64'`,content 为 base64(无 data: 前缀)
|
* PDF 内部位图底图默认 PNG(无损·最高清),文字/线条边缘真正锐利,打印推荐。
|
||||||
|
* 本地客户端用 `QPrinter` 打印位图 PDF。
|
||||||
|
* - `'svg'` 已废弃(Qt QSvgRenderer 不支持 foreignObject/HTML/CSS),类型保留仅作向后兼容。
|
||||||
|
* - `job.jobId` 由 Web 端生成(generateJobId);服务端若回传自己的 jobId 则以服务端为准。
|
||||||
*/
|
*/
|
||||||
export async function submitPrintJob(
|
export async function submitPrintJob(
|
||||||
job: PrintJobRequest,
|
job: PrintJobRequest,
|
||||||
@@ -164,7 +182,7 @@ export async function submitPrintJob(
|
|||||||
if (data.ok !== true) {
|
if (data.ok !== true) {
|
||||||
throw new PrintClientError('service', data.message || '打印客户端拒绝了本次任务')
|
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 }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------ 工具 ------------------------------ */
|
/* ------------------------------ 工具 ------------------------------ */
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export {
|
|||||||
checkHealth,
|
checkHealth,
|
||||||
listPrinters,
|
listPrinters,
|
||||||
submitPrintJob,
|
submitPrintJob,
|
||||||
|
generateJobId,
|
||||||
blobToBase64,
|
blobToBase64,
|
||||||
blobToText,
|
blobToText,
|
||||||
} from './client'
|
} from './client'
|
||||||
|
|||||||
@@ -1,27 +1,28 @@
|
|||||||
/**
|
/**
|
||||||
* 打印载荷构建 —— 模板 + 数据 → 推送给本地打印客户端的文档内容
|
* 打印载荷构建 —— 模板 + 数据 → 推送给本地打印客户端的文档内容
|
||||||
*
|
*
|
||||||
* 推送格式规则(主任定,2026-08-12):
|
* 推送格式规则(2026-08-13 最终定案):
|
||||||
* - **单页** → SVG(矢量原文,utf8)。体积小、矢量无损,客户端可直接送打印或转 EMF。
|
* - **统一推 PDF(base64)**,不论单页或多页。
|
||||||
* - **多页** → PDF(base64)。PDF 天然多页,base64 后可走 JSON 传输。
|
|
||||||
*
|
*
|
||||||
* 只 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 type { RenderRequest } from '@/core/sdk'
|
||||||
import { renderDocument } from '@/core/sdk'
|
import { renderDocument } from '@/core/sdk'
|
||||||
import { documentToPdf, documentToSvgString } from '@/core/export-engine'
|
import { documentToPdf } from '@/core/export-engine'
|
||||||
import type { FontFaceDef } from '@/core/export-engine'
|
|
||||||
import { templateUsedFonts, toExportFontDefs } from '@/core/fonts/loader'
|
|
||||||
import { blobToBase64 } from './client'
|
import { blobToBase64 } from './client'
|
||||||
import type { PrintPayloadEncoding, PrintPayloadFormat } from './types'
|
|
||||||
|
|
||||||
export interface PrintPayload {
|
export interface PrintPayload {
|
||||||
/** 载荷格式:单页 svg / 多页 pdf */
|
/** 载荷格式:恒为 pdf */
|
||||||
format: PrintPayloadFormat
|
format: 'pdf'
|
||||||
/** 载荷编码:svg=utf8 / pdf=base64 */
|
/** 载荷编码:恒为 base64 */
|
||||||
encoding: PrintPayloadEncoding
|
encoding: 'base64'
|
||||||
/** 文档内容 */
|
/** PDF 的 base64 字符串(不含 data: 前缀) */
|
||||||
content: string
|
content: string
|
||||||
/** 总页数 */
|
/** 总页数 */
|
||||||
pages: number
|
pages: number
|
||||||
@@ -30,16 +31,23 @@ export interface PrintPayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface BuildPrintPayloadOptions {
|
export interface BuildPrintPayloadOptions {
|
||||||
/** PDF 位图底图倍率,默认 2(192dpi) */
|
/** 位图倍率,默认 3(288dpi·最高清);要更锐可传 4 */
|
||||||
scale?: number
|
scale?: number
|
||||||
/**
|
/**
|
||||||
* 需要内联进 SVG 的字体。不传则自动从模板里提取用到的内置字体,
|
* 多页 PDF 的位图底图压缩格式(默认 `'png'`,无损最清晰):
|
||||||
* 保证客户端渲染 SVG 时中文不掉字(SVG 是隔离上下文,看不到宿主 @font-face)。
|
* - `'png'`:无损,文字/线条边缘锐利,打印推荐;
|
||||||
|
* - `'jpeg'`:体积小,仅体积敏感场景。
|
||||||
*/
|
*/
|
||||||
fonts?: FontFaceDef[]
|
imageType?: 'jpeg' | 'png'
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 构建推送载荷(单页 SVG / 多页 PDF-base64) */
|
/**
|
||||||
|
* 构建推送载荷(统一 PDF-base64)。
|
||||||
|
*
|
||||||
|
* 不论单页或多页,都先 `renderDocument()` → `documentToPdf()`(浏览器内用 Chromium
|
||||||
|
* 栅格化 foreignObject 内容成位图底图,默认 PNG 无损最高清)→ `blobToBase64()`。
|
||||||
|
* 本地客户端拿到的就是可直接 `QPrinter` 打印的 PDF 字节流。
|
||||||
|
*/
|
||||||
export async function buildPrintPayload(
|
export async function buildPrintPayload(
|
||||||
request: RenderRequest,
|
request: RenderRequest,
|
||||||
options: BuildPrintPayloadOptions = {},
|
options: BuildPrintPayloadOptions = {},
|
||||||
@@ -48,15 +56,19 @@ export async function buildPrintPayload(
|
|||||||
const pages = result.pages.length
|
const pages = result.pages.length
|
||||||
const deco = request.output?.pageDecoration
|
const deco = request.output?.pageDecoration
|
||||||
|
|
||||||
if (pages <= 1) {
|
const blob = await documentToPdf(result, {
|
||||||
const fonts = options.fonts ?? toExportFontDefs(templateUsedFonts(request.template))
|
scale: options.scale ?? 3,
|
||||||
const svg = await documentToSvgString(result, fonts.length ? fonts : undefined, deco)
|
imageType: options.imageType ?? 'png',
|
||||||
return { format: 'svg', encoding: 'utf8', content: svg, pages: Math.max(pages, 1), bytes: svg.length }
|
pageDecoration: deco,
|
||||||
}
|
})
|
||||||
|
|
||||||
const blob = await documentToPdf(result, { scale: options.scale ?? 2, pageDecoration: deco })
|
|
||||||
const base64 = await blobToBase64(blob)
|
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 提示用) */
|
/** 人类可读体积(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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -69,14 +69,21 @@ export interface PrinterListResponse {
|
|||||||
|
|
||||||
/* ------------------------------ /print ------------------------------ */
|
/* ------------------------------ /print ------------------------------ */
|
||||||
|
|
||||||
/** 推送载荷格式:单页 SVG(矢量文本),多页 PDF(base64) */
|
/**
|
||||||
|
* 推送载荷格式。
|
||||||
|
* **2026-08-13 最终定案**:统一推 `'pdf'`(base64)。
|
||||||
|
* - 单页/多页都走浏览器栅格化的位图 PDF,本地客户端 `QPrinter` 打印无兼容性问题。
|
||||||
|
* - `'svg'` 保留在枚举里仅供向后兼容(已废弃:Qt QSvgRenderer 不解析 foreignObject/HTML)。
|
||||||
|
*/
|
||||||
export type PrintPayloadFormat = 'svg' | 'pdf'
|
export type PrintPayloadFormat = 'svg' | 'pdf'
|
||||||
|
|
||||||
/** 载荷编码:svg 用 utf8 原文,pdf 用 base64 */
|
/** 载荷编码:jpg/pdf 用 base64;utf8 仅供向后兼容(对应已废弃的 SVG 原文路径) */
|
||||||
export type PrintPayloadEncoding = 'utf8' | 'base64'
|
export type PrintPayloadEncoding = 'utf8' | 'base64'
|
||||||
|
|
||||||
/** POST /print 请求体 */
|
/** POST /print 请求体 */
|
||||||
export interface PrintJobRequest {
|
export interface PrintJobRequest {
|
||||||
|
/** 任务号:由 Web 端生成(MMDD + 6 位随机,共 10 位);服务端若回传则以其为准,否则沿用此值 */
|
||||||
|
jobId?: string
|
||||||
/** 任务名(用于打印队列显示) */
|
/** 任务名(用于打印队列显示) */
|
||||||
taskName: string
|
taskName: string
|
||||||
/** 目标打印机名(取自 /printers 的 name;留空由客户端用系统默认) */
|
/** 目标打印机名(取自 /printers 的 name;留空由客户端用系统默认) */
|
||||||
@@ -85,7 +92,7 @@ export interface PrintJobRequest {
|
|||||||
format: PrintPayloadFormat
|
format: PrintPayloadFormat
|
||||||
/** 载荷编码 */
|
/** 载荷编码 */
|
||||||
encoding: PrintPayloadEncoding
|
encoding: PrintPayloadEncoding
|
||||||
/** 文档内容:svg=XML 原文;pdf=base64(不含 data: 前缀) */
|
/** 文档内容:pdf=PDF base64(不含 data: 前缀);svg=XML 原文(已废弃) */
|
||||||
content: string
|
content: string
|
||||||
/** 文档总页数 */
|
/** 文档总页数 */
|
||||||
pages: number
|
pages: number
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ ${s}.op-table tr.is-striped td { background: ${TABLE_STRIPE_BG}; }
|
|||||||
单元格视觉以内联样式为准(与设计期 overlay 同一套 resolveCellStyleFor 取值) */
|
单元格视觉以内联样式为准(与设计期 overlay 同一套 resolveCellStyleFor 取值) */
|
||||||
${s}.op-table tr.is-static td { /* 占位:保持类名与设计期对齐,避免样式遗漏 */ }
|
${s}.op-table tr.is-static td { /* 占位:保持类名与设计期对齐,避免样式遗漏 */ }
|
||||||
|
|
||||||
/* ---------- 表格样式预设(Excel 式快速切换;class 由渲染端挂在 <table> 上) ---------- */
|
/* ---------- 表格样式预设(Excel 式快速切换;class 由渲染端挂在 table 元素上) ---------- */
|
||||||
/* 默认(none):仅表头加粗,无任何背景色(含标题行)。无需规则,class 仅作占位。 */
|
/* 默认(none):仅表头加粗,无任何背景色(含标题行)。无需规则,class 仅作占位。 */
|
||||||
|
|
||||||
/* 表头高亮:表头浅灰底 */
|
/* 表头高亮:表头浅灰底 */
|
||||||
|
|||||||
@@ -472,6 +472,7 @@ watch(
|
|||||||
.ai-header-title {
|
.ai-header-title {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
margin-bottom: 5px;
|
||||||
color: var(--brand-text-1, #1f2329);
|
color: var(--brand-text-1, #1f2329);
|
||||||
}
|
}
|
||||||
.ai-header-sub {
|
.ai-header-sub {
|
||||||
|
|||||||
@@ -7,7 +7,10 @@
|
|||||||
* GET /printers 打印机列表(能力/状态/纸盒)
|
* GET /printers 打印机列表(能力/状态/纸盒)
|
||||||
* POST /print 推送任务
|
* POST /print 推送任务
|
||||||
*
|
*
|
||||||
* 推送格式(主任定):**单页 → SVG 原文;多页 → PDF 转 base64**。
|
* 推送格式(2026-08-13 最终定案):**统一推 PDF(base64)**。
|
||||||
|
* PDF 内部位图底图默认 PNG(无损·最高清),文字/线条边缘真正锐利,打印推荐。
|
||||||
|
* 本地客户端用 `QPrinter` 打印位图 PDF。
|
||||||
|
* 演进:SVG(Qt 不支持 foreignObject,废弃)→ 统一 PDF(最终)。
|
||||||
* 载荷由 `buildPrintPayload()` 统一构建,与预览/导出共用同一条 render 链路,保证三者一致。
|
* 载荷由 `buildPrintPayload()` 统一构建,与预览/导出共用同一条 render 链路,保证三者一致。
|
||||||
*
|
*
|
||||||
* 主任铁律:无后端全链路可用 —— 客户端不可达时只是不能出纸,设计器功能不受影响。
|
* 主任铁律:无后端全链路可用 —— 客户端不可达时只是不能出纸,设计器功能不受影响。
|
||||||
@@ -34,6 +37,7 @@ import {
|
|||||||
buildPrintPayload,
|
buildPrintPayload,
|
||||||
describePrintError,
|
describePrintError,
|
||||||
formatPayloadSize,
|
formatPayloadSize,
|
||||||
|
generateJobId,
|
||||||
submitPrintJob,
|
submitPrintJob,
|
||||||
type PrinterInfo,
|
type PrinterInfo,
|
||||||
} from '@/core/print-client'
|
} from '@/core/print-client'
|
||||||
@@ -300,6 +304,7 @@ async function doPrint(): Promise<void> {
|
|||||||
printingHint.value = `正在推送 ${payload.format.toUpperCase()}(${payload.pages} 页 · ${formatPayloadSize(payload.bytes)})…`
|
printingHint.value = `正在推送 ${payload.format.toUpperCase()}(${payload.pages} 页 · ${formatPayloadSize(payload.bytes)})…`
|
||||||
const res = await submitPrintJob(
|
const res = await submitPrintJob(
|
||||||
{
|
{
|
||||||
|
jobId: generateJobId(),
|
||||||
taskName: taskName.value,
|
taskName: taskName.value,
|
||||||
printer: selectedPrinter.value,
|
printer: selectedPrinter.value,
|
||||||
format: payload.format,
|
format: payload.format,
|
||||||
@@ -498,7 +503,7 @@ function close(): void {
|
|||||||
<!-- 推送格式说明 -->
|
<!-- 推送格式说明 -->
|
||||||
<div class="print-format-note">
|
<div class="print-format-note">
|
||||||
<div class="i-carbon-information text-13px" />
|
<div class="i-carbon-information text-13px" />
|
||||||
<span>推送规则:单页文档以 <b>SVG</b> 矢量原文推送,多页文档转 <b>PDF(base64)</b> 推送。</span>
|
<span>推送规则:所有文档统一转 <b>PDF(base64)</b> 推送,本地客户端用 QPrinter 直接打印。</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
|
|||||||
@@ -23,9 +23,7 @@ import {
|
|||||||
} from '@/config/print-settings'
|
} from '@/config/print-settings'
|
||||||
import {
|
import {
|
||||||
buildPrinterBase,
|
buildPrinterBase,
|
||||||
envPrinterBase,
|
|
||||||
FACTORY_PRINTER_BASE_URL,
|
FACTORY_PRINTER_BASE_URL,
|
||||||
hasEnvPrinterBase,
|
|
||||||
} from '@/config/printer'
|
} from '@/config/printer'
|
||||||
import { checkHealth, listPrinters, describePrintError } from '@/core/print-client'
|
import { checkHealth, listPrinters, describePrintError } from '@/core/print-client'
|
||||||
import {
|
import {
|
||||||
@@ -279,11 +277,8 @@ function copyToClipboard(value: string, label: string): void {
|
|||||||
—— 打印机探测(/health、/printers)与任务推送(/print)都走这个地址。
|
—— 打印机探测(/health、/printers)与任务推送(/print)都走这个地址。
|
||||||
</NText>
|
</NText>
|
||||||
<NText depth="3" class="block text-12px mt-1">
|
<NText depth="3" class="block text-12px mt-1">
|
||||||
出厂默认 {{ FACTORY_PRINTER_BASE_URL }};这里填的地址优先级最高,会覆盖构建期环境变量
|
出厂默认 {{ FACTORY_PRINTER_BASE_URL }};可手动改为局域网地址(客户端已支持局域网打印),
|
||||||
<code>VITE_OPENPRINT_PRINTER_BASE</code>。
|
手动填写的地址优先级最高。
|
||||||
<template v-if="hasEnvPrinterBase">
|
|
||||||
当前环境变量为 <b>{{ envPrinterBase }}</b>(仅在此处留空时生效)。
|
|
||||||
</template>
|
|
||||||
</NText>
|
</NText>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -13,9 +13,6 @@ const ui = useUiStore()
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex h-full flex-col bg-brand-surface">
|
<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>
|
<NTabs v-model:value="ui.leftTab" type="segment" size="small" class="flex-1 px-2" animated>
|
||||||
<NTabPane name="components" tab="组件">
|
<NTabPane name="components" tab="组件">
|
||||||
<ControlLibrary />
|
<ControlLibrary />
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import {
|
|||||||
NModal,
|
NModal,
|
||||||
NRadio,
|
NRadio,
|
||||||
NRadioGroup,
|
NRadioGroup,
|
||||||
NSelect,
|
|
||||||
useMessage,
|
useMessage,
|
||||||
} from 'naive-ui'
|
} from 'naive-ui'
|
||||||
import { exportDocument, downloadBlob, type ExportFormat } from '@/core/export-engine'
|
import { exportDocument, downloadBlob, type ExportFormat } from '@/core/export-engine'
|
||||||
@@ -34,7 +33,6 @@ const dsStore = useDataSourceStore()
|
|||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
|
|
||||||
const format = ref<ExportFormat>('pdf')
|
const format = ref<ExportFormat>('pdf')
|
||||||
const scale = ref(2)
|
|
||||||
const rowCount = ref(30)
|
const rowCount = ref(30)
|
||||||
const filename = ref('销售出库单')
|
const filename = ref('销售出库单')
|
||||||
const exporting = ref(false)
|
const exporting = ref(false)
|
||||||
@@ -44,11 +42,6 @@ const formatOptions = [
|
|||||||
{ label: 'JPG(每页一张)', value: 'jpg' },
|
{ label: 'JPG(每页一张)', value: 'jpg' },
|
||||||
{ label: 'SVG(矢量 · 单文件多页)', value: 'svg' },
|
{ label: 'SVG(矢量 · 单文件多页)', value: 'svg' },
|
||||||
]
|
]
|
||||||
const scaleOptions = [
|
|
||||||
{ label: '1x(96dpi)', value: 1 },
|
|
||||||
{ label: '2x(192dpi · 推荐)', value: 2 },
|
|
||||||
{ label: '3x(288dpi · 最高清)', value: 3 },
|
|
||||||
]
|
|
||||||
|
|
||||||
function close(): void {
|
function close(): void {
|
||||||
emit('update:show', false)
|
emit('update:show', false)
|
||||||
@@ -72,7 +65,7 @@ async function onExport(): Promise<void> {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
format.value,
|
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]!))
|
res.blobs.forEach((b, i) => downloadBlob(b, res.filenames[i]!))
|
||||||
message.success(`已导出 ${res.blobs.length} 个文件(${res.filenames[0]} 等)`)
|
message.success(`已导出 ${res.blobs.length} 个文件(${res.filenames[0]} 等)`)
|
||||||
@@ -111,18 +104,6 @@ async function onExport(): Promise<void> {
|
|||||||
</NRadioGroup>
|
</NRadioGroup>
|
||||||
</div>
|
</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">
|
<div class="row">
|
||||||
<span class="row-label">明细行数</span>
|
<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 |
Reference in New Issue
Block a user