返回 oh-my-ppt
page-writer-core.ts
根目录 / src / main / presentation / html / page-writer-core.ts
1 import fs from 'fs'
2 import * as cheerio from 'cheerio'
3 import type { AnyNode } from 'domhandler'
4 import { SLIDE_SIZE_PRESETS, type SlideSizePreset } from '@shared/slide-size'
5 import {
6 isPlaceholderPageHtml,
7 validateHtmlContent,
8 validatePersistedPageHtml
9 } from './html-utils'
10 import {
11 parseChartHeightClass,
12 resolveChartHeightFromNearbyComment
13 } from './chart-height'
14 import { normalizeCreativePageFragment } from './page-fragment-normalizer'
15 import { extractRemoteRuntimeResources } from './resource-policy'
16 import { buildFontHeadTags } from '../fonts/font-registry'
17 import { buildSessionAssetHeadTags } from '../assets/page-assets'
18 import { validateTemplateSkeletonPreserved } from '../templates/template-skeleton-validator'
19 import { serializedWrite } from './write-serialization'
20 import {
21 buildBasePageStyleTag,
22 buildFitScript,
23 DEFAULT_MOTION_SCRIPT,
24 VIDEO_INTERACTION_SCRIPT
25 } from './page-shell'
26 import { buildMasterStyleLink } from './master-link'
27
28 export {
29 buildBasePageStyleTag,
30 buildFitScript,
31 DEFAULT_MOTION_SCRIPT,
32 VIDEO_INTERACTION_SCRIPT
33 } from './page-shell'
34
35 function extractBackgroundStyle(styleAttr: string): string {
36 const declarations = styleAttr
37 .split(';')
38 .map((item) => item.trim())
39 .filter(Boolean)
40 const kept = declarations.filter((decl) => {
41 const normalized = decl.toLowerCase().replace(/\s+/g, ' ')
42 return (
43 normalized.startsWith('background:') ||
44 normalized.startsWith('background-color:') ||
45 normalized.startsWith('background-image:')
46 )
47 })
48 return kept.join('; ')
49 }
50
51 function isBackgroundUtilityClass(cls: string): boolean {
52 const base = cls.split(':').pop() || cls
53 return (
54 base.startsWith('bg-') ||
55 base.startsWith('from-') ||
56 base.startsWith('via-') ||
57 base.startsWith('to-')
58 )
59 }
60
61 export function syncRootBackgroundFromScaffold(html: string): string {
62 try {
63 const $ = cheerio.load(html, { scriptingEnabled: false })
64 const root = $('.ppt-page-root[data-ppt-guard-root="1"]').first()
65 if (!root.length) return html
66
67 const scaffold = root.find('[data-page-scaffold="1"]').first()
68 if (!scaffold.length) return html
69
70 const rootClassRaw = (root.attr('class') || '').trim()
71 const rootClasses = rootClassRaw.split(/\s+/).filter(Boolean)
72 const rootHasBgClass = rootClasses.some((cls) => isBackgroundUtilityClass(cls))
73
74 if (!rootHasBgClass) {
75 const scaffoldClassRaw = (scaffold.attr('class') || '').trim()
76 const scaffoldBgClasses = scaffoldClassRaw
77 .split(/\s+/)
78 .filter(Boolean)
79 .filter((cls) => isBackgroundUtilityClass(cls))
80 if (scaffoldBgClasses.length > 0) {
81 const classSet = new Set(rootClasses)
82 for (const cls of scaffoldBgClasses) classSet.add(cls)
83 root.attr('class', Array.from(classSet).join(' '))
84 }
85 }
86
87 const rootStyleRaw = (root.attr('style') || '').trim()
88 const rootBgStyle = extractBackgroundStyle(rootStyleRaw)
89 if (!rootBgStyle) {
90 const scaffoldStyleRaw = (scaffold.attr('style') || '').trim()
91 const scaffoldBgStyle = extractBackgroundStyle(scaffoldStyleRaw)
92 if (scaffoldBgStyle) {
93 const finalStyle = [rootStyleRaw, scaffoldBgStyle].filter(Boolean).join('; ')
94 root.attr('style', finalStyle)
95 }
96 }
97
98 return $.html()
99 } catch {
100 return html
101 }
102 }
103
104 const PRESET_DIMENSIONS_PATTERN = Array.from(
105 new Set(SLIDE_SIZE_PRESETS.flatMap((preset) => [preset.width, preset.height]))
106 ).join('|')
107 const PRESET_ASPECTS_PATTERN = SLIDE_SIZE_PRESETS.flatMap((preset) => [
108 `${preset.width}\\/${preset.height}`,
109 preset.id === 'wide-16-9'
110 ? '16\\/9'
111 : preset.id === 'vertical-9-16'
112 ? '9\\/16'
113 : preset.id === 'standard-4-3'
114 ? '4\\/3'
115 : preset.id === 'square-1-1'
116 ? '1\\/1'
117 : '3\\/4'
118 ]).join('|')
119
120 const CANVAS_LOCK_CLASS_PATTERNS = [
121 new RegExp(
122 `^(w|h|min-w|min-h|max-w|max-h)-\\[(?:(?:${PRESET_DIMENSIONS_PATTERN})px|100vw|100vh|100dvw|100dvh)\\]$`,
123 'i'
124 ),
125 /^(w|h|min-w|min-h|max-w|max-h)-screen$/i,
126 new RegExp(`^aspect-\\[(?:${PRESET_ASPECTS_PATTERN})\\]$`, 'i'),
127 new RegExp(`^size-\\[(?:${PRESET_DIMENSIONS_PATTERN})px\\]$`, 'i')
128 ]
129
130 function stripCanvasLockClasses(classAttr: string): string {
131 const classes = classAttr.split(/\s+/).filter(Boolean)
132 const kept = classes.filter(
133 (cls) => !CANVAS_LOCK_CLASS_PATTERNS.some((pattern) => pattern.test(cls))
134 )
135 return kept.join(' ')
136 }
137
138 function stripCanvasInlineSizes(styleAttr: string): string {
139 const declarations = styleAttr
140 .split(';')
141 .map((item) => item.trim())
142 .filter(Boolean)
143 const kept = declarations.filter((decl) => {
144 const normalized = decl.toLowerCase().replace(/\s+/g, ' ')
145 const dimensionValuePattern = `(?:${PRESET_DIMENSIONS_PATTERN})px`
146 if (
147 new RegExp(
148 `^(width|min-width|max-width): (${dimensionValuePattern}|100vw|100dvw)$`
149 ).test(normalized)
150 )
151 return false
152 if (
153 new RegExp(
154 `^(height|min-height|max-height): (${dimensionValuePattern}|100vh|100dvh)$`
155 ).test(normalized)
156 )
157 return false
158 return true
159 })
160 return kept.join('; ')
161 }
162
163 const CHART_FRAME_DEFAULT_HEIGHT_CLASS = 'h-[240px]'
164
165 function splitClassNames(classRaw: string): string[] {
166 return classRaw
167 .split(/\s+/)
168 .map((cls) => cls.trim())
169 .filter(Boolean)
170 }
171
172 function classBaseName(cls: string): string {
173 return cls.split(':').pop() || cls
174 }
175
176 function isChartCanvasLayoutClass(cls: string): boolean {
177 const base = classBaseName(cls)
178 return base === 'flex-1' || /^h-/.test(base) || /^min-h-/.test(base) || /^max-h-/.test(base)
179 }
180
181 function isMarginUtilityClass(cls: string): boolean {
182 return /^-?m[trblxy]?-[^\s]+$/.test(classBaseName(cls))
183 }
184
185 function hasFixedChartHeightClass(classes: Iterable<string>): boolean {
186 return Array.from(classes).some((cls) => parseChartHeightClass(classBaseName(cls)) !== null)
187 }
188
189 function isUnstableChartFrameLayoutClass(cls: string): boolean {
190 const base = classBaseName(cls)
191 return (
192 base === 'flex-1' ||
193 (/^h-/.test(base) && parseChartHeightClass(base) === null) ||
194 /^min-h-/.test(base) ||
195 /^max-h-/.test(base)
196 )
197 }
198
199 function hasFixedChartHeightStyle(styleRaw: string): boolean {
200 return /(?:^|;)\s*height\s*:\s*(?!\s*(?:auto|0(?:px|rem|em|%)?|100%|inherit|initial|unset)\b)[^;]+/i.test(
201 styleRaw
202 )
203 }
204
205 function resolveChartFrameHeightClassFromNearbyComment(
206 parent: cheerio.Cheerio<AnyNode>
207 ): string | null {
208 const height = resolveChartHeightFromNearbyComment(parent)
209 return height === null ? null : `h-[${height}px]`
210 }
211
212 /**
213 * Merged single-pass cheerio preprocessing: canvas lock styles, chart stabilization,
214 * and unsafe hidden states. Replaces 3 separate cheerio.load calls with one.
215 */
216 export function preprocessPageHtml(html: string): string {
217 try {
218 const $ = cheerio.load(html.trim(), { scriptingEnabled: false })
219
220 $('[class]').each((_, node) => {
221 const classValue = ($(node).attr('class') || '').trim()
222 if (!classValue) return
223 const cleaned = stripCanvasLockClasses(classValue)
224 if (cleaned.length > 0) {
225 $(node).attr('class', cleaned)
226 } else {
227 $(node).removeAttr('class')
228 }
229 })
230 $('[style]').each((_, node) => {
231 const styleValue = ($(node).attr('style') || '').trim()
232 if (!styleValue) return
233 const cleaned = stripCanvasInlineSizes(styleValue)
234 if (cleaned.length > 0) {
235 $(node).attr('style', cleaned)
236 } else {
237 $(node).removeAttr('style')
238 }
239 })
240
241 $('canvas').each((_, node) => {
242 const canvas = $(node)
243 canvas.removeAttr('width')
244 canvas.removeAttr('height')
245 const originalCanvasClasses = splitClassNames(canvas.attr('class') || '')
246 const wrapperClasses = originalCanvasClasses.filter(isMarginUtilityClass)
247 const canvasClassSet = new Set(
248 originalCanvasClasses.filter(
249 (cls) => !isChartCanvasLayoutClass(cls) && !isMarginUtilityClass(cls)
250 )
251 )
252 canvasClassSet.add('h-full')
253 canvasClassSet.add('w-full')
254 canvas.attr('class', Array.from(canvasClassSet).join(' '))
255
256 const parent = canvas.parent()
257 if (!parent.length) return
258
259 const parentClassRaw = (parent.attr('class') || '').trim()
260 const originalParentClasses = splitClassNames(parentClassRaw)
261 const parentStyle = parent.attr('style') || ''
262 const hasFixedHeightStyle = hasFixedChartHeightStyle(parentStyle)
263 const hasFixedHeightClass = hasFixedChartHeightClass(originalParentClasses)
264 const parentClassSet = new Set(
265 originalParentClasses.filter((cls) => !isUnstableChartFrameLayoutClass(cls))
266 )
267
268 if (!hasFixedHeightClass && !hasFixedHeightStyle) {
269 parentClassSet.add(
270 resolveChartFrameHeightClassFromNearbyComment(parent) || CHART_FRAME_DEFAULT_HEIGHT_CLASS
271 )
272 }
273
274 if (!parentClassSet.has('ppt-chart-frame')) parentClassSet.add('ppt-chart-frame')
275 if (!parentClassSet.has('relative')) parentClassSet.add('relative')
276 if (!parentClassSet.has('overflow-hidden')) parentClassSet.add('overflow-hidden')
277 if (wrapperClasses.length > 0) {
278 for (const cls of wrapperClasses) parentClassSet.add(cls)
279 }
280 parent.attr('class', Array.from(parentClassSet).join(' '))
281 })
282
283 $('video').each((_, node) => {
284 const video = $(node)
285 video.attr('controls', '')
286 video.attr('playsinline', '')
287 if (video.attr('preload') === undefined) {
288 video.attr('preload', 'metadata')
289 }
290 })
291
292 $('*').each((_, node) => {
293 const el = $(node)
294
295 const classRaw = (el.attr('class') || '').trim()
296 if (classRaw) {
297 const kept = classRaw
298 .split(/\s+/)
299 .filter(Boolean)
300 .filter((cls) => {
301 const base = cls.split(':').pop() || cls
302 return base !== 'opacity-0' && base !== 'invisible'
303 })
304 if (kept.length > 0) {
305 el.attr('class', kept.join(' '))
306 } else {
307 el.removeAttr('class')
308 }
309 }
310
311 const styleRaw = (el.attr('style') || '').trim()
312 if (styleRaw) {
313 const keptDecls = styleRaw
314 .split(';')
315 .map((decl) => decl.trim())
316 .filter(Boolean)
317 .filter((decl) => {
318 const idx = decl.indexOf(':')
319 if (idx < 0) return true
320 const key = decl.slice(0, idx).trim().toLowerCase()
321 const value = decl
322 .slice(idx + 1)
323 .trim()
324 .toLowerCase()
325 if (key === 'opacity' && /^0(?:\.0+)?$/.test(value)) return false
326 if (key === 'visibility' && value === 'hidden') return false
327 return true
328 })
329 if (keptDecls.length > 0) {
330 el.attr('style', keptDecls.join('; '))
331 } else {
332 el.removeAttr('style')
333 }
334 }
335 })
336
337 return $.html()
338 } catch {
339 return html
340 }
341 }
342
343 type HtmlContentValidation = ReturnType<typeof validateHtmlContent>
344
345 export type PageWriteValidationFailureKind =
346 | 'remote-resource'
347 | 'content-validation'
348 | 'template-skeleton'
349 | 'persisted-validation'
350
351 /** A presentation-domain validation failure with machine-readable diagnostics for adapters. */
352 export class PageWriteValidationError extends Error {
353 constructor(
354 readonly kind: PageWriteValidationFailureKind,
355 readonly pageId: string,
356 readonly details: readonly string[],
357 message: string
358 ) {
359 super(message)
360 this.name = 'PageWriteValidationError'
361 }
362 }
363
364 const STRUCTURAL_FRAGMENT_ERROR_RE =
365 /HTML 末尾存在未闭合标签|开闭标签数量不一致|闭标签多于开标签|缺少结尾|缺少 <\/body>/i
366
367 function trimTrailingPartialTag(content: string): string {
368 const trimmed = content.trim()
369 if (!/<[^>]*$/.test(trimmed)) return trimmed
370 return trimmed.replace(/<[^>]*$/, '').trim()
371 }
372
373 function repairMalformedCreativeFragment(content: string): string | null {
374 const repairInput = trimTrailingPartialTag(content)
375 if (!repairInput) return null
376 try {
377 const $ = cheerio.load(repairInput, { scriptingEnabled: false }, false)
378 const repaired = ($.root().html() || repairInput).trim()
379 return repaired && repaired !== content.trim() ? repaired : null
380 } catch {
381 return null
382 }
383 }
384
385 export function countHtmlTag(content: string, tagName: string): { open: number; close: number } {
386 const withoutNonStructuralBlocks = content
387 .replace(/<!--[\s\S]*?-->/g, '')
388 .replace(/<script[\s\S]*?<\/script>/gi, '')
389 .replace(/<style[\s\S]*?<\/style>/gi, '')
390 return {
391 open: (withoutNonStructuralBlocks.match(new RegExp(`<${tagName}[\\s>]`, 'gi')) || []).length,
392 close: (withoutNonStructuralBlocks.match(new RegExp(`</${tagName}>`, 'gi')) || []).length
393 }
394 }
395
396 export function validateOrRepairHtmlContent(content: string): {
397 content: string
398 validation: HtmlContentValidation
399 repaired: boolean
400 originalErrors?: string[]
401 } {
402 const validation = validateHtmlContent(content)
403 if (validation.valid) {
404 return { content, validation, repaired: false }
405 }
406
407 const onlyStructuralErrors = validation.errors.every((error) =>
408 STRUCTURAL_FRAGMENT_ERROR_RE.test(error)
409 )
410 if (!onlyStructuralErrors) {
411 return { content, validation, repaired: false }
412 }
413
414 const repairedContent = repairMalformedCreativeFragment(content)
415 if (!repairedContent) {
416 return { content, validation, repaired: false }
417 }
418
419 const repairedValidation = validateHtmlContent(repairedContent)
420 if (!repairedValidation.valid) {
421 return { content, validation: repairedValidation, repaired: false }
422 }
423
424 return {
425 content: repairedContent,
426 validation: repairedValidation,
427 repaired: true,
428 originalErrors: validation.errors
429 }
430 }
431
432 export function replacePageContentFragment(args: {
433 originalHtml: string
434 content: string
435 pageId: string
436 }): { html: string; content: string; repaired: boolean } {
437 const remoteResources = extractRemoteRuntimeResources(args.content)
438 if (remoteResources.length > 0) {
439 throw new Error(
440 `检测到禁止的 CDN/远程资源引用 (${args.pageId}),仅允许使用系统预注入的本地 ./assets/*。`
441 )
442 }
443 const normalizedFragment = normalizeCreativePageFragment(preprocessPageHtml(args.content), {
444 blockIdMode: 'strip'
445 })
446 const prepared = validateOrRepairHtmlContent(normalizedFragment)
447 const normalizedValidation = validateHtmlContent(prepared.content)
448 if (!normalizedValidation.valid) {
449 throw new Error(
450 `HTML 验证失败 (${args.pageId}): ${normalizedValidation.errors.join('; ')}。请修正后重试。`
451 )
452 }
453
454 const $ = cheerio.load(args.originalHtml, { scriptingEnabled: false })
455 const contentNode = $('.ppt-page-root[data-ppt-guard-root="1"] .ppt-page-content').first()
456 if (!contentNode.length) {
457 throw new Error(
458 `一键美化无法定位页面主体容器 (${args.pageId}):页面骨架已被破坏,请先修复页面后再美化。`
459 )
460 }
461 contentNode.html(prepared.content)
462 const html = syncRootBackgroundFromScaffold($.html())
463 const persistedValidation = validatePersistedPageHtml(html, args.pageId)
464 if (!persistedValidation.valid) {
465 throw new Error(
466 `HTML 落盘校验失败 (${args.pageId}): ${persistedValidation.errors.join('; ')}。请修正页面片段后重试。`
467 )
468 }
469 return { html, content: args.content, repaired: prepared.repaired }
470 }
471
472 function hasDataAnim(html: string): boolean {
473 return /\bdata-anim\b/i.test(html)
474 }
475
476 function hasCustomPageAnimation(html: string): boolean {
477 return (
478 /(?:anime\s*\(|anime\.(?:createTimeline|timeline|animate|stagger)\s*\()/m.test(html) ||
479 /PPT\.(?:animate|stagger|createTimeline)\s*\(/m.test(html) ||
480 /data-(?:anime|animate)\b/i.test(html)
481 )
482 }
483
484 async function buildScaffoldDocument(args: {
485 pageId: string
486 pageNumber?: number
487 innerContent: string
488 includeDefaultMotion: boolean
489 projectDir: string
490 designFonts?: { titleFont: string; bodyFont: string }
491 slideSize: SlideSizePreset
492 }): Promise<string> {
493 const { pageId, pageNumber, innerContent, includeDefaultMotion, projectDir, designFonts, slideSize } =
494 args
495 const pageNumberAttribute =
496 typeof pageNumber === 'number' && Number.isFinite(pageNumber) && pageNumber > 0
497 ? ` data-ppt-page-number="${Math.floor(pageNumber)}"`
498 : ''
499 const motionScript = includeDefaultMotion ? `\n ${DEFAULT_MOTION_SCRIPT}` : ''
500 const fontInjection =
501 designFonts
502 ? `\n ${await buildFontHeadTags({ ...designFonts, projectDir })}`
503 : ''
504 return `<!doctype html>
505 <html lang="zh-CN">
506 <head>
507 <meta charset="UTF-8" />
508 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
509 ${buildSessionAssetHeadTags()}${fontInjection}
510 ${buildBasePageStyleTag(slideSize)}
511 ${buildMasterStyleLink()}
512 </head>
513 <body data-page-id="${pageId}"${pageNumberAttribute}>
514 <main class="ppt-page-root" data-ppt-guard-root="1" data-ppt-slide-size-id="${slideSize.id}" data-ppt-width="${slideSize.width}" data-ppt-height="${slideSize.height}"${pageNumberAttribute}>
515 <div class="ppt-page-fit-scope">
516 <div class="ppt-page-content">
517 ${innerContent}
518 </div>
519 </div>
520 </main>
521 ${buildFitScript(slideSize)}
522 ${VIDEO_INTERACTION_SCRIPT}
523 ${motionScript}
524 </body>
525 </html>`
526 }
527
528 export async function normalizeAndInjectPageRuntime(
529 content: string,
530 pageId: string,
531 projectDir: string,
532 slideSize: SlideSizePreset,
533 designFonts?: { titleFont: string; bodyFont: string },
534 pageNumber?: number
535 ): Promise<string> {
536 const fragment = normalizeCreativePageFragment(preprocessPageHtml(content))
537 const document = await buildScaffoldDocument({
538 pageId,
539 pageNumber,
540 innerContent: fragment,
541 includeDefaultMotion: hasDataAnim(content) || !hasCustomPageAnimation(content),
542 projectDir,
543 slideSize,
544 designFonts
545 })
546 return syncRootBackgroundFromScaffold(document)
547 }
548
549 /**
550 * Turn a creative page fragment into a validated standalone page document.
551 * This capability deliberately stops before filesystem writes; callers own
552 * their domain-specific atomic write and rollback strategy.
553 */
554 export async function buildPersistedPageHtmlFromFragment(args: {
555 content: string
556 pageId: string
557 pageNumber?: number
558 projectDir: string
559 slideSize: SlideSizePreset
560 designFonts?: { titleFont: string; bodyFont: string }
561 }): Promise<{ html: string; content: string; repaired: boolean; originalErrors?: string[] }> {
562 const remoteResources = extractRemoteRuntimeResources(args.content)
563 if (remoteResources.length > 0) {
564 throw new PageWriteValidationError(
565 'remote-resource',
566 args.pageId,
567 remoteResources,
568 [
569 `检测到禁止的 CDN/远程资源引用 (${args.pageId}),已拒绝写入。`,
570 '请移除所有 script/link 的 http(s) 或 // 外链,仅使用系统预注入的本地 ./assets/* 资源。',
571 '示例命中:',
572 ...remoteResources
573 ].join('\n')
574 )
575 }
576 const prepared = validateOrRepairHtmlContent(args.content)
577 const normalizedContent = normalizeCreativePageFragment(preprocessPageHtml(prepared.content))
578 const normalizedValidation = validateHtmlContent(normalizedContent)
579 if (!normalizedValidation.valid) {
580 throw new PageWriteValidationError(
581 'content-validation',
582 args.pageId,
583 normalizedValidation.errors,
584 `HTML 验证失败 (${args.pageId}): ${normalizedValidation.errors.join('; ')}。请修正后重试。`
585 )
586 }
587 const html = await normalizeAndInjectPageRuntime(
588 normalizedContent,
589 args.pageId,
590 args.projectDir,
591 args.slideSize,
592 args.designFonts,
593 args.pageNumber
594 )
595 const persistedValidation = validatePersistedPageHtml(html, args.pageId)
596 if (!persistedValidation.valid) {
597 throw new PageWriteValidationError(
598 'persisted-validation',
599 args.pageId,
600 persistedValidation.errors,
601 `HTML 落盘校验失败 (${args.pageId}): ${persistedValidation.errors.join('; ')}。请修正页面片段后重试。`
602 )
603 }
604 return {
605 html,
606 content: prepared.content,
607 repaired: prepared.repaired,
608 originalErrors: prepared.originalErrors
609 }
610 }
611
612 /**
613 * Presentation-owned page persistence capability shared by Agent tools and any
614 * future non-Agent caller. It keeps validation, template-skeleton protection,
615 * and serialized writes out of the Agent adapter layer.
616 */
617 export async function persistPageHtmlFromFragment(args: {
618 content: string
619 pageId: string
620 pageNumber?: number
621 projectDir: string
622 targetPath: string
623 slideSize: SlideSizePreset
624 designFonts?: { titleFont: string; bodyFont: string }
625 preserveTemplateSkeleton?: boolean
626 }): Promise<{ html: string; content: string; repaired: boolean; originalErrors?: string[] }> {
627 const persisted = await buildPersistedPageHtmlFromFragment(args)
628 if (args.preserveTemplateSkeleton) {
629 const beforeHtml = await fs.promises.readFile(args.targetPath, 'utf-8').catch(() => '')
630 const missingTemplateRefs = validateTemplateSkeletonPreserved(beforeHtml, persisted.html)
631 if (missingTemplateRefs.length > 0) {
632 throw new PageWriteValidationError(
633 'template-skeleton',
634 args.pageId,
635 missingTemplateRefs,
636 [
637 `模板骨架资源丢失 (${args.pageId}):${missingTemplateRefs.slice(0, 8).join(', ')}`,
638 '请重新读取目标模板页,把背景图、纹理、装饰图、mask/overlay 或 CSS url(...) 对应结构保留在 update_template_page_file 的 content 中。'
639 ].join(' ')
640 )
641 }
642 }
643 await serializedWrite(args.projectDir, async () => {
644 await fs.promises.writeFile(args.targetPath, persisted.html, 'utf-8')
645 })
646 return persisted
647 }
648
649 export type PresentationPageVerification = {
650 pageId: string
651 filled: boolean
652 hasContent: boolean
653 hasRemoteRuntime: boolean
654 }
655
656 /** Read and validate the persisted presentation pages without leaking fs access to Agent tools. */
657 export async function verifyPresentationPageFiles(args: {
658 pageFileMap: Record<string, string>
659 pageIds: readonly string[]
660 }): Promise<PresentationPageVerification[]> {
661 return Promise.all(
662 args.pageIds.map(async (pageId) => {
663 const pagePath = args.pageFileMap[pageId]
664 if (!pagePath) {
665 return { pageId, filled: false, hasContent: false, hasRemoteRuntime: false }
666 }
667 let content = ''
668 try {
669 content = await fs.promises.readFile(pagePath, 'utf-8')
670 } catch (error) {
671 const code = error && typeof error === 'object' ? (error as NodeJS.ErrnoException).code : undefined
672 if (code === 'ENOENT') {
673 return { pageId, filled: false, hasContent: false, hasRemoteRuntime: false }
674 }
675 throw error
676 }
677 const filled = content.trim().length > 0
678 return {
679 pageId,
680 filled,
681 hasContent: filled && !isPlaceholderPageHtml(content),
682 hasRemoteRuntime: extractRemoteRuntimeResources(content).length > 0
683 }
684 })
685 )
686 }
687
687 lines TYPESCRIPT