From 00b7f0390c021d97f247081c168cc807e302905a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E7=90=A6?= Date: Sat, 11 Jul 2026 18:40:45 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E9=A1=B5=E9=9D=A2=E3=80=81?= =?UTF-8?q?=E4=BF=AE=E5=A4=8DBUG?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/components/ppt/PptSlideCanvas.vue | 17 +- client/src/styles/pptPreview.css | 8 + client/src/utils/articleExport.ts | 9 + client/src/utils/articlePptSlides.ts | 333 +++++++++++++++---- 4 files changed, 303 insertions(+), 64 deletions(-) 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