返回 oh-my-ppt
data-anim-validator.ts
根目录 / src / main / animation / data-anim-validator.ts
1 import * as cheerio from 'cheerio'
2 import {
3 DATA_ANIM_FROM_VALUES,
4 DATA_ANIM_SEQUENCES,
5 DATA_ANIM_SUPPORTED_TYPES,
6 DATA_ANIM_TRIGGERS,
7 normalizeDataAnimTrigger,
8 type DataAnimSequence
9 } from '../../shared/element-animation'
10
11 const LINEAR_PATH_RE =
12 /^M\s+-?\d+(?:\.\d+)?\s+-?\d+(?:\.\d+)?\s+L\s+-?\d+(?:\.\d+)?\s+-?\d+(?:\.\d+)?\s*$/i
13 const CLICK_GROUP_RE = /^[A-Za-z0-9][A-Za-z0-9_-]*$/
14
15 const isLinearMotionPathString = (value: string): boolean => LINEAR_PATH_RE.test(value.trim())
16
17 const normalizeAnimTrigger = (value: string): 'load' | 'click' | 'with' | 'after' =>
18 normalizeDataAnimTrigger(value) ?? 'load'
19
20 export function validateDataAnimContract(
21 html: string
22 ): { valid: boolean; errors: string[] } {
23 const errors: string[] = []
24 const supportedAnimTypes = new Set<string>(DATA_ANIM_SUPPORTED_TYPES)
25 const supportedAnimTriggers = new Set<string>(DATA_ANIM_TRIGGERS)
26 const supportedAnimFromValues = new Set<string>(DATA_ANIM_FROM_VALUES)
27
28 try {
29 const $ = cheerio.load(html, { scriptingEnabled: false })
30 const invalidAnimTypes = new Set<string>()
31 $('[data-anim]').each((_, node) => {
32 const type = ($(node).attr('data-anim') || '').trim().toLowerCase()
33 if (!type || !supportedAnimTypes.has(type)) {
34 invalidAnimTypes.add(type || '(empty)')
35 }
36 })
37 if (invalidAnimTypes.size > 0) {
38 errors.push(
39 `data-anim 仅支持当前公开可编辑动画类型,非法值:${Array.from(invalidAnimTypes).join(', ')}`
40 )
41 }
42
43 const invalidTriggers = new Set<string>()
44 $('[data-anim-trigger]').each((_, node) => {
45 const trigger = ($(node).attr('data-anim-trigger') || '').trim().toLowerCase()
46 // Generation gate enforces canonical triggers; legacy aliases are tolerated
47 // only by the editor's parse path (normalizeDataAnimTrigger).
48 if (!trigger || !supportedAnimTriggers.has(trigger)) {
49 invalidTriggers.add(trigger || '(empty)')
50 }
51 })
52 if (invalidTriggers.size > 0) {
53 errors.push(
54 `data-anim-trigger 仅支持 ${DATA_ANIM_TRIGGERS.join('/')},非法值:${Array.from(invalidTriggers).join(', ')}`
55 )
56 }
57
58 const invalidFromValues = new Set<string>()
59 const incompatibleCenterAnims = new Set<string>()
60 $('[data-anim-from]').each((_, node) => {
61 const from = ($(node).attr('data-anim-from') || '').trim().toLowerCase()
62 if (!from || !supportedAnimFromValues.has(from)) {
63 invalidFromValues.add(from || '(empty)')
64 }
65 if (from === 'center') {
66 const animType = ($(node).attr('data-anim') || '').trim().toLowerCase()
67 if (['fly-in', 'wipe', 'exit-fly', 'exit-wipe'].includes(animType)) {
68 incompatibleCenterAnims.add(animType)
69 }
70 }
71 })
72 if (invalidFromValues.size > 0) {
73 errors.push(
74 `data-anim-from 仅支持 ${DATA_ANIM_FROM_VALUES.join('/')},非法值:${Array.from(invalidFromValues).join(', ')}`
75 )
76 }
77 if (incompatibleCenterAnims.size > 0) {
78 errors.push(
79 `data-anim-from="center" 与以下动画类型不兼容(无法往返):${Array.from(incompatibleCenterAnims).join(', ')}。center 仅支持 fade/zoom/path 类动画`
80 )
81 }
82
83 const missingPathValues = new Set<string>()
84 const unexpectedPathValues = new Set<string>()
85 $('[data-anim]').each((_, node) => {
86 const type = ($(node).attr('data-anim') || '').trim().toLowerCase()
87 const rawPath = ($(node).attr('data-anim-path') || '').trim()
88 if (type === 'path') {
89 if (!rawPath || !isLinearMotionPathString(rawPath)) {
90 missingPathValues.add(rawPath || 'path')
91 }
92 return
93 }
94 if ($(node).attr('data-anim-path') !== undefined) {
95 unexpectedPathValues.add(type || '(empty)')
96 }
97 })
98 if (missingPathValues.size > 0) {
99 errors.push(
100 `data-anim="path" 必须同时提供可解析为线性位移的 data-anim-path,非法值:${Array.from(missingPathValues).join(', ')}`
101 )
102 }
103 if (unexpectedPathValues.size > 0) {
104 errors.push(
105 `只有 data-anim="path" 才能使用 data-anim-path,非法类型:${Array.from(unexpectedPathValues).join(', ')}`
106 )
107 }
108
109 const invalidDurations = new Set<string>()
110 $('[data-anim-duration]').each((_, node) => {
111 const raw = ($(node).attr('data-anim-duration') || '').trim()
112 const value = Number(raw)
113 if (!raw || !Number.isFinite(value) || value < 100 || value > 5000) {
114 invalidDurations.add(raw || '(empty)')
115 }
116 })
117 if (invalidDurations.size > 0) {
118 errors.push(
119 `data-anim-duration 必须是 100-5000 的数字毫秒值,非法值:${Array.from(invalidDurations).join(', ')}`
120 )
121 }
122
123 const invalidDelays = new Set<string>()
124 $('[data-anim-delay]').each((_, node) => {
125 const raw = ($(node).attr('data-anim-delay') || '').trim()
126 if (!raw) {
127 invalidDelays.add('(empty)')
128 return
129 }
130 if (/^stagger\s*\(\s*\d+\s*\)$/i.test(raw)) return
131 const value = Number(raw)
132 if (!Number.isFinite(value) || value < 0) invalidDelays.add(raw)
133 })
134 if (invalidDelays.size > 0) {
135 errors.push(
136 `data-anim-delay 必须是大于等于 0 的数字毫秒值或 stagger(N),非法值:${Array.from(invalidDelays).join(', ')}`
137 )
138 }
139
140 const invalidStaggers = new Set<string>()
141 $('[data-anim-stagger]').each((_, node) => {
142 const raw = ($(node).attr('data-anim-stagger') || '').trim()
143 const value = Number(raw)
144 if (!raw || !Number.isFinite(value) || value < 0) {
145 invalidStaggers.add(raw || '(empty)')
146 }
147 })
148 if (invalidStaggers.size > 0) {
149 errors.push(
150 `data-anim-stagger 必须是大于等于 0 的数字毫秒值,非法值:${Array.from(invalidStaggers).join(', ')}`
151 )
152 }
153
154 const runtimeOnlyAttributes = [
155 ['data-anim-easing', 'data-anim-easing'],
156 ['data-anim-repeat', 'data-anim-repeat'],
157 ['data-anim-direction', 'data-anim-direction']
158 ] as const
159 for (const [selector, label] of runtimeOnlyAttributes) {
160 const values = new Set<string>()
161 $(`[${selector}]`).each((_, node) => {
162 values.add(($(node).attr(selector) || '').trim() || '(empty)')
163 })
164 if (values.size > 0) {
165 errors.push(
166 `${label} 当前属于 runtime-only 兼容能力,不应进入标准可编辑导出页面,非法值:${Array.from(values).join(', ')}`
167 )
168 }
169 }
170
171 const invalidSequences = new Set<string>()
172 const clickSequences = new Set<string>()
173 $('[data-anim-sequence]').each((_, node) => {
174 const value = ($(node).attr('data-anim-sequence') || '').trim().toLowerCase()
175 if (!value || !DATA_ANIM_SEQUENCES.includes(value as DataAnimSequence)) {
176 invalidSequences.add(value || '(empty)')
177 return
178 }
179 const trigger = normalizeAnimTrigger($(node).attr('data-anim-trigger') || 'load')
180 if (trigger === 'click') clickSequences.add(value)
181 })
182 if (invalidSequences.size > 0) {
183 errors.push(
184 `data-anim-sequence 仅支持 with/after,非法值:${Array.from(invalidSequences).join(', ')}`
185 )
186 }
187 if (clickSequences.size > 0) {
188 errors.push(
189 `data-anim-sequence 仅用于自动动画顺序,click 动画不能使用:${Array.from(clickSequences).join(', ')}`
190 )
191 }
192
193 const invalidClickGroups = new Set<string>()
194 const nonClickGrouped: string[] = []
195 const clickGroupTimeline: Array<string | null> = []
196 $('[data-anim]').each((_, node) => {
197 const trigger = normalizeAnimTrigger($(node).attr('data-anim-trigger') || 'load')
198 const attrValue = $(node).attr('data-anim-click-group')
199 const group = (attrValue || '').trim()
200 if (trigger !== 'click') {
201 if (attrValue !== undefined && !group) {
202 invalidClickGroups.add('(empty)')
203 return
204 }
205 if (!group) return
206 if (!CLICK_GROUP_RE.test(group)) {
207 invalidClickGroups.add(group)
208 return
209 }
210 nonClickGrouped.push(group)
211 return
212 }
213 if (attrValue === undefined) {
214 clickGroupTimeline.push(null)
215 return
216 }
217 if (!group) {
218 invalidClickGroups.add('(empty)')
219 clickGroupTimeline.push(null)
220 return
221 }
222 if (!CLICK_GROUP_RE.test(group)) {
223 invalidClickGroups.add(group)
224 clickGroupTimeline.push(null)
225 return
226 }
227 clickGroupTimeline.push(group)
228 })
229 if (invalidClickGroups.size > 0) {
230 errors.push(
231 `data-anim-click-group 仅支持字母/数字/中划线/下划线,并且必须以字母或数字开头,非法值:${Array.from(invalidClickGroups).join(', ')}`
232 )
233 }
234 if (nonClickGrouped.length > 0) {
235 errors.push(
236 `data-anim-click-group 只能用于 click 触发动画,非法分组:${Array.from(new Set(nonClickGrouped)).join(', ')}`
237 )
238 }
239 if (clickGroupTimeline.length > 1) {
240 const closedGroups = new Set<string>()
241 let activeGroup: string | null = null
242 for (const group of clickGroupTimeline) {
243 if (!group) {
244 if (activeGroup) {
245 closedGroups.add(activeGroup)
246 activeGroup = null
247 }
248 continue
249 }
250 if (group === activeGroup) continue
251 if (closedGroups.has(group)) {
252 errors.push(`data-anim-click-group 必须在 click 动画的 DOM 顺序上连续出现,非法分组:${group}`)
253 break
254 }
255 if (activeGroup) closedGroups.add(activeGroup)
256 activeGroup = group
257 }
258 }
259 } catch {
260 errors.push('HTML 动画结构解析失败')
261 }
262
263 return { valid: errors.length === 0, errors }
264 }
265
266 /**
267 * Return only the contract violations a patch newly introduces (whole-page before/after diff).
268 * Pre-existing violations cancel out, so old debt elsewhere doesn't block a targeted edit;
269 * page-level constraints the patch can affect (e.g. click-group continuity) still surface.
270 */
271 export function validateDataAnimPatch(
272 beforeHtml: string,
273 afterHtml: string
274 ): { newErrors: string[] } {
275 const beforeErrors = validateDataAnimContract(beforeHtml).errors
276 const afterErrors = validateDataAnimContract(afterHtml).errors
277 return { newErrors: afterErrors.filter((error) => !beforeErrors.includes(error)) }
278 }
279
279 lines TYPESCRIPT