@vg-print/plugin-api-word 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,10 @@
1
+ # 变更日志
2
+
3
+ ## 0.1.0 — 2026-11-06
4
+
5
+ - 新增 `hiprint.register({ plugins: [pluginApiWord()] })` 注入与 `template.toWord()` 方法。
6
+ - 新增独立 `exportWord()` API,支持下载和返回 DOCX Blob。
7
+ - 基于引擎渲染 DOM 转换原生段落、表格、横纵合并及并排布局;图表/条码等局部图片化。
8
+ - 新增进度、转换警告、类型解析和异步写入前回调。
9
+ - 提供 ESM 包入口、TypeScript 声明和中文使用说明。
10
+ - 静态核对阶段,尚未在 Word/WPS 中验证排版精度。
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 vg-print contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,143 @@
1
- # Temporary Holding Version
1
+ # @vg-print/plugin-api-word
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 为 `vg-print` 打印模板添加可编辑 `.docx` 导出。由宿主通过 `hiprint.register` 注入,不依赖 Vue、不创建额外打印引擎,也不需要打印客户端、本机 Office 或云端转换服务。
4
+
5
+ 文字与表格生成 Word 原生内容;图片、条码、二维码、图表生成局部图片。不会把整页截图伪装成“可编辑 Word”。
6
+
7
+ ## 安装与注册
8
+
9
+ ```bash
10
+ npm install vg-print @vg-print/plugin-api-word
11
+ ```
12
+
13
+ ```js
14
+ import { hiprint, createTemplate } from 'vg-print'
15
+ import 'vg-print/style.css'
16
+ import pluginApiWord from '@vg-print/plugin-api-word'
17
+
18
+ // 请等待 register 完成,尤其是同时传入签名授权时。
19
+ await hiprint.register({
20
+ plugins: [
21
+ pluginApiWord({
22
+ fontName: 'Microsoft YaHei',
23
+ onWarning: warning => console.warn(warning.message)
24
+ })
25
+ ]
26
+ })
27
+
28
+ const template = createTemplate(templateJson)
29
+ await template.toWord(printData, { name: '报告.docx' })
30
+
31
+ // 不下载,获取 Blob,可自行上传或保存。
32
+ const blob = await template.toWord(printData, {
33
+ isDownload: false,
34
+ name: '报告.docx'
35
+ })
36
+ ```
37
+
38
+ 必须传入模板实例,原始模板 JSON 请先使用 `createTemplate()` 构造。插件注册前后创建的实例共享同一原型;重复注册只更新默认配置。
39
+
40
+ 也可以不注册,直接使用独立 API:
41
+
42
+ ```js
43
+ import { exportWord } from '@vg-print/plugin-api-word'
44
+ const blob = await exportWord(template, printData, { isDownload: false })
45
+ ```
46
+
47
+ ## API
48
+
49
+ `template.toWord(printData, options?): Promise<Blob>`
50
+
51
+ | 配置 | 默认值 | 说明 |
52
+ | --- | --- | --- |
53
+ | `isDownload` | `true` | 下载文件;无论是否下载都返回 Blob |
54
+ | `name` | `vg-print-{timestamp}.docx` | 文件名,自动补 `.docx` |
55
+ | `title` | `vg-print Word export` | 文档元数据标题 |
56
+ | `fontName` | `Microsoft YaHei` | 默认字体,组件已有字体优先 |
57
+ | `fontSize` | `10` | 默认字号,单位 pt,组件已有字号优先 |
58
+ | `pixelRatio` | `2` | 图片组件截图倍率,不影响可编辑文字 |
59
+ | `pageBreaks` | `true` | 按打印引擎输出的页分节;`false` 使用连续分节 |
60
+ | `renderOptions` | — | 传给 `getHtml`,支持 `beforeGetHtml`;小标签拼版固定关闭 |
61
+ | `customTypeResolver` | — | `(element, defaultType) => type \| undefined` |
62
+ | `onProgress` | — | `(current, total)`,当前页完成转换时回调 |
63
+ | `onWarning` | — | 接收布局降级说明 |
64
+ | `onBeforeWrite` | — | 转换每页后、写入文档前修改原生内容,支持异步 |
65
+
66
+ `onProgress` 最后一次回调表示页面转换完成,文件压缩和下载仍在进行;请用 `await toWord()` 控制 loading。
67
+
68
+ `pageBreaks: false` 仍保留各节纸张尺寸,Word 会根据内容自然分页;不同纸张尺寸可能强制换页。默认分节也不能阻止编辑后的内容增加页数。
69
+
70
+ ```js
71
+ await template.toWord(printData, {
72
+ onProgress: (current, total) => console.log(current, total),
73
+ onWarning: ({ code, message, pageIndex }) => {
74
+ console.warn(`第 ${pageIndex + 1} 页 [${code}] ${message}`)
75
+ },
76
+ customTypeResolver: (element, defaultType) => {
77
+ if (element.classList.contains('my-chart')) return 'image'
78
+ return undefined
79
+ },
80
+ onBeforeWrite: async ({ children, pageIndex }) => {
81
+ // children 为 docx 的 Paragraph/Table 数组。
82
+ // 可原地修改;返回新数组则替换这一页的内容。
83
+ return children
84
+ }
85
+ })
86
+ ```
87
+
88
+ 支持的转换类型:`content`(原生文字/表格)、`container`(布局容器)、`hline`(原生段落边框)、`image`(局部图片)、`other`(图片回退并警告)。
89
+
90
+ TypeScript 使用方可以显式补充模板方法类型:
91
+
92
+ ```ts
93
+ import type { WordPrintTemplate } from '@vg-print/plugin-api-word'
94
+ const wordTemplate = template as WordPrintTemplate
95
+ const blob = await wordTemplate.toWord(printData, { isDownload: false })
96
+ ```
97
+
98
+ ## 转换范围与精度
99
+
100
+ - 读取 `getHtml()` 渲染后的页面,复用字段绑定、格式化、内容隐藏和引擎分页结果。
101
+ - 普通文本、长文本保留可编辑内容、基本字体样式、显式换行和对齐。
102
+ - 动态/静态表格、e2table、w2html 中的 HTML 表格转换为原生 Word 表格,支持列宽、横纵合并、边框、背景和表头。
103
+ - 并排组件用无边框表格布局;矩形容器按实际包围范围分组,内容编辑后自然撑高。装饰矩形包围其他内容时也会按容器处理。
104
+ - 长文本标题与正文的横向布局转换为双列表格。
105
+ - 图片、条码、二维码、图表、竖线、椭圆作为图片;内部文字和图表数据不可原生编辑。
106
+ - 渲染 DOM 已包含的水印保留为背景图片;编辑型 Word 不是防篡改文件。
107
+
108
+ 不保证任意模板与预览逐像素一致。Word/WPS 的字体度量、行距、自动换行和分页与浏览器不同;自由重叠、旋转文字、复杂 CSS、混合定位和复杂列表不保证还原。未知组件使用图片回退,重叠组件按阅读顺序输出并报告警告。
109
+
110
+ 常规报告/规整表单更适合导出;不使用固定行高截断长文本。页眉页脚和页码来自引擎每页的渲染内容,作为每节正文输出,不转换为自动更新的 Word 页码域或原生重复页眉页脚。
111
+
112
+ 图片截图使用 `@zumer/snapdom`;跨域图片需允许 CORS,图片缺失或截图失败可能导致导出失败。`fontName` 不会自动安装或嵌入接收方字体。
113
+
114
+ 只支持浏览器导出。调用时需有 DOM、已加载设计器样式和可用字体。相同模板的 Word 导出会串行执行;建议不要同时对同一模板进行其他预览/导出操作。
115
+
116
+ ## 本地配套开发与发布
117
+
118
+ 插件项目与设计器项目为兄弟目录:
119
+
120
+ ```text
121
+ cusom+vgPrint/
122
+ ├── api-word/
123
+ └── vue-plugin-hiprint-demo/
124
+ ```
125
+
126
+ 设计器将此包作为 `file:../api-word` 开发依赖,通过 `src/main.js` 注册。开发 Vite 别名直接读取本项目源码,`dedupe` 从设计器解析插件依赖。
127
+
128
+ ```bash
129
+ # 在设计器目录执行;将本地包安装为副本,依赖放在设计器 node_modules。
130
+ npm install --install-links --ignore-scripts
131
+ ```
132
+
133
+ 设计器库入口不 import 插件,库打包将插件列为 external,并声明为可选 peer dependency。外部项目按需安装注册后,FullDesigner/Preview 自动显示 Word 按钮。
134
+
135
+ 此包直接发布 ESM 源码和类型声明,无需构建步骤,不提供 CommonJS `require` 入口。
136
+
137
+ ```bash
138
+ # 在 api-word 目录执行;先检查发布内容,再发布。
139
+ npm pack --dry-run
140
+ npm publish --access public
141
+ ```
142
+
143
+ 当前实现仅做静态核对。正式发布前请用代表性模板检查 Word/WPS 中的文件可打开性、文字编辑、跨页表格、合并单元格、并排内容、图片和字体替换结果。
package/package.json CHANGED
@@ -1,6 +1,25 @@
1
1
  {
2
2
  "name": "@vg-print/plugin-api-word",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Editable DOCX export plugin for vg-print, installed through hiprint.register.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "sideEffects": false,
8
+ "main": "./src/index.js",
9
+ "module": "./src/index.js",
10
+ "types": "./src/index.d.ts",
11
+ "exports": {
12
+ ".": {
13
+ "types": "./src/index.d.ts",
14
+ "import": "./src/index.js"
15
+ },
16
+ "./package.json": "./package.json"
17
+ },
18
+ "files": ["src", "README.md", "CHANGELOG.md", "LICENSE"],
19
+ "dependencies": {
20
+ "docx": "9.5.1",
21
+ "@zumer/snapdom": "^2.0.2"
22
+ },
23
+ "publishConfig": { "access": "public" },
24
+ "engines": { "node": ">=18.18.0" }
25
+ }
package/src/convert.js ADDED
@@ -0,0 +1,420 @@
1
+ import {
2
+ Paragraph, TextRun, ImageRun, Table, TableRow, TableCell,
3
+ BorderStyle, WidthType, TableLayoutType, HeightRule, AlignmentType
4
+ } from 'docx'
5
+ import { snapdom } from '@zumer/snapdom'
6
+
7
+ // CSS px -> twip(1/20pt);字号使用半 pt,与布局尺寸不能混用。
8
+ const twip = px => Math.round(Math.max(0, Number(px) || 0) * 15)
9
+ const number = value => Number.parseFloat(value) || 0
10
+ const styleOf = element => getComputedStyle(element)
11
+ const NONE = { style: BorderStyle.NONE, size: 0, color: 'FFFFFF' }
12
+ const NO_BORDERS = { top: NONE, bottom: NONE, left: NONE, right: NONE, insideHorizontal: NONE, insideVertical: NONE }
13
+ const empty = () => new Paragraph({ spacing: { before: 0, after: 0, line: 1 }, children: [new TextRun({ text: '', size: 1 })] })
14
+ const finish = children => {
15
+ // Word 单元格和文档结尾需要段落,嵌套表格后补一个极小空段落。
16
+ if (!children.length || children[children.length - 1] instanceof Table) children.push(empty())
17
+ return children
18
+ }
19
+
20
+ function color(value) {
21
+ if (!value || value === 'transparent') return undefined
22
+ const hex = value.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i)
23
+ if (hex) return hex[1].length === 3 ? hex[1].split('').map(c => c + c).join('') : hex[1]
24
+ const rgb = value.match(/^rgba?\(\s*(\d+)[,\s]+(\d+)[,\s]+(\d+)(?:\s*[,/]\s*([\d.]+))?\s*\)$/i)
25
+ if (!rgb || (rgb[4] !== undefined && Number(rgb[4]) === 0)) return undefined
26
+ return rgb.slice(1, 4).map(c => Math.min(255, Number(c)).toString(16).padStart(2, '0')).join('')
27
+ }
28
+
29
+ function borders(style) {
30
+ return Object.fromEntries(['top', 'right', 'bottom', 'left'].map(side => {
31
+ const prefix = 'border' + side[0].toUpperCase() + side.slice(1)
32
+ const width = number(style[prefix + 'Width'])
33
+ const kind = style[prefix + 'Style']
34
+ const border = width && kind !== 'none' && kind !== 'hidden' ? {
35
+ style: kind === 'dashed' ? BorderStyle.DASHED : kind === 'dotted' ? BorderStyle.DOTTED :
36
+ kind === 'double' ? BorderStyle.DOUBLE : BorderStyle.SINGLE,
37
+ size: Math.max(1, Math.round(width * 6)),
38
+ color: color(style[prefix + 'Color']) || '000000'
39
+ } : NONE
40
+ return [side, border]
41
+ }))
42
+ }
43
+
44
+ function visible(element) {
45
+ const style = styleOf(element)
46
+ return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0'
47
+ }
48
+
49
+ function runStyle(element, context) {
50
+ const style = styleOf(element)
51
+ const decoration = style.textDecorationLine || ''
52
+ return {
53
+ font: style.fontFamily?.split(',')[0].trim().replace(/["']/g, '') || context.options.fontName,
54
+ size: Math.max(1, Math.round((number(style.fontSize) || context.options.fontSize * 4 / 3) * 1.5)),
55
+ bold: Number(style.fontWeight) >= 600 || style.fontWeight === 'bold',
56
+ italics: style.fontStyle === 'italic',
57
+ color: color(style.color),
58
+ underline: decoration.includes('underline') ? {} : undefined,
59
+ strike: decoration.includes('line-through'),
60
+ subScript: element.tagName === 'SUB',
61
+ superScript: element.tagName === 'SUP',
62
+ characterSpacing: twip(number(style.letterSpacing)),
63
+ shading: color(style.backgroundColor) ? { fill: color(style.backgroundColor) } : undefined
64
+ }
65
+ }
66
+
67
+ function paragraph(runs, element) {
68
+ const style = styleOf(element)
69
+ const alignments = { center: AlignmentType.CENTER, right: AlignmentType.RIGHT, end: AlignmentType.RIGHT, justify: AlignmentType.JUSTIFIED }
70
+ const line = number(style.lineHeight)
71
+ const isCell = ['TD', 'TH'].includes(element.tagName)
72
+ return new Paragraph({
73
+ children: runs,
74
+ alignment: alignments[style.textAlign] || AlignmentType.LEFT,
75
+ // 不固定段落高度,编辑后仍允许内容自然撑开。
76
+ spacing: { before: twip(number(style.marginTop)), after: twip(number(style.marginBottom)), ...(line ? { line: twip(line), lineRule: 'atLeast' } : {}) },
77
+ indent: {
78
+ left: isCell ? 0 : twip(number(style.paddingLeft)),
79
+ right: isCell ? 0 : twip(number(style.paddingRight)),
80
+ firstLine: twip(number(style.textIndent))
81
+ }
82
+ })
83
+ }
84
+
85
+ /**
86
+ * 图片组件、SVG 条码、Canvas 图表使用局部截图,不栅格化文字/表格。
87
+ * 图片加载/跨域失败应使导出失败,避免生成缺图却提示成功的文件。
88
+ */
89
+ async function imageRun(element, context, maxWidth, floating) {
90
+ const rect = element.getBoundingClientRect()
91
+ const width = Math.max(1, Math.min(rect.width, maxWidth || rect.width))
92
+ const height = Math.max(1, rect.height * width / Math.max(1, rect.width))
93
+ const canvas = await snapdom.toCanvas(element, { scale: Number(context.options.pixelRatio), backgroundColor: 'transparent' })
94
+ const blob = await new Promise((resolve, reject) => {
95
+ canvas.toBlob(result => result ? resolve(result) : reject(new Error('图片组件转换失败')), 'image/png')
96
+ })
97
+ return new ImageRun({
98
+ type: 'png',
99
+ data: new Uint8Array(await blob.arrayBuffer()),
100
+ transformation: { width, height },
101
+ floating
102
+ })
103
+ }
104
+
105
+ /**
106
+ * 富文本递归转换:保留显式换行、内联样式、图片和原生表格。
107
+ * 块级元素按段落拆分;不把 textContent 当作完整富文本使用。
108
+ */
109
+ async function htmlBlocks(root, context, maxWidth) {
110
+ // longText 的 flex 标题/正文,以及 html 内容缩进的绝对定位标题,
111
+ // 转换成原生双列表格,避免标题另起一行或用空格模拟对齐。
112
+ const direct = Array.from(root.children).filter(el => visible(el) && !['STYLE', 'SCRIPT'].includes(el.tagName))
113
+ const rootStyle = styleOf(root)
114
+ const hasText = Array.from(root.childNodes).some(node => node.nodeType === 3 && node.textContent.trim())
115
+ if (!hasText && direct.length > 1 && (
116
+ (rootStyle.display === 'flex' && rootStyle.flexDirection === 'row') ||
117
+ direct.some(el => styleOf(el).position === 'absolute')
118
+ )) {
119
+ const origin = root.getBoundingClientRect()
120
+ const items = direct.map(el => ({ ...rectangle(el, origin), type: 'content' }))
121
+ const horizontal = items.every((item, index) => items.slice(index + 1).every(other =>
122
+ Math.min(item.right, other.right) - Math.max(item.left, other.left) <= 1
123
+ ))
124
+ if (horizontal) return layout(items, Math.min(origin.width || maxWidth, maxWidth), context)
125
+ context.warn('html-layout', '富文本复杂定位已按阅读顺序转换', root, context.pageIndex)
126
+ }
127
+ const output = []
128
+ let runs = []
129
+ const flush = element => {
130
+ if (runs.length) {
131
+ output.push(paragraph(runs, element || root))
132
+ runs = []
133
+ }
134
+ }
135
+ const walk = async node => {
136
+ if (node.nodeType === 3) {
137
+ const parent = node.parentElement || root
138
+ const whiteSpace = styleOf(parent).whiteSpace
139
+ const text = /pre|break-spaces/.test(whiteSpace) ? node.textContent : node.textContent.replace(/\s+/g, ' ')
140
+ if (text.trim() || runs.length || /pre/.test(whiteSpace)) {
141
+ const lines = text.split(/\r\n|\r|\n/)
142
+ lines.forEach((line, index) => {
143
+ if (index) runs.push(new TextRun({ break: 1 }))
144
+ if (line) runs.push(new TextRun({ text: line, ...runStyle(parent, context) }))
145
+ })
146
+ }
147
+ return
148
+ }
149
+ if (node.nodeType !== 1 || !visible(node)) return
150
+ const tag = node.tagName
151
+ if (['STYLE', 'SCRIPT', 'NOSCRIPT'].includes(tag)) return
152
+ if (tag === 'BR') { runs.push(new TextRun({ break: 1 })); return }
153
+ if (tag === 'TABLE') {
154
+ flush(root)
155
+ output.push(await htmlTable(node, context, maxWidth))
156
+ return
157
+ }
158
+ if (['IMG', 'SVG', 'CANVAS'].includes(tag)) {
159
+ runs.push(await imageRun(node, context, maxWidth))
160
+ return
161
+ }
162
+ const nodeStyle = styleOf(node)
163
+ const block = /^(block|flex|grid|list-item|table-caption)$/.test(nodeStyle.display)
164
+ if (block) {
165
+ flush(root)
166
+ // 每个块递归进入 htmlBlocks,嵌套的标题/正文布局也会被识别。
167
+ const children = await htmlBlocks(node, context, Math.min(node.getBoundingClientRect().width || maxWidth, maxWidth))
168
+ output.push(...children)
169
+ return
170
+ }
171
+ for (const child of node.childNodes) await walk(child)
172
+ }
173
+ if (root.tagName === 'LI') runs.push(new TextRun({ text: '• ', ...runStyle(root, context) }))
174
+ for (const node of root.childNodes) await walk(node)
175
+ flush(root)
176
+ return finish(output)
177
+ }
178
+
179
+ /** 合并单元格通过显式网格保留 colspan/rowspan,避免仅按 DOM 下标错位。 */
180
+ async function htmlTable(table, context, maxWidth) {
181
+ const rows = Array.from(table.rows).filter(visible)
182
+ if (!rows.length) return new Table({
183
+ rows: [new TableRow({ children: [new TableCell({ children: [empty()] })] })],
184
+ borders: NO_BORDERS
185
+ })
186
+ const occupied = []
187
+ const placements = rows.map((row, rowIndex) => {
188
+ occupied[rowIndex] ||= []
189
+ let column = 0
190
+ return Array.from(row.cells).filter(visible).map(cell => {
191
+ while (occupied[rowIndex][column]) column++
192
+ const colSpan = Math.max(1, cell.colSpan)
193
+ // HTML rowspan=0 表示延续到当前行组末尾。
194
+ const groupRows = Array.from(row.parentElement.children).filter(el => el.tagName === 'TR')
195
+ const remaining = groupRows.length - groupRows.indexOf(row)
196
+ const rowSpan = Math.min(cell.rowSpan === 0 ? remaining : Math.max(1, cell.rowSpan), rows.length - rowIndex)
197
+ const position = { cell, column, colSpan, rowSpan }
198
+ for (let y = rowIndex; y < rowIndex + rowSpan; y++) {
199
+ occupied[y] ||= []
200
+ for (let x = column; x < column + colSpan; x++) occupied[y][x] = true
201
+ }
202
+ column += colSpan
203
+ return position
204
+ })
205
+ })
206
+ const count = Math.max(...occupied.map(row => row.length), 1)
207
+ const tableRect = table.getBoundingClientRect()
208
+ const width = Math.max(1, Math.min(tableRect.width || maxWidth, maxWidth))
209
+ const edges = new Array(count + 1).fill(undefined)
210
+ edges[0] = 0
211
+ edges[count] = tableRect.width
212
+ placements.flat().forEach(({ cell, column, colSpan }) => {
213
+ const rect = cell.getBoundingClientRect()
214
+ edges[column] ??= rect.left - tableRect.left
215
+ edges[column + colSpan] ??= rect.right - tableRect.left
216
+ })
217
+ // 完全合并的列可能没有独立边界,按相邻已知边界均分。
218
+ for (let start = 0; start < count;) {
219
+ let end = start + 1
220
+ while (end < count && edges[end] === undefined) end++
221
+ for (let x = start + 1; x < end; x++) edges[x] = edges[start] + (edges[end] - edges[start]) * (x - start) / (end - start)
222
+ start = end
223
+ }
224
+ const ratio = width / Math.max(1, tableRect.width)
225
+ const widths = Array.from({ length: count }, (_, column) => Math.max(1, twip((edges[column + 1] - edges[column]) * ratio)))
226
+ const result = []
227
+ for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) {
228
+ const cells = []
229
+ for (const { cell, column, colSpan, rowSpan } of placements[rowIndex]) {
230
+ const style = styleOf(cell)
231
+ const cellWidth = widths.slice(column, column + colSpan).reduce((sum, value) => sum + value, 0)
232
+ cells.push(new TableCell({
233
+ children: await htmlBlocks(cell, context, Math.max(1, cellWidth / 15 - number(style.paddingLeft) - number(style.paddingRight))),
234
+ columnSpan: colSpan,
235
+ rowSpan,
236
+ width: { size: cellWidth, type: WidthType.DXA },
237
+ borders: borders(style),
238
+ margins: {
239
+ top: twip(number(style.paddingTop)), bottom: twip(number(style.paddingBottom)),
240
+ left: twip(number(style.paddingLeft)), right: twip(number(style.paddingRight))
241
+ },
242
+ shading: color(style.backgroundColor) ? { fill: color(style.backgroundColor) } : undefined,
243
+ verticalAlign: style.verticalAlign === 'middle' ? 'center' : style.verticalAlign === 'bottom' ? 'bottom' : 'top'
244
+ }))
245
+ }
246
+ result.push(new TableRow({
247
+ children: cells,
248
+ tableHeader: rows[rowIndex].parentElement.tagName === 'THEAD',
249
+ height: { value: twip(rows[rowIndex].getBoundingClientRect().height), rule: HeightRule.ATLEAST }
250
+ }))
251
+ }
252
+ return new Table({ rows: result, width: { size: twip(width), type: WidthType.DXA }, columnWidths: widths, layout: TableLayoutType.FIXED, borders: NO_BORDERS })
253
+ }
254
+
255
+ function elementType(element) {
256
+ const classes = element.classList
257
+ if (classes.contains('hiprint-printElement-rect')) return 'container'
258
+ if (classes.contains('hiprint-printElement-hline')) return 'hline'
259
+ if (['image', 'barcode', 'qrcode', 'echarts', 'oval', 'vline'].some(type => classes.contains('hiprint-printElement-' + type))) return 'image'
260
+ if (['text', 'longText', 'html', 'w2html', 'e2table', 'table', 'staticGridTable'].some(type => classes.contains('hiprint-printElement-' + type))) return 'content'
261
+ if (classes.contains('hiprint-paperNumber')) return 'content'
262
+ return 'other'
263
+ }
264
+
265
+ function rectangle(element, origin) {
266
+ const box = element.getBoundingClientRect()
267
+ return { element, left: box.left - origin.left, top: box.top - origin.top, width: box.width, height: box.height, right: box.right - origin.left, bottom: box.bottom - origin.top }
268
+ }
269
+
270
+ /** 根据实际 DOM 高度分行,允许同行文字高度不同;重叠不丢弃,顺序输出并警告。 */
271
+ function groupRows(items, context) {
272
+ const rows = []
273
+ for (const item of items.sort((a, b) => a.top - b.top || a.left - b.left)) {
274
+ const row = rows[rows.length - 1]
275
+ const isSameBand = row && item.top < row.bottom - 1
276
+ const overlap = isSameBand && row.items.some(other => Math.min(other.right, item.right) - Math.max(other.left, item.left) > 1)
277
+ if (isSameBand && !overlap) {
278
+ row.items.push(item)
279
+ row.bottom = Math.max(row.bottom, item.bottom)
280
+ } else {
281
+ if (overlap) context.warn('overlap', '重叠组件已按阅读顺序排列,Word 不保留叠放关系', item.element, context.pageIndex)
282
+ rows.push({ top: item.top, bottom: item.bottom, items: [item] })
283
+ }
284
+ }
285
+ return rows
286
+ }
287
+
288
+ function spacer(px) {
289
+ return new Paragraph({ spacing: { before: 0, after: 0, line: Math.max(1, twip(px)), lineRule: 'exact' }, children: [new TextRun({ text: '', size: 1 })] })
290
+ }
291
+
292
+ async function content(item, context) {
293
+ const { element } = item
294
+ let type = item.type || elementType(element)
295
+ type = context.options.customTypeResolver?.(element, type) ?? type
296
+ const transform = styleOf(element).transform
297
+ if (transform && transform !== 'none') context.warn('transform', '组件变换不能完整映射到可编辑 Word 布局', element, context.pageIndex)
298
+ if (type === 'image' || type === 'other') {
299
+ if (type === 'other') context.warn('image-fallback', '未知组件以图片导出', element, context.pageIndex)
300
+ return [new Paragraph({ children: [await imageRun(element, context, item.width)], spacing: { before: 0, after: 0 } })]
301
+ }
302
+ if (type === 'hline') {
303
+ const border = borders(styleOf(element)).top
304
+ return [new Paragraph({ border: { bottom: border }, spacing: { before: 0, after: 0, line: 1 }, children: [new TextRun({ text: '', size: 1 })] })]
305
+ }
306
+ if (type === 'container') {
307
+ if (item.children?.length) return layout(item.children, item.width, context)
308
+ return [empty()]
309
+ }
310
+ if (type !== 'content') throw new Error(`不支持的 Word 组件类型:${type}`)
311
+ const contentRoot = item.type ? element : element.querySelector(
312
+ '.hiprint-printElement-longText-content,.hiprint-printElement-text-content,.hiprint-printElement-table-content,.hiprint-printElement-w2html-content,.hiprint-printElement-e2table-content'
313
+ ) || element
314
+ return htmlBlocks(contentRoot, context, item.width)
315
+ }
316
+
317
+ async function layout(items, width, context) {
318
+ const result = []
319
+ let previousBottom = 0
320
+ for (const row of groupRows(items, context)) {
321
+ if (row.top > previousBottom + 1) result.push(spacer(row.top - previousBottom))
322
+ const cells = []
323
+ const widths = []
324
+ let cursor = 0
325
+ for (const item of row.items.sort((a, b) => a.left - b.left)) {
326
+ if (item.left < -1 || item.right > width + 1) {
327
+ context.warn('out-of-bounds', '超出可用宽度的组件已限制到 Word 列宽', item.element, context.pageIndex)
328
+ }
329
+ if (item.left > cursor + 1) {
330
+ const gap = twip(item.left - cursor)
331
+ cells.push(new TableCell({ children: [empty()], borders: NO_BORDERS, width: { size: gap, type: WidthType.DXA } }))
332
+ widths.push(gap)
333
+ }
334
+ const available = Math.max(1, Math.min(item.width, width - Math.max(0, item.left)))
335
+ const cellWidth = twip(available)
336
+ const style = styleOf(item.element)
337
+ const children = await content({ ...item, width: available }, context)
338
+ if (item.top > row.top + 1) children.unshift(spacer(item.top - row.top))
339
+ cells.push(new TableCell({
340
+ children: finish(children),
341
+ width: { size: cellWidth, type: WidthType.DXA },
342
+ borders: borders(style),
343
+ margins: { top: 0, right: 0, bottom: 0, left: 0 },
344
+ shading: color(style.backgroundColor) ? { fill: color(style.backgroundColor) } : undefined,
345
+ verticalAlign: 'top'
346
+ }))
347
+ widths.push(cellWidth)
348
+ cursor = item.left + available
349
+ }
350
+ if (cursor < width - 1) {
351
+ const gap = twip(width - cursor)
352
+ cells.push(new TableCell({ children: [empty()], borders: NO_BORDERS, width: { size: gap, type: WidthType.DXA } }))
353
+ widths.push(gap)
354
+ }
355
+ result.push(new Table({
356
+ rows: [new TableRow({ children: cells })],
357
+ columnWidths: widths,
358
+ width: { size: twip(width), type: WidthType.DXA },
359
+ layout: TableLayoutType.FIXED,
360
+ borders: NO_BORDERS,
361
+ margins: { top: 0, bottom: 0, left: 0, right: 0 }
362
+ }))
363
+ // 不强制写入设计高度;后续行由 Word 按实际内容高度推动。
364
+ result.push(empty())
365
+ previousBottom = Math.max(previousBottom, row.bottom)
366
+ }
367
+ return finish(result)
368
+ }
369
+
370
+ export async function convertPage(page, context) {
371
+ const origin = page.element.getBoundingClientRect()
372
+ const elements = Array.from(page.element.querySelectorAll('.hiprint-printElement,.hiprint-paperNumber'))
373
+ .filter(element => !element.parentElement.closest('.hiprint-printElement') && visible(element))
374
+ const items = elements.map(element => rectangle(element, origin))
375
+ .filter(item => item.width > 0 && (item.height > 0 || elementType(item.element) === 'hline'))
376
+ // 当前 vgContainer 以矩形根组件和兄弟子组件输出,按实际包围范围重建布局。
377
+ const containers = items.filter(item => elementType(item.element) === 'container')
378
+ const parentOf = new Map()
379
+ for (const item of items) {
380
+ const parent = containers.filter(candidate => candidate !== item &&
381
+ candidate.width * candidate.height > item.width * item.height &&
382
+ item.left >= candidate.left - 1 && item.right <= candidate.right + 1 &&
383
+ item.top >= candidate.top - 1 && item.bottom <= candidate.bottom + 1)
384
+ .sort((a, b) => a.width * a.height - b.width * b.height)[0]
385
+ if (parent) parentOf.set(item, parent)
386
+ }
387
+ for (const [item, parent] of parentOf) {
388
+ parent.children ||= []
389
+ // 延后转换相对坐标,保证嵌套容器使用原始纸页坐标找父级。
390
+ parent.children.push(item)
391
+ }
392
+ const relative = parent => {
393
+ for (const child of parent.children || []) {
394
+ relative(child)
395
+ child.left -= parent.left
396
+ child.right -= parent.left
397
+ child.top -= parent.top
398
+ child.bottom -= parent.top
399
+ }
400
+ }
401
+ const roots = items.filter(item => !parentOf.has(item))
402
+ roots.forEach(relative)
403
+ const children = await layout(roots, page.width, context)
404
+ // 引擎生成的水印保留为背景图片,正文文字和表格仍然可以编辑。
405
+ const watermark = Array.from(page.element.querySelectorAll('[class]')).find(el =>
406
+ Array.from(el.classList).some(name => name.startsWith('__vm__'))
407
+ )
408
+ if (watermark && visible(watermark)) {
409
+ children.unshift(new Paragraph({
410
+ children: [await imageRun(watermark, context, page.width, {
411
+ horizontalPosition: { relative: 'page', offset: 0 },
412
+ verticalPosition: { relative: 'page', offset: 0 },
413
+ behindDocument: true,
414
+ allowOverlap: true
415
+ })],
416
+ spacing: { before: 0, after: 0, line: 1 }
417
+ }))
418
+ }
419
+ return children
420
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,54 @@
1
+ import type { Paragraph, Table } from 'docx'
2
+
3
+ export type WordElementType = 'content' | 'container' | 'hline' | 'image' | 'other'
4
+ export interface WordWarning {
5
+ code: 'overlap' | 'transform' | 'image-fallback' | 'html-layout' | 'out-of-bounds'
6
+ message: string
7
+ element: HTMLElement
8
+ /** 从 0 开始。 */
9
+ pageIndex: number
10
+ }
11
+ export interface PrintTemplateLike {
12
+ getHtml(printData: unknown, options?: Record<string, unknown>): unknown
13
+ ensureImages?(root: HTMLElement): unknown
14
+ }
15
+ export interface WordWriteContext {
16
+ template: PrintTemplateLike
17
+ printData: unknown
18
+ page: HTMLElement
19
+ pageIndex: number
20
+ paperCount: number
21
+ children: Array<Paragraph | Table>
22
+ options: WordOptions
23
+ warn(code: WordWarning['code'], message: string, element: HTMLElement, pageIndex: number): void
24
+ }
25
+ export interface WordOptions {
26
+ /** 默认 true;无论是否下载,都返回 DOCX Blob。 */
27
+ isDownload?: boolean
28
+ name?: string
29
+ title?: string
30
+ /** 默认 Microsoft YaHei;组件自身字体优先。 */
31
+ fontName?: string
32
+ /** pt,默认 10;组件自身字号优先。 */
33
+ fontSize?: number
34
+ /** 图片组件截图倍率,默认 2,不影响原生文字/表格。 */
35
+ pixelRatio?: number
36
+ /** 默认 true,按引擎页分节;false 用连续分节,由 Word 自动分页。 */
37
+ pageBreaks?: boolean
38
+ /** 传给 getHtml;applyPanelLayoutHtml 固定为 false。 */
39
+ renderOptions?: Record<string, unknown>
40
+ customTypeResolver?(element: HTMLElement, defaultType: WordElementType): WordElementType | undefined
41
+ /** 页内容转换完成的进度;文件压缩/下载发生在最后一次回调之后。 */
42
+ onProgress?(current: number, total: number): void
43
+ onWarning?(warning: WordWarning): void
44
+ onBeforeWrite?(context: WordWriteContext): Array<Paragraph | Table> | void | Promise<Array<Paragraph | Table> | void>
45
+ }
46
+ export interface WordPrintTemplate extends PrintTemplateLike {
47
+ toWord(printData: unknown, options?: WordOptions): Promise<Blob>
48
+ }
49
+ export interface WordPlugin {
50
+ name: string
51
+ install(hiprint: { PrintTemplate: { prototype: object } }): void
52
+ }
53
+ export default function pluginApiWord(defaultOptions?: WordOptions): WordPlugin
54
+ export function exportWord(template: PrintTemplateLike, printData: unknown, options?: WordOptions): Promise<Blob>
package/src/index.js ADDED
@@ -0,0 +1,126 @@
1
+ import { Document, Packer } from 'docx'
2
+ import { renderPages } from './render.js'
3
+ import { convertPage } from './convert.js'
4
+
5
+ const INSTALLED = Symbol.for('@vg-print/plugin-api-word')
6
+ const MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
7
+
8
+ /**
9
+ * 插件仅依赖 register 传入的 hiprint,不 import vg-print 或 Vue,
10
+ * 因而不会创建第二套打印引擎,也可供 Vue 2/3 使用方共享。
11
+ * 重复注册更新默认配置,不重复包装模板方法。
12
+ */
13
+ export default function pluginApiWord(defaultOptions = {}) {
14
+ return {
15
+ name: '@vg-print/plugin-api-word',
16
+ install(hiprint) {
17
+ const prototype = hiprint?.PrintTemplate?.prototype
18
+ if (!prototype) throw new TypeError('Word 插件需要 hiprint.PrintTemplate')
19
+ if (prototype[INSTALLED]) {
20
+ prototype[INSTALLED].defaults = { ...defaultOptions }
21
+ return
22
+ }
23
+ if (prototype.toWord) throw new Error('PrintTemplate.toWord 已被其他插件占用')
24
+ const state = { defaults: { ...defaultOptions } }
25
+ Object.defineProperty(prototype, INSTALLED, { value: state })
26
+ Object.defineProperty(prototype, 'toWord', {
27
+ configurable: true,
28
+ writable: true,
29
+ value(printData, options = {}) {
30
+ return exportWord(this, printData, { ...state.defaults, ...options })
31
+ }
32
+ })
33
+ }
34
+ }
35
+ }
36
+
37
+ /**
38
+ * 独立 API 与模板方法使用相同链路;始终返回 Blob,下载只是额外副作用。
39
+ * 按模板串行执行,避免同一模板的 getHtml 临时状态在并发导出时相互覆盖。
40
+ */
41
+ const queues = new WeakMap()
42
+ export function exportWord(template, printData, options = {}) {
43
+ if (!template || typeof template.getHtml !== 'function') {
44
+ return Promise.reject(new TypeError('请传入具有 getHtml() 的打印模板实例'))
45
+ }
46
+ const previous = queues.get(template) || Promise.resolve()
47
+ const task = previous.catch(() => {}).then(() => writeWord(template, printData, options))
48
+ queues.set(template, task)
49
+ const cleanup = () => { if (queues.get(template) === task) queues.delete(template) }
50
+ task.then(cleanup, cleanup)
51
+ return task
52
+ }
53
+
54
+ async function writeWord(template, printData, options) {
55
+ const settings = {
56
+ fontName: 'Microsoft YaHei',
57
+ fontSize: 10,
58
+ isDownload: true,
59
+ pixelRatio: 2,
60
+ pageBreaks: true,
61
+ ...options
62
+ }
63
+ if (!(Number(settings.fontSize) > 0) || !(Number(settings.pixelRatio) > 0)) {
64
+ throw new RangeError('fontSize 和 pixelRatio 必须大于 0')
65
+ }
66
+ const warn = (code, message, element, pageIndex) => {
67
+ settings.onWarning?.({ code, message, element, pageIndex })
68
+ }
69
+ const { pages, dispose } = await renderPages(template, printData, settings)
70
+ try {
71
+ const sections = []
72
+ for (let pageIndex = 0; pageIndex < pages.length; pageIndex++) {
73
+ const page = pages[pageIndex]
74
+ const context = { options: settings, warn, pageIndex, template, printData }
75
+ let children = await convertPage(page, context)
76
+ const replacement = await settings.onBeforeWrite?.({
77
+ ...context, page: page.element, children, paperCount: pages.length
78
+ })
79
+ if (replacement !== undefined) {
80
+ if (!Array.isArray(replacement)) throw new TypeError('onBeforeWrite 应返回数组或 undefined')
81
+ children = replacement
82
+ }
83
+ sections.push({
84
+ properties: {
85
+ type: settings.pageBreaks ? 'nextPage' : 'continuous',
86
+ page: {
87
+ size: { width: Math.round(page.width * 15), height: Math.round(page.height * 15) },
88
+ margin: { top: 0, right: 0, bottom: 0, left: 0, header: 0, footer: 0 }
89
+ }
90
+ },
91
+ children
92
+ })
93
+ settings.onProgress?.(pageIndex + 1, pages.length)
94
+ }
95
+ const document = new Document({
96
+ creator: 'vg-print',
97
+ title: settings.title || 'vg-print Word export',
98
+ styles: {
99
+ default: {
100
+ document: {
101
+ run: { font: settings.fontName, size: Number(settings.fontSize) * 2 },
102
+ paragraph: { spacing: { before: 0, after: 0 } }
103
+ }
104
+ }
105
+ },
106
+ sections
107
+ })
108
+ const blob = new Blob([await Packer.toArrayBuffer(document)], { type: MIME })
109
+ if (settings.isDownload !== false) {
110
+ const name = String(settings.name || `vg-print-${Date.now()}.docx`).replace(/\.docx$/i, '') + '.docx'
111
+ const url = URL.createObjectURL(blob)
112
+ const anchor = window.document.createElement('a')
113
+ anchor.href = url
114
+ anchor.download = name
115
+ window.document.body.appendChild(anchor)
116
+ try { anchor.click() } finally {
117
+ anchor.remove()
118
+ // 下载启动后再回收 URL,避免浏览器尚未读取文件便被撤销。
119
+ setTimeout(() => URL.revokeObjectURL(url), 1000)
120
+ }
121
+ }
122
+ return blob
123
+ } finally {
124
+ dispose()
125
+ }
126
+ }
package/src/render.js ADDED
@@ -0,0 +1,70 @@
1
+ /**
2
+ * 使用引擎生成的 DOM,而不是重新执行模板 formatter/styler。
3
+ * 每次导出拥有自己的离屏容器,不使用引擎全局 removeTempContainer,
4
+ * 避免清除正在进行的 PDF/图片导出容器。
5
+ */
6
+ export async function renderPages(template, printData, options) {
7
+ if (typeof document === 'undefined' || !document.body) {
8
+ throw new Error('Word 导出需要浏览器 DOM,请在页面挂载后调用')
9
+ }
10
+ const host = document.createElement('div')
11
+ host.setAttribute('aria-hidden', 'true')
12
+ host.style.cssText = 'position:fixed;left:-100000px;top:0;pointer-events:none;width:max-content;z-index:-1;'
13
+ document.body.appendChild(host)
14
+ const dispose = () => host.remove()
15
+ try {
16
+ const html = await template.getHtml(printData, {
17
+ ...(options.renderOptions || {}),
18
+ // 小标签拼版不会成为 Word 表格布局;按引擎原始纸页导出。
19
+ applyPanelLayoutHtml: false
20
+ })
21
+ if (typeof html === 'string') host.innerHTML = html
22
+ else if (html?.nodeType) host.appendChild(html)
23
+ else if (html && typeof html.length === 'number') {
24
+ Array.from(html).forEach(node => { if (node?.nodeType) host.appendChild(node) })
25
+ } else throw new Error('getHtml() 未返回可解析的 HTML')
26
+
27
+ // 取消预览缩放,保留组件内部缩放,以实际纸张尺寸测量。
28
+ host.querySelectorAll('.hiprint-printTemplate,.hiprint-printPanel,.hiprint-printPaper').forEach(el => {
29
+ el.style.transform = 'none'
30
+ el.style.zoom = '1'
31
+ })
32
+ if (document.fonts?.ready) await document.fonts.ready
33
+ if (typeof template.ensureImages === 'function') await template.ensureImages(host)
34
+ await Promise.all(Array.from(host.querySelectorAll('img')).map(image => {
35
+ if (image.complete) return Promise.resolve()
36
+ return new Promise(resolve => {
37
+ const done = () => {
38
+ clearTimeout(timer)
39
+ image.removeEventListener('load', done)
40
+ image.removeEventListener('error', done)
41
+ resolve()
42
+ }
43
+ const timer = setTimeout(done, 15000)
44
+ image.addEventListener('load', done, { once: true })
45
+ image.addEventListener('error', done, { once: true })
46
+ })
47
+ }))
48
+ // 给图表/图片组件一次提交 DOM 的机会,不启动应用或持续轮询。
49
+ await new Promise(resolve => requestAnimationFrame(() => resolve()))
50
+ const elements = Array.from(host.querySelectorAll('.hiprint-printPaper'))
51
+ if (!elements.length) throw new Error('没有可导出的打印纸页')
52
+ const pages = elements.map(element => {
53
+ const rect = element.getBoundingClientRect()
54
+ const originalHeight = Number(element.getAttribute('original-height'))
55
+ return {
56
+ element,
57
+ width: rect.width,
58
+ // 引擎纸页通常裁掉 1mm,Word 纸张使用原始高度。
59
+ height: originalHeight > 0 ? originalHeight * 96 / 25.4 : rect.height
60
+ }
61
+ })
62
+ if (pages.some(page => !(page.width > 0 && page.height > 0))) {
63
+ throw new Error('纸页尺寸无效,请确认已加载 vg-print/style.css')
64
+ }
65
+ return { pages, dispose }
66
+ } catch (error) {
67
+ dispose()
68
+ throw error
69
+ }
70
+ }