返回 oh-my-ppt
shared.ts
根目录 / src / main / element-editor / shared.ts
1 /** Shared HTML element patching primitives for the structured editor. */
2 import * as cheerio from 'cheerio'
3 import { nanoid } from 'nanoid'
4 import type { AnyNode } from 'domhandler'
5
6 // ─── 共享锁 ───────────────────────────────────────────────
7
8 const htmlWriteLocks = new Map<string, Promise<void>>()
9
10 export async function withHtmlFileLock<T>(htmlPath: string, fn: () => Promise<T>): Promise<T> {
11 const previous = htmlWriteLocks.get(htmlPath) || Promise.resolve()
12 const run = previous.then(fn, fn)
13 const next = run.then(
14 () => undefined,
15 () => undefined
16 )
17 htmlWriteLocks.set(htmlPath, next)
18 return run.finally(() => {
19 if (htmlWriteLocks.get(htmlPath) === next) {
20 htmlWriteLocks.delete(htmlPath)
21 }
22 })
23 }
24
25 // ─── 常量 ─────────────────────────────────────────────────
26
27 export const INLINE_TAGS = new Set([
28 'a',
29 'abbr',
30 'b',
31 'code',
32 'em',
33 'i',
34 'label',
35 'small',
36 'span',
37 'strong',
38 'sub',
39 'sup'
40 ])
41
42 export const EDITABLE_TEXT_TAGS = new Set([
43 'h1',
44 'h2',
45 'h3',
46 'h4',
47 'h5',
48 'h6',
49 'p',
50 'ul',
51 'ol',
52 'li',
53 'span',
54 'strong',
55 'em',
56 'b',
57 'i',
58 'u',
59 's',
60 'small',
61 'label',
62 'button',
63 'td',
64 'th',
65 'blockquote',
66 'figcaption',
67 'sub',
68 'sup'
69 ])
70 export const EDITABLE_TEXT_CHILD_TAGS = new Set([...EDITABLE_TEXT_TAGS, 'br'])
71
72 export const SCAFFOLD_BLOCK_IDS = new Set(['content', 'page', 'root'])
73 const SUPPORTED_SIMPLE_CHART_TYPES = new Set(['bar', 'line', 'pie', 'doughnut', 'radar'])
74 export const BLOCKED_TAGS = new Set([
75 'html',
76 'head',
77 'body',
78 'script',
79 'style',
80 'link',
81 'meta',
82 'title'
83 ])
84
85 // ─── 通用工具函数 ──────────────────────────────────────────
86
87 export function parseStyle(style: string): Map<string, string> {
88 const map = new Map<string, string>()
89 for (const rawDeclaration of style.split(';')) {
90 const declaration = rawDeclaration.trim()
91 if (!declaration) continue
92 const separatorIndex = declaration.indexOf(':')
93 if (separatorIndex < 0) continue
94 const key = declaration.slice(0, separatorIndex).trim()
95 const value = declaration.slice(separatorIndex + 1).trim()
96 if (!key || !value) continue
97 map.set(key, value)
98 }
99 return map
100 }
101
102 export function serializeStyle(styleMap: Map<string, string>): string {
103 return Array.from(styleMap.entries())
104 .map(([key, value]) => `${key}: ${value}`)
105 .join('; ')
106 }
107
108 function stripStyleKeys(style: string, keys: string[]): string {
109 const excluded = new Set(keys.map((key) => key.toLowerCase()))
110 const styleMap = parseStyle(style)
111 let changed = false
112 for (const key of Array.from(styleMap.keys())) {
113 if (excluded.has(key.toLowerCase())) {
114 styleMap.delete(key)
115 changed = true
116 }
117 }
118 return changed ? serializeStyle(styleMap) : style
119 }
120
121 export function clampDragValue(value: unknown): number {
122 const parsed = Number(value)
123 if (!Number.isFinite(parsed)) return 0
124 return Math.max(-1600, Math.min(1600, Math.round(parsed * 10) / 10))
125 }
126
127 export function clampSizeValue(value: unknown): number | null {
128 if (value === undefined || value === null) return null
129 const parsed = Number(value)
130 if (!Number.isFinite(parsed)) return null
131 return Math.max(1, Math.min(3200, Math.round(parsed * 10) / 10))
132 }
133
134 function clampLayoutIslandCoordinate(value: unknown): number {
135 const parsed = Number(value)
136 if (!Number.isFinite(parsed)) return 0
137 return Math.max(-3200, Math.min(3200, Math.round(parsed * 10) / 10))
138 }
139
140 export interface ChildStyleUpdate {
141 path: number[]
142 width: number | null
143 height: number | null
144 }
145
146 export interface LayoutIslandChild {
147 index: number
148 x: number
149 y: number
150 width: number
151 height: number
152 }
153
154 export interface LayoutIslandStyle {
155 selector: string
156 width: number
157 height: number
158 children: LayoutIslandChild[]
159 }
160
161 export function normalizeChildStyleUpdates(value: unknown): ChildStyleUpdate[] {
162 if (!Array.isArray(value)) return []
163 return value
164 .map((item): ChildStyleUpdate | null => {
165 if (!item || typeof item !== 'object') return null
166 const record = item as { path?: unknown; width?: unknown; height?: unknown }
167 if (!Array.isArray(record.path) || record.path.length === 0 || record.path.length > 12)
168 return null
169 const path = record.path
170 .map((part) => Number(part))
171 .filter((part) => Number.isInteger(part) && part >= 0 && part <= 200)
172 if (path.length !== record.path.length) return null
173 const width = clampSizeValue(record.width)
174 const height = clampSizeValue(record.height)
175 if (width === null && height === null) return null
176 return { path, width, height }
177 })
178 .filter((item): item is ChildStyleUpdate => item !== null)
179 .slice(0, 20)
180 }
181
182 export function normalizeLayoutIslandStyle(value: unknown): LayoutIslandStyle | null {
183 if (!value || typeof value !== 'object') return null
184 const record = value as {
185 selector?: unknown
186 width?: unknown
187 height?: unknown
188 children?: unknown
189 }
190 const selector = typeof record.selector === 'string' ? record.selector.trim() : ''
191 const width = clampSizeValue(record.width)
192 const height = clampSizeValue(record.height)
193 if (!selector || selector.length > 1000 || width === null || height === null) return null
194 if (!Array.isArray(record.children)) return null
195 const children = record.children
196 .map((item): LayoutIslandChild | null => {
197 if (!item || typeof item !== 'object') return null
198 const child = item as {
199 index?: unknown
200 x?: unknown
201 y?: unknown
202 width?: unknown
203 height?: unknown
204 }
205 const index = Number(child.index)
206 const childWidth = clampSizeValue(child.width)
207 const childHeight = clampSizeValue(child.height)
208 if (
209 !Number.isInteger(index) ||
210 index < 0 ||
211 index > 200 ||
212 childWidth === null ||
213 childHeight === null
214 ) {
215 return null
216 }
217 return {
218 index,
219 x: clampLayoutIslandCoordinate(child.x),
220 y: clampLayoutIslandCoordinate(child.y),
221 width: childWidth,
222 height: childHeight
223 }
224 })
225 .filter((item): item is LayoutIslandChild => item !== null)
226 .slice(0, 80)
227 return children.length > 0 ? { selector, width, height, children } : null
228 }
229
230 export function normalizeText(value: unknown): string {
231 return String(value ?? '')
232 .replace(/\s+/g, ' ')
233 .trim()
234 }
235
236 export function normalizeColor(value: unknown): string | null {
237 if (typeof value !== 'string') return null
238 const text = value.trim()
239 if (!text) return null
240 if (/^#[0-9a-f]{3}(?:[0-9a-f]{3})?$/i.test(text)) return text
241 if (/^rgba?\([\d\s.,%]+\)$/i.test(text)) return text
242 return null
243 }
244
245 function applyInsertedSvgPaintColor(
246 $: cheerio.CheerioAPI,
247 target: cheerio.Cheerio<AnyNode>,
248 color: string | null,
249 styleMap: Map<string, string>
250 ): boolean {
251 if (!color) return false
252 const editKind = target.attr('data-ppt-edit-kind')
253 if (editKind !== 'shape' && editKind !== 'icon') return false
254 if (editKind === 'icon') {
255 styleMap.set('color', color)
256 return true
257 }
258
259 const paintTargets = target.find(
260 'svg [fill], svg [stroke], svg path, svg rect, svg circle, svg ellipse, svg line, svg polygon, svg polyline'
261 )
262 if (paintTargets.length === 0) return false
263 paintTargets.each((_, node) => {
264 const item = $(node)
265 const fill = item.attr('fill')
266 const stroke = item.attr('stroke')
267 if (fill && fill !== 'none') item.attr('fill', color)
268 if (stroke && stroke !== 'none') item.attr('stroke', color)
269 if ((!fill || fill === 'none') && (!stroke || stroke === 'none')) {
270 item.attr('fill', color)
271 }
272 })
273 return true
274 }
275
276 export function normalizeFontSize(value: unknown): string | null {
277 const raw =
278 typeof value === 'number' ? String(value) : typeof value === 'string' ? value.trim() : ''
279 if (!raw) return null
280 const numberValue = Number(raw.replace(/px$/i, ''))
281 if (!Number.isFinite(numberValue)) return null
282 const clamped = Math.max(8, Math.min(160, Math.round(numberValue * 10) / 10))
283 return `${clamped}px`
284 }
285
286 export function normalizeFontWeight(value: unknown): string | null {
287 const raw =
288 typeof value === 'number' ? String(value) : typeof value === 'string' ? value.trim() : ''
289 if (!raw) return null
290 if (['normal', 'bold', 'lighter', 'bolder'].includes(raw)) return raw
291 const numberValue = Number(raw)
292 if (!Number.isFinite(numberValue)) return null
293 const clamped = Math.max(100, Math.min(900, Math.round(numberValue / 100) * 100))
294 return String(clamped)
295 }
296
297 // Keep in sync with normalizeTextAlign in src/renderer/src/pages/session-detail.tsx.
298 export function normalizeTextAlign(value: unknown): string | null {
299 if (typeof value !== 'string') return null
300 const text = value.trim()
301 return ['left', 'center', 'right', 'justify'].includes(text) ? text : null
302 }
303
304 export function normalizeOpacity(value: unknown): string | null {
305 const parsed = Number(value)
306 if (!Number.isFinite(parsed)) return null
307 return String(Math.max(0, Math.min(1, Math.round(parsed * 100) / 100)))
308 }
309
310 export function normalizeObjectFit(value: unknown): string | null {
311 if (typeof value !== 'string') return null
312 const text = value.trim()
313 return ['contain', 'cover', 'fill', 'none', 'scale-down'].includes(text) ? text : null
314 }
315
316 export function normalizeBoolean(value: unknown): boolean | null {
317 if (typeof value === 'boolean') return value
318 return null
319 }
320
321 export const attrEscape = (value: string): string => value.replace(/"/g, '\\"')
322
323 export const stableSelectorFor = (pageId: string, blockId: string): string =>
324 `body[data-page-id="${attrEscape(pageId)}"] [data-block-id="${attrEscape(blockId)}"]`
325
326 export function allocateBlockId(): string {
327 return 'select-arcsin1-' + nanoid(8)
328 }
329
330 export function assertAnchorableElement(target: cheerio.Cheerio<AnyNode>): void {
331 const node = target.get(0)
332 const tagName = String((node as { tagName?: string })?.tagName || '').toLowerCase()
333 if (!tagName || BLOCKED_TAGS.has(tagName)) {
334 throw new Error(`当前元素不能锚定:<${tagName || 'unknown'}>`)
335 }
336 const role = (target.attr('data-role') || '').trim()
337 const blockId = (target.attr('data-block-id') || '').trim()
338 const classRaw = target.attr('class') || ''
339 const guardRoot = target.attr('data-ppt-guard-root') === '1'
340 if (
341 role === 'content' ||
342 SCAFFOLD_BLOCK_IDS.has(blockId) ||
343 guardRoot ||
344 /\bppt-page-(?:root|content|fit-scope)\b/.test(classRaw)
345 ) {
346 throw new Error('页面骨架元素不能锚定,请选择页面内容里的具体元素')
347 }
348 }
349
350 // ─── Patch 函数 ────────────────────────────────────────────
351
352 function patchLayoutIslandStyle(
353 $: cheerio.CheerioAPI,
354 layoutIsland: LayoutIslandStyle
355 ): void {
356 let island: cheerio.Cheerio<AnyNode>
357 try {
358 island = $(layoutIsland.selector).first()
359 } catch {
360 return
361 }
362 if (!island.length) return
363
364 const islandStyle = parseStyle(island.attr('style') || '')
365 const position = String(islandStyle.get('position') || '')
366 .trim()
367 .toLowerCase()
368 if (!position || position === 'static') islandStyle.set('position', 'relative')
369 islandStyle.set('display', 'block')
370 islandStyle.set('box-sizing', 'border-box')
371 islandStyle.set('width', `${layoutIsland.width}px`)
372 islandStyle.set('height', `${layoutIsland.height}px`)
373 island.attr('style', serializeStyle(islandStyle))
374 island.attr('data-ppt-layout-frozen', '1')
375
376 for (const childLayout of layoutIsland.children) {
377 const child = island.children().eq(childLayout.index)
378 if (!child.length) continue
379 const childStyle = parseStyle(child.attr('style') || '')
380 childStyle.set('position', 'absolute')
381 childStyle.set('left', `${childLayout.x}px`)
382 childStyle.set('top', `${childLayout.y}px`)
383 childStyle.set('width', `${childLayout.width}px`)
384 childStyle.set('height', `${childLayout.height}px`)
385 childStyle.set('margin', '0')
386 childStyle.set('box-sizing', 'border-box')
387 childStyle.delete('--ppt-drag-x')
388 childStyle.delete('--ppt-drag-y')
389 childStyle.delete('translate')
390 childStyle.delete('will-change')
391 child.attr('style', serializeStyle(childStyle))
392 child.attr('data-ppt-layout-converted', '1')
393 }
394 }
395
396 export function patchDraggedElementStyle(
397 html: string,
398 selector: string,
399 x: number,
400 y: number,
401 width: number | null,
402 height: number | null,
403 childUpdates: ChildStyleUpdate[],
404 isAbsoluteMode: boolean,
405 zIndex?: number,
406 zIndexOnly?: boolean,
407 layoutIsland?: LayoutIslandStyle | null
408 ): string {
409 const $ = cheerio.load(html, { scriptingEnabled: false })
410 let target
411 try {
412 target = $(selector).first()
413 } catch {
414 return html
415 }
416 if (!target || target.length === 0) return html
417
418 if (layoutIsland) patchLayoutIslandStyle($, layoutIsland)
419
420 const styleMap = parseStyle(target.attr('style') || '')
421
422 // zIndexOnly: only update z-index, leave everything else untouched
423 if (zIndexOnly && zIndex !== undefined) {
424 const position = String(styleMap.get('position') || '')
425 .trim()
426 .toLowerCase()
427 if (!position || position === 'static') styleMap.set('position', 'relative')
428 styleMap.set('z-index', String(zIndex))
429 target.attr('style', serializeStyle(styleMap))
430 return $.html()
431 }
432
433 const tagName = String(target.get(0)?.tagName || '').toLowerCase()
434 const effectiveZIndex = zIndex !== undefined ? String(zIndex) : undefined
435
436 if (isAbsoluteMode) {
437 styleMap.set('position', 'absolute')
438 styleMap.set('left', `${x}px`)
439 styleMap.set('top', `${y}px`)
440 if (width !== null) styleMap.set('width', `${width}px`)
441 if (height !== null) styleMap.set('height', `${height}px`)
442 if (effectiveZIndex !== undefined) {
443 styleMap.set('z-index', effectiveZIndex)
444 } else if (!styleMap.has('z-index')) {
445 styleMap.set('z-index', '10')
446 }
447 styleMap.delete('--ppt-drag-x')
448 styleMap.delete('--ppt-drag-y')
449 styleMap.delete('translate')
450 styleMap.delete('will-change')
451 target.attr('data-ppt-layout-converted', '1')
452 } else {
453 if (INLINE_TAGS.has(tagName) && !styleMap.has('display')) {
454 styleMap.set('display', 'inline-block')
455 }
456 const position = String(styleMap.get('position') || '')
457 .trim()
458 .toLowerCase()
459 if (!position || position === 'static') {
460 styleMap.set('position', 'relative')
461 }
462 if (effectiveZIndex !== undefined) {
463 styleMap.set('z-index', effectiveZIndex)
464 } else if (!styleMap.has('z-index')) {
465 styleMap.set('z-index', '10')
466 }
467 styleMap.set('--ppt-drag-x', `${x}px`)
468 styleMap.set('--ppt-drag-y', `${y}px`)
469 styleMap.set('translate', 'var(--ppt-drag-x, 0px) var(--ppt-drag-y, 0px)')
470 if (width !== null) styleMap.set('width', `${width}px`)
471 if (height !== null) styleMap.set('height', `${height}px`)
472 styleMap.delete('will-change')
473 }
474 target.attr('style', serializeStyle(styleMap))
475
476 for (const childUpdate of childUpdates) {
477 let child = target
478 for (const index of childUpdate.path) {
479 child = child.children().eq(index)
480 if (!child || child.length === 0) break
481 }
482 if (!child || child.length === 0) continue
483 if (String(child.get(0)?.tagName || '').toLowerCase() === 'canvas') continue
484 const childStyleMap = parseStyle(child.attr('style') || '')
485 if (childUpdate.width !== null) childStyleMap.set('width', `${childUpdate.width}px`)
486 if (childUpdate.height !== null) childStyleMap.set('height', `${childUpdate.height}px`)
487 child.attr('style', serializeStyle(childStyleMap))
488 }
489
490 return $.html()
491 }
492
493 export function hasOnlyEditableTextChildren(
494 $: cheerio.CheerioAPI,
495 target: cheerio.Cheerio<AnyNode>
496 ): boolean {
497 return target
498 .children()
499 .toArray()
500 .every((child) => {
501 const childTagName = String(child.tagName || '').toLowerCase()
502 if (!childTagName || !EDITABLE_TEXT_CHILD_TAGS.has(childTagName)) return false
503 const childElement = $(child)
504 return hasOnlyEditableTextChildren($, childElement)
505 })
506 }
507
508 export function patchElementProperties(
509 html: string,
510 selector: string,
511 patch: {
512 html?: string
513 text?: string
514 textTarget?: unknown
515 style?: {
516 color?: string
517 fontSize?: string
518 fontWeight?: string
519 textAlign?: string
520 }
521 }
522 ): string {
523 const $ = cheerio.load(html, { scriptingEnabled: false })
524 let target: cheerio.Cheerio<AnyNode>
525 try {
526 target = $(selector).first()
527 } catch {
528 return html
529 }
530 if (!target || target.length === 0) return html
531
532 const node = target.get(0) as { tagName?: string } | undefined
533 const tagName = String(node?.tagName || '').toLowerCase()
534 const hasRole = Boolean(target.attr('data-role'))
535 const hasBlockId = Boolean(target.attr('data-block-id'))
536 if (!EDITABLE_TEXT_TAGS.has(tagName) && !hasRole && !hasBlockId) {
537 throw new Error(`当前元素暂不支持直接编辑文字:<${tagName || 'unknown'}>`)
538 }
539 if (!hasOnlyEditableTextChildren($, target)) {
540 throw new Error('当前元素包含非文本子元素,暂不支持直接编辑;可以选择更内层的文字。')
541 }
542
543 if (typeof patch.html === 'string') {
544 const nextHtml = stripUnsafeRichTextHtml(patch.html)
545 const text = normalizeText(
546 cheerio.load(`<root>${nextHtml}</root>`, { scriptingEnabled: false }, false).text()
547 )
548 if (!text) throw new Error('文字不能为空')
549 if (text.length > 500) throw new Error('文字不能超过 500 个字符')
550 target.html(nextHtml)
551 } else if (typeof patch.text === 'string') {
552 const text = patch.text
553 const normalizedText = normalizeText(text)
554 if (!normalizedText) throw new Error('文字不能为空')
555 if (normalizedText.length > 500) throw new Error('文字不能超过 500 个字符')
556 if (!patchTextNodeTarget($, patch.textTarget, text)) {
557 target.text(normalizedText)
558 }
559 }
560
561 const stylePatch = patch.style || {}
562 const styleMap = parseStyle(target.attr('style') || '')
563 const color = normalizeColor(stylePatch.color)
564 const fontSize = normalizeFontSize(stylePatch.fontSize)
565 const fontWeight = normalizeFontWeight(stylePatch.fontWeight)
566 const textAlign = normalizeTextAlign(stylePatch.textAlign)
567 if (color) styleMap.set('color', color)
568 if (fontSize) styleMap.set('font-size', fontSize)
569 if (fontWeight) styleMap.set('font-weight', fontWeight)
570 if (textAlign) styleMap.set('text-align', textAlign)
571 if (color || fontSize || fontWeight || textAlign) {
572 target.attr('style', serializeStyle(styleMap))
573 }
574
575 return $.html()
576 }
577
578 export interface TextNodeTarget {
579 type: 'text-node'
580 parentSelector: string
581 textNodeIndex: number
582 }
583
584 function normalizeTextNodeTarget(value: unknown): TextNodeTarget | null {
585 if (!value || typeof value !== 'object') return null
586 const record = value as {
587 type?: unknown
588 parentSelector?: unknown
589 textNodeIndex?: unknown
590 }
591 if (record.type !== 'text-node') return null
592 const parentSelector =
593 typeof record.parentSelector === 'string' ? record.parentSelector.trim() : ''
594 const textNodeIndex = Number(record.textNodeIndex)
595 if (
596 !parentSelector ||
597 !Number.isInteger(textNodeIndex) ||
598 textNodeIndex < 0 ||
599 textNodeIndex > 1000
600 ) {
601 return null
602 }
603 return { type: 'text-node', parentSelector, textNodeIndex }
604 }
605
606 export function patchTextNodeTarget(
607 $: cheerio.CheerioAPI,
608 textTarget: unknown,
609 text: string
610 ): boolean {
611 const target = normalizeTextNodeTarget(textTarget)
612 if (!target) return false
613 let parent: cheerio.Cheerio<AnyNode>
614 try {
615 parent = $(target.parentSelector).first()
616 } catch {
617 return false
618 }
619 if (!parent || parent.length === 0) return false
620 const node = parent.contents().get(target.textNodeIndex) as
621 | (AnyNode & { type?: string; data?: string })
622 | undefined
623 if (!node || node.type !== 'text') return false
624 node.data = text
625 return true
626 }
627
628 function stripUnsafeRichTextHtml(html: string): string {
629 const $ = cheerio.load(`<root>${html}</root>`, { scriptingEnabled: false }, false)
630 const root = $('root').first()
631 root
632 .find(
633 'script, style, iframe, object, embed, img, video, audio, canvas, svg, input, textarea, select'
634 )
635 .remove()
636 root.find('*').each((_, node) => {
637 const el = $(node)
638 const tagName = String(node.tagName || '').toLowerCase()
639 if (!EDITABLE_TEXT_CHILD_TAGS.has(tagName)) {
640 el.replaceWith(el.contents())
641 return
642 }
643 const attrs = { ...(node.attribs || {}) }
644 for (const name of Object.keys(attrs)) {
645 if (
646 name === 'style' ||
647 name === 'class' ||
648 name === 'data-block-id' ||
649 (name === 'data-text' && el.hasClass('ppt-art-text')) ||
650 name === 'href' ||
651 name === 'target' ||
652 name === 'rel'
653 ) {
654 continue
655 }
656 el.removeAttr(name)
657 }
658 const style = stripStyleKeys(el.attr('style') || '', ['zoom'])
659 if (style) el.attr('style', style)
660 else el.removeAttr('style')
661 if (tagName === 'a') {
662 const href = el.attr('href') || ''
663 if (href && !/^(https?:|mailto:|#)/i.test(href)) el.removeAttr('href')
664 if (el.attr('target') === '_blank') el.attr('rel', 'noopener noreferrer')
665 }
666 })
667 return root.html() || ''
668 }
669
670 function stripUnsafeFormulaHtml(
671 html: string,
672 latex: string,
673 displayMode: boolean,
674 blockId?: string
675 ): string {
676 const normalizedLatex = normalizeText(latex)
677 if (!normalizedLatex) throw new Error('公式不能为空')
678 if (normalizedLatex.length > 2000) throw new Error('公式不能超过 2000 个字符')
679 if (html.length > 100000) throw new Error('公式内容过长')
680 const $ = cheerio.load(`<root>${html}</root>`, { scriptingEnabled: false }, false)
681 const root = $('root').first()
682 root.find('script, style, iframe, object, embed, img, video, audio, canvas, svg, input').remove()
683 root.find('*').each((_, node) => {
684 const el = $(node)
685 const attrs = { ...(node.attribs || {}) }
686 const className = String(attrs.class || '')
687 .split(/\s+/)
688 .filter(
689 (item) =>
690 item &&
691 !item.startsWith('arcsin1-presentation-editor-') &&
692 !item.startsWith('ppt-inspector-')
693 )
694 .join(' ')
695 if (className) el.attr('class', className)
696 else el.removeAttr('class')
697 for (const name of Object.keys(attrs)) {
698 const lowerName = name.toLowerCase()
699 const value = String(attrs[name] || '')
700 if (
701 lowerName.startsWith('on') ||
702 lowerName.startsWith('data-arcsin1-presentation-editor-') ||
703 lowerName === 'srcdoc' ||
704 ((lowerName === 'href' || lowerName === 'src') && !/^(#|data:font\/|$)/i.test(value)) ||
705 (lowerName === 'style' && /(?:url\s*\(|expression\s*\()/i.test(value))
706 ) {
707 el.removeAttr(name)
708 }
709 }
710 })
711 const rendered = root.find('.katex').first().length
712 ? root.find('.katex').first()
713 : root.find('.katex-display').first()
714 if (rendered.length === 0) throw new Error('公式渲染结果无效')
715 rendered.attr('data-ppt-formula-latex', normalizedLatex)
716 rendered.attr('data-ppt-formula-display', displayMode ? 'true' : 'false')
717 if (blockId) rendered.attr('data-block-id', blockId)
718 return root.html() || ''
719 }
720
721 function normalizeFormulaSource(value: string): string {
722 return value.replace(/\s+/g, ' ').trim()
723 }
724
725 function replaceSourceFormulaHtml(
726 html: string,
727 formula: { latex: string; originalLatex: string; displayMode: boolean }
728 ): string | null {
729 const original = normalizeFormulaSource(formula.originalLatex || '')
730 const buildDelimited = (latex: string): string =>
731 formula.displayMode ? `\\[${latex}\\]` : `\\(${latex}\\)`
732 const candidates = [
733 {
734 pattern: /\$\$([\s\S]+?)\$\$/g
735 },
736 {
737 pattern: /\\\[([\s\S]+?)\\\]/g
738 },
739 {
740 pattern: /\\\(([\s\S]+?)\\\)/g
741 },
742 {
743 pattern: /\$([^\n$]+?)\$/g
744 }
745 ]
746
747 for (const candidate of candidates) {
748 const matches = Array.from(html.matchAll(candidate.pattern))
749 if (matches.length === 0) continue
750 const exact = matches.find((match) => normalizeFormulaSource(match[1] || '') === original)
751 const match = exact || (matches.length === 1 ? matches[0] : null)
752 if (!match || match.index === undefined) continue
753 return (
754 html.slice(0, match.index) +
755 buildDelimited(formula.latex) +
756 html.slice(match.index + match[0].length)
757 )
758 }
759
760 return null
761 }
762
763 function htmlHasFormulaDelimiter(html: string): boolean {
764 return /(?:\$\$|\\\(|\\\[|\$[^\s$])/.test(html)
765 }
766
767 function isSafeFormulaHostFallback(html: string): boolean {
768 if (htmlHasFormulaDelimiter(html)) return false
769 const text = normalizeText(
770 cheerio.load(`<root>${html}</root>`, { scriptingEnabled: false }, false).text()
771 )
772 return !text
773 }
774
775 function replaceSourceFormulaWithHtml(
776 html: string,
777 formula: { matchLatex: string; replacementHtml: string }
778 ): string | null {
779 const original = normalizeFormulaSource(formula.matchLatex || '')
780 if (!original) return null
781 const candidates = [
782 /\$\$([\s\S]+?)\$\$/g,
783 /\\\[([\s\S]+?)\\\]/g,
784 /\\\(([\s\S]+?)\\\)/g,
785 /\$([^\n$]+?)\$/g
786 ]
787
788 for (const pattern of candidates) {
789 const matches = Array.from(html.matchAll(pattern))
790 if (matches.length === 0) continue
791 const exact = matches.find((match) => normalizeFormulaSource(match[1] || '') === original)
792 const match = exact || (matches.length === 1 ? matches[0] : null)
793 if (!match || match.index === undefined) continue
794 return (
795 html.slice(0, match.index) +
796 formula.replacementHtml +
797 html.slice(match.index + match[0].length)
798 )
799 }
800
801 return null
802 }
803
804 export function patchGenericElementProperties(
805 html: string,
806 selector: string,
807 patch: {
808 html?: string
809 text?: string
810 textTarget?: unknown
811 formula?: {
812 latex?: unknown
813 html?: unknown
814 displayMode?: unknown
815 originalLatex?: unknown
816 }
817 chart?: {
818 type?: unknown
819 title?: unknown
820 labels?: unknown
821 values?: unknown
822 smooth?: unknown
823 horizontal?: unknown
824 stacked?: unknown
825 areaFill?: unknown
826 showPoints?: unknown
827 showLegend?: unknown
828 doughnutCutout?: unknown
829 radarFill?: unknown
830 configJson?: unknown
831 }
832 style?: {
833 zIndex?: unknown
834 opacity?: unknown
835 backgroundColor?: unknown
836 color?: unknown
837 fontSize?: unknown
838 fontWeight?: unknown
839 textAlign?: unknown
840 objectFit?: unknown
841 }
842 attrs?: {
843 className?: unknown
844 alt?: unknown
845 poster?: unknown
846 controls?: unknown
847 muted?: unknown
848 loop?: unknown
849 autoplay?: unknown
850 playsInline?: unknown
851 preload?: unknown
852 }
853 }
854 ): string {
855 const $ = cheerio.load(html, { scriptingEnabled: false })
856 let target: cheerio.Cheerio<AnyNode>
857 try {
858 target = $(selector).first()
859 } catch {
860 return html
861 }
862 if (!target || target.length === 0) return html
863
864 if (patch.formula && typeof patch.formula.html === 'string') {
865 const latex = typeof patch.formula.latex === 'string' ? patch.formula.latex : ''
866 const originalLatex =
867 typeof patch.formula.originalLatex === 'string' ? patch.formula.originalLatex : ''
868 const displayMode = patch.formula.displayMode === true
869 const nextHtml = stripUnsafeFormulaHtml(patch.formula.html, latex, displayMode)
870 const currentHtml = target.html() || ''
871 const sourceReplaced = replaceSourceFormulaHtml(currentHtml, {
872 latex,
873 originalLatex,
874 displayMode
875 })
876 if (sourceReplaced !== null) {
877 target.html(sourceReplaced)
878 } else {
879 const renderedFormula = target.find('.katex, .katex-display').first()
880 if (renderedFormula.length > 0) renderedFormula.replaceWith(nextHtml)
881 else if (isSafeFormulaHostFallback(currentHtml)) target.html(nextHtml)
882 else throw new Error('公式定位失败,未改动原文;请重新选择公式后再编辑')
883 }
884 } else if (typeof patch.html === 'string') {
885 const nextHtml = stripUnsafeRichTextHtml(patch.html)
886 const text = normalizeText(
887 cheerio.load(`<root>${nextHtml}</root>`, { scriptingEnabled: false }, false).text()
888 )
889 if (!text) throw new Error('文字不能为空')
890 if (text.length > 500) throw new Error('文字不能超过 500 个字符')
891 target.html(nextHtml)
892 } else if (typeof patch.text === 'string') {
893 const text = patch.text
894 const normalizedText = normalizeText(text)
895 if (!normalizedText) throw new Error('文字不能为空')
896 if (normalizedText.length > 500) throw new Error('文字不能超过 500 个字符')
897 if (!patchTextNodeTarget($, patch.textTarget, text)) {
898 if (!hasOnlyEditableTextChildren($, target)) {
899 throw new Error('当前元素包含非文本子元素,暂不支持直接编辑;可以选择更内层的文字。')
900 }
901 target.text(normalizedText)
902 }
903 }
904
905 if (patch.chart && typeof patch.chart.configJson === 'string') {
906 if (target.attr('data-ppt-chart-editable') === 'simple') {
907 try {
908 const parsed = JSON.parse(patch.chart.configJson)
909 if (!SUPPORTED_SIMPLE_CHART_TYPES.has(String(parsed?.type || ''))) {
910 throw new Error('unsupported-chart-type')
911 }
912 const configJson = JSON.stringify(parsed).replace(/<\//g, '<\\/').replace(/<!--/g, '<\\!--')
913 const holder = target.find('script[data-ppt-chart-config="1"]').first()
914 if (holder.length > 0) holder.text(configJson)
915 } catch {
916 throw new Error('暂不支持编辑这个图表类型')
917 }
918 }
919 }
920
921 const stylePatch = patch.style || {}
922 const styleMap = parseStyle(target.attr('style') || '')
923 const zIndex = typeof stylePatch.zIndex === 'number' ? Math.round(stylePatch.zIndex) : null
924 const opacity = normalizeOpacity(stylePatch.opacity)
925 const backgroundColor = normalizeColor(stylePatch.backgroundColor)
926 const color = normalizeColor(stylePatch.color)
927 const fontSize = normalizeFontSize(stylePatch.fontSize)
928 const fontWeight = normalizeFontWeight(stylePatch.fontWeight)
929 const textAlign = normalizeTextAlign(stylePatch.textAlign)
930 const objectFit = normalizeObjectFit(stylePatch.objectFit)
931 if (zIndex !== null && zIndex >= -999 && zIndex <= 9999) {
932 const position = String(styleMap.get('position') || '')
933 .trim()
934 .toLowerCase()
935 if (!position || position === 'static') styleMap.set('position', 'relative')
936 styleMap.set('z-index', String(zIndex))
937 }
938 if (opacity) styleMap.set('opacity', opacity)
939 const backgroundAppliedToSvg = applyInsertedSvgPaintColor($, target, backgroundColor, styleMap)
940 if (backgroundColor && !backgroundAppliedToSvg) styleMap.set('background-color', backgroundColor)
941 if (color) styleMap.set('color', color)
942 if (fontSize) styleMap.set('font-size', fontSize)
943 if (fontWeight) styleMap.set('font-weight', fontWeight)
944 if (textAlign) styleMap.set('text-align', textAlign)
945 if (objectFit) styleMap.set('object-fit', objectFit)
946 const backgroundUpdatesOuterStyle =
947 backgroundAppliedToSvg && target.attr('data-ppt-edit-kind') === 'icon'
948 if (
949 zIndex !== null ||
950 opacity ||
951 (backgroundColor && !backgroundAppliedToSvg) ||
952 backgroundUpdatesOuterStyle ||
953 color ||
954 fontSize ||
955 fontWeight ||
956 textAlign ||
957 objectFit
958 ) {
959 target.attr('style', serializeStyle(styleMap))
960 }
961
962 const attrs = patch.attrs || {}
963 if (typeof attrs.className === 'string') {
964 const className = attrs.className.replace(/\s+/g, ' ').trim().slice(0, 2_000)
965 if (className) target.attr('class', className)
966 else target.removeAttr('class')
967 }
968 if (typeof attrs.alt === 'string') target.attr('alt', attrs.alt.slice(0, 500))
969 if (typeof attrs.poster === 'string') target.attr('poster', attrs.poster.slice(0, 1000))
970 for (const name of ['controls', 'muted', 'loop', 'autoplay'] as const) {
971 const value = normalizeBoolean(attrs[name])
972 if (value === null) continue
973 if (value) target.attr(name, '')
974 else target.removeAttr(name)
975 }
976 const playsInline = normalizeBoolean(attrs.playsInline)
977 if (playsInline !== null) {
978 if (playsInline) target.attr('playsinline', '')
979 else target.removeAttr('playsinline')
980 }
981 if (typeof attrs.preload === 'string') {
982 const preload = attrs.preload.toLowerCase()
983 if (['metadata', 'auto', 'none'].includes(preload)) target.attr('preload', preload)
984 }
985
986 return $.html()
987 }
988
989 export function ensureElementAnchorInHtml(
990 html: string,
991 args: {
992 pageId: string
993 selector: string
994 elementTag?: string
995 formula?: {
996 latex?: unknown
997 html?: unknown
998 displayMode?: unknown
999 }
1000 }
1001 ): { html: string; selector: string; blockId: string; changed: boolean } {
1002 const $ = cheerio.load(html, { scriptingEnabled: false })
1003 let target: cheerio.Cheerio<AnyNode>
1004 try {
1005 target = $(args.selector).first()
1006 } catch {
1007 throw new Error('无法锚定元素:selector 无效')
1008 }
1009 if (!target || target.length === 0) {
1010 if (args.formula && typeof args.formula.html === 'string') {
1011 const latex = typeof args.formula.latex === 'string' ? args.formula.latex : ''
1012 const displayMode = args.formula.displayMode === true
1013 const blockId = allocateBlockId()
1014 const formulaHtml = stripUnsafeFormulaHtml(args.formula.html, latex, displayMode, blockId)
1015 const nextHtml = replaceSourceFormulaWithHtml(html, {
1016 matchLatex: latex,
1017 replacementHtml: formulaHtml
1018 })
1019 if (nextHtml) {
1020 return {
1021 html: nextHtml,
1022 selector: stableSelectorFor(args.pageId, blockId),
1023 blockId,
1024 changed: true
1025 }
1026 }
1027 }
1028 throw new Error('无法锚定元素:页面内容可能已经变化')
1029 }
1030 assertAnchorableElement(target)
1031 const existingBlockId = (target.attr('data-block-id') || '').trim()
1032 if (existingBlockId) {
1033 const existingSelector = stableSelectorFor(args.pageId, existingBlockId)
1034 if ($(existingSelector).length === 1) {
1035 return {
1036 html,
1037 selector: existingSelector,
1038 blockId: existingBlockId,
1039 changed: false
1040 }
1041 }
1042 }
1043 const blockId = allocateBlockId()
1044 target.attr('data-block-id', blockId)
1045 return {
1046 html: $.html(),
1047 selector: stableSelectorFor(args.pageId, blockId),
1048 blockId,
1049 changed: true
1050 }
1051 }
1052
1053 export function patchAddElement(
1054 html: string,
1055 parentSelector: string,
1056 htmlFragment: string,
1057 insertIndex: number
1058 ): string {
1059 const $ = cheerio.load(html, { scriptingEnabled: false })
1060 const fragmentDocument = cheerio.load(
1061 `<root>${htmlFragment}</root>`,
1062 {
1063 scriptingEnabled: false
1064 },
1065 false
1066 )
1067 const fragmentRoot = fragmentDocument('root').first()
1068 fragmentRoot.children().each((_, node) => {
1069 const element = fragmentDocument(node)
1070 const styleMap = parseStyle(element.attr('style') || '')
1071 if (styleMap.has('z-index')) return
1072 const position = String(styleMap.get('position') || '')
1073 .trim()
1074 .toLowerCase()
1075 if (!position || position === 'static') styleMap.set('position', 'relative')
1076 styleMap.set('z-index', '20')
1077 element.attr('style', serializeStyle(styleMap))
1078 })
1079 const normalizedFragment = fragmentRoot.html() || ''
1080 const parent = $(parentSelector).first()
1081 if (!parent || parent.length === 0) {
1082 throw new Error('插入目标父元素不存在')
1083 }
1084 if (insertIndex < 0 || insertIndex >= parent.children().length) {
1085 parent.append(normalizedFragment)
1086 } else {
1087 parent.children().eq(insertIndex).before(normalizedFragment)
1088 }
1089 return $.html()
1090 }
1091
1092 export function removeLegacyVideoAutoplayScript(html: string): string {
1093 const $ = cheerio.load(html, { scriptingEnabled: false })
1094 $('#ppt-video-autoplay').remove()
1095 $('video').each((_, node) => {
1096 const video = $(node)
1097 video.attr('controls', '')
1098 video.attr('playsinline', '')
1099 if (video.attr('preload') === undefined) {
1100 video.attr('preload', 'metadata')
1101 }
1102 })
1103 return $.html()
1104 }
1105
1105 lines TYPESCRIPT