@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.
@@ -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 = '&emsp;'
834
- break
835
- case 'space':
836
- suffix.innerHTML = '&nbsp;'
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 = '&emsp;' // 使用 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
- }