@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 +168 -0
- package/debug-comments.cjs +19 -0
- package/index.html +312 -0
- package/package.json +36 -0
- package/src/comments/comments-parser.ts +159 -0
- package/src/comments/index.ts +6 -0
- package/src/font-table/font-loader.ts +379 -0
- package/src/font-table/font-parser.ts +258 -0
- package/src/font-table/index.ts +22 -0
- package/src/index.ts +137 -0
- package/src/parser/document-parser.ts +1606 -0
- package/src/parser/index.ts +3 -0
- package/src/parser/xml-parser.ts +152 -0
- package/src/renderer/document-renderer.ts +2163 -0
- package/src/renderer/index.ts +1 -0
- package/src/styles/index.css +692 -0
- package/src/theme/index.ts +8 -0
- package/src/theme/theme-parser.ts +172 -0
- package/src/theme/theme-utils.ts +148 -0
- package/src/types/index.ts +847 -0
- package/tsconfig.json +27 -0
- package/vite.config.ts +26 -0
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
|
+
}
|