@courtifyai/docx-render 1.0.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/README.md ADDED
@@ -0,0 +1,168 @@
1
+ # DOCX Render Enhanced
2
+
3
+ 基于 [docx-preview](https://github.com/VolodymyrBayworker/docx-preview) 的增强型 DOCX 渲染库,**支持评论显示和修改功能**。
4
+
5
+ ## 功能特性
6
+
7
+ - 基于 docx-preview 的高保真文档渲染
8
+ - 评论显示与高亮
9
+ - 评论编辑、删除功能
10
+ - 评论面板 UI
11
+ - 修改后文档保存/下载
12
+ - TypeScript 支持
13
+ - 响应式设计
14
+
15
+ ## 安装
16
+
17
+ ```bash
18
+ npm install
19
+ ```
20
+
21
+ ## 快速开始
22
+
23
+ ### 基础用法
24
+
25
+ ```typescript
26
+ import { DocxRenderer } from 'docx-render-enhanced'
27
+
28
+ // 创建渲染器
29
+ const renderer = new DocxRenderer({
30
+ container: '#docx-container',
31
+ renderComments: true,
32
+ enableCommentEdit: true,
33
+ showCommentPanel: true
34
+ })
35
+
36
+ // 渲染文件
37
+ const file = document.getElementById('file-input').files[0]
38
+ await renderer.render(file)
39
+ ```
40
+
41
+ ### 便捷方法
42
+
43
+ ```typescript
44
+ import { renderDocx } from 'docx-render-enhanced'
45
+
46
+ const renderer = await renderDocx(file, '#container', {
47
+ renderComments: true,
48
+ showCommentPanel: true
49
+ })
50
+ ```
51
+
52
+ ## API
53
+
54
+ ### DocxRenderer
55
+
56
+ #### 构造函数选项
57
+
58
+ | 选项 | 类型 | 默认值 | 说明 |
59
+ |------|------|--------|------|
60
+ | `container` | `HTMLElement \| string` | 必填 | 渲染容器 |
61
+ | `renderComments` | `boolean` | `true` | 是否显示评论 |
62
+ | `enableCommentEdit` | `boolean` | `true` | 是否允许编辑评论 |
63
+ | `showCommentPanel` | `boolean` | `true` | 是否显示评论面板 |
64
+ | `commentPanelPosition` | `'right' \| 'bottom'` | `'right'` | 评论面板位置 |
65
+ | `breakPages` | `boolean` | `true` | 是否分页显示 |
66
+ | `renderHeaders` | `boolean` | `true` | 是否显示页眉 |
67
+ | `renderFooters` | `boolean` | `true` | 是否显示页脚 |
68
+ | `className` | `string` | - | 自定义类名 |
69
+ | `onCommentClick` | `(comment) => void` | - | 评论点击回调 |
70
+ | `onCommentChange` | `(comment, action) => void` | - | 评论变更回调 |
71
+
72
+ #### 方法
73
+
74
+ ```typescript
75
+ // 渲染文件
76
+ await renderer.render(file: File | ArrayBuffer | Blob)
77
+
78
+ // 获取所有评论
79
+ renderer.getComments(): IComment[]
80
+
81
+ // 添加评论
82
+ renderer.addComment({ author, content }): IComment
83
+
84
+ // 更新评论
85
+ renderer.updateComment(id, { content }): boolean
86
+
87
+ // 删除评论
88
+ renderer.deleteComment(id): boolean
89
+
90
+ // 保存文档
91
+ const blob = await renderer.save()
92
+
93
+ // 下载文档
94
+ await renderer.download('filename.docx')
95
+
96
+ // 订阅评论事件
97
+ renderer.onCommentEvent('add' | 'update' | 'delete' | 'select', callback)
98
+
99
+ // 销毁渲染器
100
+ renderer.destroy()
101
+ ```
102
+
103
+ ### 评论数据结构
104
+
105
+ ```typescript
106
+ interface IComment {
107
+ id: string // 评论 ID
108
+ author: string // 作者
109
+ date: string // 日期 (ISO 8601)
110
+ content: string // 内容
111
+ initials?: string // 作者缩写
112
+ }
113
+ ```
114
+
115
+ ## 开发
116
+
117
+ ```bash
118
+ # 安装依赖
119
+ npm install
120
+
121
+ # 启动开发服务器
122
+ npm run dev
123
+
124
+ # 构建
125
+ npm run build
126
+ ```
127
+
128
+ ## 运行示例
129
+
130
+ ```bash
131
+ npm run dev
132
+ ```
133
+
134
+ 然后打开浏览器访问显示的地址,可以:
135
+ 1. 点击「打开文件」选择 DOCX 文件
136
+ 2. 或拖拽 DOCX 文件到页面
137
+ 3. 查看和编辑评论
138
+ 4. 点击「保存文档」下载修改后的文件
139
+
140
+ ## 技术原理
141
+
142
+ ### DOCX 文件结构
143
+
144
+ DOCX 文件实际上是一个 ZIP 压缩包,包含以下关键 XML 文件:
145
+
146
+ ```
147
+ document.docx/
148
+ ├── [Content_Types].xml
149
+ ├── word/
150
+ │ ├── document.xml # 主文档内容
151
+ │ ├── comments.xml # 评论数据
152
+ │ ├── styles.xml # 样式定义
153
+ │ └── _rels/
154
+ │ └── document.xml.rels # 关系定义
155
+ └── docProps/
156
+ └── core.xml # 文档属性
157
+ ```
158
+
159
+ ### 评论处理流程
160
+
161
+ 1. **解析阶段**:使用 JSZip 解压 DOCX,提取 `word/comments.xml`
162
+ 2. **渲染阶段**:使用 docx-preview 渲染文档,同时标记评论位置
163
+ 3. **交互阶段**:CommentManager 管理评论的显示、选中、编辑状态
164
+ 4. **保存阶段**:CommentWriter 将修改写回 XML,重新打包为 DOCX
165
+
166
+ ## License
167
+
168
+ MIT
@@ -0,0 +1,19 @@
1
+ const fs = require('fs');
2
+ const JSZip = require('jszip');
3
+
4
+ async function readComments() {
5
+ const data = fs.readFileSync('contract.docx');
6
+ const zip = await JSZip.loadAsync(data);
7
+ const commentsXml = await zip.file('word/comments.xml')?.async('string');
8
+
9
+ if (commentsXml) {
10
+ console.log('Comments XML length:', commentsXml.length);
11
+ console.log('Content:', commentsXml);
12
+ } else {
13
+ console.log('comments.xml not found');
14
+ }
15
+ }
16
+
17
+ readComments().catch(err => {
18
+ console.error('Error:', err.message);
19
+ });
package/index.html ADDED
@@ -0,0 +1,312 @@
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>DOCX Render - 文档预览与评论</title>
7
+ <style>
8
+ * {
9
+ margin: 0;
10
+ padding: 0;
11
+ box-sizing: border-box;
12
+ }
13
+
14
+ html, body {
15
+ height: 100%;
16
+ }
17
+
18
+ body {
19
+ font-family: 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
20
+ background: #0f172a;
21
+ }
22
+
23
+ .app {
24
+ display: flex;
25
+ flex-direction: column;
26
+ height: 100%;
27
+ }
28
+
29
+ /* 顶部工具栏 */
30
+ .toolbar {
31
+ display: flex;
32
+ align-items: center;
33
+ justify-content: space-between;
34
+ padding: 12px 24px;
35
+ background: linear-gradient(135deg, #1e293b 0%, #334155 100%);
36
+ border-bottom: 1px solid #475569;
37
+ }
38
+
39
+ .toolbar__brand {
40
+ display: flex;
41
+ align-items: center;
42
+ gap: 12px;
43
+ }
44
+
45
+ .toolbar__logo {
46
+ width: 36px;
47
+ height: 36px;
48
+ background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
49
+ border-radius: 8px;
50
+ display: flex;
51
+ align-items: center;
52
+ justify-content: center;
53
+ color: white;
54
+ font-weight: 700;
55
+ font-size: 18px;
56
+ }
57
+
58
+ .toolbar__title {
59
+ font-size: 18px;
60
+ font-weight: 600;
61
+ color: white;
62
+ }
63
+
64
+ .toolbar__subtitle {
65
+ font-size: 12px;
66
+ color: #94a3b8;
67
+ margin-top: 2px;
68
+ }
69
+
70
+ .toolbar__actions {
71
+ display: flex;
72
+ gap: 12px;
73
+ }
74
+
75
+ .btn {
76
+ display: inline-flex;
77
+ align-items: center;
78
+ gap: 8px;
79
+ padding: 10px 18px;
80
+ font-size: 14px;
81
+ font-weight: 500;
82
+ border: none;
83
+ border-radius: 8px;
84
+ cursor: pointer;
85
+ transition: all 0.2s;
86
+ font-family: inherit;
87
+ }
88
+
89
+ .btn--primary {
90
+ background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
91
+ color: white;
92
+ }
93
+
94
+ .btn--primary:hover {
95
+ transform: translateY(-1px);
96
+ box-shadow: 0 4px 12px rgba(245, 158, 11, 0.4);
97
+ }
98
+
99
+ .btn svg {
100
+ width: 18px;
101
+ height: 18px;
102
+ }
103
+
104
+ /* 主内容区 */
105
+ .main {
106
+ flex: 1;
107
+ overflow: hidden;
108
+ }
109
+
110
+ #viewer {
111
+ width: 100%;
112
+ height: 100%;
113
+ }
114
+
115
+ /* 加载状态 */
116
+ .loading {
117
+ display: flex;
118
+ flex-direction: column;
119
+ align-items: center;
120
+ justify-content: center;
121
+ height: 100%;
122
+ color: #64748b;
123
+ gap: 16px;
124
+ }
125
+
126
+ .loading__spinner {
127
+ width: 40px;
128
+ height: 40px;
129
+ border: 3px solid #334155;
130
+ border-top-color: #f59e0b;
131
+ border-radius: 50%;
132
+ animation: spin 0.8s linear infinite;
133
+ }
134
+
135
+ @keyframes spin {
136
+ to { transform: rotate(360deg); }
137
+ }
138
+
139
+ /* 文件输入 */
140
+ .file-input {
141
+ display: none;
142
+ }
143
+
144
+ /* 底部状态栏 */
145
+ .status-bar {
146
+ display: flex;
147
+ align-items: center;
148
+ justify-content: space-between;
149
+ padding: 8px 24px;
150
+ background: #1e293b;
151
+ border-top: 1px solid #334155;
152
+ font-size: 12px;
153
+ color: #64748b;
154
+ }
155
+
156
+ .status-bar__item {
157
+ display: flex;
158
+ align-items: center;
159
+ gap: 6px;
160
+ }
161
+
162
+ .status-bar__dot {
163
+ width: 8px;
164
+ height: 8px;
165
+ border-radius: 50%;
166
+ background: #22c55e;
167
+ }
168
+ </style>
169
+ </head>
170
+ <body>
171
+ <div class="app">
172
+ <header class="toolbar">
173
+ <div class="toolbar__brand">
174
+ <div class="toolbar__logo">D</div>
175
+ <div>
176
+ <h1 class="toolbar__title">DOCX Render</h1>
177
+ <p class="toolbar__subtitle">自研渲染引擎 · 支持评论显示与编辑</p>
178
+ </div>
179
+ </div>
180
+ <div class="toolbar__actions">
181
+ <input type="file" id="file-input" class="file-input" accept=".docx">
182
+ <button class="btn btn--primary" onclick="document.getElementById('file-input').click()">
183
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
184
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
185
+ <polyline points="17,8 12,3 7,8"/>
186
+ <line x1="12" y1="3" x2="12" y2="15"/>
187
+ </svg>
188
+ 打开其他文件
189
+ </button>
190
+ </div>
191
+ </header>
192
+
193
+ <main class="main">
194
+ <div id="viewer">
195
+ <div class="loading" id="loading">
196
+ <div class="loading__spinner"></div>
197
+ <span>正在加载文档...</span>
198
+ </div>
199
+ </div>
200
+ </main>
201
+
202
+ <footer class="status-bar">
203
+ <div class="status-bar__item">
204
+ <span class="status-bar__dot"></span>
205
+ <span id="status-text">就绪</span>
206
+ </div>
207
+ <div class="status-bar__item">
208
+ <span id="comment-count">评论: 0</span>
209
+ </div>
210
+ </footer>
211
+ </div>
212
+
213
+ <script type="module">
214
+ import { DocxRender } from './src/index.ts'
215
+
216
+ let docxRender = null
217
+
218
+ const viewer = document.getElementById('viewer')
219
+ const loading = document.getElementById('loading')
220
+ const fileInput = document.getElementById('file-input')
221
+ const statusText = document.getElementById('status-text')
222
+ const commentCount = document.getElementById('comment-count')
223
+
224
+ // 更新状态
225
+ function updateStatus(text) {
226
+ statusText.textContent = text
227
+ }
228
+
229
+ // 更新评论计数
230
+ function updateCommentCount(count) {
231
+ commentCount.textContent = `评论: ${count}`
232
+ }
233
+
234
+ // 加载文件
235
+ async function loadFile(file) {
236
+ loading.style.display = 'flex'
237
+ updateStatus('正在加载...')
238
+
239
+ try {
240
+ // 清空之前的内容
241
+ viewer.innerHTML = ''
242
+ viewer.appendChild(loading)
243
+
244
+ // 创建渲染器
245
+ docxRender = new DocxRender({
246
+ container: viewer,
247
+ renderComments: true,
248
+ enableCommentEdit: true,
249
+ showCommentLines: true,
250
+ onCommentClick: (comment) => {
251
+ console.log('点击评论:', comment)
252
+ },
253
+ onCommentChange: (comment, action) => {
254
+ console.log('评论变更:', action, comment)
255
+ updateCommentCount(docxRender.getComments().length)
256
+ }
257
+ })
258
+
259
+ await docxRender.render(file)
260
+
261
+ const comments = docxRender.getComments()
262
+ updateCommentCount(comments.length)
263
+ updateStatus(`已加载: ${file.name || 'contract.docx'}`)
264
+
265
+ console.log('文档加载完成,评论数:', comments.length)
266
+ } catch (error) {
267
+ console.error('加载失败:', error)
268
+ updateStatus('加载失败')
269
+ viewer.innerHTML = `
270
+ <div class="loading">
271
+ <span style="color: #ef4444;">加载失败: ${error.message}</span>
272
+ </div>
273
+ `
274
+ }
275
+ }
276
+
277
+ // 文件选择
278
+ fileInput.addEventListener('change', (e) => {
279
+ const file = e.target.files[0]
280
+ if (file) {
281
+ loadFile(file)
282
+ }
283
+ })
284
+
285
+ // 自动加载 contract.docx
286
+ async function loadDefaultFile() {
287
+ try {
288
+ const response = await fetch('./contract.docx')
289
+ if (response.ok) {
290
+ const blob = await response.blob()
291
+ const file = new File([blob], 'contract.docx', {
292
+ type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
293
+ })
294
+ await loadFile(file)
295
+ } else {
296
+ loading.innerHTML = `
297
+ <span>请点击上方按钮选择 .docx 文件</span>
298
+ `
299
+ updateStatus('等待文件')
300
+ }
301
+ } catch (error) {
302
+ loading.innerHTML = `
303
+ <span>请点击上方按钮选择 .docx 文件</span>
304
+ `
305
+ updateStatus('等待文件')
306
+ }
307
+ }
308
+
309
+ loadDefaultFile()
310
+ </script>
311
+ </body>
312
+ </html>
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@courtifyai/docx-render",
3
+ "version": "1.0.0",
4
+ "description": " DOCX render library",
5
+ "main": "dist/index.js",
6
+ "module": "dist/index.esm.js",
7
+ "types": "dist/index.d.ts",
8
+ "scripts": {
9
+ "dev": "vite",
10
+ "build": "tsc && vite build",
11
+ "preview": "vite preview",
12
+ "type-check": "tsc --noEmit"
13
+ },
14
+ "keywords": [
15
+ "docx",
16
+ "word",
17
+ "document",
18
+ "preview",
19
+ "comments",
20
+ "render"
21
+ ],
22
+ "author": "",
23
+ "license": "MIT",
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
27
+ "dependencies": {
28
+ "adm-zip": "^0.5.16",
29
+ "jszip": "^3.10.1"
30
+ },
31
+ "devDependencies": {
32
+ "@types/node": "^20.10.0",
33
+ "typescript": "^5.3.0",
34
+ "vite": "^5.0.0"
35
+ }
36
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * 评论扩展解析器
3
+ * 解析 word/commentsExtended.xml,提取评论的父子关系
4
+ */
5
+
6
+ import { ICommentExtended, ICommentElement } from '../types'
7
+ import { parseXmlString } from '../parser/xml-parser'
8
+
9
+ /** Word 2015 命名空间(用于 commentsExtended) */
10
+ const W15_NS = 'http://schemas.microsoft.com/office/word/2012/wordml'
11
+
12
+ /**
13
+ * 解析 commentsExtended.xml 内容
14
+ * @param xmlContent XML 字符串
15
+ * @returns 扩展评论 Map(paraId -> ICommentExtended)
16
+ */
17
+ export function parseCommentsExtended(xmlContent: string): Map<string, ICommentExtended> {
18
+ const result = new Map<string, ICommentExtended>()
19
+
20
+ if (!xmlContent) {
21
+ return result
22
+ }
23
+
24
+ try {
25
+ const doc = parseXmlString(xmlContent)
26
+ const root = doc.documentElement
27
+
28
+ // 查找所有 commentEx 元素
29
+ // 可能在 w15 命名空间下
30
+ const commentExElements = root.getElementsByTagNameNS(W15_NS, 'commentEx')
31
+
32
+ // 如果找不到,尝试不带命名空间查找
33
+ const elementsToProcess = commentExElements.length > 0
34
+ ? Array.from(commentExElements)
35
+ : Array.from(root.getElementsByTagName('commentEx'))
36
+
37
+ for (const el of elementsToProcess) {
38
+ const paraId = getAttr(el, 'paraId')
39
+ if (!paraId) continue
40
+
41
+ const extended: ICommentExtended = {
42
+ paraId,
43
+ paraIdParent: getAttr(el, 'paraIdParent'),
44
+ done: getBoolAttr(el, 'done'),
45
+ }
46
+
47
+ result.set(paraId, extended)
48
+ }
49
+
50
+ console.log('[DEBUG] parseCommentsExtended: found', result.size, 'extended comments')
51
+ } catch (e) {
52
+ console.warn('解析 commentsExtended.xml 失败:', e)
53
+ }
54
+
55
+ return result
56
+ }
57
+
58
+ /**
59
+ * 构建评论树结构(回复链)
60
+ * @param comments 所有评论列表
61
+ * @param extendedMap 扩展评论映射(paraId -> ICommentExtended)
62
+ * @returns 顶级评论列表(回复嵌套在 replies 中)
63
+ */
64
+ export function buildCommentTree(
65
+ comments: ICommentElement[],
66
+ extendedMap: Map<string, ICommentExtended>
67
+ ): ICommentElement[] {
68
+ // 构建 paraId -> comment 映射
69
+ const paraIdToComment = new Map<string, ICommentElement>()
70
+
71
+ for (const comment of comments) {
72
+ if (comment.paraId) {
73
+ paraIdToComment.set(comment.paraId, comment)
74
+ }
75
+ }
76
+
77
+ // 初始化所有评论的 replies 数组
78
+ for (const comment of comments) {
79
+ comment.replies = []
80
+ }
81
+
82
+ // 关联扩展信息并建立父子关系
83
+ for (const [paraId, extended] of extendedMap) {
84
+ const comment = paraIdToComment.get(paraId)
85
+ if (!comment) continue
86
+
87
+ // 设置完成状态
88
+ comment.done = extended.done
89
+
90
+ // 如果有父段落,建立父子关系
91
+ if (extended.paraIdParent) {
92
+ const parentComment = paraIdToComment.get(extended.paraIdParent)
93
+ if (parentComment) {
94
+ comment.parentId = parentComment.id
95
+ parentComment.replies!.push(comment)
96
+ }
97
+ }
98
+ }
99
+
100
+ // 筛选顶级评论(没有 parentId 的)
101
+ const rootComments = comments.filter(c => !c.parentId)
102
+
103
+ // 按日期排序(旧的在前)
104
+ rootComments.sort((a, b) => {
105
+ const dateA = new Date(a.date).getTime()
106
+ const dateB = new Date(b.date).getTime()
107
+ return dateA - dateB
108
+ })
109
+
110
+ // 递归排序回复
111
+ sortReplies(rootComments)
112
+
113
+ console.log('[DEBUG] buildCommentTree: root comments:', rootComments.length,
114
+ 'total comments:', comments.length)
115
+
116
+ return rootComments
117
+ }
118
+
119
+ /**
120
+ * 递归排序回复(按日期)
121
+ */
122
+ function sortReplies(comments: ICommentElement[]): void {
123
+ for (const comment of comments) {
124
+ if (comment.replies && comment.replies.length > 0) {
125
+ comment.replies.sort((a, b) => {
126
+ const dateA = new Date(a.date).getTime()
127
+ const dateB = new Date(b.date).getTime()
128
+ return dateA - dateB
129
+ })
130
+ sortReplies(comment.replies)
131
+ }
132
+ }
133
+ }
134
+
135
+ /**
136
+ * 获取元素属性(支持 w15 命名空间)
137
+ */
138
+ function getAttr(el: Element, name: string): string | undefined {
139
+ // 先尝试 w15 命名空间
140
+ let value = el.getAttributeNS(W15_NS, name)
141
+ if (value) return value
142
+
143
+ // 再尝试无命名空间
144
+ value = el.getAttribute(name)
145
+ if (value) return value
146
+
147
+ // 尝试 w: 前缀
148
+ value = el.getAttribute(`w15:${name}`)
149
+ return value || undefined
150
+ }
151
+
152
+ /**
153
+ * 获取布尔属性
154
+ */
155
+ function getBoolAttr(el: Element, name: string): boolean {
156
+ const value = getAttr(el, name)
157
+ if (!value) return false
158
+ return value === '1' || value === 'true'
159
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * 评论模块
3
+ * 处理评论解析和回复链构建
4
+ */
5
+
6
+ export { parseCommentsExtended, buildCommentTree } from './comments-parser'