diff --git a/README.md b/README.md
index 038352d..e68d9fc 100644
--- a/README.md
+++ b/README.md
@@ -15,6 +15,7 @@
+
@@ -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(
+ /