@@ -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 )
current Blocks = currentSection . blocks
if ( withH2 Block && 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 ( curren tBlocks, { type : 'h3' , text : title } )
pushBlock ( getTarge tBlocks( ) , { 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 ( curren tBlocks, inline )
pushInlineContent ( getTarge tBlocks( ) , 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 ( curren tBlocks, { type : 'code' , text : token.content.trimEnd ( ) , lang } )
pushBlock ( getTarge tBlocks( ) , { 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' , curren tBlocks)
const target = getTarge tBlocks( )
if ( items . length ) pushBlock ( currentBlocks , { type : 'list' , ordered : fal se, items } )
const { items , endIdx } = parseListItems ( tokens , i , 'bullet_list_clo se' , 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' , curren tBlocks)
const target = getTarge tBlocks( )
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 ( curren tBlocks, { type : 'placeholder' , text : '[表格内容]' } )
pushBlock ( getTarge tBlocks( ) , { type : 'placeholder' , text : '[表格内容]' } )
continue
continue
}
}
if ( token . type === 'html_block' ) {
if ( token . type === 'html_block' ) {
parseHtmlBlock ( token . content ) . forEach ( ( block ) = > pushBlock ( curren tBlocks, block ) )
parseHtmlBlock ( token . content ) . forEach ( ( block ) = > pushBlock ( getTarge tBlocks( ) , block ) )
continue
continue
}
}
if ( token . type === 'html_inline' ) {
if ( token . type === 'html_inline' ) {
parseInlineHtmlFragment ( token . content ) . forEach ( ( block ) = > pushBlock ( curren tBlocks, block ) )
parseInlineHtmlFragment ( token . content ) . forEach ( ( block ) = > pushBlock ( getTarge tBlocks( ) , 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 } = parseMarkdownToSection s ( post . content || '' )
const modules = parseMarkdownToH1Module s ( post . content || '' )
i f ( 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