diff --git a/client/src/components/ppt/PptSlideCanvas.vue b/client/src/components/ppt/PptSlideCanvas.vue
index 7d8afa1..47acca0 100644
--- a/client/src/components/ppt/PptSlideCanvas.vue
+++ b/client/src/components/ppt/PptSlideCanvas.vue
@@ -109,6 +109,18 @@ const isHeroMedia = (unit: RenderUnit) => {
const isUnitVisible = (unitIdx: number) => showFull.value || unitIdx < props.visibleStep
+const sectionTitleParts = computed(() => {
+ if (props.slide.kind !== 'section') return { parent: '', title: '' }
+ const parts = props.slide.title.split(' · ')
+ if (parts.length >= 2) {
+ return {
+ parent: parts.slice(0, -1).join(' · '),
+ title: parts[parts.length - 1] ?? props.slide.title,
+ }
+ }
+ return { parent: '', title: props.slide.title }
+})
+
const stopMediaEvent = (e: Event) => {
e.stopPropagation()
}
@@ -143,7 +155,10 @@ const stopMediaEvent = (e: Event) => {
class="ppt-slide-section-header"
:class="{ 'ppt-slide-section-header--top': !isTitleFocus }"
>
-
{{ slide.title }}
+
+ {{ sectionTitleParts.parent }}
+
+ {{ sectionTitleParts.title }}
diff --git a/client/src/styles/pptPreview.css b/client/src/styles/pptPreview.css
index 415c734..e2e98f9 100644
--- a/client/src/styles/pptPreview.css
+++ b/client/src/styles/pptPreview.css
@@ -201,6 +201,14 @@
line-height: 1.8;
}
+.ppt-slide-section-parent {
+ font-size: clamp(0.7rem, 1.2vw, 0.875rem);
+ font-weight: 500;
+ color: var(--ppt-muted);
+ margin: 0 0 0.25rem;
+ line-height: 1.3;
+}
+
.ppt-slide-section-title {
font-size: clamp(1.125rem, 2.2vw, 1.75rem);
font-weight: 700;
diff --git a/client/src/utils/articleExport.ts b/client/src/utils/articleExport.ts
index 6c9136f..3db98b5 100644
--- a/client/src/utils/articleExport.ts
+++ b/client/src/utils/articleExport.ts
@@ -418,7 +418,16 @@ async function addSectionSlideToPptx(
const slide = pptx.addSlide()
let y = addSectionHeader(slide, sectionSlide.title)
+ const titleParts = sectionSlide.title.split(' · ')
+ const h2Title = titleParts.length >= 2 ? titleParts[titleParts.length - 1] : sectionSlide.title
+ let skipDuplicateH2 = true
+
for (const block of sectionSlide.blocks) {
+ if (skipDuplicateH2 && block.type === 'h2' && block.text === h2Title) {
+ skipDuplicateH2 = false
+ continue
+ }
+ skipDuplicateH2 = false
y = await addBlockToPptxSlide(slide, block, y, mediaWarning)
}
}
diff --git a/client/src/utils/articlePptSlides.ts b/client/src/utils/articlePptSlides.ts
index bf319d2..4026aca 100644
--- a/client/src/utils/articlePptSlides.ts
+++ b/client/src/utils/articlePptSlides.ts
@@ -314,6 +314,7 @@ export function parseHtmlBlock(html: string): ContentBlock[] {
const imgs = el.querySelectorAll('img')
const videos = el.querySelectorAll('video')
if (imgs.length || videos.length) {
+ const blockStartLen = blocks.length
imgs.forEach((img) => walkNode(img))
videos.forEach((video) => walkNode(video))
const textOnly = Array.from(el.childNodes)
@@ -321,7 +322,19 @@ export function parseHtmlBlock(html: string): ContentBlock[] {
.map((n) => n.textContent || '')
.join('')
.trim()
- if (textOnly) pushParagraph(textOnly)
+ if (textOnly) {
+ let lastImage: ImageBlock | undefined
+ for (let k = blocks.length - 1; k >= blockStartLen; k--) {
+ const candidate = blocks[k]
+ if (candidate.type === 'image') {
+ lastImage = candidate
+ break
+ }
+ }
+ if (!lastImage || !isImageCaptionLike(textOnly, lastImage)) {
+ pushParagraph(textOnly)
+ }
+ }
} else {
pushParagraph(el.textContent || '')
}
@@ -372,18 +385,196 @@ export function getSlideRenderUnits(slide: PptSectionSlide): RenderUnit[] {
return buildRenderUnits(slide.blocks)
}
-/** 将 Markdown 解析为引言块与按 H2 分节的幻灯片内容 */
-export function parseMarkdownToSections(markdown: string): { intro: ContentBlock[]; sections: SlideSection[] } {
+export interface H1Module {
+ title: string
+ h1Blocks: ContentBlock[]
+ sections: SlideSection[]
+}
+
+const MEDIA_PAGINATE_HEIGHT = 2.8
+const SHORT_PARAGRAPH_MAX = 80
+const BARE_IMAGE_FILENAME_RE = /^[\w\u4e00-\u9fa5.-]+\.(jpg|jpeg|png|gif|webp|svg|bmp)$/i
+
+function normalizeCaptionKey(text: string): string {
+ return text.trim().toLowerCase().replace(/\.(jpg|jpeg|png|gif|webp|svg|bmp)$/i, '')
+}
+
+function isBareImageFilename(text: string): boolean {
+ return BARE_IMAGE_FILENAME_RE.test(text.trim())
+}
+
+type ImageBlock = Extract
+
+function getImageBasename(src: string): string {
+ const filename = src.split(/[/\\?#]/).pop() || ''
+ return filename.replace(/\.(jpg|jpeg|png|gif|webp|svg|bmp)(?:\?.*)?$/i, '')
+}
+
+function getImageCaptionKeys(image: ImageBlock): Set {
+ const keys = new Set()
+ const add = (text: string) => {
+ const key = normalizeCaptionKey(text)
+ if (key) keys.add(key)
+ }
+ add(image.alt)
+ add(getImageBasename(image.src))
+ return keys
+}
+
+/** 判定段落/列表项是否为图片冗余 caption(含「标题 + 文件名」复合形式) */
+export function isImageCaptionLike(text: string, image: ImageBlock): boolean {
+ const trimmed = text.trim()
+ if (!trimmed) return false
+
+ const keys = getImageCaptionKeys(image)
+
+ if (isBareImageFilename(trimmed)) return true
+
+ const normalized = normalizeCaptionKey(trimmed)
+ if (keys.has(normalized)) return true
+
+ const tokens = trimmed.split(/\s+/).filter(Boolean)
+ if (tokens.length > 0) {
+ const tokenKeys = tokens.map((token) => normalizeCaptionKey(token))
+ if (tokenKeys.every((key) => keys.has(key))) return true
+
+ const uniqueKeys = new Set(tokenKeys.filter(Boolean))
+ if (uniqueKeys.size === 1 && keys.has([...uniqueKeys][0])) return true
+ }
+
+ return false
+}
+
+/** 移除与相邻图片 alt 重复的 caption 段落(含裸文件名与复合 caption) */
+export function dedupeImageCaptionBlocks(blocks: ContentBlock[]): ContentBlock[] {
+ const result: ContentBlock[] = []
+
+ for (let i = 0; i < blocks.length; i++) {
+ const block = blocks[i]
+ const prev = result[result.length - 1]
+
+ if (block.type === 'paragraph') {
+ const text = block.text.trim()
+ const next = blocks[i + 1]
+
+ if (prev?.type === 'image' && isImageCaptionLike(text, prev)) {
+ continue
+ }
+ if (next?.type === 'image' && isImageCaptionLike(text, next)) {
+ continue
+ }
+ result.push(block)
+ continue
+ }
+
+ if (block.type === 'list' && prev?.type === 'image') {
+ const items = block.items.filter((item) => !isImageCaptionLike(item, prev))
+ if (items.length) {
+ result.push({ ...block, items })
+ }
+ continue
+ }
+
+ result.push(block)
+ }
+
+ return result
+}
+
+export function buildSlideSectionTitle(moduleTitle: string, sectionTitle: string): string {
+ if (sectionTitle === moduleTitle) return moduleTitle
+ return `${moduleTitle} · ${sectionTitle}`
+}
+
+function isShortParagraph(block: ContentBlock | undefined): block is Extract {
+ return block?.type === 'paragraph' && block.text.length <= SHORT_PARAGRAPH_MAX
+}
+
+/** 分页用高度估算(媒体块低于渲染估算,允许与短说明同页) */
+function estimatePaginationHeight(block: ContentBlock, next?: ContentBlock): number {
+ if (block.type === 'image' || block.type === 'video') {
+ return isShortParagraph(next) ? MEDIA_PAGINATE_HEIGHT + 0.45 : MEDIA_PAGINATE_HEIGHT
+ }
+ return estimateBlockHeight(block)
+}
+
+function accumulateBlockHeight(y: number, block: ContentBlock, next?: ContentBlock): number {
+ switch (block.type) {
+ case 'h1':
+ return y + 0.6
+ case 'h2':
+ return y + 0.5
+ case 'h3':
+ return y + 0.45
+ case 'paragraph':
+ return y + estimateBlockHeight(block) + 0.1
+ case 'code':
+ return y + estimateBlockHeight(block) + 0.35
+ case 'list':
+ return y + estimateBlockHeight(block) + 0.1
+ case 'image':
+ case 'video':
+ return y + MEDIA_PAGINATE_HEIGHT + (isShortParagraph(next) ? 0.45 : 0)
+ case 'placeholder':
+ return y + 0.4
+ default:
+ return y + 0.4
+ }
+}
+
+function compactSparseSlides(slides: PptSectionSlide[], sectionTitle: string): PptSectionSlide[] {
+ if (slides.length <= 1) return slides
+
+ const merged: PptSectionSlide[] = []
+
+ for (const slide of slides) {
+ const contentBlocks = slide.blocks.filter(
+ (b) => b.type !== 'h1' && b.type !== 'h2' && b.type !== 'h3'
+ )
+ const totalHeight = slide.blocks.reduce((sum, b) => sum + estimateBlockHeight(b), 0)
+
+ const prev = merged[merged.length - 1]
+ if (prev && contentBlocks.length <= 1 && totalHeight < 1.2) {
+ prev.blocks.push(...slide.blocks)
+ continue
+ }
+
+ merged.push({ ...slide, blocks: [...slide.blocks] })
+ }
+
+ return merged.map((slide, idx) => ({
+ ...slide,
+ title: formatContinuationTitle(sectionTitle, idx),
+ continued: idx > 0,
+ }))
+}
+
+/** 按 H1 模块(# 到下一 #)+ 模块内 ## 小节解析 Markdown */
+export function parseMarkdownToH1Modules(markdown: string): H1Module[] {
const tokens = getMarkdownParser().parse(markdown, {})
- const intro: ContentBlock[] = []
- const sections: SlideSection[] = []
- let currentBlocks = intro
+ const modules: H1Module[] = []
+ let currentModule: H1Module | null = null
let currentSection: SlideSection | null = null
- const startSection = (title: string) => {
+ const startModule = (title: string) => {
+ currentModule = { title, h1Blocks: [], sections: [] }
+ modules.push(currentModule)
+ currentSection = null
+ }
+
+ const startSection = (title: string, withH2Block = false) => {
+ if (!currentModule) startModule('前言')
currentSection = { title, blocks: [] }
- sections.push(currentSection)
- currentBlocks = currentSection.blocks
+ currentModule!.sections.push(currentSection)
+ if (withH2Block && title) {
+ pushBlock(currentSection.blocks, { type: 'h2', text: title })
+ }
+ }
+
+ const getTargetBlocks = (): ContentBlock[] => {
+ if (!currentModule) startModule('前言')
+ if (!currentSection) startSection(currentModule!.title)
+ return currentSection!.blocks
}
for (let i = 0; i < tokens.length; i++) {
@@ -391,12 +582,15 @@ export function parseMarkdownToSections(markdown: string): { intro: ContentBlock
if (token.type === 'heading_open') {
const title = getHeadingText(tokens, i)
- if (token.tag === 'h2') {
+ if (token.tag === 'h1') {
+ startModule(title || '章节')
+ pushBlock(currentModule!.h1Blocks, { type: 'h1', text: title })
startSection(title || '章节')
- } else if (token.tag === 'h1') {
- pushBlock(currentBlocks, { type: 'h1', text: title })
+ } else if (token.tag === 'h2') {
+ if (!currentModule) startModule('前言')
+ startSection(title || '小节', true)
} else if (token.tag === 'h3') {
- pushBlock(currentBlocks, { type: 'h3', text: title })
+ pushBlock(getTargetBlocks(), { type: 'h3', text: title })
}
i += 2
continue
@@ -405,7 +599,7 @@ export function parseMarkdownToSections(markdown: string): { intro: ContentBlock
if (token.type === 'paragraph_open') {
const inline = tokens[i + 1]
if (inline?.type === 'inline') {
- pushInlineContent(currentBlocks, inline)
+ pushInlineContent(getTargetBlocks(), inline)
}
i += 2
continue
@@ -413,54 +607,86 @@ export function parseMarkdownToSections(markdown: string): { intro: ContentBlock
if (token.type === 'fence') {
const lang = token.info ? token.info.split(/\s+/g)[0] : undefined
- pushBlock(currentBlocks, { type: 'code', text: token.content.trimEnd(), lang })
+ pushBlock(getTargetBlocks(), { 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 })
+ const target = getTargetBlocks()
+ const { items, endIdx } = parseListItems(tokens, i, 'bullet_list_close', target)
+ if (items.length) pushBlock(target, { 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 })
+ const target = getTargetBlocks()
+ const { items, endIdx } = parseListItems(tokens, i, 'ordered_list_close', target)
+ if (items.length) pushBlock(target, { type: 'list', ordered: true, items })
i = endIdx
continue
}
if (token.type === 'blockquote_open') {
+ const target = getTargetBlocks()
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))
+ mediaBlocks.forEach((block) => pushBlock(target, block))
if (text) quoteParts.push(text)
}
}
const quoteText = quoteParts.join('\n').trim()
- if (quoteText) pushBlock(currentBlocks, { type: 'paragraph', text: `「${quoteText}」` })
+ if (quoteText) pushBlock(target, { type: 'paragraph', text: `「${quoteText}」` })
continue
}
if (token.type === 'table_open') {
- pushBlock(currentBlocks, { type: 'placeholder', text: '[表格内容]' })
+ pushBlock(getTargetBlocks(), { type: 'placeholder', text: '[表格内容]' })
continue
}
if (token.type === 'html_block') {
- parseHtmlBlock(token.content).forEach((block) => pushBlock(currentBlocks, block))
+ parseHtmlBlock(token.content).forEach((block) => pushBlock(getTargetBlocks(), block))
continue
}
if (token.type === 'html_inline') {
- parseInlineHtmlFragment(token.content).forEach((block) => pushBlock(currentBlocks, block))
+ parseInlineHtmlFragment(token.content).forEach((block) => pushBlock(getTargetBlocks(), block))
continue
}
}
+ return modules
+ .map((module) => ({
+ ...module,
+ sections: module.sections.filter((s) => s.blocks.length > 0),
+ }))
+ .filter((module) => module.sections.length > 0 || module.h1Blocks.length > 0)
+}
+
+/** @deprecated 使用 parseMarkdownToH1Modules */
+export function parseMarkdownToSections(markdown: string): { intro: ContentBlock[]; sections: SlideSection[] } {
+ const modules = parseMarkdownToH1Modules(markdown)
+ const intro: ContentBlock[] = []
+ const sections: SlideSection[] = []
+
+ for (const module of modules) {
+ if (module.title === '前言' && modules[0] === module) {
+ for (const section of module.sections) {
+ intro.push(...section.blocks)
+ }
+ continue
+ }
+ for (const section of module.sections) {
+ sections.push({
+ title: buildSlideSectionTitle(module.title, section.title),
+ blocks: [...section.blocks],
+ })
+ }
+ }
+
if (!sections.length && intro.length) {
sections.push({ title: '正文', blocks: [...intro] })
intro.length = 0
@@ -484,7 +710,7 @@ export function estimateBlockHeight(block: ContentBlock): number {
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
+ return MEDIA_PAGINATE_HEIGHT
case 'list':
return Math.min(3, Math.max(0.5, block.items.length * 0.28))
case 'placeholder':
@@ -507,7 +733,8 @@ function formatContinuationTitle(title: string, continuation: number): string {
/** 将章节内容按高度分页为多张幻灯片 */
export function paginateSectionBlocks(section: SlideSection): PptSectionSlide[] {
- if (!section.blocks.length) return []
+ const blocks = dedupeImageCaptionBlocks(section.blocks)
+ if (!blocks.length) return []
const slides: PptSectionSlide[] = []
let currentBlocks: ContentBlock[] = []
@@ -537,41 +764,16 @@ export function paginateSectionBlocks(section: SlideSection): PptSectionSlide[]
}
}
- for (const block of section.blocks) {
- ensureSpace(estimateBlockHeight(block))
+ for (let bi = 0; bi < blocks.length; bi++) {
+ const block = blocks[bi]
+ const next = blocks[bi + 1]
+ ensureSpace(estimatePaginationHeight(block, next))
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
- }
+ y = accumulateBlockHeight(y, block, next)
}
flushSlide()
- return slides
+ return compactSparseSlides(slides, section.title)
}
function buildTitleSlide(post: Post): PptTitleSlide {
@@ -587,14 +789,19 @@ function buildTitleSlide(post: Post): PptTitleSlide {
export function buildPptSlides(post: Post): PptSlide[] {
const slides: PptSlide[] = [buildTitleSlide(post)]
- const { intro, sections } = parseMarkdownToSections(post.content || '')
+ const modules = parseMarkdownToH1Modules(post.content || '')
- if (intro.length) {
- slides.push(...paginateSectionBlocks({ title: '引言', blocks: intro }))
- }
+ for (const module of modules) {
+ for (let si = 0; si < module.sections.length; si++) {
+ const section = module.sections[si]
+ const blocks: ContentBlock[] = [...section.blocks]
+ if (si === 0 && module.h1Blocks.length) {
+ blocks.unshift(...module.h1Blocks)
+ }
- for (const section of sections) {
- slides.push(...paginateSectionBlocks(section))
+ const title = buildSlideSectionTitle(module.title, section.title)
+ slides.push(...paginateSectionBlocks({ title, blocks }))
+ }
}
return slides