@courtifyai/docx-render 1.0.0 → 1.0.1
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/dist/index.es.js +2915 -0
- package/dist/index.umd.js +27 -0
- package/dist/style.css +1 -0
- package/package.json +5 -1
- package/debug-comments.cjs +0 -19
- package/index.html +0 -312
- package/src/comments/comments-parser.ts +0 -159
- package/src/comments/index.ts +0 -6
- package/src/font-table/font-loader.ts +0 -379
- package/src/font-table/font-parser.ts +0 -258
- package/src/font-table/index.ts +0 -22
- package/src/index.ts +0 -137
- package/src/parser/document-parser.ts +0 -1606
- package/src/parser/index.ts +0 -3
- package/src/parser/xml-parser.ts +0 -152
- package/src/renderer/document-renderer.ts +0 -2163
- package/src/renderer/index.ts +0 -1
- package/src/styles/index.css +0 -692
- package/src/theme/index.ts +0 -8
- package/src/theme/theme-parser.ts +0 -172
- package/src/theme/theme-utils.ts +0 -148
- package/src/types/index.ts +0 -847
- package/tsconfig.json +0 -27
- package/vite.config.ts +0 -26
|
@@ -1,2163 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
DomType,
|
|
3
|
-
IDocxDocument,
|
|
4
|
-
IDocumentElement,
|
|
5
|
-
IOpenXmlElement,
|
|
6
|
-
IParagraphElement,
|
|
7
|
-
IRunElement,
|
|
8
|
-
ITextElement,
|
|
9
|
-
IBreakElement,
|
|
10
|
-
ITableElement,
|
|
11
|
-
ITableRowElement,
|
|
12
|
-
ITableCellElement,
|
|
13
|
-
ITableProperties,
|
|
14
|
-
ITableCellProperties,
|
|
15
|
-
ICommentElement,
|
|
16
|
-
ICommentRangeStart,
|
|
17
|
-
ICommentRangeEnd,
|
|
18
|
-
ICommentReference,
|
|
19
|
-
IHyperlinkElement,
|
|
20
|
-
IImageElement,
|
|
21
|
-
IDrawingElement,
|
|
22
|
-
IRendererOptions,
|
|
23
|
-
IParagraphProperties,
|
|
24
|
-
IRunProperties,
|
|
25
|
-
ISectionProperties,
|
|
26
|
-
IHeaderElement,
|
|
27
|
-
IFooterElement,
|
|
28
|
-
ITabElement,
|
|
29
|
-
ISymbolElement,
|
|
30
|
-
ISimpleFieldElement,
|
|
31
|
-
IComplexFieldElement,
|
|
32
|
-
IFieldInstructionElement,
|
|
33
|
-
INumberingDefinition,
|
|
34
|
-
INumberingLevel,
|
|
35
|
-
SectionType,
|
|
36
|
-
IFootnoteReference,
|
|
37
|
-
IEndnoteReference,
|
|
38
|
-
IFootnoteElement,
|
|
39
|
-
IEndnoteElement,
|
|
40
|
-
IBookmarkStartElement,
|
|
41
|
-
IBookmarkEndElement,
|
|
42
|
-
TUnderlineStyle,
|
|
43
|
-
IBorder,
|
|
44
|
-
IBorders,
|
|
45
|
-
} from '../types'
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* 分页 Section 信息
|
|
49
|
-
*/
|
|
50
|
-
interface ISection {
|
|
51
|
-
sectProps: ISectionProperties | null
|
|
52
|
-
elements: IOpenXmlElement[]
|
|
53
|
-
pageBreak: boolean
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* 评论范围信息
|
|
58
|
-
*/
|
|
59
|
-
interface ICommentRangeInfo {
|
|
60
|
-
id: string
|
|
61
|
-
startElement: HTMLElement | null
|
|
62
|
-
endElement: HTMLElement | null
|
|
63
|
-
highlightElements: HTMLElement[]
|
|
64
|
-
panelElement: HTMLElement | null
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* DOCX 文档渲染器
|
|
69
|
-
* 将解析后的文档模型渲染为 HTML,评论和文档内容一体化渲染
|
|
70
|
-
*/
|
|
71
|
-
export class DocumentRenderer {
|
|
72
|
-
private document: IDocxDocument | null = null
|
|
73
|
-
private container: HTMLElement
|
|
74
|
-
private options: Required<IRendererOptions>
|
|
75
|
-
private classPrefix: string
|
|
76
|
-
|
|
77
|
-
// 评论相关状态
|
|
78
|
-
private commentRanges: Map<string, ICommentRangeInfo> = new Map()
|
|
79
|
-
private activeCommentId: string | null = null
|
|
80
|
-
private svgLayer: SVGSVGElement | null = null
|
|
81
|
-
private currentCommentIds: Set<string> = new Set() // 当前正在渲染的评论范围
|
|
82
|
-
private commentStartInParagraph: Set<string> = new Set() // 在当前段落开始的评论
|
|
83
|
-
|
|
84
|
-
// 页码相关状态
|
|
85
|
-
private currentPageNumber = 1
|
|
86
|
-
private totalPages = 1
|
|
87
|
-
private inComplexField = false // 是否在复杂域中
|
|
88
|
-
private currentFieldInstruction = '' // 当前域指令
|
|
89
|
-
private skipFieldContent = false // 是否跳过域的静态内容(separate 后)
|
|
90
|
-
|
|
91
|
-
// 编号/列表相关状态
|
|
92
|
-
// 编号计数器:key 格式为 "numId-level",value 为当前计数
|
|
93
|
-
private numberingCounters: Map<string, number> = new Map()
|
|
94
|
-
|
|
95
|
-
// 脚注/尾注相关状态
|
|
96
|
-
private currentFootnoteIds: string[] = [] // 当前页面引用的脚注 ID
|
|
97
|
-
private currentEndnoteIds: string[] = [] // 文档中引用的尾注 ID
|
|
98
|
-
private footnoteCounter = 0 // 脚注编号计数器
|
|
99
|
-
private endnoteCounter = 0 // 尾注编号计数器
|
|
100
|
-
|
|
101
|
-
constructor(options: IRendererOptions) {
|
|
102
|
-
const containerEl = typeof options.container === 'string'
|
|
103
|
-
? document.querySelector(options.container)
|
|
104
|
-
: options.container
|
|
105
|
-
|
|
106
|
-
if (!containerEl) {
|
|
107
|
-
throw new Error('容器元素不存在')
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
this.container = containerEl as HTMLElement
|
|
111
|
-
this.classPrefix = options.classNamePrefix || 'docx'
|
|
112
|
-
|
|
113
|
-
this.options = {
|
|
114
|
-
container: this.container,
|
|
115
|
-
renderComments: options.renderComments ?? true,
|
|
116
|
-
enableCommentEdit: options.enableCommentEdit ?? true,
|
|
117
|
-
showCommentLines: options.showCommentLines ?? true,
|
|
118
|
-
breakPages: options.breakPages ?? true,
|
|
119
|
-
classNamePrefix: this.classPrefix,
|
|
120
|
-
onCommentClick: options.onCommentClick || (() => {}),
|
|
121
|
-
onCommentChange: options.onCommentChange || (() => {}),
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/**
|
|
126
|
-
* 渲染文档
|
|
127
|
-
*/
|
|
128
|
-
render(document: IDocxDocument): void {
|
|
129
|
-
this.document = document
|
|
130
|
-
this.commentRanges.clear()
|
|
131
|
-
this.currentCommentIds.clear()
|
|
132
|
-
this.numberingCounters.clear()
|
|
133
|
-
this.currentFootnoteIds = []
|
|
134
|
-
this.currentEndnoteIds = []
|
|
135
|
-
this.footnoteCounter = 0
|
|
136
|
-
this.endnoteCounter = 0
|
|
137
|
-
|
|
138
|
-
// 初始化评论范围信息
|
|
139
|
-
for (const comment of document.comments) {
|
|
140
|
-
this.commentRanges.set(comment.id, {
|
|
141
|
-
id: comment.id,
|
|
142
|
-
startElement: null,
|
|
143
|
-
endElement: null,
|
|
144
|
-
highlightElements: [],
|
|
145
|
-
panelElement: null,
|
|
146
|
-
})
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
// 清空容器
|
|
150
|
-
this.container.innerHTML = ''
|
|
151
|
-
this.container.className = `${this.classPrefix}-container`
|
|
152
|
-
|
|
153
|
-
// 创建主布局
|
|
154
|
-
const wrapper = this.createElement('div', `${this.classPrefix}-wrapper`)
|
|
155
|
-
|
|
156
|
-
// 判断是否启用分页
|
|
157
|
-
if (this.options.breakPages) {
|
|
158
|
-
// 分页渲染模式
|
|
159
|
-
this.renderWithPages(wrapper, document)
|
|
160
|
-
} else {
|
|
161
|
-
// 单页渲染模式(旧逻辑)
|
|
162
|
-
this.renderSinglePage(wrapper, document)
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
this.container.appendChild(wrapper)
|
|
166
|
-
|
|
167
|
-
// 创建 SVG 连线层(fixed 定位)
|
|
168
|
-
if (this.options.showCommentLines) {
|
|
169
|
-
this.svgLayer = this.createSvgLayer()
|
|
170
|
-
this.container.appendChild(this.svgLayer)
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
// 渲染评论气泡(fixed 定位在右侧)
|
|
174
|
-
if (this.options.renderComments) {
|
|
175
|
-
this.renderAllCommentBubbles()
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
// 使用 requestAnimationFrame 优化滚动更新
|
|
179
|
-
let ticking = false
|
|
180
|
-
const updateAll = () => {
|
|
181
|
-
if (!ticking) {
|
|
182
|
-
requestAnimationFrame(() => {
|
|
183
|
-
this.positionCommentBubbles()
|
|
184
|
-
this.updateLines()
|
|
185
|
-
ticking = false
|
|
186
|
-
})
|
|
187
|
-
ticking = true
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
// 滚动时立即更新(不等待 RAF)
|
|
192
|
-
wrapper.addEventListener('scroll', () => {
|
|
193
|
-
this.positionCommentBubbles()
|
|
194
|
-
this.updateLines()
|
|
195
|
-
}, { passive: true })
|
|
196
|
-
|
|
197
|
-
window.addEventListener('resize', updateAll)
|
|
198
|
-
|
|
199
|
-
// 初始定位和绘制连线
|
|
200
|
-
requestAnimationFrame(() => {
|
|
201
|
-
this.positionCommentBubbles()
|
|
202
|
-
this.updateLines()
|
|
203
|
-
})
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
/**
|
|
207
|
-
* 单页渲染模式
|
|
208
|
-
*/
|
|
209
|
-
private renderSinglePage(wrapper: HTMLElement, document: IDocxDocument): void {
|
|
210
|
-
const documentArea = this.createElement('div', `${this.classPrefix}-document`)
|
|
211
|
-
const page = this.createElement('div', `${this.classPrefix}-page`)
|
|
212
|
-
|
|
213
|
-
// 渲染文档内容
|
|
214
|
-
const content = this.renderElement(document.body)
|
|
215
|
-
if (content) {
|
|
216
|
-
page.appendChild(content)
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
// 渲染脚注
|
|
220
|
-
this.renderPageFootnotes(this.currentFootnoteIds, page)
|
|
221
|
-
|
|
222
|
-
documentArea.appendChild(page)
|
|
223
|
-
wrapper.appendChild(documentArea)
|
|
224
|
-
|
|
225
|
-
// 渲染尾注(在文档末尾)
|
|
226
|
-
this.renderDocumentEndnotes(wrapper)
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
/**
|
|
230
|
-
* 分页渲染模式
|
|
231
|
-
*/
|
|
232
|
-
private renderWithPages(wrapper: HTMLElement, document: IDocxDocument): void {
|
|
233
|
-
const body = document.body
|
|
234
|
-
const defaultSectionProps = body.sectionProps
|
|
235
|
-
|
|
236
|
-
// 分割内容为多个 Section
|
|
237
|
-
const sections = this.splitBySection(body.children || [], defaultSectionProps)
|
|
238
|
-
|
|
239
|
-
// 将 Section 按页面分组
|
|
240
|
-
const pages = this.groupByPageBreaks(sections)
|
|
241
|
-
|
|
242
|
-
// 设置总页数
|
|
243
|
-
this.totalPages = pages.length
|
|
244
|
-
|
|
245
|
-
let prevSectionProps: ISectionProperties | null = null
|
|
246
|
-
|
|
247
|
-
// 渲染每一页
|
|
248
|
-
for (let i = 0; i < pages.length; i++) {
|
|
249
|
-
// 设置当前页码(从 1 开始)
|
|
250
|
-
this.currentPageNumber = i + 1
|
|
251
|
-
|
|
252
|
-
// 记录本页开始时的脚注计数
|
|
253
|
-
const pageFootnoteStartIndex = this.currentFootnoteIds.length
|
|
254
|
-
|
|
255
|
-
const pageSections = pages[i]
|
|
256
|
-
if (pageSections.length === 0) continue
|
|
257
|
-
|
|
258
|
-
const firstSection = pageSections[0]
|
|
259
|
-
let sectionProps = firstSection.sectProps || defaultSectionProps
|
|
260
|
-
|
|
261
|
-
// 创建页面元素
|
|
262
|
-
const pageElement = this.createPageElement(sectionProps)
|
|
263
|
-
|
|
264
|
-
// 渲染页眉
|
|
265
|
-
if (sectionProps?.headerRefs) {
|
|
266
|
-
this.renderHeaderFooter(
|
|
267
|
-
sectionProps.headerRefs,
|
|
268
|
-
sectionProps,
|
|
269
|
-
i,
|
|
270
|
-
prevSectionProps !== sectionProps,
|
|
271
|
-
pageElement,
|
|
272
|
-
'header'
|
|
273
|
-
)
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
// 渲染每个 Section 的内容
|
|
277
|
-
for (const section of pageSections) {
|
|
278
|
-
const contentElement = this.createSectionContent(section.sectProps)
|
|
279
|
-
|
|
280
|
-
for (const element of section.elements) {
|
|
281
|
-
const rendered = this.renderElement(element)
|
|
282
|
-
if (rendered) {
|
|
283
|
-
contentElement.appendChild(rendered)
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
pageElement.appendChild(contentElement)
|
|
288
|
-
sectionProps = section.sectProps || sectionProps
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
// 渲染本页脚注
|
|
292
|
-
const pageFootnoteIds = this.currentFootnoteIds.slice(pageFootnoteStartIndex)
|
|
293
|
-
this.renderPageFootnotes(pageFootnoteIds, pageElement)
|
|
294
|
-
|
|
295
|
-
// 渲染页脚
|
|
296
|
-
if (sectionProps?.footerRefs) {
|
|
297
|
-
this.renderHeaderFooter(
|
|
298
|
-
sectionProps.footerRefs,
|
|
299
|
-
sectionProps,
|
|
300
|
-
i,
|
|
301
|
-
prevSectionProps !== sectionProps,
|
|
302
|
-
pageElement,
|
|
303
|
-
'footer'
|
|
304
|
-
)
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
wrapper.appendChild(pageElement)
|
|
308
|
-
prevSectionProps = sectionProps
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
// 渲染尾注(在文档末尾)
|
|
312
|
-
this.renderDocumentEndnotes(wrapper)
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
/**
|
|
316
|
-
* 按 Section 分割内容
|
|
317
|
-
*/
|
|
318
|
-
private splitBySection(elements: IOpenXmlElement[], defaultProps?: ISectionProperties): ISection[] {
|
|
319
|
-
const result: ISection[] = []
|
|
320
|
-
let current: ISection = { sectProps: null, elements: [], pageBreak: false }
|
|
321
|
-
result.push(current)
|
|
322
|
-
|
|
323
|
-
for (const elem of elements) {
|
|
324
|
-
// 检查 pageBreakBefore 样式
|
|
325
|
-
if (elem.type === DomType.Paragraph) {
|
|
326
|
-
const p = elem as IParagraphElement
|
|
327
|
-
if (p.props?.pageBreakBefore) {
|
|
328
|
-
// 在此段落前分页
|
|
329
|
-
current.pageBreak = true
|
|
330
|
-
current = { sectProps: null, elements: [], pageBreak: false }
|
|
331
|
-
result.push(current)
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
current.elements.push(elem)
|
|
336
|
-
|
|
337
|
-
// 检查段落中的分节符和分页符
|
|
338
|
-
if (elem.type === DomType.Paragraph) {
|
|
339
|
-
const p = elem as IParagraphElement
|
|
340
|
-
const sectProps = p.props?.sectionProps
|
|
341
|
-
|
|
342
|
-
// 检查段落内是否有分页符
|
|
343
|
-
let hasPageBreak = false
|
|
344
|
-
const checkForPageBreak = (children: IOpenXmlElement[]) => {
|
|
345
|
-
for (const child of children) {
|
|
346
|
-
if (child.type === DomType.Break) {
|
|
347
|
-
const br = child as IBreakElement
|
|
348
|
-
if (br.breakType === 'page' || br.breakType === 'lastRenderedPageBreak') {
|
|
349
|
-
hasPageBreak = true
|
|
350
|
-
return
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
if (child.type === DomType.Run && (child as IRunElement).children) {
|
|
354
|
-
checkForPageBreak((child as IRunElement).children)
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
if (this.options.breakPages && p.children) {
|
|
360
|
-
checkForPageBreak(p.children)
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
// 如果有分节符或分页符,创建新的 Section
|
|
364
|
-
if (sectProps || hasPageBreak) {
|
|
365
|
-
current.sectProps = sectProps || null
|
|
366
|
-
current.pageBreak = hasPageBreak
|
|
367
|
-
current = { sectProps: null, elements: [], pageBreak: false }
|
|
368
|
-
result.push(current)
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
// 反向传播 Section 属性
|
|
374
|
-
let currentSectProps: ISectionProperties | null = null
|
|
375
|
-
for (let i = result.length - 1; i >= 0; i--) {
|
|
376
|
-
if (result[i].sectProps === null) {
|
|
377
|
-
result[i].sectProps = currentSectProps || defaultProps || null
|
|
378
|
-
} else {
|
|
379
|
-
currentSectProps = result[i].sectProps
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
return result
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
/**
|
|
387
|
-
* 按分页符分组
|
|
388
|
-
*/
|
|
389
|
-
private groupByPageBreaks(sections: ISection[]): ISection[][] {
|
|
390
|
-
const result: ISection[][] = []
|
|
391
|
-
let current: ISection[] = []
|
|
392
|
-
result.push(current)
|
|
393
|
-
|
|
394
|
-
let prevSectProps: ISectionProperties | null = null
|
|
395
|
-
|
|
396
|
-
for (const section of sections) {
|
|
397
|
-
current.push(section)
|
|
398
|
-
|
|
399
|
-
// 检查是否需要分页
|
|
400
|
-
const needPageBreak = section.pageBreak ||
|
|
401
|
-
this.isPageBreakSection(prevSectProps, section.sectProps)
|
|
402
|
-
|
|
403
|
-
if (needPageBreak) {
|
|
404
|
-
current = []
|
|
405
|
-
result.push(current)
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
prevSectProps = section.sectProps
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
// 过滤空页面
|
|
412
|
-
return result.filter(page => page.length > 0)
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
/**
|
|
416
|
-
* 检查是否因为 Section 属性变化需要分页
|
|
417
|
-
*/
|
|
418
|
-
private isPageBreakSection(prev: ISectionProperties | null, next: ISectionProperties | null): boolean {
|
|
419
|
-
if (!prev || !next) return false
|
|
420
|
-
|
|
421
|
-
// 页面尺寸或方向改变时分页
|
|
422
|
-
const prevSize = prev.pageSize
|
|
423
|
-
const nextSize = next.pageSize
|
|
424
|
-
|
|
425
|
-
if (!prevSize || !nextSize) return false
|
|
426
|
-
|
|
427
|
-
return prevSize.orientation !== nextSize.orientation ||
|
|
428
|
-
prevSize.width !== nextSize.width ||
|
|
429
|
-
prevSize.height !== nextSize.height
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
/**
|
|
433
|
-
* 创建页面元素
|
|
434
|
-
*/
|
|
435
|
-
private createPageElement(sectionProps?: ISectionProperties | null): HTMLElement {
|
|
436
|
-
const page = this.createElement('section', `${this.classPrefix}-page`)
|
|
437
|
-
|
|
438
|
-
if (sectionProps) {
|
|
439
|
-
// 应用页面尺寸
|
|
440
|
-
if (sectionProps.pageSize) {
|
|
441
|
-
if (sectionProps.pageSize.width) {
|
|
442
|
-
page.style.width = sectionProps.pageSize.width
|
|
443
|
-
}
|
|
444
|
-
if (sectionProps.pageSize.height) {
|
|
445
|
-
page.style.minHeight = sectionProps.pageSize.height
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
// 应用页边距
|
|
450
|
-
if (sectionProps.pageMargins) {
|
|
451
|
-
const margins = sectionProps.pageMargins
|
|
452
|
-
if (margins.top) page.style.paddingTop = margins.top
|
|
453
|
-
if (margins.right) page.style.paddingRight = margins.right
|
|
454
|
-
if (margins.bottom) page.style.paddingBottom = margins.bottom
|
|
455
|
-
if (margins.left) page.style.paddingLeft = margins.left
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
return page
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
/**
|
|
463
|
-
* 创建 Section 内容区域
|
|
464
|
-
*/
|
|
465
|
-
private createSectionContent(sectionProps?: ISectionProperties | null): HTMLElement {
|
|
466
|
-
const content = this.createElement('article', `${this.classPrefix}-section-content`)
|
|
467
|
-
|
|
468
|
-
if (sectionProps?.columns) {
|
|
469
|
-
const cols = sectionProps.columns
|
|
470
|
-
if (cols.numberOfColumns && cols.numberOfColumns > 1) {
|
|
471
|
-
content.style.columnCount = String(cols.numberOfColumns)
|
|
472
|
-
if (cols.space) {
|
|
473
|
-
content.style.columnGap = cols.space
|
|
474
|
-
}
|
|
475
|
-
if (cols.separator) {
|
|
476
|
-
content.style.columnRule = '1px solid #ccc'
|
|
477
|
-
}
|
|
478
|
-
}
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
return content
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
/**
|
|
485
|
-
* 渲染页眉或页脚
|
|
486
|
-
*/
|
|
487
|
-
private renderHeaderFooter(
|
|
488
|
-
refs: { id: string; type: 'default' | 'first' | 'even' }[],
|
|
489
|
-
sectionProps: ISectionProperties,
|
|
490
|
-
pageIndex: number,
|
|
491
|
-
isFirstOfSection: boolean,
|
|
492
|
-
pageElement: HTMLElement,
|
|
493
|
-
position: 'header' | 'footer'
|
|
494
|
-
): void {
|
|
495
|
-
if (!refs || refs.length === 0) return
|
|
496
|
-
|
|
497
|
-
// 选择合适的页眉/页脚
|
|
498
|
-
let ref = null
|
|
499
|
-
|
|
500
|
-
// 首页特殊处理
|
|
501
|
-
if (sectionProps.titlePage && isFirstOfSection) {
|
|
502
|
-
ref = refs.find(r => r.type === 'first')
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
// 奇偶页处理
|
|
506
|
-
if (!ref) {
|
|
507
|
-
if (pageIndex % 2 === 1) {
|
|
508
|
-
ref = refs.find(r => r.type === 'even')
|
|
509
|
-
}
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
// 默认页眉/页脚
|
|
513
|
-
if (!ref) {
|
|
514
|
-
ref = refs.find(r => r.type === 'default')
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
if (!ref) return
|
|
518
|
-
|
|
519
|
-
// 获取页眉/页脚内容
|
|
520
|
-
const map = position === 'header' ? this.document?.headers : this.document?.footers
|
|
521
|
-
const content = map?.get(ref.id)
|
|
522
|
-
|
|
523
|
-
if (!content) return
|
|
524
|
-
|
|
525
|
-
// 渲染
|
|
526
|
-
const element = this.createElement('div', `${this.classPrefix}-${position}`)
|
|
527
|
-
|
|
528
|
-
for (const child of content.children || []) {
|
|
529
|
-
const rendered = this.renderElement(child)
|
|
530
|
-
if (rendered) {
|
|
531
|
-
element.appendChild(rendered)
|
|
532
|
-
}
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
// 应用边距
|
|
536
|
-
if (sectionProps.pageMargins) {
|
|
537
|
-
const margins = sectionProps.pageMargins
|
|
538
|
-
if (position === 'header' && margins.header && margins.top) {
|
|
539
|
-
element.style.marginTop = `calc(${margins.header} - ${margins.top})`
|
|
540
|
-
element.style.minHeight = `calc(${margins.top} - ${margins.header})`
|
|
541
|
-
} else if (position === 'footer' && margins.footer && margins.bottom) {
|
|
542
|
-
element.style.marginBottom = `calc(${margins.footer} - ${margins.bottom})`
|
|
543
|
-
element.style.minHeight = `calc(${margins.bottom} - ${margins.footer})`
|
|
544
|
-
}
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
if (position === 'header') {
|
|
548
|
-
pageElement.insertBefore(element, pageElement.firstChild)
|
|
549
|
-
} else {
|
|
550
|
-
pageElement.appendChild(element)
|
|
551
|
-
}
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
/**
|
|
555
|
-
* 渲染所有评论气泡(右侧固定面板)
|
|
556
|
-
*/
|
|
557
|
-
private renderAllCommentBubbles(): void {
|
|
558
|
-
// 创建评论面板(fixed 定位在视口右侧)
|
|
559
|
-
const commentsLayer = this.createElement('div', `${this.classPrefix}-comments-layer`)
|
|
560
|
-
this.container.appendChild(commentsLayer)
|
|
561
|
-
|
|
562
|
-
// 调试:打印所有评论
|
|
563
|
-
const comments = this.document?.comments || []
|
|
564
|
-
console.log('[DEBUG] renderAllCommentBubbles - Total comments:', comments.length)
|
|
565
|
-
comments.forEach(c => {
|
|
566
|
-
console.log('[DEBUG] Comment:', {
|
|
567
|
-
id: c.id,
|
|
568
|
-
author: c.author,
|
|
569
|
-
date: c.date,
|
|
570
|
-
childrenCount: c.children?.length || 0,
|
|
571
|
-
text: this.getCommentText(c)
|
|
572
|
-
})
|
|
573
|
-
})
|
|
574
|
-
console.log('[DEBUG] Comment ranges count:', this.commentRanges.size)
|
|
575
|
-
|
|
576
|
-
// 为每个评论创建气泡
|
|
577
|
-
for (const comment of comments) {
|
|
578
|
-
const range = this.commentRanges.get(comment.id)
|
|
579
|
-
|
|
580
|
-
if (range && range.highlightElements.length > 0) {
|
|
581
|
-
const bubble = this.createCommentBubble(comment)
|
|
582
|
-
range.panelElement = bubble
|
|
583
|
-
commentsLayer.appendChild(bubble)
|
|
584
|
-
}
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
// 在 DOM 渲染后定位评论气泡(在分页模式下不需要绝对定位)
|
|
588
|
-
// requestAnimationFrame(() => this.positionCommentBubbles())
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
/**
|
|
592
|
-
* 定位评论气泡到对应的高亮文字旁边
|
|
593
|
-
* 原文不可见时隐藏评论
|
|
594
|
-
*/
|
|
595
|
-
private positionCommentBubbles(): void {
|
|
596
|
-
// 收集可见的评论气泡及其目标位置
|
|
597
|
-
const visibleBubbles: { range: ICommentRangeInfo; top: number }[] = []
|
|
598
|
-
|
|
599
|
-
// 获取有效的视口范围(排除顶部工具栏和底部状态栏)
|
|
600
|
-
const viewportTop = 60
|
|
601
|
-
const viewportBottom = window.innerHeight - 40
|
|
602
|
-
|
|
603
|
-
for (const [, range] of this.commentRanges) {
|
|
604
|
-
if (!range.panelElement || range.highlightElements.length === 0) continue
|
|
605
|
-
|
|
606
|
-
const highlightEl = range.highlightElements[0]
|
|
607
|
-
const rect = highlightEl.getBoundingClientRect()
|
|
608
|
-
|
|
609
|
-
// 检查高亮文字是否在有效视口内(至少中心点在视口内)
|
|
610
|
-
const centerY = rect.top + rect.height / 2
|
|
611
|
-
const isVisible = centerY > viewportTop && centerY < viewportBottom
|
|
612
|
-
|
|
613
|
-
if (isVisible) {
|
|
614
|
-
visibleBubbles.push({ range, top: rect.top })
|
|
615
|
-
range.panelElement.style.display = 'block'
|
|
616
|
-
} else {
|
|
617
|
-
range.panelElement.style.display = 'none'
|
|
618
|
-
}
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
// 排序并避免重叠
|
|
622
|
-
visibleBubbles.sort((a, b) => a.top - b.top)
|
|
623
|
-
|
|
624
|
-
let lastBottom = viewportTop
|
|
625
|
-
for (const { range, top } of visibleBubbles) {
|
|
626
|
-
let finalTop = Math.max(top, lastBottom + 8)
|
|
627
|
-
// 确保不超出视口底部
|
|
628
|
-
finalTop = Math.min(finalTop, viewportBottom - 100)
|
|
629
|
-
range.panelElement!.style.top = `${finalTop}px`
|
|
630
|
-
lastBottom = finalTop + range.panelElement!.offsetHeight
|
|
631
|
-
}
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
/**
|
|
635
|
-
* 创建 SVG 连线层
|
|
636
|
-
*/
|
|
637
|
-
private createSvgLayer(): SVGSVGElement {
|
|
638
|
-
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
|
|
639
|
-
svg.classList.add(`${this.classPrefix}-lines`)
|
|
640
|
-
// 样式由 CSS 控制,不设置内联样式
|
|
641
|
-
return svg
|
|
642
|
-
}
|
|
643
|
-
|
|
644
|
-
/**
|
|
645
|
-
* 更新所有连线
|
|
646
|
-
*/
|
|
647
|
-
private updateLines(): void {
|
|
648
|
-
if (!this.svgLayer) return
|
|
649
|
-
|
|
650
|
-
this.svgLayer.innerHTML = ''
|
|
651
|
-
|
|
652
|
-
// 绘制所有可见评论的连线
|
|
653
|
-
for (const [id] of this.commentRanges) {
|
|
654
|
-
const isActive = id === this.activeCommentId
|
|
655
|
-
this.drawCommentLine(id, isActive)
|
|
656
|
-
}
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
/**
|
|
660
|
-
* 绘制单个评论的连线
|
|
661
|
-
*/
|
|
662
|
-
private drawCommentLine(commentId: string, active: boolean): void {
|
|
663
|
-
const range = this.commentRanges.get(commentId)
|
|
664
|
-
if (!range || !range.highlightElements.length || !range.panelElement || !this.svgLayer) return
|
|
665
|
-
|
|
666
|
-
// 如果评论气泡被隐藏,不绘制连线
|
|
667
|
-
if (range.panelElement.style.display === 'none') return
|
|
668
|
-
|
|
669
|
-
// 直接使用视口坐标(SVG 是 fixed 定位)
|
|
670
|
-
const highlightRect = range.highlightElements[0].getBoundingClientRect()
|
|
671
|
-
const panelRect = range.panelElement.getBoundingClientRect()
|
|
672
|
-
|
|
673
|
-
const startX = highlightRect.right
|
|
674
|
-
const startY = highlightRect.top + highlightRect.height / 2
|
|
675
|
-
const endX = panelRect.left
|
|
676
|
-
const endY = panelRect.top + 16
|
|
677
|
-
|
|
678
|
-
// 创建直线
|
|
679
|
-
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line')
|
|
680
|
-
line.setAttribute('x1', String(startX))
|
|
681
|
-
line.setAttribute('y1', String(startY))
|
|
682
|
-
line.setAttribute('x2', String(endX))
|
|
683
|
-
line.setAttribute('y2', String(endY))
|
|
684
|
-
line.setAttribute('stroke', '#ef4444') // 红色
|
|
685
|
-
line.setAttribute('stroke-width', active ? '2' : '1')
|
|
686
|
-
|
|
687
|
-
this.svgLayer.appendChild(line)
|
|
688
|
-
}
|
|
689
|
-
|
|
690
|
-
/**
|
|
691
|
-
* 渲染元素
|
|
692
|
-
*/
|
|
693
|
-
private renderElement(element: IOpenXmlElement): HTMLElement | null {
|
|
694
|
-
switch (element.type) {
|
|
695
|
-
case DomType.Document:
|
|
696
|
-
return this.renderDocument(element)
|
|
697
|
-
case DomType.Paragraph:
|
|
698
|
-
return this.renderParagraph(element as IParagraphElement)
|
|
699
|
-
case DomType.Run:
|
|
700
|
-
return this.renderRun(element as IRunElement)
|
|
701
|
-
case DomType.Text:
|
|
702
|
-
return this.renderText(element as ITextElement)
|
|
703
|
-
case DomType.Break:
|
|
704
|
-
return this.renderBreak(element as IBreakElement)
|
|
705
|
-
case DomType.Tab:
|
|
706
|
-
return this.renderTab()
|
|
707
|
-
case DomType.Symbol:
|
|
708
|
-
return this.renderSymbol(element as ISymbolElement)
|
|
709
|
-
case DomType.SimpleField:
|
|
710
|
-
return this.renderSimpleField(element as ISimpleFieldElement)
|
|
711
|
-
case DomType.ComplexField:
|
|
712
|
-
return this.renderComplexField(element as IComplexFieldElement)
|
|
713
|
-
case DomType.FieldInstruction:
|
|
714
|
-
return this.renderFieldInstruction(element as IFieldInstructionElement)
|
|
715
|
-
case DomType.Table:
|
|
716
|
-
return this.renderTable(element as ITableElement)
|
|
717
|
-
case DomType.TableRow:
|
|
718
|
-
return this.renderTableRow(element as ITableRowElement)
|
|
719
|
-
case DomType.TableCell:
|
|
720
|
-
return this.renderTableCell(element as ITableCellElement)
|
|
721
|
-
case DomType.Hyperlink:
|
|
722
|
-
return this.renderHyperlink(element as IHyperlinkElement)
|
|
723
|
-
case DomType.Drawing:
|
|
724
|
-
return this.renderDrawing(element as IDrawingElement)
|
|
725
|
-
case DomType.Image:
|
|
726
|
-
return this.renderImage(element as IImageElement)
|
|
727
|
-
case DomType.CommentRangeStart:
|
|
728
|
-
return this.renderCommentRangeStart(element as ICommentRangeStart)
|
|
729
|
-
case DomType.CommentRangeEnd:
|
|
730
|
-
return this.renderCommentRangeEnd(element as ICommentRangeEnd)
|
|
731
|
-
case DomType.CommentReference:
|
|
732
|
-
return this.renderCommentReference(element as ICommentReference)
|
|
733
|
-
case DomType.FootnoteReference:
|
|
734
|
-
return this.renderFootnoteReference(element as IFootnoteReference)
|
|
735
|
-
case DomType.EndnoteReference:
|
|
736
|
-
return this.renderEndnoteReference(element as IEndnoteReference)
|
|
737
|
-
case DomType.Footnote:
|
|
738
|
-
return this.renderFootnote(element as IFootnoteElement)
|
|
739
|
-
case DomType.Endnote:
|
|
740
|
-
return this.renderEndnote(element as IEndnoteElement)
|
|
741
|
-
case DomType.BookmarkStart:
|
|
742
|
-
return this.renderBookmarkStart(element as IBookmarkStartElement)
|
|
743
|
-
case DomType.BookmarkEnd:
|
|
744
|
-
return this.renderBookmarkEnd(element as IBookmarkEndElement)
|
|
745
|
-
default:
|
|
746
|
-
return null
|
|
747
|
-
}
|
|
748
|
-
}
|
|
749
|
-
|
|
750
|
-
/**
|
|
751
|
-
* 渲染文档
|
|
752
|
-
*/
|
|
753
|
-
private renderDocument(element: IOpenXmlElement): HTMLElement {
|
|
754
|
-
const article = this.createElement('article', `${this.classPrefix}-body`)
|
|
755
|
-
this.renderChildren(element.children || [], article)
|
|
756
|
-
return article
|
|
757
|
-
}
|
|
758
|
-
|
|
759
|
-
/**
|
|
760
|
-
* 渲染段落
|
|
761
|
-
*/
|
|
762
|
-
private renderParagraph(element: IParagraphElement): HTMLElement {
|
|
763
|
-
const p = this.createElement('p', `${this.classPrefix}-p`)
|
|
764
|
-
|
|
765
|
-
// 清空当前段落的评论开始记录
|
|
766
|
-
this.commentStartInParagraph.clear()
|
|
767
|
-
|
|
768
|
-
// 应用段落样式
|
|
769
|
-
if (element.props) {
|
|
770
|
-
this.applyParagraphStyles(p, element.props)
|
|
771
|
-
}
|
|
772
|
-
|
|
773
|
-
// 渲染编号
|
|
774
|
-
const numberingContent = this.renderNumbering(element.props)
|
|
775
|
-
if (numberingContent) {
|
|
776
|
-
p.insertBefore(numberingContent, p.firstChild)
|
|
777
|
-
p.classList.add(`${this.classPrefix}-list-item`)
|
|
778
|
-
}
|
|
779
|
-
|
|
780
|
-
// 渲染子元素
|
|
781
|
-
this.renderChildren(element.children || [], p)
|
|
782
|
-
|
|
783
|
-
// 如果段落为空,添加一个换行符保持高度
|
|
784
|
-
if (p.childNodes.length === 0 || (p.childNodes.length === 1 && numberingContent)) {
|
|
785
|
-
p.appendChild(document.createElement('br'))
|
|
786
|
-
}
|
|
787
|
-
|
|
788
|
-
// 如果当前段落有评论开始,标记段落
|
|
789
|
-
if (this.commentStartInParagraph.size > 0 && this.options.renderComments) {
|
|
790
|
-
// 将评论 ID 存储在段落元素上,稍后处理
|
|
791
|
-
p.dataset.commentIds = Array.from(this.commentStartInParagraph).join(',')
|
|
792
|
-
}
|
|
793
|
-
|
|
794
|
-
return p
|
|
795
|
-
}
|
|
796
|
-
|
|
797
|
-
/**
|
|
798
|
-
* 渲染编号
|
|
799
|
-
*/
|
|
800
|
-
private renderNumbering(props?: IParagraphProperties): HTMLElement | null {
|
|
801
|
-
if (!props?.numbering || !this.document?.numberingMap) {
|
|
802
|
-
return null
|
|
803
|
-
}
|
|
804
|
-
|
|
805
|
-
const { id, level } = props.numbering
|
|
806
|
-
const numbering = this.document.numberingMap.get(id)
|
|
807
|
-
|
|
808
|
-
if (!numbering) {
|
|
809
|
-
return null
|
|
810
|
-
}
|
|
811
|
-
|
|
812
|
-
const levelDef = numbering.levels.find(l => l.level === level)
|
|
813
|
-
if (!levelDef) {
|
|
814
|
-
return null
|
|
815
|
-
}
|
|
816
|
-
|
|
817
|
-
// 获取编号内容
|
|
818
|
-
const content = this.getNumberingContent(numbering, levelDef, id, level)
|
|
819
|
-
|
|
820
|
-
// 创建编号 span
|
|
821
|
-
const span = this.createElement('span', `${this.classPrefix}-numbering`)
|
|
822
|
-
span.textContent = content
|
|
823
|
-
|
|
824
|
-
// 应用编号级别的文本样式
|
|
825
|
-
if (levelDef.runProps) {
|
|
826
|
-
this.applyRunStyles(span, levelDef.runProps)
|
|
827
|
-
}
|
|
828
|
-
|
|
829
|
-
// 添加后缀(tab、space 等)
|
|
830
|
-
const suffix = this.createElement('span', `${this.classPrefix}-numbering-suffix`)
|
|
831
|
-
switch (levelDef.suffix) {
|
|
832
|
-
case 'tab':
|
|
833
|
-
suffix.innerHTML = ' '
|
|
834
|
-
break
|
|
835
|
-
case 'space':
|
|
836
|
-
suffix.innerHTML = ' '
|
|
837
|
-
break
|
|
838
|
-
default:
|
|
839
|
-
// nothing
|
|
840
|
-
break
|
|
841
|
-
}
|
|
842
|
-
|
|
843
|
-
const wrapper = this.createElement('span', `${this.classPrefix}-numbering-wrapper`)
|
|
844
|
-
wrapper.appendChild(span)
|
|
845
|
-
wrapper.appendChild(suffix)
|
|
846
|
-
|
|
847
|
-
// 应用缩进
|
|
848
|
-
if (levelDef.paragraphProps?.indentation) {
|
|
849
|
-
const indent = levelDef.paragraphProps.indentation
|
|
850
|
-
if (indent.left) {
|
|
851
|
-
wrapper.style.marginLeft = indent.left
|
|
852
|
-
}
|
|
853
|
-
if (indent.hanging) {
|
|
854
|
-
wrapper.style.textIndent = `-${indent.hanging}`
|
|
855
|
-
wrapper.style.paddingLeft = indent.hanging
|
|
856
|
-
}
|
|
857
|
-
}
|
|
858
|
-
|
|
859
|
-
return wrapper
|
|
860
|
-
}
|
|
861
|
-
|
|
862
|
-
/**
|
|
863
|
-
* 获取编号内容
|
|
864
|
-
*/
|
|
865
|
-
private getNumberingContent(
|
|
866
|
-
numbering: INumberingDefinition,
|
|
867
|
-
levelDef: INumberingLevel,
|
|
868
|
-
numId: string,
|
|
869
|
-
level: number
|
|
870
|
-
): string {
|
|
871
|
-
const format = levelDef.format
|
|
872
|
-
const text = levelDef.text
|
|
873
|
-
|
|
874
|
-
// 处理 bullet(无序列表)
|
|
875
|
-
if (format === 'bullet') {
|
|
876
|
-
return text || '•'
|
|
877
|
-
}
|
|
878
|
-
|
|
879
|
-
// 更新计数器
|
|
880
|
-
const counterKey = `${numId}-${level}`
|
|
881
|
-
let count = this.numberingCounters.get(counterKey) ?? (levelDef.start - 1)
|
|
882
|
-
count++
|
|
883
|
-
this.numberingCounters.set(counterKey, count)
|
|
884
|
-
|
|
885
|
-
// 重置更高级别的计数器
|
|
886
|
-
for (let i = level + 1; i <= 8; i++) {
|
|
887
|
-
this.numberingCounters.delete(`${numId}-${i}`)
|
|
888
|
-
}
|
|
889
|
-
|
|
890
|
-
// 替换文本模板中的占位符
|
|
891
|
-
// 如 "%1." 表示第一级编号,"%1.%2." 表示第一级.第二级
|
|
892
|
-
let result = text
|
|
893
|
-
for (let i = 0; i <= level; i++) {
|
|
894
|
-
const lvlCount = this.numberingCounters.get(`${numId}-${i}`) ?? 1
|
|
895
|
-
const lvlDef = numbering.levels.find(l => l.level === i)
|
|
896
|
-
const lvlFormat = lvlDef?.format || 'decimal'
|
|
897
|
-
const formatted = this.formatNumber(lvlCount, lvlFormat)
|
|
898
|
-
result = result.replace(`%${i + 1}`, formatted)
|
|
899
|
-
}
|
|
900
|
-
|
|
901
|
-
return result
|
|
902
|
-
}
|
|
903
|
-
|
|
904
|
-
/**
|
|
905
|
-
* 格式化编号
|
|
906
|
-
*/
|
|
907
|
-
private formatNumber(num: number, format: string): string {
|
|
908
|
-
switch (format) {
|
|
909
|
-
case 'decimal':
|
|
910
|
-
return String(num)
|
|
911
|
-
case 'decimalZero':
|
|
912
|
-
return num < 10 ? `0${num}` : String(num)
|
|
913
|
-
case 'lowerLetter':
|
|
914
|
-
return this.toLetters(num, false)
|
|
915
|
-
case 'upperLetter':
|
|
916
|
-
return this.toLetters(num, true)
|
|
917
|
-
case 'lowerRoman':
|
|
918
|
-
return this.toRoman(num).toLowerCase()
|
|
919
|
-
case 'upperRoman':
|
|
920
|
-
return this.toRoman(num)
|
|
921
|
-
case 'chineseCountingThousand':
|
|
922
|
-
case 'chineseCounting':
|
|
923
|
-
return this.toChinese(num)
|
|
924
|
-
case 'ideographTraditional':
|
|
925
|
-
return this.toChineseTraditional(num)
|
|
926
|
-
case 'bullet':
|
|
927
|
-
return '•'
|
|
928
|
-
default:
|
|
929
|
-
return String(num)
|
|
930
|
-
}
|
|
931
|
-
}
|
|
932
|
-
|
|
933
|
-
/**
|
|
934
|
-
* 数字转字母
|
|
935
|
-
*/
|
|
936
|
-
private toLetters(num: number, upper: boolean): string {
|
|
937
|
-
const base = upper ? 65 : 97 // A or a
|
|
938
|
-
let result = ''
|
|
939
|
-
while (num > 0) {
|
|
940
|
-
num--
|
|
941
|
-
result = String.fromCharCode(base + (num % 26)) + result
|
|
942
|
-
num = Math.floor(num / 26)
|
|
943
|
-
}
|
|
944
|
-
return result
|
|
945
|
-
}
|
|
946
|
-
|
|
947
|
-
/**
|
|
948
|
-
* 数字转罗马数字
|
|
949
|
-
*/
|
|
950
|
-
private toRoman(num: number): string {
|
|
951
|
-
const romanNumerals: [number, string][] = [
|
|
952
|
-
[1000, 'M'], [900, 'CM'], [500, 'D'], [400, 'CD'],
|
|
953
|
-
[100, 'C'], [90, 'XC'], [50, 'L'], [40, 'XL'],
|
|
954
|
-
[10, 'X'], [9, 'IX'], [5, 'V'], [4, 'IV'], [1, 'I']
|
|
955
|
-
]
|
|
956
|
-
let result = ''
|
|
957
|
-
for (const [value, symbol] of romanNumerals) {
|
|
958
|
-
while (num >= value) {
|
|
959
|
-
result += symbol
|
|
960
|
-
num -= value
|
|
961
|
-
}
|
|
962
|
-
}
|
|
963
|
-
return result
|
|
964
|
-
}
|
|
965
|
-
|
|
966
|
-
/**
|
|
967
|
-
* 数字转中文数字
|
|
968
|
-
*/
|
|
969
|
-
private toChinese(num: number): string {
|
|
970
|
-
const digits = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九']
|
|
971
|
-
const units = ['', '十', '百', '千', '万']
|
|
972
|
-
|
|
973
|
-
if (num <= 10) {
|
|
974
|
-
return num === 10 ? '十' : digits[num]
|
|
975
|
-
}
|
|
976
|
-
|
|
977
|
-
let result = ''
|
|
978
|
-
let unitIndex = 0
|
|
979
|
-
while (num > 0) {
|
|
980
|
-
const digit = num % 10
|
|
981
|
-
if (digit !== 0) {
|
|
982
|
-
result = digits[digit] + units[unitIndex] + result
|
|
983
|
-
} else if (result && !result.startsWith('零')) {
|
|
984
|
-
result = '零' + result
|
|
985
|
-
}
|
|
986
|
-
num = Math.floor(num / 10)
|
|
987
|
-
unitIndex++
|
|
988
|
-
}
|
|
989
|
-
|
|
990
|
-
// 处理 "一十" -> "十"
|
|
991
|
-
if (result.startsWith('一十')) {
|
|
992
|
-
result = result.substring(1)
|
|
993
|
-
}
|
|
994
|
-
|
|
995
|
-
return result
|
|
996
|
-
}
|
|
997
|
-
|
|
998
|
-
/**
|
|
999
|
-
* 数字转中文传统数字(甲乙丙丁...)
|
|
1000
|
-
*/
|
|
1001
|
-
private toChineseTraditional(num: number): string {
|
|
1002
|
-
const traditional = ['甲', '乙', '丙', '丁', '戊', '己', '庚', '辛', '壬', '癸']
|
|
1003
|
-
if (num >= 1 && num <= 10) {
|
|
1004
|
-
return traditional[num - 1]
|
|
1005
|
-
}
|
|
1006
|
-
return String(num)
|
|
1007
|
-
}
|
|
1008
|
-
|
|
1009
|
-
/**
|
|
1010
|
-
* 创建评论气泡
|
|
1011
|
-
*/
|
|
1012
|
-
private createCommentBubble(comment: ICommentElement): HTMLElement {
|
|
1013
|
-
const bubble = this.createElement('div', `${this.classPrefix}-comment-bubble`)
|
|
1014
|
-
bubble.dataset.commentId = comment.id
|
|
1015
|
-
|
|
1016
|
-
const initials = comment.initials || comment.author.charAt(0).toUpperCase()
|
|
1017
|
-
const date = this.formatDate(comment.date)
|
|
1018
|
-
const content = this.getCommentText(comment)
|
|
1019
|
-
|
|
1020
|
-
bubble.innerHTML = `
|
|
1021
|
-
<div class="${this.classPrefix}-comment-header">
|
|
1022
|
-
<div class="${this.classPrefix}-comment-avatar">${this.escapeHtml(initials)}</div>
|
|
1023
|
-
<div class="${this.classPrefix}-comment-meta">
|
|
1024
|
-
<span class="${this.classPrefix}-comment-author">${this.escapeHtml(comment.author)}</span>
|
|
1025
|
-
<span class="${this.classPrefix}-comment-date">${date}</span>
|
|
1026
|
-
</div>
|
|
1027
|
-
</div>
|
|
1028
|
-
<div class="${this.classPrefix}-comment-content">${this.escapeHtml(content)}</div>
|
|
1029
|
-
${this.options.enableCommentEdit ? `
|
|
1030
|
-
<div class="${this.classPrefix}-comment-actions">
|
|
1031
|
-
<button class="${this.classPrefix}-comment-btn edit" title="编辑">
|
|
1032
|
-
<svg viewBox="0 0 24 24" width="14" height="14"><path fill="currentColor" d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
|
|
1033
|
-
</button>
|
|
1034
|
-
<button class="${this.classPrefix}-comment-btn delete" title="删除">
|
|
1035
|
-
<svg viewBox="0 0 24 24" width="14" height="14"><path fill="currentColor" d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
|
|
1036
|
-
</button>
|
|
1037
|
-
</div>
|
|
1038
|
-
` : ''}
|
|
1039
|
-
`
|
|
1040
|
-
|
|
1041
|
-
// 绑定事件
|
|
1042
|
-
bubble.addEventListener('mouseenter', () => this.highlightComment(comment.id))
|
|
1043
|
-
bubble.addEventListener('mouseleave', () => this.unhighlightComment(comment.id))
|
|
1044
|
-
bubble.addEventListener('click', () => {
|
|
1045
|
-
this.selectComment(comment.id)
|
|
1046
|
-
this.options.onCommentClick(comment)
|
|
1047
|
-
})
|
|
1048
|
-
|
|
1049
|
-
// 编辑按钮
|
|
1050
|
-
const editBtn = bubble.querySelector('.edit')
|
|
1051
|
-
editBtn?.addEventListener('click', (e) => {
|
|
1052
|
-
e.stopPropagation()
|
|
1053
|
-
this.editComment(comment)
|
|
1054
|
-
})
|
|
1055
|
-
|
|
1056
|
-
// 删除按钮
|
|
1057
|
-
const deleteBtn = bubble.querySelector('.delete')
|
|
1058
|
-
deleteBtn?.addEventListener('click', (e) => {
|
|
1059
|
-
e.stopPropagation()
|
|
1060
|
-
this.deleteComment(comment.id)
|
|
1061
|
-
})
|
|
1062
|
-
|
|
1063
|
-
return bubble
|
|
1064
|
-
}
|
|
1065
|
-
|
|
1066
|
-
/**
|
|
1067
|
-
* 获取评论文本内容
|
|
1068
|
-
*/
|
|
1069
|
-
private getCommentText(comment: ICommentElement): string {
|
|
1070
|
-
const texts: string[] = []
|
|
1071
|
-
const extractText = (elements: IOpenXmlElement[]) => {
|
|
1072
|
-
for (const el of elements) {
|
|
1073
|
-
if (el.type === DomType.Text) {
|
|
1074
|
-
texts.push((el as ITextElement).text)
|
|
1075
|
-
}
|
|
1076
|
-
if (el.children) {
|
|
1077
|
-
extractText(el.children)
|
|
1078
|
-
}
|
|
1079
|
-
}
|
|
1080
|
-
}
|
|
1081
|
-
extractText(comment.children || [])
|
|
1082
|
-
|
|
1083
|
-
const result = texts.join('')
|
|
1084
|
-
|
|
1085
|
-
// 如果递归提取失败,使用原始文本作为备用
|
|
1086
|
-
if (!result && comment.rawText) {
|
|
1087
|
-
console.log('[DEBUG] Comment', comment.id, 'using rawText:', comment.rawText)
|
|
1088
|
-
return comment.rawText
|
|
1089
|
-
}
|
|
1090
|
-
|
|
1091
|
-
console.log('[DEBUG] Comment', comment.id, 'text result:', result)
|
|
1092
|
-
return result || ''
|
|
1093
|
-
}
|
|
1094
|
-
|
|
1095
|
-
/**
|
|
1096
|
-
* 高亮评论
|
|
1097
|
-
*/
|
|
1098
|
-
private highlightComment(commentId: string): void {
|
|
1099
|
-
this.activeCommentId = commentId
|
|
1100
|
-
|
|
1101
|
-
const range = this.commentRanges.get(commentId)
|
|
1102
|
-
if (range) {
|
|
1103
|
-
range.highlightElements.forEach(el => {
|
|
1104
|
-
el.classList.add(`${this.classPrefix}-highlight--active`)
|
|
1105
|
-
})
|
|
1106
|
-
range.panelElement?.classList.add(`${this.classPrefix}-comment-bubble--active`)
|
|
1107
|
-
}
|
|
1108
|
-
|
|
1109
|
-
this.updateLines()
|
|
1110
|
-
}
|
|
1111
|
-
|
|
1112
|
-
/**
|
|
1113
|
-
* 取消高亮评论
|
|
1114
|
-
*/
|
|
1115
|
-
private unhighlightComment(commentId: string): void {
|
|
1116
|
-
if (this.activeCommentId === commentId) {
|
|
1117
|
-
this.activeCommentId = null
|
|
1118
|
-
}
|
|
1119
|
-
|
|
1120
|
-
const range = this.commentRanges.get(commentId)
|
|
1121
|
-
if (range) {
|
|
1122
|
-
range.highlightElements.forEach(el => {
|
|
1123
|
-
el.classList.remove(`${this.classPrefix}-highlight--active`)
|
|
1124
|
-
})
|
|
1125
|
-
range.panelElement?.classList.remove(`${this.classPrefix}-comment-bubble--active`)
|
|
1126
|
-
}
|
|
1127
|
-
|
|
1128
|
-
this.updateLines()
|
|
1129
|
-
}
|
|
1130
|
-
|
|
1131
|
-
/**
|
|
1132
|
-
* 选中评论
|
|
1133
|
-
*/
|
|
1134
|
-
private selectComment(commentId: string): void {
|
|
1135
|
-
this.activeCommentId = commentId
|
|
1136
|
-
this.updateLines()
|
|
1137
|
-
}
|
|
1138
|
-
|
|
1139
|
-
/**
|
|
1140
|
-
* 编辑评论
|
|
1141
|
-
*/
|
|
1142
|
-
private editComment(comment: ICommentElement): void {
|
|
1143
|
-
const currentText = this.getCommentText(comment)
|
|
1144
|
-
const newText = prompt('编辑评论:', currentText)
|
|
1145
|
-
|
|
1146
|
-
if (newText !== null && newText !== currentText) {
|
|
1147
|
-
// 更新评论内容
|
|
1148
|
-
comment.children = [{
|
|
1149
|
-
type: DomType.Paragraph,
|
|
1150
|
-
children: [{
|
|
1151
|
-
type: DomType.Run,
|
|
1152
|
-
children: [{
|
|
1153
|
-
type: DomType.Text,
|
|
1154
|
-
text: newText,
|
|
1155
|
-
}]
|
|
1156
|
-
}]
|
|
1157
|
-
}] as IOpenXmlElement[]
|
|
1158
|
-
|
|
1159
|
-
// 更新 UI
|
|
1160
|
-
const range = this.commentRanges.get(comment.id)
|
|
1161
|
-
if (range?.panelElement) {
|
|
1162
|
-
const contentEl = range.panelElement.querySelector(`.${this.classPrefix}-comment-content`)
|
|
1163
|
-
if (contentEl) {
|
|
1164
|
-
contentEl.textContent = newText
|
|
1165
|
-
}
|
|
1166
|
-
}
|
|
1167
|
-
|
|
1168
|
-
this.options.onCommentChange(comment, 'update')
|
|
1169
|
-
}
|
|
1170
|
-
}
|
|
1171
|
-
|
|
1172
|
-
/**
|
|
1173
|
-
* 删除评论
|
|
1174
|
-
*/
|
|
1175
|
-
private deleteComment(commentId: string): void {
|
|
1176
|
-
if (!confirm('确定要删除这条评论吗?')) return
|
|
1177
|
-
|
|
1178
|
-
const comment = this.document?.commentMap.get(commentId)
|
|
1179
|
-
if (!comment) return
|
|
1180
|
-
|
|
1181
|
-
// 移除高亮
|
|
1182
|
-
const range = this.commentRanges.get(commentId)
|
|
1183
|
-
if (range) {
|
|
1184
|
-
range.highlightElements.forEach(el => {
|
|
1185
|
-
el.classList.remove(`${this.classPrefix}-highlight`)
|
|
1186
|
-
el.classList.remove(`${this.classPrefix}-highlight--active`)
|
|
1187
|
-
})
|
|
1188
|
-
range.panelElement?.remove()
|
|
1189
|
-
}
|
|
1190
|
-
|
|
1191
|
-
// 从文档中移除
|
|
1192
|
-
if (this.document) {
|
|
1193
|
-
const index = this.document.comments.indexOf(comment)
|
|
1194
|
-
if (index > -1) {
|
|
1195
|
-
this.document.comments.splice(index, 1)
|
|
1196
|
-
}
|
|
1197
|
-
this.document.commentMap.delete(commentId)
|
|
1198
|
-
}
|
|
1199
|
-
|
|
1200
|
-
this.commentRanges.delete(commentId)
|
|
1201
|
-
this.options.onCommentChange(comment, 'delete')
|
|
1202
|
-
this.updateLines()
|
|
1203
|
-
}
|
|
1204
|
-
|
|
1205
|
-
/**
|
|
1206
|
-
* 应用段落样式
|
|
1207
|
-
*/
|
|
1208
|
-
private applyParagraphStyles(element: HTMLElement, props: IParagraphProperties): void {
|
|
1209
|
-
const styles: string[] = []
|
|
1210
|
-
|
|
1211
|
-
if (props.justification) {
|
|
1212
|
-
const alignMap: Record<string, string> = {
|
|
1213
|
-
left: 'left',
|
|
1214
|
-
center: 'center',
|
|
1215
|
-
right: 'right',
|
|
1216
|
-
both: 'justify',
|
|
1217
|
-
}
|
|
1218
|
-
styles.push(`text-align: ${alignMap[props.justification] || 'left'}`)
|
|
1219
|
-
}
|
|
1220
|
-
|
|
1221
|
-
if (props.indentation) {
|
|
1222
|
-
if (props.indentation.left) styles.push(`padding-left: ${props.indentation.left}`)
|
|
1223
|
-
if (props.indentation.right) styles.push(`padding-right: ${props.indentation.right}`)
|
|
1224
|
-
if (props.indentation.firstLine) styles.push(`text-indent: ${props.indentation.firstLine}`)
|
|
1225
|
-
}
|
|
1226
|
-
|
|
1227
|
-
if (props.spacing) {
|
|
1228
|
-
if (props.spacing.before) styles.push(`margin-top: ${props.spacing.before}`)
|
|
1229
|
-
if (props.spacing.after) styles.push(`margin-bottom: ${props.spacing.after}`)
|
|
1230
|
-
|
|
1231
|
-
// 根据 lineRule 计算行间距
|
|
1232
|
-
if (props.spacing.line !== undefined) {
|
|
1233
|
-
const line = props.spacing.line
|
|
1234
|
-
switch (props.spacing.lineRule) {
|
|
1235
|
-
case 'auto':
|
|
1236
|
-
// auto: line 值是 240 的倍数(240 = 单倍行距)
|
|
1237
|
-
styles.push(`line-height: ${(line / 240).toFixed(2)}`)
|
|
1238
|
-
break
|
|
1239
|
-
case 'atLeast':
|
|
1240
|
-
// atLeast: 最小行高,line 值是 twip(1/20 pt)
|
|
1241
|
-
styles.push(`line-height: calc(100% + ${line / 20}pt)`)
|
|
1242
|
-
break
|
|
1243
|
-
case 'exact':
|
|
1244
|
-
default:
|
|
1245
|
-
// exact/默认: 精确行高,line 值是 twip
|
|
1246
|
-
styles.push(`line-height: ${line / 20}pt`)
|
|
1247
|
-
break
|
|
1248
|
-
}
|
|
1249
|
-
}
|
|
1250
|
-
}
|
|
1251
|
-
|
|
1252
|
-
if (styles.length > 0) {
|
|
1253
|
-
element.style.cssText = styles.join('; ')
|
|
1254
|
-
}
|
|
1255
|
-
}
|
|
1256
|
-
|
|
1257
|
-
/**
|
|
1258
|
-
* 渲染 Run
|
|
1259
|
-
*/
|
|
1260
|
-
private renderRun(element: IRunElement): HTMLElement | null {
|
|
1261
|
-
// 如果正在跳过域内容,检查这个 run 是否包含域结束符
|
|
1262
|
-
// 如果不包含域结束符,就跳过整个 run
|
|
1263
|
-
if (this.skipFieldContent) {
|
|
1264
|
-
const hasFieldEnd = (element.children || []).some(
|
|
1265
|
-
child => child.type === DomType.ComplexField &&
|
|
1266
|
-
(child as IComplexFieldElement).charType === 'end'
|
|
1267
|
-
)
|
|
1268
|
-
if (!hasFieldEnd) {
|
|
1269
|
-
return null
|
|
1270
|
-
}
|
|
1271
|
-
}
|
|
1272
|
-
|
|
1273
|
-
const span = this.createElement('span', `${this.classPrefix}-run`)
|
|
1274
|
-
|
|
1275
|
-
// 如果在评论范围内,添加高亮类
|
|
1276
|
-
if (this.currentCommentIds.size > 0) {
|
|
1277
|
-
span.classList.add(`${this.classPrefix}-highlight`)
|
|
1278
|
-
for (const id of this.currentCommentIds) {
|
|
1279
|
-
span.classList.add(`${this.classPrefix}-highlight-${id}`)
|
|
1280
|
-
span.dataset.commentId = id
|
|
1281
|
-
|
|
1282
|
-
// 记录高亮元素
|
|
1283
|
-
const range = this.commentRanges.get(id)
|
|
1284
|
-
if (range) {
|
|
1285
|
-
range.highlightElements.push(span)
|
|
1286
|
-
}
|
|
1287
|
-
}
|
|
1288
|
-
|
|
1289
|
-
// 点击高亮文字时高亮对应评论
|
|
1290
|
-
span.addEventListener('click', () => {
|
|
1291
|
-
const id = span.dataset.commentId
|
|
1292
|
-
if (id) {
|
|
1293
|
-
this.highlightComment(id)
|
|
1294
|
-
}
|
|
1295
|
-
})
|
|
1296
|
-
}
|
|
1297
|
-
|
|
1298
|
-
// 应用文本样式
|
|
1299
|
-
if (element.props) {
|
|
1300
|
-
this.applyRunStyles(span, element.props)
|
|
1301
|
-
}
|
|
1302
|
-
|
|
1303
|
-
// 渲染子元素
|
|
1304
|
-
this.renderChildren(element.children || [], span)
|
|
1305
|
-
|
|
1306
|
-
// 如果 span 是空的(所有内容都被跳过了),返回 null
|
|
1307
|
-
if (span.childNodes.length === 0) {
|
|
1308
|
-
return null
|
|
1309
|
-
}
|
|
1310
|
-
|
|
1311
|
-
return span
|
|
1312
|
-
}
|
|
1313
|
-
|
|
1314
|
-
/**
|
|
1315
|
-
* 应用 Run 样式
|
|
1316
|
-
*/
|
|
1317
|
-
private applyRunStyles(element: HTMLElement, props: IRunProperties): void {
|
|
1318
|
-
const styles: string[] = []
|
|
1319
|
-
|
|
1320
|
-
if (props.bold) styles.push('font-weight: bold')
|
|
1321
|
-
if (props.italic) styles.push('font-style: italic')
|
|
1322
|
-
|
|
1323
|
-
// 处理下划线和删除线(可以同时存在多种)
|
|
1324
|
-
const textDecorations: string[] = []
|
|
1325
|
-
if (props.underline && props.underline !== 'none') {
|
|
1326
|
-
// 根据下划线样式添加对应的 CSS 类
|
|
1327
|
-
element.classList.add(`${this.classPrefix}-underline-${props.underline}`)
|
|
1328
|
-
// 对于复杂下划线样式,用 CSS 类处理;对于简单样式,用内联样式
|
|
1329
|
-
if (!this.isComplexUnderline(props.underline)) {
|
|
1330
|
-
textDecorations.push('underline')
|
|
1331
|
-
}
|
|
1332
|
-
}
|
|
1333
|
-
if (props.strike) {
|
|
1334
|
-
textDecorations.push('line-through')
|
|
1335
|
-
}
|
|
1336
|
-
if (props.dstrike) {
|
|
1337
|
-
// 双删除线使用 CSS 类实现
|
|
1338
|
-
element.classList.add(`${this.classPrefix}-dstrike`)
|
|
1339
|
-
}
|
|
1340
|
-
|
|
1341
|
-
if (textDecorations.length > 0) {
|
|
1342
|
-
styles.push(`text-decoration: ${textDecorations.join(' ')}`)
|
|
1343
|
-
}
|
|
1344
|
-
|
|
1345
|
-
// 上标/下标
|
|
1346
|
-
if (props.vertAlign) {
|
|
1347
|
-
element.classList.add(`${this.classPrefix}-${props.vertAlign}`)
|
|
1348
|
-
}
|
|
1349
|
-
|
|
1350
|
-
if (props.color) styles.push(`color: ${props.color}`)
|
|
1351
|
-
if (props.fontSize) styles.push(`font-size: ${props.fontSize}`)
|
|
1352
|
-
if (props.fontFamily) styles.push(`font-family: "${props.fontFamily}"`)
|
|
1353
|
-
if (props.highlight) {
|
|
1354
|
-
const colorMap: Record<string, string> = {
|
|
1355
|
-
yellow: '#ffff00',
|
|
1356
|
-
green: '#00ff00',
|
|
1357
|
-
cyan: '#00ffff',
|
|
1358
|
-
magenta: '#ff00ff',
|
|
1359
|
-
blue: '#0000ff',
|
|
1360
|
-
red: '#ff0000',
|
|
1361
|
-
darkBlue: '#000080',
|
|
1362
|
-
darkCyan: '#008080',
|
|
1363
|
-
darkGreen: '#008000',
|
|
1364
|
-
darkMagenta: '#800080',
|
|
1365
|
-
darkRed: '#800000',
|
|
1366
|
-
darkYellow: '#808000',
|
|
1367
|
-
darkGray: '#808080',
|
|
1368
|
-
lightGray: '#c0c0c0',
|
|
1369
|
-
black: '#000000',
|
|
1370
|
-
}
|
|
1371
|
-
styles.push(`background-color: ${colorMap[props.highlight] || props.highlight}`)
|
|
1372
|
-
}
|
|
1373
|
-
|
|
1374
|
-
if (styles.length > 0) {
|
|
1375
|
-
element.style.cssText = styles.join('; ')
|
|
1376
|
-
}
|
|
1377
|
-
}
|
|
1378
|
-
|
|
1379
|
-
/**
|
|
1380
|
-
* 检查是否是复杂下划线样式(需要特殊 CSS 处理)
|
|
1381
|
-
*/
|
|
1382
|
-
private isComplexUnderline(style: TUnderlineStyle): boolean {
|
|
1383
|
-
const complexStyles = [
|
|
1384
|
-
'double', 'thick', 'dotted', 'dottedHeavy',
|
|
1385
|
-
'dash', 'dashedHeavy', 'dashLong', 'dashLongHeavy',
|
|
1386
|
-
'dotDash', 'dashDotHeavy', 'dotDotDash', 'dashDotDotHeavy',
|
|
1387
|
-
'wave', 'wavyHeavy', 'wavyDouble'
|
|
1388
|
-
]
|
|
1389
|
-
return complexStyles.includes(style)
|
|
1390
|
-
}
|
|
1391
|
-
|
|
1392
|
-
/**
|
|
1393
|
-
* 渲染文本
|
|
1394
|
-
*/
|
|
1395
|
-
private renderText(element: ITextElement): HTMLElement | null {
|
|
1396
|
-
// 如果在跳过域内容模式,不渲染文本
|
|
1397
|
-
if (this.skipFieldContent) {
|
|
1398
|
-
return null
|
|
1399
|
-
}
|
|
1400
|
-
const span = document.createElement('span')
|
|
1401
|
-
span.textContent = element.text
|
|
1402
|
-
return span
|
|
1403
|
-
}
|
|
1404
|
-
|
|
1405
|
-
/**
|
|
1406
|
-
* 渲染换行
|
|
1407
|
-
*/
|
|
1408
|
-
private renderBreak(element: IBreakElement): HTMLElement {
|
|
1409
|
-
switch (element.breakType) {
|
|
1410
|
-
case 'page':
|
|
1411
|
-
case 'lastRenderedPageBreak':
|
|
1412
|
-
const div = this.createElement('div', `${this.classPrefix}-page-break`)
|
|
1413
|
-
div.style.pageBreakAfter = 'always'
|
|
1414
|
-
return div
|
|
1415
|
-
case 'column':
|
|
1416
|
-
const colBreak = this.createElement('span', `${this.classPrefix}-column-break`)
|
|
1417
|
-
colBreak.style.breakAfter = 'column'
|
|
1418
|
-
return colBreak
|
|
1419
|
-
default:
|
|
1420
|
-
return document.createElement('br')
|
|
1421
|
-
}
|
|
1422
|
-
}
|
|
1423
|
-
|
|
1424
|
-
/**
|
|
1425
|
-
* 渲染 Tab
|
|
1426
|
-
*/
|
|
1427
|
-
private renderTab(): HTMLElement {
|
|
1428
|
-
const tab = this.createElement('span', `${this.classPrefix}-tab`)
|
|
1429
|
-
tab.innerHTML = ' ' // 使用 em 空格模拟 tab
|
|
1430
|
-
return tab
|
|
1431
|
-
}
|
|
1432
|
-
|
|
1433
|
-
/**
|
|
1434
|
-
* 渲染 Symbol 字符
|
|
1435
|
-
* 处理 Word 中的特殊符号字符(如 Wingdings、Symbol 字体中的符号)
|
|
1436
|
-
*/
|
|
1437
|
-
private renderSymbol(element: ISymbolElement): HTMLElement {
|
|
1438
|
-
const span = this.createElement('span', `${this.classPrefix}-symbol`)
|
|
1439
|
-
|
|
1440
|
-
if (element.font) {
|
|
1441
|
-
// 尝试使用原始字体渲染
|
|
1442
|
-
span.style.fontFamily = `"${element.font}", "Segoe UI Symbol", "Apple Symbols", sans-serif`
|
|
1443
|
-
}
|
|
1444
|
-
|
|
1445
|
-
if (element.char) {
|
|
1446
|
-
span.textContent = element.char
|
|
1447
|
-
} else {
|
|
1448
|
-
// 如果没有字符,显示一个占位符
|
|
1449
|
-
span.textContent = '□'
|
|
1450
|
-
}
|
|
1451
|
-
|
|
1452
|
-
return span
|
|
1453
|
-
}
|
|
1454
|
-
|
|
1455
|
-
/**
|
|
1456
|
-
* 渲染简单域 - 如 PAGE, NUMPAGES 等
|
|
1457
|
-
*/
|
|
1458
|
-
private renderSimpleField(element: ISimpleFieldElement): HTMLElement | null {
|
|
1459
|
-
const instruction = element.instruction.trim().toUpperCase()
|
|
1460
|
-
const fieldValue = this.evaluateFieldInstruction(instruction)
|
|
1461
|
-
|
|
1462
|
-
if (fieldValue !== null) {
|
|
1463
|
-
const span = this.createElement('span', `${this.classPrefix}-field`)
|
|
1464
|
-
span.textContent = fieldValue
|
|
1465
|
-
return span
|
|
1466
|
-
}
|
|
1467
|
-
|
|
1468
|
-
// 如果不是我们支持的域,渲染子内容
|
|
1469
|
-
const span = this.createElement('span', `${this.classPrefix}-field`)
|
|
1470
|
-
this.renderChildren(element.children || [], span)
|
|
1471
|
-
return span
|
|
1472
|
-
}
|
|
1473
|
-
|
|
1474
|
-
/**
|
|
1475
|
-
* 渲染复杂域字符
|
|
1476
|
-
*/
|
|
1477
|
-
private renderComplexField(element: IComplexFieldElement): HTMLElement | null {
|
|
1478
|
-
switch (element.charType) {
|
|
1479
|
-
case 'begin':
|
|
1480
|
-
this.inComplexField = true
|
|
1481
|
-
this.skipFieldContent = false
|
|
1482
|
-
this.currentFieldInstruction = ''
|
|
1483
|
-
return null
|
|
1484
|
-
case 'separate':
|
|
1485
|
-
// 在 separate 后面的内容是域的显示值(Word 保存的静态值)
|
|
1486
|
-
// 如果我们能解析域,就输出我们计算的值并跳过后面的静态内容
|
|
1487
|
-
const fieldValue = this.evaluateFieldInstruction(this.currentFieldInstruction.trim().toUpperCase())
|
|
1488
|
-
if (fieldValue !== null) {
|
|
1489
|
-
this.skipFieldContent = true // 跳过后面的静态内容
|
|
1490
|
-
const span = this.createElement('span', `${this.classPrefix}-field`)
|
|
1491
|
-
span.textContent = fieldValue
|
|
1492
|
-
return span
|
|
1493
|
-
}
|
|
1494
|
-
return null
|
|
1495
|
-
case 'end':
|
|
1496
|
-
this.inComplexField = false
|
|
1497
|
-
this.skipFieldContent = false
|
|
1498
|
-
this.currentFieldInstruction = ''
|
|
1499
|
-
return null
|
|
1500
|
-
default:
|
|
1501
|
-
return null
|
|
1502
|
-
}
|
|
1503
|
-
}
|
|
1504
|
-
|
|
1505
|
-
/**
|
|
1506
|
-
* 渲染域指令
|
|
1507
|
-
*/
|
|
1508
|
-
private renderFieldInstruction(element: IFieldInstructionElement): HTMLElement | null {
|
|
1509
|
-
if (this.inComplexField) {
|
|
1510
|
-
this.currentFieldInstruction += element.text
|
|
1511
|
-
}
|
|
1512
|
-
return null // 域指令本身不显示
|
|
1513
|
-
}
|
|
1514
|
-
|
|
1515
|
-
/**
|
|
1516
|
-
* 计算域值
|
|
1517
|
-
*/
|
|
1518
|
-
private evaluateFieldInstruction(instruction: string): string | null {
|
|
1519
|
-
// 解析域指令,提取域类型
|
|
1520
|
-
const parts = instruction.split(/\s+/)
|
|
1521
|
-
const fieldType = parts[0]
|
|
1522
|
-
|
|
1523
|
-
switch (fieldType) {
|
|
1524
|
-
case 'PAGE':
|
|
1525
|
-
return String(this.currentPageNumber)
|
|
1526
|
-
case 'NUMPAGES':
|
|
1527
|
-
return String(this.totalPages)
|
|
1528
|
-
case 'DATE':
|
|
1529
|
-
return new Date().toLocaleDateString('zh-CN')
|
|
1530
|
-
case 'TIME':
|
|
1531
|
-
return new Date().toLocaleTimeString('zh-CN')
|
|
1532
|
-
default:
|
|
1533
|
-
return null // 不支持的域类型
|
|
1534
|
-
}
|
|
1535
|
-
}
|
|
1536
|
-
|
|
1537
|
-
// 表格垂直合并状态
|
|
1538
|
-
private tableVerticalMerges: Map<number, HTMLTableCellElement>[] = []
|
|
1539
|
-
private currentVerticalMerge: Map<number, HTMLTableCellElement> = new Map()
|
|
1540
|
-
private currentCellCol = 0
|
|
1541
|
-
|
|
1542
|
-
// 表格边框状态(用于 insideH/insideV 内部边框)
|
|
1543
|
-
private currentTableBorders: IBorders | undefined = undefined
|
|
1544
|
-
private tableBordersStack: (IBorders | undefined)[] = []
|
|
1545
|
-
private currentTableRowIndex = 0
|
|
1546
|
-
private currentTableRowCount = 0
|
|
1547
|
-
private currentTableColCount = 0
|
|
1548
|
-
|
|
1549
|
-
/**
|
|
1550
|
-
* 渲染表格
|
|
1551
|
-
*/
|
|
1552
|
-
private renderTable(element: ITableElement): HTMLElement {
|
|
1553
|
-
const table = this.createElement('table', `${this.classPrefix}-table`)
|
|
1554
|
-
|
|
1555
|
-
// 保存当前垂直合并状态
|
|
1556
|
-
this.tableVerticalMerges.push(this.currentVerticalMerge)
|
|
1557
|
-
this.currentVerticalMerge = new Map()
|
|
1558
|
-
|
|
1559
|
-
// 保存当前表格边框状态(用于嵌套表格)
|
|
1560
|
-
this.tableBordersStack.push(this.currentTableBorders)
|
|
1561
|
-
this.currentTableBorders = element.props?.borders
|
|
1562
|
-
this.currentTableRowCount = element.children?.length || 0
|
|
1563
|
-
this.currentTableColCount = element.columns?.length || 0
|
|
1564
|
-
this.currentTableRowIndex = 0
|
|
1565
|
-
|
|
1566
|
-
// 渲染列宽度
|
|
1567
|
-
if (element.columns && element.columns.length > 0) {
|
|
1568
|
-
const colgroup = document.createElement('colgroup')
|
|
1569
|
-
for (const col of element.columns) {
|
|
1570
|
-
const colEl = document.createElement('col')
|
|
1571
|
-
if (col.width) {
|
|
1572
|
-
colEl.style.width = col.width
|
|
1573
|
-
}
|
|
1574
|
-
colgroup.appendChild(colEl)
|
|
1575
|
-
}
|
|
1576
|
-
table.appendChild(colgroup)
|
|
1577
|
-
}
|
|
1578
|
-
|
|
1579
|
-
// 应用表格样式(包含边框)
|
|
1580
|
-
if (element.props) {
|
|
1581
|
-
this.applyTableStyles(table, element.props)
|
|
1582
|
-
}
|
|
1583
|
-
|
|
1584
|
-
// 渲染表格行
|
|
1585
|
-
const tbody = document.createElement('tbody')
|
|
1586
|
-
for (const row of element.children || []) {
|
|
1587
|
-
this.currentCellCol = 0
|
|
1588
|
-
const tr = this.renderTableRow(row)
|
|
1589
|
-
tbody.appendChild(tr)
|
|
1590
|
-
this.currentTableRowIndex++
|
|
1591
|
-
}
|
|
1592
|
-
table.appendChild(tbody)
|
|
1593
|
-
|
|
1594
|
-
// 恢复垂直合并状态
|
|
1595
|
-
this.currentVerticalMerge = this.tableVerticalMerges.pop() || new Map()
|
|
1596
|
-
|
|
1597
|
-
// 恢复表格边框状态
|
|
1598
|
-
this.currentTableBorders = this.tableBordersStack.pop()
|
|
1599
|
-
|
|
1600
|
-
return table
|
|
1601
|
-
}
|
|
1602
|
-
|
|
1603
|
-
/**
|
|
1604
|
-
* 应用表格样式
|
|
1605
|
-
*/
|
|
1606
|
-
private applyTableStyles(table: HTMLElement, props: ITableProperties): void {
|
|
1607
|
-
if (props.width) {
|
|
1608
|
-
if (props.widthType === 'pct') {
|
|
1609
|
-
table.style.width = props.width
|
|
1610
|
-
} else {
|
|
1611
|
-
table.style.width = props.width
|
|
1612
|
-
}
|
|
1613
|
-
}
|
|
1614
|
-
|
|
1615
|
-
if (props.justification === 'center') {
|
|
1616
|
-
table.style.marginLeft = 'auto'
|
|
1617
|
-
table.style.marginRight = 'auto'
|
|
1618
|
-
}
|
|
1619
|
-
|
|
1620
|
-
// 应用单元格间距
|
|
1621
|
-
if (props.cellSpacing) {
|
|
1622
|
-
table.style.borderSpacing = props.cellSpacing
|
|
1623
|
-
table.style.borderCollapse = 'separate'
|
|
1624
|
-
}
|
|
1625
|
-
}
|
|
1626
|
-
|
|
1627
|
-
/**
|
|
1628
|
-
* 将边框属性转换为 CSS 字符串
|
|
1629
|
-
*/
|
|
1630
|
-
private borderToCss(border: IBorder | undefined): string {
|
|
1631
|
-
if (!border || !border.style) {
|
|
1632
|
-
return 'none'
|
|
1633
|
-
}
|
|
1634
|
-
|
|
1635
|
-
// 转换 Word 边框类型到 CSS 边框类型
|
|
1636
|
-
const cssStyle = this.parseBorderType(border.style)
|
|
1637
|
-
|
|
1638
|
-
if (cssStyle === 'none') {
|
|
1639
|
-
return 'none'
|
|
1640
|
-
}
|
|
1641
|
-
|
|
1642
|
-
const width = border.width || '1px'
|
|
1643
|
-
const color = border.color || 'black'
|
|
1644
|
-
|
|
1645
|
-
return `${width} ${cssStyle} ${color}`
|
|
1646
|
-
}
|
|
1647
|
-
|
|
1648
|
-
/**
|
|
1649
|
-
* 将 Word 边框类型转换为 CSS 边框样式
|
|
1650
|
-
*/
|
|
1651
|
-
private parseBorderType(type: string | undefined): string {
|
|
1652
|
-
switch (type) {
|
|
1653
|
-
case 'single':
|
|
1654
|
-
return 'solid'
|
|
1655
|
-
case 'dashDotStroked':
|
|
1656
|
-
return 'solid'
|
|
1657
|
-
case 'dashed':
|
|
1658
|
-
return 'dashed'
|
|
1659
|
-
case 'dashSmallGap':
|
|
1660
|
-
return 'dashed'
|
|
1661
|
-
case 'dotDash':
|
|
1662
|
-
return 'dotted'
|
|
1663
|
-
case 'dotDotDash':
|
|
1664
|
-
return 'dotted'
|
|
1665
|
-
case 'dotted':
|
|
1666
|
-
return 'dotted'
|
|
1667
|
-
case 'double':
|
|
1668
|
-
return 'double'
|
|
1669
|
-
case 'doubleWave':
|
|
1670
|
-
return 'double'
|
|
1671
|
-
case 'inset':
|
|
1672
|
-
return 'inset'
|
|
1673
|
-
case 'nil':
|
|
1674
|
-
return 'none'
|
|
1675
|
-
case 'none':
|
|
1676
|
-
return 'none'
|
|
1677
|
-
case 'outset':
|
|
1678
|
-
return 'outset'
|
|
1679
|
-
case 'thick':
|
|
1680
|
-
return 'solid'
|
|
1681
|
-
case 'thickThinLargeGap':
|
|
1682
|
-
return 'solid'
|
|
1683
|
-
case 'thickThinMediumGap':
|
|
1684
|
-
return 'solid'
|
|
1685
|
-
case 'thickThinSmallGap':
|
|
1686
|
-
return 'solid'
|
|
1687
|
-
case 'thinThickLargeGap':
|
|
1688
|
-
return 'solid'
|
|
1689
|
-
case 'thinThickMediumGap':
|
|
1690
|
-
return 'solid'
|
|
1691
|
-
case 'thinThickSmallGap':
|
|
1692
|
-
return 'solid'
|
|
1693
|
-
case 'thinThickThinLargeGap':
|
|
1694
|
-
return 'solid'
|
|
1695
|
-
case 'thinThickThinMediumGap':
|
|
1696
|
-
return 'solid'
|
|
1697
|
-
case 'thinThickThinSmallGap':
|
|
1698
|
-
return 'solid'
|
|
1699
|
-
case 'threeDEmboss':
|
|
1700
|
-
return 'solid'
|
|
1701
|
-
case 'threeDEngrave':
|
|
1702
|
-
return 'solid'
|
|
1703
|
-
case 'triple':
|
|
1704
|
-
return 'double'
|
|
1705
|
-
case 'wave':
|
|
1706
|
-
return 'solid'
|
|
1707
|
-
default:
|
|
1708
|
-
return 'solid'
|
|
1709
|
-
}
|
|
1710
|
-
}
|
|
1711
|
-
|
|
1712
|
-
/**
|
|
1713
|
-
* 应用单元格边框样式
|
|
1714
|
-
*/
|
|
1715
|
-
private applyCellBorders(
|
|
1716
|
-
td: HTMLElement,
|
|
1717
|
-
cellBorders: IBorders | undefined,
|
|
1718
|
-
rowIndex: number,
|
|
1719
|
-
colIndex: number,
|
|
1720
|
-
colSpan: number
|
|
1721
|
-
): void {
|
|
1722
|
-
const tableBorders = this.currentTableBorders
|
|
1723
|
-
const isFirstRow = rowIndex === 0
|
|
1724
|
-
const isLastRow = rowIndex === this.currentTableRowCount - 1
|
|
1725
|
-
const isFirstCol = colIndex === 0
|
|
1726
|
-
const isLastCol = colIndex + colSpan >= this.currentTableColCount
|
|
1727
|
-
|
|
1728
|
-
// 优先使用单元格自身边框,其次使用表格边框
|
|
1729
|
-
// Top border
|
|
1730
|
-
let topBorder = cellBorders?.top
|
|
1731
|
-
if (!topBorder) {
|
|
1732
|
-
if (isFirstRow) {
|
|
1733
|
-
topBorder = tableBorders?.top
|
|
1734
|
-
} else {
|
|
1735
|
-
topBorder = tableBorders?.insideH
|
|
1736
|
-
}
|
|
1737
|
-
}
|
|
1738
|
-
if (topBorder) {
|
|
1739
|
-
td.style.borderTop = this.borderToCss(topBorder)
|
|
1740
|
-
}
|
|
1741
|
-
|
|
1742
|
-
// Bottom border
|
|
1743
|
-
let bottomBorder = cellBorders?.bottom
|
|
1744
|
-
if (!bottomBorder) {
|
|
1745
|
-
if (isLastRow) {
|
|
1746
|
-
bottomBorder = tableBorders?.bottom
|
|
1747
|
-
} else {
|
|
1748
|
-
bottomBorder = tableBorders?.insideH
|
|
1749
|
-
}
|
|
1750
|
-
}
|
|
1751
|
-
if (bottomBorder) {
|
|
1752
|
-
td.style.borderBottom = this.borderToCss(bottomBorder)
|
|
1753
|
-
}
|
|
1754
|
-
|
|
1755
|
-
// Left border
|
|
1756
|
-
let leftBorder = cellBorders?.left
|
|
1757
|
-
if (!leftBorder) {
|
|
1758
|
-
if (isFirstCol) {
|
|
1759
|
-
leftBorder = tableBorders?.left
|
|
1760
|
-
} else {
|
|
1761
|
-
leftBorder = tableBorders?.insideV
|
|
1762
|
-
}
|
|
1763
|
-
}
|
|
1764
|
-
if (leftBorder) {
|
|
1765
|
-
td.style.borderLeft = this.borderToCss(leftBorder)
|
|
1766
|
-
}
|
|
1767
|
-
|
|
1768
|
-
// Right border
|
|
1769
|
-
let rightBorder = cellBorders?.right
|
|
1770
|
-
if (!rightBorder) {
|
|
1771
|
-
if (isLastCol) {
|
|
1772
|
-
rightBorder = tableBorders?.right
|
|
1773
|
-
} else {
|
|
1774
|
-
rightBorder = tableBorders?.insideV
|
|
1775
|
-
}
|
|
1776
|
-
}
|
|
1777
|
-
if (rightBorder) {
|
|
1778
|
-
td.style.borderRight = this.borderToCss(rightBorder)
|
|
1779
|
-
}
|
|
1780
|
-
}
|
|
1781
|
-
|
|
1782
|
-
/**
|
|
1783
|
-
* 渲染表格行
|
|
1784
|
-
*/
|
|
1785
|
-
private renderTableRow(element: ITableRowElement): HTMLElement {
|
|
1786
|
-
const tr = this.createElement('tr', `${this.classPrefix}-tr`)
|
|
1787
|
-
this.currentCellCol = 0
|
|
1788
|
-
|
|
1789
|
-
for (const cell of element.children || []) {
|
|
1790
|
-
const td = this.renderTableCell(cell)
|
|
1791
|
-
if (td) {
|
|
1792
|
-
tr.appendChild(td)
|
|
1793
|
-
}
|
|
1794
|
-
}
|
|
1795
|
-
|
|
1796
|
-
return tr
|
|
1797
|
-
}
|
|
1798
|
-
|
|
1799
|
-
/**
|
|
1800
|
-
* 渲染表格单元格
|
|
1801
|
-
*/
|
|
1802
|
-
private renderTableCell(element: ITableCellElement): HTMLElement | null {
|
|
1803
|
-
const td = this.createElement('td', `${this.classPrefix}-td`) as HTMLTableCellElement
|
|
1804
|
-
|
|
1805
|
-
const props = element.props
|
|
1806
|
-
const colKey = this.currentCellCol
|
|
1807
|
-
const colSpan = props?.gridSpan || 1
|
|
1808
|
-
|
|
1809
|
-
// 处理垂直合并
|
|
1810
|
-
if (props?.verticalMerge) {
|
|
1811
|
-
if (props.verticalMerge === 'restart') {
|
|
1812
|
-
// 开始新的垂直合并
|
|
1813
|
-
this.currentVerticalMerge.set(colKey, td)
|
|
1814
|
-
td.rowSpan = 1
|
|
1815
|
-
} else {
|
|
1816
|
-
// 继续垂直合并
|
|
1817
|
-
const mergeCell = this.currentVerticalMerge.get(colKey)
|
|
1818
|
-
if (mergeCell) {
|
|
1819
|
-
mergeCell.rowSpan += 1
|
|
1820
|
-
this.currentCellCol += colSpan
|
|
1821
|
-
return null // 不渲染这个单元格
|
|
1822
|
-
}
|
|
1823
|
-
}
|
|
1824
|
-
} else {
|
|
1825
|
-
// 清除垂直合并状态
|
|
1826
|
-
this.currentVerticalMerge.delete(colKey)
|
|
1827
|
-
}
|
|
1828
|
-
|
|
1829
|
-
// 应用单元格样式
|
|
1830
|
-
if (props) {
|
|
1831
|
-
if (props.width) {
|
|
1832
|
-
td.style.width = props.width
|
|
1833
|
-
}
|
|
1834
|
-
if (props.verticalAlign) {
|
|
1835
|
-
td.style.verticalAlign = props.verticalAlign
|
|
1836
|
-
}
|
|
1837
|
-
if (props.shading) {
|
|
1838
|
-
td.style.backgroundColor = props.shading
|
|
1839
|
-
}
|
|
1840
|
-
if (colSpan > 1) {
|
|
1841
|
-
td.colSpan = colSpan
|
|
1842
|
-
}
|
|
1843
|
-
}
|
|
1844
|
-
|
|
1845
|
-
// 应用单元格边框(优先使用单元格边框,其次使用表格边框)
|
|
1846
|
-
this.applyCellBorders(
|
|
1847
|
-
td,
|
|
1848
|
-
props?.borders,
|
|
1849
|
-
this.currentTableRowIndex,
|
|
1850
|
-
colKey,
|
|
1851
|
-
colSpan
|
|
1852
|
-
)
|
|
1853
|
-
|
|
1854
|
-
// 渲染内容
|
|
1855
|
-
this.renderChildren(element.children || [], td)
|
|
1856
|
-
|
|
1857
|
-
// 更新列位置
|
|
1858
|
-
this.currentCellCol += colSpan
|
|
1859
|
-
|
|
1860
|
-
return td
|
|
1861
|
-
}
|
|
1862
|
-
|
|
1863
|
-
/**
|
|
1864
|
-
* 渲染超链接
|
|
1865
|
-
*/
|
|
1866
|
-
private renderHyperlink(element: IHyperlinkElement): HTMLElement {
|
|
1867
|
-
const a = document.createElement('a')
|
|
1868
|
-
a.className = `${this.classPrefix}-link`
|
|
1869
|
-
|
|
1870
|
-
if (element.href) {
|
|
1871
|
-
a.href = element.href
|
|
1872
|
-
a.target = '_blank'
|
|
1873
|
-
a.rel = 'noopener noreferrer'
|
|
1874
|
-
} else if (element.anchor) {
|
|
1875
|
-
a.href = `#${element.anchor}`
|
|
1876
|
-
}
|
|
1877
|
-
|
|
1878
|
-
this.renderChildren(element.children || [], a)
|
|
1879
|
-
return a
|
|
1880
|
-
}
|
|
1881
|
-
|
|
1882
|
-
/**
|
|
1883
|
-
* 渲染绘图
|
|
1884
|
-
*/
|
|
1885
|
-
private renderDrawing(element: IDrawingElement): HTMLElement {
|
|
1886
|
-
const span = this.createElement('span', `${this.classPrefix}-drawing`)
|
|
1887
|
-
this.renderChildren(element.children || [], span)
|
|
1888
|
-
return span
|
|
1889
|
-
}
|
|
1890
|
-
|
|
1891
|
-
/**
|
|
1892
|
-
* 渲染图片
|
|
1893
|
-
*/
|
|
1894
|
-
private renderImage(element: IImageElement): HTMLElement {
|
|
1895
|
-
const img = document.createElement('img') as HTMLImageElement
|
|
1896
|
-
img.className = `${this.classPrefix}-image`
|
|
1897
|
-
img.src = element.src
|
|
1898
|
-
|
|
1899
|
-
if (element.width) img.style.width = element.width
|
|
1900
|
-
if (element.height) img.style.height = element.height
|
|
1901
|
-
if (element.alt) img.alt = element.alt
|
|
1902
|
-
|
|
1903
|
-
return img
|
|
1904
|
-
}
|
|
1905
|
-
|
|
1906
|
-
/**
|
|
1907
|
-
* 渲染评论范围开始
|
|
1908
|
-
*/
|
|
1909
|
-
private renderCommentRangeStart(element: ICommentRangeStart): HTMLElement {
|
|
1910
|
-
this.currentCommentIds.add(element.id)
|
|
1911
|
-
this.commentStartInParagraph.add(element.id) // 记录这个评论在当前段落开始
|
|
1912
|
-
|
|
1913
|
-
const marker = this.createElement('span', `${this.classPrefix}-comment-start`)
|
|
1914
|
-
marker.dataset.commentId = element.id
|
|
1915
|
-
|
|
1916
|
-
const range = this.commentRanges.get(element.id)
|
|
1917
|
-
if (range) {
|
|
1918
|
-
range.startElement = marker
|
|
1919
|
-
}
|
|
1920
|
-
|
|
1921
|
-
return marker
|
|
1922
|
-
}
|
|
1923
|
-
|
|
1924
|
-
/**
|
|
1925
|
-
* 渲染评论范围结束
|
|
1926
|
-
*/
|
|
1927
|
-
private renderCommentRangeEnd(element: ICommentRangeEnd): HTMLElement {
|
|
1928
|
-
this.currentCommentIds.delete(element.id)
|
|
1929
|
-
|
|
1930
|
-
const marker = this.createElement('span', `${this.classPrefix}-comment-end`)
|
|
1931
|
-
marker.dataset.commentId = element.id
|
|
1932
|
-
|
|
1933
|
-
const range = this.commentRanges.get(element.id)
|
|
1934
|
-
if (range) {
|
|
1935
|
-
range.endElement = marker
|
|
1936
|
-
}
|
|
1937
|
-
|
|
1938
|
-
return marker
|
|
1939
|
-
}
|
|
1940
|
-
|
|
1941
|
-
/**
|
|
1942
|
-
* 渲染评论引用
|
|
1943
|
-
*/
|
|
1944
|
-
private renderCommentReference(element: ICommentReference): HTMLElement {
|
|
1945
|
-
const marker = this.createElement('span', `${this.classPrefix}-comment-ref`)
|
|
1946
|
-
marker.dataset.commentId = element.id
|
|
1947
|
-
marker.textContent = '📝'
|
|
1948
|
-
marker.title = '查看评论'
|
|
1949
|
-
|
|
1950
|
-
marker.addEventListener('click', () => {
|
|
1951
|
-
this.highlightComment(element.id)
|
|
1952
|
-
})
|
|
1953
|
-
|
|
1954
|
-
return marker
|
|
1955
|
-
}
|
|
1956
|
-
|
|
1957
|
-
/**
|
|
1958
|
-
* 渲染书签开始标记
|
|
1959
|
-
* 创建一个锚点元素,供超链接跳转使用
|
|
1960
|
-
*/
|
|
1961
|
-
private renderBookmarkStart(element: IBookmarkStartElement): HTMLElement {
|
|
1962
|
-
// 忽略以 _ 开头的内置书签(如 _GoBack)
|
|
1963
|
-
if (element.name.startsWith('_')) {
|
|
1964
|
-
const empty = this.createElement('span')
|
|
1965
|
-
return empty
|
|
1966
|
-
}
|
|
1967
|
-
|
|
1968
|
-
const anchor = this.createElement('span', `${this.classPrefix}-bookmark`)
|
|
1969
|
-
// 设置 id 属性,用于超链接跳转(href="#bookmarkName")
|
|
1970
|
-
anchor.id = element.name
|
|
1971
|
-
anchor.dataset.bookmarkId = element.id
|
|
1972
|
-
anchor.dataset.bookmarkName = element.name
|
|
1973
|
-
|
|
1974
|
-
return anchor
|
|
1975
|
-
}
|
|
1976
|
-
|
|
1977
|
-
/**
|
|
1978
|
-
* 渲染书签结束标记
|
|
1979
|
-
* 书签结束标记不需要渲染任何可见内容
|
|
1980
|
-
*/
|
|
1981
|
-
private renderBookmarkEnd(_element: IBookmarkEndElement): HTMLElement {
|
|
1982
|
-
// 返回一个空的 span,不影响文档结构
|
|
1983
|
-
return this.createElement('span')
|
|
1984
|
-
}
|
|
1985
|
-
|
|
1986
|
-
/**
|
|
1987
|
-
* 渲染脚注引用(文档正文中的上标数字)
|
|
1988
|
-
*/
|
|
1989
|
-
private renderFootnoteReference(element: IFootnoteReference): HTMLElement {
|
|
1990
|
-
this.footnoteCounter++
|
|
1991
|
-
this.currentFootnoteIds.push(element.id)
|
|
1992
|
-
|
|
1993
|
-
const sup = this.createElement('sup', `${this.classPrefix}-footnote-ref`)
|
|
1994
|
-
sup.dataset.footnoteId = element.id
|
|
1995
|
-
sup.textContent = String(this.footnoteCounter)
|
|
1996
|
-
sup.title = '脚注'
|
|
1997
|
-
|
|
1998
|
-
// 点击跳转到脚注
|
|
1999
|
-
sup.addEventListener('click', () => {
|
|
2000
|
-
const footnoteEl = document.getElementById(`${this.classPrefix}-footnote-${element.id}`)
|
|
2001
|
-
footnoteEl?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
2002
|
-
})
|
|
2003
|
-
|
|
2004
|
-
return sup
|
|
2005
|
-
}
|
|
2006
|
-
|
|
2007
|
-
/**
|
|
2008
|
-
* 渲染尾注引用(文档正文中的上标数字)
|
|
2009
|
-
*/
|
|
2010
|
-
private renderEndnoteReference(element: IEndnoteReference): HTMLElement {
|
|
2011
|
-
this.endnoteCounter++
|
|
2012
|
-
this.currentEndnoteIds.push(element.id)
|
|
2013
|
-
|
|
2014
|
-
const sup = this.createElement('sup', `${this.classPrefix}-endnote-ref`)
|
|
2015
|
-
sup.dataset.endnoteId = element.id
|
|
2016
|
-
sup.textContent = String(this.endnoteCounter)
|
|
2017
|
-
sup.title = '尾注'
|
|
2018
|
-
|
|
2019
|
-
// 点击跳转到尾注
|
|
2020
|
-
sup.addEventListener('click', () => {
|
|
2021
|
-
const endnoteEl = document.getElementById(`${this.classPrefix}-endnote-${element.id}`)
|
|
2022
|
-
endnoteEl?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
2023
|
-
})
|
|
2024
|
-
|
|
2025
|
-
return sup
|
|
2026
|
-
}
|
|
2027
|
-
|
|
2028
|
-
/**
|
|
2029
|
-
* 渲染脚注内容
|
|
2030
|
-
*/
|
|
2031
|
-
private renderFootnote(element: IFootnoteElement): HTMLElement {
|
|
2032
|
-
const li = this.createElement('li', `${this.classPrefix}-footnote`)
|
|
2033
|
-
li.id = `${this.classPrefix}-footnote-${element.id}`
|
|
2034
|
-
li.dataset.footnoteId = element.id
|
|
2035
|
-
|
|
2036
|
-
this.renderChildren(element.children || [], li)
|
|
2037
|
-
|
|
2038
|
-
return li
|
|
2039
|
-
}
|
|
2040
|
-
|
|
2041
|
-
/**
|
|
2042
|
-
* 渲染尾注内容
|
|
2043
|
-
*/
|
|
2044
|
-
private renderEndnote(element: IEndnoteElement): HTMLElement {
|
|
2045
|
-
const li = this.createElement('li', `${this.classPrefix}-endnote`)
|
|
2046
|
-
li.id = `${this.classPrefix}-endnote-${element.id}`
|
|
2047
|
-
li.dataset.endnoteId = element.id
|
|
2048
|
-
|
|
2049
|
-
this.renderChildren(element.children || [], li)
|
|
2050
|
-
|
|
2051
|
-
return li
|
|
2052
|
-
}
|
|
2053
|
-
|
|
2054
|
-
/**
|
|
2055
|
-
* 渲染页面脚注区域
|
|
2056
|
-
*/
|
|
2057
|
-
private renderPageFootnotes(footnoteIds: string[], container: HTMLElement): void {
|
|
2058
|
-
if (footnoteIds.length === 0 || !this.document?.footnotes) return
|
|
2059
|
-
|
|
2060
|
-
const footnotesSection = this.createElement('div', `${this.classPrefix}-footnotes-section`)
|
|
2061
|
-
|
|
2062
|
-
// 分隔线
|
|
2063
|
-
const separator = this.createElement('hr', `${this.classPrefix}-footnotes-separator`)
|
|
2064
|
-
footnotesSection.appendChild(separator)
|
|
2065
|
-
|
|
2066
|
-
// 脚注列表
|
|
2067
|
-
const ol = this.createElement('ol', `${this.classPrefix}-footnotes-list`)
|
|
2068
|
-
|
|
2069
|
-
for (const id of footnoteIds) {
|
|
2070
|
-
const footnote = this.document.footnotes.get(id)
|
|
2071
|
-
if (footnote) {
|
|
2072
|
-
const rendered = this.renderFootnote(footnote)
|
|
2073
|
-
ol.appendChild(rendered)
|
|
2074
|
-
}
|
|
2075
|
-
}
|
|
2076
|
-
|
|
2077
|
-
footnotesSection.appendChild(ol)
|
|
2078
|
-
container.appendChild(footnotesSection)
|
|
2079
|
-
}
|
|
2080
|
-
|
|
2081
|
-
/**
|
|
2082
|
-
* 渲染文档尾注区域
|
|
2083
|
-
*/
|
|
2084
|
-
private renderDocumentEndnotes(wrapper: HTMLElement): void {
|
|
2085
|
-
if (this.currentEndnoteIds.length === 0 || !this.document?.endnotes) return
|
|
2086
|
-
|
|
2087
|
-
const endnotesSection = this.createElement('div', `${this.classPrefix}-endnotes-section`)
|
|
2088
|
-
|
|
2089
|
-
// 标题
|
|
2090
|
-
const title = this.createElement('h3', `${this.classPrefix}-endnotes-title`)
|
|
2091
|
-
title.textContent = '尾注'
|
|
2092
|
-
endnotesSection.appendChild(title)
|
|
2093
|
-
|
|
2094
|
-
// 尾注列表
|
|
2095
|
-
const ol = this.createElement('ol', `${this.classPrefix}-endnotes-list`)
|
|
2096
|
-
|
|
2097
|
-
for (const id of this.currentEndnoteIds) {
|
|
2098
|
-
const endnote = this.document.endnotes.get(id)
|
|
2099
|
-
if (endnote) {
|
|
2100
|
-
const rendered = this.renderEndnote(endnote)
|
|
2101
|
-
ol.appendChild(rendered)
|
|
2102
|
-
}
|
|
2103
|
-
}
|
|
2104
|
-
|
|
2105
|
-
endnotesSection.appendChild(ol)
|
|
2106
|
-
wrapper.appendChild(endnotesSection)
|
|
2107
|
-
}
|
|
2108
|
-
|
|
2109
|
-
/**
|
|
2110
|
-
* 渲染子元素
|
|
2111
|
-
*/
|
|
2112
|
-
private renderChildren(children: IOpenXmlElement[], parent: HTMLElement): void {
|
|
2113
|
-
for (const child of children) {
|
|
2114
|
-
const rendered = this.renderElement(child)
|
|
2115
|
-
if (rendered) {
|
|
2116
|
-
parent.appendChild(rendered)
|
|
2117
|
-
}
|
|
2118
|
-
}
|
|
2119
|
-
}
|
|
2120
|
-
|
|
2121
|
-
/**
|
|
2122
|
-
* 创建元素
|
|
2123
|
-
*/
|
|
2124
|
-
private createElement(tag: string, className?: string): HTMLElement {
|
|
2125
|
-
const el = document.createElement(tag)
|
|
2126
|
-
if (className) {
|
|
2127
|
-
el.className = className
|
|
2128
|
-
}
|
|
2129
|
-
return el
|
|
2130
|
-
}
|
|
2131
|
-
|
|
2132
|
-
/**
|
|
2133
|
-
* 格式化日期
|
|
2134
|
-
*/
|
|
2135
|
-
private formatDate(dateStr: string): string {
|
|
2136
|
-
try {
|
|
2137
|
-
const date = new Date(dateStr)
|
|
2138
|
-
return date.toLocaleDateString('zh-CN', {
|
|
2139
|
-
year: 'numeric',
|
|
2140
|
-
month: '2-digit',
|
|
2141
|
-
day: '2-digit',
|
|
2142
|
-
})
|
|
2143
|
-
} catch {
|
|
2144
|
-
return dateStr
|
|
2145
|
-
}
|
|
2146
|
-
}
|
|
2147
|
-
|
|
2148
|
-
/**
|
|
2149
|
-
* 转义 HTML
|
|
2150
|
-
*/
|
|
2151
|
-
private escapeHtml(str: string): string {
|
|
2152
|
-
const div = document.createElement('div')
|
|
2153
|
-
div.textContent = str
|
|
2154
|
-
return div.innerHTML
|
|
2155
|
-
}
|
|
2156
|
-
|
|
2157
|
-
/**
|
|
2158
|
-
* 获取文档对象
|
|
2159
|
-
*/
|
|
2160
|
-
getDocument(): IDocxDocument | null {
|
|
2161
|
-
return this.document
|
|
2162
|
-
}
|
|
2163
|
-
}
|