From 588e90e7601875bd5ff13b235f59aa33aa5ab976 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=B7=E6=98=8E?= <251352239@qq.com> Date: Thu, 13 Aug 2026 22:14:07 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E6=89=93=E5=8D=B0=E6=9C=BA?= =?UTF-8?q?=E8=BF=9E=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 48 ++++++++- env.d.ts | 6 -- overview.md | 108 +++++++++++++++++++++ package.json | 4 +- src/config/printer.ts | 22 +---- src/core/export-engine/export-pdf.ts | 48 ++++++--- src/core/export-engine/index.ts | 16 ++- src/core/export-engine/rasterize.ts | 41 ++++++-- src/core/print-client/client.ts | 24 ++++- src/core/print-client/index.ts | 1 + src/core/print-client/payload.ts | 66 +++++++------ src/core/print-client/print-client.spec.ts | 25 +++++ src/core/print-client/types.ts | 13 ++- src/core/renderer-html/css-generator.ts | 2 +- src/design/ai/AiAssistantPanel.vue | 1 + src/design/modals/PrintDialog.vue | 9 +- src/design/modals/SettingsModal.vue | 9 +- src/design/panels/LeftPanel.vue | 3 - src/design/toolbar/ExportDialog.vue | 21 +--- 临时/export-test-清晰版.pdf | Bin 0 -> 81301 bytes 临时/pdf-300dpi渲染.png | Bin 0 -> 174362 bytes 临时/receipt-300dpi-png-scale3.png | Bin 0 -> 148892 bytes 22 files changed, 349 insertions(+), 118 deletions(-) create mode 100644 src/core/print-client/print-client.spec.ts create mode 100644 临时/export-test-清晰版.pdf create mode 100644 临时/pdf-300dpi渲染.png create mode 100644 临时/receipt-300dpi-png-scale3.png diff --git a/README.md b/README.md index 038352d..e68d9fc 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ Vue 3 TypeScript Fabric.js + npm

@@ -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 + +``` + +### 从源码运行 ```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,可部署可选的桌面客户端。 diff --git a/env.d.ts b/env.d.ts index fdd8cde..1c055af 100644 --- a/env.d.ts +++ b/env.d.ts @@ -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 { diff --git a/overview.md b/overview.md index 099858a..975bd12 100644 --- a/overview.md +++ b/overview.md @@ -30,3 +30,111 @@ svg2pdf 的矢量渲染、中文 TTF 注入、旋转页回退逻辑**无法在 1. 图表在 PDF 中随缩放保持清晰(矢量); 2. 中文标签正常显示(非方块/空白); 3. 含旋转图表的页面正确回退为位图且不丢图。 + +--- + +# 导出/打印空白纸修复 + +## 问题 +用户反馈:导出 PDF、推送本地打印都是空白纸。 + +## 根因 +`src/core/renderer-html/css-generator.ts` 的 `tableCss()` 里有一段 CSS 注释包含未转义的 `` 字符串: + +```css +/* ---------- 表格样式预设(Excel 式快速切换;class 由渲染端挂在
上) ---------- */ +``` + +导出/打印时,整页 CSS 被塞进 SVG 的 `` 时抛出: + +``` +Opening and ending tag mismatch: table line 180 and style +``` + +整张 SVG 无法作为 `` 加载,`rasterize.ts` 的 `svgToCanvas` 触发超时/错误兜底,返回**白底空 canvas**,最终 PDF / 打印推送都是白纸。 + +## 修复 +将注释改为无尖括号的描述: + +```css +/* ---------- 表格样式预设(Excel 式快速切换;class 由渲染端挂在 table 元素上) ---------- */ +``` + +改动仅一行:`src/core/renderer-html/css-generator.ts`。 + +## 验证 +- 用真实 Chromium 无头复现修复前:`` 加载 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'`。 diff --git a/package.json b/package.json index 5367ed9..d32428c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "open-print", + "name": "openprint26", "version": "0.0.0", - "private": true, + "private": false, "type": "module", "scripts": { "dev": "vite", diff --git a/src/config/printer.ts b/src/config/printer.ts index 1618a8f..9d6faf3 100644 --- a/src/config/printer.ts +++ b/src/config/printer.ts @@ -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 diff --git a/src/core/export-engine/export-pdf.ts b/src/core/export-engine/export-pdf.ts index d50930e..1b6429b 100644 --- a/src/core/export-engine/export-pdf.ts +++ b/src/core/export-engine/export-pdf.ts @@ -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 { + const { mime } = imgSpec(imageType) const blob = await pageToImageBlob(result, index, { - type: 'image/png', + type: mime, scale, background: pageDecoration?.backgroundColor ?? '#ffffff', fonts: PDF_FONTS, diff --git a/src/core/export-engine/index.ts b/src/core/export-engine/index.ts index 028e892..6a49705 100644 --- a/src/core/export-engine/index.ts +++ b/src/core/export-engine/index.ts @@ -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' } } diff --git a/src/core/export-engine/rasterize.ts b/src/core/export-engine/rasterize.ts index 50ccc73..b920ca3 100644 --- a/src/core/export-engine/rasterize.ts +++ b/src/core/export-engine/rasterize.ts @@ -108,10 +108,27 @@ function loadImage(src: string): Promise { * "Tainted canvases may not be exported"。同一 SVG 改用 `data:` URL 加载则不污染。 * (实测对照: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( + /]*?)\bwidth="([\d.]+)"([^>]*?)\bheight="([\d.]+)"/, + (m, p1: string, w: string, p2: string, h: string) => + ` { - 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(/]*\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, ) }) } diff --git a/src/core/print-client/client.ts b/src/core/print-client/client.ts index bd4a0fe..4091f3c 100644 --- a/src/core/print-client/client.ts +++ b/src/core/print-client/client.ts @@ -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 } } /* ------------------------------ 工具 ------------------------------ */ diff --git a/src/core/print-client/index.ts b/src/core/print-client/index.ts index 510e62b..6badd01 100644 --- a/src/core/print-client/index.ts +++ b/src/core/print-client/index.ts @@ -18,6 +18,7 @@ export { checkHealth, listPrinters, submitPrintJob, + generateJobId, blobToBase64, blobToText, } from './client' diff --git a/src/core/print-client/payload.ts b/src/core/print-client/payload.ts index 8b176be..2591d9e 100644 --- a/src/core/print-client/payload.ts +++ b/src/core/print-client/payload.ts @@ -1,27 +1,28 @@ /** * 打印载荷构建 —— 模板 + 数据 → 推送给本地打印客户端的文档内容 * - * 推送格式规则(主任定,2026-08-12): - * - **单页** → SVG(矢量原文,utf8)。体积小、矢量无损,客户端可直接送打印或转 EMF。 - * - **多页** → PDF(base64)。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 提示用) */ diff --git a/src/core/print-client/print-client.spec.ts b/src/core/print-client/print-client.spec.ts new file mode 100644 index 0000000..ea897ca --- /dev/null +++ b/src/core/print-client/print-client.spec.ts @@ -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() + for (let i = 0; i < 50; i++) seen.add(generateJobId().slice(4)) + // 50 次抽样几乎必然出现碰撞以外的情况;极端碰撞属概率事件,放宽到 >=49 唯一 + expect(seen.size).toBeGreaterThanOrEqual(49) + }) +}) diff --git a/src/core/print-client/types.ts b/src/core/print-client/types.ts index 21243ea..345e655 100644 --- a/src/core/print-client/types.ts +++ b/src/core/print-client/types.ts @@ -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 用 base64;utf8 仅供向后兼容(对应已废弃的 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 diff --git a/src/core/renderer-html/css-generator.ts b/src/core/renderer-html/css-generator.ts index 4c4b8e5..3e336eb 100644 --- a/src/core/renderer-html/css-generator.ts +++ b/src/core/renderer-html/css-generator.ts @@ -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 由渲染端挂在
上) ---------- */ +/* ---------- 表格样式预设(Excel 式快速切换;class 由渲染端挂在 table 元素上) ---------- */ /* 默认(none):仅表头加粗,无任何背景色(含标题行)。无需规则,class 仅作占位。 */ /* 表头高亮:表头浅灰底 */ diff --git a/src/design/ai/AiAssistantPanel.vue b/src/design/ai/AiAssistantPanel.vue index 9a5e507..3e74277 100644 --- a/src/design/ai/AiAssistantPanel.vue +++ b/src/design/ai/AiAssistantPanel.vue @@ -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 { diff --git a/src/design/modals/PrintDialog.vue b/src/design/modals/PrintDialog.vue index 8b9a9bc..34ddaa5 100644 --- a/src/design/modals/PrintDialog.vue +++ b/src/design/modals/PrintDialog.vue @@ -7,7 +7,10 @@ * GET /printers 打印机列表(能力/状态/纸盒) * POST /print 推送任务 * - * 推送格式(主任定):**单页 → SVG 原文;多页 → PDF 转 base64**。 + * 推送格式(2026-08-13 最终定案):**统一推 PDF(base64)**。 + * PDF 内部位图底图默认 PNG(无损·最高清),文字/线条边缘真正锐利,打印推荐。 + * 本地客户端用 `QPrinter` 打印位图 PDF。 + * 演进:SVG(Qt 不支持 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 { 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 {