| 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 |