优化页面、修复BUG

This commit is contained in:
李琦
2026-07-11 18:11:59 +08:00
parent c864853845
commit 45ec3a0a31
14 changed files with 2454 additions and 95 deletions

View File

@@ -1,10 +1,20 @@
/**
* 文章多格式导出工具
* 支持 Markdown、纯文本、PDF、PNG 四种格式
* 支持 Markdown、纯文本、PDF、PNG、PPT 等格式
*/
import html2canvas from 'html2canvas'
import { jsPDF } from 'jspdf'
import PptxGenJS from 'pptxgenjs'
import type { Post } from '../services/api'
import {
buildPptSlides,
estimateBlockHeight,
inferVideoExtn,
resolveImageUrl,
type ContentBlock,
type PptSectionSlide,
type PptSlide,
} from './articlePptSlides'
import { buildExportBasename, downloadBlob, downloadText } from './fileDownload'
import '../styles/articleExport.css'
@@ -12,7 +22,7 @@ import '../styles/articleExport.css'
export type ExportTheme = 'light' | 'dark'
/** 支持的导出格式 */
export type ExportFormat = 'md' | 'txt' | 'pdf' | 'png'
export type ExportFormat = 'md' | 'txt' | 'pdf' | 'png' | 'ppt-structured' | 'ppt-screenshot'
/** 导出操作结果 */
export interface ExportResult {
@@ -21,11 +31,11 @@ export interface ExportResult {
}
const EXPORT_CONTAINER_WIDTH = 800
const PPT_CONTENT_X = 0.5
const PPT_CONTENT_W = 9
const PPT_SLIDE_BOTTOM = 5.1
const MAX_VIDEO_EXPORT_BYTES = 30 * 1024 * 1024
/**
* 构建 Markdown frontmatter 元信息块
* @param post 文章数据
*/
function buildMarkdownFrontmatter(post: Post): string {
const tags = post.tags?.map((t) => t.name).join(', ') || ''
const lines = [
@@ -40,42 +50,24 @@ function buildMarkdownFrontmatter(post: Post): string {
return lines.join('\n')
}
/**
* 将 Markdown 转为可读纯文本(轻量剥离语法)
* @param markdown 原始 Markdown 内容
*/
function markdownToPlainText(markdown: string): string {
let text = markdown
// 代码块:保留内容,去掉围栏
text = text.replace(/```[\s\S]*?```/g, (block) =>
block.replace(/^```[\w-]*\n?/, '').replace(/```$/, '').trim()
)
// 行内代码
text = text.replace(/`([^`]+)`/g, '$1')
// 图片:保留 alt 文本
text = text.replace(/!\[([^\]]*)\]\([^)]+\)/g, '$1')
// 链接:保留文字
text = text.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
// 标题前缀
text = text.replace(/^#{1,6}\s+/gm, '')
// 粗体/斜体
text = text.replace(/(\*\*|__)(.*?)\1/g, '$2')
text = text.replace(/(\*|_)(.*?)\1/g, '$2')
// 引用
text = text.replace(/^>\s+/gm, '')
// 无序列表
text = text.replace(/^[-*+]\s+/gm, '• ')
// HTML 标签
text = text.replace(/<[^>]+>/g, '')
// 多余空行
text = text.replace(/\n{3,}/g, '\n\n')
return text.trim()
}
/**
* 构建纯文本元信息头部
* @param post 文章数据
*/
function buildTextHeader(post: Post): string {
const lines = [
post.title,
@@ -89,12 +81,20 @@ function buildTextHeader(post: Post): string {
return lines.join('\n')
}
/**
* 创建离屏导出 DOM 容器PDF/PNG 共用)
* @param post 文章数据
* @param renderedHtml 已渲染的正文 HTML
* @param theme 导出主题
*/
function sanitizeExportHtml(html: string): string {
if (typeof DOMParser === 'undefined') return html
const doc = new DOMParser().parseFromString(html, 'text/html')
doc.querySelectorAll('video').forEach((video) => {
const poster = video.getAttribute('poster')
const placeholder = document.createElement('div')
placeholder.className = 'export-video-placeholder'
placeholder.textContent = poster ? '[视频]' : '[视频内容]'
placeholder.style.cssText = 'padding:2rem;text-align:center;color:#888;border:1px dashed #ccc;border-radius:8px;margin:1rem 0;'
video.replaceWith(placeholder)
})
return doc.body.innerHTML
}
function buildExportContainer(post: Post, renderedHtml: string, theme: ExportTheme): HTMLDivElement {
const container = document.createElement('div')
container.className = `article-export-root export-theme-${theme}`
@@ -116,20 +116,14 @@ function buildExportContainer(post: Post, renderedHtml: string, theme: ExportThe
<h1 class="export-title">${escapeHtml(post.title)}</h1>
${post.userName ? `<div class="export-meta export-author">作者:${escapeHtml(post.userName)}</div>` : ''}
</div>
<div class="export-body">${renderedHtml}</div>
<div class="export-body">${sanitizeExportHtml(renderedHtml)}</div>
`
// 移除交互元素,避免截图出现按钮
container.querySelectorAll('.copy-btn').forEach((el) => el.remove())
document.body.appendChild(container)
return container
}
/**
* HTML 特殊字符转义(用于拼接模板字符串)
* @param str 原始字符串
*/
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
@@ -138,10 +132,316 @@ function escapeHtml(str: string): string {
.replace(/"/g, '&quot;')
}
/**
* 使用 html2canvas 将 DOM 转为 Canvas
* @param element 待截图元素
*/
async function fetchImageAsDataUrl(url: string): Promise<{ data: string | null; failed: boolean }> {
try {
const response = await fetch(resolveImageUrl(url), { mode: 'cors' })
if (!response.ok) return { data: null, failed: true }
const blob = await response.blob()
const data = await new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string)
reader.onerror = reject
reader.readAsDataURL(blob)
})
return { data, failed: false }
} catch {
return { data: null, failed: true }
}
}
async function fetchVideoAsBase64(
url: string,
mimeType?: string
): Promise<{ data: string | null; extn: string; failed: boolean; tooLarge: boolean }> {
try {
const response = await fetch(resolveImageUrl(url), { mode: 'cors' })
if (!response.ok) return { data: null, extn: inferVideoExtn(url, mimeType), failed: true, tooLarge: false }
const blob = await response.blob()
if (blob.size > MAX_VIDEO_EXPORT_BYTES) {
return { data: null, extn: inferVideoExtn(url, mimeType), failed: true, tooLarge: true }
}
const data = await new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string)
reader.onerror = reject
reader.readAsDataURL(blob)
})
return { data, extn: inferVideoExtn(url, blob.type || mimeType), failed: false, tooLarge: false }
} catch {
return { data: null, extn: inferVideoExtn(url, mimeType), failed: true, tooLarge: false }
}
}
function createPptx(): PptxGenJS {
const pptx = new PptxGenJS()
pptx.layout = 'LAYOUT_16x9'
pptx.author = 'Blog Export'
return pptx
}
function addTitleSlideToPptx(pptx: PptxGenJS, slide: Extract<PptSlide, { kind: 'title' }>): void {
const pptSlide = pptx.addSlide()
const metaLines = [
slide.date || '',
slide.categoryName || '未分类',
slide.userName ? `作者:${slide.userName}` : '',
slide.tagNames.length ? `标签:${slide.tagNames.join(' · ')}` : '',
].filter(Boolean)
pptSlide.addText(slide.title, {
x: PPT_CONTENT_X,
y: 1.4,
w: PPT_CONTENT_W,
h: 1.5,
fontSize: 32,
bold: true,
color: '1A1A1A',
align: 'center',
valign: 'middle',
})
if (metaLines.length) {
pptSlide.addText(metaLines.join('\n'), {
x: PPT_CONTENT_X,
y: 3.2,
w: PPT_CONTENT_W,
h: 1.5,
fontSize: 14,
color: '666666',
align: 'center',
valign: 'top',
})
}
}
function addSectionHeader(slide: PptxGenJS.Slide, title: string, y = 0.35): number {
slide.addText(title, {
x: PPT_CONTENT_X,
y,
w: PPT_CONTENT_W,
h: 0.6,
fontSize: 22,
bold: true,
color: '1A1A1A',
})
slide.addShape('rect' as PptxGenJS.SHAPE_NAME, {
x: PPT_CONTENT_X,
y: y + 0.55,
w: 1.2,
h: 0.05,
fill: { color: 'C9A962' },
line: { color: 'C9A962' },
})
return y + 0.85
}
async function addBlockToPptxSlide(
slide: PptxGenJS.Slide,
block: ContentBlock,
y: number,
mediaWarning: { value: boolean }
): Promise<number> {
switch (block.type) {
case 'h1':
slide.addText(block.text, {
x: PPT_CONTENT_X,
y,
w: PPT_CONTENT_W,
h: 0.5,
fontSize: 22,
bold: true,
color: '222222',
})
return y + 0.6
case 'h2':
slide.addText(block.text, {
x: PPT_CONTENT_X,
y,
w: PPT_CONTENT_W,
h: 0.45,
fontSize: 18,
bold: true,
color: '333333',
})
return y + 0.5
case 'h3':
slide.addText(block.text, {
x: PPT_CONTENT_X,
y,
w: PPT_CONTENT_W,
h: 0.4,
fontSize: 16,
bold: true,
color: '333333',
})
return y + 0.45
case 'paragraph':
slide.addText(block.text, {
x: PPT_CONTENT_X,
y,
w: PPT_CONTENT_W,
h: PPT_SLIDE_BOTTOM - y,
fontSize: 14,
color: '444444',
valign: 'top',
breakLine: true,
})
return y + estimateBlockHeight(block) + 0.1
case 'code': {
const label = block.lang ? block.lang.toUpperCase() : 'CODE'
slide.addText(label, {
x: PPT_CONTENT_X,
y,
w: PPT_CONTENT_W,
h: 0.25,
fontSize: 9,
color: '888888',
})
const codeHeight = estimateBlockHeight(block)
slide.addText(block.text, {
x: PPT_CONTENT_X,
y: y + 0.25,
w: PPT_CONTENT_W,
h: codeHeight,
fontSize: 10,
fontFace: 'Courier New',
color: 'EEEEEE',
fill: { color: '1E1E1E' },
valign: 'top',
margin: 0.08,
breakLine: true,
})
return y + codeHeight + 0.35
}
case 'list': {
const listHeight = estimateBlockHeight(block)
slide.addText(
block.items.map((item) => ({
text: item,
options: { bullet: block.ordered ? { type: 'number' } : true, breakLine: true },
})),
{
x: PPT_CONTENT_X,
y,
w: PPT_CONTENT_W,
h: listHeight,
fontSize: 14,
color: '444444',
valign: 'top',
}
)
return y + listHeight + 0.1
}
case 'image': {
const { data, failed } = await fetchImageAsDataUrl(block.src)
if (failed || !data) {
mediaWarning.value = true
slide.addText(`[图片: ${block.alt}]`, {
x: PPT_CONTENT_X,
y,
w: PPT_CONTENT_W,
h: 0.4,
fontSize: 12,
color: '888888',
italic: true,
})
return y + 0.45
}
slide.addImage({
data,
x: PPT_CONTENT_X + 1,
y,
w: 7,
h: 3,
sizing: { type: 'contain', w: 7, h: 3 },
})
return y + 3.2
}
case 'video': {
const { data, failed, tooLarge, extn } = await fetchVideoAsBase64(block.src, block.mimeType)
if (failed || !data) {
mediaWarning.value = true
slide.addText(tooLarge ? '[视频: 文件过大,未嵌入]' : '[视频: 无法嵌入]', {
x: PPT_CONTENT_X,
y,
w: PPT_CONTENT_W,
h: 0.4,
fontSize: 12,
color: '888888',
italic: true,
})
return y + 0.45
}
slide.addMedia({
type: 'video',
data,
extn,
x: PPT_CONTENT_X,
y,
w: PPT_CONTENT_W,
h: 3,
})
return y + 3.5
}
case 'placeholder':
slide.addText(block.text, {
x: PPT_CONTENT_X,
y,
w: PPT_CONTENT_W,
h: 0.35,
fontSize: 12,
color: '888888',
italic: true,
})
return y + 0.4
default:
return y
}
}
async function addSectionSlideToPptx(
pptx: PptxGenJS,
sectionSlide: PptSectionSlide,
mediaWarning: { value: boolean }
): Promise<void> {
const slide = pptx.addSlide()
let y = addSectionHeader(slide, sectionSlide.title)
for (const block of sectionSlide.blocks) {
y = await addBlockToPptxSlide(slide, block, y, mediaWarning)
}
}
function canvasToPptSlides(pptx: PptxGenJS, canvas: HTMLCanvasElement): void {
const sliceHeight = Math.floor(canvas.width * (9 / 16))
if (sliceHeight <= 0) return
for (let y = 0; y < canvas.height; y += sliceHeight) {
const height = Math.min(sliceHeight, canvas.height - y)
const sliceCanvas = document.createElement('canvas')
sliceCanvas.width = canvas.width
sliceCanvas.height = height
const ctx = sliceCanvas.getContext('2d')
if (!ctx) continue
ctx.drawImage(canvas, 0, y, canvas.width, height, 0, 0, canvas.width, height)
const data = sliceCanvas.toDataURL('image/png')
const slide = pptx.addSlide()
slide.addImage({ data, x: 0, y: 0, w: '100%', h: '100%' })
}
}
async function domToCanvas(element: HTMLElement): Promise<{ canvas: HTMLCanvasElement; hasImageWarning: boolean }> {
let hasImageWarning = false
@@ -154,9 +454,7 @@ async function domToCanvas(element: HTMLElement): Promise<{ canvas: HTMLCanvasEl
scrollY: -window.scrollY,
scrollX: -window.scrollX,
onclone: (_doc, clonedEl) => {
// 检测跨域图片是否可能加载失败
const imgs = clonedEl.querySelectorAll('img')
imgs.forEach((img) => {
clonedEl.querySelectorAll('img').forEach((img) => {
if (img.src && !img.src.startsWith(window.location.origin) && !img.complete) {
hasImageWarning = true
}
@@ -167,27 +465,16 @@ async function domToCanvas(element: HTMLElement): Promise<{ canvas: HTMLCanvasEl
return { canvas, hasImageWarning }
}
/**
* 将 Canvas 保存为 PNG 文件
* @param canvas 画布
* @param filename 文件名
*/
function canvasToPng(canvas: HTMLCanvasElement, filename: string): void {
canvas.toBlob((blob) => {
if (blob) downloadBlob(blob, filename)
}, 'image/png')
}
/**
* 将 Canvas 分页写入 PDF 并下载
* @param canvas 画布
* @param filename 文件名
*/
function canvasToPdf(canvas: HTMLCanvasElement, filename: string): void {
const pdf = new jsPDF('p', 'mm', 'a4')
const pageWidth = pdf.internal.pageSize.getWidth()
const pageHeight = pdf.internal.pageSize.getHeight()
const imgData = canvas.toDataURL('image/png')
const imgWidth = pageWidth
const imgHeight = (canvas.height * imgWidth) / canvas.width
@@ -208,10 +495,6 @@ function canvasToPdf(canvas: HTMLCanvasElement, filename: string): void {
pdf.save(filename)
}
/**
* 导出文章为 Markdown 文件
* @param post 文章数据
*/
export function exportAsMarkdown(post: Post): ExportResult {
const content = post.content || ''
const frontmatter = buildMarkdownFrontmatter(post)
@@ -220,10 +503,6 @@ export function exportAsMarkdown(post: Post): ExportResult {
return { success: true }
}
/**
* 导出文章为纯文本文件
* @param post 文章数据
*/
export function exportAsTxt(post: Post): ExportResult {
const header = buildTextHeader(post)
const body = markdownToPlainText(post.content || '')
@@ -232,12 +511,6 @@ export function exportAsTxt(post: Post): ExportResult {
return { success: true }
}
/**
* 导出文章为 PNG 长图
* @param post 文章数据
* @param renderedHtml 已渲染正文 HTML
* @param theme 导出主题
*/
export async function exportAsPng(
post: Post,
renderedHtml: string,
@@ -257,12 +530,6 @@ export async function exportAsPng(
}
}
/**
* 导出文章为 PDF 文件
* @param post 文章数据
* @param renderedHtml 已渲染正文 HTML
* @param theme 导出主题
*/
export async function exportAsPdf(
post: Post,
renderedHtml: string,
@@ -282,13 +549,49 @@ export async function exportAsPdf(
}
}
/**
* 统一导出入口
* @param format 导出格式
* @param post 文章数据
* @param renderedHtml 已渲染正文 HTMLPDF/PNG 需要)
* @param theme 导出主题PDF/PNG 需要)
*/
export async function exportAsPptStructured(post: Post): Promise<ExportResult> {
const pptx = createPptx()
const mediaWarning = { value: false }
const slides = buildPptSlides(post)
for (const slide of slides) {
if (slide.kind === 'title') {
addTitleSlideToPptx(pptx, slide)
} else {
await addSectionSlideToPptx(pptx, slide, mediaWarning)
}
}
const basename = buildExportBasename(post.title, post.date)
await pptx.writeFile({ fileName: `${basename}.pptx` })
return {
success: true,
warning: mediaWarning.value ? '部分图片或视频可能因跨域/大小限制未能导出' : undefined,
}
}
export async function exportAsPptScreenshot(
post: Post,
renderedHtml: string,
theme: ExportTheme
): Promise<ExportResult> {
const container = buildExportContainer(post, renderedHtml, theme)
try {
const { canvas, hasImageWarning } = await domToCanvas(container)
const pptx = createPptx()
canvasToPptSlides(pptx, canvas)
const basename = buildExportBasename(post.title, post.date)
await pptx.writeFile({ fileName: `${basename}-screenshot.pptx` })
return {
success: true,
warning: hasImageWarning ? '部分图片可能因跨域限制未能导出' : undefined,
}
} finally {
container.remove()
}
}
export async function exportArticle(
format: ExportFormat,
post: Post,
@@ -304,7 +607,13 @@ export async function exportArticle(
return exportAsPng(post, renderedHtml, theme)
case 'pdf':
return exportAsPdf(post, renderedHtml, theme)
case 'ppt-structured':
return exportAsPptStructured(post)
case 'ppt-screenshot':
return exportAsPptScreenshot(post, renderedHtml, theme)
default:
return { success: false, warning: '不支持的导出格式' }
}
}
export { buildPptSlides } from './articlePptSlides'

View File

@@ -0,0 +1,618 @@
/**
* 文章结构化 PPT 幻灯片模型
* 预览与导出共用同一套分页逻辑
*/
import MarkdownIt from 'markdown-it'
import type Token from 'markdown-it/lib/token.mjs'
import type { Post } from '../services/api'
export type ContentBlock =
| { type: 'h1'; text: string }
| { type: 'h2'; text: string }
| { type: 'h3'; text: string }
| { type: 'paragraph'; text: string }
| { type: 'code'; text: string; lang?: string }
| { type: 'image'; alt: string; src: string }
| { type: 'video'; src: string; poster?: string; mimeType?: string }
| { type: 'list'; ordered: boolean; items: string[] }
| { type: 'placeholder'; text: string }
export type RenderUnit =
| { kind: 'single'; block: ContentBlock }
| { kind: 'image-text'; image: Extract<ContentBlock, { type: 'image' }>; text: Extract<ContentBlock, { type: 'paragraph' }> }
export interface SlideSection {
title: string
blocks: ContentBlock[]
}
export interface PptTitleSlide {
kind: 'title'
title: string
date?: string
categoryName?: string
userName?: string
tagNames: string[]
}
export interface PptSectionSlide {
kind: 'section'
title: string
blocks: ContentBlock[]
continued: boolean
}
export type PptSlide = PptTitleSlide | PptSectionSlide
const PPT_SLIDE_BOTTOM = 5.1
const SECTION_HEADER_HEIGHT = 0.85
const IMAGE_PLACEHOLDER_GLOBAL_RE = /\[图片(?::[^\]]*)?\]/g
let markdownParser: MarkdownIt | null = null
function getMarkdownParser(): MarkdownIt {
if (!markdownParser) {
markdownParser = new MarkdownIt({ html: true, linkify: true })
}
return markdownParser
}
function renderTokenChildren(children: Token[]): string {
return children
.map((child) => {
if (child.children?.length) return renderTokenChildren(child.children)
switch (child.type) {
case 'text':
case 'code_inline':
return child.content
case 'softbreak':
case 'hardbreak':
return '\n'
case 'image':
return `[图片: ${child.content || child.attrGet('alt') || 'image'}]`
default:
return child.content || ''
}
})
.join('')
}
function renderInlineText(inlineToken: Token): string {
if (!inlineToken.children?.length) return inlineToken.content
return renderTokenChildren(inlineToken.children)
}
/** 移除 inline 渲染产生的 [图片: ...] 占位片段 */
export function stripImagePlaceholders(text: string): string {
return text.replace(IMAGE_PLACEHOLDER_GLOBAL_RE, '').replace(/\s+/g, ' ').trim()
}
function getImgSrc(el: Element): string {
return (
el.getAttribute('src') ||
el.getAttribute('data-src') ||
el.getAttribute('data-original') ||
''
)
}
function htmlHeadingType(tag: string): 'h1' | 'h2' | 'h3' | null {
if (tag === 'H1') return 'h1'
if (tag === 'H2') return 'h2'
if (tag === 'H3' || tag === 'H4' || tag === 'H5' || tag === 'H6') return 'h3'
return null
}
/** 从 HTML 片段提取块(供 html_inline / 段落内 HTML 使用) */
export function parseInlineHtmlFragment(html: string): ContentBlock[] {
const wrapped = html.trim().startsWith('<') ? html : `<span>${html}</span>`
return parseHtmlBlock(wrapped)
}
/** 从 inline token 提取图片、视频与剥离占位后的文本 */
export function extractMediaFromInline(inlineToken: Token): {
blocks: ContentBlock[]
text: string
} {
const blocks: ContentBlock[] = []
if (!inlineToken.children?.length) {
return { blocks, text: stripImagePlaceholders(inlineToken.content || '') }
}
for (const child of inlineToken.children) {
if (child.type === 'image') {
const src = child.attrGet('src') || ''
if (src) {
blocks.push({
type: 'image',
alt: child.content || child.attrGet('alt') || 'image',
src,
})
}
continue
}
if (child.type === 'html_inline' && child.content) {
parseInlineHtmlFragment(child.content).forEach((block) => pushBlock(blocks, block))
}
}
const text = stripImagePlaceholders(renderInlineText(inlineToken))
return { blocks, text }
}
function pushInlineContent(blocks: ContentBlock[], inline: Token): void {
const { blocks: mediaBlocks, text } = extractMediaFromInline(inline)
mediaBlocks.forEach((block) => pushBlock(blocks, block))
if (text) pushBlock(blocks, { type: 'paragraph', text })
}
function getHeadingText(tokens: Token[], idx: number): string {
const inline = tokens[idx + 1]
return inline?.type === 'inline' ? renderInlineText(inline) : ''
}
function parseListItems(
tokens: Token[],
startIdx: number,
listType: 'bullet_list_close' | 'ordered_list_close',
blocks: ContentBlock[]
): { items: string[]; endIdx: number } {
const items: string[] = []
let i = startIdx + 1
while (i < tokens.length && tokens[i].type !== listType) {
if (tokens[i].type === 'list_item_open') {
for (let j = i + 1; j < tokens.length; j++) {
if (tokens[j].type === 'inline') {
const { blocks: mediaBlocks, text } = extractMediaFromInline(tokens[j])
mediaBlocks.forEach((block) => pushBlock(blocks, block))
if (text) items.push(text)
break
}
if (tokens[j].type === 'list_item_close') break
}
}
i++
}
return { items, endIdx: i }
}
function pushBlock(blocks: ContentBlock[], block: ContentBlock): void {
if (block.type === 'paragraph' && !block.text.trim()) return
blocks.push(block)
}
export function inferVideoExtn(src: string, mimeType?: string): string {
if (mimeType?.includes('webm')) return 'webm'
if (mimeType?.includes('quicktime')) return 'mov'
const match = src.match(/\.(\w+)(?:\?|$)/)
if (match) {
const ext = match[1].toLowerCase()
if (['mp4', 'webm', 'mov', 'm4v', 'ogg'].includes(ext)) return ext
}
return 'mp4'
}
function parsePictureSrc(picture: Element): string {
const img = picture.querySelector('img')
if (img) return getImgSrc(img)
const source = picture.querySelector('source')
if (source) {
const srcset = source.getAttribute('srcset') || source.getAttribute('src') || ''
return srcset.split(/\s+/)[0] || ''
}
return ''
}
/** 从 HTML 块按 DOM 顺序提取图片、视频与文本 */
export function parseHtmlBlock(html: string): ContentBlock[] {
if (typeof DOMParser === 'undefined') {
return [{ type: 'placeholder', text: '[嵌入内容]' }]
}
const doc = new DOMParser().parseFromString(html, 'text/html')
const blocks: ContentBlock[] = []
const pushParagraph = (text: string) => {
const trimmed = stripImagePlaceholders(text)
if (trimmed) pushBlock(blocks, { type: 'paragraph', text: trimmed })
}
const parseList = (list: Element) => {
const items = Array.from(list.querySelectorAll(':scope > li'))
.map((li) => stripImagePlaceholders(li.textContent || ''))
.filter(Boolean)
if (items.length) {
blocks.push({ type: 'list', ordered: list.tagName === 'OL', items })
}
}
const parseVideo = (video: HTMLVideoElement) => {
let src = video.getAttribute('src') || ''
let mimeType = video.getAttribute('type') || undefined
if (!src) {
const source = video.querySelector('source')
if (source) {
src = source.getAttribute('src') || ''
mimeType = source.getAttribute('type') || mimeType
}
}
if (src) {
blocks.push({
type: 'video',
src,
poster: video.getAttribute('poster') || undefined,
mimeType,
})
}
}
const walkNode = (node: Node) => {
if (node.nodeType !== Node.ELEMENT_NODE) return
const el = node as Element
const tag = el.tagName
const headingType = htmlHeadingType(tag)
if (headingType) {
pushBlock(blocks, { type: headingType, text: el.textContent?.trim() || '' })
return
}
if (tag === 'IMG') {
const src = getImgSrc(el)
if (src) {
blocks.push({
type: 'image',
alt: el.getAttribute('alt') || 'image',
src,
})
}
return
}
if (tag === 'VIDEO') {
parseVideo(el as HTMLVideoElement)
return
}
if (tag === 'PICTURE') {
const src = parsePictureSrc(el)
const img = el.querySelector('img')
if (src) {
blocks.push({
type: 'image',
alt: img?.getAttribute('alt') || 'image',
src,
})
}
return
}
if (tag === 'A') {
el.querySelectorAll('img').forEach((img) => walkNode(img))
el.querySelectorAll('video').forEach((video) => walkNode(video))
return
}
if (tag === 'UL' || tag === 'OL') {
parseList(el)
return
}
if (tag === 'FIGURE') {
const img = el.querySelector('img')
const video = el.querySelector('video')
if (img) walkNode(img)
else if (video) walkNode(video)
const caption = el.querySelector('figcaption')?.textContent?.trim()
if (caption) pushParagraph(caption)
return
}
if (tag === 'P') {
const imgs = el.querySelectorAll('img')
const videos = el.querySelectorAll('video')
if (imgs.length || videos.length) {
imgs.forEach((img) => walkNode(img))
videos.forEach((video) => walkNode(video))
const textOnly = Array.from(el.childNodes)
.filter((n) => n.nodeType === Node.TEXT_NODE)
.map((n) => n.textContent || '')
.join('')
.trim()
if (textOnly) pushParagraph(textOnly)
} else {
pushParagraph(el.textContent || '')
}
return
}
if (tag === 'DIV' || tag === 'SECTION' || tag === 'ARTICLE' || tag === 'BLOCKQUOTE' || tag === 'SPAN') {
el.childNodes.forEach((child) => walkNode(child))
return
}
if (el.children.length > 0) {
el.childNodes.forEach((child) => walkNode(child))
return
}
if (el.textContent?.trim()) {
pushParagraph(el.textContent)
}
}
doc.body.childNodes.forEach((child) => walkNode(child))
if (!blocks.length) {
const text = doc.body.textContent?.trim()
if (text) pushParagraph(text)
else blocks.push({ type: 'placeholder', text: '[嵌入内容]' })
}
return blocks
}
/** 将 blocks 分组为渲染单元(图片/视频始终独立成块) */
export function buildRenderUnits(blocks: ContentBlock[]): RenderUnit[] {
return blocks.map((block) => ({ kind: 'single' as const, block }))
}
/** 幻灯片分步总数step0=仅标题) */
export function getSlideStepCount(slide: PptSlide): number {
if (slide.kind === 'title') {
const hasMeta = Boolean(slide.date || slide.categoryName || slide.userName || slide.tagNames.length)
return hasMeta ? 2 : 1
}
return 1 + buildRenderUnits(slide.blocks).length
}
export function getSlideRenderUnits(slide: PptSectionSlide): RenderUnit[] {
return buildRenderUnits(slide.blocks)
}
/** 将 Markdown 解析为引言块与按 H2 分节的幻灯片内容 */
export function parseMarkdownToSections(markdown: string): { intro: ContentBlock[]; sections: SlideSection[] } {
const tokens = getMarkdownParser().parse(markdown, {})
const intro: ContentBlock[] = []
const sections: SlideSection[] = []
let currentBlocks = intro
let currentSection: SlideSection | null = null
const startSection = (title: string) => {
currentSection = { title, blocks: [] }
sections.push(currentSection)
currentBlocks = currentSection.blocks
}
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i]
if (token.type === 'heading_open') {
const title = getHeadingText(tokens, i)
if (token.tag === 'h2') {
startSection(title || '章节')
} else if (token.tag === 'h1') {
pushBlock(currentBlocks, { type: 'h1', text: title })
} else if (token.tag === 'h3') {
pushBlock(currentBlocks, { type: 'h3', text: title })
}
i += 2
continue
}
if (token.type === 'paragraph_open') {
const inline = tokens[i + 1]
if (inline?.type === 'inline') {
pushInlineContent(currentBlocks, inline)
}
i += 2
continue
}
if (token.type === 'fence') {
const lang = token.info ? token.info.split(/\s+/g)[0] : undefined
pushBlock(currentBlocks, { type: 'code', text: token.content.trimEnd(), lang })
continue
}
if (token.type === 'bullet_list_open') {
const { items, endIdx } = parseListItems(tokens, i, 'bullet_list_close', currentBlocks)
if (items.length) pushBlock(currentBlocks, { type: 'list', ordered: false, items })
i = endIdx
continue
}
if (token.type === 'ordered_list_open') {
const { items, endIdx } = parseListItems(tokens, i, 'ordered_list_close', currentBlocks)
if (items.length) pushBlock(currentBlocks, { type: 'list', ordered: true, items })
i = endIdx
continue
}
if (token.type === 'blockquote_open') {
const quoteParts: string[] = []
for (let j = i + 1; j < tokens.length && tokens[j].type !== 'blockquote_close'; j++) {
if (tokens[j].type === 'inline') {
const { blocks: mediaBlocks, text } = extractMediaFromInline(tokens[j])
mediaBlocks.forEach((block) => pushBlock(currentBlocks, block))
if (text) quoteParts.push(text)
}
}
const quoteText = quoteParts.join('\n').trim()
if (quoteText) pushBlock(currentBlocks, { type: 'paragraph', text: `${quoteText}` })
continue
}
if (token.type === 'table_open') {
pushBlock(currentBlocks, { type: 'placeholder', text: '[表格内容]' })
continue
}
if (token.type === 'html_block') {
parseHtmlBlock(token.content).forEach((block) => pushBlock(currentBlocks, block))
continue
}
if (token.type === 'html_inline') {
parseInlineHtmlFragment(token.content).forEach((block) => pushBlock(currentBlocks, block))
continue
}
}
if (!sections.length && intro.length) {
sections.push({ title: '正文', blocks: [...intro] })
intro.length = 0
}
return { intro, sections }
}
/** 估算内容块在幻灯片上的高度 */
export function estimateBlockHeight(block: ContentBlock): number {
switch (block.type) {
case 'h1':
return 0.6
case 'h2':
return 0.5
case 'h3':
return 0.45
case 'paragraph':
return Math.min(2.5, Math.max(0.45, Math.ceil(block.text.length / 70) * 0.22))
case 'code':
return Math.min(3.2, Math.max(0.8, block.text.split('\n').length * 0.16 + 0.3))
case 'image':
case 'video':
return 4.2
case 'list':
return Math.min(3, Math.max(0.5, block.items.length * 0.28))
case 'placeholder':
return 0.4
default:
return 0.4
}
}
/** 判断是否为独立媒体块(用于 hero 大图布局) */
export function isStandaloneMediaBlock(block: ContentBlock): boolean {
return block.type === 'image' || block.type === 'video'
}
function formatContinuationTitle(title: string, continuation: number): string {
if (continuation <= 0) return title
if (continuation === 1) return `${title}(续)`
return `${title}(续${continuation}`
}
/** 将章节内容按高度分页为多张幻灯片 */
export function paginateSectionBlocks(section: SlideSection): PptSectionSlide[] {
if (!section.blocks.length) return []
const slides: PptSectionSlide[] = []
let currentBlocks: ContentBlock[] = []
let y = SECTION_HEADER_HEIGHT
let continuation = 0
const flushSlide = () => {
if (!currentBlocks.length) return
slides.push({
kind: 'section',
title: formatContinuationTitle(section.title, continuation),
blocks: currentBlocks,
continued: continuation > 0,
})
currentBlocks = []
y = SECTION_HEADER_HEIGHT
}
const startContinuation = () => {
flushSlide()
continuation += 1
}
const ensureSpace = (needed: number) => {
if (y + needed > PPT_SLIDE_BOTTOM && currentBlocks.length) {
startContinuation()
}
}
for (const block of section.blocks) {
ensureSpace(estimateBlockHeight(block))
currentBlocks.push(block)
switch (block.type) {
case 'h1':
y += 0.6
break
case 'h2':
y += 0.5
break
case 'h3':
y += 0.45
break
case 'paragraph':
y += estimateBlockHeight(block) + 0.1
break
case 'code':
y += estimateBlockHeight(block) + 0.35
break
case 'list':
y += estimateBlockHeight(block) + 0.1
break
case 'image':
case 'video':
y += 4.2
break
case 'placeholder':
y += 0.4
break
}
}
flushSlide()
return slides
}
function buildTitleSlide(post: Post): PptTitleSlide {
return {
kind: 'title',
title: post.title,
date: post.date,
categoryName: post.categoryName,
userName: post.userName,
tagNames: post.tags?.map((t) => t.name) || [],
}
}
export function buildPptSlides(post: Post): PptSlide[] {
const slides: PptSlide[] = [buildTitleSlide(post)]
const { intro, sections } = parseMarkdownToSections(post.content || '')
if (intro.length) {
slides.push(...paginateSectionBlocks({ title: '引言', blocks: intro }))
}
for (const section of sections) {
slides.push(...paginateSectionBlocks(section))
}
return slides
}
/** 解析媒体 URL相对路径转绝对路径 */
export function resolveImageUrl(src: string): string {
try {
return new URL(src, window.location.href).href
} catch {
return src
}
}
/** 判断点击/键盘事件是否发生在媒体交互区域 */
export function isMediaInteractionTarget(el: EventTarget | null): boolean {
if (!el || !(el instanceof HTMLElement)) return false
return Boolean(
el.closest('video, audio, .ppt-block-video-wrap, .ppt-block-image-wrap, [controls]')
)
}