import { readFile } from 'node:fs/promises' import { parseSync } from '@slidev/parser' import type { DeckMetadata } from './content.ts' export type StaticOverflowDirection = 'horizontal' | 'vertical' export interface StaticOverflowIssue { deck: DeckMetadata directions: StaticOverflowDirection[] heading: string reason: string slide: number } export interface StaticOverflowResult { errors: Array<{ deck: DeckMetadata, message: string }> issues: StaticOverflowIssue[] } interface StaticOverflowAuditorOptions { onAnalyzeSlide?: (deck: DeckMetadata, slide: number) => void } interface CachedSlide { fingerprint: string issues: StaticOverflowIssue[] } interface RegionEstimate { height: number horizontalRisk: boolean } interface SlideSource { content: string frontmatter: Record revision?: string title?: string } const canvasHeight = 768 const canvasWidth = 1024 const slidePaddingX = 52 const slidePaddingY = 42 const contentHeight = canvasHeight - slidePaddingY * 2 const contentWidth = canvasWidth - slidePaddingX * 2 const twoColumnGap = 46 const baseFontSize = 32 const baseLineHeight = baseFontSize * 1.42 const verticalRiskTolerance = 1.08 const codeHeightPresets = new Map([ ['easy-code-height-sm', 180], ['easy-code-height-md', 300], ['easy-code-height-lg', 420], ]) export class StaticOverflowAuditor { private cache = new Map>() private onAnalyzeSlide?: StaticOverflowAuditorOptions['onAnalyzeSlide'] constructor(options: StaticOverflowAuditorOptions = {}) { this.onAnalyzeSlide = options.onAnalyzeSlide } async audit(decks: DeckMetadata[]): Promise { const result: StaticOverflowResult = { errors: [], issues: [] } for (const deck of decks) { try { result.issues.push(...await this.auditDeck(deck)) } catch (error) { result.errors.push({ deck, message: error instanceof Error ? error.message : String(error), }) } } return result } private async auditDeck(deck: DeckMetadata) { const source = await readFile(deck.entry, 'utf8') const parsed = parseSync(source, deck.entry) const previous = this.cache.get(deck.entry) ?? new Map() const next = new Map() const issues: StaticOverflowIssue[] = [] parsed.slides.forEach((slide, index) => { const number = index + 1 const fingerprint = slide.revision ?? slide.raw const cached = previous.get(number) if (cached?.fingerprint === fingerprint) { next.set(number, cached) issues.push(...cached.issues) return } this.onAnalyzeSlide?.(deck, number) const slideIssues = analyzeSlide(deck, number, slide) next.set(number, { fingerprint, issues: slideIssues }) issues.push(...slideIssues) }) this.cache.set(deck.entry, next) return issues } } export async function auditStaticDecks(decks: DeckMetadata[]) { return await new StaticOverflowAuditor().audit(decks) } function analyzeSlide(deck: DeckMetadata, number: number, slide: SlideSource): StaticOverflowIssue[] { const content = stripComments(slide.content) const layout = String(slide.frontmatter.layout ?? (number === 1 ? 'cover' : 'default')) const classNames = normalizeClasses(slide.frontmatter.class) const heading = slide.title?.trim() || extractHeading(content) const estimates = estimateSlide(content, layout, classNames, String(slide.frontmatter.imagePlacement ?? 'auto')) const directions: StaticOverflowDirection[] = [] if (estimates.some(estimate => estimate.height > contentHeight * verticalRiskTolerance)) directions.push('vertical') if (estimates.some(estimate => estimate.horizontalRisk)) directions.push('horizontal') if (!directions.length) return [] const reason = directions.length === 2 ? '估算内容高度超过画布容量,且存在无法可靠换行的宽内容' : directions[0] === 'vertical' ? '估算内容高度超过 easy-jyy 画布容量' : '存在无法可靠换行的宽内容' return [{ deck, directions, heading, reason, slide: number }] } function estimateSlide(content: string, layout: string, classNames: string[], imagePlacement: string) { if (layout === 'two-cols') { const slots = splitTwoColumnContent(content) const headingEstimate = estimateRegion(slots.before, contentWidth, layout, classNames) const columnWidth = (contentWidth - twoColumnGap) / 2 const left = estimateRegion(slots.left, columnWidth, layout, classNames) const right = estimateRegion(slots.right, columnWidth, layout, classNames) return [ { height: headingEstimate.height + Math.max(left.height, right.height), horizontalRisk: headingEstimate.horizontalRisk || left.horizontalRisk || right.horizontalRisk, }, ] } const splitImageLayout = ['image-left', 'image-right'].includes(layout) || (layout === 'image-auto' && ['left', 'right'].includes(imagePlacement)) const width = splitImageLayout ? (contentWidth - 42) / 2 : contentWidth return [estimateRegion(content, width, layout, classNames)] } function splitTwoColumnContent(content: string) { const leftMarker = /^::left::\s*$/m const rightMarker = /^::right::\s*$/m const leftMatch = leftMarker.exec(content) const rightMatch = rightMarker.exec(content) if (!leftMatch && !rightMatch) return { before: content, left: '', right: '' } const firstIndex = Math.min(leftMatch?.index ?? Number.POSITIVE_INFINITY, rightMatch?.index ?? Number.POSITIVE_INFINITY) const before = content.slice(0, firstIndex) const left = leftMatch ? content.slice(leftMatch.index + leftMatch[0].length, rightMatch && rightMatch.index > leftMatch.index ? rightMatch.index : undefined) : '' const right = rightMatch ? content.slice(rightMatch.index + rightMatch[0].length, leftMatch && leftMatch.index > rightMatch.index ? leftMatch.index : undefined) : '' return { before, left, right } } function estimateRegion(markdown: string, width: number, layout: string, classNames: string[]): RegionEstimate { const lines = markdown.replace(/\r/g, '').split('\n') let height = 0 let horizontalRisk = false for (let index = 0; index < lines.length;) { const raw = lines[index] const line = raw.trim() if (!line || isStructuralLine(line)) { index++ continue } const fence = /^(?:`{3,}|~{3,})([^\s{]*)/.exec(line) if (fence) { const marker = line.startsWith('`') ? '`' : '~' const markerLength = line.match(new RegExp(`^\\${marker}+`))?.[0].length ?? 3 const language = fence[1].toLowerCase() let end = index + 1 while (end < lines.length && !new RegExp(`^\\s*\\${marker}{${markerLength},}`).test(lines[end])) end++ if (language === 'mermaid') height += 180 else height += explicitCodeHeight(lines, index, classNames) index = Math.min(lines.length, end + 1) continue } const heading = /^(#{1,4})\s+(.+)$/.exec(line) if (heading) { const level = heading[1].length const fontSize = level === 1 ? (layout === 'cover' ? 68 : 60) : level === 2 ? 48 : level === 3 ? 38 : 32 const wraps = wrappedLines(heading[2], width, fontSize) const margins = level === 1 ? 26 : level === 2 ? 39 : level === 3 ? 34 : 24 height += wraps * fontSize * 1.15 + margins horizontalRisk ||= hasHorizontalRisk(heading[2], width, fontSize) index++ continue } if (line === '$$' || line.startsWith('$$')) { let end = index + 1 while (end < lines.length && !lines[end].includes('$$')) end++ height += 82 + Math.max(0, end - index - 2) * 38 index = Math.min(lines.length, end + 1) continue } if (isStandaloneImage(line)) { height += canvasHeight * 0.46 + 16 index++ continue } if (isTableRow(line) && isTableSeparator(lines[index + 1] ?? '')) { const tableLines: string[] = [line] index += 2 while (index < lines.length && isTableRow(lines[index].trim())) { tableLines.push(lines[index].trim()) index++ } const columnCount = Math.max(1, splitTableRow(tableLines[0]).length) const cellWidth = width / columnCount - 28 for (const row of tableLines) { const rowWraps = Math.max(...splitTableRow(row).map(cell => wrappedLines(cell, cellWidth, 30)), 1) height += rowWraps * 30 * 1.42 + 20 } continue } if (isListLine(line)) { const items: string[] = [] while (index < lines.length && (isListLine(lines[index].trim()) || /^\s{2,}\S/.test(lines[index]))) { if (isListLine(lines[index].trim())) items.push(lines[index].trim().replace(/^(?:[-+*]|\d+[.)])\s+/, '')) index++ } const itemHeight = items.reduce((sum, item) => sum + wrappedLines(item, width - baseFontSize * 1.2, baseFontSize) * baseLineHeight, 0) height += itemHeight + Math.max(0, items.length - 1) * 10 + 28 horizontalRisk ||= items.some(item => hasHorizontalRisk(item, width - baseFontSize * 1.2, baseFontSize)) continue } if (line.startsWith('>')) { const quote: string[] = [] while (index < lines.length && lines[index].trim().startsWith('>')) { quote.push(lines[index].trim().replace(/^>\s?/, '')) index++ } const fontSize = layout === 'quote' ? 46 : baseFontSize height += wrappedLines(quote.join(' '), width - 29, fontSize) * fontSize * (layout === 'quote' ? 1.35 : 1.42) + 48 horizontalRisk ||= hasHorizontalRisk(quote.join(' '), width - 29, fontSize) continue } const paragraph: string[] = [] while (index < lines.length && !isBlockStart(lines, index)) { const text = stripMarkup(lines[index]) if (text) paragraph.push(text) index++ } if (paragraph.length) { const text = paragraph.join(' ') height += wrappedLines(text, width, baseFontSize) * baseLineHeight + 28 horizontalRisk ||= hasHorizontalRisk(text, width, baseFontSize) } else { index++ } } horizontalRisk ||= explicitWidthRisk(markdown, width) return { height, horizontalRisk } } function isBlockStart(lines: string[], index: number) { const line = lines[index].trim() if (!line || isStructuralLine(line) || /^(?:`{3,}|~{3,}|#{1,4}\s|\$\$|>)/.test(line)) return true if (isStandaloneImage(line) || isListLine(line)) return true return isTableRow(line) && isTableSeparator(lines[index + 1] ?? '') } function explicitCodeHeight(lines: string[], fenceIndex: number, classNames: string[]) { const nearby = `${classNames.join(' ')} ${lines.slice(Math.max(0, fenceIndex - 8), fenceIndex + 1).join(' ')}` for (const [className, height] of codeHeightPresets) { if (new RegExp(`(?:^|\\s|["'])${className}(?:$|\\s|["'])`).test(nearby)) return height } const inlineHeight = /(?:height|max-height)\s*:\s*(\d+(?:\.\d+)?)px/i.exec(nearby) return inlineHeight ? Number.parseFloat(inlineHeight[1]) : 0 } function wrappedLines(value: string, width: number, fontSize: number) { const available = Math.max(1, width / fontSize) return Math.max(1, Math.ceil(displayWidth(stripMarkup(value)) / available)) } function displayWidth(value: string) { let width = 0 for (const character of value) { if (/\s/.test(character)) width += 0.33 else if (/[\u1100-\u115f\u2e80-\ua4cf\uac00-\ud7a3\uf900-\ufaff\ufe10-\ufe6f\uff01-\uff60\uffe0-\uffe6]/.test(character)) width += 1 else width += 0.56 } return width } function hasHorizontalRisk(value: string, width: number, fontSize: number) { const available = width / fontSize return stripMarkup(value) .split(/\s+/) .some(token => !/[\u2e80-\u9fff]/.test(token) && displayWidth(token) > available * 1.05) } function explicitWidthRisk(markdown: string, width: number) { for (const match of markdown.matchAll(/(?:width\s*:\s*|width\s*=\s*["']?)(\d+(?:\.\d+)?)px/gi)) { if (Number.parseFloat(match[1]) > width) return true } return false } function normalizeClasses(value: unknown) { if (Array.isArray(value)) return value.flatMap(item => String(item).split(/\s+/)).filter(Boolean) return String(value ?? '').split(/\s+/).filter(Boolean) } function extractHeading(markdown: string) { return /^(?:#{1,4})\s+(.+)$/m.exec(markdown)?.[1].trim() ?? '' } function stripComments(markdown: string) { return markdown.replace(//g, '') } function stripMarkup(value: string) { return value .replace(/!\[[^\]]*\]\([^)]*\)(?:\{[^}]*\})?/g, '') .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') .replace(/<[^>]+>/g, ' ') .replace(/[`*_~]/g, '') .trim() } function isStructuralLine(line: string) { return /^::(?:left|right|image)::$/i.test(line) || /^<\/?(?:v-clicks|div|span|template|slot)(?:\s[^>]*)?>$/i.test(line) } function isStandaloneImage(line: string) { return /^!\[[^\]]*\]\([^)]*\)(?:\{[^}]*\})?$/.test(line) } function isListLine(line: string) { return /^(?:[-+*]|\d+[.)])\s+/.test(line) } function isTableRow(line: string) { return line.includes('|') } function isTableSeparator(line: string) { return /^\s*\|?\s*:?-{3,}:?(?:\s*\|\s*:?-{3,}:?)+\s*\|?\s*$/.test(line) } function splitTableRow(line: string) { return line.replace(/^\s*\||\|\s*$/g, '').split('|').map(cell => cell.trim()) }