优化页面、修复BUG
This commit is contained in:
@@ -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, '&')
|
||||
@@ -138,10 +132,316 @@ function escapeHtml(str: string): string {
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 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 已渲染正文 HTML(PDF/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'
|
||||
|
||||
618
client/src/utils/articlePptSlides.ts
Normal file
618
client/src/utils/articlePptSlides.ts
Normal 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]')
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user