返回 oh-my-ppt
index.ts
根目录 / src / main / io / pptx-import / index.ts
1 import fs from 'fs'
2 import path from 'path'
3 import crypto from 'crypto'
4 import * as cheerio from 'cheerio'
5 import {
6 parse,
7 type Element,
8 type ElementLayer,
9 type Fill,
10 type OoxmlShape,
11 type ParseIssue,
12 type Shadow,
13 type Slide
14 } from '@arcsin1/pptx2json'
15 import {
16 buildPageScaffoldHtml,
17 buildProjectIndexHtml,
18 type DeckPageFile
19 } from '../../session/template-builder'
20 import { escapeHtml } from '../../presentation/html/escape'
21 import { validatePersistedPageHtml } from '../../presentation/html/html-utils'
22 import { PptxTextValidator } from './text-validator'
23 import {
24 normalizePptxShapeName,
25 readPptxAnimationPlans,
26 type ImportedElementAnimation,
27 type SlideAnimationPlan
28 } from './animation-import'
29 import { type PptxXmlShapeMetadata } from './xml-shape-metadata'
30 import {
31 getSvgPathBounds,
32 renderOoxmlCustomGeometryPath,
33 renderOoxmlPresetShapePath,
34 type SvgPathBounds
35 } from '@arcsin1/pptx-ooxml-geometry'
36 import { getSvgShapeViewBox } from './shape-view-box'
37 import { DEFAULT_IMPORTED_TEXT_FONT, PAGE_HEIGHT, PAGE_WIDTH, PPTX_IMPORT_SLIDE_SIZE } from './constants'
38 import {
39 buildChartBlock,
40 buildChartFrameStyle,
41 buildChartHtmlFromConfig,
42 chartCanvasId,
43 unsupportedChartWarning
44 } from './chart-renderer'
45 import { buildAnimationAttrs, buildBlockStyle, clampNumber } from './render-shared'
46 import type {
47 FlattenedElement,
48 ImageRegistry,
49 ImportedPptxDeck,
50 ImportedPptxPage,
51 ImportedTableBorder,
52 ImportedTableCell,
53 ImportProgress,
54 ImportWarning,
55 PptxChartRewriteHandler,
56 SlideAnimationContext,
57 SvgShapeFill,
58 TableBorderSide,
59 TextImportAdjustment,
60 ZIndexCounter
61 } from './types'
62
63 export type {
64 ImportedPptxDeck,
65 ImportedPptxPage,
66 PptxChartRewriteHandler,
67 PptxChartRewriteRequest,
68 PptxChartRewriteResult,
69 PptxImportProgressPayload
70 } from './types'
71
72 const stripHtml = (html: string): string => {
73 if (!html) return ''
74 const $ = cheerio.load(html, { scriptingEnabled: false })
75 return $.root().text().replace(/\u00a0/g, ' ').replace(/\s+/g, ' ').trim()
76 }
77
78 type ElementFrame = {
79 left: number
80 top: number
81 width: number
82 height: number
83 }
84
85 const elementFrame = (element: Element): ElementFrame => {
86 const record = element as unknown as Record<string, unknown>
87 return {
88 left: clampNumber(record.left),
89 top: clampNumber(record.top),
90 width: clampNumber(record.width),
91 height: clampNumber(record.height)
92 }
93 }
94
95 const elementBounds = (elements: Element[]): ElementFrame | null => {
96 let minX = Number.POSITIVE_INFINITY
97 let minY = Number.POSITIVE_INFINITY
98 let maxX = Number.NEGATIVE_INFINITY
99 let maxY = Number.NEGATIVE_INFINITY
100 for (const element of elements) {
101 const frame = elementFrame(element)
102 minX = Math.min(minX, frame.left)
103 minY = Math.min(minY, frame.top)
104 maxX = Math.max(maxX, frame.left + frame.width)
105 maxY = Math.max(maxY, frame.top + frame.height)
106 }
107 if (!Number.isFinite(minX) || !Number.isFinite(minY) || !Number.isFinite(maxX) || !Number.isFinite(maxY)) {
108 return null
109 }
110 return {
111 left: minX,
112 top: minY,
113 width: Math.max(0, maxX - minX),
114 height: Math.max(0, maxY - minY)
115 }
116 }
117
118 const normalizeGroupChildren = (group: Element, children: Element[]): Element[] => {
119 if (!children.length) return children
120 const groupFrame = elementFrame(group)
121 const bounds = elementBounds(children)
122 if (!bounds || bounds.width <= 0 || bounds.height <= 0 || groupFrame.width <= 0 || groupFrame.height <= 0) {
123 return children
124 }
125 const scaleX = groupFrame.width / bounds.width
126 const scaleY = groupFrame.height / bounds.height
127 const needsNormalization =
128 Math.abs(scaleX - 1) > 0.001 ||
129 Math.abs(scaleY - 1) > 0.001 ||
130 Math.abs(bounds.left) > 0.001 ||
131 Math.abs(bounds.top) > 0.001
132 if (!needsNormalization) return children
133
134 return children.map((child) => {
135 const frame = elementFrame(child)
136 return {
137 ...child,
138 left: (frame.left - bounds.left) * scaleX,
139 top: (frame.top - bounds.top) * scaleY,
140 width: frame.width * scaleX,
141 height: frame.height * scaleY
142 } as Element
143 })
144 }
145
146 const flattenElements = (
147 elements: Element[],
148 offsetX = 0,
149 offsetY = 0
150 ): FlattenedElement[] => {
151 const flattened: FlattenedElement[] = []
152 for (const element of elements) {
153 const record = element as unknown as Record<string, unknown>
154 const left = offsetX + clampNumber(record.left)
155 const top = offsetY + clampNumber(record.top)
156 if (element.type === 'group') {
157 const children = normalizeGroupChildren(
158 element,
159 Array.isArray(element.elements) ? element.elements : []
160 )
161 flattened.push(
162 ...flattenElements(
163 children,
164 left,
165 top
166 )
167 )
168 continue
169 }
170 flattened.push({
171 element,
172 left,
173 top,
174 width: clampNumber(record.width),
175 height: clampNumber(record.height),
176 text: 'content' in element ? stripHtml(String(element.content || '')) : ''
177 })
178 }
179 return flattened
180 }
181
182 const isLowValueTitleText = (text: string): boolean => {
183 const normalized = text.toLowerCase()
184 if (!normalized) return true
185 if (/https?:\/\//i.test(text) || /www\./i.test(text)) return true
186 if (normalized.includes('ppt模板') || normalized.includes('1ppt.com')) return true
187 if (text.includes('单击此处输入') || text.includes('请输入')) return true
188 if (normalized.includes('thank you for your attention')) return true
189 return false
190 }
191
192 const hasCjkText = (text: string): boolean => /[\u3400-\u9fff]/.test(text)
193
194 const hasDeckTitleKeyword = (text: string): boolean =>
195 /(总结|汇报|报告|计划|规划|方案|复盘|目录|概述|情况|不足|introduction|overview|summary|agenda|conclusion|plan|report|review)/i.test(text)
196
197 const warningFromParseIssue = (issue: ParseIssue): ImportWarning => {
198 const location = [
199 issue.scope,
200 issue.file ? `文件 ${issue.file}` : '',
201 issue.elementOrder !== undefined ? `元素 ${issue.elementOrder}` : ''
202 ].filter(Boolean).join(' / ')
203 return {
204 pageNumber: issue.slideIndex !== undefined ? issue.slideIndex + 1 : undefined,
205 message: `${location ? `${location}: ` : ''}${issue.message}`
206 }
207 }
208
209 const xmlShapeFromParserOoxml = (
210 element: Record<string, unknown>
211 ): PptxXmlShapeMetadata | undefined => {
212 const ooxml = element.ooxml as OoxmlShape | undefined
213 if (!ooxml || typeof ooxml !== 'object') return undefined
214 const preset = typeof ooxml.preset === 'string' ? ooxml.preset : ''
215 const metadata: PptxXmlShapeMetadata = {
216 id: '',
217 name: typeof element.name === 'string' ? element.name : '',
218 preset,
219 adjustments: ooxml.adjustments,
220 textInsets: ooxml.textInsets,
221 textAnchor: ooxml.textAnchor,
222 headEnd: ooxml.lineHeadEnd,
223 tailEnd: ooxml.lineTailEnd
224 }
225 return preset ||
226 metadata.adjustments ||
227 metadata.textInsets ||
228 metadata.textAnchor ||
229 metadata.headEnd ||
230 metadata.tailEnd
231 ? metadata
232 : undefined
233 }
234
235 const ALLOWED_TEXT_TAGS = new Set([
236 'p',
237 'span',
238 'strong',
239 'b',
240 'em',
241 'i',
242 'u',
243 's',
244 'ul',
245 'ol',
246 'li',
247 'br',
248 'h1',
249 'h2',
250 'h3',
251 'h4',
252 'h5',
253 'h6',
254 'sub',
255 'sup'
256 ])
257
258 const DANGEROUS_TAGS = new Set([
259 'script',
260 'style',
261 'iframe',
262 'object',
263 'embed',
264 'link',
265 'meta',
266 'base',
267 'form',
268 'input',
269 'button',
270 'textarea',
271 'select',
272 'option',
273 'svg',
274 'math',
275 'canvas',
276 'video',
277 'audio',
278 'img'
279 ])
280
281 const ALLOWED_TEXT_STYLE_PROPS = new Set([
282 'color',
283 'background',
284 'background-image',
285 'background-color',
286 'background-clip',
287 '-webkit-background-clip',
288 '-webkit-text-fill-color',
289 'font-size',
290 'font-weight',
291 'font-style',
292 'text-decoration',
293 'text-decoration-line',
294 'text-align',
295 'text-shadow',
296 'line-height',
297 'vertical-align',
298 'letter-spacing',
299 'margin',
300 'margin-top',
301 'margin-right',
302 'margin-bottom',
303 'margin-left',
304 'text-indent'
305 ])
306
307 const normalizeImportedSymbols = (value: string): string =>
308 value
309 // PowerPoint stores Wingdings 3 glyph 0xC4 in Unicode's private-use area.
310 .replace(/\uf0c4/gi, '➜')
311
312 const scaleCssLengthToken = (value: string, scale: number): string | null => {
313 const trimmed = value.trim()
314 if (/^0(?:\.0+)?(?:px|pt)?$/i.test(trimmed)) return '0'
315 const ptMatch = trimmed.match(/^(-?[0-9.]+)pt$/i)
316 if (ptMatch) return `${(clampNumber(ptMatch[1]) * scale).toFixed(1)}px`
317 const pxMatch = trimmed.match(/^(-?[0-9.]+)px$/i)
318 if (pxMatch) return `${clampNumber(pxMatch[1]).toFixed(1)}px`
319 return null
320 }
321
322 const sanitizeCssBoxLength = (value: string, scale: number): string | null => {
323 const tokens = value.trim().split(/\s+/)
324 if (tokens.length < 1 || tokens.length > 4) return null
325 const scaled = tokens.map((token) => scaleCssLengthToken(token, scale))
326 return scaled.every((token): token is string => Boolean(token)) ? scaled.join(' ') : null
327 }
328
329 const sanitizeCssValue = (property: string, rawValue: string, scale: number): string | null => {
330 const value = rawValue.trim()
331 if (!value) return null
332 if (/url\s*\(|expression\s*\(|javascript:|data:/i.test(value)) return null
333 const normalizedProperty = property.trim().toLowerCase()
334 if (normalizedProperty === 'background' || normalizedProperty === 'background-image') {
335 if (!/^(?:linear-gradient|radial-gradient)\s*\(/i.test(value)) return null
336 }
337 if (normalizedProperty === 'background-clip' || normalizedProperty === '-webkit-background-clip') {
338 return /^(?:text|border-box|padding-box|content-box)$/i.test(value) ? value : null
339 }
340 if (property === 'font-size' || property === 'line-height') {
341 const ptMatch = value.match(/^([0-9.]+)pt$/i)
342 if (ptMatch) {
343 const px = Math.max(8, clampNumber(ptMatch[1]) * scale)
344 return `${px.toFixed(1)}px`
345 }
346 }
347 if (
348 normalizedProperty === 'text-indent' ||
349 normalizedProperty === 'margin' ||
350 normalizedProperty.startsWith('margin-')
351 ) {
352 return sanitizeCssBoxLength(value, scale)
353 }
354 if (/^[#a-z0-9\s.,()%'"-]+$/i.test(value)) return value
355 return null
356 }
357
358 const ensureVisibleTextStyle = (style: string): string => {
359 if (!style) return ''
360 const hasTransparentText =
361 /(?:^|;)\s*color\s*:\s*transparent\s*(?:;|$)/i.test(style) ||
362 /(?:^|;)\s*-webkit-text-fill-color\s*:\s*transparent\s*(?:;|$)/i.test(style)
363 if (!hasTransparentText) return style
364
365 const hasGradientBackground =
366 /(?:^|;)\s*background(?:-image)?\s*:\s*(?:linear-gradient|radial-gradient)\s*\(/i.test(style)
367 const hasTextClip =
368 /(?:^|;)\s*(?:-webkit-)?background-clip\s*:\s*text\s*(?:;|$)/i.test(style)
369
370 if (hasGradientBackground && hasTextClip) {
371 return style.includes('-webkit-background-clip')
372 ? style
373 : `${style};-webkit-background-clip:text`
374 }
375
376 return style
377 .replace(/((?:^|;)\s*color\s*:\s*)transparent(\s*(?:;|$))/gi, '$1#111827$2')
378 .replace(
379 /((?:^|;)\s*-webkit-text-fill-color\s*:\s*)transparent(\s*(?:;|$))/gi,
380 '$1#111827$2'
381 )
382 }
383
384 const sanitizeImportedCssColor = (rawValue: unknown): string | null => {
385 if (typeof rawValue !== 'string') return null
386 return sanitizeCssValue('color', rawValue, 1)
387 }
388
389 const isTransparentCssColor = (color: string | null | undefined): boolean => {
390 if (!color) return true
391 const normalized = color.trim().toLowerCase()
392 if (!normalized || normalized === 'none' || normalized === 'transparent') return true
393 const hex = normalized.match(/^#([0-9a-f]{8})$/i)
394 return Boolean(hex && hex[1].slice(6) === '00')
395 }
396
397 const hasVisibleFill = (fill: Fill | undefined): boolean =>
398 Boolean(
399 fill &&
400 (
401 fill.type === 'image' ||
402 fill.type === 'gradient' ||
403 fill.type === 'pattern' ||
404 (fill.type === 'color' && !isTransparentCssColor(sanitizeImportedCssColor(fill.value)))
405 )
406 )
407
408 const hasVisibleSurface = (element: Record<string, unknown>): boolean => {
409 const borderColor = sanitizeImportedCssColor(element.borderColor)
410 return (
411 hasVisibleFill(element.fill as Fill | undefined) ||
412 (clampNumber(element.borderWidth) > 0 && !isTransparentCssColor(borderColor))
413 )
414 }
415
416 const boxShadowCss = (
417 element: Record<string, unknown>,
418 scaleX: number,
419 scaleY: number
420 ): string[] => {
421 const shadow = element.shadow as Shadow | undefined
422 if (!shadow || !hasVisibleSurface(element)) return []
423 const offsetX = clampNumber(shadow.h) * scaleX
424 const offsetY = clampNumber(shadow.v) * scaleY
425 const blur = Math.max(0, clampNumber(shadow.blur) * ((scaleX + scaleY) / 2))
426 if (Math.abs(offsetX) < 0.01 && Math.abs(offsetY) < 0.01 && blur < 0.01) return []
427 const color = sanitizeImportedCssColor(shadow.color) || '#00000066'
428 return [`box-shadow:${offsetX.toFixed(1)}px ${offsetY.toFixed(1)}px ${blur.toFixed(1)}px ${color}`]
429 }
430
431 const normalizeGradientPosition = (
432 rawPosition: unknown,
433 fallbackIndex = 0,
434 fallbackCount = 1
435 ): string => {
436 const fallback = `${Math.round((fallbackIndex / Math.max(1, fallbackCount - 1)) * 100)}%`
437 if (typeof rawPosition !== 'string' && typeof rawPosition !== 'number') return fallback
438 const value = String(rawPosition).trim()
439 if (!value) return fallback
440 const percentMatch = value.match(/^([0-9.]+)%$/)
441 if (percentMatch) {
442 const percent = clampNumber(percentMatch[1])
443 return `${Math.max(0, Math.min(100, percent)).toFixed(percent % 1 ? 2 : 0)}%`
444 }
445 if (!/^[0-9.]+$/.test(value)) return fallback
446 const numeric = Number(value)
447 if (!Number.isFinite(numeric)) return fallback
448 if (numeric > 100) {
449 return `${Math.max(0, Math.min(100, numeric / 1000)).toFixed(numeric % 1000 ? 2 : 0)}%`
450 }
451 return `${Math.max(0, Math.min(100, numeric)).toFixed(numeric % 1 ? 2 : 0)}%`
452 }
453
454 const sanitizeGradientStop = (
455 rawColor: unknown,
456 rawPosition: unknown,
457 fallbackIndex = 0,
458 fallbackCount = 1
459 ): string | null => {
460 const color = sanitizeImportedCssColor(rawColor)
461 if (!color) return null
462 return `${color} ${normalizeGradientPosition(rawPosition, fallbackIndex, fallbackCount)}`
463 }
464
465 const sanitizeStyleAttribute = (style: string, scale: number): string => {
466 return ensureVisibleTextStyle(
467 style
468 .split(';')
469 .map((part) => {
470 const [propertyRaw, ...valueParts] = part.split(':')
471 const property = propertyRaw?.trim().toLowerCase()
472 const valueRaw = valueParts.join(':')
473 if (!property || !ALLOWED_TEXT_STYLE_PROPS.has(property)) return ''
474 const value = sanitizeCssValue(property, valueRaw, scale)
475 return value ? `${property}:${value}` : ''
476 })
477 .filter(Boolean)
478 .join(';')
479 )
480 }
481
482 const sanitizeContentHtml = (html: string, scale: number): string => {
483 if (!html) return ''
484 const $ = cheerio.load(html, { scriptingEnabled: false }, false)
485 $('*').each((_, node) => {
486 const rawNode = node as unknown as { tagName?: string; attribs?: Record<string, string> }
487 const element = $(node)
488 const tagName = String(rawNode.tagName || '').toLowerCase()
489 if (DANGEROUS_TAGS.has(tagName)) {
490 element.remove()
491 return
492 }
493 if (!ALLOWED_TEXT_TAGS.has(tagName)) {
494 element.replaceWith(element.contents())
495 return
496 }
497 for (const attribute of Object.keys(rawNode.attribs || {})) {
498 const value = element.attr(attribute) || ''
499 const name = attribute.toLowerCase()
500 if (name.startsWith('on')) {
501 element.removeAttr(attribute)
502 continue
503 }
504 if (name !== 'style') {
505 element.removeAttr(attribute)
506 continue
507 }
508 const sanitizedStyle = sanitizeStyleAttribute(value, scale)
509 if (sanitizedStyle) {
510 element.attr('style', sanitizedStyle)
511 } else {
512 element.removeAttr('style')
513 }
514 }
515 })
516 $.root()
517 .contents()
518 .add($.root().find('*').contents())
519 .each((_, node) => {
520 if (node.type === 'text' && 'data' in node && typeof node.data === 'string') {
521 node.data = normalizeImportedSymbols(node.data)
522 }
523 })
524 return $.root().html() || ''
525 }
526
527 const sanitizeTableCellContentHtml = (html: string, scale: number): string => {
528 const sanitized = sanitizeContentHtml(html, Math.min(scale, 1.25))
529 return sanitized.replace(/\u00a0/g, '&nbsp;')
530 }
531
532 const parseCssPx = (style: string, property: string): number | null => {
533 const escaped = property.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
534 const match = style.match(new RegExp(`${escaped}\\s*:\\s*([0-9.]+)px`, 'i'))
535 if (!match) return null
536 const value = Number(match[1])
537 return Number.isFinite(value) ? value : null
538 }
539
540 const parseCssValue = (style: string, property: string): string | null => {
541 const escaped = property.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
542 const match = style.match(new RegExp(`${escaped}\\s*:\\s*([^;]+)`, 'i'))
543 return match?.[1]?.trim() || null
544 }
545
546 const extractTextTypography = (
547 content: string,
548 element: Record<string, unknown>,
549 textScale: number
550 ): {
551 fontSize: number
552 lineHeight: number
553 fontFamily: string
554 fontWeight: string
555 fontStyle: string
556 letterSpacing: number
557 } => {
558 const $ = cheerio.load(`<body>${content}</body>`, { scriptingEnabled: false })
559 let style = ''
560 $('*').each((_, node) => {
561 const candidate = $(node).attr('style') || ''
562 if (candidate && (!style || candidate.includes('font-size'))) style = candidate
563 })
564 const fontSize =
565 parseCssPx(style, 'font-size') ||
566 Math.max(10, clampNumber(element.fontSize || element.font_size || 18) * textScale)
567 const lineHeight = parseCssPx(style, 'line-height') || fontSize * 1.18
568 return {
569 fontSize,
570 lineHeight,
571 fontFamily: DEFAULT_IMPORTED_TEXT_FONT,
572 fontWeight:
573 parseCssValue(style, 'font-weight') ||
574 (element.fontBold || element.bold ? '700' : '400'),
575 fontStyle: parseCssValue(style, 'font-style') || (element.fontItalic ? 'italic' : 'normal'),
576 letterSpacing: parseCssPx(style, 'letter-spacing') || 0
577 }
578 }
579
580 const scaleContentTypography = (content: string, ratio: number): string => {
581 if (ratio >= 0.995) return content
582 const $ = cheerio.load(`<body>${content}</body>`, { scriptingEnabled: false })
583 $('*').each((_, node) => {
584 const element = $(node)
585 const style = element.attr('style') || ''
586 if (!style) return
587 const scaled = style
588 .split(';')
589 .map((part) => {
590 const [propertyRaw, ...valueParts] = part.split(':')
591 const property = propertyRaw?.trim()
592 const value = valueParts.join(':').trim()
593 if (!property || !value) return ''
594 if (/^(font-size|line-height|letter-spacing)$/i.test(property)) {
595 const pxMatch = value.match(/^([0-9.]+)px$/i)
596 if (pxMatch) {
597 return `${property}:${Math.max(0, Number(pxMatch[1]) * ratio).toFixed(1)}px`
598 }
599 }
600 return `${property}:${value}`
601 })
602 .filter(Boolean)
603 .join(';')
604 if (scaled) element.attr('style', scaled)
605 })
606 return $('body').html() || content
607 }
608
609 const getRegistryKey = (key: string, dataUrl: string): string => {
610 const stableKey = key.trim()
611 if (stableKey && stableKey.length < 512 && !stableKey.startsWith('data:')) return `ref:${stableKey}`
612 return `sha256:${crypto.createHash('sha256').update(stableKey || dataUrl).digest('hex')}`
613 }
614
615 const getDataUrlInfo = (dataUrl: string): { mimeType: string; extension: string; data: string } => {
616 const match = dataUrl.match(/^data:([^;,]+);base64,(.+)$/)
617 if (!match) return { mimeType: 'application/octet-stream', extension: '.bin', data: dataUrl }
618 const mimeType = match[1]
619 const extension =
620 mimeType === 'image/png'
621 ? '.png'
622 : mimeType === 'image/jpeg'
623 ? '.jpg'
624 : mimeType === 'image/webp'
625 ? '.webp'
626 : mimeType === 'image/gif'
627 ? '.gif'
628 : mimeType === 'image/svg+xml'
629 ? '.svg'
630 : '.bin'
631 return { mimeType, extension, data: match[2] }
632 }
633
634 const writeImageDataUrl = async (
635 imagesDir: string,
636 registry: ImageRegistry,
637 key: string,
638 dataUrl: string
639 ): Promise<string | null> => {
640 if (!dataUrl) return null
641 const registryKey = getRegistryKey(key, dataUrl)
642 const existing = registry.byKey.get(registryKey)
643 if (existing) return existing
644 const info = getDataUrlInfo(dataUrl)
645 if (!info.data || info.extension === '.bin') return null
646 registry.index += 1
647 const fileName = `imported-${String(registry.index).padStart(4, '0')}${info.extension}`
648 const targetPath = path.join(imagesDir, fileName)
649 await fs.promises.writeFile(targetPath, Buffer.from(info.data, 'base64'))
650 const relativePath = `./images/${fileName}`
651 registry.byKey.set(registryKey, relativePath)
652 return relativePath
653 }
654
655 const fillToCss = async (
656 fill: Fill | undefined,
657 imagesDir: string,
658 registry: ImageRegistry
659 ): Promise<string[]> => {
660 if (!fill) return []
661 if (fill.type === 'color' && fill.value) {
662 const color = sanitizeImportedCssColor(fill.value)
663 return color ? [`background:${color}`] : []
664 }
665 if (fill.type === 'image' && fill.value?.base64) {
666 const imagePath = await writeImageDataUrl(
667 imagesDir,
668 registry,
669 fill.value.ref || fill.value.base64,
670 fill.value.base64
671 )
672 if (imagePath) {
673 return [
674 `background-image:url('${imagePath}')`,
675 'background-size:cover',
676 'background-position:center'
677 ]
678 }
679 }
680 if (fill.type === 'gradient' && Array.isArray(fill.value?.colors) && fill.value.colors.length) {
681 const colors = fill.value.colors
682 .map((item, index) => sanitizeGradientStop(item.color, item.pos, index, fill.value.colors.length))
683 .filter((item): item is string => Boolean(item))
684 return colors.length ? [`background:linear-gradient(135deg, ${colors.join(', ')})`] : []
685 }
686 return []
687 }
688
689 const borderCss = (element: Record<string, unknown>, scale: number): string[] => {
690 const width = clampNumber(element.borderWidth)
691 if (width <= 0) return []
692 const color = sanitizeImportedCssColor(element.borderColor)
693 if (isTransparentCssColor(color)) return []
694 const rawType = typeof element.borderType === 'string' ? element.borderType.trim().toLowerCase() : ''
695 const type = ['solid', 'dashed', 'dotted', 'double'].includes(rawType) ? rawType : 'solid'
696 return [`border:${Math.max(1, width * scale).toFixed(1)}px ${type} ${color}`]
697 }
698
699 const normalizeBorderType = (value: unknown): string => {
700 const raw = typeof value === 'string' ? value.trim().toLowerCase() : ''
701 if (['solid', 'dashed', 'dotted', 'double'].includes(raw)) return raw
702 return 'solid'
703 }
704
705 const tableBorderDeclaration = (
706 side: TableBorderSide,
707 border: ImportedTableBorder | undefined,
708 scale: number
709 ): string | null => {
710 if (!border) return null
711 const width = clampNumber(border.borderWidth)
712 if (width <= 0) return null
713 const color = sanitizeImportedCssColor(border.borderColor)
714 if (isTransparentCssColor(color)) return null
715 const type = normalizeBorderType(border.borderType)
716 return `border-${side}:${Math.max(0.5, width * scale).toFixed(1)}px ${type} ${color}`
717 }
718
719 const tableBorderDeclarations = (
720 cellBorders: Partial<Record<TableBorderSide, ImportedTableBorder>> | undefined,
721 fallbackBorders: Partial<Record<TableBorderSide, ImportedTableBorder>> | undefined,
722 scale: number
723 ): string[] => {
724 const declarations = (['top', 'right', 'bottom', 'left'] as TableBorderSide[])
725 .map((side) => tableBorderDeclaration(side, cellBorders?.[side] || fallbackBorders?.[side], scale))
726 .filter((item): item is string => Boolean(item))
727 return declarations.length > 0 ? declarations : ['border:1px solid #d1d5db']
728 }
729
730 const spanAttr = (name: 'colspan' | 'rowspan', value: unknown): string => {
731 const span = Math.floor(clampNumber(value, 1))
732 return span > 1 ? ` ${name}="${span}"` : ''
733 }
734
735 const spanSize = (value: unknown): number => Math.max(1, Math.floor(clampNumber(value, 1)))
736
737 const isMergedTableContinuation = (cell: ImportedTableCell): boolean =>
738 clampNumber(cell.hMerge) > 0 || clampNumber(cell.vMerge) > 0
739
740 const tableVerticalAlign = (value: unknown): string => {
741 const raw = typeof value === 'string' ? value.trim().toLowerCase() : ''
742 if (raw === 'mid' || raw === 'middle' || raw === 'center' || raw === 'ctr') return 'middle'
743 if (raw === 'down' || raw === 'bottom' || raw === 'b') return 'bottom'
744 return 'top'
745 }
746
747 const resolveSlideFit = (size: { width: number; height: number }): {
748 scale: number
749 offsetX: number
750 offsetY: number
751 } => {
752 const sourceWidth = Math.max(1, size.width)
753 const sourceHeight = Math.max(1, size.height)
754 const scale = Math.min(PAGE_WIDTH / sourceWidth, PAGE_HEIGHT / sourceHeight)
755 return {
756 scale,
757 offsetX: Math.max(0, (PAGE_WIDTH - sourceWidth * scale) / 2),
758 offsetY: Math.max(0, (PAGE_HEIGHT - sourceHeight * scale) / 2)
759 }
760 }
761
762 const overlapArea = (
763 left: { x: number; y: number; w: number; h: number },
764 right: { x: number; y: number; w: number; h: number }
765 ): number => {
766 const x = Math.max(0, Math.min(left.x + left.w, right.x + right.w) - Math.max(left.x, right.x))
767 const y = Math.max(0, Math.min(left.y + left.h, right.y + right.h) - Math.max(left.y, right.y))
768 return x * y
769 }
770
771 const centerInside = (
772 inner: { x: number; y: number; w: number; h: number },
773 outer: { x: number; y: number; w: number; h: number }
774 ): boolean => {
775 const cx = inner.x + inner.w / 2
776 const cy = inner.y + inner.h / 2
777 return cx >= outer.x && cx <= outer.x + outer.w && cy >= outer.y && cy <= outer.y + outer.h
778 }
779
780 const resolveElementAnimation = (
781 context: SlideAnimationContext | undefined,
782 element: Record<string, unknown>,
783 offsetX: number,
784 offsetY: number
785 ): ImportedElementAnimation | undefined => {
786 const plan = context?.plan
787 if (!plan || plan.animations.length === 0) return undefined
788 const name = normalizePptxShapeName(element.name)
789 if (name) {
790 const byName = plan.byName.get(name)
791 const match = byName?.find((animation) => !context.usedAnimationIds.has(animation.id))
792 if (match) {
793 context.usedAnimationIds.add(match.id)
794 return match
795 }
796 }
797
798 const box = {
799 x: clampNumber(element.left) + offsetX,
800 y: clampNumber(element.top) + offsetY,
801 w: Math.max(1, clampNumber(element.width)),
802 h: Math.max(1, clampNumber(element.height))
803 }
804 const boxArea = Math.max(0.0001, box.w * box.h)
805 const candidates = plan.animations
806 .filter(
807 (animation) =>
808 !context.usedAnimationIds.has(animation.id) &&
809 animation.x !== undefined &&
810 animation.y !== undefined &&
811 animation.w !== undefined &&
812 animation.h !== undefined
813 )
814 .map((animation) => {
815 const animBox = {
816 x: animation.x || 0,
817 y: animation.y || 0,
818 w: Math.max(1, animation.w || 1),
819 h: Math.max(1, animation.h || 1)
820 }
821 const overlap = overlapArea(box, animBox)
822 const animArea = Math.max(0.0001, animBox.w * animBox.h)
823 const eligible =
824 overlap > 0 &&
825 (centerInside(box, animBox) || overlap / boxArea >= 0.45 || overlap / animArea >= 0.25)
826 return { animation, overlap, eligible }
827 })
828 .filter((candidate) => candidate.eligible)
829 .sort((a, b) => b.overlap - a.overlap || a.animation.id - b.animation.id)
830 const match = candidates[0]?.animation
831 if (match) context.usedAnimationIds.add(match.id)
832 return match
833 }
834
835 const adjustTextBlockWithPretext = async (args: {
836 validator?: PptxTextValidator
837 element: Record<string, unknown>
838 blockId: string
839 content: string
840 text: string
841 scaleX: number
842 scaleY: number
843 textScale: number
844 offsetX: number
845 offsetY: number
846 pageNumber?: number
847 warnings?: ImportWarning[]
848 }): Promise<TextImportAdjustment> => {
849 if (!args.validator || args.text.length < 2) {
850 return { content: args.content, extraCss: [] }
851 }
852 const y = (clampNumber(args.element.top) + clampNumber(args.offsetY)) * args.scaleY
853 const width = Math.max(1, clampNumber(args.element.width) * args.scaleX)
854 const height = Math.max(1, clampNumber(args.element.height) * args.scaleY)
855 const typography = extractTextTypography(args.content, args.element, args.textScale)
856 const [result] = await args.validator.measure([
857 {
858 id: args.blockId,
859 text: args.text,
860 width,
861 height,
862 ...typography
863 }
864 ])
865 if (!result || (!result.overflow && result.suggestedFontSize >= typography.fontSize - 0.5)) {
866 return {
867 content: args.content,
868 extraCss: [
869 `font-size:${typography.fontSize.toFixed(1)}px`,
870 `line-height:${typography.lineHeight.toFixed(1)}px`
871 ]
872 }
873 }
874
875 const fontRatio = Math.min(1, result.suggestedFontSize / typography.fontSize)
876 const maxHeight = Math.max(1, PAGE_HEIGHT - y - 2)
877 const nextHeight = Math.min(maxHeight, Math.max(height, result.suggestedHeight))
878 const extraCss = [
879 `font-size:${result.suggestedFontSize.toFixed(1)}px`,
880 `line-height:${result.suggestedLineHeight.toFixed(1)}px`
881 ]
882 if (nextHeight > height + 1) {
883 extraCss.push(`height:${nextHeight.toFixed(1)}px`)
884 }
885 args.warnings?.push({
886 pageNumber: args.pageNumber,
887 message: `文本块 ${args.blockId} 已按 Pretext 测量调整排版`
888 })
889
890 return {
891 content: scaleContentTypography(args.content, fontRatio),
892 extraCss
893 }
894 }
895
896 const titleFromSlide = (slide: Slide, pageNumber: number): string => {
897 const candidates = flattenElements([...(slide.layoutElements || []), ...(slide.elements || [])])
898 .filter((item) => (item.element.type === 'text' || item.element.type === 'shape') && item.text.length > 0)
899 .map((item) => {
900 const area = item.width * item.height
901 const textLength = Array.from(item.text).length
902 const isShortFragment = textLength <= 1
903 const isPrimaryBand = item.top < 180
904 const score =
905 area +
906 (isPrimaryBand ? 8000 : 0) +
907 (hasCjkText(item.text) ? 5000 : 0) +
908 (hasDeckTitleKeyword(item.text) ? 28000 : 0) +
909 (textLength >= 2 && textLength <= 28 ? 6000 : 0) -
910 (isShortFragment ? 16000 : 0) -
911 (isLowValueTitleText(item.text) ? 50000 : 0)
912 return { ...item, area, score }
913 })
914 .sort((a, b) => b.score - a.score || a.top - b.top)
915 const title = candidates.find((item) => !isLowValueTitleText(item.text))?.text || candidates[0]?.text
916 return title?.slice(0, 80) || `第 ${pageNumber} 页`
917 }
918
919 const countExplicitTextLines = (content: string): number | null => {
920 const $ = cheerio.load(`<body>${content}</body>`, { scriptingEnabled: false })
921 const paragraphs = $('p')
922 if (paragraphs.length === 0) return null
923 let lineCount = 0
924 paragraphs.each((_, node) => {
925 const paragraph = $(node)
926 const text = paragraph.text().replace(/\u00a0/g, ' ').trim()
927 if (!text) return
928 lineCount += Math.max(1, paragraph.find('br').length + 1)
929 })
930 return lineCount > 0 ? lineCount : null
931 }
932
933 const isCompactAutoFitText = (
934 element: Record<string, unknown>,
935 content: string,
936 scaleY: number,
937 textScale: number
938 ): boolean => {
939 const autoFit = element.autoFit as { type?: string } | undefined
940 if (autoFit?.type !== 'shape') return false
941 const text = stripHtml(content)
942 if (!text) return false
943 const lineCount = countExplicitTextLines(content)
944 if (!lineCount || lineCount > 3) return false
945 const typography = extractTextTypography(content, element, textScale)
946 const renderedHeight = Math.max(1, clampNumber(element.height) * scaleY)
947 const lineHeight = Math.max(1, typography.lineHeight)
948 return renderedHeight <= lineHeight * (lineCount + 0.95)
949 }
950
951 const textAnchorCss = (xmlShape?: PptxXmlShapeMetadata): string[] => {
952 const anchor = xmlShape?.textAnchor?.toLowerCase()
953 if (anchor !== 'ctr' && anchor !== 'b') return []
954 return [
955 'display:flex',
956 'flex-direction:column',
957 `justify-content:${anchor === 'b' ? 'flex-end' : 'center'}`
958 ]
959 }
960
961 const textVerticalCss = (
962 element: Record<string, unknown>,
963 xmlShape: PptxXmlShapeMetadata | undefined,
964 content: string,
965 scaleY: number,
966 textScale: number
967 ): string[] => {
968 const anchorCss = textAnchorCss(xmlShape)
969 if (anchorCss.length) return anchorCss
970 if (!isCompactAutoFitText(element, content, scaleY, textScale)) return []
971 return [
972 'display:flex',
973 'flex-direction:column',
974 'justify-content:center'
975 ]
976 }
977
978 const textInsetCss = (xmlShape?: PptxXmlShapeMetadata, scale = 1): string[] => {
979 const base = ['box-sizing:border-box']
980 const insets = xmlShape?.textInsets
981 if (!insets) return [...base, 'padding:0.1px']
982 const top = insets.top ?? 0
983 const right = insets.right ?? 0
984 const bottom = insets.bottom ?? 0
985 const left = insets.left ?? 0
986 if (top === 0 && right === 0 && bottom === 0 && left === 0) return [...base, 'padding:0.1px']
987 return [
988 ...base,
989 `padding:${(top * scale).toFixed(1)}px ${(right * scale).toFixed(1)}px ${(bottom * scale).toFixed(1)}px ${(left * scale).toFixed(1)}px`
990 ]
991 }
992
993 const applyXmlShapeFrame = (
994 element: Record<string, unknown>,
995 xmlShape?: PptxXmlShapeMetadata
996 ): Record<string, unknown> => {
997 if (!xmlShape) return element
998 return {
999 ...element,
1000 left: xmlShape.left ?? element.left,
1001 top: xmlShape.top ?? element.top,
1002 width: xmlShape.width ?? element.width,
1003 height: xmlShape.height ?? element.height,
1004 rotate: xmlShape.rotate ?? element.rotate,
1005 isFlipH: Boolean(element.isFlipH) || Boolean(xmlShape.flipH),
1006 isFlipV: Boolean(element.isFlipV) || Boolean(xmlShape.flipV)
1007 }
1008 }
1009
1010 const layerSourceRank = (source: unknown): number => {
1011 if (source === 'master') return 0
1012 if (source === 'layout') return 1
1013 if (source === 'slide') return 2
1014 if (source === 'group') return 3
1015 return 4
1016 }
1017
1018 const numberAtPath = (path: unknown, index: number): number => {
1019 if (!Array.isArray(path)) return 0
1020 return clampNumber(path[index])
1021 }
1022
1023 const compareElementLayerPath = (leftPath: unknown, rightPath: unknown): number => {
1024 const left = Array.isArray(leftPath) ? leftPath : []
1025 const right = Array.isArray(rightPath) ? rightPath : []
1026 const length = Math.max(left.length, right.length)
1027 for (let i = 0; i < length; i += 1) {
1028 const delta = numberAtPath(left, i) - numberAtPath(right, i)
1029 if (delta !== 0) return delta
1030 }
1031 return 0
1032 }
1033
1034 const elementLayer = (element: Element): ElementLayer | undefined =>
1035 (element as unknown as Record<string, unknown>).layer as ElementLayer | undefined
1036
1037 const elementZIndex = (element: Element): number => {
1038 const record = element as unknown as Record<string, unknown>
1039 const layer = elementLayer(element)
1040 return clampNumber(record.zIndex ?? layer?.zIndex ?? record.order)
1041 }
1042
1043 const compareElementsForRender = (left: Element, right: Element): number => {
1044 const leftLayer = elementLayer(left)
1045 const rightLayer = elementLayer(right)
1046 return (
1047 layerSourceRank(leftLayer?.source) - layerSourceRank(rightLayer?.source) ||
1048 elementZIndex(left) - elementZIndex(right) ||
1049 compareElementLayerPath(leftLayer?.path, rightLayer?.path) ||
1050 clampNumber((left as unknown as Record<string, unknown>).order) -
1051 clampNumber((right as unknown as Record<string, unknown>).order)
1052 )
1053 }
1054
1055 const buildTextBlock = async (args: {
1056 element: Record<string, unknown>
1057 blockId: string
1058 role?: string
1059 animation?: ImportedElementAnimation
1060 imagesDir: string
1061 registry: ImageRegistry
1062 scaleX: number
1063 scaleY: number
1064 textScale: number
1065 zIndex: number
1066 offsetX: number
1067 offsetY: number
1068 pageNumber?: number
1069 warnings?: ImportWarning[]
1070 textValidator?: PptxTextValidator
1071 xmlShape?: PptxXmlShapeMetadata
1072 }): Promise<string> => {
1073 const fillCss = await fillToCss(args.element.fill as Fill | undefined, args.imagesDir, args.registry)
1074 const rawContent = String(args.element.content || '')
1075 const text = stripHtml(rawContent)
1076 const sanitizedContent = sanitizeContentHtml(rawContent, args.textScale)
1077 const adjustment = await adjustTextBlockWithPretext({
1078 validator: args.textValidator,
1079 element: args.element,
1080 blockId: args.blockId,
1081 content: sanitizedContent,
1082 text,
1083 scaleX: args.scaleX,
1084 scaleY: args.scaleY,
1085 textScale: args.textScale,
1086 offsetX: args.offsetX,
1087 offsetY: args.offsetY,
1088 pageNumber: args.pageNumber,
1089 warnings: args.warnings
1090 })
1091 const css = buildBlockStyle({
1092 element: args.element,
1093 scaleX: args.scaleX,
1094 scaleY: args.scaleY,
1095 zIndex: args.zIndex,
1096 offsetX: args.offsetX,
1097 offsetY: args.offsetY,
1098 extra: [
1099 ...fillCss,
1100 ...borderCss(args.element, args.textScale),
1101 ...boxShadowCss(args.element, args.scaleX, args.scaleY),
1102 ...textInsetCss(args.xmlShape, args.textScale),
1103 ...textVerticalCss(args.element, args.xmlShape, sanitizedContent, args.scaleY, args.textScale),
1104 ...adjustment.extraCss
1105 ]
1106 })
1107 const roleAttr = args.role ? ` data-role="${escapeHtml(args.role)}"` : ''
1108 const animationAttrs = buildAnimationAttrs(args.animation)
1109 const animationAttrText = animationAttrs ? ` ${animationAttrs}` : ''
1110 return `<section data-block-id="${escapeHtml(args.blockId)}"${roleAttr}${animationAttrText} style="${css}">${adjustment.content || '&nbsp;'}</section>`
1111 }
1112
1113 const buildImageBlock = async (args: {
1114 element: Record<string, unknown>
1115 blockId: string
1116 animation?: ImportedElementAnimation
1117 imagesDir: string
1118 registry: ImageRegistry
1119 scaleX: number
1120 scaleY: number
1121 zIndex: number
1122 offsetX: number
1123 offsetY: number
1124 }): Promise<string> => {
1125 const source = await writeImageDataUrl(
1126 args.imagesDir,
1127 args.registry,
1128 String(args.element.ref || args.element.base64 || args.blockId),
1129 String(args.element.base64 || '')
1130 )
1131 const css = buildBlockStyle({
1132 element: args.element,
1133 scaleX: args.scaleX,
1134 scaleY: args.scaleY,
1135 zIndex: args.zIndex,
1136 offsetX: args.offsetX,
1137 offsetY: args.offsetY,
1138 overflow: 'hidden',
1139 extra: [...borderCss(args.element, Math.min(args.scaleX, args.scaleY)), 'display:flex']
1140 })
1141 const animationAttrs = buildAnimationAttrs(args.animation)
1142 const animationAttrText = animationAttrs ? ` ${animationAttrs}` : ''
1143 if (!source) {
1144 return `<section data-block-id="${escapeHtml(args.blockId)}"${animationAttrText} style="${css};align-items:center;justify-content:center;background:#f3f4f6;color:#6b7280;font-size:18px;">图片未能导入</section>`
1145 }
1146 return `<figure data-block-id="${escapeHtml(args.blockId)}"${animationAttrText} style="${css}"><img src="${source}" alt="" style="width:100%;height:100%;object-fit:contain;display:block;" /></figure>`
1147 }
1148
1149 const svgResourceId = (blockId: string, suffix: string): string =>
1150 `pptx-${blockId}-${suffix}`.replace(/[^a-zA-Z0-9_-]/g, '-')
1151
1152 const OPEN_SHAPE_PRESETS = new Set(['arc', 'line', 'straightconnector1'])
1153
1154 const isOpenXmlShape = (xmlShape?: PptxXmlShapeMetadata): boolean =>
1155 Boolean(xmlShape?.preset && OPEN_SHAPE_PRESETS.has(xmlShape.preset.toLowerCase()))
1156
1157 const resolveSvgShapeFill = async (args: {
1158 fill?: Fill
1159 blockId: string
1160 safePath: string
1161 viewBox: SvgPathBounds
1162 imagesDir: string
1163 registry: ImageRegistry
1164 }): Promise<SvgShapeFill> => {
1165 if (!args.fill) return { defs: [], paint: 'none' }
1166 if (args.fill.type === 'color') {
1167 return { defs: [], paint: sanitizeImportedCssColor(args.fill.value) || 'none' }
1168 }
1169 if (args.fill.type === 'gradient' && args.fill.value.colors.length > 0) {
1170 const gradient = args.fill.value
1171 const gradientId = svgResourceId(args.blockId, 'gradient')
1172 const fallbackPaint = gradient.colors
1173 .map((stop) => sanitizeImportedCssColor(stop.color))
1174 .find((color): color is string => Boolean(color)) || '#000000'
1175 const stops = gradient.colors
1176 .map((stop, index) => {
1177 const color = sanitizeImportedCssColor(stop.color)
1178 if (!color) return ''
1179 const offset = normalizeGradientPosition(stop.pos, index, gradient.colors.length)
1180 return `<stop offset="${offset}" stop-color="${color}" />`
1181 })
1182 .filter(Boolean)
1183 .join('')
1184 if (!stops) return { defs: [], paint: 'none' }
1185 if (gradient.path === 'line') {
1186 const rotation = clampNumber(gradient.rot)
1187 return {
1188 defs: [
1189 `<linearGradient id="${gradientId}" x1="0" y1="0.5" x2="1" y2="0.5" gradientTransform="rotate(${rotation.toFixed(2)} 0.5 0.5)">${stops}</linearGradient>`
1190 ],
1191 paint: `url(#${gradientId}) ${fallbackPaint}`
1192 }
1193 }
1194 return {
1195 defs: [
1196 `<radialGradient id="${gradientId}" cx="50%" cy="50%" r="70%">${stops}</radialGradient>`
1197 ],
1198 paint: `url(#${gradientId}) ${fallbackPaint}`
1199 }
1200 }
1201 if (args.fill.type === 'pattern') {
1202 const patternId = svgResourceId(args.blockId, 'pattern')
1203 const foreground = sanitizeImportedCssColor(args.fill.value.foregroundColor) || '#000000'
1204 const background = sanitizeImportedCssColor(args.fill.value.backgroundColor) || '#ffffff'
1205 const patternType = String(args.fill.value.type || '').toLowerCase()
1206 const patternLines = patternType.includes('vert')
1207 ? '<path d="M4 0 V8" />'
1208 : patternType.includes('horz')
1209 ? '<path d="M0 4 H8" />'
1210 : patternType.includes('cross')
1211 ? '<path d="M4 0 V8 M0 4 H8" />'
1212 : '<path d="M-2 2 L2 -2 M0 8 L8 0 M6 10 L10 6" />'
1213 return {
1214 defs: [
1215 `<pattern id="${patternId}" width="8" height="8" patternUnits="userSpaceOnUse"><rect width="8" height="8" fill="${background}" /><g fill="none" stroke="${foreground}" stroke-width="1">${patternLines}</g></pattern>`
1216 ],
1217 paint: `url(#${patternId}) ${background}`
1218 }
1219 }
1220 if (args.fill.type === 'image' && args.fill.value.base64) {
1221 const source = await writeImageDataUrl(
1222 args.imagesDir,
1223 args.registry,
1224 args.fill.value.ref || args.fill.value.base64,
1225 args.fill.value.base64
1226 )
1227 if (!source) return { defs: [], paint: 'none' }
1228 const clipId = svgResourceId(args.blockId, 'clip')
1229 const opacity = Math.min(1, Math.max(0, clampNumber(args.fill.value.opacity, 1)))
1230 return {
1231 defs: [`<clipPath id="${clipId}"><path d="${escapeHtml(args.safePath)}" /></clipPath>`],
1232 paint: 'none',
1233 content: `<image href="${escapeHtml(source)}" x="${args.viewBox.minX.toFixed(4)}" y="${args.viewBox.minY.toFixed(4)}" width="${args.viewBox.width.toFixed(4)}" height="${args.viewBox.height.toFixed(4)}" preserveAspectRatio="xMidYMid slice" opacity="${opacity.toFixed(3)}" clip-path="url(#${clipId})" />`
1234 }
1235 }
1236 return { defs: [], paint: 'none' }
1237 }
1238
1239 const buildShapeBlock = async (args: {
1240 element: Record<string, unknown>
1241 blockId: string
1242 role?: string
1243 animation?: ImportedElementAnimation
1244 imagesDir: string
1245 registry: ImageRegistry
1246 scaleX: number
1247 scaleY: number
1248 textScale: number
1249 zIndex: number
1250 offsetX: number
1251 offsetY: number
1252 pageNumber?: number
1253 warnings?: ImportWarning[]
1254 textValidator?: PptxTextValidator
1255 xmlShape?: PptxXmlShapeMetadata
1256 }): Promise<string> => {
1257 const element = applyXmlShapeFrame(args.element, args.xmlShape)
1258 const rawContent = typeof element.content === 'string' ? element.content : ''
1259 const hasTextContent = stripHtml(rawContent).length > 0
1260 const customGeometryPath = args.xmlShape?.customGeometry
1261 ? renderOoxmlCustomGeometryPath(
1262 args.xmlShape.customGeometry,
1263 clampNumber(element.width),
1264 clampNumber(element.height)
1265 )
1266 : ''
1267 const presetGeometryPath =
1268 !customGeometryPath && args.xmlShape?.preset
1269 ? renderOoxmlPresetShapePath(
1270 args.xmlShape.preset,
1271 clampNumber(element.width),
1272 clampNumber(element.height),
1273 args.xmlShape.adjustments
1274 )
1275 : ''
1276 const importedGeometryPath = !customGeometryPath && !presetGeometryPath &&
1277 String(element.shapType || '').toLowerCase() === 'customgeometry' &&
1278 typeof element.path === 'string'
1279 ? scaleImportedUnitPath(element.path, clampNumber(element.width), clampNumber(element.height))
1280 : ''
1281 const rawPath =
1282 customGeometryPath || presetGeometryPath || importedGeometryPath || (typeof element.path === 'string' ? element.path.trim() : '')
1283 const safePath = /^[MmLlHhVvCcSsQqTtAaZz0-9eE+.,\s-]+$/.test(rawPath) ? rawPath : ''
1284 const fill = element.fill as Fill | undefined
1285 const pathBounds = safePath ? getSvgPathBounds(safePath) : null
1286 const isDegeneratePath = Boolean(pathBounds && (pathBounds.width < 0.5 || pathBounds.height < 0.5))
1287 const borderColor = sanitizeImportedCssColor(element.borderColor)
1288 const hasVisibleBorder = clampNumber(element.borderWidth) > 0 && !isTransparentCssColor(borderColor)
1289 const shapeHasVisibleFill = hasVisibleFill(fill)
1290 if (hasTextContent && (!(safePath && pathBounds) || (isDegeneratePath && !shapeHasVisibleFill && !hasVisibleBorder))) {
1291 return buildTextBlock({ ...args, element })
1292 }
1293 if (safePath && pathBounds) {
1294 const viewBox = getSvgShapeViewBox(element, pathBounds, safePath, args.xmlShape)
1295 const shadow = element.shadow as
1296 | { h?: number; v?: number; blur?: number; color?: string }
1297 | undefined
1298 const css = buildBlockStyle({
1299 element,
1300 scaleX: args.scaleX,
1301 scaleY: args.scaleY,
1302 zIndex: args.zIndex,
1303 offsetX: args.offsetX,
1304 offsetY: args.offsetY,
1305 overflow: shadow ? 'visible' : 'hidden'
1306 })
1307 const isOpenShape = isOpenXmlShape(args.xmlShape)
1308 const svgFill = isOpenShape
1309 ? { defs: [], paint: 'none' }
1310 : args.xmlShape?.fillColor
1311 ? { defs: [], paint: args.xmlShape.fillColor }
1312 : await resolveSvgShapeFill({
1313 fill,
1314 blockId: args.blockId,
1315 safePath,
1316 viewBox,
1317 imagesDir: args.imagesDir,
1318 registry: args.registry
1319 })
1320 const strokeWidth = Math.max(
1321 0,
1322 args.xmlShape?.lineWidth !== undefined
1323 ? args.xmlShape.lineWidth
1324 : clampNumber(element.borderWidth)
1325 ) * (4 / 3)
1326 const rawStrokeColor = args.xmlShape?.lineColor || sanitizeImportedCssColor(element.borderColor)
1327 const strokeColor = strokeWidth > 0 && !isTransparentCssColor(rawStrokeColor)
1328 ? rawStrokeColor || '#000000'
1329 : 'none'
1330 let dashArray = typeof element.borderStrokeDasharray === 'string' &&
1331 /^[0-9.,\s-]+$/.test(element.borderStrokeDasharray)
1332 ? element.borderStrokeDasharray
1333 : ''
1334 const borderType = String(element.borderType || '').toLowerCase()
1335 if (!dashArray && strokeWidth > 0 && borderType === 'dashed') {
1336 dashArray = `${(strokeWidth * 4).toFixed(2)} ${(strokeWidth * 2).toFixed(2)}`
1337 } else if (!dashArray && strokeWidth > 0 && borderType === 'dotted') {
1338 dashArray = `0 ${(strokeWidth * 2).toFixed(2)}`
1339 }
1340 if (strokeColor === 'none') dashArray = ''
1341 const defs = [...svgFill.defs]
1342 let filterAttribute = ''
1343 if (shadow) {
1344 const shadowColor = sanitizeImportedCssColor(shadow.color) || '#00000066'
1345 const shadowId = svgResourceId(args.blockId, 'shadow')
1346 defs.push(
1347 `<filter id="${shadowId}" x="-50%" y="-50%" width="200%" height="200%"><feDropShadow dx="${(clampNumber(shadow.h) * (4 / 3)).toFixed(3)}" dy="${(clampNumber(shadow.v) * (4 / 3)).toFixed(3)}" stdDeviation="${Math.max(0, clampNumber(shadow.blur) * (2 / 3)).toFixed(3)}" flood-color="${shadowColor}" /></filter>`
1348 )
1349 filterAttribute = ` filter="url(#${shadowId})"`
1350 }
1351 const markerAttributes: string[] = []
1352 if (strokeColor !== 'none' && args.xmlShape?.headEnd && args.xmlShape.headEnd !== 'none') {
1353 const markerId = svgResourceId(args.blockId, 'head-arrow')
1354 defs.push(
1355 `<marker id="${markerId}" viewBox="0 0 10 10" refX="2" refY="5" markerWidth="5" markerHeight="5" orient="auto-start-reverse"><path d="M 10 0 L 0 5 L 10 10 z" fill="${strokeColor}"></path></marker>`
1356 )
1357 markerAttributes.push(`marker-start="url(#${markerId})"`)
1358 }
1359 if (strokeColor !== 'none' && args.xmlShape?.tailEnd && args.xmlShape.tailEnd !== 'none') {
1360 const markerId = svgResourceId(args.blockId, 'tail-arrow')
1361 defs.push(
1362 `<marker id="${markerId}" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="5" markerHeight="5" orient="auto"><path d="M 0 0 L 10 5 L 0 10 z" fill="${strokeColor}"></path></marker>`
1363 )
1364 markerAttributes.push(`marker-end="url(#${markerId})"`)
1365 }
1366 const flipX = element.isFlipH ? -1 : 1
1367 const flipY = element.isFlipV ? -1 : 1
1368 const svgTransform = flipX === 1 && flipY === 1
1369 ? ''
1370 : `transform:scale(${flipX},${flipY});transform-origin:center;`
1371 const animationAttrs = buildAnimationAttrs(args.animation)
1372 const animationAttrText = animationAttrs ? ` ${animationAttrs}` : ''
1373 const defsMarkup = defs.length > 0 ? `<defs>${defs.join('')}</defs>` : ''
1374 const markerAttrText = markerAttributes.length ? ` ${markerAttributes.join(' ')}` : ''
1375 const shapeMarkup = `${svgFill.content || ''}<path d="${escapeHtml(safePath)}" fill="${svgFill.paint}" stroke="${strokeColor}" stroke-width="${strokeWidth.toFixed(3)}"${dashArray ? ` stroke-dasharray="${dashArray}"` : ''} stroke-linecap="round" stroke-linejoin="round"${markerAttrText} />`
1376 const sanitizedOverlayContent = hasTextContent ? sanitizeContentHtml(rawContent, args.textScale) : ''
1377 const overlayCss = [
1378 'position:absolute',
1379 'inset:0',
1380 'overflow:visible',
1381 ...textInsetCss(args.xmlShape, args.textScale),
1382 ...textVerticalCss(
1383 element,
1384 args.xmlShape,
1385 sanitizedOverlayContent,
1386 args.scaleY,
1387 args.textScale
1388 )
1389 ].join(';')
1390 const textOverlay = hasTextContent
1391 ? `<div style="${overlayCss}">${sanitizedOverlayContent}</div>`
1392 : ''
1393 return `<figure data-block-id="${escapeHtml(args.blockId)}" data-pptx-kind="vector-shape"${animationAttrText} style="${css};margin:0"><svg viewBox="${viewBox.minX.toFixed(4)} ${viewBox.minY.toFixed(4)} ${viewBox.width.toFixed(4)} ${viewBox.height.toFixed(4)}" preserveAspectRatio="none" style="width:100%;height:100%;display:block;overflow:visible;${svgTransform}" aria-hidden="true">${defsMarkup}<g${filterAttribute}>${shapeMarkup}</g></svg>${textOverlay}</figure>`
1394 }
1395 const fillCss = await fillToCss(element.fill as Fill | undefined, args.imagesDir, args.registry)
1396 const shadowCss = boxShadowCss(element, args.scaleX, args.scaleY)
1397 const css = buildBlockStyle({
1398 element,
1399 scaleX: args.scaleX,
1400 scaleY: args.scaleY,
1401 zIndex: args.zIndex,
1402 offsetX: args.offsetX,
1403 offsetY: args.offsetY,
1404 overflow: shadowCss.length ? 'visible' : 'hidden',
1405 extra: [...fillCss, ...borderCss(element, args.textScale), ...shadowCss]
1406 })
1407 const animationAttrs = buildAnimationAttrs(args.animation)
1408 const animationAttrText = animationAttrs ? ` ${animationAttrs}` : ''
1409 return `<div data-block-id="${escapeHtml(args.blockId)}"${animationAttrText} style="${css}"></div>`
1410 }
1411
1412 const scaleImportedUnitPath = (path: string, width: number, height: number): string => {
1413 const tokens = path.match(/[A-Za-z]|-?\d*\.?\d+(?:e[-+]?\d+)?/gi) || []
1414 const output: string[] = []
1415 let command = ''
1416 let coordinateIndex = 0
1417 for (const token of tokens) {
1418 if (/^[A-Za-z]$/.test(token)) {
1419 command = token
1420 coordinateIndex = 0
1421 output.push(token)
1422 continue
1423 }
1424 const value = Number(token)
1425 if (!Number.isFinite(value)) continue
1426 const upperCommand = command.toUpperCase()
1427 let scaled = value
1428 if (['M', 'L', 'C', 'Q', 'S', 'T'].includes(upperCommand)) {
1429 scaled = value * (coordinateIndex % 2 === 0 ? width : height)
1430 } else if (upperCommand === 'A') {
1431 const arcIndex = coordinateIndex % 7
1432 if (arcIndex === 0 || arcIndex === 5) scaled = value * width
1433 else if (arcIndex === 1 || arcIndex === 6) scaled = value * height
1434 }
1435 output.push(Number(scaled.toFixed(4)).toString())
1436 coordinateIndex += 1
1437 }
1438 return output.join(' ')
1439 }
1440
1441 const buildTableBlock = (args: {
1442 element: Record<string, unknown>
1443 blockId: string
1444 animation?: ImportedElementAnimation
1445 scaleX: number
1446 scaleY: number
1447 textScale: number
1448 zIndex: number
1449 offsetX: number
1450 offsetY: number
1451 }): string => {
1452 const rows = Array.isArray(args.element.data) ? (args.element.data as ImportedTableCell[][]) : []
1453 const tableTextScale = Math.min(args.textScale, 1.25)
1454 const tableBorders = args.element.borders as Partial<Record<TableBorderSide, ImportedTableBorder>> | undefined
1455 const colWidths = Array.isArray(args.element.colWidths)
1456 ? (args.element.colWidths as unknown[])
1457 .map((width) => clampNumber(width) * args.scaleX)
1458 .filter((width) => width > 0)
1459 : []
1460 const rowHeights = Array.isArray(args.element.rowHeights)
1461 ? (args.element.rowHeights as unknown[]).map((height) => clampNumber(height) * args.scaleY)
1462 : []
1463 const colgroup = colWidths.length
1464 ? `<colgroup>${colWidths
1465 .map((width) => `<col style="width:${width.toFixed(1)}px;" />`)
1466 .join('')}</colgroup>`
1467 : ''
1468 const tableRows = rows
1469 .map((row, rowIndex) => {
1470 let logicalColIndex = 0
1471 const rowHeight = rowHeights[rowIndex] && rowHeights[rowIndex] > 0
1472 ? ` style="height:${rowHeights[rowIndex].toFixed(1)}px;"`
1473 : ''
1474 const cells = row
1475 .map((cell) => {
1476 if (isMergedTableContinuation(cell)) {
1477 logicalColIndex += 1
1478 return ''
1479 }
1480 const colIndex = logicalColIndex
1481 logicalColIndex += spanSize(cell.colSpan)
1482 const styles = [
1483 ...tableBorderDeclarations(cell.borders, tableBorders, args.textScale),
1484 'padding:6px 8px',
1485 'overflow-wrap:anywhere',
1486 'white-space:pre-wrap',
1487 `vertical-align:${tableVerticalAlign(cell.vAlign)}`,
1488 sanitizeImportedCssColor(cell.fillColor) ? `background:${sanitizeImportedCssColor(cell.fillColor)}` : '',
1489 sanitizeImportedCssColor(cell.fontColor) ? `color:${sanitizeImportedCssColor(cell.fontColor)}` : '',
1490 cell.fontBold ? 'font-weight:700' : '',
1491 rowHeights[rowIndex] && rowHeights[rowIndex] > 0
1492 ? `height:${rowHeights[rowIndex].toFixed(1)}px`
1493 : ''
1494 ]
1495 .filter(Boolean)
1496 .join(';')
1497 const colspan = spanAttr('colspan', cell.colSpan)
1498 const rowspan = spanAttr('rowspan', cell.rowSpan)
1499 const content = sanitizeTableCellContentHtml(String(cell.text || ''), args.textScale)
1500 return `<td data-cell-id="r${rowIndex + 1}-c${colIndex + 1}"${colspan}${rowspan} style="${styles}">${content || '&nbsp;'}</td>`
1501 })
1502 .join('')
1503 return `<tr${rowHeight}>${cells}</tr>`
1504 })
1505 .join('')
1506 const css = buildBlockStyle({
1507 element: args.element,
1508 scaleX: args.scaleX,
1509 scaleY: args.scaleY,
1510 zIndex: args.zIndex,
1511 offsetX: args.offsetX,
1512 offsetY: args.offsetY,
1513 extra: ['background:transparent']
1514 })
1515 const placeholderCss = buildBlockStyle({
1516 element: args.element,
1517 scaleX: args.scaleX,
1518 scaleY: args.scaleY,
1519 zIndex: args.zIndex,
1520 offsetX: args.offsetX,
1521 offsetY: args.offsetY,
1522 extra: ['background:#fff']
1523 })
1524 const animationAttrs = buildAnimationAttrs(args.animation)
1525 const animationAttrText = animationAttrs ? ` ${animationAttrs}` : ''
1526 if (!rows.length) {
1527 return `<section data-block-id="${escapeHtml(args.blockId)}" data-pptx-kind="table" data-pptx-import-mode="placeholder"${animationAttrText} style="${placeholderCss};display:flex;align-items:center;justify-content:center;color:#6b7280;">表格已作为占位导入</section>`
1528 }
1529 return `<section data-block-id="${escapeHtml(args.blockId)}" data-pptx-kind="table" data-pptx-import-mode="editable"${animationAttrText} style="${css}"><table style="width:100%;height:100%;border-collapse:collapse;border-spacing:0;table-layout:fixed;font-size:${Math.max(12, 12 * tableTextScale).toFixed(1)}px;">${colgroup}${tableRows}</table></section>`
1530 }
1531
1532 export const __pptxImporterTestUtils = {
1533 buildShapeBlock,
1534 buildTextBlock,
1535 buildTableBlock,
1536 buildChartBlock,
1537 flattenElements,
1538 compareElementsForRender,
1539 resolveSlideFit,
1540 sanitizeContentHtml,
1541 getSvgPathBounds,
1542 xmlShapeFromParserOoxml
1543 }
1544
1545 const renderElement = async (args: {
1546 element: Element
1547 pageId: string
1548 blockCounters: Record<string, number>
1549 animationContext?: SlideAnimationContext
1550 inheritedAnimation?: ImportedElementAnimation
1551 imagesDir: string
1552 registry: ImageRegistry
1553 scaleX: number
1554 scaleY: number
1555 textScale: number
1556 zIndexCounter: ZIndexCounter
1557 offsetX: number
1558 offsetY: number
1559 titleAssigned: boolean
1560 pageNumber?: number
1561 warnings?: ImportWarning[]
1562 textValidator?: PptxTextValidator
1563 chartRewrite?: PptxChartRewriteHandler
1564 }): Promise<{ html: string; titleAssigned: boolean }> => {
1565 const nextBlockId = (prefix: string): string => {
1566 args.blockCounters[prefix] = (args.blockCounters[prefix] || 0) + 1
1567 return `${prefix}-${args.blockCounters[prefix]}`
1568 }
1569 const record = args.element as unknown as Record<string, unknown>
1570 const xmlShape = xmlShapeFromParserOoxml(record)
1571 const elementAnimation =
1572 resolveElementAnimation(args.animationContext, record, args.offsetX, args.offsetY) ||
1573 args.inheritedAnimation
1574 if (args.element.type === 'group') {
1575 const children = Array.isArray(args.element.elements)
1576 ? normalizeGroupChildren(args.element, args.element.elements).sort(compareElementsForRender)
1577 : []
1578 const rendered: string[] = []
1579 let titleAssigned = args.titleAssigned
1580 const groupOffsetX = args.offsetX + clampNumber(record.left)
1581 const groupOffsetY = args.offsetY + clampNumber(record.top)
1582 for (const child of children) {
1583 const result = await renderElement({
1584 ...args,
1585 element: child,
1586 offsetX: groupOffsetX,
1587 offsetY: groupOffsetY,
1588 inheritedAnimation: elementAnimation,
1589 titleAssigned
1590 })
1591 rendered.push(result.html)
1592 titleAssigned = result.titleAssigned
1593 }
1594 return { html: rendered.join('\n'), titleAssigned }
1595 }
1596 if (args.element.type === 'image') {
1597 return {
1598 html: await buildImageBlock({
1599 element: record,
1600 blockId: nextBlockId('image'),
1601 animation: elementAnimation,
1602 imagesDir: args.imagesDir,
1603 registry: args.registry,
1604 scaleX: args.scaleX,
1605 scaleY: args.scaleY,
1606 offsetX: args.offsetX,
1607 offsetY: args.offsetY,
1608 zIndex: args.zIndexCounter.value++
1609 }),
1610 titleAssigned: args.titleAssigned
1611 }
1612 }
1613 if (args.element.type === 'table') {
1614 return {
1615 html: buildTableBlock({
1616 element: record,
1617 blockId: nextBlockId('table'),
1618 animation: elementAnimation,
1619 scaleX: args.scaleX,
1620 scaleY: args.scaleY,
1621 textScale: args.textScale,
1622 offsetX: args.offsetX,
1623 offsetY: args.offsetY,
1624 zIndex: args.zIndexCounter.value++
1625 }),
1626 titleAssigned: args.titleAssigned
1627 }
1628 }
1629 if (args.element.type === 'chart') {
1630 const chartIndex = (args.blockCounters.chart || 0) + 1
1631 args.blockCounters.chart = chartIndex
1632 const blockId = `chart-${chartIndex}`
1633 const canvasId = chartCanvasId(args.pageId, chartIndex)
1634 const animationAttrs = buildAnimationAttrs(elementAnimation)
1635 const animationAttrText = animationAttrs ? ` ${animationAttrs}` : ''
1636 const zIndex = args.zIndexCounter.value++
1637 const frameStyle = buildChartFrameStyle({
1638 element: args.element,
1639 scaleX: args.scaleX,
1640 scaleY: args.scaleY,
1641 zIndex,
1642 offsetX: args.offsetX,
1643 offsetY: args.offsetY
1644 })
1645 let html = buildChartBlock({
1646 element: args.element,
1647 blockId,
1648 animation: elementAnimation,
1649 pageId: args.pageId,
1650 chartIndex,
1651 scaleX: args.scaleX,
1652 scaleY: args.scaleY,
1653 offsetX: args.offsetX,
1654 offsetY: args.offsetY,
1655 zIndex,
1656 pageNumber: args.pageNumber,
1657 warnings: args.warnings,
1658 suppressUnsupportedWarning: true
1659 })
1660 if (html.includes('data-pptx-import-mode="placeholder"') && args.chartRewrite) {
1661 const rewritten = await args.chartRewrite({
1662 element: args.element,
1663 blockId,
1664 pageId: args.pageId,
1665 chartIndex,
1666 canvasId,
1667 frameStyle,
1668 animationAttrs,
1669 pageNumber: args.pageNumber
1670 })
1671 if (rewritten?.config) {
1672 html = buildChartHtmlFromConfig({
1673 element: args.element,
1674 blockId,
1675 canvasId,
1676 frameStyle,
1677 animationAttrText,
1678 config: rewritten.config
1679 })
1680 if (rewritten.warnings?.length) {
1681 args.warnings?.push(
1682 ...rewritten.warnings.map((message) => ({ pageNumber: args.pageNumber, message }))
1683 )
1684 }
1685 }
1686 }
1687 if (html.includes('data-pptx-import-mode="placeholder"')) {
1688 args.warnings?.push({
1689 pageNumber: args.pageNumber,
1690 message: unsupportedChartWarning(blockId, args.element.chartType)
1691 })
1692 }
1693 return {
1694 html,
1695 titleAssigned: args.titleAssigned
1696 }
1697 }
1698 if (args.element.type === 'text') {
1699 const text = stripHtml(String(record.content || ''))
1700 const shouldBeTitle = !args.titleAssigned && text.length > 0 && clampNumber(record.top) < 120
1701 return {
1702 html: await buildTextBlock({
1703 element: record,
1704 blockId: shouldBeTitle ? 'title' : nextBlockId('text'),
1705 role: shouldBeTitle ? 'title' : undefined,
1706 animation: elementAnimation,
1707 imagesDir: args.imagesDir,
1708 registry: args.registry,
1709 scaleX: args.scaleX,
1710 scaleY: args.scaleY,
1711 textScale: args.textScale,
1712 offsetX: args.offsetX,
1713 offsetY: args.offsetY,
1714 zIndex: args.zIndexCounter.value++,
1715 pageNumber: args.pageNumber,
1716 warnings: args.warnings,
1717 textValidator: args.textValidator,
1718 xmlShape
1719 }),
1720 titleAssigned: args.titleAssigned || shouldBeTitle
1721 }
1722 }
1723 if (args.element.type === 'shape') {
1724 const text = stripHtml(String(record.content || ''))
1725 const shouldBeTitle = !args.titleAssigned && text.length > 0 && clampNumber(record.top) < 120
1726 return {
1727 html: await buildShapeBlock({
1728 element: record,
1729 blockId: shouldBeTitle ? 'title' : nextBlockId(text ? 'text' : 'shape'),
1730 role: shouldBeTitle ? 'title' : undefined,
1731 animation: elementAnimation,
1732 imagesDir: args.imagesDir,
1733 registry: args.registry,
1734 scaleX: args.scaleX,
1735 scaleY: args.scaleY,
1736 textScale: args.textScale,
1737 offsetX: args.offsetX,
1738 offsetY: args.offsetY,
1739 zIndex: args.zIndexCounter.value++,
1740 xmlShape,
1741 pageNumber: args.pageNumber,
1742 warnings: args.warnings,
1743 textValidator: args.textValidator
1744 }),
1745 titleAssigned: args.titleAssigned || shouldBeTitle
1746 }
1747 }
1748 if (args.element.type === 'diagram' && Array.isArray(args.element.elements)) {
1749 const text = args.element.textList?.join(' / ') || 'SmartArt'
1750 const css = buildBlockStyle({
1751 element: record,
1752 scaleX: args.scaleX,
1753 scaleY: args.scaleY,
1754 zIndex: args.zIndexCounter.value++,
1755 offsetX: args.offsetX,
1756 offsetY: args.offsetY,
1757 extra: ['background:#f8fafc', 'border:1px dashed #cbd5e1', 'padding:12px', 'color:#475569']
1758 })
1759 const animationAttrs = buildAnimationAttrs(elementAnimation)
1760 const animationAttrText = animationAttrs ? ` ${animationAttrs}` : ''
1761 return {
1762 html: `<section data-block-id="${nextBlockId('diagram')}"${animationAttrText} style="${css}">${escapeHtml(text)}</section>`,
1763 titleAssigned: args.titleAssigned
1764 }
1765 }
1766 if (args.element.type === 'math') {
1767 const text = String(record.latex || record.text || 'Formula')
1768 const css = buildBlockStyle({
1769 element: record,
1770 scaleX: args.scaleX,
1771 scaleY: args.scaleY,
1772 zIndex: args.zIndexCounter.value++,
1773 offsetX: args.offsetX,
1774 offsetY: args.offsetY,
1775 extra: [
1776 'background:#ffffff',
1777 'border:1px dashed #cbd5e1',
1778 'padding:10px',
1779 'color:#334155',
1780 'font-family:Georgia,serif',
1781 'font-size:18px',
1782 'display:flex',
1783 'align-items:center',
1784 'justify-content:center',
1785 'text-align:center'
1786 ]
1787 })
1788 const animationAttrs = buildAnimationAttrs(elementAnimation)
1789 const animationAttrText = animationAttrs ? ` ${animationAttrs}` : ''
1790 return {
1791 html: `<section data-block-id="${nextBlockId('math')}" data-pptx-kind="math"${animationAttrText} style="${css}">${escapeHtml(text)}</section>`,
1792 titleAssigned: args.titleAssigned
1793 }
1794 }
1795 return { html: '', titleAssigned: args.titleAssigned }
1796 }
1797
1798 const buildFallbackTitle = (title: string): string =>
1799 `<header data-block-id="title" data-role="title" style="position:absolute;left:48px;top:36px;width:900px;height:56px;z-index:1;overflow:hidden;">
1800 <h1 style="margin:0;font-size:36px;line-height:1.2;color:#111827;">${escapeHtml(title)}</h1>
1801 </header>`
1802
1803 const buildImportedPptxMotionScript = (): string => `<script data-pptx-import-motion="1">
1804 (function () {
1805 function runImportedPptxMotion() {
1806 var root = document.querySelector(".ppt-page-root");
1807 var pptApi = window.PPT;
1808 if (!root || !pptApi || typeof pptApi.scanDataAnim !== "function") return;
1809 var config = pptApi.scanDataAnim(root);
1810 if (!config || (!config.load.length && !config.click.length)) return;
1811 if (config.load.length && typeof pptApi.executeDataAnim === "function") {
1812 pptApi.executeDataAnim(config.load);
1813 }
1814 if (config.click.length && pptApi.clicks && typeof pptApi.clicks.on === "function") {
1815 var clickSteps = Array.isArray(config.clickSteps) && config.clickSteps.length > 0
1816 ? config.clickSteps
1817 : config.click.map(function (animDef) { return [animDef]; });
1818 clickSteps.forEach(function (stepDefs, index) {
1819 pptApi.clicks.on(index + 1, function () {
1820 if (typeof pptApi.executeDataAnim === "function") {
1821 pptApi.executeDataAnim(stepDefs);
1822 }
1823 });
1824 });
1825 }
1826 }
1827 if (document.readyState === "loading") {
1828 document.addEventListener("DOMContentLoaded", runImportedPptxMotion, { once: true });
1829 } else {
1830 runImportedPptxMotion();
1831 }
1832 })();
1833 </script>`
1834
1835 const buildSlideHtml = async (args: {
1836 slide: Slide
1837 pageNumber: number
1838 pageId: string
1839 title: string
1840 size: { width: number; height: number }
1841 animationPlan?: SlideAnimationPlan
1842 projectDir: string
1843 registry: ImageRegistry
1844 textValidator?: PptxTextValidator
1845 chartRewrite?: PptxChartRewriteHandler
1846 }): Promise<{ html: string; contentOutline: string; warnings: ImportWarning[] }> => {
1847 const imagesDir = path.join(args.projectDir, 'images')
1848 const slideFit = resolveSlideFit(args.size)
1849 const scaleX = slideFit.scale
1850 const scaleY = slideFit.scale
1851 const textScale = slideFit.scale
1852 const warnings: ImportWarning[] = []
1853 const backgroundCss = await fillToCss(args.slide.fill, imagesDir, args.registry)
1854 const blockCounters: Record<string, number> = {}
1855 const animationContext: SlideAnimationContext = {
1856 plan: args.animationPlan,
1857 usedAnimationIds: new Set<number>()
1858 }
1859 const elements = [...(args.slide.layoutElements || []), ...(args.slide.elements || [])].sort(
1860 compareElementsForRender
1861 )
1862 const rendered: string[] = []
1863 const zIndexCounter: ZIndexCounter = { value: 2 }
1864 let titleAssigned = false
1865 for (const [index, element] of elements.entries()) {
1866 try {
1867 const result = await renderElement({
1868 element,
1869 pageId: args.pageId,
1870 blockCounters,
1871 animationContext,
1872 imagesDir,
1873 registry: args.registry,
1874 scaleX,
1875 scaleY,
1876 textScale,
1877 zIndexCounter,
1878 offsetX: slideFit.offsetX,
1879 offsetY: slideFit.offsetY,
1880 titleAssigned,
1881 pageNumber: args.pageNumber,
1882 warnings,
1883 textValidator: args.textValidator,
1884 chartRewrite: args.chartRewrite
1885 })
1886 if (result.html) rendered.push(result.html)
1887 titleAssigned = result.titleAssigned
1888 } catch (error) {
1889 warnings.push({
1890 pageNumber: args.pageNumber,
1891 message: `元素 ${index + 1} 导入失败:${error instanceof Error ? error.message : String(error)}`
1892 })
1893 }
1894 }
1895 if (!titleAssigned) {
1896 rendered.unshift(buildFallbackTitle(args.title))
1897 }
1898 const contentOutline = flattenElements(elements)
1899 .map(({ element, text }) => {
1900 if (text && !isLowValueTitleText(text)) return text
1901 if (element.type === 'table') return '表格'
1902 if (element.type === 'chart') return '图表'
1903 if (element.type === 'image') return '图片'
1904 return ''
1905 })
1906 .filter(Boolean)
1907 .slice(0, 8)
1908 .join(';')
1909 const sectionStyle = ['position:relative', 'width:100%', 'height:100%', 'overflow:hidden', ...backgroundCss].join(';')
1910 const hasImportedAnimations = rendered.some((html) => /\sdata-anim=/.test(html))
1911 const body = `<section data-page-scaffold="1" style="${sectionStyle}">
1912 <main data-block-id="content" data-role="content" style="position:absolute;inset:0;z-index:0;">
1913 ${rendered.join('\n')}
1914 </main>
1915 </section>
1916 ${hasImportedAnimations ? buildImportedPptxMotionScript() : ''}`
1917 const scaffold = buildPageScaffoldHtml({
1918 pageNumber: args.pageNumber,
1919 pageId: args.pageId,
1920 title: args.title
1921 }, PPTX_IMPORT_SLIDE_SIZE)
1922 const $ = cheerio.load(scaffold, { scriptingEnabled: false })
1923 $('.ppt-page-root').first().removeClass('p-2 p-8').attr('style', 'padding:0;')
1924 $('.ppt-page-content').first().html(body)
1925 const html = $.html()
1926 const validation = validatePersistedPageHtml(html, args.pageId)
1927 if (!validation.valid) {
1928 warnings.push(
1929 ...validation.errors.map((message) => ({
1930 pageNumber: args.pageNumber,
1931 message
1932 }))
1933 )
1934 }
1935 return {
1936 html,
1937 contentOutline: contentOutline || args.title,
1938 warnings
1939 }
1940 }
1941
1942 /**
1943 * 等距抽样:从 slides 中均匀选取 count 页,保证首尾都包含,中间按等距取。
1944 */
1945 type SelectedSlide<T> = { slide: T; originalIndex: number }
1946
1947 function selectSlidesEvenly<T>(slides: T[], count: number): SelectedSlide<T>[] {
1948 const entries = slides.map((slide, originalIndex) => ({ slide, originalIndex }))
1949 if (count >= slides.length) return entries
1950 if (count <= 2) return [entries[0], entries[entries.length - 1]]
1951 const result: SelectedSlide<T>[] = [entries[0]]
1952 const middle = slides.slice(1, -1)
1953 const middleCount = count - 2
1954 for (let i = 0; i < middleCount; i++) {
1955 const idx = Math.floor((i + 0.5) * middle.length / middleCount)
1956 result.push(entries[idx + 1])
1957 }
1958 result.push(entries[entries.length - 1])
1959 return result
1960 }
1961
1962 export async function importPptxToEditableHtml(args: {
1963 filePath: string
1964 projectDir: string
1965 title?: string
1966 maxPages?: number
1967 onProgress?: ImportProgress
1968 chartRewrite?: PptxChartRewriteHandler
1969 }): Promise<ImportedPptxDeck> {
1970 const fileName = path.basename(args.filePath)
1971 const title = (args.title || path.basename(fileName, path.extname(fileName)) || '导入的 PPTX').trim()
1972 const indexPath = path.join(args.projectDir, 'index.html')
1973 const imagesDir = path.join(args.projectDir, 'images')
1974 await fs.promises.mkdir(imagesDir, { recursive: true })
1975 args.onProgress?.({ stage: 'reading', progress: 5, label: '正在读取 PPTX 文件' })
1976 const buffer = await fs.promises.readFile(args.filePath)
1977 args.onProgress?.({ stage: 'parsing', progress: 14, label: '正在解析 PPTX 结构' })
1978 const parsed = await parse(buffer, {
1979 imageMode: 'base64',
1980 videoMode: 'none',
1981 audioMode: 'none'
1982 })
1983 const slides = parsed.slides || []
1984 if (slides.length === 0) {
1985 throw new Error('PPTX 中没有可导入的幻灯片')
1986 }
1987 const rawMaxPages = typeof args.maxPages === 'number' ? Math.floor(args.maxPages) : null
1988 const maxPages = rawMaxPages && rawMaxPages > 0 ? rawMaxPages : null
1989 const effectiveSlides = maxPages && maxPages < slides.length
1990 ? selectSlidesEvenly(slides, maxPages)
1991 : slides.map((slide, originalIndex) => ({ slide, originalIndex }))
1992 const animationPlans = readPptxAnimationPlans(
1993 buffer,
1994 effectiveSlides.map(({ originalIndex }) => originalIndex),
1995 parsed.size
1996 )
1997 args.onProgress?.({
1998 stage: 'media',
1999 progress: 24,
2000 label: '正在整理图片和页面元素',
2001 totalPages: effectiveSlides.length
2002 })
2003 const registry: ImageRegistry = { index: 0, byKey: new Map() }
2004 const pages: ImportedPptxPage[] = []
2005 const allWarnings: ImportWarning[] = (parsed.diagnostics || []).map(warningFromParseIssue)
2006 const textValidator = new PptxTextValidator()
2007 try {
2008 for (let i = 0; i < effectiveSlides.length; i += 1) {
2009 const pageNumber = i + 1
2010 const pageId = `page-${pageNumber}`
2011 const selectedSlide = effectiveSlides[i]
2012 const pageTitle = titleFromSlide(selectedSlide.slide, pageNumber)
2013 args.onProgress?.({
2014 stage: 'pages',
2015 progress: 25 + Math.round((pageNumber / effectiveSlides.length) * 58),
2016 label: `正在导入并校验第 ${pageNumber} / ${effectiveSlides.length} 页`,
2017 pageNumber,
2018 totalPages: effectiveSlides.length
2019 })
2020 const htmlPath = path.join(args.projectDir, `${pageId}.html`)
2021 const rendered = await buildSlideHtml({
2022 slide: selectedSlide.slide,
2023 pageNumber,
2024 pageId,
2025 title: pageTitle,
2026 size: parsed.size,
2027 animationPlan: animationPlans[i],
2028 projectDir: args.projectDir,
2029 registry,
2030 textValidator,
2031 chartRewrite: args.chartRewrite
2032 })
2033 await fs.promises.writeFile(htmlPath, rendered.html, 'utf-8')
2034 pages.push({
2035 pageNumber,
2036 pageId,
2037 title: pageTitle,
2038 htmlPath,
2039 html: rendered.html,
2040 contentOutline: rendered.contentOutline
2041 })
2042 allWarnings.push(...rendered.warnings)
2043 }
2044 } finally {
2045 textValidator.close()
2046 }
2047 args.onProgress?.({ stage: 'index', progress: 90, label: '正在生成演示总览' })
2048 await fs.promises.writeFile(
2049 indexPath,
2050 buildProjectIndexHtml(
2051 title,
2052 pages.map(
2053 (page): DeckPageFile => ({
2054 pageNumber: page.pageNumber,
2055 pageId: page.pageId,
2056 title: page.title,
2057 htmlPath: path.basename(page.htmlPath)
2058 })
2059 ),
2060 PPTX_IMPORT_SLIDE_SIZE
2061 ),
2062 'utf-8'
2063 )
2064 return {
2065 title: title.slice(0, 120) || '导入的 PPTX',
2066 pageCount: pages.length,
2067 indexPath,
2068 pages,
2069 warnings: allWarnings.map((warning) =>
2070 warning.pageNumber ? `第 ${warning.pageNumber} 页:${warning.message}` : warning.message
2071 )
2072 }
2073 }
2074
2074 lines TYPESCRIPT