优化页面、修复BUG
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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 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
|
||||
|
||||
Reference in New Issue
Block a user