优化页面、修复BUG

This commit is contained in:
李琦
2026-07-11 18:40:45 +08:00
parent 45ec3a0a31
commit 00b7f0390c
4 changed files with 303 additions and 64 deletions

View File

@@ -109,6 +109,18 @@ const isHeroMedia = (unit: RenderUnit) => {
const isUnitVisible = (unitIdx: number) => showFull.value || unitIdx < props.visibleStep 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) => { const stopMediaEvent = (e: Event) => {
e.stopPropagation() e.stopPropagation()
} }
@@ -143,7 +155,10 @@ const stopMediaEvent = (e: Event) => {
class="ppt-slide-section-header" class="ppt-slide-section-header"
:class="{ 'ppt-slide-section-header--top': !isTitleFocus }" :class="{ 'ppt-slide-section-header--top': !isTitleFocus }"
> >
<h2 class="ppt-slide-section-title">{{ slide.title }}</h2> <p v-if="sectionTitleParts.parent" class="ppt-slide-section-parent">
{{ sectionTitleParts.parent }}
</p>
<h2 class="ppt-slide-section-title">{{ sectionTitleParts.title }}</h2>
<Transition name="ppt-reveal"> <Transition name="ppt-reveal">
<div v-if="!isTitleFocus" class="ppt-slide-section-accent"></div> <div v-if="!isTitleFocus" class="ppt-slide-section-accent"></div>
</Transition> </Transition>

View File

@@ -201,6 +201,14 @@
line-height: 1.8; 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 { .ppt-slide-section-title {
font-size: clamp(1.125rem, 2.2vw, 1.75rem); font-size: clamp(1.125rem, 2.2vw, 1.75rem);
font-weight: 700; font-weight: 700;

View File

@@ -418,7 +418,16 @@ async function addSectionSlideToPptx(
const slide = pptx.addSlide() const slide = pptx.addSlide()
let y = addSectionHeader(slide, sectionSlide.title) 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) { 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) y = await addBlockToPptxSlide(slide, block, y, mediaWarning)
} }
} }

View File

@@ -314,6 +314,7 @@ export function parseHtmlBlock(html: string): ContentBlock[] {
const imgs = el.querySelectorAll('img') const imgs = el.querySelectorAll('img')
const videos = el.querySelectorAll('video') const videos = el.querySelectorAll('video')
if (imgs.length || videos.length) { if (imgs.length || videos.length) {
const blockStartLen = blocks.length
imgs.forEach((img) => walkNode(img)) imgs.forEach((img) => walkNode(img))
videos.forEach((video) => walkNode(video)) videos.forEach((video) => walkNode(video))
const textOnly = Array.from(el.childNodes) const textOnly = Array.from(el.childNodes)
@@ -321,7 +322,19 @@ export function parseHtmlBlock(html: string): ContentBlock[] {
.map((n) => n.textContent || '') .map((n) => n.textContent || '')
.join('') .join('')
.trim() .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 { } else {
pushParagraph(el.textContent || '') pushParagraph(el.textContent || '')
} }
@@ -372,18 +385,196 @@ export function getSlideRenderUnits(slide: PptSectionSlide): RenderUnit[] {
return buildRenderUnits(slide.blocks) return buildRenderUnits(slide.blocks)
} }
/** 将 Markdown 解析为引言块与按 H2 分节的幻灯片内容 */ export interface H1Module {
export function parseMarkdownToSections(markdown: string): { intro: ContentBlock[]; sections: SlideSection[] } { 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<ContentBlock, { type: 'image' }>
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<string> {
const keys = new Set<string>()
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<ContentBlock, { type: 'paragraph' }> {
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 tokens = getMarkdownParser().parse(markdown, {})
const intro: ContentBlock[] = [] const modules: H1Module[] = []
const sections: SlideSection[] = [] let currentModule: H1Module | null = null
let currentBlocks = intro
let currentSection: SlideSection | 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: [] } currentSection = { title, blocks: [] }
sections.push(currentSection) currentModule!.sections.push(currentSection)
currentBlocks = currentSection.blocks 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++) { for (let i = 0; i < tokens.length; i++) {
@@ -391,12 +582,15 @@ export function parseMarkdownToSections(markdown: string): { intro: ContentBlock
if (token.type === 'heading_open') { if (token.type === 'heading_open') {
const title = getHeadingText(tokens, i) const title = getHeadingText(tokens, i)
if (token.tag === 'h2') { if (token.tag === 'h1') {
startModule(title || '章节')
pushBlock(currentModule!.h1Blocks, { type: 'h1', text: title })
startSection(title || '章节') startSection(title || '章节')
} else if (token.tag === 'h1') { } else if (token.tag === 'h2') {
pushBlock(currentBlocks, { type: 'h1', text: title }) if (!currentModule) startModule('前言')
startSection(title || '小节', true)
} else if (token.tag === 'h3') { } else if (token.tag === 'h3') {
pushBlock(currentBlocks, { type: 'h3', text: title }) pushBlock(getTargetBlocks(), { type: 'h3', text: title })
} }
i += 2 i += 2
continue continue
@@ -405,7 +599,7 @@ export function parseMarkdownToSections(markdown: string): { intro: ContentBlock
if (token.type === 'paragraph_open') { if (token.type === 'paragraph_open') {
const inline = tokens[i + 1] const inline = tokens[i + 1]
if (inline?.type === 'inline') { if (inline?.type === 'inline') {
pushInlineContent(currentBlocks, inline) pushInlineContent(getTargetBlocks(), inline)
} }
i += 2 i += 2
continue continue
@@ -413,54 +607,86 @@ export function parseMarkdownToSections(markdown: string): { intro: ContentBlock
if (token.type === 'fence') { if (token.type === 'fence') {
const lang = token.info ? token.info.split(/\s+/g)[0] : undefined 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 continue
} }
if (token.type === 'bullet_list_open') { if (token.type === 'bullet_list_open') {
const { items, endIdx } = parseListItems(tokens, i, 'bullet_list_close', currentBlocks) const target = getTargetBlocks()
if (items.length) pushBlock(currentBlocks, { type: 'list', ordered: false, items }) const { items, endIdx } = parseListItems(tokens, i, 'bullet_list_close', target)
if (items.length) pushBlock(target, { type: 'list', ordered: false, items })
i = endIdx i = endIdx
continue continue
} }
if (token.type === 'ordered_list_open') { if (token.type === 'ordered_list_open') {
const { items, endIdx } = parseListItems(tokens, i, 'ordered_list_close', currentBlocks) const target = getTargetBlocks()
if (items.length) pushBlock(currentBlocks, { type: 'list', ordered: true, items }) const { items, endIdx } = parseListItems(tokens, i, 'ordered_list_close', target)
if (items.length) pushBlock(target, { type: 'list', ordered: true, items })
i = endIdx i = endIdx
continue continue
} }
if (token.type === 'blockquote_open') { if (token.type === 'blockquote_open') {
const target = getTargetBlocks()
const quoteParts: string[] = [] const quoteParts: string[] = []
for (let j = i + 1; j < tokens.length && tokens[j].type !== 'blockquote_close'; j++) { for (let j = i + 1; j < tokens.length && tokens[j].type !== 'blockquote_close'; j++) {
if (tokens[j].type === 'inline') { if (tokens[j].type === 'inline') {
const { blocks: mediaBlocks, text } = extractMediaFromInline(tokens[j]) const { blocks: mediaBlocks, text } = extractMediaFromInline(tokens[j])
mediaBlocks.forEach((block) => pushBlock(currentBlocks, block)) mediaBlocks.forEach((block) => pushBlock(target, block))
if (text) quoteParts.push(text) if (text) quoteParts.push(text)
} }
} }
const quoteText = quoteParts.join('\n').trim() const quoteText = quoteParts.join('\n').trim()
if (quoteText) pushBlock(currentBlocks, { type: 'paragraph', text: `${quoteText}` }) if (quoteText) pushBlock(target, { type: 'paragraph', text: `${quoteText}` })
continue continue
} }
if (token.type === 'table_open') { if (token.type === 'table_open') {
pushBlock(currentBlocks, { type: 'placeholder', text: '[表格内容]' }) pushBlock(getTargetBlocks(), { type: 'placeholder', text: '[表格内容]' })
continue continue
} }
if (token.type === 'html_block') { if (token.type === 'html_block') {
parseHtmlBlock(token.content).forEach((block) => pushBlock(currentBlocks, block)) parseHtmlBlock(token.content).forEach((block) => pushBlock(getTargetBlocks(), block))
continue continue
} }
if (token.type === 'html_inline') { if (token.type === 'html_inline') {
parseInlineHtmlFragment(token.content).forEach((block) => pushBlock(currentBlocks, block)) parseInlineHtmlFragment(token.content).forEach((block) => pushBlock(getTargetBlocks(), block))
continue 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) { if (!sections.length && intro.length) {
sections.push({ title: '正文', blocks: [...intro] }) sections.push({ title: '正文', blocks: [...intro] })
intro.length = 0 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)) return Math.min(3.2, Math.max(0.8, block.text.split('\n').length * 0.16 + 0.3))
case 'image': case 'image':
case 'video': case 'video':
return 4.2 return MEDIA_PAGINATE_HEIGHT
case 'list': case 'list':
return Math.min(3, Math.max(0.5, block.items.length * 0.28)) return Math.min(3, Math.max(0.5, block.items.length * 0.28))
case 'placeholder': case 'placeholder':
@@ -507,7 +733,8 @@ function formatContinuationTitle(title: string, continuation: number): string {
/** 将章节内容按高度分页为多张幻灯片 */ /** 将章节内容按高度分页为多张幻灯片 */
export function paginateSectionBlocks(section: SlideSection): PptSectionSlide[] { export function paginateSectionBlocks(section: SlideSection): PptSectionSlide[] {
if (!section.blocks.length) return [] const blocks = dedupeImageCaptionBlocks(section.blocks)
if (!blocks.length) return []
const slides: PptSectionSlide[] = [] const slides: PptSectionSlide[] = []
let currentBlocks: ContentBlock[] = [] let currentBlocks: ContentBlock[] = []
@@ -537,41 +764,16 @@ export function paginateSectionBlocks(section: SlideSection): PptSectionSlide[]
} }
} }
for (const block of section.blocks) { for (let bi = 0; bi < blocks.length; bi++) {
ensureSpace(estimateBlockHeight(block)) const block = blocks[bi]
const next = blocks[bi + 1]
ensureSpace(estimatePaginationHeight(block, next))
currentBlocks.push(block) currentBlocks.push(block)
y = accumulateBlockHeight(y, block, next)
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() flushSlide()
return slides return compactSparseSlides(slides, section.title)
} }
function buildTitleSlide(post: Post): PptTitleSlide { function buildTitleSlide(post: Post): PptTitleSlide {
@@ -587,14 +789,19 @@ function buildTitleSlide(post: Post): PptTitleSlide {
export function buildPptSlides(post: Post): PptSlide[] { export function buildPptSlides(post: Post): PptSlide[] {
const slides: PptSlide[] = [buildTitleSlide(post)] const slides: PptSlide[] = [buildTitleSlide(post)]
const { intro, sections } = parseMarkdownToSections(post.content || '') const modules = parseMarkdownToH1Modules(post.content || '')
if (intro.length) { for (const module of modules) {
slides.push(...paginateSectionBlocks({ title: '引言', blocks: intro })) 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) { const title = buildSlideSectionTitle(module.title, section.title)
slides.push(...paginateSectionBlocks(section)) slides.push(...paginateSectionBlocks({ title, blocks }))
}
} }
return slides return slides