aegon-lv82 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 (148) hide show
  1. package/package.json +12 -0
  2. package/src/App.vue +44 -0
  3. package/src/api/index.ts +19 -0
  4. package/src/api/modules/build-report/reportEditor.ts +526 -0
  5. package/src/api/modules/build-report/reportPermission.ts +64 -0
  6. package/src/api/modules/index.ts +69 -0
  7. package/src/api/modules/industry/green_mortgage.ts +42 -0
  8. package/src/api/modules/user/index.ts +4 -0
  9. package/src/api/request.ts +124 -0
  10. package/src/assets/sample-access-icon.png +0 -0
  11. package/src/assets/sample-pie-chart.png +0 -0
  12. package/src/assets/vue.svg +1 -0
  13. package/src/components/CapsuleScrollbar.vue +7 -0
  14. package/src/components/Export/ExcelExport.vue +592 -0
  15. package/src/components/Export/ExcelExport2.vue +494 -0
  16. package/src/components/Export/ExcelExport3.vue +342 -0
  17. package/src/components/Export/ExcelExport4.vue +665 -0
  18. package/src/components/Export/excelExport.js +547 -0
  19. package/src/components/Export/excelExport.ts +551 -0
  20. package/src/components/GEN-AI/index.vue +140 -0
  21. package/src/components/GEN-AI/index1.vue +422 -0
  22. package/src/components/GEN-AI/index10.vue +5 -0
  23. package/src/components/GEN-AI/index2.vue +548 -0
  24. package/src/components/GEN-AI/index3.vue +605 -0
  25. package/src/components/GEN-AI/index4.vue +609 -0
  26. package/src/components/GEN-AI/index5.vue +558 -0
  27. package/src/components/GEN-AI/index6.vue +634 -0
  28. package/src/components/GEN-AI/index7.vue +688 -0
  29. package/src/components/GEN-AI/index8.vue +458 -0
  30. package/src/components/GEN-AI/index9.vue +1217 -0
  31. package/src/components/GEN-AI/types.ts +12 -0
  32. package/src/components/GEN-AI/utils.ts +42 -0
  33. package/src/components/HelloWorld.vue +41 -0
  34. package/src/components/PageCard.vue +124 -0
  35. package/src/components/PageHeader.vue +32 -0
  36. package/src/components/backup/index5 copy.vue +556 -0
  37. package/src/components/backup/index5.vue +620 -0
  38. package/src/components/backup/index9 copy.vue +1029 -0
  39. package/src/components/backup/index9-pro.vue +1065 -0
  40. package/src/components/backup/index9.vue +1057 -0
  41. package/src/directives/btnLoading.ts +455 -0
  42. package/src/directives/debounce copy.ts +670 -0
  43. package/src/directives/debounce.ts +98 -0
  44. package/src/directives/index.ts +25 -0
  45. package/src/layouts/MainLayout.vue +129 -0
  46. package/src/main.ts +76 -0
  47. package/src/router/index.ts +142 -0
  48. package/src/router/menus.ts +28 -0
  49. package/src/store/modules/gen-ai/index.ts +1 -0
  50. package/src/store/modules/gen-ai/reportBuild.ts +37 -0
  51. package/src/style.css +79 -0
  52. package/src/styles/report-config-popover.css +39 -0
  53. package/src/styles/report-icon-btn.css +19 -0
  54. package/src/test/mock.ts +101 -0
  55. package/src/test/test1.vue +402 -0
  56. package/src/test/test2.vue +1689 -0
  57. package/src/types/gen-ai/build-report/reportEditor.ts +684 -0
  58. package/src/utils/function.ts +46 -0
  59. package/src/utils/gen-ai-navigation.ts +1 -0
  60. package/src/utils/gen-ai-scroll.ts +1 -0
  61. package/src/utils/openDataLoaderWordExport.ts +33 -0
  62. package/src/utils/pageScrollbar.ts +115 -0
  63. package/src/utils/randomTranscode.ts +87 -0
  64. package/src/utils/reportPdfExport.ts +44 -0
  65. package/src/views/Blank.vue +68 -0
  66. package/src/views/Home.vue +29 -0
  67. package/src/views/TemplateCenter/Knowledge.ts +83 -0
  68. package/src/views/TemplateCenter/data.d.ts +10 -0
  69. package/src/views/TemplateCenter/index.vue +1128 -0
  70. package/src/views/TemplateCenter/service.ts +69 -0
  71. package/src/views/gen-ai/build-report/chartStreamRefs.ts +174 -0
  72. package/src/views/gen-ai/build-report/coherence-check/index.vue +559 -0
  73. package/src/views/gen-ai/build-report/components/ReportChapterWorkspace.vue +4258 -0
  74. package/src/views/gen-ai/build-report/components/ReportConfigPanel.vue +727 -0
  75. package/src/views/gen-ai/build-report/components/ReportGenerateSettingsPanel.vue +2361 -0
  76. package/src/views/gen-ai/build-report/components/ReportGeneratingPanel.vue +1765 -0
  77. package/src/views/gen-ai/build-report/components/ReportPromptPanel.vue +614 -0
  78. package/src/views/gen-ai/build-report/components/ReportSourceFileSummary.vue +656 -0
  79. package/src/views/gen-ai/build-report/components/ReportSourcesPanel.vue +1327 -0
  80. package/src/views/gen-ai/build-report/components/ReportTemplateDetail.vue +191 -0
  81. package/src/views/gen-ai/build-report/components/ReportTemplateEditorFormPanel.vue +667 -0
  82. package/src/views/gen-ai/build-report/components/ReportTemplatePicker.vue +302 -0
  83. package/src/views/gen-ai/build-report/components/ReportWorkflowSidebar.vue +148 -0
  84. package/src/views/gen-ai/build-report/components/paramsSetting.vue +886 -0
  85. package/src/views/gen-ai/build-report/components/reportEdit.vue +2283 -0
  86. package/src/views/gen-ai/build-report/index.vue +856 -0
  87. package/src/views/gen-ai/build-report/report-domain.ts +1133 -0
  88. package/src/views/gen-ai/build-report/report-template-data.ts +295 -0
  89. package/src/views/gen-ai/build-report/useReportWorkflow.ts +1408 -0
  90. package/src/views/gen-ai/common/assets/NotoSans-SC.woff2 +0 -0
  91. package/src/views/gen-ai/common/assets/NotoSans-TC.woff2 +0 -0
  92. package/src/views/gen-ai/common/assets/NotoSans.woff2 +0 -0
  93. package/src/views/gen-ai/common/assets/fonts.css +278 -0
  94. package/src/views/gen-ai/common/assets/pagination.css +163 -0
  95. package/src/views/gen-ai/common/directives/btnLoading.ts +455 -0
  96. package/src/views/gen-ai/common/directives/debounce.ts +98 -0
  97. package/src/views/gen-ai/common/directives/index.ts +30 -0
  98. package/src/views/gen-ai/common/en.md +417 -0
  99. package/src/views/gen-ai/common/index.ts +36 -0
  100. package/src/views/gen-ai/common/types/component-expose.ts +9 -0
  101. package/src/views/gen-ai/common/utils/api-res-status.ts +7 -0
  102. package/src/views/gen-ai/common/utils/navigation.ts +59 -0
  103. package/src/views/gen-ai/common/utils/remote-request.ts +45 -0
  104. package/src/views/gen-ai/common/utils/scroll.ts +577 -0
  105. package/src/views/gen-ai/components/AIExport/docx-export.ts +950 -0
  106. package/src/views/gen-ai/components/AIExport/docxExport.ts +2566 -0
  107. package/src/views/gen-ai/components/AIExport/index.ts +52 -0
  108. package/src/views/gen-ai/components/AIExport/parseMarkdownHtml.ts +649 -0
  109. package/src/views/gen-ai/components/CapsuleScrollbar.vue +149 -0
  110. package/src/views/gen-ai/components/ChapterTitleScroll.vue +468 -0
  111. package/src/views/gen-ai/components/EntryHeroNavCards.vue +379 -0
  112. package/src/views/gen-ai/components/GenAiPagination.vue +52 -0
  113. package/src/views/gen-ai/components/ModelCard.vue +1114 -0
  114. package/src/views/gen-ai/components/Preview/FilePreviewPage.vue +947 -0
  115. package/src/views/gen-ai/components/Preview/PreviewCapsuleScroll.vue +205 -0
  116. package/src/views/gen-ai/components/Preview/PreviewOutlineTree.vue +148 -0
  117. package/src/views/gen-ai/components/Preview/index.ts +11 -0
  118. package/src/views/gen-ai/components/Preview/preview.ts +722 -0
  119. package/src/views/gen-ai/components/PromptField.vue +505 -0
  120. package/src/views/gen-ai/components/RecentReportsTable.vue +974 -0
  121. package/src/views/gen-ai/components/ReportCollapsibleSection.vue +172 -0
  122. package/src/views/gen-ai/components/ReportHistoryList.vue +637 -0
  123. package/src/views/gen-ai/components/ReportSectionConfirmPopover.vue +251 -0
  124. package/src/views/gen-ai/components/editor/ChapterTiptapEditor.vue +3139 -0
  125. package/src/views/gen-ai/components/editor/StructureBlockView.vue +441 -0
  126. package/src/views/gen-ai/components/editor/TiptapToolbar.vue +1036 -0
  127. package/src/views/gen-ai/components/editor/TypewriterDocEditor.vue +398 -0
  128. package/src/views/gen-ai/components/editor/_StructureOrgNode.vue +326 -0
  129. package/src/views/gen-ai/components/editor/index.ts +12 -0
  130. package/src/views/gen-ai/components/editor/parseMarkdownHtml.ts +667 -0
  131. package/src/views/gen-ai/gen-entry/api/index.ts +32 -0
  132. package/src/views/gen-ai/gen-entry/index.vue +287 -0
  133. package/src/views/gen-ai/gen-entry/types/index.ts +4 -0
  134. package/src/views/gen-ai/i.mark +2 -0
  135. package/src/views/gen-ai/management-center/index.vue +1815 -0
  136. package/src/views/gen-ai/management-center/mockData.ts +248 -0
  137. package/src/views/gen-ai/management-center/types/index.ts +37 -0
  138. package/src/views/gen-ai/model-manager/TemplateEditorFormPanel.vue +661 -0
  139. package/src/views/gen-ai/model-manager/api/index.ts +32 -0
  140. package/src/views/gen-ai/model-manager/api/mockApi.ts +33 -0
  141. package/src/views/gen-ai/model-manager/index.vue +1583 -0
  142. package/src/views/gen-ai/model-manager/mockData.ts +177 -0
  143. package/src/views/gen-ai/model-manager/template-detail-shared.ts +154 -0
  144. package/src/views/gen-ai/model-manager/template-editor.vue +345 -0
  145. package/src/views/gen-ai/model-manager/types/index.ts +40 -0
  146. package/src/views/gen-ai/model-manager/utils.ts +25 -0
  147. package/src/views/greenMortgage.vue +1834 -0
  148. package/src/vite-env.d.ts +46 -0
@@ -0,0 +1,950 @@
1
+ /**
2
+ * Word 導出(一期唯一底座:marked + JSZip/altChunk MHT)
3
+ *
4
+ * 版式對齊「行業調研 導出模板」:
5
+ * 頁眉「商密三级」藍字下劃線、頁腳 N / Total、
6
+ * 封面 / 小組成員 / 目錄 / N. 章節標題 / 參考文獻清單
7
+ */
8
+ import { saveAs } from 'file-saver'
9
+ import {
10
+ A4_CONTENT_WIDTH_PT,
11
+ A4_CONTENT_WIDTH_PX,
12
+ buildWordDocumentHtml,
13
+ exportHtmlToDocx,
14
+ prepareDomForDocx,
15
+ rasterizeImagesForDocx,
16
+ stripRefMarkersFromHtml,
17
+ stripWordPageBreaks,
18
+ } from './docxExport'
19
+ import { parseMarkdownToHtml } from './parseMarkdownHtml'
20
+
21
+ /** 默認密級(與導出模板頁眉一致) */
22
+ export const DOCX_HEADER_CLASSIFICATION = '商密三级'
23
+
24
+ export type ExportReportMember = {
25
+ name: string
26
+ employeeId?: string
27
+ department?: string
28
+ }
29
+
30
+ export type ExportSourceRef = {
31
+ /** 資訊源檔名(可含副檔名) */
32
+ fileName: string
33
+ /** 頁碼(有則輸出「第 N 頁」) */
34
+ page?: number | string
35
+ }
36
+
37
+ export type ExportReportMeta = {
38
+ /** 報告標題(封面居中) */
39
+ reportName?: string
40
+ /** 年份,如 2026 */
41
+ year?: string
42
+ /** 生成人(封面「小組成員」與 Client Manager 續頁標題) */
43
+ members?: ExportReportMember[]
44
+ /** 資訊源檔名(文末參考文獻清單) */
45
+ sourceFileNames?: string[]
46
+ /** 章節「參考資料」明細(含頁碼時優先於 sourceFileNames) */
47
+ sourceRefs?: ExportSourceRef[]
48
+ /**
49
+ * 目錄章節標題;缺省時從正文 h1.docx-chapter-title / 章節 meta 推斷
50
+ */
51
+ tocTitles?: string[]
52
+ }
53
+
54
+ export type ExportMarkdownChapter = {
55
+ title?: string
56
+ content: string
57
+ }
58
+
59
+ export type ExportMarkdownToAiWordOptions = {
60
+ filename?: string
61
+ headerText?: string
62
+ showPageNumbers?: boolean
63
+ hideSelectors?: string[]
64
+ removeSelectors?: string[]
65
+ fallbackChapters?: ExportMarkdownChapter[]
66
+ reportMeta?: ExportReportMeta
67
+ }
68
+
69
+ export type ExportMarkdownToDocxWordOptions = {
70
+ filename?: string
71
+ headerText?: string
72
+ showPageNumbers?: boolean
73
+ reportMeta?: ExportReportMeta
74
+ }
75
+
76
+ const DEFAULT_HIDE_SELECTORS = ['.animate-pulse', '.report-tiptap-editor__toolbar']
77
+ const DEFAULT_REMOVE_SELECTORS = [
78
+ '.report-tiptap-editor__toolbar',
79
+ '.no-export',
80
+ '.no-print',
81
+ '.report-chapter-review-popover',
82
+ ]
83
+
84
+ /** 首頁「小組成員」超過此行數則餘下獨占續頁 */
85
+ const COVER_MEMBER_LINES_MAX = 8
86
+
87
+ function escapeHtmlText(value: string) {
88
+ return value
89
+ .replace(/&/g, '&')
90
+ .replace(/</g, '&lt;')
91
+ .replace(/>/g, '&gt;')
92
+ .replace(/"/g, '&quot;')
93
+ }
94
+
95
+ function normalizeFilename(filename: string) {
96
+ const raw = String(filename || '報告').trim() || '報告'
97
+ // Windows / 瀏覽器下載非法字符;保留中文與常見符號
98
+ const safe = raw
99
+ .replace(/[\\/:*?"<>|]/g, '_')
100
+ .replace(/[\u0000-\u001f\u007f]/g, '')
101
+ .replace(/\s+/g, ' ')
102
+ .trim()
103
+ const base = safe || '報告'
104
+ return base.toLowerCase().endsWith('.docx') ? base : `${base}.docx`
105
+ }
106
+
107
+ /** file-saver 失敗時(iframe / 部分內核)用 <a download> 兜底 */
108
+ function downloadDocxBlob(blob: Blob, filename: string) {
109
+ const name = normalizeFilename(filename)
110
+ try {
111
+ saveAs(blob, name)
112
+ return
113
+ } catch (e) {
114
+ console.warn('file-saver saveAs failed, fallback to anchor download:', e)
115
+ }
116
+ const url = URL.createObjectURL(blob)
117
+ try {
118
+ const a = document.createElement('a')
119
+ a.href = url
120
+ a.download = name
121
+ a.rel = 'noopener'
122
+ a.style.display = 'none'
123
+ document.body.appendChild(a)
124
+ a.click()
125
+ a.remove()
126
+ } finally {
127
+ // 延遲 revoke,避免部分瀏覽器下載未開始就失效
128
+ window.setTimeout(() => URL.revokeObjectURL(url), 60_000)
129
+ }
130
+ }
131
+
132
+ async function assertHealthyDocxBlob(blob: Blob) {
133
+ if (!(blob instanceof Blob) || blob.size < 1024) {
134
+ throw new Error('Word 導出失敗:生成的文件異常過小,請確認已完整複製 AIExport 並安裝依賴')
135
+ }
136
+ // 校驗 ZIP 魔數,避免下載到「假 docx」
137
+ const head = new Uint8Array(await blob.slice(0, 4).arrayBuffer())
138
+ if (head[0] !== 0x50 || head[1] !== 0x4b) {
139
+ throw new Error('Word 導出失敗:文件不是有效的 docx(ZIP),請檢查 jszip')
140
+ }
141
+ }
142
+
143
+ function readableTextLength(htmlOrText: string) {
144
+ return htmlOrText
145
+ .replace(/<[^>]+>/g, ' ')
146
+ .replace(/&nbsp;/gi, ' ')
147
+ .replace(/\s+/g, '')
148
+ .trim().length
149
+ }
150
+
151
+ /** 導出正文去掉 [ref:…] 字面量(避免 Word 紅波浪 / 佔位下標) */
152
+ function stripRefMarkersFromText(value: string) {
153
+ return stripRefMarkersFromHtml(value)
154
+ }
155
+
156
+ /**
157
+ * 導出不再把 [ref:…] 寫進正文 / 章節末「參考資料」
158
+ * (紅藍下標僅頁眉密級保留)
159
+ */
160
+ function appendRefAppendixToChaptersHtml(bodyHtml: string, _meta: ExportReportMeta = {}): string {
161
+ return bodyHtml
162
+ }
163
+
164
+ /**
165
+ * Word 打開 HTML altChunk 時可靠的硬分頁。
166
+ * 只用單一 page-break(禁止雙重 br+p,否則 TOC 後會多一頁空白);
167
+ * 禁止 section-break(否則會新建節、頁面大小不一致)。
168
+ */
169
+ function pageBreakHtml() {
170
+ return `<p class="docx-page-break" style="page-break-before:always;mso-special-character:line-break;height:0;margin:0;padding:0;border:0;font-size:1pt;line-height:0;">&nbsp;</p>`
171
+ }
172
+
173
+ /** 離屏測量 HTML 塊高度(px),供封面/目錄實佔頁數 */
174
+ function measureHtmlBlockHeightPx(html: string): number {
175
+ if (typeof document === 'undefined') return 0
176
+ const probe = document.createElement('div')
177
+ probe.style.cssText = [
178
+ 'position:fixed',
179
+ 'left:-100000px',
180
+ 'top:0',
181
+ `width:${A4_CONTENT_WIDTH_PX}px`,
182
+ 'visibility:hidden',
183
+ 'pointer-events:none',
184
+ 'box-sizing:border-box',
185
+ 'font-family:"Times New Roman","PMingLiU","新細明體",SimSun,serif',
186
+ 'font-size:11pt',
187
+ 'line-height:1.5',
188
+ 'color:#000',
189
+ ].join(';')
190
+ // 注入與導出一致的關鍵樣式,避免 class 未生效導致佔頁測偏
191
+ probe.innerHTML = `<style>
192
+ .docx-cover-spacer{margin:0;padding:0;font-size:1pt;line-height:260pt;}
193
+ .docx-cover-title{font-size:18pt;font-weight:bold;line-height:1.45;margin:0 0 8pt;text-align:center;}
194
+ .docx-cover-year{font-size:18pt;font-weight:bold;line-height:1.45;margin:0;text-align:center;}
195
+ .docx-toc-title{font-size:12pt;margin:12pt 0 18pt;}
196
+ .docx-toc-entry{font-size:12pt;line-height:1.5;margin:0 0 6pt;}
197
+ .docx-chapter-title{font-size:14pt;font-weight:bold;margin:14pt 0 8pt;line-height:1.45;}
198
+ p{font-size:11pt;line-height:1.5;margin:0 0 10pt;}
199
+ table{border-collapse:collapse;max-width:100%;width:100%;}
200
+ </style>${String(html || '').trim() || '<p>&nbsp;</p>'}`
201
+ document.body.appendChild(probe)
202
+ try {
203
+ return Math.max(1, Math.ceil(Math.max(probe.scrollHeight, probe.offsetHeight, 1)))
204
+ } finally {
205
+ probe.remove()
206
+ }
207
+ }
208
+
209
+ /** 按可印區高度把塊高換算為頁數(每段硬分頁後至少 1 頁) */
210
+ function pageCountForHtmlBlock(html: string, pageH: number): number {
211
+ const h = measureHtmlBlockHeightPx(html)
212
+ return Math.max(1, Math.ceil(h / Math.max(1, pageH)))
213
+ }
214
+
215
+ /** 目錄條目過多時估頁(無 DOM 回退) */
216
+ function estimateTocPageCount(titleCount: number) {
217
+ const n = Math.max(0, titleCount)
218
+ // 標題 ~36pt + 每條 ~24pt;A4 可印區約 650pt
219
+ const usedPt = 36 + Math.max(1, n) * 24
220
+ return Math.max(1, Math.ceil(usedPt / 650))
221
+ }
222
+
223
+ /** 去掉已有編號 / 方塊前綴,再套「N. 標題」 */
224
+ export function formatChapterHeading(index: number, title: string) {
225
+ const bare = String(title || '')
226
+ .replace(/^■\s*/, '')
227
+ .replace(/^\d+\.\s*/, '')
228
+ .trim()
229
+ return `${index}. ${bare || `章節 ${index}`}`
230
+ }
231
+
232
+ function formatMemberLine(member: ExportReportMember) {
233
+ const dept = String(member.department ?? '').trim()
234
+ const emp = String(member.employeeId ?? '').trim()
235
+ const name = String(member.name ?? '').trim()
236
+ if (dept && emp && name) return `${dept} ${emp}: ${name}`
237
+ if (dept && name) return `${dept}: ${name}`
238
+ return name || emp || dept
239
+ }
240
+
241
+ function buildMemberLinesHtml(members: ExportReportMember[]) {
242
+ if (!members.length) return `<p class="docx-member-line">—</p>`
243
+ return members
244
+ .map((m) => `<p class="docx-member-line">${escapeHtmlText(formatMemberLine(m))}</p>`)
245
+ .join('')
246
+ }
247
+
248
+ /**
249
+ * 封面第 1 頁 1:1:
250
+ * - 上方居中標題
251
+ * - 中間大空白
252
+ * - 「小組成員:」+ 生成人落在首頁約下 1/3
253
+ * - 不超出首頁不新開頁;超出才續頁
254
+ */
255
+ function buildFrontMatterParts(meta: ExportReportMeta): string[] {
256
+ const reportName = String(meta.reportName ?? '').trim() || '行業調研報告'
257
+ const year = String(meta.year ?? '').trim()
258
+ const yearLabel = year ? `(${year} 年)` : ''
259
+
260
+ // 去重生成人(同一人名只保留一行,同屬一塊)
261
+ const seen = new Set<string>()
262
+ const members: ExportReportMember[] = []
263
+ for (const m of meta.members ?? []) {
264
+ const line = formatMemberLine(m)
265
+ if (!line || seen.has(line)) continue
266
+ seen.add(line)
267
+ members.push(m)
268
+ }
269
+
270
+ // 標題分行:長標題可多行;年份單獨一行(對齊模板)
271
+ const titleLines = reportName
272
+ .split(/\n+/)
273
+ .map((s) => s.trim())
274
+ .filter(Boolean)
275
+ const titleHtml = titleLines
276
+ .map((line) => `<p class="docx-cover-title">${escapeHtmlText(line)}</p>`)
277
+ .join('')
278
+ const yearHtml = yearLabel ? `<p class="docx-cover-year">${escapeHtmlText(yearLabel)}</p>` : ''
279
+
280
+ const onCover = members.slice(0, COVER_MEMBER_LINES_MAX)
281
+ const overflow = members.slice(COVER_MEMBER_LINES_MAX)
282
+
283
+ // spacer 約 240pt:把「小組成員」推到首頁下 1/3(Word 對固定行高最穩)
284
+ const coverHtml = `
285
+ <div class="docx-cover-page">
286
+ <div class="docx-cover">
287
+ ${titleHtml}
288
+ ${yearHtml}
289
+ </div>
290
+ <p class="docx-cover-spacer">&nbsp;</p>
291
+ <div class="docx-members-block">
292
+ <p class="docx-members-label"><u>小組成員</u>:</p>
293
+ ${buildMemberLinesHtml(onCover)}
294
+ </div>
295
+ </div>
296
+ `
297
+
298
+ const parts = [coverHtml]
299
+
300
+ if (overflow.length) {
301
+ parts.push(`
302
+ <div class="docx-members-block docx-members-block--cont">
303
+ <p class="docx-members-label"><u>小組成員</u>:</p>
304
+ ${buildMemberLinesHtml(overflow)}
305
+ </div>
306
+ `)
307
+ }
308
+
309
+ return parts
310
+ }
311
+
312
+ /** 目錄頁:寫靜態實測頁碼(不用 PAGEREF——altChunk 下會失效並全部變成 1) */
313
+ function buildTocHtml(titles: string[], pageNumbers: number[]) {
314
+ const entries = titles
315
+ .map((title, i) => {
316
+ const n = i + 1
317
+ const bare = String(title || '')
318
+ .replace(/^■\s*/, '')
319
+ .replace(/^\d+\.\s*/, '')
320
+ .trim()
321
+ const label = `${n}. ${bare || `章節 ${n}`}`
322
+ const raw = Number(pageNumbers[i] ?? pageNumbers[pageNumbers.length - 1] ?? pageNumbers[0] ?? 1)
323
+ const page = Number.isFinite(raw) && raw >= 1 ? Math.floor(raw) : 1
324
+ return `<p class="docx-toc-entry" style="margin:0 0 6pt 0;padding:0;font-size:12pt;line-height:1.5;text-align:left;text-indent:0;font-family:'Times New Roman','PMingLiU','新細明體',serif;mso-ascii-font-family:'Times New Roman';mso-fareast-font-family:'新細明體';mso-hansi-font-family:'Times New Roman';tab-stops:right dotted ${A4_CONTENT_WIDTH_PT}pt;">${escapeHtmlText(label)}<span style="mso-tab-count:1">&nbsp;</span>${page}</p>`
325
+ })
326
+ .join('\n')
327
+
328
+ return `<div class="docx-toc-page-root">
329
+ <p class="docx-toc-title" style="color:#2262E0;font-size:12pt;font-weight:normal;margin:12pt 0 18pt 0;padding:0;font-family:'FangSong','仿宋',serif;mso-ascii-font-family:'FangSong';mso-fareast-font-family:'仿宋';mso-hansi-font-family:'FangSong';text-decoration:none;border:none;">目錄</p>
330
+ ${entries || `<p class="docx-toc-entry" style="margin:0;font-size:12pt;font-family:'Times New Roman','PMingLiU','新細明體',serif;">(暫無章節)</p>`}
331
+ </div>`
332
+ }
333
+
334
+ function buildReferencesAppendixHtml(meta: ExportReportMeta) {
335
+ const year =
336
+ String(meta.year ?? new Date().getFullYear()).trim() || String(new Date().getFullYear())
337
+ const names = (meta.sourceFileNames ?? []).map((n) => String(n || '').trim()).filter(Boolean)
338
+ const lines =
339
+ names.length > 0
340
+ ? names
341
+ .map(
342
+ (name) =>
343
+ `<p class="docx-ref-line" style="border:none;mso-border-alt:none;margin:0 0 4pt;">[發布機構]. (${escapeHtmlText(year)}). 《${escapeHtmlText(name)}》</p>`
344
+ )
345
+ .join('')
346
+ : `<p class="docx-ref-line" style="border:none;mso-border-alt:none;margin:0 0 4pt;">[發布機構]. (${escapeHtmlText(year)}). 《[上傳的文件]》</p>`
347
+
348
+ return `
349
+ <div class="docx-refs-appendix" style="border:none;mso-border-alt:none;">
350
+ <p class="docx-appendix-title" style="border:none;mso-border-alt:none;">参考文献清单 (References List):</p>
351
+ ${lines}
352
+ </div>
353
+ `
354
+ }
355
+
356
+ /** 正文章節加書籤,供目錄 PAGEREF(掛在標題上:id + name,兼容 Word HTML) */
357
+ function annotateChapterBookmarks(bodyHtml: string): string {
358
+ if (typeof document === 'undefined') return bodyHtml
359
+ const host = document.createElement('div')
360
+ host.innerHTML = bodyHtml
361
+ const ensureBookmark = (parent: Element, index: number) => {
362
+ const name = `_Ch${index}`
363
+ const heading =
364
+ parent.matches?.('h1, h2')
365
+ ? parent
366
+ : parent.querySelector('h1.docx-chapter-title, h1, h2') || parent
367
+
368
+ if (heading instanceof HTMLElement) {
369
+ heading.setAttribute('id', name)
370
+ }
371
+
372
+ let anchor =
373
+ (heading.querySelector(`:scope > a[name="${name}"]`) as HTMLElement | null) ||
374
+ (heading.querySelector(`a[name="${name}"]`) as HTMLElement | null)
375
+ if (!anchor) {
376
+ anchor = document.createElement('a')
377
+ heading.insertBefore(anchor, heading.firstChild)
378
+ }
379
+ anchor.setAttribute('name', name)
380
+ anchor.removeAttribute('id')
381
+ anchor.removeAttribute('href')
382
+ }
383
+
384
+ const sections = Array.from(host.querySelectorAll('.docx-export-chapter'))
385
+ if (sections.length) {
386
+ sections.forEach((section, i) => ensureBookmark(section, i + 1))
387
+ return host.innerHTML
388
+ }
389
+
390
+ const headings = Array.from(host.querySelectorAll('h1.docx-chapter-title'))
391
+ headings.forEach((h, i) => ensureBookmark(h, i + 1))
392
+ return host.innerHTML
393
+ }
394
+
395
+ function extractChapterSectionHtmls(bodyHtml: string, chapterCount: number): string[] {
396
+ const host = document.createElement('div')
397
+ host.innerHTML = bodyHtml
398
+ const sections = Array.from(host.querySelectorAll('.docx-export-chapter'))
399
+ if (sections.length) {
400
+ return sections.map((el) => (el as HTMLElement).outerHTML)
401
+ }
402
+ const html = bodyHtml
403
+ const parts = html.split(/(?=<h1\b[^>]*class="[^"]*docx-chapter-title)/i).filter(Boolean)
404
+ if (parts.length >= 2) return parts
405
+ return Array.from({ length: Math.max(chapterCount, 1) }, (_, i) => (i === 0 ? bodyHtml : ''))
406
+ }
407
+
408
+ /**
409
+ * A4 可打印正文區高度(上下頁邊距各 2cm),與 @page / sectPr 一致。
410
+ * 勿再乘經驗係數:偏小會把目錄章頁碼系統性抬高。
411
+ */
412
+ function getExportPageContentHeightPx() {
413
+ return Math.max(1, Math.round(((297 - 40) / 25.4) * 96)) // ≈970px @96dpi
414
+ }
415
+
416
+ /** 封面模板固定佔 1 頁;其餘硬分頁塊按實測高度折頁 */
417
+ function pageCountForFrontPart(html: string, pageH: number): number {
418
+ if (/docx-cover-page/i.test(html)) return 1
419
+ return pageCountForHtmlBlock(html, pageH)
420
+ }
421
+
422
+ /**
423
+ * 按正文連續流測量每章頂部位置 → 起始頁碼(短章可同頁同碼)。
424
+ * 必須在表格已 rebuild 後的 HTML 上測,否則頁碼會偏大/偏小。
425
+ */
426
+ function computeChapterStartPages(sectionHtmls: string[], bodyStartPage: number): number[] {
427
+ const start = Math.max(1, Math.floor(Number(bodyStartPage)) || 1)
428
+ if (!sectionHtmls.length) return [start]
429
+
430
+ const measured = measureChapterStartOffsetsPx(sectionHtmls)
431
+ let pages: number[]
432
+ if (measured && measured.length === sectionHtmls.length) {
433
+ const pageH = getExportPageContentHeightPx()
434
+ pages = measured.map((y) => start + Math.floor(Math.max(0, y) / pageH))
435
+ } else {
436
+ const CHARS_PER_PAGE = 880
437
+ const HEADING_OVERHEAD = 36
438
+ pages = []
439
+ let usedChars = 0
440
+ for (let i = 0; i < sectionHtmls.length; i++) {
441
+ pages.push(start + Math.floor(usedChars / CHARS_PER_PAGE))
442
+ const html = sectionHtmls[i] || ''
443
+ const textLen = readableTextLength(html)
444
+ const tableBonus = (html.match(/<table\b/gi) || []).length * 220
445
+ const imgBonus = (html.match(/<img\b/gi) || []).length * 160
446
+ usedChars += Math.max(HEADING_OVERHEAD, textLen + HEADING_OVERHEAD + tableBonus + imgBonus)
447
+ }
448
+ }
449
+
450
+ for (let i = 1; i < pages.length; i++) {
451
+ const prev = pages[i - 1] ?? start
452
+ const cur = pages[i] ?? prev
453
+ pages[i] = Math.max(cur, prev)
454
+ }
455
+ return pages.map((p) => {
456
+ const v = Math.floor(Number(p))
457
+ return Number.isFinite(v) && v >= 1 ? v : start
458
+ })
459
+ }
460
+
461
+ /** 離屏連續排版,返回每章相對正文頂的 offsetTop(px) */
462
+ function measureChapterStartOffsetsPx(sectionHtmls: string[]): number[] | null {
463
+ if (typeof document === 'undefined') return null
464
+
465
+ const probe = document.createElement('div')
466
+ probe.setAttribute('data-docx-toc-measure', 'true')
467
+ probe.style.cssText = [
468
+ 'position:fixed',
469
+ 'left:-100000px',
470
+ 'top:0',
471
+ `width:${A4_CONTENT_WIDTH_PX}px`,
472
+ 'visibility:hidden',
473
+ 'pointer-events:none',
474
+ 'box-sizing:border-box',
475
+ 'font-family:"Times New Roman","PMingLiU","新細明體",SimSun,serif',
476
+ 'font-size:11pt',
477
+ 'line-height:1.5',
478
+ 'color:#000',
479
+ ].join(';')
480
+
481
+ const flow = document.createElement('div')
482
+ flow.className = 'docx-chapters-body'
483
+ flow.style.cssText = 'width:100%;box-sizing:border-box;margin:0;padding:0;'
484
+
485
+ sectionHtmls.forEach((html, i) => {
486
+ const wrap = document.createElement('div')
487
+ wrap.className = 'docx-export-chapter'
488
+ wrap.setAttribute('data-toc-measure-ch', String(i))
489
+ // sectionHtmls 可能是 outerHTML(已含 chapter 殼)或內層 HTML
490
+ const trimmed = String(html || '').trim()
491
+ if (/class=["'][^"']*docx-export-chapter/i.test(trimmed)) {
492
+ wrap.innerHTML = trimmed
493
+ // 若誤套雙層,取內層章節
494
+ const inner = wrap.querySelector(':scope > .docx-export-chapter') || wrap
495
+ if (inner !== wrap) {
496
+ wrap.innerHTML = inner.innerHTML
497
+ }
498
+ } else {
499
+ wrap.innerHTML =
500
+ trimmed ||
501
+ '<h1 class="docx-chapter-title" style="font-size:14pt;font-weight:bold;margin:14pt 0 8pt;">&nbsp;</h1><p style="margin:0 0 10pt;">&nbsp;</p>'
502
+ }
503
+ wrap.querySelectorAll('h1.docx-chapter-title, .docx-chapter-title').forEach((node) => {
504
+ const el = node as HTMLElement
505
+ el.style.fontSize = '14pt'
506
+ el.style.fontWeight = 'bold'
507
+ el.style.margin = '14pt 0 8pt'
508
+ el.style.lineHeight = '1.45'
509
+ })
510
+ wrap.querySelectorAll('p').forEach((node) => {
511
+ const el = node as HTMLElement
512
+ if (!el.style.margin) el.style.margin = '0 0 10pt'
513
+ if (!el.style.fontSize) el.style.fontSize = '11pt'
514
+ if (!el.style.lineHeight) el.style.lineHeight = '1.5'
515
+ })
516
+ wrap.querySelectorAll('table').forEach((node) => {
517
+ const el = node as HTMLElement
518
+ el.style.maxWidth = '100%'
519
+ el.style.borderCollapse = 'collapse'
520
+ el.style.height = 'auto'
521
+ })
522
+ flow.appendChild(wrap)
523
+ })
524
+
525
+ probe.appendChild(flow)
526
+ // 與導出正文樣式對齊,避免測頁與 Word 版式差一截
527
+ const style = document.createElement('style')
528
+ style.textContent = `
529
+ .docx-chapters-body .docx-chapter-title{font-size:14pt;font-weight:bold;margin:14pt 0 8pt;line-height:1.45;}
530
+ .docx-chapters-body p{font-size:11pt;line-height:1.5;margin:0 0 10pt;}
531
+ .docx-chapters-body table{border-collapse:collapse;max-width:100%;width:100%;}
532
+ .docx-chapters-body td,.docx-chapters-body th{border:1px solid #000;padding:4pt 6pt;font-size:11pt;vertical-align:top;}
533
+ `
534
+ probe.appendChild(style)
535
+ document.body.appendChild(probe)
536
+
537
+ try {
538
+ // 累計 offsetHeight,避免 getBoundingClientRect 全 0 導致目錄頁碼全相同
539
+ void probe.offsetHeight
540
+ const offsets: number[] = []
541
+ let y = 0
542
+ for (let i = 0; i < sectionHtmls.length; i++) {
543
+ const el = flow.querySelector(`[data-toc-measure-ch="${i}"]`) as HTMLElement | null
544
+ offsets.push(y)
545
+ const h = el ? Math.max(el.offsetHeight, el.scrollHeight, 0) : 0
546
+ y += Math.max(h, 24)
547
+ }
548
+ return offsets
549
+ } finally {
550
+ probe.remove()
551
+ }
552
+ }
553
+
554
+ /** 將正文章節標題統一為 N. …(h1,供 Word 導航窗格) */
555
+ export function normalizeChapterHeadingsInHtml(bodyHtml: string, titles?: string[]): string {
556
+ const host = document.createElement('div')
557
+ host.innerHTML = bodyHtml
558
+
559
+ const sections = Array.from(host.querySelectorAll('.docx-export-chapter'))
560
+ if (sections.length) {
561
+ sections.forEach((section, i) => {
562
+ const heading = section.querySelector('h1, h2')
563
+ const fallbackTitle = titles?.[i] ?? `章節 ${i + 1}`
564
+ const raw = heading?.textContent?.trim() || fallbackTitle
565
+ const h1 = document.createElement('h1')
566
+ h1.className = 'docx-chapter-title'
567
+ h1.textContent = formatChapterHeading(i + 1, raw)
568
+ if (heading) heading.replaceWith(h1)
569
+ else section.insertBefore(h1, section.firstChild)
570
+ })
571
+ return host.innerHTML
572
+ }
573
+
574
+ const tops = Array.from(host.children).filter(
575
+ (el) => el instanceof HTMLElement && /^(H1|H2)$/.test(el.tagName)
576
+ ) as HTMLElement[]
577
+ if (tops.length) {
578
+ tops.forEach((el, i) => {
579
+ const h1 = document.createElement('h1')
580
+ h1.className = 'docx-chapter-title'
581
+ h1.textContent = formatChapterHeading(i + 1, el.textContent || titles?.[i] || '')
582
+ el.replaceWith(h1)
583
+ })
584
+ }
585
+
586
+ return host.innerHTML
587
+ }
588
+
589
+ function resolveTocTitles(meta: ExportReportMeta, bodyHtml: string): string[] {
590
+ if (meta.tocTitles?.length) {
591
+ return meta.tocTitles.map((t) => String(t || '').trim()).filter(Boolean)
592
+ }
593
+ const host = document.createElement('div')
594
+ host.innerHTML = bodyHtml
595
+ return Array.from(host.querySelectorAll('h1.docx-chapter-title, h1, h2'))
596
+ .map((el) => el.textContent?.trim() || '')
597
+ .filter(Boolean)
598
+ }
599
+
600
+ /** 目錄標題與正文各章順序對齊(頁碼按章測量,標題也必須同源) */
601
+ function alignTocTitlesWithSections(sectionHtmls: string[], fallbackTitles: string[]): string[] {
602
+ return sectionHtmls.map((html, i) => {
603
+ if (typeof document !== 'undefined') {
604
+ const host = document.createElement('div')
605
+ host.innerHTML = html
606
+ const heading = host.querySelector('h1.docx-chapter-title, h1, h2')
607
+ const fromBody = heading?.textContent?.trim()
608
+ if (fromBody) return fromBody
609
+ }
610
+ return String(fallbackTitles[i] || '').trim() || `章節 ${i + 1}`
611
+ })
612
+ }
613
+
614
+ /**
615
+ * 拆成多個 HTML 片段,由硬分頁切開:
616
+ * 封面… →【目錄獨頁】→【正文連續排版,章間不分頁】→ 參考文獻
617
+ *
618
+ * 目錄頁碼:封面(1) + 目錄佔頁 + 正文連續流實測各章起始頁(靜態數字,不用 PAGEREF)。
619
+ */
620
+ export function buildIndustryResearchExportParts(
621
+ bodyHtml: string,
622
+ meta: ExportReportMeta = {}
623
+ ): { parts: string[]; chapterStartPage: number } {
624
+ const normalizedBody = normalizeChapterHeadingsInHtml(bodyHtml, meta.tocTitles)
625
+ const bodyWithRefs = appendRefAppendixToChaptersHtml(normalizedBody, meta)
626
+ const tocTitlesFallback = resolveTocTitles(meta, bodyWithRefs)
627
+ const frontParts = buildFrontMatterParts(meta)
628
+ const pageH = getExportPageContentHeightPx()
629
+
630
+ // 先按導出規則重建表格,再連續流測頁
631
+ let measureHtml = bodyWithRefs
632
+ if (typeof document !== 'undefined') {
633
+ const host = document.createElement('div')
634
+ host.className = 'docx-page-root'
635
+ host.innerHTML = `<div class="docx-chapters-body">${bodyWithRefs}</div>`
636
+ try {
637
+ prepareDomForDocx(host, true)
638
+ measureHtml =
639
+ host.querySelector('.docx-chapters-body')?.innerHTML?.trim() || host.innerHTML
640
+ } catch {
641
+ measureHtml = bodyWithRefs
642
+ }
643
+ }
644
+
645
+ // 書籤可保留(不影響目錄數字);目錄只用靜態實測頁碼
646
+ measureHtml = annotateChapterBookmarks(measureHtml)
647
+
648
+ const sectionHtmls = extractChapterSectionHtmls(measureHtml, tocTitlesFallback.length || 1)
649
+ const tocTitles = alignTocTitlesWithSections(sectionHtmls, tocTitlesFallback)
650
+
651
+ let frontPageCount = 0
652
+ if (typeof document !== 'undefined') {
653
+ frontPageCount = frontParts.reduce((sum, part) => sum + pageCountForFrontPart(part, pageH), 0)
654
+ } else {
655
+ frontPageCount = Math.max(1, frontParts.length)
656
+ }
657
+ frontPageCount = Math.max(1, frontPageCount)
658
+
659
+ let tocPageCount =
660
+ typeof document !== 'undefined'
661
+ ? pageCountForHtmlBlock(
662
+ buildTocHtml(
663
+ tocTitles,
664
+ tocTitles.map((_, i) => frontPageCount + estimateTocPageCount(tocTitles.length) + 1 + i)
665
+ ),
666
+ pageH
667
+ )
668
+ : estimateTocPageCount(tocTitles.length)
669
+ tocPageCount = Math.max(1, tocPageCount)
670
+
671
+ let bodyStartPage = frontPageCount + tocPageCount + 1
672
+ let pageNumbers = computeChapterStartPages(sectionHtmls, bodyStartPage)
673
+
674
+ // 目錄條目數與章節數對齊:缺的頁碼用最後一章頁碼補上
675
+ while (pageNumbers.length < tocTitles.length) {
676
+ pageNumbers.push(pageNumbers[pageNumbers.length - 1] ?? bodyStartPage)
677
+ }
678
+ pageNumbers = pageNumbers.slice(0, tocTitles.length)
679
+
680
+ let tocPart = buildTocHtml(tocTitles, pageNumbers)
681
+
682
+ if (typeof document !== 'undefined') {
683
+ const tocPagesFinal = pageCountForHtmlBlock(tocPart, pageH)
684
+ if (tocPagesFinal !== tocPageCount) {
685
+ tocPageCount = Math.max(1, tocPagesFinal)
686
+ bodyStartPage = frontPageCount + tocPageCount + 1
687
+ pageNumbers = computeChapterStartPages(sectionHtmls, bodyStartPage)
688
+ while (pageNumbers.length < tocTitles.length) {
689
+ pageNumbers.push(pageNumbers[pageNumbers.length - 1] ?? bodyStartPage)
690
+ }
691
+ pageNumbers = pageNumbers.slice(0, tocTitles.length)
692
+ tocPart = buildTocHtml(tocTitles, pageNumbers)
693
+ }
694
+ }
695
+
696
+ const bodyPart = `<div class="docx-chapters-body">${measureHtml}</div>`
697
+
698
+ return {
699
+ chapterStartPage: bodyStartPage,
700
+ parts: [...frontParts, tocPart, bodyPart, buildReferencesAppendixHtml(meta)],
701
+ }
702
+ }
703
+
704
+ /** @deprecated 請用 buildIndustryResearchExportParts(OOXML 硬分頁) */
705
+ export function wrapIndustryResearchExportHtml(
706
+ bodyHtml: string,
707
+ meta: ExportReportMeta = {}
708
+ ): string {
709
+ return buildIndustryResearchExportParts(bodyHtml, meta).parts.join(pageBreakHtml())
710
+ }
711
+
712
+ function buildBodyHtmlFromMarkdown(
713
+ input: string | ExportMarkdownChapter[],
714
+ tocTitles?: string[],
715
+ meta: ExportReportMeta = {}
716
+ ): string {
717
+ if (typeof input === 'string') {
718
+ const html = parseMarkdownToHtml(input)
719
+ return appendRefAppendixToChaptersHtml(`<div class="docx-export-chapter">${html}</div>`, meta)
720
+ }
721
+
722
+ return input
723
+ .map((chapter, i) => {
724
+ const title = String(chapter.title ?? tocTitles?.[i] ?? '').trim()
725
+ const titleHtml = title
726
+ ? `<h1 class="docx-chapter-title">${escapeHtmlText(formatChapterHeading(i + 1, title))}</h1>`
727
+ : ''
728
+ const rawContent = stripRefMarkersFromText(String(chapter.content ?? ''))
729
+ let bodyHtml = parseMarkdownToHtml(rawContent)
730
+ bodyHtml = stripRefMarkersFromText(bodyHtml)
731
+ // 正文若已含同標題 h1/h2,去掉避免重複
732
+ if (title) {
733
+ const re = new RegExp(
734
+ `^\\s*<h[12][^>]*>\\s*(?:■\\s*)?(?:\\d+\\.\\s*)?${title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*</h[12]>`,
735
+ 'i'
736
+ )
737
+ bodyHtml = bodyHtml.replace(re, '')
738
+ }
739
+ // 導出不插入章節末 [ref:…]「參考資料」佔位
740
+ return `<div class="docx-export-chapter">${titleHtml}${bodyHtml}</div>`
741
+ })
742
+ .filter(Boolean)
743
+ .join('\n')
744
+ }
745
+
746
+ /** 優先抽取 TipTap/ProseMirror 正文 */
747
+ function buildExportRootFromElement(element: HTMLElement): HTMLElement {
748
+ const proses = Array.from(
749
+ element.querySelectorAll('.ProseMirror, .report-tiptap-editor__prose')
750
+ ) as HTMLElement[]
751
+
752
+ if (proses.length) {
753
+ const host = document.createElement('div')
754
+ host.className = 'docx-page-root docx-export-from-prose'
755
+ for (const prose of proses) {
756
+ const html = prose.innerHTML.trim()
757
+ if (!html || readableTextLength(html) < 1) continue
758
+ const section = document.createElement('div')
759
+ section.className = 'docx-export-chapter'
760
+ section.innerHTML = html
761
+ host.appendChild(section)
762
+ }
763
+ if (host.childElementCount > 0) return host
764
+ }
765
+
766
+ return element.cloneNode(true) as HTMLElement
767
+ }
768
+
769
+ async function finalizeAndDownload(
770
+ bodyInnerHtml: string,
771
+ options: {
772
+ filename: string
773
+ headerText: string
774
+ showPageNumbers: boolean
775
+ reportMeta?: ExportReportMeta
776
+ }
777
+ ): Promise<Blob> {
778
+ const { parts } = buildIndustryResearchExportParts(bodyInnerHtml, options.reportMeta ?? {})
779
+ // 封面 / 目錄+正文 / 參考文獻:在 HTML 內分頁,再打成【單一】完整 HTML → 單一 afchunk.html
780
+ // 禁止多段 altChunk / 禁止 MHT(否則遷移環境易出現 --------tDocumentpart--)
781
+ const joinedBody = parts
782
+ .map((part) => String(part || '').trim())
783
+ .filter(Boolean)
784
+ .join(pageBreakHtml())
785
+ if (!joinedBody || readableTextLength(joinedBody) < 1) {
786
+ throw new Error('Word 導出失敗:組裝後無可打包內容')
787
+ }
788
+ const fullHtml = buildWordDocumentHtml(joinedBody)
789
+ const blob = await exportHtmlToDocx(fullHtml, {
790
+ headerFooter: {
791
+ headerText: options.headerText,
792
+ showPageNumbers: options.showPageNumbers,
793
+ },
794
+ })
795
+ await assertHealthyDocxBlob(blob)
796
+ downloadDocxBlob(blob, options.filename)
797
+ return blob
798
+ }
799
+
800
+ /**
801
+ * 將頁面中的報告內容 DOM 導出為 Word(.docx)
802
+ */
803
+ export async function exportMarkdownToAiWord(
804
+ element: HTMLElement,
805
+ options: ExportMarkdownToAiWordOptions = {}
806
+ ): Promise<Blob> {
807
+ const {
808
+ filename = '報告.docx',
809
+ headerText = DOCX_HEADER_CLASSIFICATION,
810
+ showPageNumbers = true,
811
+ hideSelectors = DEFAULT_HIDE_SELECTORS,
812
+ removeSelectors = DEFAULT_REMOVE_SELECTORS,
813
+ fallbackChapters,
814
+ reportMeta,
815
+ } = options
816
+
817
+ const hidden: Array<{ el: HTMLElement; display: string }> = []
818
+ for (const selector of hideSelectors) {
819
+ element.querySelectorAll(selector).forEach((node) => {
820
+ const el = node as HTMLElement
821
+ hidden.push({ el, display: el.style.display })
822
+ el.style.display = 'none'
823
+ })
824
+ }
825
+
826
+ try {
827
+ const cloneContainer = buildExportRootFromElement(element)
828
+ for (const selector of removeSelectors) {
829
+ cloneContainer.querySelectorAll(selector).forEach((node) => node.remove())
830
+ }
831
+
832
+ const domTextLen = readableTextLength(cloneContainer.innerHTML)
833
+ const fallbackTextLen = Array.isArray(fallbackChapters)
834
+ ? fallbackChapters.reduce(
835
+ (sum, chapter) =>
836
+ sum +
837
+ readableTextLength(String(chapter.title ?? '')) +
838
+ readableTextLength(String(chapter.content ?? '')),
839
+ 0
840
+ )
841
+ : 0
842
+
843
+ // 權威章節正文明顯長於 DOM(打字機未完成 / 流式佔位)→ 走 Markdown 導出
844
+ if (
845
+ fallbackChapters?.length &&
846
+ fallbackTextLen > 0 &&
847
+ (domTextLen < 1 || domTextLen < Math.max(20, Math.floor(fallbackTextLen * 0.5)))
848
+ ) {
849
+ return exportMarkdownToDocxWord(fallbackChapters, {
850
+ filename,
851
+ headerText,
852
+ showPageNumbers,
853
+ reportMeta,
854
+ })
855
+ }
856
+ if (domTextLen < 1) {
857
+ throw new Error('暫無可導出的頁面正文')
858
+ }
859
+
860
+ stripWordPageBreaks(cloneContainer)
861
+ try {
862
+ await rasterizeImagesForDocx(cloneContainer, element)
863
+ } catch (e) {
864
+ console.warn('rasterizeImagesForDocx soft-fail:', e)
865
+ }
866
+ await new Promise((resolve) => setTimeout(resolve, 200))
867
+
868
+ const prepared = prepareDomForDocx(cloneContainer, true)
869
+ if (readableTextLength(prepared.innerHTML) < 1) {
870
+ if (fallbackChapters?.length) {
871
+ return exportMarkdownToDocxWord(fallbackChapters, {
872
+ filename,
873
+ headerText,
874
+ showPageNumbers,
875
+ reportMeta,
876
+ })
877
+ }
878
+ throw new Error('暫無可導出的頁面正文')
879
+ }
880
+
881
+ return finalizeAndDownload(prepared.innerHTML, {
882
+ filename,
883
+ headerText,
884
+ showPageNumbers,
885
+ reportMeta,
886
+ })
887
+ } finally {
888
+ for (const { el, display } of hidden) {
889
+ el.style.display = display
890
+ }
891
+ }
892
+ }
893
+
894
+ /**
895
+ * Markdown → 同源 marked → 模板殼 → docx
896
+ */
897
+ export async function exportMarkdownToDocxWord(
898
+ markdownOrChapters: string | ExportMarkdownChapter[],
899
+ options: ExportMarkdownToDocxWordOptions = {}
900
+ ): Promise<Blob> {
901
+ const {
902
+ filename = '報告.docx',
903
+ headerText = DOCX_HEADER_CLASSIFICATION,
904
+ showPageNumbers = true,
905
+ reportMeta,
906
+ } = options
907
+
908
+ const tocTitles =
909
+ reportMeta?.tocTitles ??
910
+ (Array.isArray(markdownOrChapters)
911
+ ? markdownOrChapters.map((c) => String(c.title ?? '').trim())
912
+ : undefined)
913
+
914
+ const bodyHtml = buildBodyHtmlFromMarkdown(markdownOrChapters, tocTitles, reportMeta ?? {}).trim()
915
+ if (!bodyHtml || readableTextLength(bodyHtml) < 1) {
916
+ throw new Error('暫無可導出的 Markdown 內容')
917
+ }
918
+
919
+ const host = document.createElement('div')
920
+ host.setAttribute('data-docx-export', 'markdown-transform')
921
+ host.className = 'docx-page-root'
922
+ host.style.cssText =
923
+ 'position:fixed;left:-10000px;top:0;width:794px;opacity:0;pointer-events:none;'
924
+ host.innerHTML = bodyHtml
925
+ document.body.appendChild(host)
926
+
927
+ try {
928
+ stripWordPageBreaks(host)
929
+ try {
930
+ await rasterizeImagesForDocx(host, host)
931
+ } catch (e) {
932
+ // 圖片柵格化失敗不應阻斷正文導出
933
+ console.warn('rasterizeImagesForDocx soft-fail:', e)
934
+ }
935
+ await new Promise((resolve) => setTimeout(resolve, 200))
936
+
937
+ const prepared = prepareDomForDocx(host, true)
938
+ return finalizeAndDownload(prepared.innerHTML, {
939
+ filename,
940
+ headerText,
941
+ showPageNumbers,
942
+ reportMeta: {
943
+ ...reportMeta,
944
+ tocTitles: tocTitles?.filter(Boolean),
945
+ },
946
+ })
947
+ } finally {
948
+ host.remove()
949
+ }
950
+ }