gcs-ui-lib 1.2.28 → 1.2.30

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.
@@ -0,0 +1,725 @@
1
+ /**
2
+ * 高保真页面 HTML 快照导出(Vue2 页面适用)
3
+ *
4
+ * 相比 gcs-ui-lib/exportPageSnapshot:
5
+ * - 不做 CSS 裁剪,保留文档全部可访问样式
6
+ * - 克隆节点后内联关键 computed 布局样式,提升离线还原度
7
+ * - 内嵌图标字体(woff/woff2),跳过大体积宋体
8
+ * - 默认不注入 PDF 专用覆盖样式,避免表格/字号被压扁
9
+ */
10
+
11
+ export const PAGE_SNAPSHOT_BACKEND_PLACEHOLDER = '<!-- PAGE_SNAPSHOT_BACKEND_PLACEHOLDER -->'
12
+
13
+ const SNAPSHOT_FILENAME_PREFIX = 'page-snapshot'
14
+ const SKIP_TAGS = new Set(['script', 'style', 'link', 'noscript', 'svg'])
15
+ const SKIP_CLASSES = [
16
+ 'page-snapshot-btn',
17
+ 'page-button-shadow',
18
+ 'detpl-form-operate-box',
19
+ 'self-footer'
20
+ ]
21
+
22
+ const SCROLL_UNWRAP_SELECTORS = [
23
+ '.el-table__body-wrapper',
24
+ '.el-table__fixed-body-wrapper',
25
+ '.el-scrollbar__wrap',
26
+ '.el-scrollbar__view',
27
+ '.vxe-table--body-wrapper',
28
+ '.vxe-table--body-inner-wrapper',
29
+ '.vxe-table--main-wrapper'
30
+ ]
31
+
32
+ /** 内联 computed 样式时关注的属性(布局 + 常见视觉) */
33
+ const INLINE_STYLE_PROPS = [
34
+ 'display', 'flex', 'flex-direction', 'flex-wrap', 'flex-grow', 'flex-shrink', 'flex-basis',
35
+ 'align-items', 'align-self', 'align-content', 'justify-content', 'justify-self', 'gap', 'row-gap', 'column-gap',
36
+ 'grid-template-columns', 'grid-template-rows', 'grid-column', 'grid-row',
37
+ 'position', 'top', 'right', 'bottom', 'left', 'z-index', 'float', 'clear',
38
+ 'width', 'height', 'min-width', 'min-height', 'max-width', 'max-height',
39
+ 'margin', 'margin-top', 'margin-right', 'margin-bottom', 'margin-left',
40
+ 'padding', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left',
41
+ 'box-sizing', 'overflow', 'overflow-x', 'overflow-y',
42
+ 'border', 'border-top', 'border-right', 'border-bottom', 'border-left',
43
+ 'border-radius', 'border-collapse', 'table-layout',
44
+ 'background', 'background-color', 'background-image', 'background-size', 'background-position',
45
+ 'color', 'font-size', 'font-weight', 'font-style', 'line-height', 'letter-spacing',
46
+ 'text-align', 'text-decoration', 'vertical-align', 'white-space', 'word-break', 'word-wrap',
47
+ 'opacity', 'visibility', 'box-shadow', 'transform'
48
+ ]
49
+
50
+ const SNAPSHOT_BASE_CSS = `
51
+ html, body {
52
+ margin: 0;
53
+ padding: 0;
54
+ background: #fff;
55
+ -webkit-print-color-adjust: exact;
56
+ print-color-adjust: exact;
57
+ }
58
+
59
+ n20-page,
60
+ [class$="-wrap"],
61
+ .n20-page-content,
62
+ .page-content,
63
+ .action-parse-form-container {
64
+ height: auto !important;
65
+ max-height: none !important;
66
+ overflow: visible !important;
67
+ }
68
+
69
+ .el-dialog__wrapper,
70
+ .el-message,
71
+ .el-message-box__wrapper,
72
+ .el-notification,
73
+ .el-loading-mask,
74
+ .page-snapshot-btn,
75
+ .page-button-shadow {
76
+ display: none !important;
77
+ }
78
+ `
79
+
80
+ const SNAPSHOT_PDF_CSS = `
81
+ @page { size: A4 landscape; margin: 15mm; }
82
+ html, body { font-family: SimSun, "Songti SC", "Microsoft YaHei", serif; font-size: 10px; line-height: 1.4; }
83
+ td, th { white-space: normal !important; padding: 4px; font-size: 10px; vertical-align: top; }
84
+ `
85
+
86
+ function safeRun(fn, fallback, label) {
87
+ try {
88
+ return fn()
89
+ } catch (e) {
90
+ if (label) console.warn(`[exportPageHtml] ${label}`, e)
91
+ return fallback
92
+ }
93
+ }
94
+
95
+ function buildSnapshotFilename() {
96
+ const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
97
+ return `${SNAPSHOT_FILENAME_PREFIX}-${ts}.html`
98
+ }
99
+
100
+ function shouldSkipElement(el) {
101
+ if (!el || el.nodeType !== Node.ELEMENT_NODE) return false
102
+ if (SKIP_TAGS.has(el.tagName.toLowerCase())) return true
103
+ return SKIP_CLASSES.some((cls) => el.classList?.contains(cls))
104
+ }
105
+
106
+ function clonePageNode(node) {
107
+ if (!node) return null
108
+
109
+ return safeRun(() => {
110
+ if (node.nodeType === Node.TEXT_NODE) return node.cloneNode(false)
111
+ if (node.nodeType !== Node.ELEMENT_NODE) return null
112
+ if (shouldSkipElement(node)) return null
113
+
114
+ const tag = node.tagName.toLowerCase()
115
+ const cloned = node.cloneNode(false)
116
+
117
+ Array.from(node.attributes || []).forEach((attr) => {
118
+ safeRun(() => cloned.setAttribute(attr.name, attr.value), undefined)
119
+ })
120
+
121
+ if (tag === 'input') {
122
+ cloned.setAttribute('value', node.value || '')
123
+ if (node.type === 'checkbox' || node.type === 'radio') {
124
+ if (node.checked) cloned.setAttribute('checked', 'checked')
125
+ else cloned.removeAttribute('checked')
126
+ }
127
+ }
128
+ if (tag === 'textarea') {
129
+ cloned.textContent = node.value || ''
130
+ }
131
+ if (tag === 'select') {
132
+ const idx = node.selectedIndex
133
+ Array.from(cloned.options || []).forEach((opt, i) => {
134
+ if (i === idx) opt.setAttribute('selected', 'selected')
135
+ else opt.removeAttribute('selected')
136
+ })
137
+ }
138
+
139
+ Array.from(node.childNodes).forEach((child) => {
140
+ const childClone = clonePageNode(child)
141
+ if (childClone) cloned.appendChild(childClone)
142
+ })
143
+ return cloned
144
+ }, null, '克隆节点失败')
145
+ }
146
+
147
+ function unwrapScrollContainers(root) {
148
+ if (!root?.querySelectorAll) return
149
+ SCROLL_UNWRAP_SELECTORS.forEach((sel) => {
150
+ root.querySelectorAll(sel).forEach((el) => {
151
+ el.style.maxHeight = 'none'
152
+ el.style.height = 'auto'
153
+ el.style.overflow = 'visible'
154
+ })
155
+ })
156
+ }
157
+
158
+ /**
159
+ * 删除页面锚点导航 DOM(N20-anchor 右侧/左侧导航栏)
160
+ * @param {HTMLElement} root
161
+ */
162
+ export function removeAnchorNav(root) {
163
+ if (!root?.querySelectorAll) return
164
+
165
+ root.querySelectorAll('.detpl-edit-form-container.flex-box').forEach((flexBox) => {
166
+ Array.from(flexBox.children).forEach((child) => {
167
+ if (child.classList.contains('flex-item')) return
168
+ if (child.querySelector('.n20-anchor2-nav')) {
169
+ child.remove()
170
+ }
171
+ })
172
+ flexBox.style?.removeProperty('padding-right')
173
+ })
174
+
175
+ root.querySelectorAll('.n20-anchor, .n20-anchor-left, .n20-anchor2-sidebar').forEach((el) => {
176
+ el.remove()
177
+ })
178
+
179
+ root.querySelectorAll('.n20-anchor2-nav').forEach((el) => {
180
+ if (!el.closest('.flex-item')) {
181
+ el.remove()
182
+ }
183
+ })
184
+ }
185
+
186
+ /** 底部固定提交/操作按钮组选择器 */
187
+ const BOTTOM_OPERATE_SELECTORS = [
188
+ '.page-button-shadow',
189
+ '.detpl-form-operate-box',
190
+ '.self-footer',
191
+ '.page-footer-shadow'
192
+ ]
193
+
194
+ /**
195
+ * 删除底部固定提交按钮组 DOM
196
+ * @param {HTMLElement} root
197
+ */
198
+ export function removeBottomOperateButtons(root) {
199
+ if (!root?.querySelectorAll) return
200
+
201
+ BOTTOM_OPERATE_SELECTORS.forEach((selector) => {
202
+ root.querySelectorAll(selector).forEach((el) => el.remove())
203
+ })
204
+ }
205
+
206
+ function mergeInlineStyle(el, extraCss) {
207
+ if (!extraCss) return
208
+ const prev = el.getAttribute('style') || ''
209
+ const merged = prev ? `${prev};${extraCss}` : extraCss
210
+ el.setAttribute('style', merged)
211
+ }
212
+
213
+ function buildComputedStyleText(el) {
214
+ const computed = window.getComputedStyle(el)
215
+ const chunks = []
216
+
217
+ INLINE_STYLE_PROPS.forEach((prop) => {
218
+ const val = computed.getPropertyValue(prop)
219
+ if (!val || val === 'initial' || val === 'auto' && prop !== 'height' && prop !== 'width') return
220
+ if (prop === 'background-image' && val === 'none') return
221
+ chunks.push(`${prop}:${val}`)
222
+ })
223
+ return chunks.join(';')
224
+ }
225
+
226
+ function inlineComputedStyles(sourceEl, cloneEl) {
227
+ if (!sourceEl || !cloneEl || sourceEl.nodeType !== Node.ELEMENT_NODE) return
228
+
229
+ mergeInlineStyle(cloneEl, buildComputedStyleText(sourceEl))
230
+
231
+ const srcChildren = sourceEl.children || []
232
+ const cloneChildren = cloneEl.children || []
233
+ const len = Math.min(srcChildren.length, cloneChildren.length)
234
+ for (let i = 0; i < len; i++) {
235
+ inlineComputedStyles(srcChildren[i], cloneChildren[i])
236
+ }
237
+ }
238
+
239
+ function normalizeCssChunk(text) {
240
+ return (text || '').replace(/\s+/g, ' ').trim()
241
+ }
242
+
243
+ function collectDocumentCss() {
244
+ const seen = new Set()
245
+ const rules = []
246
+ const processedStyleNodes = new Set()
247
+
248
+ const addRule = (cssText) => {
249
+ const chunk = normalizeCssChunk(cssText)
250
+ if (!chunk || seen.has(chunk)) return
251
+ seen.add(chunk)
252
+ rules.push(cssText.trim())
253
+ }
254
+
255
+ safeRun(() => {
256
+ Array.from(document.styleSheets || []).forEach((sheet) => {
257
+ safeRun(() => {
258
+ const sheetRules = sheet.cssRules || sheet.rules
259
+ if (sheet.ownerNode) processedStyleNodes.add(sheet.ownerNode)
260
+ if (!sheetRules) return
261
+ Array.from(sheetRules).forEach((rule) => {
262
+ safeRun(() => addRule(rule.cssText), undefined)
263
+ })
264
+ }, () => {
265
+ if (sheet.ownerNode?.tagName === 'STYLE') {
266
+ processedStyleNodes.add(sheet.ownerNode)
267
+ addRule(sheet.ownerNode.textContent || '')
268
+ }
269
+ })
270
+ })
271
+ }, undefined, '遍历样式表失败')
272
+
273
+ safeRun(() => {
274
+ document.querySelectorAll('style').forEach((el) => {
275
+ if (!processedStyleNodes.has(el)) addRule(el.textContent || '')
276
+ })
277
+ }, undefined, '收集 style 标签失败')
278
+
279
+ return rules.join('\n')
280
+ }
281
+
282
+ function captureRootCssVariables() {
283
+ return safeRun(() => {
284
+ const styles = getComputedStyle(document.documentElement)
285
+ const vars = []
286
+ for (let i = 0; i < styles.length; i++) {
287
+ const prop = styles[i]
288
+ if (prop.startsWith('--')) {
289
+ vars.push(`${prop}:${styles.getPropertyValue(prop)}`)
290
+ }
291
+ }
292
+ return vars.length ? `:root { ${vars.join(';')} }` : ''
293
+ }, '', '收集 CSS 变量失败')
294
+ }
295
+
296
+ function stripHeavyFontFaces(css) {
297
+ return (css || '').replace(/@font-face\s*\{[^}]*font-family\s*:\s*SUN[^}]*\}/gi, '')
298
+ }
299
+
300
+ function getAppStaticBase() {
301
+ const base = typeof process !== 'undefined' && process.env?.BASE_URL
302
+ ? process.env.BASE_URL
303
+ : '/gdebit/'
304
+ try {
305
+ return new URL(`static/`, new URL(base, window.location.origin)).href
306
+ } catch (e) {
307
+ return `${window.location.origin}/gdebit/static/`
308
+ }
309
+ }
310
+
311
+ function getStylesheetBaseUrls() {
312
+ const bases = new Set([getAppStaticBase(), `${window.location.origin}/`])
313
+
314
+ safeRun(() => {
315
+ document.querySelectorAll('link[rel="stylesheet"][href]').forEach((link) => {
316
+ bases.add(new URL('.', link.href).href)
317
+ })
318
+ Array.from(document.styleSheets || []).forEach((sheet) => {
319
+ if (!sheet.href) return
320
+ bases.add(new URL('.', sheet.href).href)
321
+ })
322
+ }, undefined)
323
+
324
+ return [...bases]
325
+ }
326
+
327
+ function getLoadedFontResourceUrls() {
328
+ return safeRun(() => {
329
+ return performance
330
+ .getEntriesByType('resource')
331
+ .filter((entry) => /\.(woff2?|ttf|otf)(\?|$)/i.test(entry.name))
332
+ .map((entry) => entry.name)
333
+ }, [], '读取已加载字体资源失败')
334
+ }
335
+
336
+ function toAbsoluteFontAssetUrl(assetUrl) {
337
+ if (!assetUrl) return ''
338
+ if (/^https?:\/\//i.test(assetUrl) || assetUrl.startsWith('data:')) return assetUrl
339
+ try {
340
+ return new URL(assetUrl, window.location.origin).href
341
+ } catch (e) {
342
+ return assetUrl
343
+ }
344
+ }
345
+
346
+ function safeRequireFont(modulePath) {
347
+ try {
348
+ return toAbsoluteFontAssetUrl(require(modulePath))
349
+ } catch (e) {
350
+ return ''
351
+ }
352
+ }
353
+
354
+ /** webpack 解析的图标字体 URL(/gdebit/static/fonts/...) */
355
+ function getWebpackIconFontUrls() {
356
+ const paths = [
357
+ 'element-ui/lib/theme-chalk/fonts/element-icons.woff',
358
+ 'element-ui/lib/theme-chalk/fonts/element-icons.ttf',
359
+ 'n20-common-lib/src/assets/iconFont2/iconfont.woff2',
360
+ 'n20-common-lib/src/assets/iconFont2/iconfont.woff',
361
+ 'n20-common-lib/src/assets/iconFont2/iconfont.ttf'
362
+ ]
363
+
364
+ return [...new Set(paths.map(safeRequireFont).filter(Boolean))]
365
+ }
366
+
367
+ const ICON_FONT_FAMILIES = [
368
+ 'element-icons',
369
+ 'core-lib-iconfont',
370
+ 'iconfont',
371
+ 'vxeiconfont'
372
+ ]
373
+
374
+ function escapeRegExp(text) {
375
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
376
+ }
377
+
378
+ /** 导出前触发图标字体加载,便于 performance / document.fonts 拿到真实 URL */
379
+ async function ensureIconFontsLoaded() {
380
+ const probe = document.createElement('div')
381
+ probe.style.cssText = 'position:absolute;left:-9999px;visibility:hidden;pointer-events:none'
382
+ probe.innerHTML = [
383
+ '<i class="el-icon-arrow-down"></i>',
384
+ '<i class="el-icon-date"></i>',
385
+ '<i class="n20-icon-yuefenqiehuan-zuoce"></i>',
386
+ '<i class="vxe-icon-caret-down"></i>'
387
+ ].join('')
388
+
389
+ document.body.appendChild(probe)
390
+
391
+ await safeRun(async () => {
392
+ if (!document.fonts?.load) return
393
+ const tasks = ICON_FONT_FAMILIES.map((family) => (
394
+ document.fonts.load(`16px ${family}`).catch(() => {})
395
+ ))
396
+ await Promise.all(tasks)
397
+ await document.fonts.ready
398
+ }, undefined)
399
+
400
+ await new Promise((resolve) => setTimeout(resolve, 120))
401
+ document.body.removeChild(probe)
402
+ }
403
+
404
+ function collectFontUrlsFromStyleSheets() {
405
+ const urls = new Set()
406
+
407
+ safeRun(() => {
408
+ Array.from(document.styleSheets || []).forEach((sheet) => {
409
+ let rules
410
+ try {
411
+ rules = sheet.cssRules
412
+ } catch (e) {
413
+ return
414
+ }
415
+ if (!rules) return
416
+
417
+ const base = sheet.href || window.location.href
418
+ Array.from(rules).forEach((rule) => {
419
+ if (rule.type !== CSSRule.FONT_FACE_RULE) return
420
+ const cssText = rule.cssText || ''
421
+ if (!/iconfont|element-icons|vxeicon/i.test(cssText)) return
422
+ for (const match of cssText.matchAll(/url\(\s*["']?([^"')]+)["']?\s*\)/g)) {
423
+ const raw = match[1].trim()
424
+ if (raw.startsWith('data:')) {
425
+ urls.add(raw)
426
+ continue
427
+ }
428
+ safeRun(() => urls.add(new URL(raw, base).href))
429
+ }
430
+ })
431
+ })
432
+ }, undefined)
433
+
434
+ return [...urls]
435
+ }
436
+
437
+ function getAllIconFontFetchUrls() {
438
+ const urls = new Set()
439
+
440
+ getWebpackIconFontUrls().forEach((url) => urls.add(url))
441
+ getLoadedFontResourceUrls().forEach((url) => {
442
+ if (/iconfont|element-icons|vxeicon|\.woff2?|\.ttf|\.otf/i.test(url)) {
443
+ urls.add(url)
444
+ }
445
+ })
446
+ collectFontUrlsFromStyleSheets().forEach((url) => urls.add(url))
447
+
448
+ return [...urls]
449
+ }
450
+
451
+ function getFilename(url) {
452
+ return (url || '').split('/').pop().split('?')[0]
453
+ }
454
+
455
+ function buildFontFetchCandidates(rawUrl) {
456
+ if (!rawUrl || rawUrl.startsWith('data:') || rawUrl.startsWith('blob:')) {
457
+ return rawUrl ? [rawUrl] : []
458
+ }
459
+
460
+ const candidates = new Set()
461
+ const filename = getFilename(rawUrl)
462
+
463
+ if (/^https?:\/\//i.test(rawUrl)) {
464
+ candidates.add(rawUrl)
465
+ }
466
+
467
+ safeRun(() => candidates.add(new URL(rawUrl, window.location.href).href))
468
+ getStylesheetBaseUrls().forEach((base) => {
469
+ safeRun(() => candidates.add(new URL(rawUrl, base).href))
470
+ })
471
+
472
+ getAllIconFontFetchUrls().forEach((knownUrl) => {
473
+ if (knownUrl.includes(filename)) {
474
+ candidates.add(knownUrl)
475
+ }
476
+ })
477
+
478
+ return [...candidates]
479
+ }
480
+
481
+ function isFontBinary(buffer) {
482
+ if (!buffer || buffer.byteLength < 4) return false
483
+ const bytes = new Uint8Array(buffer.slice(0, 4))
484
+ const signature = String.fromCharCode(bytes[0], bytes[1], bytes[2], bytes[3])
485
+ return (
486
+ signature === 'wOFF'
487
+ || signature === 'wOF2'
488
+ || signature === 'OTTO'
489
+ || (bytes[0] === 0x00 && bytes[1] === 0x01 && bytes[2] === 0x00 && bytes[3] === 0x00)
490
+ )
491
+ }
492
+
493
+ function isFontDataUrl(dataUrl) {
494
+ if (!dataUrl || typeof dataUrl !== 'string') return false
495
+ if (!dataUrl.startsWith('data:')) return false
496
+ if (/^data:text\/html/i.test(dataUrl)) return false
497
+ if (/^data:application\/json/i.test(dataUrl)) return false
498
+ if (/^data:image\//i.test(dataUrl)) return false
499
+ return /^data:font\//i.test(dataUrl) || /^data:application\//i.test(dataUrl)
500
+ }
501
+
502
+ async function fetchFontAsDataUrl(rawUrl) {
503
+ const candidates = buildFontFetchCandidates(rawUrl)
504
+
505
+ for (const candidate of candidates) {
506
+ if (candidate.startsWith('data:')) {
507
+ if (isFontDataUrl(candidate)) return candidate
508
+ continue
509
+ }
510
+
511
+ try {
512
+ const resp = await fetch(candidate)
513
+ if (!resp.ok) continue
514
+
515
+ const contentType = resp.headers.get('content-type') || ''
516
+ if (/text\/html|application\/json/i.test(contentType)) continue
517
+
518
+ const buffer = await resp.arrayBuffer()
519
+ if (!isFontBinary(buffer)) continue
520
+
521
+ const blob = new Blob([buffer], {
522
+ type: contentType || 'application/octet-stream'
523
+ })
524
+ const dataUrl = await new Promise((resolve, reject) => {
525
+ const reader = new FileReader()
526
+ reader.onload = () => resolve(reader.result)
527
+ reader.onerror = reject
528
+ reader.readAsDataURL(blob)
529
+ })
530
+
531
+ if (isFontDataUrl(dataUrl)) return dataUrl
532
+ } catch (e) {
533
+ // try next candidate
534
+ }
535
+ }
536
+
537
+ throw new Error(`font fetch failed: ${rawUrl}`)
538
+ }
539
+
540
+ function shouldEmbedFontUrl(url) {
541
+ if (!url || url.startsWith('data:')) return false
542
+ if (/SIMSUN|simsun/i.test(url)) return false
543
+ return /iconfont|element-icons|vxeicon|\.woff2?|\.ttf|\.otf/i.test(url)
544
+ }
545
+
546
+ async function embedIconFonts(css) {
547
+ await ensureIconFontsLoaded()
548
+
549
+ const filenameToData = new Map()
550
+
551
+ for (const knownUrl of getAllIconFontFetchUrls()) {
552
+ const filename = getFilename(knownUrl)
553
+ if (!filename || filenameToData.has(filename)) continue
554
+ if (knownUrl.startsWith('data:') && isFontDataUrl(knownUrl)) {
555
+ filenameToData.set(filename, knownUrl)
556
+ continue
557
+ }
558
+ try {
559
+ filenameToData.set(filename, await fetchFontAsDataUrl(knownUrl))
560
+ } catch (e) {
561
+ // try next source
562
+ }
563
+ }
564
+
565
+ const rawUrls = [...new Set(
566
+ [...(css || '').matchAll(/url\(\s*["']?([^"')]+)["']?\s*\)/g)].map((m) => m[1].trim())
567
+ )]
568
+
569
+ for (const rawUrl of rawUrls) {
570
+ if (!shouldEmbedFontUrl(rawUrl)) continue
571
+ const filename = getFilename(rawUrl)
572
+ if (filenameToData.has(filename)) continue
573
+ try {
574
+ filenameToData.set(filename, await fetchFontAsDataUrl(rawUrl))
575
+ } catch (e) {
576
+ console.warn('[exportPageHtml] 字体内嵌失败', rawUrl, e)
577
+ }
578
+ }
579
+
580
+ let result = css
581
+ filenameToData.forEach((dataUrl, filename) => {
582
+ const pattern = new RegExp(
583
+ `url\\(\\s*["']?[^"')]*${escapeRegExp(filename)}[^"')]*["']?\\s*\\)`,
584
+ 'gi'
585
+ )
586
+ result = result.replace(pattern, `url("${dataUrl}")`)
587
+ })
588
+
589
+ result = result.replace(
590
+ /url\(\s*["']?data:text\/html[^"')]+["']?\s*\)/gi,
591
+ 'url("")'
592
+ )
593
+
594
+ return result
595
+ }
596
+
597
+ function downloadBlob(blob, filename) {
598
+ safeRun(() => {
599
+ const url = URL.createObjectURL(blob)
600
+ const a = document.createElement('a')
601
+ a.href = url
602
+ a.download = filename
603
+ document.body.appendChild(a)
604
+ a.click()
605
+ document.body.removeChild(a)
606
+ URL.revokeObjectURL(url)
607
+ }, undefined, '下载失败')
608
+ }
609
+
610
+ function normalizeOptions(options) {
611
+ if (typeof options === 'boolean') {
612
+ return { autoDownload: options, inlineStyles: false, embedFonts: true, forPdf: false }
613
+ }
614
+ return {
615
+ autoDownload: false,
616
+ inlineStyles: false,
617
+ embedFonts: true,
618
+ forPdf: false,
619
+ ...(options || {})
620
+ }
621
+ }
622
+
623
+ /**
624
+ * 构建高保真页面 HTML 快照
625
+ * @param {HTMLElement} rootEl
626
+ * @param {{ autoDownload?: boolean, inlineStyles?: boolean, embedFonts?: boolean, forPdf?: boolean }} [options]
627
+ */
628
+ export async function buildPageHtmlSnapshot(rootEl, options = {}) {
629
+ const { inlineStyles, embedFonts, forPdf } = normalizeOptions(options)
630
+
631
+ if (!rootEl || rootEl.nodeType !== Node.ELEMENT_NODE) {
632
+ return { ok: false, message: '未找到页面内容' }
633
+ }
634
+
635
+ try {
636
+ if (embedFonts) {
637
+ await ensureIconFontsLoaded()
638
+ }
639
+
640
+ const filename = buildSnapshotFilename()
641
+ const pageTitle = document.title || SNAPSHOT_FILENAME_PREFIX
642
+ const ts = filename.replace(`${SNAPSHOT_FILENAME_PREFIX}-`, '').replace('.html', '')
643
+
644
+ const pageClone = clonePageNode(rootEl)
645
+ if (!pageClone) return { ok: false, message: '克隆页面失败' }
646
+
647
+ unwrapScrollContainers(pageClone)
648
+ removeAnchorNav(pageClone)
649
+ removeBottomOperateButtons(pageClone)
650
+
651
+ const pageHtml = pageClone.outerHTML
652
+ if (!pageHtml) return { ok: false, message: '生成页面 HTML 失败' }
653
+
654
+ let css = [
655
+ captureRootCssVariables(),
656
+ stripHeavyFontFaces(collectDocumentCss()),
657
+ SNAPSHOT_BASE_CSS,
658
+ forPdf ? SNAPSHOT_PDF_CSS : ''
659
+ ].filter(Boolean).join('\n')
660
+
661
+ if (embedFonts) {
662
+ css = await embedIconFonts(css)
663
+ }
664
+
665
+ const html = `<!DOCTYPE html>
666
+ <html lang="zh-CN">
667
+ <head>
668
+ <meta charset="UTF-8">
669
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
670
+ <title>${pageTitle} - ${ts}</title>
671
+ <style>
672
+ ${css}
673
+ </style>
674
+ </head>
675
+ <body>
676
+ ${pageHtml}
677
+ ${PAGE_SNAPSHOT_BACKEND_PLACEHOLDER}
678
+ </body>
679
+ </html>`
680
+
681
+ const blob = new Blob([html], { type: 'text/html;charset=utf-8' })
682
+ const file = new File([blob], filename, { type: blob.type })
683
+
684
+ return { ok: true, html, filename, blob, file, ts, pageTitle }
685
+ } catch (e) {
686
+ console.warn('[exportPageHtml] 构建页面快照失败', e)
687
+ return { ok: false, message: '构建页面快照失败' }
688
+ }
689
+ }
690
+
691
+ /**
692
+ * @param {HTMLElement} rootEl
693
+ * @param {boolean|object} [options]
694
+ */
695
+ export async function exportPageHtml(rootEl, options = false) {
696
+ const { autoDownload, ...rest } = normalizeOptions(options)
697
+ const result = await buildPageHtmlSnapshot(rootEl, rest)
698
+
699
+ if (!result.ok) return result
700
+ if (autoDownload) downloadBlob(result.blob, result.filename)
701
+ return result
702
+ }
703
+
704
+ /**
705
+ * 上传用 FormData
706
+ * @param {HTMLElement} rootEl
707
+ * @param {object} [options]
708
+ */
709
+ export async function createPageHtmlFormData(rootEl, options = {}) {
710
+ const built = await buildPageHtmlSnapshot(rootEl, options)
711
+ if (!built.ok) return built
712
+
713
+ return safeRun(() => {
714
+ const formData = new FormData()
715
+ formData.append('file', built.file, built.filename)
716
+ return {
717
+ ok: true,
718
+ formData,
719
+ file: built.file,
720
+ blob: built.blob,
721
+ filename: built.filename,
722
+ html: built.html
723
+ }
724
+ }, { ok: false, message: '构建上传数据失败' }, '构建 FormData 失败')
725
+ }