返回 oh-my-ppt
html-utils.ts
根目录 / src / main / presentation / html / html-utils.ts
1 import * as cheerio from 'cheerio'
2 import {
3 SHARED_PAGE_STYLES_END,
4 SHARED_PAGE_STYLES_START,
5 pageContentEndMarker,
6 pageContentStartMarker
7 } from './page-contract'
8 import { validateDataAnimContract } from '../../animation/data-anim-validator'
9 import {
10 CHART_SKILL_NAME,
11 DATA_ANIM_SKILL_NAME,
12 formatSkillUsageRequirement
13 } from '../../product-skills/contract'
14 import {
15 CHART_FRAME_HEIGHT_COMMENT_MARKER,
16 parseChartHeightClass,
17 resolveChartHeightFromNearbyComment
18 } from './chart-height'
19
20 // ── HTML parsing ──
21
22 export const extractBodyHtml = (html: string): string => {
23 const $ = cheerio.load(html, { scriptingEnabled: false })
24 $('script').remove()
25 const bodyHtml = $('body').html()
26 return (bodyHtml || '').trim()
27 }
28
29 export const extractStyleCss = (html: string): string =>
30 (html.match(/<style[^>]*>([\s\S]*?)<\/style>/i)?.[1] || '').trim()
31
32 export const normalizePageCss = (css: string): string =>
33 css
34 .replace(/body\s*\{/g, '.ppt-page-root {')
35 .replace(/\s+$/g, '')
36 .trim()
37
38 export const unwrapCss = (input: string): string => {
39 const styleMatch = input.match(/<style[^>]*>([\s\S]*?)<\/style>/i)
40 return normalizePageCss((styleMatch?.[1] || input).trim())
41 }
42
43 // ── Marker-based replacement ──
44
45 export const replaceBetweenMarkers = (
46 source: string,
47 startMarker: string,
48 endMarker: string,
49 replacement: string
50 ): string | null => {
51 const startIndex = source.indexOf(startMarker)
52 const endIndex = source.indexOf(endMarker)
53 if (startIndex < 0 || endIndex < 0 || endIndex < startIndex) {
54 return null // marker block not found, caller should handle
55 }
56 const before = source.slice(0, startIndex + startMarker.length)
57 const after = source.slice(endIndex)
58 return `${before}\n${replacement.trim()}\n${after}`
59 }
60
61 // ── Validation ──
62
63 // Tags that should be strictly balanced (any imbalance is an error)
64 const STRICT_TAGS = [
65 'div',
66 'section',
67 'main',
68 'ul',
69 'ol',
70 'li',
71 'table',
72 'thead',
73 'tbody',
74 'tr',
75 'p',
76 'h1',
77 'h2',
78 'h3',
79 'h4',
80 'h5',
81 'h6',
82 'article',
83 'header',
84 'footer',
85 'aside',
86 'figure',
87 'figcaption',
88 'blockquote'
89 ]
90
91 const SCRIPT_SRC_RE = /<script[^>]*\bsrc\s*=\s*["']([^"']+)["'][^>]*>/gi
92 const INLINE_SCRIPT_RE = /<script\b(?![^>]*\bsrc\s*=)([^>]*)>([\s\S]*?)<\/script>/gi
93 const REMOTE_SCRIPT_OR_LINK_RE =
94 /<(script|link)\b[^>]*(?:src|href)\s*=\s*["'](?:https?:)?\/\/[^"']+["'][^>]*>/i
95 const HIDDEN_STYLE_RULE_RE =
96 /(?:^|[;}])\s*[^{}]+\{\s*[^{}]*(?:opacity\s*:\s*0(?:\.0+)?|visibility\s*:\s*hidden)[^{}]*\}/i
97 const CHART_LABELS_ARRAY_RE = /\blabels\s*:\s*\[([\s\S]*?)\]/gi
98 const HTML_TAG_IN_STRING_RE = /<\s*\/?\s*[a-z][^>]*>/i
99 export const PAGE_PLACEHOLDER_TEXT = '等待模型填充这一页内容'
100
101 export const isPlaceholderPageHtml = (html: string): boolean =>
102 html.includes(PAGE_PLACEHOLDER_TEXT) || /data-placeholder-page\s*=\s*["']1["']/i.test(html)
103
104 const getInlineScriptSyntaxErrors = (html: string): string[] => {
105 const errors: string[] = []
106 let scriptIndex = 0
107 for (const match of html.matchAll(INLINE_SCRIPT_RE)) {
108 const attrs = match[1] || ''
109 const type = attrs.match(/\btype\s*=\s*["']([^"']+)["']/i)?.[1]?.trim().toLowerCase()
110 if (type && type !== 'text/javascript' && type !== 'application/javascript') {
111 continue
112 }
113 const scriptBody = (match[2] || '').trim()
114 if (!scriptBody) continue
115 scriptIndex += 1
116 try {
117 new Function(scriptBody)
118 } catch (error) {
119 const message = error instanceof Error ? error.message : String(error)
120 errors.push(`第 ${scriptIndex} 个内联 script 语法错误:${message}`)
121 }
122 }
123 return errors
124 }
125
126 // All explicit h-[Npx] heights on the frame, as positive pixel values. Deliberately
127 // NOT range-clamped: the marker/class contract is "must match", so an out-of-range
128 // class (e.g. h-[100px]) still counts and is compared against the marker instead of
129 // being silently dropped as "missing".
130 const getFixedChartHeightClasses = (classRaw: string): number[] =>
131 classRaw
132 .split(/\s+/)
133 .map((cls) => cls.split(':').pop() || cls)
134 .map(parseChartHeightClass)
135 .filter((value): value is number => value !== null)
136
137 const getChartHeightMarkerMismatchErrors = (html: string): string[] => {
138 const errors: string[] = []
139 try {
140 const $ = cheerio.load(html, { scriptingEnabled: false })
141 $('canvas').each((index, node) => {
142 const parent = $(node).parent()
143 if (!parent.length) return
144 const markerHeight = resolveChartHeightFromNearbyComment(parent)
145 if (!markerHeight) return
146 const classHeights = getFixedChartHeightClasses(parent.attr('class') || '')
147 if (classHeights.length === 0 || classHeights.includes(markerHeight)) return
148 const actual = classHeights.map((height) => `h-[${height}px]`).join(', ')
149 errors.push(
150 `第 ${index + 1} 个图表高度标记 ${CHART_FRAME_HEIGHT_COMMENT_MARKER}=${markerHeight} 与图表框 class 不一致:${actual}`
151 )
152 })
153 } catch {
154 // Structural parse errors are reported by the existing HTML parser checks.
155 }
156 return errors
157 }
158
159 const getVisibleChartHeightMarkerErrors = (html: string): string[] => {
160 const withoutComments = html.replace(/<!--[\s\S]*?-->/g, '')
161 if (!new RegExp(`${CHART_FRAME_HEIGHT_COMMENT_MARKER}\\s*=`, 'i').test(withoutComments)) {
162 return []
163 }
164 return [
165 `图表高度标记 ${CHART_FRAME_HEIGHT_COMMENT_MARKER}=N 必须写在 HTML 注释中,不能作为可见文本放进图表框。`
166 ]
167 }
168
169 const getChartHtmlLabelErrors = (html: string): string[] => {
170 const errors: string[] = []
171 let scriptIndex = 0
172 for (const match of html.matchAll(INLINE_SCRIPT_RE)) {
173 scriptIndex += 1
174 const scriptBody = match[2] || ''
175 if (!/PPT\.createChart\s*\(|new\s+Chart\s*\(/i.test(scriptBody)) continue
176 for (const labelsMatch of scriptBody.matchAll(CHART_LABELS_ARRAY_RE)) {
177 const labelsSource = labelsMatch[1] || ''
178 if (!HTML_TAG_IN_STRING_RE.test(labelsSource)) continue
179 errors.push(
180 `第 ${scriptIndex} 个图表 labels 包含 HTML 标签。Chart.js 不会渲染 <br>/<span>,请使用纯文本标签、字符串数组换行,或 tooltip/注释承载补充信息。`
181 )
182 break
183 }
184 }
185 return errors
186 }
187
188 const isAllowedRuntimeAsset = (src: string): boolean => {
189 const normalized = src.trim().toLowerCase()
190 const clean = normalized.split('?')[0].split('#')[0]
191 return (
192 clean.endsWith('/assets/anime.v4.js') ||
193 clean.endsWith('./assets/anime.v4.js') ||
194 clean.endsWith('assets/anime.v4.js') ||
195 clean.endsWith('/assets/ppt-runtime.js') ||
196 clean.endsWith('./assets/ppt-runtime.js') ||
197 clean.endsWith('assets/ppt-runtime.js') ||
198 clean.endsWith('/assets/chart.v4.js') ||
199 clean.endsWith('./assets/chart.v4.js') ||
200 clean.endsWith('assets/chart.v4.js') ||
201 clean.endsWith('/assets/tailwindcss.v3.js') ||
202 clean.endsWith('./assets/tailwindcss.v3.js') ||
203 clean.endsWith('assets/tailwindcss.v3.js') ||
204 clean.endsWith('/assets/katex/katex.min.js') ||
205 clean.endsWith('./assets/katex/katex.min.js') ||
206 clean.endsWith('assets/katex/katex.min.js') ||
207 clean.endsWith('/assets/katex/katex-auto-render.min.js') ||
208 clean.endsWith('./assets/katex/katex-auto-render.min.js') ||
209 clean.endsWith('assets/katex/katex-auto-render.min.js')
210 )
211 }
212
213 export const validateHtmlContent = (html: string): { valid: boolean; errors: string[] } => {
214 const errors: string[] = []
215 const animationCallScanHtml = html.replace(
216 /\bdata-anim-delay\s*=\s*(["'])stagger\s*\(\s*\d+\s*\)\1/gi,
217 'data-anim-delay=$1__DATA_ANIM_STAGGER__$1'
218 )
219 const hasUnqualifiedCall = (fnName: string): boolean =>
220 new RegExp(`(^|[^\\w$.])${fnName}\\s*\\(`, 'm').test(animationCallScanHtml)
221 if (!html || html.trim().length === 0) {
222 errors.push('HTML 内容为空')
223 return { valid: false, errors }
224 }
225 // Creative fragment mode: content must be a fragment, while write tools add page semantics.
226 if (/<!doctype[\s>]/i.test(html)) {
227 errors.push('检测到 <!doctype>。请仅传页面片段,不要传完整文档。')
228 }
229 if (/<html[\s>]/i.test(html) || /<\/html>/i.test(html)) {
230 errors.push('检测到 <html> 标签。请仅传页面片段,不要传完整文档。')
231 }
232 if (/<head[\s>]/i.test(html) || /<\/head>/i.test(html)) {
233 errors.push('检测到 <head> 标签。请仅传页面片段,不要传完整文档。')
234 }
235 if (/<body[\s>]/i.test(html) || /<\/body>/i.test(html)) {
236 errors.push('检测到 <body> 标签。请仅传页面片段,不要传完整文档。')
237 }
238 if (/<meta[\s>]/i.test(html)) {
239 errors.push('检测到 <meta> 标签。页面片段中禁止包含 head 元信息。')
240 }
241 if (/<title[\s>]/i.test(html) || /<\/title>/i.test(html)) {
242 errors.push('检测到 <title> 标签。页面片段中禁止包含标题标签。')
243 }
244 if (/<link\b[^>]*>/i.test(html)) {
245 errors.push('检测到 <link> 标签。页面片段中禁止引入字体或外部资源,字体由系统统一注入。')
246 }
247 if (/@font-face\b/i.test(html)) {
248 errors.push('检测到 @font-face。页面片段中禁止声明字体,字体由系统统一注入。')
249 }
250 if (/url\(\s*["']?(?:https?:)?\/\//i.test(html)) {
251 errors.push('检测到远程 CSS URL。页面片段中禁止引入远程字体或样式资源。')
252 }
253 if (/data-ppt-guard-root\s*=\s*["']1["']/i.test(html)) {
254 errors.push('检测到 data-ppt-guard-root。禁止传入页面骨架根节点,请仅传主体片段。')
255 }
256 if (
257 /\bppt-page-root\b/i.test(html) ||
258 /\bppt-page-content\b/i.test(html) ||
259 /\bppt-page-fit-scope\b/i.test(html)
260 ) {
261 errors.push('检测到页面骨架类(ppt-page-root/content/fit-scope)。请仅传主体片段。')
262 }
263 if (/<script[^>]*id=["']ppt-(?:page-fit|default-motion|page-guard-style)["'][^>]*>/i.test(html)) {
264 errors.push('检测到内置运行时脚本/样式块。请不要自行注入,系统会自动注入。')
265 }
266 if (/<iframe[\s>]/gi.test(html)) {
267 errors.push('内容中包含 iframe 标签,页面内不允许嵌套 iframe')
268 }
269 const scriptSrcHits = Array.from(html.matchAll(SCRIPT_SRC_RE)).map((m) => (m[1] || '').trim())
270 const disallowedScriptSrc = scriptSrcHits.filter((src) => !isAllowedRuntimeAsset(src))
271 if (disallowedScriptSrc.length > 0) {
272 const preview = disallowedScriptSrc.slice(0, 3).join(', ')
273 errors.push(`检测到不允许的 script src:${preview}。页面片段禁止引入脚本资源,运行时已预注入。`)
274 }
275 errors.push(...getInlineScriptSyntaxErrors(html))
276 errors.push(...getVisibleChartHeightMarkerErrors(html))
277 errors.push(...getChartHeightMarkerMismatchErrors(html))
278 errors.push(...getChartHtmlLabelErrors(html))
279 errors.push(...validateDataAnimContract(html).errors)
280 if (/anime\s*\(\s*\{[\s\S]{0,240}?targets\s*:/im.test(html)) {
281 errors.push(`检测到旧版 anime({ targets, ... }) 写法;修改动画前请先 ${formatSkillUsageRequirement(DATA_ANIM_SKILL_NAME)}`)
282 }
283 if (/(^|[^\w$])anime\.(?:animate|stagger|createTimeline|timeline)\s*\(/i.test(html)) {
284 errors.push(`检测到直接 anime.* 调用;修改动画前请先 ${formatSkillUsageRequirement(DATA_ANIM_SKILL_NAME)}`)
285 }
286 if (/\banime\.(?:svg\.)?(?:createMotionPath|createDrawable|morphTo)\s*\(/i.test(html)) {
287 errors.push(`检测到 anime 的 SVG/path/morph 高级能力;这些能力当前属于 preview-only 方向,不应进入标准可编辑页面。修改动画前请先 ${formatSkillUsageRequirement(DATA_ANIM_SKILL_NAME)}`)
288 }
289 if (/\b(?:anime\.)?splitText\s*\(/i.test(html)) {
290 errors.push(`检测到 splitText 文本碎片动画;该能力当前属于 preview-only 方向,不应进入标准可编辑页面。修改动画前请先 ${formatSkillUsageRequirement(DATA_ANIM_SKILL_NAME)}`)
291 }
292 if (/PPT\.animate\s*\(\s*\{[\s\S]{0,240}?targets\s*:/im.test(html)) {
293 errors.push(`检测到 PPT.animate({ targets, ... }) 写法;修改动画前请先 ${formatSkillUsageRequirement(DATA_ANIM_SKILL_NAME)}`)
294 }
295 if (
296 hasUnqualifiedCall('animate') ||
297 hasUnqualifiedCall('stagger') ||
298 hasUnqualifiedCall('createTimeline')
299 ) {
300 errors.push(`检测到未命名空间的动画调用(animate/stagger/createTimeline);修改动画前请先 ${formatSkillUsageRequirement(DATA_ANIM_SKILL_NAME)}`)
301 }
302 if (/new\s+Chart\s*\(/i.test(html)) {
303 errors.push(
304 `检测到直接 new Chart(...) 调用;修改图表前请先 ${formatSkillUsageRequirement(CHART_SKILL_NAME)}`
305 )
306 }
307 if (/addEventListener\s*\(\s*['"](?:ppt-ready|ppt-rendered|ppt-page-ready)['"]/i.test(html)) {
308 errors.push(
309 `检测到自定义事件(ppt-ready/ppt-rendered/ppt-page-ready)绑定 chart 代码,这些事件运行时不会触发。请改用 DOMContentLoaded。${formatSkillUsageRequirement(CHART_SKILL_NAME)}`
310 )
311 }
312 if (/PPT\.createChart/i.test(html) && !/DOMContentLoaded/i.test(html)) {
313 errors.push(
314 `PPT.createChart 未包裹在 DOMContentLoaded 回调中,图表可能无法渲染。${formatSkillUsageRequirement(CHART_SKILL_NAME)}`
315 )
316 }
317 if (/<[^>]*$/.test(html.trim())) {
318 errors.push('HTML 末尾存在未闭合标签,内容可能被截断')
319 }
320 const normalized = html.trim()
321 if (/<html[\s>]/i.test(normalized) && !/<\/html>\s*$/i.test(normalized)) {
322 errors.push('检测到 <html> 但缺少结尾 </html>,内容可能被截断')
323 }
324 if (/<body[\s>]/i.test(normalized) && !/<\/body>/i.test(normalized)) {
325 errors.push('检测到 <body> 但缺少 </body>,内容可能被截断')
326 }
327
328 // Remove comments/script/style to avoid counting pseudo tags in JS/CSS/comment text.
329 const structuralHtml = html
330 .replace(/<!--[\s\S]*?-->/g, '')
331 .replace(/<script[\s\S]*?<\/script>/gi, '')
332 .replace(/<style[\s\S]*?<\/style>/gi, '')
333
334 // Check for orphan closing tags (closing tag without a matching open)
335 for (const tag of STRICT_TAGS) {
336 const opens = (structuralHtml.match(new RegExp(`<${tag}[\\s>]`, 'gi')) || []).length
337 const closes = (structuralHtml.match(new RegExp(`</${tag}>`, 'gi')) || []).length
338 if (opens < closes) {
339 errors.push(`</${tag}> 闭标签多于开标签(${opens} 个开, ${closes} 个闭),可能是内容被截断`)
340 } else if (opens !== closes) {
341 errors.push(`<${tag}> 开闭标签数量不一致(${opens} 个开, ${closes} 个闭),内容可能被截断`)
342 }
343 }
344 try {
345 const $ = cheerio.load(html, { scriptingEnabled: false })
346 const blockIds = new Map<string, number>()
347 $('[data-block-id]').each((_, node) => {
348 const id = ($(node).attr('data-block-id') || '').trim()
349 if (!id) return
350 blockIds.set(id, (blockIds.get(id) || 0) + 1)
351 })
352 const duplicatedBlockIds = Array.from(blockIds.entries())
353 .filter(([, count]) => count > 1)
354 .map(([id]) => id)
355 if (duplicatedBlockIds.length > 0) {
356 errors.push(`data-block-id 必须唯一,重复项:${duplicatedBlockIds.join(', ')}`)
357 }
358 } catch {
359 errors.push('HTML 片段结构解析失败')
360 }
361 return { valid: errors.length === 0, errors }
362 }
363
364 export const validatePersistedPageHtml = (
365 html: string,
366 pageId: string
367 ): { valid: boolean; errors: string[] } => {
368 const errors: string[] = []
369 if (!html || html.trim().length === 0) {
370 return { valid: false, errors: [`${pageId}.html 内容为空`] }
371 }
372 if (isPlaceholderPageHtml(html)) {
373 errors.push('仍包含页面占位文案')
374 }
375 const $ = cheerio.load(html, { scriptingEnabled: false })
376 errors.push(...getInlineScriptSyntaxErrors(html))
377 errors.push(...getVisibleChartHeightMarkerErrors(html))
378 errors.push(...getChartHeightMarkerMismatchErrors(html))
379 errors.push(...getChartHtmlLabelErrors(html))
380 if (REMOTE_SCRIPT_OR_LINK_RE.test(html)) {
381 errors.push('包含远程资源引用(字体已改为本地加载,禁止 CDN 链接)')
382 }
383 $('style').each((_, node) => {
384 const el = $(node)
385 const css = el.text()
386 const fontMarker = el.attr('data-ppt-fonts')
387 if (/@font-face\b/i.test(css) && fontMarker !== 'user' && fontMarker !== 'google') {
388 errors.push('@font-face 只能由系统字体注入块声明')
389 return false
390 }
391 if (/url\(\s*["']?(?:https?:)?\/\//i.test(css)) {
392 errors.push('样式块中包含远程 URL')
393 return false
394 }
395 if (/url\(\s*"(?!\.\/assets\/fonts\/user-fonts\/)[^)]+/i.test(css) && fontMarker === 'user') {
396 errors.push('@font-face 只能引用 ./assets/fonts/user-fonts/ 下的字体文件')
397 return false
398 }
399 if (/url\(\s*"(?!\.\/assets\/fonts\/google-fonts\/)[^)]+/i.test(css) && fontMarker === 'google') {
400 errors.push('Google 字体只能引用 ./assets/fonts/google-fonts/ 下的字体文件')
401 return false
402 }
403 return undefined
404 })
405 $('style').each((_, node) => {
406 const css = $(node).text()
407 if (HIDDEN_STYLE_RULE_RE.test(css)) {
408 errors.push('样式块包含默认隐藏态规则,可能导致内容不可见')
409 return false
410 }
411 return undefined
412 })
413 $('[class], [style]').each((_, node) => {
414 const el = $(node)
415 const classRaw = el.attr('class') || ''
416 const styleRaw = el.attr('style') || ''
417 if (/\bopacity-0\b|\binvisible\b/i.test(classRaw)) {
418 errors.push('包含默认隐藏态 class,可能导致内容不可见')
419 return false
420 }
421 if (/visibility\s*:\s*hidden|opacity\s*:\s*0(?:\.0+)?(?:;|$)/i.test(styleRaw)) {
422 errors.push('包含默认隐藏态 style,可能导致内容不可见')
423 return false
424 }
425 return undefined
426 })
427 const root = $('.ppt-page-root[data-ppt-guard-root="1"]').first()
428 if (!root.length) {
429 errors.push('缺少 .ppt-page-root[data-ppt-guard-root="1"]')
430 }
431 const content = $('.ppt-page-content').first()
432 if (!content.length) {
433 errors.push('缺少 .ppt-page-content')
434 }
435 const blockIds = new Map<string, number>()
436 $('[data-block-id]').each((_, node) => {
437 const id = ($(node).attr('data-block-id') || '').trim()
438 if (!id) return
439 blockIds.set(id, (blockIds.get(id) || 0) + 1)
440 })
441 const duplicatedBlockIds = Array.from(blockIds.entries())
442 .filter(([, count]) => count > 1)
443 .map(([id]) => id)
444 if (duplicatedBlockIds.length > 0) {
445 errors.push(`data-block-id 重复:${duplicatedBlockIds.join(', ')}`)
446 }
447
448 $('video').each((index, node) => {
449 const video = $(node)
450 const missingAttrs = ['controls', 'playsinline'].filter(
451 (attr) => video.attr(attr) === undefined
452 )
453 if (missingAttrs.length > 0) {
454 errors.push(`第 ${index + 1} 个 video 缺少属性:${missingAttrs.join(', ')}`)
455 }
456 const preload = (video.attr('preload') || '').toLowerCase()
457 if (preload && !['metadata', 'auto', 'none'].includes(preload)) {
458 errors.push(`第 ${index + 1} 个 video 的 preload 只能是 metadata、auto 或 none`)
459 }
460 })
461
462 return { valid: errors.length === 0, errors }
463 }
464
465 // ── Section content normalization ──
466
467 export const normalizeSectionContent = (pageId: string, html: string): string => {
468 const trimmed = html.trim()
469 const bodyHtml = extractBodyHtml(trimmed)
470 const css = extractStyleCss(trimmed)
471 const normalizedBody = (bodyHtml || trimmed).trim()
472 const normalizedCss = normalizePageCss(css)
473 if (!normalizedCss) return normalizedBody
474 return `<style data-page-style="${pageId}">
475 ${normalizedCss}
476 </style>
477 ${normalizedBody}`
478 }
479
480 // ── Re-export markers for convenience ──
481
482 export {
483 SHARED_PAGE_STYLES_START,
484 SHARED_PAGE_STYLES_END,
485 pageContentStartMarker,
486 pageContentEndMarker
487 }
488
488 lines TYPESCRIPT