返回 oh-my-ppt
finalization-validator.ts
根目录 / src / main / image-generation / finalization-validator.ts
1 import * as cheerio from 'cheerio'
2 import type { Element } from 'domhandler'
3 import { validateDataAnimContract } from '../animation/data-anim-validator'
4
5 export type FinalizationAsset = {
6 slotId: string
7 layoutSlotId?: string
8 layer?: 'background' | 'visual'
9 relativePath: string
10 }
11
12 export type FinalizationValidationResult = {
13 valid: boolean
14 errors: string[]
15 usedSlotIds: string[]
16 unusedSlotIds: string[]
17 }
18
19 const IMAGE_DRAFT_ATTRIBUTE_PREFIX = 'data-img-'
20
21 const hasImageDraftAttributes = (node: Element): boolean =>
22 Object.keys(node.attribs || {}).some((name) =>
23 name.toLowerCase().startsWith(IMAGE_DRAFT_ATTRIBUTE_PREFIX)
24 )
25
26 const hasImageIntentDrafts = ($: cheerio.CheerioAPI): boolean =>
27 $('*').toArray().some((node) => hasImageDraftAttributes(node as Element))
28
29 const stripImageDraftAttributes = ($: cheerio.CheerioAPI): void => {
30 $('script[data-img-intent]').remove()
31 $('*').each((_index, node) => {
32 const $node = $(node)
33 Object.keys((node as Element).attribs || {})
34 .filter((name) => name.toLowerCase().startsWith(IMAGE_DRAFT_ATTRIBUTE_PREFIX))
35 .forEach((name) => $node.removeAttr(name))
36 })
37 }
38
39 const trimStyleValue = (value: string | undefined): string => (value || '').trim().toLowerCase()
40
41 const classTokens = ($node: cheerio.Cheerio<any>): string[] =>
42 ($node.attr('class') || '')
43 .trim()
44 .split(/\s+/)
45 .filter(Boolean)
46
47 const getStyleValue = ($node: cheerio.Cheerio<any>, property: string): string => {
48 const declarations = ($node.attr('style') || '').split(';')
49 for (const declaration of declarations) {
50 const [rawProperty, ...rawValue] = declaration.split(':')
51 if (rawProperty?.trim().toLowerCase() === property) {
52 return trimStyleValue(rawValue.join(':'))
53 }
54 }
55 return ''
56 }
57
58 const isIncomingAnimation = ($node: cheerio.Cheerio<any>): boolean => {
59 const animation = ($node.attr('data-anim') || '').trim().toLowerCase()
60 return Boolean(animation) && !animation.startsWith('exit-')
61 }
62
63 const hasHiddenUtility = ($node: cheerio.Cheerio<any>): boolean =>
64 classTokens($node).some((token) => token === 'hidden' || token === 'invisible')
65
66 const hasZeroOpacityUtility = ($node: cheerio.Cheerio<any>): boolean =>
67 classTokens($node).includes('opacity-0')
68
69 const isPermanentlyHidden = ($: cheerio.CheerioAPI, node: any): boolean => {
70 let current = $(node)
71 while (current.length > 0) {
72 const display = getStyleValue(current, 'display')
73 const visibility = getStyleValue(current, 'visibility')
74 const opacity = getStyleValue(current, 'opacity')
75 if (display === 'none' || visibility === 'hidden' || hasHiddenUtility(current)) return true
76 if ((opacity === '0' || hasZeroOpacityUtility(current)) && !isIncomingAnimation(current)) {
77 return true
78 }
79 current = current.parent()
80 }
81 return false
82 }
83
84 const isZeroDimension = (value: string): boolean =>
85 /^0(?:px|rem|%|vh|vw)?(?:\s*!important)?$/i.test(value)
86
87 const hasZeroDimensionUtility = ($node: cheerio.Cheerio<any>): boolean =>
88 classTokens($node).some((token) =>
89 /^(?:(?:max-)?[wh]|size)-0$|^(?:(?:max-)?[wh]|size)-\[0(?:px|rem|%|vh|vw)?\]$/i.test(
90 token
91 )
92 )
93
94 const hasZeroDimension = ($: cheerio.CheerioAPI, node: any): boolean => {
95 let current = $(node)
96 while (current.length > 0) {
97 const width = getStyleValue(current, 'width') || trimStyleValue(current.attr('width'))
98 const height = getStyleValue(current, 'height') || trimStyleValue(current.attr('height'))
99 if (isZeroDimension(width) || isZeroDimension(height) || hasZeroDimensionUtility(current)) {
100 return true
101 }
102 current = current.parent()
103 }
104 return false
105 }
106
107 const isBackgroundImageContainer = ($node: cheerio.Cheerio<any>): boolean =>
108 Boolean(getStyleValue($node, 'background-image'))
109
110 const hasBackgroundGeometry = ($node: cheerio.Cheerio<any>): boolean => {
111 const className = ($node.attr('class') || '').toLowerCase()
112 return Boolean(
113 getStyleValue($node, 'height') ||
114 getStyleValue($node, 'min-height') ||
115 getStyleValue($node, 'aspect-ratio') ||
116 getStyleValue($node, 'inset') ||
117 (getStyleValue($node, 'position') === 'absolute' &&
118 (getStyleValue($node, 'top') || getStyleValue($node, 'bottom')) &&
119 (getStyleValue($node, 'left') || getStyleValue($node, 'right'))) ||
120 /(?:^|\s)(?:h-|min-h-|aspect-|inset-)/.test(className)
121 )
122 }
123
124 const usesAsset = ($: cheerio.CheerioAPI, path: string): cheerio.Cheerio<any> => {
125 const image = $(`img[src="${path.replace(/"/g, '\\"')}"]`).first()
126 if (image.length > 0) return image
127 return $(`[style*="${path.replace(/"/g, '\\"')}"]`)
128 .filter((_index, node) => ($(node).attr('style') || '').includes(path))
129 .first()
130 }
131
132 const setInlineStyle = (
133 $node: cheerio.Cheerio<any>,
134 declarations: Record<string, string>
135 ): void => {
136 const existing = ($node.attr('style') || '')
137 .split(';')
138 .map((declaration) => declaration.trim())
139 .filter(Boolean)
140 const values = new Map<string, string>()
141 for (const declaration of existing) {
142 const separator = declaration.indexOf(':')
143 if (separator <= 0) continue
144 values.set(
145 declaration.slice(0, separator).trim().toLowerCase(),
146 declaration.slice(separator + 1).trim()
147 )
148 }
149 for (const [property, value] of Object.entries(declarations)) values.set(property, value)
150 $node.attr(
151 'style',
152 [...values.entries()].map(([property, value]) => `${property}:${value}`).join(';')
153 )
154 }
155
156 const escapeSelectorValue = (value: string): string => value.replace(/(["\\])/g, '\\$1')
157
158 /**
159 * Fulfills image request markers without asking another model to rewrite the page.
160 * Layout slots already own the geometry, so the asset is inserted into that exact node.
161 */
162 export const adoptFinalizedImageAssets = (
163 html: string,
164 assets: FinalizationAsset[]
165 ): { html: string; errors: string[] } => {
166 const $ = cheerio.load(html, { scriptingEnabled: false })
167 const errors: string[] = []
168
169 for (const asset of assets) {
170 const slotId = asset.layoutSlotId || asset.slotId
171 const selector = `[data-ppt-slot="${escapeSelectorValue(slotId)}"][data-img-request]`
172 const slot = $(selector).first()
173 if (slot.length === 0) {
174 errors.push(`Image asset for slot ${asset.slotId} has no matching image request marker.`)
175 continue
176 }
177
178 if ((asset.layer || 'visual') === 'background') {
179 setInlineStyle(slot, {
180 'background-image': `url("${asset.relativePath}")`,
181 'background-size': 'cover',
182 'background-position': 'center',
183 'background-repeat': 'no-repeat'
184 })
185 } else {
186 slot.empty()
187 const blockId = `generated-image-${slotId.replace(/[^A-Za-z0-9_-]+/g, '-')}`
188 slot.append(
189 `<img data-block-id="${blockId}" data-ppt-generated-image="true" src="${asset.relativePath}" alt="" style="display:block;width:100%;height:100%;object-fit:cover" />`
190 )
191 }
192 }
193
194 if (errors.length === 0) {
195 stripImageDraftAttributes($)
196 }
197
198 return { html: $.html(), errors }
199 }
200
201 export const stripImageIntentDrafts = (html: string): string => {
202 const $ = cheerio.load(html, { scriptingEnabled: false })
203 stripImageDraftAttributes($)
204 return $.html()
205 }
206
207 export const validateFinalizedImageHtml = (
208 html: string,
209 assets: FinalizationAsset[]
210 ): FinalizationValidationResult => {
211 const $ = cheerio.load(html, { scriptingEnabled: false })
212 const errors = [...validateDataAnimContract(html).errors]
213 const usedSlotIds: string[] = []
214 const unusedSlotIds: string[] = []
215
216 if (hasImageIntentDrafts($)) {
217 errors.push('Final HTML must not contain image intent draft attributes or scripts.')
218 }
219 if (html.includes('/.staging/') || html.includes('images/.staging/')) {
220 errors.push('Final HTML must not reference staging image assets.')
221 }
222
223 for (const asset of assets) {
224 const node = usesAsset($, asset.relativePath)
225 if (node.length === 0) {
226 unusedSlotIds.push(asset.slotId)
227 continue
228 }
229 usedSlotIds.push(asset.slotId)
230 if (isPermanentlyHidden($, node.get(0))) {
231 errors.push(`Image asset for slot ${asset.slotId} is permanently hidden.`)
232 }
233 if (hasZeroDimension($, node.get(0))) {
234 errors.push(`Image asset for slot ${asset.slotId} has a zero dimension.`)
235 }
236 if (isBackgroundImageContainer(node) && !hasBackgroundGeometry(node)) {
237 errors.push(`Background image asset for slot ${asset.slotId} has no visible geometry.`)
238 }
239 }
240
241 return {
242 valid: errors.length === 0,
243 errors,
244 usedSlotIds,
245 unusedSlotIds
246 }
247 }
248
248 lines TYPESCRIPT