md2ui 1.0.9 → 1.0.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -2
- package/src/App.vue +8 -4
- package/src/components/DocContent.vue +17 -3
- package/src/components/ImageZoom.vue +119 -15
- package/src/composables/useDocManager.js +25 -8
- package/src/composables/useExportWord.js +632 -0
- package/src/style.css +42 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "md2ui",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.11",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "将本地 Markdown 文档转换为美观的 HTML 页面",
|
|
6
6
|
"author": "",
|
|
@@ -22,7 +22,8 @@
|
|
|
22
22
|
"scripts": {
|
|
23
23
|
"dev": "vite",
|
|
24
24
|
"build": "vite build",
|
|
25
|
-
"preview": "vite preview"
|
|
25
|
+
"preview": "vite preview",
|
|
26
|
+
"publish": "bash publish.sh"
|
|
26
27
|
},
|
|
27
28
|
"files": [
|
|
28
29
|
"bin",
|
|
@@ -33,6 +34,8 @@
|
|
|
33
34
|
],
|
|
34
35
|
"dependencies": {
|
|
35
36
|
"@vitejs/plugin-vue": "^5.0.0",
|
|
37
|
+
"docx": "^9.6.1",
|
|
38
|
+
"file-saver": "^2.0.5",
|
|
36
39
|
"flexsearch": "^0.8.212",
|
|
37
40
|
"github-slugger": "^2.0.0",
|
|
38
41
|
"highlight.js": "^11.11.1",
|
package/src/App.vue
CHANGED
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
:htmlContent="htmlContent"
|
|
47
47
|
:prevDoc="prevDoc"
|
|
48
48
|
:nextDoc="nextDoc"
|
|
49
|
+
:docTitle="currentDocTitle"
|
|
49
50
|
@scroll="handleScroll"
|
|
50
51
|
@content-click="onContentClick"
|
|
51
52
|
@start="loadFirstDoc"
|
|
@@ -77,7 +78,7 @@
|
|
|
77
78
|
</button>
|
|
78
79
|
</transition>
|
|
79
80
|
<!-- 图片放大 -->
|
|
80
|
-
<ImageZoom :visible="zoomVisible" :
|
|
81
|
+
<ImageZoom :visible="zoomVisible" :images="zoomImages" :currentIndex="zoomIndex" @update:currentIndex="zoomIndex = $event" @close="zoomVisible = false" />
|
|
81
82
|
|
|
82
83
|
</div>
|
|
83
84
|
</template>
|
|
@@ -104,10 +105,12 @@ const sidebarCollapsed = ref(false)
|
|
|
104
105
|
const tocCollapsed = ref(false)
|
|
105
106
|
const zoomVisible = ref(false)
|
|
106
107
|
const zoomContent = ref('')
|
|
108
|
+
const zoomImages = ref([])
|
|
109
|
+
const zoomIndex = ref(0)
|
|
107
110
|
|
|
108
111
|
// composables
|
|
109
112
|
const {
|
|
110
|
-
docsList, currentDoc, showWelcome, htmlContent, tocItems,
|
|
113
|
+
docsList, currentDoc, currentDocTitle, showWelcome, htmlContent, tocItems,
|
|
111
114
|
scrollProgress, showBackToTop, activeHeading,
|
|
112
115
|
handleScroll, scrollToHeading, scrollToTop,
|
|
113
116
|
loadDocsList, loadFromUrl, goHome, loadDoc, loadFirstDoc,
|
|
@@ -125,8 +128,9 @@ const { isMobile, mobileDrawerOpen, mobileTocOpen } = useMobile()
|
|
|
125
128
|
// 内容区点击:委托给 docManager,图片放大回调在这里处理
|
|
126
129
|
function onContentClick(event) {
|
|
127
130
|
handleContentClick(event, {
|
|
128
|
-
onZoom(
|
|
129
|
-
|
|
131
|
+
onZoom({ images, index }) {
|
|
132
|
+
zoomImages.value = images
|
|
133
|
+
zoomIndex.value = index
|
|
130
134
|
zoomVisible.value = true
|
|
131
135
|
}
|
|
132
136
|
})
|
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
<main class="content" @scroll="$emit('scroll', $event)" @click="$emit('content-click', $event)">
|
|
3
3
|
<WelcomePage v-if="showWelcome" @start="$emit('start')" />
|
|
4
4
|
<template v-else>
|
|
5
|
+
<div class="doc-toolbar">
|
|
6
|
+
<button class="export-word-btn" :disabled="exporting" @click="handleExport" title="导出为 Word 文档">
|
|
7
|
+
<FileDown :size="15" />
|
|
8
|
+
<span>{{ exporting ? '导出中...' : '导出 Word' }}</span>
|
|
9
|
+
</button>
|
|
10
|
+
</div>
|
|
5
11
|
<article class="markdown-content" v-html="htmlContent"></article>
|
|
6
12
|
<nav v-if="prevDoc || nextDoc" class="doc-nav">
|
|
7
13
|
<a v-if="prevDoc" class="doc-nav-link prev" @click.prevent="$emit('load-doc', prevDoc.key)">
|
|
@@ -25,15 +31,23 @@
|
|
|
25
31
|
</template>
|
|
26
32
|
|
|
27
33
|
<script setup>
|
|
28
|
-
import { ChevronLeft, ChevronRight } from 'lucide-vue-next'
|
|
34
|
+
import { ChevronLeft, ChevronRight, FileDown } from 'lucide-vue-next'
|
|
29
35
|
import WelcomePage from './WelcomePage.vue'
|
|
36
|
+
import { useExportWord } from '../composables/useExportWord.js'
|
|
30
37
|
|
|
31
|
-
defineProps({
|
|
38
|
+
const props = defineProps({
|
|
32
39
|
showWelcome: { type: Boolean, default: true },
|
|
33
40
|
htmlContent: { type: String, default: '' },
|
|
34
41
|
prevDoc: { type: Object, default: null },
|
|
35
|
-
nextDoc: { type: Object, default: null }
|
|
42
|
+
nextDoc: { type: Object, default: null },
|
|
43
|
+
docTitle: { type: String, default: '文档' }
|
|
36
44
|
})
|
|
37
45
|
|
|
38
46
|
defineEmits(['scroll', 'content-click', 'start', 'load-doc'])
|
|
47
|
+
|
|
48
|
+
const { exporting, exportToWord } = useExportWord()
|
|
49
|
+
|
|
50
|
+
function handleExport() {
|
|
51
|
+
exportToWord(props.docTitle)
|
|
52
|
+
}
|
|
39
53
|
</script>
|
|
@@ -23,11 +23,25 @@
|
|
|
23
23
|
<RotateCcw :size="16" />
|
|
24
24
|
</button>
|
|
25
25
|
<span class="zoom-level">{{ Math.round(scale * 100) }}%</span>
|
|
26
|
+
<!-- 图片计数 -->
|
|
27
|
+
<span v-if="images.length > 1" class="image-counter">{{ currentIndex + 1 }} / {{ images.length }}</span>
|
|
26
28
|
<button class="zoom-btn close-btn" @click="close" title="关闭">
|
|
27
29
|
<X :size="16" />
|
|
28
30
|
</button>
|
|
29
31
|
</div>
|
|
30
32
|
|
|
33
|
+
<!-- 左切换按钮 -->
|
|
34
|
+
<button
|
|
35
|
+
v-if="images.length > 1"
|
|
36
|
+
class="nav-btn nav-btn-left"
|
|
37
|
+
:class="{ 'nav-btn-disabled': currentIndex <= 0 }"
|
|
38
|
+
:disabled="currentIndex <= 0"
|
|
39
|
+
@click="goPrev"
|
|
40
|
+
title="上一张"
|
|
41
|
+
>
|
|
42
|
+
<ChevronLeft :size="28" />
|
|
43
|
+
</button>
|
|
44
|
+
|
|
31
45
|
<!-- 图片容器 -->
|
|
32
46
|
<div
|
|
33
47
|
class="image-wrapper"
|
|
@@ -35,16 +49,28 @@
|
|
|
35
49
|
transform: `translate(${translateX}px, ${translateY}px) scale(${scale})`,
|
|
36
50
|
transformOrigin: 'center center'
|
|
37
51
|
}"
|
|
38
|
-
v-html="
|
|
52
|
+
v-html="currentContent"
|
|
39
53
|
></div>
|
|
54
|
+
|
|
55
|
+
<!-- 右切换按钮 -->
|
|
56
|
+
<button
|
|
57
|
+
v-if="images.length > 1"
|
|
58
|
+
class="nav-btn nav-btn-right"
|
|
59
|
+
:class="{ 'nav-btn-disabled': currentIndex >= images.length - 1 }"
|
|
60
|
+
:disabled="currentIndex >= images.length - 1"
|
|
61
|
+
@click="goNext"
|
|
62
|
+
title="下一张"
|
|
63
|
+
>
|
|
64
|
+
<ChevronRight :size="28" />
|
|
65
|
+
</button>
|
|
40
66
|
</div>
|
|
41
67
|
</div>
|
|
42
68
|
</teleport>
|
|
43
69
|
</template>
|
|
44
70
|
|
|
45
71
|
<script setup>
|
|
46
|
-
import { ref } from 'vue'
|
|
47
|
-
import { ZoomIn, ZoomOut, RotateCcw, X } from 'lucide-vue-next'
|
|
72
|
+
import { ref, computed } from 'vue'
|
|
73
|
+
import { ZoomIn, ZoomOut, RotateCcw, X, ChevronLeft, ChevronRight } from 'lucide-vue-next'
|
|
48
74
|
|
|
49
75
|
// Props
|
|
50
76
|
const props = defineProps({
|
|
@@ -52,14 +78,33 @@ const props = defineProps({
|
|
|
52
78
|
type: Boolean,
|
|
53
79
|
default: false
|
|
54
80
|
},
|
|
81
|
+
// 兼容单图模式
|
|
55
82
|
imageContent: {
|
|
56
83
|
type: String,
|
|
57
84
|
default: ''
|
|
85
|
+
},
|
|
86
|
+
// 图片列表(多图切换)
|
|
87
|
+
images: {
|
|
88
|
+
type: Array,
|
|
89
|
+
default: () => []
|
|
90
|
+
},
|
|
91
|
+
// 当前图片索引
|
|
92
|
+
currentIndex: {
|
|
93
|
+
type: Number,
|
|
94
|
+
default: 0
|
|
58
95
|
}
|
|
59
96
|
})
|
|
60
97
|
|
|
61
98
|
// Emits
|
|
62
|
-
const emit = defineEmits(['close'])
|
|
99
|
+
const emit = defineEmits(['close', 'update:currentIndex'])
|
|
100
|
+
|
|
101
|
+
// 当前展示内容:优先使用列表模式
|
|
102
|
+
const currentContent = computed(() => {
|
|
103
|
+
if (props.images.length > 0) {
|
|
104
|
+
return props.images[props.currentIndex] || ''
|
|
105
|
+
}
|
|
106
|
+
return props.imageContent
|
|
107
|
+
})
|
|
63
108
|
|
|
64
109
|
// 缩放和拖拽状态
|
|
65
110
|
const scale = ref(1)
|
|
@@ -95,6 +140,21 @@ function resetZoom() {
|
|
|
95
140
|
translateY.value = 0
|
|
96
141
|
}
|
|
97
142
|
|
|
143
|
+
// 切换图片时重置缩放
|
|
144
|
+
function goPrev() {
|
|
145
|
+
if (props.currentIndex > 0) {
|
|
146
|
+
resetZoom()
|
|
147
|
+
emit('update:currentIndex', props.currentIndex - 1)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function goNext() {
|
|
152
|
+
if (props.currentIndex < props.images.length - 1) {
|
|
153
|
+
resetZoom()
|
|
154
|
+
emit('update:currentIndex', props.currentIndex + 1)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
98
158
|
// 关闭
|
|
99
159
|
function close() {
|
|
100
160
|
resetZoom()
|
|
@@ -111,12 +171,9 @@ function handleOverlayClick(event) {
|
|
|
111
171
|
// 处理滚轮缩放
|
|
112
172
|
function handleWheel(event) {
|
|
113
173
|
event.preventDefault()
|
|
114
|
-
|
|
115
|
-
// 使用比例缩放,每次滚轮缩放 2%,体感更平滑
|
|
116
174
|
const zoomFactor = 0.02
|
|
117
175
|
const direction = event.deltaY > 0 ? -1 : 1
|
|
118
176
|
const newScale = Math.max(minScale, Math.min(maxScale, scale.value * (1 + direction * zoomFactor)))
|
|
119
|
-
|
|
120
177
|
if (newScale !== scale.value) {
|
|
121
178
|
scale.value = newScale
|
|
122
179
|
}
|
|
@@ -124,8 +181,7 @@ function handleWheel(event) {
|
|
|
124
181
|
|
|
125
182
|
// 处理鼠标按下
|
|
126
183
|
function handleMouseDown(event) {
|
|
127
|
-
if (event.target.closest('.zoom-toolbar')) return
|
|
128
|
-
|
|
184
|
+
if (event.target.closest('.zoom-toolbar') || event.target.closest('.nav-btn')) return
|
|
129
185
|
isDragging.value = true
|
|
130
186
|
lastMouseX.value = event.clientX
|
|
131
187
|
lastMouseY.value = event.clientY
|
|
@@ -135,13 +191,10 @@ function handleMouseDown(event) {
|
|
|
135
191
|
// 处理鼠标移动
|
|
136
192
|
function handleMouseMove(event) {
|
|
137
193
|
if (!isDragging.value) return
|
|
138
|
-
|
|
139
194
|
const deltaX = event.clientX - lastMouseX.value
|
|
140
195
|
const deltaY = event.clientY - lastMouseY.value
|
|
141
|
-
|
|
142
196
|
translateX.value += deltaX
|
|
143
197
|
translateY.value += deltaY
|
|
144
|
-
|
|
145
198
|
lastMouseX.value = event.clientX
|
|
146
199
|
lastMouseY.value = event.clientY
|
|
147
200
|
}
|
|
@@ -154,7 +207,6 @@ function handleMouseUp() {
|
|
|
154
207
|
// 监听键盘事件
|
|
155
208
|
function handleKeyDown(event) {
|
|
156
209
|
if (!props.visible) return
|
|
157
|
-
|
|
158
210
|
switch (event.key) {
|
|
159
211
|
case 'Escape':
|
|
160
212
|
close()
|
|
@@ -169,10 +221,15 @@ function handleKeyDown(event) {
|
|
|
169
221
|
case '0':
|
|
170
222
|
resetZoom()
|
|
171
223
|
break
|
|
224
|
+
case 'ArrowLeft':
|
|
225
|
+
goPrev()
|
|
226
|
+
break
|
|
227
|
+
case 'ArrowRight':
|
|
228
|
+
goNext()
|
|
229
|
+
break
|
|
172
230
|
}
|
|
173
231
|
}
|
|
174
232
|
|
|
175
|
-
// 添加键盘监听
|
|
176
233
|
if (typeof window !== 'undefined') {
|
|
177
234
|
window.addEventListener('keydown', handleKeyDown)
|
|
178
235
|
}
|
|
@@ -261,6 +318,53 @@ if (typeof window !== 'undefined') {
|
|
|
261
318
|
text-align: center;
|
|
262
319
|
}
|
|
263
320
|
|
|
321
|
+
.image-counter {
|
|
322
|
+
font-size: 12px;
|
|
323
|
+
font-weight: 600;
|
|
324
|
+
color: #718096;
|
|
325
|
+
padding: 0 4px;
|
|
326
|
+
white-space: nowrap;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/* 左右切换按钮 */
|
|
330
|
+
.nav-btn {
|
|
331
|
+
position: absolute;
|
|
332
|
+
top: 50%;
|
|
333
|
+
transform: translateY(-50%);
|
|
334
|
+
z-index: 10000;
|
|
335
|
+
pointer-events: auto;
|
|
336
|
+
display: flex;
|
|
337
|
+
align-items: center;
|
|
338
|
+
justify-content: center;
|
|
339
|
+
width: 44px;
|
|
340
|
+
height: 44px;
|
|
341
|
+
border: none;
|
|
342
|
+
border-radius: 50%;
|
|
343
|
+
background: rgba(255, 255, 255, 0.9);
|
|
344
|
+
color: #2d3748;
|
|
345
|
+
cursor: pointer;
|
|
346
|
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
|
347
|
+
transition: all 0.2s;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
.nav-btn:hover:not(:disabled) {
|
|
351
|
+
background: #fff;
|
|
352
|
+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
.nav-btn-disabled {
|
|
356
|
+
opacity: 0.3;
|
|
357
|
+
cursor: not-allowed;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
.nav-btn-left {
|
|
361
|
+
left: 20px;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
.nav-btn-right {
|
|
365
|
+
right: 20px;
|
|
366
|
+
}
|
|
367
|
+
|
|
264
368
|
.image-wrapper {
|
|
265
369
|
transition: transform 0.2s ease-out;
|
|
266
370
|
cursor: grab;
|
|
@@ -284,4 +388,4 @@ if (typeof window !== 'undefined') {
|
|
|
284
388
|
width: auto !important;
|
|
285
389
|
height: auto !important;
|
|
286
390
|
}
|
|
287
|
-
</style>
|
|
391
|
+
</style>
|
|
@@ -160,18 +160,34 @@ export function useDocManager() {
|
|
|
160
160
|
}
|
|
161
161
|
return
|
|
162
162
|
}
|
|
163
|
-
//
|
|
164
|
-
|
|
165
|
-
onZoom(`<img src="${target.src}" alt="${target.alt || ''}" style="max-width: 100%; height: auto;" />`)
|
|
166
|
-
return
|
|
167
|
-
}
|
|
168
|
-
// Mermaid 图表放大
|
|
163
|
+
// 图片/Mermaid 放大(收集所有可放大元素,支持左右切换)
|
|
164
|
+
const isImg = target.tagName === 'IMG' && target.classList.contains('zoomable-image')
|
|
169
165
|
const mermaidEl = target.closest('.mermaid')
|
|
170
|
-
|
|
171
|
-
|
|
166
|
+
const isMermaid = mermaidEl && mermaidEl.classList.contains('zoomable-image')
|
|
167
|
+
if (isImg || isMermaid) {
|
|
168
|
+
const container = document.querySelector('.markdown-content')
|
|
169
|
+
if (!container) return
|
|
170
|
+
// 收集所有可放大元素
|
|
171
|
+
const allZoomable = [...container.querySelectorAll('.zoomable-image')]
|
|
172
|
+
const images = allZoomable.map(el => {
|
|
173
|
+
if (el.tagName === 'IMG') {
|
|
174
|
+
return `<img src="${el.src}" alt="${el.alt || ''}" style="max-width: 100%; height: auto;" />`
|
|
175
|
+
}
|
|
176
|
+
return el.innerHTML
|
|
177
|
+
})
|
|
178
|
+
const clickedEl = isImg ? target : mermaidEl
|
|
179
|
+
const index = allZoomable.indexOf(clickedEl)
|
|
180
|
+
onZoom({ images, index: Math.max(index, 0) })
|
|
172
181
|
}
|
|
173
182
|
}
|
|
174
183
|
|
|
184
|
+
// 当前文档标题
|
|
185
|
+
const currentDocTitle = computed(() => {
|
|
186
|
+
if (!currentDoc.value) return '文档'
|
|
187
|
+
const doc = findDoc(docsList.value, currentDoc.value)
|
|
188
|
+
return doc?.label || '文档'
|
|
189
|
+
})
|
|
190
|
+
|
|
175
191
|
// 上一篇/下一篇
|
|
176
192
|
const prevDoc = computed(() => {
|
|
177
193
|
if (!currentDoc.value) return null
|
|
@@ -248,6 +264,7 @@ export function useDocManager() {
|
|
|
248
264
|
// 状态
|
|
249
265
|
docsList,
|
|
250
266
|
currentDoc,
|
|
267
|
+
currentDocTitle,
|
|
251
268
|
showWelcome,
|
|
252
269
|
htmlContent,
|
|
253
270
|
tocItems,
|
|
@@ -0,0 +1,632 @@
|
|
|
1
|
+
import { ref } from 'vue'
|
|
2
|
+
import {
|
|
3
|
+
Document, Packer, Paragraph, TextRun, HeadingLevel,
|
|
4
|
+
Table, TableRow, TableCell, WidthType, BorderStyle,
|
|
5
|
+
ImageRun, AlignmentType, ShadingType,
|
|
6
|
+
ExternalHyperlink,
|
|
7
|
+
convertInchesToTwip
|
|
8
|
+
} from 'docx'
|
|
9
|
+
import { saveAs } from 'file-saver'
|
|
10
|
+
|
|
11
|
+
// ---- SVG 转 PNG ----
|
|
12
|
+
|
|
13
|
+
async function svgToBase64Png(svgEl) {
|
|
14
|
+
const svgClone = svgEl.cloneNode(true)
|
|
15
|
+
const bbox = svgEl.getBoundingClientRect()
|
|
16
|
+
const width = Math.ceil(bbox.width) || 800
|
|
17
|
+
const height = Math.ceil(bbox.height) || 400
|
|
18
|
+
svgClone.setAttribute('width', width)
|
|
19
|
+
svgClone.setAttribute('height', height)
|
|
20
|
+
if (!svgClone.getAttribute('xmlns')) {
|
|
21
|
+
svgClone.setAttribute('xmlns', 'http://www.w3.org/2000/svg')
|
|
22
|
+
}
|
|
23
|
+
// 内联所有计算样式到 SVG 元素,确保文字可见
|
|
24
|
+
inlineStyles(svgEl, svgClone)
|
|
25
|
+
|
|
26
|
+
const svgData = new XMLSerializer().serializeToString(svgClone)
|
|
27
|
+
// 将 SVG 字符串编码为 base64(支持 UTF-8 中文字符)
|
|
28
|
+
const encoder = new TextEncoder()
|
|
29
|
+
const bytes = encoder.encode(svgData)
|
|
30
|
+
let binary = ''
|
|
31
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
32
|
+
binary += String.fromCharCode(bytes[i])
|
|
33
|
+
}
|
|
34
|
+
const base64Svg = btoa(binary)
|
|
35
|
+
const dataUri = `data:image/svg+xml;base64,${base64Svg}`
|
|
36
|
+
|
|
37
|
+
return new Promise((resolve, reject) => {
|
|
38
|
+
const img = new Image()
|
|
39
|
+
img.onload = () => {
|
|
40
|
+
const scale = 2
|
|
41
|
+
const canvas = document.createElement('canvas')
|
|
42
|
+
canvas.width = width * scale
|
|
43
|
+
canvas.height = height * scale
|
|
44
|
+
const ctx = canvas.getContext('2d')
|
|
45
|
+
ctx.fillStyle = '#ffffff'
|
|
46
|
+
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
|
47
|
+
ctx.scale(scale, scale)
|
|
48
|
+
ctx.drawImage(img, 0, 0, width, height)
|
|
49
|
+
resolve({ dataUrl: canvas.toDataURL('image/png'), width, height })
|
|
50
|
+
}
|
|
51
|
+
img.onerror = () => reject(new Error('SVG 转图片失败'))
|
|
52
|
+
img.src = dataUri
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// 递归内联计算样式(确保 SVG 序列化后文字、颜色等不丢失)
|
|
57
|
+
function inlineStyles(source, target) {
|
|
58
|
+
const computed = window.getComputedStyle(source)
|
|
59
|
+
const dominated = ['font-family', 'font-size', 'font-weight', 'fill', 'stroke',
|
|
60
|
+
'stroke-width', 'opacity', 'visibility', 'display', 'text-anchor',
|
|
61
|
+
'dominant-baseline', 'color', 'transform']
|
|
62
|
+
let style = ''
|
|
63
|
+
for (const prop of dominated) {
|
|
64
|
+
const val = computed.getPropertyValue(prop)
|
|
65
|
+
if (val) style += `${prop}:${val};`
|
|
66
|
+
}
|
|
67
|
+
if (style) target.setAttribute('style', (target.getAttribute('style') || '') + ';' + style)
|
|
68
|
+
|
|
69
|
+
const sourceChildren = source.children
|
|
70
|
+
const targetChildren = target.children
|
|
71
|
+
for (let i = 0; i < sourceChildren.length && i < targetChildren.length; i++) {
|
|
72
|
+
inlineStyles(sourceChildren[i], targetChildren[i])
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ---- DOM 解析为 docx 元素 ----
|
|
77
|
+
|
|
78
|
+
const HEADING_MAP = {
|
|
79
|
+
H1: HeadingLevel.HEADING_1,
|
|
80
|
+
H2: HeadingLevel.HEADING_2,
|
|
81
|
+
H3: HeadingLevel.HEADING_3,
|
|
82
|
+
H4: HeadingLevel.HEADING_4,
|
|
83
|
+
H5: HeadingLevel.HEADING_5,
|
|
84
|
+
H6: HeadingLevel.HEADING_6,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// 从 DOM 节点提取内联文本 runs(支持超链接、高亮、上下标、内联图片)
|
|
88
|
+
function extractTextRuns(node, inherited = {}) {
|
|
89
|
+
const runs = []
|
|
90
|
+
if (!node) return runs
|
|
91
|
+
|
|
92
|
+
for (const child of node.childNodes) {
|
|
93
|
+
if (child.nodeType === Node.TEXT_NODE) {
|
|
94
|
+
const text = child.textContent
|
|
95
|
+
if (text) {
|
|
96
|
+
runs.push(new TextRun({ text, ...inherited }))
|
|
97
|
+
}
|
|
98
|
+
} else if (child.nodeType === Node.ELEMENT_NODE) {
|
|
99
|
+
const tag = child.tagName
|
|
100
|
+
// 跳过锚点图标
|
|
101
|
+
if (child.classList?.contains('heading-anchor')) continue
|
|
102
|
+
|
|
103
|
+
if (tag === 'STRONG' || tag === 'B') {
|
|
104
|
+
runs.push(...extractTextRuns(child, { ...inherited, bold: true }))
|
|
105
|
+
} else if (tag === 'EM' || tag === 'I') {
|
|
106
|
+
runs.push(...extractTextRuns(child, { ...inherited, italics: true }))
|
|
107
|
+
} else if (tag === 'CODE') {
|
|
108
|
+
runs.push(new TextRun({
|
|
109
|
+
text: child.textContent,
|
|
110
|
+
font: 'Consolas',
|
|
111
|
+
size: 18, // 9pt
|
|
112
|
+
shading: { type: ShadingType.CLEAR, fill: 'f0f0f0' },
|
|
113
|
+
...inherited,
|
|
114
|
+
}))
|
|
115
|
+
} else if (tag === 'A') {
|
|
116
|
+
const href = child.getAttribute('href') || ''
|
|
117
|
+
// 生成真正的可点击超链接
|
|
118
|
+
if (href && (href.startsWith('http') || href.startsWith('mailto:'))) {
|
|
119
|
+
runs.push(new ExternalHyperlink({
|
|
120
|
+
children: [new TextRun({
|
|
121
|
+
text: child.textContent,
|
|
122
|
+
style: 'Hyperlink',
|
|
123
|
+
color: '4a6cf7',
|
|
124
|
+
underline: {},
|
|
125
|
+
...inherited,
|
|
126
|
+
})],
|
|
127
|
+
link: href,
|
|
128
|
+
}))
|
|
129
|
+
} else {
|
|
130
|
+
runs.push(new TextRun({
|
|
131
|
+
text: child.textContent,
|
|
132
|
+
color: '4a6cf7',
|
|
133
|
+
underline: {},
|
|
134
|
+
...inherited,
|
|
135
|
+
}))
|
|
136
|
+
}
|
|
137
|
+
} else if (tag === 'DEL' || tag === 'S') {
|
|
138
|
+
runs.push(...extractTextRuns(child, { ...inherited, strike: true }))
|
|
139
|
+
} else if (tag === 'MARK') {
|
|
140
|
+
// 高亮文本
|
|
141
|
+
runs.push(...extractTextRuns(child, {
|
|
142
|
+
...inherited,
|
|
143
|
+
shading: { type: ShadingType.CLEAR, fill: 'ffff00' },
|
|
144
|
+
}))
|
|
145
|
+
} else if (tag === 'SUP') {
|
|
146
|
+
runs.push(...extractTextRuns(child, { ...inherited, superScript: true }))
|
|
147
|
+
} else if (tag === 'SUB') {
|
|
148
|
+
runs.push(...extractTextRuns(child, { ...inherited, subScript: true }))
|
|
149
|
+
} else if (tag === 'BR') {
|
|
150
|
+
runs.push(new TextRun({ break: 1 }))
|
|
151
|
+
} else if (tag === 'IMG') {
|
|
152
|
+
// 内联图片标记为占位符(实际图片在段落级处理)
|
|
153
|
+
const alt = child.getAttribute('alt') || '[图片]'
|
|
154
|
+
runs.push(new TextRun({ text: alt, color: '999999', italics: true }))
|
|
155
|
+
} else {
|
|
156
|
+
runs.push(...extractTextRuns(child, inherited))
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return runs
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// 解析表格
|
|
164
|
+
function parseTable(tableEl) {
|
|
165
|
+
const rows = []
|
|
166
|
+
const allTr = tableEl.querySelectorAll('tr')
|
|
167
|
+
for (const tr of allTr) {
|
|
168
|
+
const cells = []
|
|
169
|
+
const tds = tr.querySelectorAll('th, td')
|
|
170
|
+
const isHeader = tr.querySelector('th') !== null
|
|
171
|
+
for (const td of tds) {
|
|
172
|
+
cells.push(new TableCell({
|
|
173
|
+
children: [new Paragraph({
|
|
174
|
+
children: extractTextRuns(td, isHeader ? { bold: true } : {}),
|
|
175
|
+
spacing: { before: 40, after: 40 },
|
|
176
|
+
})],
|
|
177
|
+
shading: isHeader
|
|
178
|
+
? { type: ShadingType.CLEAR, fill: 'f0f0f0' }
|
|
179
|
+
: undefined,
|
|
180
|
+
width: { size: 100 / tds.length, type: WidthType.PERCENTAGE },
|
|
181
|
+
}))
|
|
182
|
+
}
|
|
183
|
+
if (cells.length > 0) {
|
|
184
|
+
rows.push(new TableRow({ children: cells }))
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (rows.length === 0) return null
|
|
188
|
+
return new Table({
|
|
189
|
+
rows,
|
|
190
|
+
width: { size: 100, type: WidthType.PERCENTAGE },
|
|
191
|
+
})
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// 解析列表
|
|
195
|
+
function parseList(listEl, level = 0) {
|
|
196
|
+
const items = []
|
|
197
|
+
for (const li of listEl.children) {
|
|
198
|
+
if (li.tagName !== 'LI') continue
|
|
199
|
+
// 检查 checkbox(任务列表)
|
|
200
|
+
const checkbox = li.querySelector('input[type="checkbox"]')
|
|
201
|
+
let prefix = ''
|
|
202
|
+
if (checkbox) {
|
|
203
|
+
prefix = checkbox.checked ? '[x] ' : '[ ] '
|
|
204
|
+
}
|
|
205
|
+
const isOrdered = listEl.tagName === 'OL'
|
|
206
|
+
const idx = Array.from(listEl.children).indexOf(li)
|
|
207
|
+
|
|
208
|
+
// 提取文本(排除嵌套列表,支持 li 内 p 包裹的多段落)
|
|
209
|
+
const textRuns = []
|
|
210
|
+
if (prefix) {
|
|
211
|
+
textRuns.push(new TextRun({ text: prefix, font: 'Consolas', size: 20 }))
|
|
212
|
+
}
|
|
213
|
+
for (const child of li.childNodes) {
|
|
214
|
+
if (child.nodeType === Node.TEXT_NODE) {
|
|
215
|
+
const t = child.textContent.trim()
|
|
216
|
+
if (t) textRuns.push(new TextRun({ text: t }))
|
|
217
|
+
} else if (child.nodeType === Node.ELEMENT_NODE) {
|
|
218
|
+
const cTag = child.tagName
|
|
219
|
+
if (cTag === 'UL' || cTag === 'OL') continue // 嵌套列表单独处理
|
|
220
|
+
if (cTag === 'INPUT') continue // 跳过 checkbox 本身
|
|
221
|
+
// li 内的 p 标签:提取内容并在段落间加换行
|
|
222
|
+
if (cTag === 'P') {
|
|
223
|
+
if (textRuns.length > 0) {
|
|
224
|
+
textRuns.push(new TextRun({ break: 1 }))
|
|
225
|
+
}
|
|
226
|
+
textRuns.push(...extractTextRuns(child))
|
|
227
|
+
} else {
|
|
228
|
+
textRuns.push(...extractTextRuns(child))
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const bullet = isOrdered ? `${idx + 1}. ` : '\u2022 '
|
|
234
|
+
const baseIndent = 360
|
|
235
|
+
const indent = baseIndent + level * 360
|
|
236
|
+
items.push(new Paragraph({
|
|
237
|
+
children: [
|
|
238
|
+
new TextRun({ text: bullet }),
|
|
239
|
+
...textRuns,
|
|
240
|
+
],
|
|
241
|
+
spacing: { before: 40, after: 40 },
|
|
242
|
+
indent: { left: indent, hanging: 240 },
|
|
243
|
+
}))
|
|
244
|
+
|
|
245
|
+
// 嵌套列表
|
|
246
|
+
const nested = li.querySelector('ul, ol')
|
|
247
|
+
if (nested) {
|
|
248
|
+
items.push(...parseList(nested, level + 1))
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return items
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// 主解析函数:将 DOM 转为 docx 元素数组
|
|
255
|
+
async function parseDomToDocx(contentEl) {
|
|
256
|
+
const elements = []
|
|
257
|
+
|
|
258
|
+
for (const node of contentEl.children) {
|
|
259
|
+
const tag = node.tagName
|
|
260
|
+
|
|
261
|
+
// 标题
|
|
262
|
+
if (HEADING_MAP[tag]) {
|
|
263
|
+
const runs = extractTextRuns(node)
|
|
264
|
+
elements.push(new Paragraph({
|
|
265
|
+
children: runs,
|
|
266
|
+
heading: HEADING_MAP[tag],
|
|
267
|
+
spacing: { before: 240, after: 120 },
|
|
268
|
+
}))
|
|
269
|
+
continue
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// 段落(处理内嵌图片)
|
|
273
|
+
if (tag === 'P') {
|
|
274
|
+
// 检查段落内是否有图片
|
|
275
|
+
const imgs = node.querySelectorAll('img')
|
|
276
|
+
if (imgs.length > 0) {
|
|
277
|
+
// 先输出文本部分
|
|
278
|
+
const runs = extractTextRuns(node)
|
|
279
|
+
const textOnly = runs.filter(r => !(r instanceof ImageRun))
|
|
280
|
+
if (textOnly.length > 0) {
|
|
281
|
+
elements.push(new Paragraph({
|
|
282
|
+
children: textOnly,
|
|
283
|
+
spacing: { before: 80, after: 80 },
|
|
284
|
+
}))
|
|
285
|
+
}
|
|
286
|
+
// 再逐个输出图片
|
|
287
|
+
for (const img of imgs) {
|
|
288
|
+
try {
|
|
289
|
+
const imgData = await fetchImageAsBytes(img.src)
|
|
290
|
+
if (imgData) {
|
|
291
|
+
elements.push(new Paragraph({
|
|
292
|
+
children: [new ImageRun({
|
|
293
|
+
data: imgData.bytes,
|
|
294
|
+
transformation: { width: imgData.width, height: imgData.height },
|
|
295
|
+
type: 'png',
|
|
296
|
+
})],
|
|
297
|
+
spacing: { before: 120, after: 120 },
|
|
298
|
+
alignment: AlignmentType.CENTER,
|
|
299
|
+
}))
|
|
300
|
+
}
|
|
301
|
+
} catch { /* 跳过无法加载的图片 */ }
|
|
302
|
+
}
|
|
303
|
+
} else {
|
|
304
|
+
const runs = extractTextRuns(node)
|
|
305
|
+
if (runs.length > 0) {
|
|
306
|
+
elements.push(new Paragraph({
|
|
307
|
+
children: runs,
|
|
308
|
+
spacing: { before: 80, after: 80 },
|
|
309
|
+
}))
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
continue
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// 文档元信息
|
|
316
|
+
if (node.classList?.contains('doc-meta')) {
|
|
317
|
+
elements.push(new Paragraph({
|
|
318
|
+
children: [new TextRun({
|
|
319
|
+
text: node.textContent.trim(),
|
|
320
|
+
color: '999999',
|
|
321
|
+
size: 18,
|
|
322
|
+
})],
|
|
323
|
+
spacing: { before: 40, after: 120 },
|
|
324
|
+
}))
|
|
325
|
+
continue
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// 代码块
|
|
329
|
+
if (node.classList?.contains('code-block-wrapper')) {
|
|
330
|
+
const rawCode = decodeURIComponent(node.dataset.rawCode || '')
|
|
331
|
+
const lang = (node.dataset.lang || '').toUpperCase()
|
|
332
|
+
|
|
333
|
+
const codeLines = rawCode.split('\n')
|
|
334
|
+
const codeRuns = []
|
|
335
|
+
|
|
336
|
+
// 语言标签
|
|
337
|
+
if (lang) {
|
|
338
|
+
codeRuns.push(new TextRun({
|
|
339
|
+
text: lang,
|
|
340
|
+
font: 'Consolas',
|
|
341
|
+
size: 16,
|
|
342
|
+
color: '999999',
|
|
343
|
+
}))
|
|
344
|
+
codeRuns.push(new TextRun({ break: 1 }))
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// 代码内容,逐行添加
|
|
348
|
+
codeLines.forEach((line, i) => {
|
|
349
|
+
codeRuns.push(new TextRun({
|
|
350
|
+
text: line || ' ', // 空行用空格占位
|
|
351
|
+
font: 'Consolas',
|
|
352
|
+
size: 18,
|
|
353
|
+
color: '333333',
|
|
354
|
+
}))
|
|
355
|
+
if (i < codeLines.length - 1) {
|
|
356
|
+
codeRuns.push(new TextRun({ break: 1 }))
|
|
357
|
+
}
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
elements.push(new Paragraph({
|
|
361
|
+
children: codeRuns,
|
|
362
|
+
alignment: AlignmentType.LEFT,
|
|
363
|
+
shading: { type: ShadingType.CLEAR, fill: 'f5f5f5' },
|
|
364
|
+
spacing: { before: 120, after: 120, line: 276 },
|
|
365
|
+
border: {
|
|
366
|
+
top: { style: BorderStyle.SINGLE, size: 1, color: 'dddddd' },
|
|
367
|
+
bottom: { style: BorderStyle.SINGLE, size: 1, color: 'dddddd' },
|
|
368
|
+
left: { style: BorderStyle.SINGLE, size: 1, color: 'dddddd' },
|
|
369
|
+
right: { style: BorderStyle.SINGLE, size: 1, color: 'dddddd' },
|
|
370
|
+
},
|
|
371
|
+
indent: { left: 200, right: 200 },
|
|
372
|
+
}))
|
|
373
|
+
continue
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Mermaid 图表
|
|
377
|
+
if (node.classList?.contains('mermaid')) {
|
|
378
|
+
const svg = node.querySelector('svg')
|
|
379
|
+
if (svg) {
|
|
380
|
+
try {
|
|
381
|
+
const { dataUrl, width, height } = await svgToBase64Png(svg)
|
|
382
|
+
// base64 数据提取
|
|
383
|
+
const base64Data = dataUrl.split(',')[1]
|
|
384
|
+
const binaryString = atob(base64Data)
|
|
385
|
+
const bytes = new Uint8Array(binaryString.length)
|
|
386
|
+
for (let i = 0; i < binaryString.length; i++) {
|
|
387
|
+
bytes[i] = binaryString.charCodeAt(i)
|
|
388
|
+
}
|
|
389
|
+
// 限制最大宽度为 6 英寸(Word 页面宽度约 6.5 英寸)
|
|
390
|
+
const maxWidth = 6 * 96 // 576px
|
|
391
|
+
const scale = width > maxWidth ? maxWidth / width : 1
|
|
392
|
+
const imgWidth = Math.round(width * scale)
|
|
393
|
+
const imgHeight = Math.round(height * scale)
|
|
394
|
+
|
|
395
|
+
elements.push(new Paragraph({
|
|
396
|
+
children: [new ImageRun({
|
|
397
|
+
data: bytes,
|
|
398
|
+
transformation: { width: imgWidth, height: imgHeight },
|
|
399
|
+
type: 'png',
|
|
400
|
+
})],
|
|
401
|
+
spacing: { before: 120, after: 120 },
|
|
402
|
+
alignment: AlignmentType.CENTER,
|
|
403
|
+
}))
|
|
404
|
+
} catch {
|
|
405
|
+
elements.push(new Paragraph({
|
|
406
|
+
children: [new TextRun({ text: '[图表无法导出]', color: '999999', italics: true })],
|
|
407
|
+
}))
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
continue
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// 表格(可能被 table-wrapper 包裹)
|
|
414
|
+
if (tag === 'TABLE' || node.classList?.contains('table-wrapper')) {
|
|
415
|
+
const tableEl = tag === 'TABLE' ? node : node.querySelector('table')
|
|
416
|
+
if (tableEl) {
|
|
417
|
+
const table = parseTable(tableEl)
|
|
418
|
+
if (table) elements.push(table)
|
|
419
|
+
}
|
|
420
|
+
continue
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// 列表
|
|
424
|
+
if (tag === 'UL' || tag === 'OL') {
|
|
425
|
+
elements.push(...parseList(node))
|
|
426
|
+
continue
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// 引用块:递归处理内部块级元素,保留完整结构
|
|
430
|
+
if (tag === 'BLOCKQUOTE') {
|
|
431
|
+
const bqStyle = {
|
|
432
|
+
indent: { left: 400 },
|
|
433
|
+
border: {
|
|
434
|
+
left: { style: BorderStyle.SINGLE, size: 6, color: 'cccccc' },
|
|
435
|
+
},
|
|
436
|
+
shading: { type: ShadingType.CLEAR, fill: 'fafafa' },
|
|
437
|
+
}
|
|
438
|
+
if (node.children.length > 0) {
|
|
439
|
+
for (const child of node.children) {
|
|
440
|
+
const childTag = child.tagName
|
|
441
|
+
// 嵌套引用块:增加缩进
|
|
442
|
+
if (childTag === 'BLOCKQUOTE') {
|
|
443
|
+
const innerChildren = child.children.length > 0 ? child.children : [child]
|
|
444
|
+
for (const inner of innerChildren) {
|
|
445
|
+
const runs = extractTextRuns(inner)
|
|
446
|
+
if (runs.length > 0) {
|
|
447
|
+
elements.push(new Paragraph({
|
|
448
|
+
children: runs,
|
|
449
|
+
spacing: { before: 80, after: 80 },
|
|
450
|
+
indent: { left: 800 },
|
|
451
|
+
border: {
|
|
452
|
+
left: { style: BorderStyle.SINGLE, size: 6, color: 'aaaaaa' },
|
|
453
|
+
},
|
|
454
|
+
shading: { type: ShadingType.CLEAR, fill: 'f5f5f5' },
|
|
455
|
+
}))
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
} else if (childTag === 'UL' || childTag === 'OL') {
|
|
459
|
+
// 引用块内的列表
|
|
460
|
+
const listItems = parseList(child)
|
|
461
|
+
for (const item of listItems) {
|
|
462
|
+
elements.push(item)
|
|
463
|
+
}
|
|
464
|
+
} else if (childTag === 'PRE' || child.classList?.contains('code-block-wrapper')) {
|
|
465
|
+
// 引用块内的代码块,走正常代码块逻辑但加引用样式
|
|
466
|
+
const runs = extractTextRuns(child)
|
|
467
|
+
if (runs.length > 0) {
|
|
468
|
+
elements.push(new Paragraph({
|
|
469
|
+
children: runs,
|
|
470
|
+
spacing: { before: 80, after: 80 },
|
|
471
|
+
...bqStyle,
|
|
472
|
+
}))
|
|
473
|
+
}
|
|
474
|
+
} else {
|
|
475
|
+
const runs = extractTextRuns(child)
|
|
476
|
+
if (runs.length > 0) {
|
|
477
|
+
elements.push(new Paragraph({
|
|
478
|
+
children: runs,
|
|
479
|
+
spacing: { before: 80, after: 80 },
|
|
480
|
+
...bqStyle,
|
|
481
|
+
}))
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
} else {
|
|
486
|
+
const runs = extractTextRuns(node)
|
|
487
|
+
if (runs.length > 0) {
|
|
488
|
+
elements.push(new Paragraph({
|
|
489
|
+
children: runs,
|
|
490
|
+
spacing: { before: 80, after: 80 },
|
|
491
|
+
...bqStyle,
|
|
492
|
+
}))
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
continue
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// 图片
|
|
499
|
+
if (tag === 'IMG') {
|
|
500
|
+
try {
|
|
501
|
+
const imgData = await fetchImageAsBytes(node.src)
|
|
502
|
+
if (imgData) {
|
|
503
|
+
elements.push(new Paragraph({
|
|
504
|
+
children: [new ImageRun({
|
|
505
|
+
data: imgData.bytes,
|
|
506
|
+
transformation: { width: imgData.width, height: imgData.height },
|
|
507
|
+
type: 'png',
|
|
508
|
+
})],
|
|
509
|
+
spacing: { before: 120, after: 120 },
|
|
510
|
+
alignment: AlignmentType.CENTER,
|
|
511
|
+
}))
|
|
512
|
+
}
|
|
513
|
+
} catch { /* 跳过无法加载的图片 */ }
|
|
514
|
+
continue
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// 水平线
|
|
518
|
+
if (tag === 'HR') {
|
|
519
|
+
elements.push(new Paragraph({
|
|
520
|
+
children: [],
|
|
521
|
+
border: { bottom: { style: BorderStyle.SINGLE, size: 1, color: 'cccccc' } },
|
|
522
|
+
spacing: { before: 120, after: 120 },
|
|
523
|
+
}))
|
|
524
|
+
continue
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// 其他 div 容器,递归处理子元素
|
|
528
|
+
if (tag === 'DIV' || tag === 'SECTION' || tag === 'ARTICLE') {
|
|
529
|
+
const sub = await parseDomToDocx(node)
|
|
530
|
+
elements.push(...sub)
|
|
531
|
+
continue
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// 兜底:提取文本
|
|
535
|
+
const text = node.textContent?.trim()
|
|
536
|
+
if (text) {
|
|
537
|
+
elements.push(new Paragraph({
|
|
538
|
+
children: [new TextRun({ text })],
|
|
539
|
+
spacing: { before: 80, after: 80 },
|
|
540
|
+
}))
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
return elements
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// 加载图片为字节数组
|
|
548
|
+
async function fetchImageAsBytes(src) {
|
|
549
|
+
try {
|
|
550
|
+
const img = new Image()
|
|
551
|
+
img.crossOrigin = 'anonymous'
|
|
552
|
+
await new Promise((resolve, reject) => {
|
|
553
|
+
img.onload = resolve
|
|
554
|
+
img.onerror = reject
|
|
555
|
+
img.src = src
|
|
556
|
+
})
|
|
557
|
+
const maxWidth = 576
|
|
558
|
+
const scale = img.naturalWidth > maxWidth ? maxWidth / img.naturalWidth : 1
|
|
559
|
+
const w = Math.round(img.naturalWidth * scale)
|
|
560
|
+
const h = Math.round(img.naturalHeight * scale)
|
|
561
|
+
const canvas = document.createElement('canvas')
|
|
562
|
+
canvas.width = w
|
|
563
|
+
canvas.height = h
|
|
564
|
+
const ctx = canvas.getContext('2d')
|
|
565
|
+
ctx.drawImage(img, 0, 0, w, h)
|
|
566
|
+
const dataUrl = canvas.toDataURL('image/png')
|
|
567
|
+
const base64 = dataUrl.split(',')[1]
|
|
568
|
+
const binary = atob(base64)
|
|
569
|
+
const bytes = new Uint8Array(binary.length)
|
|
570
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
|
|
571
|
+
return { bytes, width: w, height: h }
|
|
572
|
+
} catch {
|
|
573
|
+
return null
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// ---- 主 composable ----
|
|
578
|
+
|
|
579
|
+
export function useExportWord() {
|
|
580
|
+
const exporting = ref(false)
|
|
581
|
+
|
|
582
|
+
async function exportToWord(title = '文档') {
|
|
583
|
+
exporting.value = true
|
|
584
|
+
try {
|
|
585
|
+
const contentEl = document.querySelector('.markdown-content')
|
|
586
|
+
if (!contentEl) return
|
|
587
|
+
|
|
588
|
+
const children = await parseDomToDocx(contentEl)
|
|
589
|
+
|
|
590
|
+
const doc = new Document({
|
|
591
|
+
styles: {
|
|
592
|
+
default: {
|
|
593
|
+
document: {
|
|
594
|
+
run: {
|
|
595
|
+
font: 'Microsoft YaHei',
|
|
596
|
+
size: 22, // 11pt
|
|
597
|
+
color: '333333',
|
|
598
|
+
},
|
|
599
|
+
paragraph: {
|
|
600
|
+
spacing: { line: 360 }, // 1.5 倍行距
|
|
601
|
+
alignment: AlignmentType.LEFT,
|
|
602
|
+
},
|
|
603
|
+
},
|
|
604
|
+
},
|
|
605
|
+
},
|
|
606
|
+
sections: [{
|
|
607
|
+
properties: {
|
|
608
|
+
page: {
|
|
609
|
+
margin: {
|
|
610
|
+
top: convertInchesToTwip(1),
|
|
611
|
+
bottom: convertInchesToTwip(1),
|
|
612
|
+
left: convertInchesToTwip(1.2),
|
|
613
|
+
right: convertInchesToTwip(1.2),
|
|
614
|
+
},
|
|
615
|
+
},
|
|
616
|
+
},
|
|
617
|
+
children,
|
|
618
|
+
}],
|
|
619
|
+
})
|
|
620
|
+
|
|
621
|
+
const blob = await Packer.toBlob(doc)
|
|
622
|
+
const fileName = title.replace(/[\\/:*?"<>|]/g, '_') + '.docx'
|
|
623
|
+
saveAs(blob, fileName)
|
|
624
|
+
} catch (err) {
|
|
625
|
+
console.error('导出 Word 失败:', err)
|
|
626
|
+
} finally {
|
|
627
|
+
exporting.value = false
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
return { exporting, exportToWord }
|
|
632
|
+
}
|
package/src/style.css
CHANGED
|
@@ -1260,6 +1260,48 @@ body {
|
|
|
1260
1260
|
color: var(--color-border);
|
|
1261
1261
|
}
|
|
1262
1262
|
|
|
1263
|
+
/* ===== 文档工具栏 ===== */
|
|
1264
|
+
.doc-toolbar {
|
|
1265
|
+
display: flex;
|
|
1266
|
+
justify-content: flex-end;
|
|
1267
|
+
width: 100%;
|
|
1268
|
+
max-width: 960px;
|
|
1269
|
+
margin: 0 auto;
|
|
1270
|
+
padding: 8px 32px 0;
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
.export-word-btn {
|
|
1274
|
+
display: inline-flex;
|
|
1275
|
+
align-items: center;
|
|
1276
|
+
gap: 5px;
|
|
1277
|
+
padding: 5px 12px;
|
|
1278
|
+
font-size: 12px;
|
|
1279
|
+
color: var(--color-text-secondary);
|
|
1280
|
+
background: transparent;
|
|
1281
|
+
border: 1px solid var(--color-border);
|
|
1282
|
+
border-radius: 5px;
|
|
1283
|
+
cursor: pointer;
|
|
1284
|
+
transition: all 0.15s;
|
|
1285
|
+
white-space: nowrap;
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
.export-word-btn:hover {
|
|
1289
|
+
color: var(--color-accent);
|
|
1290
|
+
border-color: var(--color-accent);
|
|
1291
|
+
background: var(--color-accent-bg);
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
.export-word-btn:disabled {
|
|
1295
|
+
opacity: 0.5;
|
|
1296
|
+
cursor: not-allowed;
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
@media (max-width: 768px) {
|
|
1300
|
+
.doc-toolbar {
|
|
1301
|
+
padding: 8px 16px 0;
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1263
1305
|
/* ===== 上一篇/下一篇导航 ===== */
|
|
1264
1306
|
.doc-nav {
|
|
1265
1307
|
display: flex;
|