aegon-lv97 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/gen-ai/build-report/chartStreamRefs.ts +227 -0
  2. package/gen-ai/build-report/coherence-check/index.vue +559 -0
  3. package/gen-ai/build-report/components/ReportChapterWorkspace.vue +4571 -0
  4. package/gen-ai/build-report/components/ReportConfigPanel.vue +727 -0
  5. package/gen-ai/build-report/components/ReportGenerateSettingsPanel.vue +2361 -0
  6. package/gen-ai/build-report/components/ReportGeneratingPanel.vue +1765 -0
  7. package/gen-ai/build-report/components/ReportPromptPanel.vue +625 -0
  8. package/gen-ai/build-report/components/ReportSourceFileSummary.vue +665 -0
  9. package/gen-ai/build-report/components/ReportSourcesPanel.vue +1327 -0
  10. package/gen-ai/build-report/components/ReportTemplateDetail.vue +191 -0
  11. package/gen-ai/build-report/components/ReportTemplateEditorFormPanel.vue +667 -0
  12. package/gen-ai/build-report/components/ReportTemplatePicker.vue +302 -0
  13. package/gen-ai/build-report/components/ReportWorkflowSidebar.vue +148 -0
  14. package/gen-ai/build-report/components/paramsSetting.vue +886 -0
  15. package/gen-ai/build-report/components/reportEdit.vue +2283 -0
  16. package/gen-ai/build-report/index.vue +856 -0
  17. package/gen-ai/build-report/report-domain.ts +1133 -0
  18. package/gen-ai/build-report/report-template-data.ts +295 -0
  19. package/gen-ai/build-report/useReportWorkflow.ts +1386 -0
  20. package/gen-ai/common/assets/NotoSans-SC.woff2 +0 -0
  21. package/gen-ai/common/assets/NotoSans-TC.woff2 +0 -0
  22. package/gen-ai/common/assets/NotoSans.woff2 +0 -0
  23. package/gen-ai/common/assets/fonts.css +278 -0
  24. package/gen-ai/common/assets/pagination.css +163 -0
  25. package/gen-ai/common/directives/btnLoading.ts +455 -0
  26. package/gen-ai/common/directives/debounce.ts +98 -0
  27. package/gen-ai/common/directives/index.ts +30 -0
  28. package/gen-ai/common/en.md +417 -0
  29. package/gen-ai/common/index.ts +36 -0
  30. package/gen-ai/common/types/component-expose.ts +9 -0
  31. package/gen-ai/common/utils/api-res-status.ts +7 -0
  32. package/gen-ai/common/utils/navigation.ts +59 -0
  33. package/gen-ai/common/utils/remote-request.ts +45 -0
  34. package/gen-ai/common/utils/scroll.ts +577 -0
  35. package/gen-ai/components/AIExport/docx-export.ts +950 -0
  36. package/gen-ai/components/AIExport/docxExport.ts +2566 -0
  37. package/gen-ai/components/AIExport/index.ts +52 -0
  38. package/gen-ai/components/AIExport/parseMarkdownHtml.ts +653 -0
  39. package/gen-ai/components/CapsuleScrollbar.vue +149 -0
  40. package/gen-ai/components/ChapterTitleScroll.vue +468 -0
  41. package/gen-ai/components/EntryHeroNavCards.vue +379 -0
  42. package/gen-ai/components/GenAiPagination.vue +52 -0
  43. package/gen-ai/components/ModelCard.vue +1114 -0
  44. package/gen-ai/components/Preview/FilePreviewPage.vue +949 -0
  45. package/gen-ai/components/Preview/PreviewCapsuleScroll.vue +205 -0
  46. package/gen-ai/components/Preview/PreviewOutlineTree.vue +148 -0
  47. package/gen-ai/components/Preview/index.ts +11 -0
  48. package/gen-ai/components/Preview/preview.ts +724 -0
  49. package/gen-ai/components/PromptField.vue +505 -0
  50. package/gen-ai/components/RecentReportsTable.vue +974 -0
  51. package/gen-ai/components/ReportCollapsibleSection.vue +172 -0
  52. package/gen-ai/components/ReportHistoryList.vue +637 -0
  53. package/gen-ai/components/ReportSectionConfirmPopover.vue +251 -0
  54. package/gen-ai/components/editor/ChapterTiptapEditor.vue +3718 -0
  55. package/gen-ai/components/editor/StructureBlockView.vue +441 -0
  56. package/gen-ai/components/editor/TiptapToolbar.vue +1036 -0
  57. package/gen-ai/components/editor/TypewriterDocEditor.vue +422 -0
  58. package/gen-ai/components/editor/_StructureOrgNode.vue +326 -0
  59. package/gen-ai/components/editor/index.ts +28 -0
  60. package/gen-ai/components/editor/parseMarkdownHtml.ts +754 -0
  61. package/gen-ai/components/editor/reviewCitationTip.ts +163 -0
  62. package/gen-ai/gen-entry/api/index.ts +32 -0
  63. package/gen-ai/gen-entry/index.vue +287 -0
  64. package/gen-ai/gen-entry/types/index.ts +4 -0
  65. package/gen-ai/i.mark +2 -0
  66. package/gen-ai/management-center/index.vue +1815 -0
  67. package/gen-ai/management-center/mockData.ts +248 -0
  68. package/gen-ai/management-center/types/index.ts +37 -0
  69. package/gen-ai/model-manager/TemplateEditorFormPanel.vue +661 -0
  70. package/gen-ai/model-manager/api/index.ts +32 -0
  71. package/gen-ai/model-manager/api/mockApi.ts +33 -0
  72. package/gen-ai/model-manager/index.vue +1583 -0
  73. package/gen-ai/model-manager/mockData.ts +177 -0
  74. package/gen-ai/model-manager/template-detail-shared.ts +154 -0
  75. package/gen-ai/model-manager/template-editor.vue +345 -0
  76. package/gen-ai/model-manager/types/index.ts +40 -0
  77. package/gen-ai/model-manager/utils.ts +25 -0
  78. package/package.json +12 -0
@@ -0,0 +1,2566 @@
1
+ import JSZip from 'jszip'
2
+ import html2canvas from 'html2canvas'
3
+
4
+ /** 純 text/html altChunk,不再使用 MHT(避免 --------tDocumentpart-- 殘片) */
5
+
6
+ /** OOXML 根關係(與 html-docx-js 一致) */
7
+ const PACKAGE_RELS_XML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
8
+ <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
9
+ <Relationship
10
+ Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument"
11
+ Target="/word/document.xml" Id="R09c83fafc067488e" />
12
+ </Relationships>
13
+ `
14
+
15
+ /** 與 html-docx-js defaultMargins 數值對齊(twips) */
16
+ export const defaultMargins = {
17
+ top: 1440,
18
+ right: 1440,
19
+ bottom: 1440,
20
+ left: 1440,
21
+ header: 720,
22
+ footer: 720,
23
+ gutter: 0,
24
+ } as const
25
+
26
+ export type DocxMargins = {
27
+ top: number
28
+ right: number
29
+ bottom: number
30
+ left: number
31
+ header: number
32
+ footer: number
33
+ gutter: number
34
+ }
35
+
36
+ export interface DocxHeaderFooterOptions {
37
+ /** 页眉右上角文字,默认「商密三级」(对齐行业调研导出模板) */
38
+ headerText?: string
39
+ /** 页脚是否显示「当前页 / 总页数」,默认 true */
40
+ showPageNumbers?: boolean
41
+ }
42
+
43
+ export interface DocxExportOptions {
44
+ orientation?: 'portrait' | 'landscape'
45
+ margins?: Partial<DocxMargins>
46
+ /** 页面宽度(twips),默认 A4 竖向 */
47
+ pageWidth?: number
48
+ /** 页面高度(twips),默认 A4 竖向 */
49
+ pageHeight?: number
50
+ headerFooter?: DocxHeaderFooterOptions
51
+ }
52
+
53
+ /** A4 竖向:210mm × 297mm(Word twips,1 inch = 1440 twips) */
54
+ export const A4_PORTRAIT = {
55
+ width: 11906,
56
+ height: 16838,
57
+ } as const
58
+
59
+ /** A4 常用 2cm 页边距 ≈ 1134 twips;页眉靠近页顶(约 0.75cm) */
60
+ export const A4_MARGINS_2CM = {
61
+ top: 1134,
62
+ right: 1134,
63
+ bottom: 1134,
64
+ left: 1134,
65
+ header: 425,
66
+ footer: 720,
67
+ gutter: 0,
68
+ } as const
69
+
70
+ /** 可打印区域宽度(px@96dpi):210mm - 左右各 2cm ≈ 643px */
71
+ export const A4_CONTENT_WIDTH_PX = Math.round(((210 - 40) / 25.4) * 96)
72
+
73
+ /** Word 可打印区宽度(pt):210mm - 左右各 2cm ≈ 482pt */
74
+ export const A4_CONTENT_WIDTH_PT = Math.round(((210 - 40) / 25.4) * 72)
75
+
76
+ /** 图表导出高度(与页面 400px 比例协调,且可落在单页 A4 内) */
77
+ export const A4_CHART_HEIGHT_PX = 380
78
+
79
+ /** 单张图表在 A4 可打印区域内的最大高度 */
80
+ export const A4_CHART_MAX_HEIGHT_PX = Math.round(((297 - 4) / 25.4) * 96 * 0.42)
81
+
82
+ /** 左浮动无障碍图标(固定尺寸,不超出页边距) */
83
+ export const DOCX_FLOAT_ICON_SIZE_PX = 72
84
+
85
+ /** 饼图左栏最大宽度:A4 可打印区 42% 减去间距 */
86
+ export const DOCX_PIE_CHART_MAX_WIDTH_PX = Math.min(
87
+ 220,
88
+ Math.floor(A4_CONTENT_WIDTH_PX * 0.42) - 16
89
+ )
90
+
91
+ /** 饼图最大高度(避免 Word 单页溢出) */
92
+ export const DOCX_PIE_CHART_MAX_HEIGHT_PX = Math.min(
93
+ 120,
94
+ Math.floor(A4_CHART_MAX_HEIGHT_PX * 0.32)
95
+ )
96
+
97
+ export const DEFAULT_DOCX_EXPORT_OPTIONS: DocxExportOptions = {
98
+ orientation: 'portrait',
99
+ pageWidth: A4_PORTRAIT.width,
100
+ pageHeight: A4_PORTRAIT.height,
101
+ margins: { ...A4_MARGINS_2CM },
102
+ }
103
+
104
+ function mergeMargins(margins?: Partial<DocxMargins>): DocxMargins {
105
+ return { ...defaultMargins, ...margins }
106
+ }
107
+
108
+ function resolvePageSize(options: DocxExportOptions) {
109
+ const orientation = options.orientation ?? 'portrait'
110
+ if (options.pageWidth && options.pageHeight) {
111
+ return { width: options.pageWidth, height: options.pageHeight, orientation }
112
+ }
113
+ if (orientation === 'landscape') {
114
+ return { width: A4_PORTRAIT.height, height: A4_PORTRAIT.width, orientation }
115
+ }
116
+ return { width: A4_PORTRAIT.width, height: A4_PORTRAIT.height, orientation }
117
+ }
118
+
119
+ /**
120
+ * 不再組 MHT。Word 官方支持 altChunk + text/html;
121
+ * MHT(message/rfc822)在遷移環境極易導入失敗,正文只剩 --------tDocumentpart--。
122
+ */
123
+ function encodeHtmlAltChunk(htmlSource: string): Uint8Array {
124
+ const html = String(htmlSource || '').trim()
125
+ if (!html) throw new Error('Word 導出失敗:HTML 為空')
126
+ // UTF-8 BOM:提升 Word / 部分環境對 charset 的識別
127
+ const bom = new Uint8Array([0xef, 0xbb, 0xbf])
128
+ const body = new TextEncoder().encode(html)
129
+ const out = new Uint8Array(bom.length + body.length)
130
+ out.set(bom, 0)
131
+ out.set(body, bom.length)
132
+ return out
133
+ }
134
+
135
+ function loadImageNaturalSize(
136
+ dataUrl: string
137
+ ): Promise<{ width: number; height: number }> {
138
+ return new Promise((resolve, reject) => {
139
+ const probe = new Image()
140
+ probe.onload = () =>
141
+ resolve({ width: probe.naturalWidth, height: probe.naturalHeight })
142
+ probe.onerror = () => reject(new Error('chart image load failed'))
143
+ probe.src = dataUrl
144
+ })
145
+ }
146
+
147
+ /** 将图表缩放到 A4 页边距内,避免 Word 裁切或撑出页面 */
148
+ function fitChartToA4Content(naturalWidth: number, naturalHeight: number) {
149
+ const maxW = A4_CONTENT_WIDTH_PX
150
+ const maxH = Math.min(A4_CHART_HEIGHT_PX, A4_CHART_MAX_HEIGHT_PX)
151
+
152
+ let width = maxW
153
+ let height = Math.round((naturalHeight / naturalWidth) * width)
154
+
155
+ if (height > maxH) {
156
+ height = maxH
157
+ width = Math.round((naturalWidth / naturalHeight) * height)
158
+ }
159
+
160
+ return { width, height }
161
+ }
162
+
163
+ /** 图表容器:Word 需使用数值 width/height(勿用 100% 属性,否则会只显示一半) */
164
+ async function createChartBlock(dataUrl: string): Promise<HTMLDivElement> {
165
+ const natural = await loadImageNaturalSize(dataUrl)
166
+ const { width, height } = fitChartToA4Content(natural.width, natural.height)
167
+
168
+ const wrap = document.createElement('div')
169
+ wrap.className = 'docx-chart-wrap'
170
+ wrap.style.cssText = [
171
+ `width:${width}px`,
172
+ `max-width:${A4_CONTENT_WIDTH_PX}px`,
173
+ 'margin:12pt auto',
174
+ 'text-align:center',
175
+ 'page-break-inside:avoid',
176
+ 'box-sizing:border-box',
177
+ 'overflow:visible',
178
+ ].join(';')
179
+
180
+ const img = document.createElement('img')
181
+ img.className = 'docx-chart-img'
182
+ img.src = dataUrl
183
+ img.setAttribute('width', String(width))
184
+ img.setAttribute('height', String(height))
185
+ img.style.cssText = [
186
+ `width:${width}px`,
187
+ `height:${height}px`,
188
+ `max-width:${A4_CONTENT_WIDTH_PX}px`,
189
+ 'display:block',
190
+ 'margin:0 auto',
191
+ 'border:0',
192
+ ].join(';')
193
+
194
+ wrap.appendChild(img)
195
+ return wrap
196
+ }
197
+
198
+ /** ECharts:按 A4 可打印区导出完整图表,避免裁切与溢出 */
199
+ export async function rasterizeEchartsForDocx(
200
+ sourceRoot: HTMLElement,
201
+ targetRoot: HTMLElement,
202
+ echartsApi: {
203
+ getInstanceByDom: (el: HTMLElement) => {
204
+ resize: (opts?: { width?: number; height?: number }) => void
205
+ getDataURL: (opts: object) => string
206
+ } | undefined
207
+ }
208
+ ) {
209
+ const sourceCharts = sourceRoot.querySelectorAll('.echarts-container')
210
+ const targetCharts = targetRoot.querySelectorAll('.echarts-container')
211
+ const exportW = A4_CONTENT_WIDTH_PX
212
+ const exportH = A4_CHART_HEIGHT_PX
213
+
214
+ for (let i = 0; i < sourceCharts.length; i++) {
215
+ const sourceEl = sourceCharts[i] as HTMLElement
216
+ const targetEl = targetCharts[i] as HTMLElement
217
+ if (!targetEl) continue
218
+
219
+ const prevWidth = sourceEl.style.width
220
+ const prevHeight = sourceEl.style.height
221
+ const prevMaxWidth = sourceEl.style.maxWidth
222
+
223
+ let dataUrl = ''
224
+ const inst = echartsApi.getInstanceByDom(sourceEl)
225
+ if (inst) {
226
+ try {
227
+ sourceEl.style.width = `${exportW}px`
228
+ sourceEl.style.height = `${exportH}px`
229
+ sourceEl.style.maxWidth = `${exportW}px`
230
+ inst.resize({ width: exportW, height: exportH })
231
+ await new Promise((r) => setTimeout(r, 320))
232
+ dataUrl = inst.getDataURL({
233
+ type: 'png',
234
+ pixelRatio: 1,
235
+ backgroundColor: '#ffffff',
236
+ excludeComponents: ['toolbox', 'dataZoom'],
237
+ })
238
+ } finally {
239
+ sourceEl.style.width = prevWidth
240
+ sourceEl.style.height = prevHeight
241
+ sourceEl.style.maxWidth = prevMaxWidth
242
+ inst.resize()
243
+ }
244
+ }
245
+
246
+ if (!dataUrl) {
247
+ const canvas = sourceEl.querySelector('canvas')
248
+ if (canvas instanceof HTMLCanvasElement) {
249
+ dataUrl = canvas.toDataURL('image/png')
250
+ }
251
+ }
252
+
253
+ if (!dataUrl) continue
254
+
255
+ try {
256
+ const chartBlock = await createChartBlock(dataUrl)
257
+ chartBlock.classList.add('echarts-container')
258
+ targetEl.replaceWith(chartBlock)
259
+ } catch (e) {
260
+ console.warn('Failed to rasterize echarts for docx:', e)
261
+ }
262
+ }
263
+ }
264
+
265
+ async function svgToPngImg(svg: SVGSVGElement): Promise<HTMLImageElement> {
266
+ const clone = svg.cloneNode(true) as SVGSVGElement
267
+ if (!clone.getAttribute('xmlns')) {
268
+ clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg')
269
+ }
270
+ const rect = svg.getBoundingClientRect()
271
+ const w = Math.max(rect.width, 16)
272
+ const h = Math.max(rect.height, 16)
273
+ clone.setAttribute('width', String(w))
274
+ clone.setAttribute('height', String(h))
275
+
276
+ const svgData = new XMLSerializer().serializeToString(clone)
277
+ const url = URL.createObjectURL(
278
+ new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' })
279
+ )
280
+
281
+ try {
282
+ return await new Promise((resolve, reject) => {
283
+ const img = new Image()
284
+ img.onload = () => {
285
+ const canvas = document.createElement('canvas')
286
+ canvas.width = w * 2
287
+ canvas.height = h * 2
288
+ const ctx = canvas.getContext('2d')
289
+ ctx?.drawImage(img, 0, 0, canvas.width, canvas.height)
290
+ const wordImg = document.createElement('img')
291
+ wordImg.src = canvas.toDataURL('image/png')
292
+ wordImg.style.width = `${w}px`
293
+ wordImg.style.height = `${h}px`
294
+ wordImg.style.verticalAlign = 'middle'
295
+ wordImg.style.display = 'inline-block'
296
+ resolve(wordImg)
297
+ }
298
+ img.onerror = () => reject(new Error('SVG load failed'))
299
+ img.src = url
300
+ })
301
+ } finally {
302
+ URL.revokeObjectURL(url)
303
+ }
304
+ }
305
+
306
+ /** Element Plus 图标等:html2canvas 栅格化,避免 <use> 外链丢失 */
307
+ export async function rasterizeIconsForDocx(
308
+ sourceRoot: HTMLElement,
309
+ targetRoot: HTMLElement
310
+ ) {
311
+ const sourceIcons = sourceRoot.querySelectorAll('.el-icon')
312
+ const targetIcons = targetRoot.querySelectorAll('.el-icon')
313
+
314
+ for (let i = 0; i < sourceIcons.length; i++) {
315
+ const src = sourceIcons[i] as HTMLElement
316
+ const tgt = targetIcons[i] as HTMLElement
317
+ if (!tgt) continue
318
+
319
+ try {
320
+ const canvas = await html2canvas(src, {
321
+ backgroundColor: null,
322
+ scale: 2,
323
+ logging: false,
324
+ })
325
+ const img = document.createElement('img')
326
+ img.src = canvas.toDataURL('image/png')
327
+ img.setAttribute('width', '24')
328
+ img.setAttribute('height', '24')
329
+ img.style.width = '24px'
330
+ img.style.height = '24px'
331
+ img.style.verticalAlign = 'middle'
332
+ img.style.display = 'inline-block'
333
+ tgt.replaceWith(img)
334
+ } catch {
335
+ const svg = src.querySelector('svg')
336
+ if (svg) {
337
+ try {
338
+ const img = await svgToPngImg(svg)
339
+ tgt.replaceWith(img)
340
+ } catch {
341
+ /* keep original */
342
+ }
343
+ }
344
+ }
345
+ }
346
+ }
347
+
348
+ /** 图标替换后剩余的独立 SVG(如 Lucide)转 PNG */
349
+ export async function rasterizeStandaloneSvgsForDocx(targetRoot: HTMLElement) {
350
+ const svgs = Array.from(targetRoot.querySelectorAll('svg'))
351
+ for (const svg of svgs) {
352
+ try {
353
+ const img = await svgToPngImg(svg)
354
+ svg.replaceWith(img)
355
+ } catch {
356
+ /* keep */
357
+ }
358
+ }
359
+ }
360
+
361
+ async function fetchImageAsDataUrl(url: string): Promise<string> {
362
+ const res = await fetch(url)
363
+ if (!res.ok) throw new Error(`fetch image failed: ${res.status}`)
364
+ const blob = await res.blob()
365
+ return new Promise((resolve, reject) => {
366
+ const reader = new FileReader()
367
+ reader.onload = () => resolve(reader.result as string)
368
+ reader.onerror = () => reject(new Error('read image blob failed'))
369
+ reader.readAsDataURL(blob)
370
+ })
371
+ }
372
+
373
+ export function getDocxImageTargetSize(img: HTMLImageElement): {
374
+ width: number
375
+ height: number
376
+ } {
377
+ const naturalW = img.naturalWidth || Number.parseInt(img.getAttribute('width') || '0', 10) || 100
378
+ const naturalH = img.naturalHeight || Number.parseInt(img.getAttribute('height') || '0', 10) || 100
379
+
380
+ if (img.classList.contains('doc-float-icon')) {
381
+ return {
382
+ width: DOCX_FLOAT_ICON_SIZE_PX,
383
+ height: DOCX_FLOAT_ICON_SIZE_PX,
384
+ }
385
+ }
386
+
387
+ if (img.classList.contains('doc-pie-chart-img')) {
388
+ let width = Math.min(
389
+ DOCX_PIE_CHART_MAX_WIDTH_PX,
390
+ Number.parseInt(img.getAttribute('width') || '0', 10) || DOCX_PIE_CHART_MAX_WIDTH_PX,
391
+ A4_CONTENT_WIDTH_PX
392
+ )
393
+ let height = Math.round((naturalH / naturalW) * width)
394
+ if (height > DOCX_PIE_CHART_MAX_HEIGHT_PX) {
395
+ height = DOCX_PIE_CHART_MAX_HEIGHT_PX
396
+ width = Math.round((naturalW / naturalH) * height)
397
+ }
398
+ return { width, height }
399
+ }
400
+
401
+ if (naturalW > A4_CONTENT_WIDTH_PX) {
402
+ return {
403
+ width: A4_CONTENT_WIDTH_PX,
404
+ height: Math.round((naturalH / naturalW) * A4_CONTENT_WIDTH_PX),
405
+ }
406
+ }
407
+
408
+ return { width: naturalW, height: naturalH }
409
+ }
410
+
411
+ function imgElementToDataUrl(
412
+ img: HTMLImageElement,
413
+ targetW: number,
414
+ targetH: number
415
+ ): Promise<string> {
416
+ return new Promise((resolve, reject) => {
417
+ const draw = () => {
418
+ if (!targetW || !targetH) {
419
+ reject(new Error('invalid image dimensions'))
420
+ return
421
+ }
422
+ const canvas = document.createElement('canvas')
423
+ canvas.width = targetW
424
+ canvas.height = targetH
425
+ const ctx = canvas.getContext('2d')
426
+ if (!ctx) {
427
+ reject(new Error('no canvas context'))
428
+ return
429
+ }
430
+ ctx.drawImage(img, 0, 0, targetW, targetH)
431
+ resolve(canvas.toDataURL('image/png'))
432
+ }
433
+
434
+ if (img.complete && img.naturalWidth > 0) {
435
+ draw()
436
+ return
437
+ }
438
+
439
+ const prevOnload = img.onload
440
+ const prevOnerror = img.onerror
441
+ img.onload = () => {
442
+ img.onload = prevOnload
443
+ img.onerror = prevOnerror
444
+ draw()
445
+ }
446
+ img.onerror = () => {
447
+ img.onload = prevOnload
448
+ img.onerror = prevOnerror
449
+ reject(new Error('image load failed'))
450
+ }
451
+ if (!img.complete) {
452
+ const src = img.getAttribute('src')
453
+ if (src) img.src = src
454
+ }
455
+ })
456
+ }
457
+
458
+ /** 将页面中的 <img>(Vite 资源 URL 等)转为 data URL,避免 Word MHT 丢失图片 */
459
+ export async function rasterizeImagesForDocx(
460
+ root: HTMLElement,
461
+ sourceRoot?: HTMLElement
462
+ ) {
463
+ const images = Array.from(root.querySelectorAll('img')) as HTMLImageElement[]
464
+ const sourceImages = sourceRoot
465
+ ? (Array.from(sourceRoot.querySelectorAll('img')) as HTMLImageElement[])
466
+ : images
467
+
468
+ await Promise.all(
469
+ images.map(async (img, index) => {
470
+ const src = img.getAttribute('src') || img.src
471
+ if (!src) return
472
+
473
+ const liveImg = sourceImages[index] ?? img
474
+ const { width, height } = getDocxImageTargetSize(liveImg)
475
+
476
+ if (src.startsWith('data:')) {
477
+ img.setAttribute('width', String(width))
478
+ img.setAttribute('height', String(height))
479
+ img.style.width = `${width}px`
480
+ img.style.height = `${height}px`
481
+ img.style.maxWidth = `${width}px`
482
+ img.style.maxHeight = `${height}px`
483
+ return
484
+ }
485
+
486
+ const applyRasterized = (dataUrl: string) => {
487
+ img.setAttribute('src', dataUrl)
488
+ img.src = dataUrl
489
+ img.setAttribute('width', String(width))
490
+ img.setAttribute('height', String(height))
491
+ img.style.width = `${width}px`
492
+ img.style.height = `${height}px`
493
+ img.style.maxWidth = `${width}px`
494
+ img.style.maxHeight = `${height}px`
495
+ img.style.display = 'block'
496
+ img.style.border = '0'
497
+ }
498
+
499
+ try {
500
+ applyRasterized(await imgElementToDataUrl(liveImg, width, height))
501
+ return
502
+ } catch {
503
+ /* fall through to fetch */
504
+ }
505
+
506
+ try {
507
+ const fetchUrl = liveImg.currentSrc || liveImg.src || src
508
+ const probe = new Image()
509
+ probe.src = await fetchImageAsDataUrl(fetchUrl)
510
+ await new Promise<void>((resolve, reject) => {
511
+ probe.onload = () => resolve()
512
+ probe.onerror = () => reject(new Error('probe load failed'))
513
+ })
514
+ applyRasterized(await imgElementToDataUrl(probe, width, height))
515
+ } catch (e) {
516
+ console.warn('Failed to rasterize image for docx:', src, e)
517
+ }
518
+ })
519
+ )
520
+ }
521
+
522
+ export async function rasterizeMediaForDocx(
523
+ sourceRoot: HTMLElement,
524
+ targetRoot: HTMLElement,
525
+ echartsApi?: {
526
+ getInstanceByDom: (el: HTMLElement) => {
527
+ resize: () => void
528
+ getDataURL: (opts: object) => string
529
+ } | undefined
530
+ }
531
+ ) {
532
+ if (echartsApi) {
533
+ await rasterizeEchartsForDocx(sourceRoot, targetRoot, echartsApi)
534
+ }
535
+ await rasterizeIconsForDocx(sourceRoot, targetRoot)
536
+ await rasterizeStandaloneSvgsForDocx(targetRoot)
537
+ await rasterizeImagesForDocx(targetRoot)
538
+ }
539
+
540
+ const DOCX_XML_NS =
541
+ 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" ' +
542
+ 'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"'
543
+
544
+ function escapeXmlText(text: string): string {
545
+ return text
546
+ .replace(/&/g, '&amp;')
547
+ .replace(/</g, '&lt;')
548
+ .replace(/>/g, '&gt;')
549
+ .replace(/"/g, '&quot;')
550
+ }
551
+
552
+ /** 页眉密级:文字 #606266;文字下方双蓝线 #2262E0(仅跟文字等宽,不通栏) */
553
+ const HEADER_TEXT_COLOR = '606266'
554
+ const HEADER_UNDERLINE_COLOR = '2262E0'
555
+
556
+ /**
557
+ * 頁腳頁碼域:用 fldSimple,避免 complex fldChar+instrText 偶發把 PAGE/NUMPAGES
558
+ * 當正文英文字母顯示(WPS / 部分 Word 打開 altChunk 文檔時)。
559
+ */
560
+ function pageFieldRun(instr: string, placeholder = '1'): string {
561
+ const rPr = `<w:rPr>
562
+ <w:rFonts w:ascii="Times New Roman" w:eastAsia="宋体" w:hAnsi="Times New Roman"/>
563
+ <w:sz w:val="18"/>
564
+ <w:szCs w:val="18"/>
565
+ <w:color w:val="000000"/>
566
+ </w:rPr>`
567
+ return `<w:fldSimple w:instr=" ${instr} ">
568
+ <w:r>
569
+ ${rPr}
570
+ <w:t>${placeholder}</w:t>
571
+ </w:r>
572
+ </w:fldSimple>`
573
+ }
574
+
575
+ /**
576
+ * 页眉「商密三级」:文字 #606266 + 文字等宽双蓝线 #2262E0(同图)。
577
+ * 仅单元格底边 double;表格底边必须 nil,否则会叠出两套双线。
578
+ */
579
+ function buildHeaderXml(headerText: string): string {
580
+ return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
581
+ <w:hdr ${DOCX_XML_NS}>
582
+ <w:tbl>
583
+ <w:tblPr>
584
+ <w:tblW w:w="0" w:type="auto"/>
585
+ <w:jc w:val="right"/>
586
+ <w:tblBorders>
587
+ <w:top w:val="nil"/>
588
+ <w:left w:val="nil"/>
589
+ <w:bottom w:val="nil"/>
590
+ <w:right w:val="nil"/>
591
+ <w:insideH w:val="nil"/>
592
+ <w:insideV w:val="nil"/>
593
+ </w:tblBorders>
594
+ <w:tblCellMar>
595
+ <w:top w:w="0" w:type="dxa"/>
596
+ <w:left w:w="0" w:type="dxa"/>
597
+ <w:bottom w:w="0" w:type="dxa"/>
598
+ <w:right w:w="0" w:type="dxa"/>
599
+ </w:tblCellMar>
600
+ </w:tblPr>
601
+ <w:tr>
602
+ <w:tc>
603
+ <w:tcPr>
604
+ <w:tcW w:w="0" w:type="auto"/>
605
+ <w:tcBorders>
606
+ <w:top w:val="nil"/>
607
+ <w:left w:val="nil"/>
608
+ <w:bottom w:val="double" w:sz="6" w:space="1" w:color="${HEADER_UNDERLINE_COLOR}"/>
609
+ <w:right w:val="nil"/>
610
+ </w:tcBorders>
611
+ </w:tcPr>
612
+ <w:p>
613
+ <w:pPr>
614
+ <w:jc w:val="right"/>
615
+ <w:spacing w:before="0" w:after="0" w:line="240" w:lineRule="auto"/>
616
+ </w:pPr>
617
+ <w:r>
618
+ <w:rPr>
619
+ <w:rFonts w:ascii="Microsoft YaHei" w:eastAsia="微软雅黑" w:hAnsi="Microsoft YaHei"/>
620
+ <w:sz w:val="18"/>
621
+ <w:szCs w:val="18"/>
622
+ <w:color w:val="${HEADER_TEXT_COLOR}"/>
623
+ </w:rPr>
624
+ <w:t>${escapeXmlText(headerText)}</w:t>
625
+ </w:r>
626
+ </w:p>
627
+ </w:tc>
628
+ </w:tr>
629
+ </w:tbl>
630
+ </w:hdr>`
631
+ }
632
+
633
+ function buildFooterXml(): string {
634
+ return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
635
+ <w:ftr ${DOCX_XML_NS}>
636
+ <w:p>
637
+ <w:pPr><w:jc w:val="center"/></w:pPr>
638
+ ${pageFieldRun('PAGE')}
639
+ <w:r>
640
+ <w:rPr>
641
+ <w:rFonts w:ascii="Times New Roman" w:eastAsia="宋体" w:hAnsi="Times New Roman"/>
642
+ <w:sz w:val="18"/>
643
+ <w:szCs w:val="18"/>
644
+ <w:color w:val="000000"/>
645
+ </w:rPr>
646
+ <w:t xml:space="preserve"> / </w:t>
647
+ </w:r>
648
+ ${pageFieldRun('NUMPAGES')}
649
+ </w:p>
650
+ </w:ftr>`
651
+ }
652
+
653
+ function buildDocumentXmlWithHeaderFooter(
654
+ width: number,
655
+ height: number,
656
+ orient: string,
657
+ margins: ReturnType<typeof mergeMargins>
658
+ ): string {
659
+ // 單一 altChunk + text/html(禁止 MHT / 禁止多段 afchunk)
660
+ return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
661
+ <w:document
662
+ xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
663
+ xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math"
664
+ xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
665
+ xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"
666
+ xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
667
+ xmlns:ns6="http://schemas.openxmlformats.org/schemaLibrary/2006/main"
668
+ xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart"
669
+ xmlns:ns8="http://schemas.openxmlformats.org/drawingml/2006/chartDrawing"
670
+ xmlns:dgm="http://schemas.openxmlformats.org/drawingml/2006/diagram"
671
+ xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture"
672
+ xmlns:ns11="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing"
673
+ xmlns:dsp="http://schemas.microsoft.com/office/drawing/2008/diagram"
674
+ xmlns:ns13="urn:schemas-microsoft-com:office:excel"
675
+ xmlns:o="urn:schemas-microsoft-com:office:office"
676
+ xmlns:v="urn:schemas-microsoft-com:vml"
677
+ xmlns:w10="urn:schemas-microsoft-com:office:word"
678
+ xmlns:ns17="urn:schemas-microsoft-com:office:powerpoint"
679
+ xmlns:odx="http://opendope.org/xpaths"
680
+ xmlns:odc="http://opendope.org/conditions"
681
+ xmlns:odq="http://opendope.org/questions"
682
+ xmlns:odi="http://opendope.org/components"
683
+ xmlns:odgm="http://opendope.org/SmartArt/DataHierarchy"
684
+ xmlns:ns24="http://schemas.openxmlformats.org/officeDocument/2006/bibliography"
685
+ xmlns:ns25="http://schemas.openxmlformats.org/drawingml/2006/compatibility"
686
+ xmlns:ns26="http://schemas.openxmlformats.org/drawingml/2006/lockedCanvas">
687
+ <w:body>
688
+ <w:altChunk r:id="htmlChunk" />
689
+ <w:sectPr>
690
+ <w:headerReference w:type="default" r:id="rIdHeader"/>
691
+ <w:footerReference w:type="default" r:id="rIdFooter"/>
692
+ <w:pgSz w:w="${width}" w:h="${height}" w:orient="${orient}" />
693
+ <w:pgMar w:top="${margins.top}"
694
+ w:right="${margins.right}"
695
+ w:bottom="${margins.bottom}"
696
+ w:left="${margins.left}"
697
+ w:header="${margins.header}"
698
+ w:footer="${margins.footer}"
699
+ w:gutter="${margins.gutter}"/>
700
+ </w:sectPr>
701
+ </w:body>
702
+ </w:document>`
703
+ }
704
+
705
+ function buildContentTypesXml(): string {
706
+ return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
707
+ <Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
708
+ <Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml" />
709
+ <Default Extension="html" ContentType="text/html" />
710
+ <Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
711
+ <Override PartName="/word/afchunk.html" ContentType="text/html"/>
712
+ <Override PartName="/word/header1.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml"/>
713
+ <Override PartName="/word/footer1.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml"/>
714
+ <Override PartName="/word/settings.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml"/>
715
+ </Types>
716
+ `
717
+ }
718
+
719
+ function buildDocumentRelsXml(): string {
720
+ // 相對路徑(相對 word/_rels/ → word/afchunk.html)。比絕對 /word/... 更兼容遷移環境 Word
721
+ return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
722
+ <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
723
+ <Relationship Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/aFChunk"
724
+ Target="afchunk.html" Id="htmlChunk" />
725
+ <Relationship Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header"
726
+ Target="header1.xml" Id="rIdHeader" />
727
+ <Relationship Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer"
728
+ Target="footer1.xml" Id="rIdFooter" />
729
+ <Relationship Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings"
730
+ Target="settings.xml" Id="rIdSettings" />
731
+ </Relationships>
732
+ `
733
+ }
734
+
735
+ /** 多段完整 HTML 無法直接拼接;僅在「已是同一文檔殼」時由上層合併 body 後傳入單一字串 */
736
+ function resolveSingleHtml(htmlOrParts: string | string[]): string {
737
+ if (typeof htmlOrParts === 'string') {
738
+ const html = htmlOrParts.trim()
739
+ if (!html) throw new Error('Word 導出失敗:無可打包的 HTML 內容')
740
+ return html
741
+ }
742
+ const parts = htmlOrParts.map((p) => String(p || '').trim()).filter(Boolean)
743
+ if (!parts.length) throw new Error('Word 導出失敗:無可打包的 HTML 內容')
744
+ if (parts.length === 1) return parts[0] as string
745
+ throw new Error(
746
+ 'Word 導出失敗:請傳入單一完整 HTML(封面/目錄/正文應在 HTML 內分頁後一次打包)'
747
+ )
748
+ }
749
+
750
+ /** 打包後自檢:禁止 .mht / 多段 afchunk */
751
+ async function assertPackerIntegrity(zipBytes: Uint8Array, sourceHtml: string) {
752
+ const zip = await JSZip.loadAsync(zipBytes)
753
+ const names = Object.keys(zip.files).filter((n) => !n.endsWith('/'))
754
+
755
+ const mhtFiles = names.filter((n) => /\.mht$/i.test(n))
756
+ if (mhtFiles.length) {
757
+ throw new Error(
758
+ `Word 導出失敗:檢測到舊版 MHT(${mhtFiles.join(', ')}),請完整覆蓋 AIExport 並移除 asBlob 舊入口`
759
+ )
760
+ }
761
+
762
+ const multiHtml = names.filter((n) => /word\/afchunk\d+\.html$/i.test(n))
763
+ if (multiHtml.length) {
764
+ throw new Error(
765
+ `Word 導出失敗:檢測到多段 afchunk(${multiHtml.join(', ')}),必須為單一 word/afchunk.html`
766
+ )
767
+ }
768
+
769
+ const htmlFile = zip.file('word/afchunk.html')
770
+ if (!htmlFile) {
771
+ throw new Error('Word 導出失敗:缺少 word/afchunk.html')
772
+ }
773
+
774
+ const docRels = zip.file('word/_rels/document.xml.rels')
775
+ if (docRels) {
776
+ const relsXml = await docRels.async('string')
777
+ if (!/Target="afchunk\.html"/i.test(relsXml) || !/Id="htmlChunk"/i.test(relsXml)) {
778
+ throw new Error('Word 導出失敗:document.xml.rels 未正確指向 afchunk.html')
779
+ }
780
+ if (/\.mht/i.test(relsXml)) {
781
+ throw new Error('Word 導出失敗:rels 仍指向 .mht,請覆蓋最新 AIExport')
782
+ }
783
+ }
784
+
785
+ const embedded = await htmlFile.async('string')
786
+ if (/mhtDocumentPart|aiexportMhtPart|Content-Transfer-Encoding:\s*quoted-printable/i.test(embedded)) {
787
+ throw new Error('Word 導出失敗:HTML 塊內含 MHT 殘片')
788
+ }
789
+ if (!/<html[\s>]/i.test(embedded) && !/<body[\s>]/i.test(embedded)) {
790
+ throw new Error('Word 導出失敗:afchunk.html 不是有效 HTML')
791
+ }
792
+
793
+ const plainSrc = String(sourceHtml || '')
794
+ .replace(/<[^>]+>/g, ' ')
795
+ .replace(/&nbsp;/gi, ' ')
796
+ .replace(/\s+/g, '')
797
+ const plainEmb = embedded.replace(/<[^>]+>/g, ' ').replace(/\s+/g, '')
798
+ if (plainSrc.length >= 8) {
799
+ const probe = plainSrc.slice(0, 8)
800
+ if (!plainEmb.includes(probe) && !plainEmb.includes(probe.slice(0, 4))) {
801
+ console.warn('[AIExport] embedded HTML probe mismatch (non-fatal)')
802
+ }
803
+ }
804
+ }
805
+
806
+ async function buildDocxZip(
807
+ htmlOrParts: string | string[],
808
+ options: DocxExportOptions = DEFAULT_DOCX_EXPORT_OPTIONS
809
+ ): Promise<Blob> {
810
+ const html = resolveSingleHtml(htmlOrParts)
811
+
812
+ const { width, height, orientation } = resolvePageSize(options)
813
+ const margins = mergeMargins({ ...A4_MARGINS_2CM, ...options.margins })
814
+ const headerFooter = {
815
+ headerText: options.headerFooter?.headerText ?? '商密三级',
816
+ showPageNumbers: options.headerFooter?.showPageNumbers ?? true,
817
+ }
818
+
819
+ const zip = new JSZip()
820
+ zip.file('[Content_Types].xml', buildContentTypesXml())
821
+ zip.folder('_rels')!.file('.rels', PACKAGE_RELS_XML)
822
+
823
+ const wordFolder = zip.folder('word')!
824
+ wordFolder.file(
825
+ 'document.xml',
826
+ buildDocumentXmlWithHeaderFooter(width, height, orientation, margins)
827
+ )
828
+
829
+ // 純 HTML altChunk(UTF-8 BOM)——永不生成 afchunk.mht
830
+ wordFolder.file('afchunk.html', encodeHtmlAltChunk(html))
831
+
832
+ wordFolder.file('header1.xml', buildHeaderXml(headerFooter.headerText))
833
+
834
+ if (headerFooter.showPageNumbers) {
835
+ wordFolder.file('footer1.xml', buildFooterXml())
836
+ } else {
837
+ wordFolder.file(
838
+ 'footer1.xml',
839
+ `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:ftr ${DOCX_XML_NS}><w:p/></w:ftr>`
840
+ )
841
+ }
842
+
843
+ wordFolder.folder('_rels')!.file('document.xml.rels', buildDocumentRelsXml())
844
+
845
+ wordFolder.file(
846
+ 'settings.xml',
847
+ `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
848
+ <w:settings xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
849
+ <w:zoom w:percent="100"/>
850
+ <w:defaultTabStop w:val="420"/>
851
+ <w:updateFields w:val="true"/>
852
+ <w:hideSpellingErrors/>
853
+ <w:hideGrammaticalErrors/>
854
+ </w:settings>`
855
+ )
856
+
857
+ const buffer = await zip.generateAsync({
858
+ type: 'arraybuffer',
859
+ compression: 'DEFLATE',
860
+ })
861
+
862
+ const bytes = new Uint8Array(buffer.slice(0))
863
+ if (bytes.length < 4 || bytes[0] !== 0x50 || bytes[1] !== 0x4b) {
864
+ throw new Error('Word 導出失敗:ZIP 打包結果異常,請檢查 jszip 版本(建議 3.10.x)')
865
+ }
866
+
867
+ await assertPackerIntegrity(bytes, html)
868
+
869
+ return new Blob([bytes], {
870
+ type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
871
+ })
872
+ }
873
+
874
+ export function buildWordDocumentHtml(bodyHtml: string): string {
875
+ const safeBody = sanitizeExportBodyHtml(bodyHtml)
876
+ return `<!DOCTYPE html>
877
+ <html xmlns:o="urn:schemas-microsoft-com:office:office"
878
+ xmlns:w="urn:schemas-microsoft-com:office:word"
879
+ xmlns="http://www.w3.org/TR/REC-html40"
880
+ lang="zh-Hant">
881
+ <head>
882
+ <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
883
+ <meta charset="UTF-8">
884
+ <!--[if gte mso 9]>
885
+ <xml>
886
+ <w:WordDocument>
887
+ <w:View>Print</w:View>
888
+ <w:Zoom>100</w:Zoom>
889
+ <w:DoNotOptimizeForBrowser/>
890
+ <w:HideSpellingErrors/>
891
+ <w:HideGrammaticalErrors/>
892
+ </w:WordDocument>
893
+ </xml>
894
+ <![endif]-->
895
+ <style>
896
+ /* 固定 A4 pt;與 document.xml sectPr 一致,避免節間落到 Letter */
897
+ @page {
898
+ size: 595.3pt 841.9pt;
899
+ margin: 56.7pt;
900
+ mso-page-orientation: portrait;
901
+ mso-header-margin: 21.3pt;
902
+ mso-footer-margin: 35.4pt;
903
+ mso-paper-source: 0;
904
+ }
905
+ @page WordSection1 {
906
+ size: 595.3pt 841.9pt;
907
+ margin: 56.7pt;
908
+ mso-page-orientation: portrait;
909
+ mso-header-margin: 21.3pt;
910
+ mso-footer-margin: 35.4pt;
911
+ mso-paper-source: 0;
912
+ }
913
+ div.WordSection1 {
914
+ page: WordSection1;
915
+ width: 100%;
916
+ max-width: 100%;
917
+ box-sizing: border-box;
918
+ }
919
+ html, body {
920
+ font-family: "Times New Roman", "宋体", SimSun, serif;
921
+ font-size: 11pt;
922
+ line-height: 1.5;
923
+ color: #000000;
924
+ margin: 0;
925
+ padding: 0;
926
+ width: 100%;
927
+ max-width: 100%;
928
+ box-sizing: border-box;
929
+ word-wrap: break-word;
930
+ overflow-wrap: break-word;
931
+ }
932
+ .docx-page-root,
933
+ .doc-sample-root,
934
+ .doc-sample-body {
935
+ width: 100% !important;
936
+ max-width: 100% !important;
937
+ margin: 0;
938
+ padding: 0;
939
+ box-sizing: border-box;
940
+ font-family: "Times New Roman", "宋体", SimSun, serif;
941
+ font-size: 11pt;
942
+ line-height: 1.5;
943
+ color: #000000;
944
+ text-align: left;
945
+ overflow: hidden;
946
+ word-wrap: break-word;
947
+ overflow-wrap: break-word;
948
+ }
949
+ /* 正文禁止文字下劃線(勿傷表格邊框;頁眉雙藍線在 OOXML) */
950
+ .docx-chapters-body,
951
+ .docx-toc-page-root,
952
+ .docx-cover-page :not(.docx-members-label):not(.docx-members-label *) {
953
+ text-decoration: none !important;
954
+ text-decoration-line: none !important;
955
+ }
956
+ .docx-chapters-body u,
957
+ .docx-chapters-body a,
958
+ .docx-chapters-body span,
959
+ .docx-chapters-body p,
960
+ .docx-toc-page-root u,
961
+ .docx-toc-page-root a,
962
+ .docx-toc-page-root span,
963
+ .docx-toc-page-root p,
964
+ .docx-cover-page u {
965
+ text-decoration: none !important;
966
+ text-decoration-line: none !important;
967
+ border-bottom: none !important;
968
+ }
969
+ .docx-members-label,
970
+ .docx-members-label u {
971
+ text-decoration: underline !important;
972
+ text-decoration-color: #000000 !important;
973
+ -webkit-text-decoration-color: #000000 !important;
974
+ border-bottom: none !important;
975
+ }
976
+ .docx-chapters-body h1,
977
+ .docx-chapters-body h2,
978
+ .docx-chapters-body h3,
979
+ .docx-chapters-body p,
980
+ .docx-chapter-title {
981
+ border-bottom: none !important;
982
+ text-decoration: none !important;
983
+ text-decoration-color: #000000 !important;
984
+ }
985
+ /* 表格邊框必須保留(蓋過上文文字去線規則) */
986
+ .docx-chapters-body table,
987
+ .docx-chapters-body .doc-table,
988
+ .docx-chapters-body .doc-table-word {
989
+ border: 1px solid #000000 !important;
990
+ border-collapse: collapse !important;
991
+ }
992
+ .docx-chapters-body table td,
993
+ .docx-chapters-body table th,
994
+ .docx-chapters-body .doc-table td,
995
+ .docx-chapters-body .doc-table th,
996
+ .docx-chapters-body .doc-table-word td,
997
+ .docx-chapters-body .doc-table-word th {
998
+ border: 1px solid #000000 !important;
999
+ border-bottom: 1px solid #000000 !important;
1000
+ border-top: 1px solid #000000 !important;
1001
+ border-left: 1px solid #000000 !important;
1002
+ border-right: 1px solid #000000 !important;
1003
+ }
1004
+ .docx-page-break {
1005
+ page-break-before: always !important;
1006
+ height: 0;
1007
+ margin: 0 !important;
1008
+ padding: 0 !important;
1009
+ border: 0 !important;
1010
+ font-size: 1pt !important;
1011
+ line-height: 0 !important;
1012
+ }
1013
+ .docx-toc-page-root,
1014
+ .docx-cover-page,
1015
+ .docx-chapters-body,
1016
+ .docx-members-block {
1017
+ width: 100% !important;
1018
+ max-width: 100% !important;
1019
+ box-sizing: border-box;
1020
+ overflow: hidden;
1021
+ }
1022
+ /* 目錄整體左移:去掉多餘左邊距,右制表位收回頁邊距內 */
1023
+ .docx-toc-page-root {
1024
+ margin: 0 !important;
1025
+ padding: 0 !important;
1026
+ text-align: left !important;
1027
+ page-break-after: auto;
1028
+ }
1029
+ .docx-chapters-body {
1030
+ page-break-before: auto;
1031
+ text-align: left;
1032
+ }
1033
+ /* 圖二正文標準 */
1034
+ .docx-chapters-body h1.docx-chapter-title,
1035
+ .docx-chapters-body .docx-chapter-title {
1036
+ font-size: 14pt !important;
1037
+ font-weight: bold !important;
1038
+ color: #000000 !important;
1039
+ text-align: left !important;
1040
+ margin: 14pt 0 8pt 0 !important;
1041
+ page-break-after: avoid;
1042
+ max-width: 100% !important;
1043
+ word-wrap: break-word;
1044
+ }
1045
+ .docx-chapters-body p,
1046
+ .docx-chapters-body li {
1047
+ font-size: 11pt !important;
1048
+ line-height: 1.5 !important;
1049
+ color: #000000 !important;
1050
+ margin: 0 0 10pt 0 !important;
1051
+ text-align: left !important;
1052
+ max-width: 100% !important;
1053
+ word-wrap: break-word;
1054
+ overflow-wrap: break-word;
1055
+ }
1056
+ .docx-chapters-body h2,
1057
+ .docx-chapters-body h3 {
1058
+ font-weight: bold !important;
1059
+ color: #000000 !important;
1060
+ text-align: left !important;
1061
+ margin: 10pt 0 6pt 0 !important;
1062
+ max-width: 100% !important;
1063
+ }
1064
+ .docx-cover-page {
1065
+ width: 100%;
1066
+ }
1067
+ .docx-cover {
1068
+ margin: 36pt 0 0;
1069
+ text-align: center;
1070
+ }
1071
+ .docx-cover-title {
1072
+ font-family: "PMingLiU", "新細明體", "新细明体", serif !important;
1073
+ mso-ascii-font-family: "PMingLiU";
1074
+ mso-fareast-font-family: "新細明體";
1075
+ mso-hansi-font-family: "PMingLiU";
1076
+ font-size: 18pt !important;
1077
+ font-weight: bold !important;
1078
+ color: #000000 !important;
1079
+ text-align: center !important;
1080
+ margin: 0 0 8pt !important;
1081
+ line-height: 1.45 !important;
1082
+ }
1083
+ .docx-cover-year {
1084
+ font-family: "FangSong", "仿宋", STFangsong, serif !important;
1085
+ mso-ascii-font-family: "FangSong";
1086
+ mso-fareast-font-family: "仿宋";
1087
+ mso-hansi-font-family: "FangSong";
1088
+ font-size: 18pt !important;
1089
+ font-weight: bold !important;
1090
+ text-align: center !important;
1091
+ margin: 0 0 0 !important;
1092
+ line-height: 1.45 !important;
1093
+ }
1094
+ /* 固定行高撑开空白,把「小組成員」推到首頁約下 1/3 */
1095
+ .docx-cover-spacer {
1096
+ margin: 0 !important;
1097
+ padding: 0 !important;
1098
+ font-size: 1pt !important;
1099
+ line-height: 260pt !important;
1100
+ mso-line-height-rule: exactly;
1101
+ }
1102
+ .docx-members-block {
1103
+ margin-top: 0 !important;
1104
+ page-break-inside: avoid;
1105
+ page-break-before: auto;
1106
+ text-align: left;
1107
+ }
1108
+ .docx-members-block--cont {
1109
+ margin-top: 24pt !important;
1110
+ }
1111
+ .docx-members-label {
1112
+ font-family: "FangSong", "仿宋", STFangsong, serif !important;
1113
+ mso-ascii-font-family: "FangSong";
1114
+ mso-fareast-font-family: "仿宋";
1115
+ mso-hansi-font-family: "FangSong";
1116
+ font-size: 18pt !important;
1117
+ font-weight: bold !important;
1118
+ text-align: left !important;
1119
+ text-decoration: none !important;
1120
+ margin: 0 0 10pt !important;
1121
+ color: #000000 !important;
1122
+ border: none !important;
1123
+ border-bottom: none !important;
1124
+ }
1125
+ .docx-members-label u {
1126
+ text-decoration: underline !important;
1127
+ text-decoration-color: #000000 !important;
1128
+ -webkit-text-decoration-color: #000000 !important;
1129
+ }
1130
+ .docx-member-line {
1131
+ font-family: "FangSong", "仿宋", STFangsong, serif !important;
1132
+ mso-ascii-font-family: "FangSong";
1133
+ mso-fareast-font-family: "仿宋";
1134
+ mso-hansi-font-family: "FangSong";
1135
+ font-size: 16pt !important;
1136
+ font-weight: normal !important;
1137
+ text-align: left !important;
1138
+ margin: 0 0 6pt !important;
1139
+ line-height: 1.4 !important;
1140
+ color: #000000 !important;
1141
+ white-space: normal !important;
1142
+ word-wrap: break-word !important;
1143
+ overflow-wrap: anywhere !important;
1144
+ word-break: break-word !important;
1145
+ }
1146
+ .docx-member-group-title {
1147
+ font-size: 14pt !important;
1148
+ font-weight: bold !important;
1149
+ text-align: left !important;
1150
+ margin: 0 0 10pt !important;
1151
+ }
1152
+ .docx-toc-title {
1153
+ font-family: "FangSong", "仿宋", STFangsong, serif !important;
1154
+ mso-ascii-font-family: "FangSong";
1155
+ mso-fareast-font-family: "仿宋";
1156
+ mso-hansi-font-family: "FangSong";
1157
+ font-size: 12pt !important;
1158
+ font-weight: normal !important;
1159
+ color: #2262E0 !important;
1160
+ margin: 12pt 0 18pt 0 !important;
1161
+ padding: 0 !important;
1162
+ text-align: left !important;
1163
+ text-decoration: none !important;
1164
+ border: none !important;
1165
+ border-bottom: none !important;
1166
+ }
1167
+ .docx-toc-entry {
1168
+ /* 中文:新細明體;英文:Times New Roman;字號 12 */
1169
+ font-family: "Times New Roman", "PMingLiU", "新細明體", "新细明体", serif !important;
1170
+ mso-ascii-font-family: "Times New Roman";
1171
+ mso-fareast-font-family: "新細明體";
1172
+ mso-hansi-font-family: "Times New Roman";
1173
+ font-size: 12pt !important;
1174
+ color: #000000 !important;
1175
+ margin: 0 0 6pt 0 !important;
1176
+ padding: 0 !important;
1177
+ line-height: 1.5 !important;
1178
+ text-align: left !important;
1179
+ text-indent: 0 !important;
1180
+ /* 右制表位對齊右頁邊距 */
1181
+ tab-stops: right dotted ${A4_CONTENT_WIDTH_PT}pt;
1182
+ max-width: 100% !important;
1183
+ word-wrap: break-word;
1184
+ }
1185
+ h1.docx-chapter-title,
1186
+ .docx-chapter-title {
1187
+ font-family: "Times New Roman", "宋体", SimSun, serif !important;
1188
+ font-size: 14pt !important;
1189
+ font-weight: bold !important;
1190
+ color: #000000 !important;
1191
+ text-align: left !important;
1192
+ margin: 14pt 0 8pt !important;
1193
+ padding: 0 !important;
1194
+ border: none !important;
1195
+ page-break-after: avoid;
1196
+ max-width: 100% !important;
1197
+ word-wrap: break-word;
1198
+ }
1199
+ .docx-refs-title {
1200
+ font-size: 12pt !important;
1201
+ font-weight: bold !important;
1202
+ margin: 12pt 0 8pt !important;
1203
+ }
1204
+ .docx-ref-line {
1205
+ font-size: 11pt !important;
1206
+ margin: 0 0 4pt !important;
1207
+ line-height: 1.35 !important;
1208
+ max-width: 100% !important;
1209
+ word-wrap: break-word;
1210
+ border: none !important;
1211
+ border-width: 0 !important;
1212
+ outline: none !important;
1213
+ mso-border-alt: none !important;
1214
+ }
1215
+ /* 章節末「參考資料:」——有 [ref:…] 時追加 */
1216
+ .docx-chapter-refs-label {
1217
+ font-family: "FangSong", "仿宋", STFangsong, serif !important;
1218
+ font-size: 11pt !important;
1219
+ font-weight: normal !important;
1220
+ color: #000000 !important;
1221
+ margin: 14pt 0 6pt 0 !important;
1222
+ line-height: 1.5 !important;
1223
+ text-align: left !important;
1224
+ }
1225
+ .docx-chapter-refs-item {
1226
+ font-family: "Times New Roman", "PMingLiU", "新細明體", serif !important;
1227
+ mso-ascii-font-family: "Times New Roman";
1228
+ mso-fareast-font-family: "新細明體";
1229
+ font-size: 11pt !important;
1230
+ font-weight: normal !important;
1231
+ color: #000000 !important;
1232
+ margin: 0 0 4pt 0 !important;
1233
+ line-height: 1.5 !important;
1234
+ text-align: left !important;
1235
+ max-width: 100% !important;
1236
+ word-wrap: break-word;
1237
+ }
1238
+ .docx-appendix-title {
1239
+ font-size: 14pt !important;
1240
+ font-weight: bold !important;
1241
+ margin: 0 0 12pt !important;
1242
+ border: none !important;
1243
+ outline: none !important;
1244
+ mso-border-alt: none !important;
1245
+ }
1246
+ /* 參考文獻清單:禁止黑框 / 表格邊(Word 導入時易誤加) */
1247
+ .docx-refs-appendix,
1248
+ .docx-refs-appendix p,
1249
+ .docx-refs-appendix div,
1250
+ .docx-refs-appendix table,
1251
+ .docx-refs-appendix td,
1252
+ .docx-refs-appendix th {
1253
+ border: none !important;
1254
+ border-width: 0 !important;
1255
+ outline: none !important;
1256
+ mso-border-alt: none !important;
1257
+ background: transparent !important;
1258
+ }
1259
+ .docx-table-wrap {
1260
+ width: ${A4_CONTENT_WIDTH_PT}pt !important;
1261
+ max-width: ${A4_CONTENT_WIDTH_PT}pt !important;
1262
+ height: auto !important;
1263
+ min-height: 0 !important;
1264
+ margin: 6pt 0 12pt;
1265
+ padding: 0;
1266
+ overflow: hidden;
1267
+ box-sizing: border-box;
1268
+ }
1269
+ .docx-table-wrap--nested {
1270
+ width: auto !important;
1271
+ max-width: 100% !important;
1272
+ margin: 4pt 0 !important;
1273
+ }
1274
+ table, .doc-table, .doc-table-word {
1275
+ border-collapse: collapse !important;
1276
+ height: auto !important;
1277
+ min-height: 0 !important;
1278
+ table-layout: fixed !important;
1279
+ mso-table-layout-alt: fixed !important;
1280
+ word-wrap: break-word !important;
1281
+ overflow-wrap: break-word !important;
1282
+ margin: 0 !important;
1283
+ border: 1px solid #000000;
1284
+ box-sizing: border-box;
1285
+ page-break-inside: auto !important;
1286
+ }
1287
+ /* 頂層表鋪滿正文區;嵌套表寬度由內聯 pt 決定,勿再強制整頁寬 */
1288
+ .docx-table-wrap:not(.docx-table-wrap--nested) > table {
1289
+ width: ${A4_CONTENT_WIDTH_PT}pt !important;
1290
+ max-width: ${A4_CONTENT_WIDTH_PT}pt !important;
1291
+ }
1292
+ .docx-table-wrap--nested > table {
1293
+ max-width: 100% !important;
1294
+ height: auto !important;
1295
+ }
1296
+ table tr,
1297
+ table td,
1298
+ table th,
1299
+ .doc-table tr, .doc-table td, .doc-table th,
1300
+ .doc-table-word tr, .doc-table-word td, .doc-table-word th {
1301
+ height: auto !important;
1302
+ min-height: 0 !important;
1303
+ page-break-inside: auto !important;
1304
+ }
1305
+ table td, table th,
1306
+ .doc-table td, .doc-table th,
1307
+ .doc-table-word td, .doc-table-word th {
1308
+ word-wrap: break-word !important;
1309
+ overflow-wrap: break-word !important;
1310
+ overflow: hidden !important;
1311
+ vertical-align: top !important;
1312
+ line-height: 1.35 !important;
1313
+ }
1314
+ .doc-table-complex th,
1315
+ .doc-table-complex td,
1316
+ .doc-table-word th,
1317
+ .doc-table-word td {
1318
+ font-size: 9pt !important;
1319
+ padding: 2pt 3pt !important;
1320
+ }
1321
+ .doc-h1, h1.doc-h1 {
1322
+ font-size: 20pt !important;
1323
+ font-weight: bold !important;
1324
+ color: #c00000 !important;
1325
+ text-align: left !important;
1326
+ margin: 0 0 12pt !important;
1327
+ padding: 0 !important;
1328
+ border: none !important;
1329
+ }
1330
+ .doc-h2, h2.doc-h2 {
1331
+ font-size: 14pt !important;
1332
+ font-weight: bold !important;
1333
+ color: #000000 !important;
1334
+ margin: 12pt 0 6pt !important;
1335
+ padding: 0 !important;
1336
+ border: none !important;
1337
+ }
1338
+ .doc-h3, h3.doc-h3 {
1339
+ font-size: 12pt !important;
1340
+ font-weight: bold !important;
1341
+ color: #000000 !important;
1342
+ margin: 10pt 0 4pt !important;
1343
+ }
1344
+ .doc-p, p.doc-p {
1345
+ font-size: 11pt !important;
1346
+ line-height: 1.15 !important;
1347
+ color: #000000 !important;
1348
+ margin: 0 0 10pt !important;
1349
+ text-align: left !important;
1350
+ }
1351
+ .doc-link-line, p.doc-link-line {
1352
+ font-size: 11pt !important;
1353
+ margin: 0 0 2pt !important;
1354
+ padding: 0 !important;
1355
+ }
1356
+ .doc-link-anchor, a.doc-link-anchor {
1357
+ color: #000000 !important;
1358
+ text-decoration: none !important;
1359
+ }
1360
+ .typewriter-paragraph {
1361
+ white-space: pre-wrap;
1362
+ word-break: break-word;
1363
+ font-size: inherit !important;
1364
+ line-height: inherit !important;
1365
+ color: inherit !important;
1366
+ }
1367
+ /* 列表:双列无边框表——标记 | 正文;换行只在右列,禁止负缩进顶出页边 */
1368
+ .doc-ol-export { margin: 6pt 0 10pt; padding: 0; font-size: 11pt; color: #000000; max-width: 100%; box-sizing: border-box; }
1369
+ table.doc-list-item-table,
1370
+ .doc-list-item-table {
1371
+ width: 100% !important;
1372
+ max-width: 100% !important;
1373
+ border: none !important;
1374
+ border-collapse: collapse !important;
1375
+ border-spacing: 0 !important;
1376
+ margin: 0 0 2pt 0 !important;
1377
+ padding: 0 !important;
1378
+ table-layout: fixed !important;
1379
+ mso-table-layout-alt: fixed !important;
1380
+ box-sizing: border-box !important;
1381
+ }
1382
+ .doc-list-item-table td,
1383
+ table.doc-list-item-table td {
1384
+ border: none !important;
1385
+ border-width: 0 !important;
1386
+ mso-border-alt: none !important;
1387
+ background: transparent !important;
1388
+ padding: 0 !important;
1389
+ margin: 0 !important;
1390
+ font-size: 11pt !important;
1391
+ line-height: 1.15 !important;
1392
+ color: #000000 !important;
1393
+ vertical-align: top !important;
1394
+ word-wrap: break-word !important;
1395
+ overflow-wrap: break-word !important;
1396
+ }
1397
+ .doc-list-item-table .doc-list-marker {
1398
+ width: 22pt !important;
1399
+ max-width: 22pt !important;
1400
+ white-space: nowrap !important;
1401
+ padding-right: 4pt !important;
1402
+ }
1403
+ .doc-list-item-table .doc-list-text {
1404
+ width: auto !important;
1405
+ }
1406
+ .docx-chapters-body ul,
1407
+ .docx-chapters-body ol {
1408
+ margin: 0 0 10pt 0 !important;
1409
+ padding-left: 18pt !important;
1410
+ max-width: 100% !important;
1411
+ box-sizing: border-box !important;
1412
+ }
1413
+ .docx-chapters-body li {
1414
+ margin: 0 0 2pt 0 !important;
1415
+ max-width: 100% !important;
1416
+ box-sizing: border-box !important;
1417
+ word-wrap: break-word !important;
1418
+ }
1419
+ th, td {
1420
+ border: 1px solid #000000;
1421
+ padding: 3pt 4pt !important;
1422
+ font-size: 10pt !important;
1423
+ color: #000000;
1424
+ height: auto !important;
1425
+ min-height: 0 !important;
1426
+ word-wrap: break-word !important;
1427
+ overflow-wrap: break-word !important;
1428
+ box-sizing: border-box;
1429
+ vertical-align: top !important;
1430
+ }
1431
+ th { font-weight: bold; background-color: #ffffff; }
1432
+ .doc-th-green, th.doc-th-green { background-color: #c6efce !important; color: #000000 !important; }
1433
+ .doc-th-red, th.doc-th-red { background-color: #ffc7ce !important; color: #000000 !important; }
1434
+ .doc-chart-intro {
1435
+ display: block;
1436
+ width: 100% !important;
1437
+ max-width: 100% !important;
1438
+ margin: 0 0 10pt;
1439
+ overflow: hidden;
1440
+ box-sizing: border-box;
1441
+ }
1442
+ .doc-chart-intro__left,
1443
+ .doc-chart-intro__right {
1444
+ display: block;
1445
+ float: none !important;
1446
+ width: 100% !important;
1447
+ max-width: 100% !important;
1448
+ margin: 0 0 8pt 0 !important;
1449
+ overflow: visible;
1450
+ box-sizing: border-box;
1451
+ }
1452
+ .doc-pie-chart-img { display: block; width: ${DOCX_PIE_CHART_MAX_WIDTH_PX}px; height: auto; max-width: 100%; max-height: ${DOCX_PIE_CHART_MAX_HEIGHT_PX}px; box-sizing: border-box; }
1453
+ .doc-chart-intro-text { margin: 0; text-align: left; font-size: 11pt !important; }
1454
+ .doc-images-block { overflow: hidden; margin: 0 0 10pt; max-width: 100%; width: 100%; box-sizing: border-box; }
1455
+ .doc-float-icon { float: left; width: ${DOCX_FLOAT_ICON_SIZE_PX}px; height: ${DOCX_FLOAT_ICON_SIZE_PX}px; max-width: ${DOCX_FLOAT_ICON_SIZE_PX}px; max-height: ${DOCX_FLOAT_ICON_SIZE_PX}px; margin: 0 12pt 8pt 0; display: block; box-sizing: border-box; }
1456
+ .doc-images-p { margin: 0 0 10pt; text-align: left; font-size: 11pt; line-height: 1.15; }
1457
+ .success-row td { background-color: #f0f9eb; }
1458
+ .info-row td { background-color: #e6f7ff; }
1459
+ .warning-row td { background-color: #fdf6ec; }
1460
+ .danger-row td { background-color: #fef0f0; }
1461
+ img { max-width: 100%; height: auto; }
1462
+ .docx-chart-wrap,
1463
+ .echarts-container.docx-chart-wrap {
1464
+ max-width: 100%;
1465
+ width: 100%;
1466
+ margin: 12pt 0 !important;
1467
+ text-align: center !important;
1468
+ page-break-inside: avoid;
1469
+ page-break-before: auto;
1470
+ box-sizing: border-box;
1471
+ overflow: hidden;
1472
+ }
1473
+ .docx-chart-wrap .docx-chart-img,
1474
+ .docx-chart-wrap img {
1475
+ display: block !important;
1476
+ margin: 0 auto !important;
1477
+ border: 0;
1478
+ max-width: 100%;
1479
+ }
1480
+ button, .el-button {
1481
+ display: inline-block !important;
1482
+ padding: 4px 12px;
1483
+ margin: 0 6px 6px 0;
1484
+ border: 1px solid #dcdfe6;
1485
+ border-radius: 4px;
1486
+ font-size: 12px;
1487
+ background: #fff;
1488
+ color: #606266;
1489
+ }
1490
+ .el-button--danger { background: #fef0f0 !important; color: #f56c6c !important; border-color: #fbc4c4 !important; }
1491
+ .el-button--success { background: #f0f9eb !important; color: #67c23a !important; }
1492
+ .el-button--primary { background: #ecf5ff !important; color: #409eff !important; }
1493
+ .section-title {
1494
+ font-size: 18px;
1495
+ font-weight: bold;
1496
+ color: #1a1a1a;
1497
+ border-top: 1px solid #e5e7eb;
1498
+ padding-top: 24px;
1499
+ margin-bottom: 20px;
1500
+ }
1501
+ </style>
1502
+ </head>
1503
+ <body>
1504
+ <div class="WordSection1">
1505
+ <div class="docx-page-root">${safeBody}</div>
1506
+ </div>
1507
+ </body>
1508
+ </html>`
1509
+ }
1510
+
1511
+ const STYLE_PROPS = [
1512
+ 'color',
1513
+ 'fontSize',
1514
+ 'fontFamily',
1515
+ 'fontWeight',
1516
+ 'fontStyle',
1517
+ 'backgroundColor',
1518
+ 'textAlign',
1519
+ 'lineHeight',
1520
+ 'marginTop',
1521
+ 'marginBottom',
1522
+ 'marginLeft',
1523
+ 'marginRight',
1524
+ 'paddingTop',
1525
+ 'paddingBottom',
1526
+ 'paddingLeft',
1527
+ 'paddingRight',
1528
+ 'borderTop',
1529
+ 'borderBottom',
1530
+ 'borderLeft',
1531
+ 'borderRight',
1532
+ 'display',
1533
+ 'width',
1534
+ 'height',
1535
+ 'maxWidth',
1536
+ 'verticalAlign',
1537
+ 'whiteSpace',
1538
+ 'letterSpacing',
1539
+ ] as const
1540
+
1541
+ type InlineStyleMap = Record<string, string>
1542
+
1543
+ const DOC_CLASS_INLINE_STYLES: Record<string, InlineStyleMap> = {
1544
+ 'doc-h1': {
1545
+ fontSize: '20pt',
1546
+ fontWeight: 'bold',
1547
+ color: '#c00000',
1548
+ textAlign: 'left',
1549
+ margin: '0 0 12pt',
1550
+ padding: '0',
1551
+ border: 'none',
1552
+ },
1553
+ 'doc-h2': {
1554
+ fontSize: '14pt',
1555
+ fontWeight: 'bold',
1556
+ color: '#000000',
1557
+ margin: '12pt 0 6pt',
1558
+ padding: '0',
1559
+ border: 'none',
1560
+ },
1561
+ 'doc-h3': {
1562
+ fontSize: '12pt',
1563
+ fontWeight: 'bold',
1564
+ color: '#000000',
1565
+ margin: '10pt 0 4pt',
1566
+ },
1567
+ 'doc-p': {
1568
+ fontSize: '11pt',
1569
+ lineHeight: '1.15',
1570
+ color: '#000000',
1571
+ margin: '0 0 10pt',
1572
+ textAlign: 'left',
1573
+ },
1574
+ 'doc-link-line': {
1575
+ fontSize: '11pt',
1576
+ margin: '0 0 2pt',
1577
+ padding: '0',
1578
+ },
1579
+ 'doc-link-anchor': {
1580
+ color: '#000000',
1581
+ textDecoration: 'none',
1582
+ },
1583
+ 'doc-th-green': {
1584
+ backgroundColor: '#c6efce',
1585
+ color: '#000000',
1586
+ fontWeight: 'bold',
1587
+ },
1588
+ 'doc-th-red': {
1589
+ backgroundColor: '#ffc7ce',
1590
+ color: '#000000',
1591
+ fontWeight: 'bold',
1592
+ },
1593
+ }
1594
+
1595
+ function applyInlineStyles(el: HTMLElement, styles: InlineStyleMap) {
1596
+ for (const [prop, val] of Object.entries(styles)) {
1597
+ ;(el.style as unknown as Record<string, string>)[prop] = val
1598
+ }
1599
+ }
1600
+
1601
+ function applyDocClassInlineStyles(el: HTMLElement) {
1602
+ Array.from(el.classList).forEach((cls) => {
1603
+ const styles = DOC_CLASS_INLINE_STYLES[cls]
1604
+ if (styles) applyInlineStyles(el, styles)
1605
+ })
1606
+ }
1607
+
1608
+ function mountCloneForStyleRead(clone: HTMLElement): () => void {
1609
+ const host = document.createElement('div')
1610
+ host.setAttribute('data-docx-export-host', 'true')
1611
+ host.style.cssText = `position:fixed;left:-100000px;top:0;width:${A4_CONTENT_WIDTH_PX}px;max-width:100%;visibility:hidden;pointer-events:none;box-sizing:border-box;`
1612
+ host.appendChild(clone)
1613
+ document.body.appendChild(host)
1614
+ return () => {
1615
+ host.remove()
1616
+ }
1617
+ }
1618
+
1619
+ function clampAllImagesForDocx(root: HTMLElement) {
1620
+ root.querySelectorAll('img').forEach((node) => {
1621
+ if (!(node instanceof HTMLImageElement)) return
1622
+ const { width, height } = getDocxImageTargetSize(node)
1623
+ node.setAttribute('width', String(width))
1624
+ node.setAttribute('height', String(height))
1625
+ node.style.maxWidth = `${width}px`
1626
+ node.style.maxHeight = `${height}px`
1627
+ node.style.width = `${width}px`
1628
+ node.style.height = `${height}px`
1629
+ node.style.boxSizing = 'border-box'
1630
+ })
1631
+ }
1632
+
1633
+ function flattenChartIntroForDocx(root: HTMLElement) {
1634
+ root.querySelectorAll('.doc-chart-intro').forEach((intro) => {
1635
+ if (!(intro instanceof HTMLElement)) return
1636
+ intro.style.cssText = [
1637
+ 'display:block',
1638
+ 'width:100%',
1639
+ 'max-width:100%',
1640
+ 'margin:0 0 10pt',
1641
+ 'overflow:hidden',
1642
+ 'box-sizing:border-box',
1643
+ ].join(';')
1644
+
1645
+ const left = intro.querySelector('.doc-chart-intro__left')
1646
+ const right = intro.querySelector('.doc-chart-intro__right')
1647
+ const img = intro.querySelector('.doc-pie-chart-img')
1648
+
1649
+ if (left instanceof HTMLElement) {
1650
+ left.style.cssText = [
1651
+ 'display:block',
1652
+ 'float:none',
1653
+ 'width:100%',
1654
+ 'max-width:100%',
1655
+ 'margin:0 0 8pt 0',
1656
+ 'box-sizing:border-box',
1657
+ ].join(';')
1658
+ }
1659
+ if (img instanceof HTMLImageElement) {
1660
+ const { width, height } = getDocxImageTargetSize(img)
1661
+ const maxW = Math.min(width, Math.floor(A4_CONTENT_WIDTH_PX * 0.55))
1662
+ const maxH = DOCX_PIE_CHART_MAX_HEIGHT_PX
1663
+ img.style.cssText = [
1664
+ 'display:block',
1665
+ `width:${maxW}px`,
1666
+ `height:${height}px`,
1667
+ 'max-width:100%',
1668
+ `max-height:${maxH}px`,
1669
+ 'border:0',
1670
+ 'box-sizing:border-box',
1671
+ ].join(';')
1672
+ img.setAttribute('width', String(maxW))
1673
+ img.setAttribute('height', String(height))
1674
+ }
1675
+ if (right instanceof HTMLElement) {
1676
+ right.style.cssText = [
1677
+ 'display:block',
1678
+ 'float:none',
1679
+ 'width:100%',
1680
+ 'max-width:100%',
1681
+ 'margin:0',
1682
+ 'overflow:hidden',
1683
+ 'box-sizing:border-box',
1684
+ ].join(';')
1685
+ }
1686
+ })
1687
+ }
1688
+
1689
+ function flattenImagesBlockForDocx(root: HTMLElement) {
1690
+ root.querySelectorAll('.doc-images-block').forEach((block) => {
1691
+ if (!(block instanceof HTMLElement)) return
1692
+ block.style.overflow = 'hidden'
1693
+ block.style.maxWidth = '100%'
1694
+ block.style.width = '100%'
1695
+ block.style.boxSizing = 'border-box'
1696
+ })
1697
+
1698
+ root.querySelectorAll('.doc-float-icon').forEach((img) => {
1699
+ if (!(img instanceof HTMLImageElement)) return
1700
+ const size = DOCX_FLOAT_ICON_SIZE_PX
1701
+ img.style.cssText = `float:left;width:${size}px;height:${size}px;max-width:${size}px;max-height:${size}px;margin:0 12pt 8pt 0;display:block;border:0;box-sizing:border-box;`
1702
+ img.setAttribute('width', String(size))
1703
+ img.setAttribute('height', String(size))
1704
+ })
1705
+ }
1706
+
1707
+ /**
1708
+ * 列表展平为「标记 | 正文」无边框双列表:
1709
+ * - 换行只发生在右列,不会越过编号/圆点
1710
+ * - 绝不用负 text-indent / 负 margin,避免顶出页边距
1711
+ */
1712
+ const DOC_LIST_MARKER_W_OL_PT = 22
1713
+ const DOC_LIST_MARKER_W_UL_PT = 16
1714
+ const DOC_LIST_INDENT_STEP_PT = 12
1715
+
1716
+ function getListItemLabel(li: HTMLLIElement): string {
1717
+ const label = li.querySelector(':scope > .doc-ol-label')
1718
+ if (label?.textContent?.trim()) return label.textContent.trim()
1719
+
1720
+ const parts: string[] = []
1721
+ li.childNodes.forEach((node) => {
1722
+ if (node instanceof HTMLElement && (node.tagName === 'UL' || node.tagName === 'OL')) return
1723
+ if (node.nodeType === Node.TEXT_NODE) {
1724
+ const text = node.textContent?.trim()
1725
+ if (text) parts.push(text)
1726
+ } else if (node instanceof HTMLElement) {
1727
+ const text = node.textContent?.trim()
1728
+ if (text) parts.push(text)
1729
+ }
1730
+ })
1731
+ if (parts.length) return parts.join(' ')
1732
+ return li.textContent?.trim() ?? ''
1733
+ }
1734
+
1735
+ function appendFlattenedList(
1736
+ list: HTMLOListElement | HTMLUListElement,
1737
+ container: HTMLElement,
1738
+ depth: number
1739
+ ) {
1740
+ const ordered = list.tagName === 'OL'
1741
+ const markerW = ordered ? DOC_LIST_MARKER_W_OL_PT : DOC_LIST_MARKER_W_UL_PT
1742
+ const indent = depth * DOC_LIST_INDENT_STEP_PT
1743
+ let index = 0
1744
+ list.querySelectorAll(':scope > li').forEach((li) => {
1745
+ if (!(li instanceof HTMLLIElement)) return
1746
+ index++
1747
+ const mainText = getListItemLabel(li)
1748
+ const marker = ordered ? `${index}.` : '•'
1749
+
1750
+ const table = document.createElement('table')
1751
+ table.className = 'doc-list-item-table'
1752
+ table.setAttribute('border', '0')
1753
+ table.setAttribute('cellpadding', '0')
1754
+ table.setAttribute('cellspacing', '0')
1755
+ table.style.cssText = [
1756
+ 'width:100%',
1757
+ 'max-width:100%',
1758
+ 'border:none',
1759
+ 'border-collapse:collapse',
1760
+ 'margin:0 0 2pt 0',
1761
+ 'padding:0',
1762
+ 'table-layout:fixed',
1763
+ 'box-sizing:border-box',
1764
+ ].join(';')
1765
+
1766
+ const tr = document.createElement('tr')
1767
+ const tdMarker = document.createElement('td')
1768
+ tdMarker.className = 'doc-list-marker'
1769
+ tdMarker.style.cssText = [
1770
+ 'border:none',
1771
+ `width:${markerW + indent}pt`,
1772
+ `max-width:${markerW + indent}pt`,
1773
+ indent > 0 ? `padding-left:${indent}pt` : 'padding:0',
1774
+ 'padding-right:4pt',
1775
+ 'padding-top:0',
1776
+ 'padding-bottom:0',
1777
+ 'vertical-align:top',
1778
+ 'white-space:nowrap',
1779
+ 'font-size:11pt',
1780
+ 'line-height:1.15',
1781
+ 'color:#000000',
1782
+ ].join(';')
1783
+ tdMarker.textContent = marker
1784
+
1785
+ const tdText = document.createElement('td')
1786
+ tdText.className = 'doc-list-text'
1787
+ tdText.style.cssText = [
1788
+ 'border:none',
1789
+ 'width:auto',
1790
+ 'padding:0',
1791
+ 'vertical-align:top',
1792
+ 'font-size:11pt',
1793
+ 'line-height:1.15',
1794
+ 'color:#000000',
1795
+ 'word-wrap:break-word',
1796
+ 'overflow-wrap:break-word',
1797
+ ].join(';')
1798
+ tdText.textContent = mainText
1799
+
1800
+ tr.appendChild(tdMarker)
1801
+ tr.appendChild(tdText)
1802
+ table.appendChild(tr)
1803
+ container.appendChild(table)
1804
+
1805
+ li.querySelectorAll(':scope > ul, :scope > ol').forEach((nested) => {
1806
+ if (nested instanceof HTMLOListElement || nested instanceof HTMLUListElement) {
1807
+ appendFlattenedList(nested, container, depth + 1)
1808
+ }
1809
+ })
1810
+ })
1811
+ }
1812
+
1813
+ /** Word altChunk 无法可靠处理原生 ol/ul;展平为无边框双列表(换行不越标记、不顶页边) */
1814
+ function normalizeListsForDocx(root: HTMLElement) {
1815
+ const topLists = Array.from(root.querySelectorAll('ol, ul')).filter((el) => {
1816
+ return !el.parentElement?.closest('ol, ul')
1817
+ })
1818
+
1819
+ topLists.forEach((list) => {
1820
+ if (!(list instanceof HTMLOListElement || list instanceof HTMLUListElement)) return
1821
+ const container = document.createElement('div')
1822
+ container.className = 'doc-ol-export'
1823
+ container.style.cssText =
1824
+ 'margin:6pt 0 10pt;padding:0;font-size:11pt;color:#000000;max-width:100%;box-sizing:border-box;'
1825
+ appendFlattenedList(list, container, 0)
1826
+ list.replaceWith(container)
1827
+ })
1828
+ }
1829
+
1830
+ /** 统计表格逻辑列数(含 colspan) */
1831
+ function getTableColumnCount(table: HTMLTableElement): number {
1832
+ let maxCols = 0
1833
+ Array.from(table.rows).forEach((row) => {
1834
+ let cols = 0
1835
+ Array.from(row.cells).forEach((cell) => {
1836
+ cols += cell.colSpan || 1
1837
+ })
1838
+ maxCols = Math.max(maxCols, cols)
1839
+ })
1840
+ return maxCols
1841
+ }
1842
+
1843
+ function getColumnWidthsPt(colCount: number, total = A4_CONTENT_WIDTH_PT): number[] {
1844
+ const safeTotal = Math.max(40, Math.round(total))
1845
+ if (colCount <= 0) return [safeTotal]
1846
+ if (colCount === 3) {
1847
+ const c0 = Math.round(safeTotal * 0.28)
1848
+ const c1 = Math.round(safeTotal * 0.36)
1849
+ return [c0, c1, safeTotal - c0 - c1]
1850
+ }
1851
+ if (colCount === 5) {
1852
+ const c0 = Math.round(safeTotal * 0.26)
1853
+ const rest = Math.floor((safeTotal - c0) / 4)
1854
+ return [c0, rest, rest, rest, safeTotal - c0 - rest * 3]
1855
+ }
1856
+ const each = Math.floor(safeTotal / colCount)
1857
+ return Array.from({ length: colCount }, (_, index) =>
1858
+ index === colCount - 1 ? safeTotal - each * (colCount - 1) : each
1859
+ )
1860
+ }
1861
+
1862
+ /** 列寬用絕對 pt,總和 = 正文區滿寬,對齊右頁邊距(Word 對 width:100% 常收縮) */
1863
+ function applyTableColgroupPt(table: HTMLTableElement, widthsPt: number[]) {
1864
+ table.querySelector('colgroup')?.remove()
1865
+ const colgroup = document.createElement('colgroup')
1866
+ widthsPt.forEach((width) => {
1867
+ const col = document.createElement('col')
1868
+ col.setAttribute('width', String(width))
1869
+ col.setAttribute(
1870
+ 'style',
1871
+ `width:${width}pt;mso-width-source:userset;mso-width-alt:${width * 20}`
1872
+ )
1873
+ colgroup.appendChild(col)
1874
+ })
1875
+ table.insertBefore(colgroup, table.firstChild)
1876
+ }
1877
+
1878
+ type DocxCellInfo = {
1879
+ text: string
1880
+ tag: 'th' | 'td'
1881
+ className: string
1882
+ textAlign: string
1883
+ }
1884
+
1885
+ /** 将含 colspan/rowspan 的表格展开为逻辑网格 */
1886
+ function expandTableToGrid(table: HTMLTableElement): DocxCellInfo[][] {
1887
+ const colCount = getTableColumnCount(table)
1888
+ const grid: (DocxCellInfo | null)[][] = []
1889
+
1890
+ Array.from(table.rows).forEach((row, rowIndex) => {
1891
+ if (!grid[rowIndex]) grid[rowIndex] = Array(colCount).fill(null)
1892
+ let colIndex = 0
1893
+
1894
+ Array.from(row.cells).forEach((cell) => {
1895
+ while (colIndex < colCount && grid[rowIndex]![colIndex] !== null) colIndex++
1896
+
1897
+ const info: DocxCellInfo = {
1898
+ text: cell.textContent?.trim() ?? '',
1899
+ tag: cell.tagName === 'TH' ? 'th' : 'td',
1900
+ className: cell.className,
1901
+ textAlign: (cell as HTMLElement).style.textAlign || '',
1902
+ }
1903
+ const colspan = cell.colSpan || 1
1904
+ const rowspan = cell.rowSpan || 1
1905
+
1906
+ for (let r = 0; r < rowspan; r++) {
1907
+ for (let c = 0; c < colspan; c++) {
1908
+ const targetRow = rowIndex + r
1909
+ if (!grid[targetRow]) grid[targetRow] = Array(colCount).fill(null)
1910
+ grid[targetRow]![colIndex + c] =
1911
+ r === 0 && c === 0 ? info : { ...info, text: '' }
1912
+ }
1913
+ }
1914
+ colIndex += colspan
1915
+ })
1916
+ })
1917
+
1918
+ return grid.filter((row) => row.some((cell) => cell !== null)) as DocxCellInfo[][]
1919
+ }
1920
+
1921
+ function applyWordTableStyles(
1922
+ table: HTMLTableElement,
1923
+ colCount: number,
1924
+ tableWidthPt = A4_CONTENT_WIDTH_PT
1925
+ ) {
1926
+ const isComplex = table.classList.contains('doc-table-complex') || colCount >= 5
1927
+ const cellFontSize = isComplex ? '9pt' : '10pt'
1928
+ const cellPadding = isComplex ? '2pt 3pt' : '3pt 4pt'
1929
+ const colWidthsPt = getColumnWidthsPt(colCount, tableWidthPt)
1930
+
1931
+ applyTableColgroupPt(table, colWidthsPt)
1932
+
1933
+ // 清掉編輯器 / getComputedStyle 寫死的 height,避免 Word 把末行拉滿頁面
1934
+ table.removeAttribute('width')
1935
+ table.removeAttribute('height')
1936
+ table.removeAttribute('style')
1937
+ table.setAttribute('width', String(tableWidthPt))
1938
+ table.style.cssText = [
1939
+ 'border-collapse:collapse',
1940
+ `width:${tableWidthPt}pt`,
1941
+ `max-width:${tableWidthPt}pt`,
1942
+ 'height:auto',
1943
+ 'min-height:0',
1944
+ 'table-layout:fixed',
1945
+ 'mso-table-layout-alt:fixed',
1946
+ 'word-wrap:break-word',
1947
+ 'overflow-wrap:break-word',
1948
+ 'margin:0',
1949
+ 'border:1px solid #000000',
1950
+ 'box-sizing:border-box',
1951
+ 'page-break-inside:auto',
1952
+ ].join(';')
1953
+
1954
+ table.querySelectorAll('tr').forEach((row) => {
1955
+ const tr = row as HTMLTableRowElement
1956
+ tr.removeAttribute('height')
1957
+ tr.style.height = 'auto'
1958
+ tr.style.minHeight = '0'
1959
+ tr.style.pageBreakInside = 'auto'
1960
+ Array.from(tr.cells).forEach((cell, cellIndex) => {
1961
+ cell.removeAttribute('colspan')
1962
+ cell.removeAttribute('rowspan')
1963
+ cell.removeAttribute('height')
1964
+ const colW = colWidthsPt[cellIndex] ?? colWidthsPt[colWidthsPt.length - 1]
1965
+ if (colW != null) {
1966
+ cell.setAttribute('width', String(colW))
1967
+ cell.style.width = `${colW}pt`
1968
+ }
1969
+ cell.style.height = 'auto'
1970
+ cell.style.minHeight = '0'
1971
+ cell.style.minWidth = ''
1972
+ cell.style.maxWidth = ''
1973
+ cell.style.border = '1px solid #000000'
1974
+ cell.style.padding = cellPadding
1975
+ cell.style.fontSize = cellFontSize
1976
+ cell.style.lineHeight = '1.35'
1977
+ cell.style.wordWrap = 'break-word'
1978
+ cell.style.overflowWrap = 'break-word'
1979
+ cell.style.boxSizing = 'border-box'
1980
+ cell.style.verticalAlign = 'top'
1981
+ cell.style.pageBreakInside = 'auto'
1982
+ cell.style.overflow = 'hidden'
1983
+
1984
+ if (!cell.classList.contains('doc-th-green') && !cell.classList.contains('doc-th-red')) {
1985
+ cell.style.color = '#000000'
1986
+ }
1987
+ })
1988
+ })
1989
+ }
1990
+
1991
+ function createWordTableCell(info: DocxCellInfo, cellFontSize: string, cellPadding: string) {
1992
+ const cell = document.createElement(info.tag)
1993
+ cell.className = info.className
1994
+ cell.textContent = info.text
1995
+ cell.style.border = '1px solid #000000'
1996
+ cell.style.padding = cellPadding
1997
+ cell.style.fontSize = cellFontSize
1998
+ cell.style.lineHeight = '1.35'
1999
+ cell.style.height = 'auto'
2000
+ cell.style.minHeight = '0'
2001
+ cell.style.wordWrap = 'break-word'
2002
+ cell.style.overflowWrap = 'break-word'
2003
+ cell.style.boxSizing = 'border-box'
2004
+ cell.style.verticalAlign = 'top'
2005
+ cell.style.pageBreakInside = 'auto'
2006
+ if (info.textAlign) cell.style.textAlign = info.textAlign
2007
+ if (!cell.classList.contains('doc-th-green') && !cell.classList.contains('doc-th-red')) {
2008
+ cell.style.color = '#000000'
2009
+ }
2010
+ return cell
2011
+ }
2012
+
2013
+ function rebuildFlatWordTable(
2014
+ grid: DocxCellInfo[][],
2015
+ tableClasses: string,
2016
+ tableWidthPt = A4_CONTENT_WIDTH_PT
2017
+ ): HTMLDivElement {
2018
+ const colCount = grid[0]?.length ?? 0
2019
+ const isComplex = tableClasses.includes('doc-table-complex') || colCount >= 5
2020
+ const cellFontSize = isComplex ? '9pt' : '10pt'
2021
+ const cellPadding = isComplex ? '2pt 3pt' : '3pt 4pt'
2022
+ const nested = tableWidthPt < A4_CONTENT_WIDTH_PT - 1
2023
+
2024
+ const wrap = document.createElement('div')
2025
+ wrap.className = nested ? 'docx-table-wrap docx-table-wrap--nested' : 'docx-table-wrap'
2026
+ wrap.style.cssText = [
2027
+ `width:${tableWidthPt}pt`,
2028
+ `max-width:${tableWidthPt}pt`,
2029
+ 'height:auto',
2030
+ 'margin:6pt 0 12pt',
2031
+ 'padding:0',
2032
+ 'overflow:hidden',
2033
+ 'box-sizing:border-box',
2034
+ ].join(';')
2035
+
2036
+ const table = document.createElement('table')
2037
+ table.className = `${tableClasses} doc-table-word`.trim()
2038
+ applyWordTableStyles(table, colCount, tableWidthPt)
2039
+
2040
+ grid.forEach((row) => {
2041
+ const tr = document.createElement('tr')
2042
+ tr.style.height = 'auto'
2043
+ tr.style.minHeight = '0'
2044
+ tr.style.pageBreakInside = 'auto'
2045
+ row.forEach((info) => {
2046
+ tr.appendChild(createWordTableCell(info, cellFontSize, cellPadding))
2047
+ })
2048
+ table.appendChild(tr)
2049
+ })
2050
+
2051
+ wrap.appendChild(table)
2052
+ return wrap
2053
+ }
2054
+
2055
+ function tableHasSpanningCells(table: HTMLTableElement): boolean {
2056
+ return Array.from(table.querySelectorAll('th, td')).some((cell) => {
2057
+ const el = cell as HTMLTableCellElement
2058
+ return (el.colSpan || 1) > 1 || (el.rowSpan || 1) > 1
2059
+ })
2060
+ }
2061
+
2062
+ function getTableNestingDepth(table: HTMLTableElement): number {
2063
+ let depth = 0
2064
+ let el: HTMLElement | null = table.parentElement
2065
+ while (el) {
2066
+ if (el instanceof HTMLTableElement) depth += 1
2067
+ el = el.parentElement
2068
+ }
2069
+ return depth
2070
+ }
2071
+
2072
+ /** 嵌套表寬 = 父單元格可用寬(等比例,勿再拉滿整頁) */
2073
+ function resolveTableWidthPt(table: HTMLTableElement): number {
2074
+ const parentCell = table.parentElement?.closest('td, th') as HTMLTableCellElement | null
2075
+ if (!parentCell) return A4_CONTENT_WIDTH_PT
2076
+
2077
+ const raw =
2078
+ Number.parseFloat(parentCell.getAttribute('width') || '') ||
2079
+ Number.parseFloat(String(parentCell.style.width || '').replace(/pt$/i, '')) ||
2080
+ 0
2081
+ // 減去單元格左右 padding,避免嵌套表撐破父格
2082
+ const inner = raw > 0 ? Math.max(36, Math.round(raw - 8)) : Math.round(A4_CONTENT_WIDTH_PT * 0.48)
2083
+ return Math.min(inner, A4_CONTENT_WIDTH_PT)
2084
+ }
2085
+
2086
+ /** 去掉全空行,避免 Word 把空白行拉高 */
2087
+ function removeBlankTableRows(table: HTMLTableElement) {
2088
+ Array.from(table.rows).forEach((row) => {
2089
+ const hasText = Array.from(row.cells).some((cell) => (cell.textContent || '').trim().length > 0)
2090
+ const hasMedia = Array.from(row.cells).some((cell) => cell.querySelector('img, table, svg'))
2091
+ if (!hasText && !hasMedia) row.remove()
2092
+ })
2093
+ }
2094
+
2095
+ /** 重建 Word 安全表格:頂層鋪滿正文區;嵌套表按父格寬等比例;禁止行高被拉長 */
2096
+ function rebuildTablesForWord(root: HTMLElement) {
2097
+ const tables = Array.from(root.querySelectorAll('table')).filter((node) => {
2098
+ if (!(node instanceof HTMLTableElement)) return false
2099
+ if (node.classList.contains('docx-toc-row')) return false
2100
+ // 列表双列表:勿重建、勿加黑框
2101
+ if (node.classList.contains('doc-list-item-table')) return false
2102
+ if (node.closest('.doc-ol-export')) return false
2103
+ return true
2104
+ }) as HTMLTableElement[]
2105
+
2106
+ // 外層先處理,嵌套表才能讀到父 td 的 pt 寬
2107
+ tables.sort((a, b) => getTableNestingDepth(a) - getTableNestingDepth(b))
2108
+
2109
+ tables.forEach((node) => {
2110
+ // 已被包進 wrap 後替換掉的節點可能脫離文檔
2111
+ if (!root.contains(node)) return
2112
+
2113
+ removeBlankTableRows(node)
2114
+ if (!node.rows.length) {
2115
+ node.remove()
2116
+ return
2117
+ }
2118
+
2119
+ const tableWidthPt = resolveTableWidthPt(node)
2120
+ const nested = tableWidthPt < A4_CONTENT_WIDTH_PT - 1
2121
+ const tableClasses = Array.from(node.classList)
2122
+ .filter((c) => c !== 'doc-table-word')
2123
+ .join(' ')
2124
+ const colCount = getTableColumnCount(node)
2125
+
2126
+ if (tableHasSpanningCells(node)) {
2127
+ const grid = expandTableToGrid(node)
2128
+ // 展開後再剔空行
2129
+ const compact = grid.filter((row) => row.some((cell) => (cell.text || '').trim().length > 0))
2130
+ node.replaceWith(
2131
+ rebuildFlatWordTable(compact.length ? compact : grid, tableClasses, tableWidthPt)
2132
+ )
2133
+ return
2134
+ }
2135
+
2136
+ const wrap = document.createElement('div')
2137
+ wrap.className = nested ? 'docx-table-wrap docx-table-wrap--nested' : 'docx-table-wrap'
2138
+ wrap.style.cssText = [
2139
+ `width:${tableWidthPt}pt`,
2140
+ `max-width:${tableWidthPt}pt`,
2141
+ 'height:auto',
2142
+ 'margin:6pt 0 12pt',
2143
+ 'padding:0',
2144
+ 'overflow:hidden',
2145
+ 'box-sizing:border-box',
2146
+ ].join(';')
2147
+
2148
+ node.classList.add('doc-table-word')
2149
+ applyWordTableStyles(node, colCount, tableWidthPt)
2150
+ node.parentNode?.insertBefore(wrap, node)
2151
+ wrap.appendChild(node)
2152
+ })
2153
+ }
2154
+
2155
+ /** 導出正文去掉 [ref:…](含全角冒號/括號) */
2156
+ export function stripRefMarkersFromHtml(value: string) {
2157
+ return String(value || '')
2158
+ .replace(/\[\s*ref\s*[::]\s*[^\]]+\]/gi, '')
2159
+ .replace(/[\s*ref\s*[::][^]]+]/gi, '')
2160
+ .replace(
2161
+ /<p\b[^>]*>\s*參考資料\s*[::]?\s*<\/p>\s*(?:<p\b[^>]*>\s*(?:\[\d+\]\s*)*(?:\[\s*ref\s*[::][^\]]*\])?(?:\s*\[\d+\])*\s*<\/p>\s*)+/gi,
2162
+ ''
2163
+ )
2164
+ .replace(/[ \t]{2,}/g, ' ')
2165
+ }
2166
+
2167
+ /** 章節書籤:目錄 PAGEREF 目標(name + id,兼容 Word HTML altChunk) */
2168
+ function isChapterBookmarkName(value: string): boolean {
2169
+ return /^_Ch\d+$/i.test(String(value || '').trim())
2170
+ }
2171
+
2172
+ /** 表格重建 / 清裝飾後重新掛 _ChN 書籤(掛在章標題上,避免空錨被 Word 丟掉) */
2173
+ export function reannotateChapterBookmarksInDom(root: HTMLElement): void {
2174
+ const sections = Array.from(root.querySelectorAll('.docx-export-chapter'))
2175
+ const targets: Element[] = sections.length
2176
+ ? sections
2177
+ : Array.from(root.querySelectorAll('h1.docx-chapter-title'))
2178
+
2179
+ targets.forEach((target, i) => {
2180
+ const name = `_Ch${i + 1}`
2181
+ const heading =
2182
+ target.matches?.('h1, h2')
2183
+ ? target
2184
+ : target.querySelector('h1.docx-chapter-title, h1, h2') || target
2185
+
2186
+ if (heading instanceof HTMLElement) {
2187
+ heading.setAttribute('id', name)
2188
+ }
2189
+
2190
+ let anchor =
2191
+ (heading.querySelector(`:scope > a[name="${name}"]`) as HTMLElement | null) ||
2192
+ (heading.querySelector(`a[name="${name}"]`) as HTMLElement | null)
2193
+ if (!anchor) {
2194
+ anchor = document.createElement('a')
2195
+ heading.insertBefore(anchor, heading.firstChild)
2196
+ }
2197
+ anchor.setAttribute('name', name)
2198
+ anchor.removeAttribute('id')
2199
+ anchor.removeAttribute('href')
2200
+ })
2201
+ }
2202
+
2203
+ /**
2204
+ * 最終打包前清正文紅藍下標:
2205
+ * - 刪 [ref:…]
2206
+ * - 展開正文 <u>(保留小組成員標題)
2207
+ * - 去掉 underline / 藍紅底邊
2208
+ * - 保留目錄 PAGEREF 用的章節書籤 a[name=_ChN] / id=_ChN
2209
+ */
2210
+ export function sanitizeExportBodyHtml(html: string): string {
2211
+ let out = stripRefMarkersFromHtml(html)
2212
+
2213
+ out = out.replace(
2214
+ /(<p\b[^>]*class="[^"]*docx-members-label[^"]*"[^>]*>[\s\S]*?<\/p>)|<u\b[^>]*>([\s\S]*?)<\/u>/gi,
2215
+ (_full, membersLabel, inner) => (membersLabel ? membersLabel : String(inner ?? ''))
2216
+ )
2217
+
2218
+ out = out.replace(/\btext-decoration\s*:\s*[^;"']*underline[^;"']*;?/gi, 'text-decoration:none;')
2219
+ out = out.replace(/\btext-decoration-line\s*:\s*[^;"']*;?/gi, '')
2220
+ out = out.replace(/\btext-decoration-color\s*:\s*[^;"']*;?/gi, '')
2221
+ // 不在此處動 border-bottom:會誤傷表格下邊線
2222
+ // 去掉普通鏈接,但保留 _ChN 書籤錨點(目錄 PAGEREF 依賴)
2223
+ out = out.replace(/<a\b([^>]*)>([\s\S]*?)<\/a>/gi, (full, attrs, inner) => {
2224
+ if (/\b(?:name|id)\s*=\s*["']_Ch\d+["']/i.test(String(attrs || ''))) return full
2225
+ return String(inner ?? '')
2226
+ })
2227
+
2228
+ return out
2229
+ }
2230
+
2231
+ /** 正文去掉引用標記 / 藍紅下劃線等裝飾(頁眉 OOXML 雙藍線不受影響) */
2232
+ function stripBodyExportDecorations(root: HTMLElement) {
2233
+ const isMembersLabel = (el: Element) =>
2234
+ el.classList.contains('docx-members-label') || !!el.closest('.docx-members-label')
2235
+
2236
+ // 1) 刪除 citation 圓標(導出不需要)
2237
+ root.querySelectorAll('.report-tiptap-citation, [data-citation]').forEach((el) => el.remove())
2238
+
2239
+ // 2) 章節末「參考資料:[n] [ref:…]」整段去掉(佔位下標)
2240
+ root.querySelectorAll('.docx-chapter-refs-label, .docx-chapter-refs-item').forEach((el) => {
2241
+ el.remove()
2242
+ })
2243
+
2244
+ // 3) 鏈接去掉藍字下劃線,只留純文字;章節書籤 a[name=_ChN] 必須保留(目錄 PAGEREF)
2245
+ root.querySelectorAll('a').forEach((el) => {
2246
+ if (!(el instanceof HTMLElement)) return
2247
+ if (isMembersLabel(el)) return
2248
+ const name = String(el.getAttribute('name') || '')
2249
+ const id = String(el.getAttribute('id') || '')
2250
+ if (isChapterBookmarkName(name) || isChapterBookmarkName(id)) {
2251
+ el.removeAttribute('href')
2252
+ el.style.textDecoration = 'none'
2253
+ el.style.color = 'inherit'
2254
+ el.style.borderBottom = 'none'
2255
+ return
2256
+ }
2257
+ const text = el.textContent ?? ''
2258
+ const span = document.createElement('span')
2259
+ span.textContent = text
2260
+ el.replaceWith(span)
2261
+ })
2262
+
2263
+ // 4) <u> 展開為純文字(小組成員標題保留下劃線)
2264
+ root.querySelectorAll('u').forEach((el) => {
2265
+ if (isMembersLabel(el)) return
2266
+ const parent = el.parentNode
2267
+ if (!parent) return
2268
+ while (el.firstChild) parent.insertBefore(el.firstChild, el)
2269
+ el.remove()
2270
+ })
2271
+
2272
+ // 5) 去掉正文內聯 [ref:…](含全角冒號;跨文本節點先併父級再清)
2273
+ const refRe = /\[\s*ref\s*[::]\s*[^\]]+\]/gi
2274
+ root.querySelectorAll('p, li, td, th, h1, h2, h3, h4, span, div').forEach((el) => {
2275
+ if (!(el instanceof HTMLElement)) return
2276
+ if (isMembersLabel(el)) return
2277
+ const raw = el.innerHTML
2278
+ if (!/ref\s*[::]/i.test(raw) && !/[\s*ref/i.test(raw)) return
2279
+ const cleaned = stripRefMarkersFromHtml(raw)
2280
+ if (cleaned !== raw) el.innerHTML = cleaned
2281
+ })
2282
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
2283
+ const textNodes: Text[] = []
2284
+ while (walker.nextNode()) textNodes.push(walker.currentNode as Text)
2285
+ for (const node of textNodes) {
2286
+ if (!node.nodeValue || !refRe.test(node.nodeValue)) continue
2287
+ refRe.lastIndex = 0
2288
+ const next = node.nodeValue.replace(refRe, '').replace(/[ \t]{2,}/g, ' ')
2289
+ node.nodeValue = next
2290
+ }
2291
+
2292
+ // 5b) 刪掉只剩「參考資料:」或空編號的段落
2293
+ root.querySelectorAll('p').forEach((el) => {
2294
+ const t = (el.textContent || '').replace(/\s+/g, ' ').trim()
2295
+ if (/^參考資料\s*[::]?$/.test(t) || /^\[\d+\]$/.test(t)) {
2296
+ el.remove()
2297
+ }
2298
+ })
2299
+
2300
+ const isBlue = (c: string) => {
2301
+ const s = String(c || '').toLowerCase().replace(/\s+/g, '')
2302
+ if (!s || s === 'transparent' || s === 'inherit') return false
2303
+ if (s.includes('2262') || s.includes('0563c1') || s.includes('409eff') || s.includes('0070c0')) {
2304
+ return true
2305
+ }
2306
+ if (/^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(s)) {
2307
+ const hex =
2308
+ s.length === 4 ? `#${s[1]}${s[1]}${s[2]}${s[2]}${s[3]}${s[3]}` : s
2309
+ const r = Number.parseInt(hex.slice(1, 3), 16)
2310
+ const g = Number.parseInt(hex.slice(3, 5), 16)
2311
+ const b = Number.parseInt(hex.slice(5, 7), 16)
2312
+ return b > 140 && b > r + 30 && b > g + 20
2313
+ }
2314
+ return /rgb\(\s*\d+\s*,\s*\d+\s*,\s*(1[5-9]\d|2\d{2})\s*\)/i.test(s)
2315
+ }
2316
+
2317
+ const isRed = (c: string) => {
2318
+ const s = String(c || '').toLowerCase().replace(/\s+/g, '')
2319
+ if (!s || s === 'transparent' || s === 'inherit') return false
2320
+ if (s.includes('ff0000') || s.includes('e74c3c') || s.includes('f56c6c') || s.includes('c00000')) {
2321
+ return true
2322
+ }
2323
+ if (/^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(s)) {
2324
+ const hex =
2325
+ s.length === 4 ? `#${s[1]}${s[1]}${s[2]}${s[2]}${s[3]}${s[3]}` : s
2326
+ const r = Number.parseInt(hex.slice(1, 3), 16)
2327
+ const g = Number.parseInt(hex.slice(3, 5), 16)
2328
+ const b = Number.parseInt(hex.slice(5, 7), 16)
2329
+ return r > 160 && r > g + 40 && r > b + 40
2330
+ }
2331
+ return /rgb\(\s*(1[5-9]\d|2\d{2})\s*,\s*\d+\s*,\s*\d+\s*\)/i.test(s)
2332
+ }
2333
+
2334
+ // 6) 去掉正文文字下劃線 / 藍紅裝飾底邊(絕對不碰表格)
2335
+ const isTablePart = (el: Element) =>
2336
+ /^(TABLE|THEAD|TBODY|TFOOT|TR|TD|TH|COLGROUP|COL)$/i.test(el.tagName) ||
2337
+ !!el.closest('table')
2338
+
2339
+ root.querySelectorAll('*').forEach((node) => {
2340
+ if (!(node instanceof HTMLElement)) return
2341
+ if (isMembersLabel(node)) return
2342
+ if (isTablePart(node)) return
2343
+ const style = node.style
2344
+
2345
+ // 僅清文字裝飾用底邊(藍/紅),勿動黑框表格
2346
+ if (style.borderBottom && style.borderBottom !== 'none' && style.borderBottom !== '0px') {
2347
+ if (
2348
+ isBlue(style.borderBottomColor) ||
2349
+ isRed(style.borderBottomColor) ||
2350
+ isBlue(style.borderColor) ||
2351
+ isRed(style.borderColor)
2352
+ ) {
2353
+ style.borderBottom = 'none'
2354
+ style.borderBottomColor = ''
2355
+ }
2356
+ }
2357
+
2358
+ const deco = String(style.textDecoration || style.textDecorationLine || '')
2359
+ const decoColor = String(style.textDecorationColor || '')
2360
+ if (
2361
+ /underline/i.test(deco) ||
2362
+ isBlue(decoColor) ||
2363
+ isRed(decoColor) ||
2364
+ node.classList.contains('report-tiptap-link') ||
2365
+ node.classList.contains('doc-link-anchor')
2366
+ ) {
2367
+ style.textDecoration = 'none'
2368
+ style.textDecorationColor = ''
2369
+ style.textDecorationLine = 'none'
2370
+ if (isBlue(style.color) || isRed(style.color)) {
2371
+ style.color = '#000000'
2372
+ }
2373
+ }
2374
+
2375
+ if (/^H[1-6]$/.test(node.tagName) || node.classList.contains('docx-chapter-title')) {
2376
+ style.borderBottom = 'none'
2377
+ style.textDecoration = 'none'
2378
+ }
2379
+ })
2380
+ }
2381
+
2382
+ /** Word 导出移除预览分页标记,避免 MHT 中产生整页空白 */
2383
+ export function stripWordPageBreaks(root: HTMLElement) {
2384
+ root.querySelectorAll('[data-export-page-break]').forEach((el) => el.remove())
2385
+ }
2386
+
2387
+ /** @deprecated 使用 stripWordPageBreaks,注入分页符会在 Word 中产生大量空白 */
2388
+ export function injectWordPageBreaks(root: HTMLElement) {
2389
+ stripWordPageBreaks(root)
2390
+ }
2391
+
2392
+ /** 剥离 Vue / 无用属性,内联计算样式,尽量贴近页面 1:1 */
2393
+ export function prepareDomForDocx(root: HTMLElement, inPlace = false): HTMLElement {
2394
+ const clone = inPlace ? root : (root.cloneNode(true) as HTMLElement)
2395
+
2396
+ clone.querySelectorAll('.no-print, .animate-pulse, .typewriter-cursor').forEach((el) =>
2397
+ el.remove()
2398
+ )
2399
+
2400
+ const unmount = mountCloneForStyleRead(clone)
2401
+
2402
+ try {
2403
+ clone.querySelectorAll('*').forEach((el) => {
2404
+ if (!(el instanceof HTMLElement)) return
2405
+
2406
+ applyDocClassInlineStyles(el)
2407
+
2408
+ const computed = window.getComputedStyle(el)
2409
+ if (computed.display === 'flex' || computed.display === 'inline-flex') {
2410
+ el.style.display = 'block'
2411
+ }
2412
+
2413
+ for (const prop of STYLE_PROPS) {
2414
+ // 表格行高絕不能內聯像素 height,否則 Word 會把末行拉長變形
2415
+ if (prop === 'height' && /^(TABLE|TR|TD|TH|TBODY|THEAD|TFOOT|COLGROUP|COL)$/i.test(el.tagName)) {
2416
+ continue
2417
+ }
2418
+ const val = computed[prop as keyof CSSStyleDeclaration]
2419
+ if (!val || val === 'initial' || val === 'normal' || val === 'auto' || val === 'none') {
2420
+ continue
2421
+ }
2422
+ // TipTap / 滾動容器常算出 height:0,內聯後 Word 正文被裁成空白
2423
+ if (
2424
+ (prop === 'height' || prop === 'width' || prop === 'maxWidth') &&
2425
+ (val === '0px' || val === '0' || val === '0%')
2426
+ ) {
2427
+ continue
2428
+ }
2429
+ const styleRecord = el.style as unknown as Record<string, string>
2430
+ if (!styleRecord[prop]) {
2431
+ styleRecord[prop] = String(val)
2432
+ }
2433
+ }
2434
+
2435
+ if (el.tagName === 'TABLE') {
2436
+ el.style.borderCollapse = 'collapse'
2437
+ el.style.tableLayout = 'fixed'
2438
+ el.style.width = '100%'
2439
+ el.style.maxWidth = '100%'
2440
+ el.style.height = 'auto'
2441
+ el.style.minHeight = '0'
2442
+ el.style.pageBreakInside = 'auto'
2443
+ el.removeAttribute('height')
2444
+ }
2445
+ if (el.tagName === 'TR') {
2446
+ el.style.height = 'auto'
2447
+ el.style.minHeight = '0'
2448
+ el.removeAttribute('height')
2449
+ }
2450
+ if (el.tagName === 'TD' || el.tagName === 'TH') {
2451
+ el.style.border = '1px solid #000000'
2452
+ el.style.padding = '4pt 6pt'
2453
+ el.style.fontSize = '11pt'
2454
+ el.style.height = 'auto'
2455
+ el.style.minHeight = '0'
2456
+ el.style.verticalAlign = 'top'
2457
+ el.removeAttribute('height')
2458
+ if (!el.classList.contains('doc-th-green') && !el.classList.contains('doc-th-red')) {
2459
+ el.style.color = '#000000'
2460
+ }
2461
+ }
2462
+ if (el.tagName === 'IMG') {
2463
+ const img = el as HTMLImageElement
2464
+ el.style.display = el.style.display || 'block'
2465
+ el.style.maxWidth = el.style.maxWidth || '100%'
2466
+ if (!img.getAttribute('width') && img.naturalWidth > 0) {
2467
+ img.setAttribute('width', String(img.naturalWidth))
2468
+ }
2469
+ if (!img.getAttribute('height') && img.naturalHeight > 0) {
2470
+ img.setAttribute('height', String(img.naturalHeight))
2471
+ }
2472
+ }
2473
+ })
2474
+ } finally {
2475
+ unmount()
2476
+ }
2477
+
2478
+ clone.querySelectorAll('*').forEach((el) => {
2479
+ if (!(el instanceof HTMLElement)) return
2480
+ Array.from(el.attributes).forEach((attr) => {
2481
+ if (
2482
+ attr.name.startsWith('data-v-') ||
2483
+ attr.name === 'data-v-app' ||
2484
+ attr.name.startsWith('data-cy') ||
2485
+ attr.name === 'data-docx-export-host'
2486
+ ) {
2487
+ el.removeAttribute(attr.name)
2488
+ }
2489
+ })
2490
+ })
2491
+
2492
+ clampAllImagesForDocx(clone)
2493
+ flattenChartIntroForDocx(clone)
2494
+ flattenImagesBlockForDocx(clone)
2495
+ normalizeListsForDocx(clone)
2496
+ rebuildTablesForWord(clone)
2497
+ stripBodyExportDecorations(clone)
2498
+ // 表格重建 / 去裝飾後重新掛章節書籤,供目錄 PAGEREF(必須在 strip 之後)
2499
+ reannotateChapterBookmarksInDom(clone)
2500
+ // 去紅藍下標後強制還原表格邊框(防止誤清 border-bottom);列表雙列表保持無邊框
2501
+ clone.querySelectorAll('table').forEach((table) => {
2502
+ if (!(table instanceof HTMLElement)) return
2503
+ if (table.classList.contains('doc-list-item-table') || table.closest('.doc-ol-export')) {
2504
+ table.style.border = 'none'
2505
+ table.style.borderCollapse = 'collapse'
2506
+ return
2507
+ }
2508
+ table.style.borderCollapse = 'collapse'
2509
+ table.style.border = table.style.border || '1px solid #000000'
2510
+ })
2511
+ clone.querySelectorAll('td, th').forEach((cell) => {
2512
+ if (!(cell instanceof HTMLElement)) return
2513
+ if (cell.closest('.doc-list-item-table') || cell.closest('.doc-ol-export')) {
2514
+ cell.style.border = 'none'
2515
+ cell.style.borderBottom = 'none'
2516
+ return
2517
+ }
2518
+ cell.style.border = '1px solid #000000'
2519
+ cell.style.borderBottom = '1px solid #000000'
2520
+ })
2521
+
2522
+ clone.querySelectorAll('.typewriter-card__body, .content-wrapper, .doc-sample-body').forEach((el) => {
2523
+ if (!(el instanceof HTMLElement)) return
2524
+ el.style.minHeight = '0'
2525
+ el.style.height = 'auto'
2526
+ el.style.padding = '0'
2527
+ el.style.maxWidth = '100%'
2528
+ el.style.width = '100%'
2529
+ el.style.boxSizing = 'border-box'
2530
+ })
2531
+
2532
+ clone.classList.add('docx-page-root')
2533
+ clone.style.maxHeight = 'none'
2534
+ clone.style.overflow = 'hidden'
2535
+ clone.style.border = 'none'
2536
+ clone.style.padding = '0'
2537
+ clone.style.minHeight = '0'
2538
+ clone.style.backgroundColor = '#ffffff'
2539
+ clone.style.boxSizing = 'border-box'
2540
+ clone.style.width = '100%'
2541
+ clone.style.maxWidth = '100%'
2542
+ clone.style.margin = '0'
2543
+ clone.style.fontFamily = '"Times New Roman", "宋体", SimSun, serif'
2544
+ clone.style.fontSize = '11pt'
2545
+ clone.style.lineHeight = '1.5'
2546
+ clone.style.color = '#000000'
2547
+ clone.style.wordWrap = 'break-word'
2548
+
2549
+ clone.querySelectorAll('.echarts-container').forEach((el) => {
2550
+ if (!(el instanceof HTMLElement)) return
2551
+ if (el.querySelector('.docx-chart-wrap')) return
2552
+ el.style.width = '100%'
2553
+ el.style.maxWidth = '100%'
2554
+ el.style.textAlign = 'center'
2555
+ el.style.margin = '12pt auto'
2556
+ })
2557
+
2558
+ return clone
2559
+ }
2560
+
2561
+ export async function exportHtmlToDocx(
2562
+ fullHtmlOrParts: string | string[],
2563
+ options: DocxExportOptions = DEFAULT_DOCX_EXPORT_OPTIONS
2564
+ ): Promise<Blob> {
2565
+ return buildDocxZip(fullHtmlOrParts, { ...DEFAULT_DOCX_EXPORT_OPTIONS, ...options })
2566
+ }