dsh-ppt-studio 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +480 -0
- package/agent-presets/ppt/agent.cordis.yml +308 -0
- package/agent-presets/ppt/preset.yml +3 -0
- package/cordis.patch.yml +29 -0
- package/docs/00-/350/256/276/350/256/241/346/272/220.md +537 -0
- package/docs/01-/351/234/200/346/261/202/344/270/216/347/233/256/346/240/207.md +136 -0
- package/docs/02-/346/212/200/346/234/257/346/212/245/345/221/212.md +483 -0
- package/docs/03-/346/233/264/346/226/260/346/227/245/345/277/227.md +2898 -0
- package/docs/04-/350/267/257/347/272/277/345/233/276/344/270/216/351/207/214/347/250/213/347/242/221.md +113 -0
- package/docs/05-/350/277/255/344/273/243/346/265/201/347/250/213.md +210 -0
- package/docs/06-/350/257/204/345/256/241/344/270/216/346/265/213/350/257/225.md +296 -0
- package/docs/fidelity-report.md +225 -0
- package/examples/fx/fx-mini/deck.yaml +39 -0
- package/examples/fx/fx-mini/pages/01.yaml +6 -0
- package/examples/fx/fx-mini/pages/02.yaml +2 -0
- package/examples/fx/fx-mini/pages/03.yaml +6 -0
- package/examples/fx/fx-pro/deck.yaml +48 -0
- package/examples/fx/fx-pro/pages/01_cover.yaml +40 -0
- package/examples/fx/fx-pro/pages/02_arch.yaml +55 -0
- package/examples/fx/fx-pro/pages/03_table.yaml +20 -0
- package/examples/fx/fx-pro/pages/04_chart.yaml +40 -0
- package/examples/fx/fx-pro/pages/05_timeline.yaml +71 -0
- package/examples/fx/fx-pro/pages/06_longtext.yaml +13 -0
- package/examples/fx/fx-pro/pages/07_safearea.yaml +33 -0
- package/examples/fx/fx-pro/pages/08_contrast.yaml +22 -0
- package/examples/fx/fx-pro/pages/09_custgeom.yaml +32 -0
- package/examples/fx/fx-pro/pages/10_numbers.yaml +25 -0
- package/examples/fx/fx-pro/pages/11_section.yaml +20 -0
- package/examples/fx/fx-pro/pages/12_summary.yaml +57 -0
- package/examples/fx/fx-pro/preview/01_pages_01_cover.html +1 -0
- package/examples/fx/fx-pro/preview/02_pages_02_arch.html +3 -0
- package/examples/fx/fx-pro/preview/03_pages_03_table.html +1 -0
- package/examples/fx/fx-pro/preview/04_pages_04_chart.html +3 -0
- package/examples/fx/fx-pro/preview/05_pages_05_timeline.html +2 -0
- package/examples/fx/fx-pro/preview/06_pages_06_longtext.html +4 -0
- package/examples/fx/fx-pro/preview/07_pages_07_safearea.html +2 -0
- package/examples/fx/fx-pro/preview/08_pages_08_contrast.html +1 -0
- package/examples/fx/fx-pro/preview/09_pages_09_custgeom.html +1 -0
- package/examples/fx/fx-pro/preview/10_pages_10_numbers.html +1 -0
- package/examples/fx/fx-pro/preview/11_pages_11_section.html +1 -0
- package/examples/fx/fx-pro/preview/12_pages_12_summary.html +1 -0
- package/examples/fx/fx-pro/preview/deck.html +10 -0
- package/examples/fx/fx-pro/preview/layout.json +2325 -0
- package/examples/fx/fx-pro/preview/measured.json +814 -0
- package/examples/fx/fx-pro.pptx +0 -0
- package/examples/fx/ren-single/01.png +0 -0
- package/examples/fx/ren-spliced/01.png +0 -0
- package/examples/fx/ren-spliced/03.png +0 -0
- package/examples/fx/seed.pptx +0 -0
- package/examples/fx/single.pptx +0 -0
- package/examples/fx/spliced.pptx +0 -0
- package/examples/smoke/deck.yaml +26 -0
- package/examples/smoke/imported/deck.yaml +30 -0
- package/examples/smoke/imported/import-styles.json +126 -0
- package/examples/smoke/imported/pages/slide_01.yaml +31 -0
- package/examples/smoke/imported/pages/slide_02.yaml +38 -0
- package/examples/smoke/imported/pages/slide_03.yaml +55 -0
- package/examples/smoke/imported/preview-rt/01_pages_slide_01.html +1 -0
- package/examples/smoke/imported/preview-rt/02_pages_slide_02.html +1 -0
- package/examples/smoke/imported/preview-rt/03_pages_slide_03.html +1 -0
- package/examples/smoke/imported/preview-rt/deck.html +1 -0
- package/examples/smoke/imported/preview-rt/layout.json +434 -0
- package/examples/smoke/imported/reference/previews/01.png +0 -0
- package/examples/smoke/imported/reference/previews/02.png +0 -0
- package/examples/smoke/imported/reference/previews/03.png +0 -0
- package/examples/smoke/imported/source.pptx +0 -0
- package/examples/smoke/media-test.png +0 -0
- package/examples/smoke/out-smoke.pptx +0 -0
- package/examples/smoke/pages/01_cover.yaml +27 -0
- package/examples/smoke/pages/02_content.yaml +42 -0
- package/examples/smoke/pages/03_chart.yaml +35 -0
- package/examples/smoke/preview/01_pages_01_cover.html +1 -0
- package/examples/smoke/preview/02_pages_02_content.html +1 -0
- package/examples/smoke/preview/03_pages_03_chart.html +1 -0
- package/examples/smoke/preview/deck.html +1 -0
- package/examples/smoke/preview/layout.json +378 -0
- package/lib/autodeclare.js +40 -0
- package/lib/commands.js +161 -0
- package/lib/crosscheck.js +300 -0
- package/lib/imgmeta.js +55 -0
- package/lib/index.js +239 -0
- package/lib/measurement.js +209 -0
- package/lib/msrender.js +123 -0
- package/lib/pptd/export-pptx.js +686 -0
- package/lib/pptd/import-pptx.js +774 -0
- package/lib/pptd/layout.js +209 -0
- package/lib/pptd/render-html.js +254 -0
- package/lib/pptd/schema.js +518 -0
- package/lib/pptd/svgCharts.js +147 -0
- package/lib/pptxPy.js +205 -0
- package/lib/preset-delivery.js +195 -0
- package/lib/preview-server.js +164 -0
- package/lib/router.js +165 -0
- package/lib/scaffold.js +315 -0
- package/lib/skill.js +174 -0
- package/lib/splice.js +219 -0
- package/lib/state.js +88 -0
- package/lib/surgical.js +369 -0
- package/lib/templates.js +400 -0
- package/lib/tools.js +955 -0
- package/lib/verify.js +828 -0
- package/lib/xmljs.js +112 -0
- package/lib/zips.js +117 -0
- package/package.json +81 -0
- package/scripts/audit-manual-facts.mjs +294 -0
- package/scripts/audit-template-fidelity.mjs +162 -0
- package/scripts/bench-m2.mjs +48 -0
- package/scripts/build-templates.mjs +63 -0
- package/scripts/build.mjs +14 -0
- package/scripts/build.sh +7 -0
- package/scripts/check-lib-freshness.mjs +70 -0
- package/scripts/check-preset.mjs +189 -0
- package/scripts/check-tracked-lf.mjs +84 -0
- package/scripts/dbg-import.mjs +13 -0
- package/scripts/dbg-media.mjs +10 -0
- package/scripts/dbg-tpl-inventory.mjs +23 -0
- package/scripts/dump-workflow-baseline.mjs +38 -0
- package/scripts/e2e-1.0.mjs +129 -0
- package/scripts/eval-skills-blind.mjs +91 -0
- package/scripts/eval-skills-score.mjs +129 -0
- package/scripts/export-orig-templates.ps1 +17 -0
- package/scripts/fixtures/workflow-baseline.json +300 -0
- package/scripts/fixtures.mjs +501 -0
- package/scripts/import-user-templates.mjs +60 -0
- package/scripts/install-desktop.mjs +312 -0
- package/scripts/install.mjs +209 -0
- package/scripts/migrate-templates-dualtrack.mjs +71 -0
- package/scripts/preflight-1.0.mjs +198 -0
- package/scripts/probe-asset-freshness.mjs +57 -0
- package/scripts/probe-effect-semantics.mjs +69 -0
- package/scripts/probe-reparse-safety.mjs +32 -0
- package/scripts/rebuild-templates-c11.mjs +69 -0
- package/scripts/regression-real.mjs +84 -0
- package/scripts/release-sync.mjs +425 -0
- package/scripts/shot-templates.mjs +49 -0
- package/scripts/smoke.mjs +2512 -0
- package/scripts/verify-bundle-install.mjs +281 -0
- package/scripts/verify-fresh-install.mjs +260 -0
- package/skills/ppt-studio-copy/SKILL.md +139 -0
- package/skills/ppt-studio-craft/SKILL.md +171 -0
- package/skills/ppt-studio-data/SKILL.md +173 -0
- package/skills/ppt-studio-manual/SKILL.md +104 -0
- package/templates/academic-white/deck.yaml +24 -0
- package/templates/academic-white/pages/_chart.yaml +43 -0
- package/templates/academic-white/pages/_compare.yaml +37 -0
- package/templates/academic-white/pages/_content.yaml +35 -0
- package/templates/academic-white/pages/_cover.yaml +28 -0
- package/templates/academic-white/pages/_ending.yaml +28 -0
- package/templates/academic-white/pages/_flow.yaml +61 -0
- package/templates/academic-white/preview.png +0 -0
- package/templates/academic-white/template.yaml +14 -0
- package/templates/business-blue/deck.yaml +28 -0
- package/templates/business-blue/pages/_chart.yaml +36 -0
- package/templates/business-blue/pages/_compare.yaml +45 -0
- package/templates/business-blue/pages/_content.yaml +80 -0
- package/templates/business-blue/pages/_cover.yaml +39 -0
- package/templates/business-blue/pages/_ending.yaml +32 -0
- package/templates/business-blue/pages/_flow.yaml +87 -0
- package/templates/business-blue/preview.png +0 -0
- package/templates/business-blue/template.yaml +14 -0
- package/templates/pitch-bold/deck.yaml +23 -0
- package/templates/pitch-bold/pages/_chart.yaml +32 -0
- package/templates/pitch-bold/pages/_compare.yaml +36 -0
- package/templates/pitch-bold/pages/_content.yaml +58 -0
- package/templates/pitch-bold/pages/_cover.yaml +31 -0
- package/templates/pitch-bold/pages/_ending.yaml +27 -0
- package/templates/pitch-bold/pages/_flow.yaml +46 -0
- package/templates/pitch-bold/preview.png +0 -0
- package/templates/pitch-bold/template.yaml +14 -0
- package/templates/tech-dark/deck.yaml +24 -0
- package/templates/tech-dark/pages/_chart.yaml +27 -0
- package/templates/tech-dark/pages/_compare.yaml +38 -0
- package/templates/tech-dark/pages/_content.yaml +51 -0
- package/templates/tech-dark/pages/_cover.yaml +30 -0
- package/templates/tech-dark/pages/_ending.yaml +25 -0
- package/templates/tech-dark/pages/_flow.yaml +80 -0
- package/templates/tech-dark/preview.png +0 -0
- package/templates/tech-dark/template.yaml +14 -0
|
@@ -0,0 +1,774 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PPTX → PPTD 导入器(补完/修改/总结的基础):内容保真、版式参考。
|
|
3
|
+
* 抽取:文本(内容+字号/颜色/粗体)、形状(rect/ellipse/triangle)、
|
|
4
|
+
* 图片(拷入 media/)、表格;chart 降级为文本占位(保留标题/说明)。
|
|
5
|
+
* 输出 deck 项目目录(deck.yaml + pages/*.yaml + media/),几何保留供参考。
|
|
6
|
+
*/
|
|
7
|
+
import { readFile, writeFile, mkdir, copyFile } from 'node:fs/promises'
|
|
8
|
+
import { existsSync } from 'node:fs'
|
|
9
|
+
import { join, basename } from 'node:path'
|
|
10
|
+
import { zipRead, decodeXml } from '../zips.js'
|
|
11
|
+
import { parseXml, children, first, allText } from '../xmljs.js'
|
|
12
|
+
|
|
13
|
+
const EMU = 12700
|
|
14
|
+
const px = (emu) => Math.round(emu / EMU)
|
|
15
|
+
|
|
16
|
+
/** v0.9.1 候选 A:常见 prst → shape.kind 直通(白名单 = schema SHAPE_KINDS;未见名→rect 兜底并记录 _styleRaw.prst)。 */
|
|
17
|
+
const PRST_MAP = new Set([
|
|
18
|
+
'roundRect', 'ellipse', 'triangle',
|
|
19
|
+
'rightArrow', 'leftArrow', 'upArrow', 'downArrow', 'leftRightArrow',
|
|
20
|
+
'pentagon', 'hexagon', 'chevron', 'parallelogram', 'diamond', 'octagon', 'star5',
|
|
21
|
+
'flowchartProcess', 'flowchartDecision', 'flowchartData', 'flowchartTerminator',
|
|
22
|
+
])
|
|
23
|
+
|
|
24
|
+
export async function importPptx(pptxPath, outDir) {
|
|
25
|
+
const buf = await readFile(pptxPath)
|
|
26
|
+
const files = zipRead(buf)
|
|
27
|
+
const get = (p) => {
|
|
28
|
+
const d = files.get(p)
|
|
29
|
+
if (!d) throw new Error(`pptx part missing: ${p}`)
|
|
30
|
+
return decodeXml(d)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// 尺寸
|
|
34
|
+
const pres = first(parseXml(get('ppt/presentation.xml')), 'presentation')
|
|
35
|
+
const sldSz = pres ? first(pres, 'sldSz') : undefined
|
|
36
|
+
const width = sldSz ? px(Number(sldSz.attrs.cx ?? 12192000)) : 960
|
|
37
|
+
const height = sldSz ? px(Number(sldSz.attrs.cy ?? 6858000)) : 540
|
|
38
|
+
|
|
39
|
+
// 主题色(schemeClr → hex 映射,P0-1:导入保样式)
|
|
40
|
+
const themeClr = {}
|
|
41
|
+
const themeXml = files.get('ppt/theme/theme1.xml')
|
|
42
|
+
if (themeXml) {
|
|
43
|
+
const theme = parseXml(decodeXml(themeXml))
|
|
44
|
+
const themeElements = first(theme, 'themeElements') ?? theme
|
|
45
|
+
const clrScheme = first(themeElements, 'clrScheme')
|
|
46
|
+
for (const c of clrScheme?.children ?? []) {
|
|
47
|
+
const srgb = first(c, 'srgbClr')
|
|
48
|
+
const sys = first(c, 'sysClr')
|
|
49
|
+
const val = srgb?.attrs?.val ?? sys?.attrs?.lastClr ?? sys?.attrs?.val
|
|
50
|
+
if (val) themeClr[c.tag] = '#' + String(val).toUpperCase()
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const colorOf = (node) => {
|
|
54
|
+
// 支持两层结构:node 直接含 srgbClr/schemeClr,或经 solidFill 包裹(rPr/spPr 常见形态)
|
|
55
|
+
const holder = first(node, 'solidFill') ?? node
|
|
56
|
+
const srgb = first(holder, 'srgbClr')
|
|
57
|
+
if (srgb?.attrs?.val) return '#' + String(srgb.attrs.val).toUpperCase()
|
|
58
|
+
const scheme = first(holder, 'schemeClr')
|
|
59
|
+
if (scheme?.attrs?.val) {
|
|
60
|
+
const k = scheme.attrs.val
|
|
61
|
+
return themeClr[k] ?? { lt1: '#FFFFFF', dk1: '#000000', lt2: '#F3F4F6', dk2: '#1F2937', tx1: '#000000', bg1: '#FFFFFF' }[k] ?? '#000000'
|
|
62
|
+
}
|
|
63
|
+
return undefined
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// slide 顺序
|
|
67
|
+
const sldIdLst = pres ? first(pres, 'sldIdLst') : undefined
|
|
68
|
+
const slideIds = (sldIdLst?.children ?? []).filter((c) => c.tag === 'sldId')
|
|
69
|
+
const relsOf = (xmlText) => {
|
|
70
|
+
if (!xmlText) return new Map()
|
|
71
|
+
const tree = parseXml(xmlText)
|
|
72
|
+
const root = tree.children.find((c) => c.tag === 'Relationships') ?? tree
|
|
73
|
+
return new Map(root.children.filter((c) => c.tag === 'Relationship').map((r) => [r.attrs.Id, r.attrs.Target]))
|
|
74
|
+
}
|
|
75
|
+
const relMap = relsOf(get('ppt/_rels/presentation.xml.rels'))
|
|
76
|
+
|
|
77
|
+
// 抽取每页
|
|
78
|
+
const pages = []
|
|
79
|
+
const mediaNames = new Set()
|
|
80
|
+
const layoutRels = relsOf(files.get('ppt/slideLayouts/_rels/slideLayout1.xml.rels')?.toString('utf8'))
|
|
81
|
+
const layoutXml = files.get('ppt/slideLayouts/slideLayout1.xml') ? parseXml(decodeXml(files.get('ppt/slideLayouts/slideLayout1.xml'))) : null
|
|
82
|
+
const layoutBg = layoutXml ? bgOfSlide(layoutXml, layoutRels, mediaNames) : null
|
|
83
|
+
for (let i = 0; i < slideIds.length; i++) {
|
|
84
|
+
const rId = slideIds[i].attrs['r:id'] ?? slideIds[i].attrs.id
|
|
85
|
+
const target = relMap.get(rId) ?? `slides/slide${i + 1}.xml`
|
|
86
|
+
const part = target.startsWith('/') ? target.slice(1) : 'ppt/' + target.replace(/^\.\.\//, '')
|
|
87
|
+
const slideXml = get(part)
|
|
88
|
+
const relsXml = files.get(part.replace(/\/[^/]+$/, '/_rels/' + basename(part) + '.rels'))?.toString('utf8')
|
|
89
|
+
const slideRels = relsOf(relsXml)
|
|
90
|
+
const slide = first(parseXml(slideXml), 'sld')
|
|
91
|
+
const spTree = first(first(slide, 'cSld'), 'spTree')
|
|
92
|
+
const parsed = parseShapes(spTree, slideRels, mediaNames, { width, height }, { colorOf })
|
|
93
|
+
const slideBg = bgOfSlide(slide, slideRels, mediaNames)
|
|
94
|
+
// 背景优先级:原生 slide bg > 满页图 > 布局 bg > 无
|
|
95
|
+
const bg = slideBg ?? parsed.bg ?? layoutBg ?? null
|
|
96
|
+
// 讲稿(备注页):slide rels → notesSlide → 段落文本(2026-09-14 与导出的备注通道对称)
|
|
97
|
+
const notes = notesOfSlide(files, part, slideRels)
|
|
98
|
+
pages.push({ index: i, elements: parsed.elements, background: bg, notes })
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// 写项目
|
|
102
|
+
await mkdir(outDir, { recursive: true })
|
|
103
|
+
await mkdir(join(outDir, 'pages'), { recursive: true })
|
|
104
|
+
await mkdir(join(outDir, 'media'), { recursive: true })
|
|
105
|
+
const band = detectBands(pages, { width, height }) // D4:跨页页眉/页脚带探测(写入建议,不自动启用)
|
|
106
|
+
const stats = collectStyleStats(pages) // P0-1:样式聚合(theme 建议)
|
|
107
|
+
const pageRefs = []
|
|
108
|
+
for (const page of pages) {
|
|
109
|
+
const name = `slide_${String(page.index + 1).padStart(2, '0')}`
|
|
110
|
+
const ref = `pages/${name}.yaml`
|
|
111
|
+
pageRefs.push(ref)
|
|
112
|
+
const yaml = pageYaml(page, name)
|
|
113
|
+
await writeFile(join(outDir, ref), yaml)
|
|
114
|
+
}
|
|
115
|
+
const deckYaml = yamlDeck({ title: basename(pptxPath).replace(/\.pptx$/i, ''), width, height, pageRefs, band, stats })
|
|
116
|
+
// 真相层 v0.9.0/0.9.1:保留原始 pptx → source.pptx(零失真源)+ Office 真渲染整页 → reference/previews/
|
|
117
|
+
await copyFile(pptxPath, join(outDir, 'source.pptx')).catch(() => {})
|
|
118
|
+
let refPreviews = null
|
|
119
|
+
try {
|
|
120
|
+
const { findPowerPoint, renderPptxToPng } = await import('../msrender.js')
|
|
121
|
+
if (findPowerPoint()) {
|
|
122
|
+
const r = await renderPptxToPng(pptxPath, join(outDir, 'reference', 'previews'), { timeoutMs: 300000 })
|
|
123
|
+
if (r.pages) refPreviews = r.files.map((f) => 'reference/previews/' + basename(f))
|
|
124
|
+
}
|
|
125
|
+
} catch { /* 无 Office / 渲染失败:参考任务退化为骨架层 + source.pptx(可用 ppt_visual 补渲) */ }
|
|
126
|
+
// 参考双轨注入:referenceSource 无条件(source 总在;previews 视 Office 可用性)
|
|
127
|
+
const refBlock = `referenceSource:\n name: ${JSON.stringify(basename(pptxPath))}\n source: source.pptx\n${refPreviews?.length ? ` previews:\n${refPreviews.map((p) => ` - ${JSON.stringify(p)}`).join('\n')}\n` : ''}`
|
|
128
|
+
await writeFile(join(outDir, 'deck.yaml'), deckYaml + refBlock)
|
|
129
|
+
for (const name of mediaNames) {
|
|
130
|
+
const data = files.get('ppt/media/' + name)
|
|
131
|
+
if (data) await writeFile(join(outDir, 'media', name), data)
|
|
132
|
+
}
|
|
133
|
+
// A2 修复(反馈二):页面引用 vs media 目录一致性自检——rels 指向包外/特殊格式的资源
|
|
134
|
+
// 此前静默引用 media/placeholder.png(未提取)→ 导出时 ENOENT 硬失败且无指引;
|
|
135
|
+
// 现在:缺失清单可行动提示 + 自动生成 1×1 白色占位图(导出立即可用,人工替换后自然清除)。
|
|
136
|
+
const referenced = new Set()
|
|
137
|
+
for (const page of pages) {
|
|
138
|
+
if (page.background?.type === 'image' && typeof page.background.src === 'string') referenced.add(basename(page.background.src))
|
|
139
|
+
for (const el of page.elements) if (el.elementType === 'image' && typeof el.src === 'string') referenced.add(basename(el.src))
|
|
140
|
+
}
|
|
141
|
+
const missingMedia = [...referenced].filter((bn) => !existsSync(join(outDir, 'media', bn)))
|
|
142
|
+
if (missingMedia.length) {
|
|
143
|
+
const tinyPng = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64')
|
|
144
|
+
for (const bn of missingMedia) await writeFile(join(outDir, 'media', bn), tinyPng)
|
|
145
|
+
}
|
|
146
|
+
// P0-1:原始样式清单(改版求一致场景直接复用;渐变 stops/阴影/字体引用保留原值)
|
|
147
|
+
const stylesJson = {
|
|
148
|
+
source: basename(pptxPath),
|
|
149
|
+
size: { width, height },
|
|
150
|
+
generatedBy: 'dsh-ppt-studio',
|
|
151
|
+
note: '原始样式清单(改版求一致时直接复用;字段:fill/gradient/line/font/sz/color/bold/italic/align/shadow;页面元素按 page+id 定位)',
|
|
152
|
+
elements: pages.flatMap((p) => p.elements.map((el) => ({ page: p.index + 1, id: el.elementId, ...(el._styleRaw ?? {}) }))),
|
|
153
|
+
}
|
|
154
|
+
await writeFile(join(outDir, 'import-styles.json'), JSON.stringify(stylesJson, null, 2))
|
|
155
|
+
const bgCount = pages.filter((p) => p.background).length
|
|
156
|
+
const styleCount = Object.keys(stats.colors).length
|
|
157
|
+
return {
|
|
158
|
+
outDir, pages: pages.length, media: [...mediaNames], size: { width, height },
|
|
159
|
+
reference: refPreviews?.length ? { previews: refPreviews, source: 'source.pptx' } : null,
|
|
160
|
+
warnings: ['chart 降级为文本占位;未映射 prst 形状近似为矩形(import-styles.json 有记录)',
|
|
161
|
+
...(missingMedia.length ? [`⚠ 原稿 ${missingMedia.length} 个媒体引用未提取(${missingMedia.join('、')}——rels 指向包外/特殊格式):已在 media/ 生成 1×1 白色占位图,导出可用;建议人工替换或删除对应页面元素`] : []),
|
|
162
|
+
...(existsSync(join(outDir, 'source.pptx')) ? ['已保留原始 pptx ⊳ source.pptx(零失真真相层;参考双轨 v0.9.1)'] : []),
|
|
163
|
+
...(refPreviews?.length ? [`已生成整页真渲染 ⊳ reference/previews/(${refPreviews.length} 页,Office COM)——参考任务先看真身再动手`] : ['⚠ 未生成整页真渲染(无 Office 或渲染失败):可用 ppt_visual 对 source.pptx 补渲;骨架层仍可用']),
|
|
164
|
+
...(styleCount ? [`已保留样式:${styleCount} 个颜色 / 字体与字号已映射(import-styles.json 含渐变/阴影原始值)`] : []),
|
|
165
|
+
...(bgCount ? [`已提取 ${bgCount} 页背景(含背景图/色/满页图)`] : []),
|
|
166
|
+
...(band ? [`检测到跨页页眉/页脚带(上 ${band.top}px / 下 ${band.bottom}px):deck.yaml 已写入建议 safeArea(注释呈现,未启用;确认为模板后取消注释并微调)`] : [])],
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** P0-1:样式聚合统计(theme 建议):颜色频次 / 字号频次 / 字体频次。 */
|
|
171
|
+
function collectStyleStats(pages) {
|
|
172
|
+
const colors = {}
|
|
173
|
+
const fonts = {}
|
|
174
|
+
const sizes = {}
|
|
175
|
+
for (const p of pages) {
|
|
176
|
+
for (const el of p.elements) {
|
|
177
|
+
const raw = el._styleRaw ?? {}
|
|
178
|
+
for (const c of [raw.fill, raw.color, raw.line?.color].filter(Boolean)) colors[c] = (colors[c] ?? 0) + 1
|
|
179
|
+
if (raw.font) fonts[raw.font] = (fonts[raw.font] ?? 0) + 1
|
|
180
|
+
if (raw.sz) sizes[raw.sz] = (sizes[raw.sz] ?? 0) + 1
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return { colors, fonts, sizes }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* D4:跨页重复元素带探测 → safeArea 建议(只写注释,不自动启用)。
|
|
188
|
+
* 原理:页眉/页脚/logo 通常"跨页同位置重复";统计元素矩形签名出现页数,
|
|
189
|
+
* 重复率 ≥ max(2, 60% 页数) 且位于顶部 30% / 底部 30% 页高 → 形成带。
|
|
190
|
+
*/
|
|
191
|
+
function detectBands(pages, size, minPages = 3) {
|
|
192
|
+
if (pages.length < minPages) return null
|
|
193
|
+
const sigOf = (el) => {
|
|
194
|
+
const b = el.bounds
|
|
195
|
+
return `${el.elementType}:${el.kind ?? ''}:${Math.round(b.x)},${Math.round(b.y)},${Math.round(b.w)},${Math.round(b.h)}`
|
|
196
|
+
}
|
|
197
|
+
const counts = new Map()
|
|
198
|
+
for (const p of pages) {
|
|
199
|
+
const seen = new Set()
|
|
200
|
+
for (const el of p.elements) {
|
|
201
|
+
const s = sigOf(el)
|
|
202
|
+
if (seen.has(s)) continue
|
|
203
|
+
seen.add(s)
|
|
204
|
+
counts.set(s, (counts.get(s) ?? 0) + 1)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
const need = Math.max(2, Math.ceil(pages.length * 0.6))
|
|
208
|
+
const tops = []
|
|
209
|
+
const bottoms = []
|
|
210
|
+
for (const p of pages) {
|
|
211
|
+
for (const el of p.elements) {
|
|
212
|
+
if ((counts.get(sigOf(el)) ?? 0) < need) continue
|
|
213
|
+
const b = el.bounds
|
|
214
|
+
if (b.y + b.h <= size.height * 0.3) tops.push(b.y + b.h)
|
|
215
|
+
else if (b.y >= size.height * 0.7) bottoms.push(b.y)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const hint = {}
|
|
219
|
+
if (tops.length) hint.top = Math.max(...tops)
|
|
220
|
+
if (bottoms.length) hint.bottom = size.height - Math.min(...bottoms)
|
|
221
|
+
if (!Object.keys(hint).length) return null
|
|
222
|
+
return hint
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* 保真解析(v0.7.1 升级):递归遍历 spTree——
|
|
227
|
+
* - grpSp 组合:子元素按 group xfrm(off/ext/chOff/chExt)坐标换算展开(嵌套组递归);
|
|
228
|
+
* - cxnSp 连接线:解析为 line 元素(flipH/flipV 决定方向,tailEnd → arrow);
|
|
229
|
+
* - sp/pic/graphicFrame:原有解析(经坐标变换链落到页面系)。
|
|
230
|
+
*/
|
|
231
|
+
function parseShapes(spTree, slideRels, mediaNames, pageSize, styleCtx) {
|
|
232
|
+
const out = []
|
|
233
|
+
let bg = null
|
|
234
|
+
const st = { ...styleCtx, slideRels, mediaNames, pageSize, out, bgRef: (b) => { bg = b } }
|
|
235
|
+
const identity = (x, y, w, h) => ({ x, y, w, h })
|
|
236
|
+
for (const node of spTree?.children ?? []) walkNode(node, st, identity)
|
|
237
|
+
// elementId 去重(原稿常见重复名如 "_文本框";重复名追加 -2/-3...)
|
|
238
|
+
const seen = new Map()
|
|
239
|
+
for (const el of out) {
|
|
240
|
+
const base = el.elementId
|
|
241
|
+
const n = seen.get(base) ?? 0
|
|
242
|
+
seen.set(base, n + 1)
|
|
243
|
+
if (n > 0) el.elementId = `${base}-${n + 1}`
|
|
244
|
+
}
|
|
245
|
+
return { elements: out, bg }
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** 递归子节点(toPage: 局部坐标 → 页面坐标的换算函数链)。 */
|
|
249
|
+
function walkNode(node, st, toPage) {
|
|
250
|
+
if (node.tag === 'grpSp') {
|
|
251
|
+
const g = groupXfrmOf(node)
|
|
252
|
+
const kx = g.ext.w / Math.max(1e-6, g.chExt.w)
|
|
253
|
+
const ky = g.ext.h / Math.max(1e-6, g.chExt.h)
|
|
254
|
+
const childOfPage = (x, y, w, h) => toPage(g.off.x + (x - g.chOff.x) * kx, g.off.y + (y - g.chOff.y) * ky, w * kx, h * ky)
|
|
255
|
+
for (const child of node.children ?? []) walkNode(child, st, childOfPage)
|
|
256
|
+
return
|
|
257
|
+
}
|
|
258
|
+
const push = (el) => {
|
|
259
|
+
if (!el) return
|
|
260
|
+
// v0.11 候选 C:形状内文本(_shapeText = 附加 text 元素,与形状同 bounds,居中)
|
|
261
|
+
if (el._shapeText) {
|
|
262
|
+
const extra = el._shapeText
|
|
263
|
+
delete el._shapeText
|
|
264
|
+
const b = toPage(Number(extra.bounds.x), Number(extra.bounds.y), Number(extra.bounds.w), Number(extra.bounds.h))
|
|
265
|
+
extra.bounds = safeBounds(b)
|
|
266
|
+
st.out.push(extra)
|
|
267
|
+
}
|
|
268
|
+
if (el.bounds) {
|
|
269
|
+
const b = toPage(Number(el.bounds.x), Number(el.bounds.y), Number(el.bounds.w), Number(el.bounds.h))
|
|
270
|
+
el.bounds = safeBounds(b)
|
|
271
|
+
}
|
|
272
|
+
st.out.push(el)
|
|
273
|
+
}
|
|
274
|
+
if (node.tag === 'sp') push(spToEl(node, st))
|
|
275
|
+
else if (node.tag === 'cxnSp') push(cxnToEl(node, st))
|
|
276
|
+
else if (node.tag === 'pic') {
|
|
277
|
+
const el = picToEl(node, st.slideRels, st.mediaNames)
|
|
278
|
+
if (el && isFullPagePic(el.bounds, st.pageSize)) st.bgRef({ type: 'image', src: el.src, fit: 'cover' })
|
|
279
|
+
else push(el)
|
|
280
|
+
} else if (node.tag === 'graphicFrame') {
|
|
281
|
+
const graphicData = first(node, 'graphic') && first(first(node, 'graphic'), 'graphicData')
|
|
282
|
+
if (!graphicData) return
|
|
283
|
+
const uri = graphicData.attrs.uri ?? ''
|
|
284
|
+
if (uri.includes('/table')) {
|
|
285
|
+
const tbl = first(graphicData, 'tbl')
|
|
286
|
+
if (tbl) push(tableToEl(node, tbl))
|
|
287
|
+
} else {
|
|
288
|
+
push(chartToEl(node))
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** 组合 xfrm:off/ext(父系位置)+ chOff/chExt(子坐标系)。 */
|
|
294
|
+
function groupXfrmOf(node) {
|
|
295
|
+
const spPr = first(node, 'spPr')
|
|
296
|
+
const x = spPr ? first(spPr, 'xfrm') : undefined
|
|
297
|
+
const off = x ? first(x, 'off') : undefined
|
|
298
|
+
const ext = x ? first(x, 'ext') : undefined
|
|
299
|
+
const chOff = x ? first(x, 'chOff') : undefined
|
|
300
|
+
const chExt = x ? first(x, 'chExt') : undefined
|
|
301
|
+
if (!off || !ext) return { off: { x: 0, y: 0 }, ext: { w: 100, h: 100 }, chOff: { x: 0, y: 0 }, chExt: { w: 100, h: 100 } }
|
|
302
|
+
return {
|
|
303
|
+
off: { x: Number(off.attrs.x ?? 0), y: Number(off.attrs.y ?? 0) },
|
|
304
|
+
ext: { w: Math.max(1, Number(ext.attrs.cx ?? 0)), h: Math.max(1, Number(ext.attrs.cy ?? 0)) },
|
|
305
|
+
chOff: { x: Number(chOff?.attrs?.x ?? 0), y: Number(chOff?.attrs?.y ?? 0) },
|
|
306
|
+
chExt: { w: Math.max(1, Number(chExt?.attrs?.cx ?? 0)), h: Math.max(1, Number(chExt?.attrs?.cy ?? 0)) },
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** 连接线(cxnSp)→ line 元素;flipH/flipV 决定端点;tailEnd → 箭头。 */
|
|
311
|
+
function cxnToEl(node, st) {
|
|
312
|
+
const nv = first(node, 'nvCxnSpPr') ?? node
|
|
313
|
+
const cNvPr = first(nv, 'cNvPr')
|
|
314
|
+
const id = cNvPr?.attrs?.name ?? cNvPr?.attrs?.id ?? 'conn'
|
|
315
|
+
const b = xfrmOf(node) ?? { x: 0, y: 0, w: 100, h: 10 }
|
|
316
|
+
const spPr = first(node, 'spPr')
|
|
317
|
+
const xf = spPr ? first(spPr, 'xfrm') : undefined
|
|
318
|
+
const flipH = xf?.attrs?.flipH === '1'
|
|
319
|
+
const flipV = xf?.attrs?.flipV === '1'
|
|
320
|
+
const x1 = flipH ? b.x + b.w : b.x
|
|
321
|
+
const x2 = flipH ? b.x : b.x + b.w
|
|
322
|
+
const y1 = flipV ? b.y + b.h : b.y
|
|
323
|
+
const y2 = flipV ? b.y : b.y + b.h
|
|
324
|
+
const ln = lineOf(node, st.colorOf)
|
|
325
|
+
const lnNode = spPr ? first(spPr, 'ln') : undefined
|
|
326
|
+
const arrow = !!(lnNode && first(lnNode, 'tailEnd'))
|
|
327
|
+
return {
|
|
328
|
+
elementId: sanitize(id), elementType: 'line', bounds: safeBounds(b),
|
|
329
|
+
points: [[x1, y1], [x2, y2]],
|
|
330
|
+
...(ln && Object.keys(ln).length ? { line: ln } : {}),
|
|
331
|
+
...(arrow ? { arrow: true } : {}),
|
|
332
|
+
_styleRaw: { line: ln },
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function xfrmOf(node) {
|
|
337
|
+
const spPr = first(node, 'spPr')
|
|
338
|
+
let x = spPr ? first(spPr, 'xfrm') : first(node, 'xfrm')
|
|
339
|
+
if (x && !first(x, 'off')) x = first(x, 'xfrm') || x // 兼容嵌套 a:xfrm
|
|
340
|
+
const off = x ? first(x, 'off') : undefined
|
|
341
|
+
const ext = x ? first(x, 'ext') : undefined
|
|
342
|
+
if (!off || !ext) return null
|
|
343
|
+
return { x: px(Number(off.attrs.x ?? 0)), y: px(Number(off.attrs.y ?? 0)), w: Math.max(1, px(Number(ext.attrs.cx ?? 0))), h: Math.max(1, px(Number(ext.attrs.cy ?? 0))) }
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** 防御性 bounds(占位符/异常几何兜底为默认占位,避免 undefined/NaN 进入校验)。输入数组/对象均可,输出对象 {x,y,w,h}(内部契约)。 */
|
|
347
|
+
function safeBounds(b) {
|
|
348
|
+
const obj = Array.isArray(b) ? { x: b[0], y: b[1], w: b[2], h: b[3] } : (b && typeof b === 'object' ? b : null)
|
|
349
|
+
const x = Number(obj?.x)
|
|
350
|
+
const y = Number(obj?.y)
|
|
351
|
+
const w = Number(obj?.w)
|
|
352
|
+
const h = Number(obj?.h)
|
|
353
|
+
if ([x, y, w, h].every((n) => Number.isFinite(n)) && w > 0 && h > 0) return { x, y, w, h }
|
|
354
|
+
return { x: 0, y: 0, w: 200, h: 50 }
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function solidColor(node) {
|
|
358
|
+
let fill = first(node, 'solidFill')
|
|
359
|
+
if (!fill) {
|
|
360
|
+
const ln = first(node, 'ln')
|
|
361
|
+
if (ln) fill = first(ln, 'solidFill')
|
|
362
|
+
}
|
|
363
|
+
const s = fill ? first(fill, 'srgbClr') : undefined
|
|
364
|
+
return s ? '#' + String(s.attrs.val).toUpperCase() : undefined
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** P0-1:填充样式:solidFill(srgb/scheme + alpha,v0.11 候选 C)或 gradFill(v0.9.1 双 stop + 逐 stop alpha)。 */
|
|
368
|
+
function fillOf(node, colorOf) {
|
|
369
|
+
const spPr = first(node, 'spPr')
|
|
370
|
+
const solid = spPr ? first(spPr, 'solidFill') : undefined
|
|
371
|
+
if (solid) {
|
|
372
|
+
const sc = first(solid, 'srgbClr') ?? first(solid, 'schemeClr')
|
|
373
|
+
const alpha = alphaOf(sc)
|
|
374
|
+
const color = colorOf(solid)
|
|
375
|
+
return color ? (alpha !== undefined ? { color, alpha } : { color }) : {}
|
|
376
|
+
}
|
|
377
|
+
const grad = spPr ? first(spPr, 'gradFill') : undefined
|
|
378
|
+
if (grad) {
|
|
379
|
+
const gsLst = first(grad, 'gsLst') ?? grad
|
|
380
|
+
const stops = []
|
|
381
|
+
for (const gs of children(gsLst, 'gs')) {
|
|
382
|
+
const c = colorOf(gs)
|
|
383
|
+
const pos = gs.attrs?.pos !== undefined ? Number(gs.attrs.pos) / 1000 : undefined // OOXML pos 0-100000(0-100%)
|
|
384
|
+
if (c) stops.push({ ...(pos !== undefined && Number.isFinite(pos) ? { pos } : {}), color: c, ...(alphaOf(gs) !== undefined ? { alpha: alphaOf(gs) } : {}) })
|
|
385
|
+
}
|
|
386
|
+
if (stops.length) {
|
|
387
|
+
const lin = first(grad, 'lin')
|
|
388
|
+
const angle = lin?.attrs?.ang !== undefined ? Math.round(Number(lin.attrs.ang) / 60000) % 360 : undefined
|
|
389
|
+
const kind = grad.children?.some?.((c) => c.tag === 'path') ? 'path' : grad.children?.some?.((c) => c.tag === 'radialGradient') ? 'radial' : undefined
|
|
390
|
+
const normalized = stops.map((s, i) => ({ pos: s.pos ?? (i / (stops.length - 1)) * 100, color: s.color, ...(s.alpha !== undefined ? { alpha: s.alpha } : {}) }))
|
|
391
|
+
// 兼容:color = 末 stop(实色端);gradient = 完整对象(闭环渲染/导出用)
|
|
392
|
+
return { color: stops[stops.length - 1].color, gradient: { type: kind ?? 'linear', stops: normalized, ...(angle !== undefined ? { angle } : {}) } }
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
return {}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** 颜色节点(srgbClr/schemeClr)的 alpha(0-100,OOXML val/1000)。 */
|
|
399
|
+
function alphaOf(colorNode) {
|
|
400
|
+
const a = colorNode ? first(colorNode, 'alpha') : undefined
|
|
401
|
+
return a?.attrs?.val !== undefined ? Number(a.attrs.val) / 1000 : undefined
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** custGeom pathLst → PPTD path({w, h, commands},路径坐标为抽象单位——原样保留,不除以 EMU)。 */
|
|
405
|
+
function custPathOf(custGeom) {
|
|
406
|
+
const pathLst = first(custGeom, 'pathLst')
|
|
407
|
+
const path = pathLst ? first(pathLst, 'path') : undefined
|
|
408
|
+
if (!path) return null
|
|
409
|
+
const w = path.attrs?.w !== undefined && Number.isFinite(Number(path.attrs.w)) ? Number(path.attrs.w) : undefined
|
|
410
|
+
const h = path.attrs?.h !== undefined && Number.isFinite(Number(path.attrs.h)) ? Number(path.attrs.h) : undefined
|
|
411
|
+
const commands = []
|
|
412
|
+
for (const c of path.children ?? []) {
|
|
413
|
+
const pts = (c.tag === 'moveTo' || c.tag === 'lnTo' || c.tag === 'quadBezTo' || c.tag === 'cubicBezTo')
|
|
414
|
+
? children(c, 'pt').map((pt) => [Number(pt.attrs?.x ?? 0), Number(pt.attrs?.y ?? 0)])
|
|
415
|
+
: []
|
|
416
|
+
if (c.tag === 'moveTo' || c.tag === 'lnTo' || c.tag === 'quadBezTo' || c.tag === 'cubicBezTo') {
|
|
417
|
+
if (pts.length === ({ moveTo: 1, lnTo: 1, quadBezTo: 2, cubicBezTo: 3 })[c.tag]) commands.push({ cmd: c.tag, pts })
|
|
418
|
+
} else if (c.tag === 'arcTo') {
|
|
419
|
+
commands.push({ cmd: 'arcTo', wR: Number(c.attrs?.wR ?? 0), hR: Number(c.attrs?.hR ?? 0), stAng: Number(c.attrs?.stAng ?? 0), swAng: Number(c.attrs?.swAng ?? 0) })
|
|
420
|
+
} else if (c.tag === 'close') {
|
|
421
|
+
commands.push({ cmd: 'close' })
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
if (!commands.length) return null
|
|
425
|
+
return { ...(w !== undefined ? { w } : {}), ...(h !== undefined ? { h } : {}), commands }
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** 文本内容提取(isText 与形状内文本共用):rPr 样式 → {content, raw}。 */
|
|
429
|
+
function textContentOf(tx, text, colorOf) {
|
|
430
|
+
const p0 = tx ? first(tx, 'p') : undefined
|
|
431
|
+
const pPr = p0 ? first(p0, 'pPr') : undefined
|
|
432
|
+
const r = p0 ? first(p0, 'r') : undefined
|
|
433
|
+
const rPr = r ? first(r, 'rPr') : undefined
|
|
434
|
+
const szAttr = rPr?.attrs?.sz ? Number(rPr.attrs.sz) / 100 : 18
|
|
435
|
+
const color = rPr ? colorOf(rPr) : undefined
|
|
436
|
+
const font = rPr?.children?.find((c) => c.tag === 'latin' || c.tag === 'ea')?.attrs?.typeface
|
|
437
|
+
const align = pPr?.attrs?.algn ? { left: 'left', ctr: 'center', r: 'right' }[pPr.attrs.algn] : undefined
|
|
438
|
+
const lnSpc = pPr ? first(pPr, 'lnSpc') : undefined
|
|
439
|
+
const spcPct = lnSpc ? first(lnSpc, 'spcPct') : undefined
|
|
440
|
+
const lineHeight = spcPct?.attrs?.val ? Math.round(Number(spcPct.attrs.val) / 100000 * 100) / 100 : undefined
|
|
441
|
+
const bold = rPr?.attrs?.b === '1'
|
|
442
|
+
const italic = rPr?.attrs?.i === '1'
|
|
443
|
+
return {
|
|
444
|
+
content: {
|
|
445
|
+
text,
|
|
446
|
+
...(szAttr !== 18 ? { fontSize: szAttr } : {}),
|
|
447
|
+
...(bold ? { bold: true } : {}),
|
|
448
|
+
...(italic ? { italic: true } : {}),
|
|
449
|
+
...(color ? { color } : {}),
|
|
450
|
+
...(font ? { fontFamily: font } : {}),
|
|
451
|
+
...(align ? { align } : {}),
|
|
452
|
+
...(lineHeight && Math.abs(lineHeight - 1.2) > 0.05 ? { lineHeight } : {}),
|
|
453
|
+
},
|
|
454
|
+
raw: { sz: szAttr, font, color, bold, italic, align, lineHeight },
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** P0-1:描边样式 a:ln → {color, width(px)}。 */
|
|
459
|
+
function lineOf(node, colorOf) {
|
|
460
|
+
const spPr = first(node, 'spPr')
|
|
461
|
+
const ln = spPr ? first(spPr, 'ln') : undefined
|
|
462
|
+
if (!ln) return undefined
|
|
463
|
+
const w = Number(ln.attrs?.w ?? 0)
|
|
464
|
+
const c = colorOf(ln)
|
|
465
|
+
return {
|
|
466
|
+
...(c ? { color: c } : {}),
|
|
467
|
+
...(w > 0 ? { width: Math.max(1, Math.round(w / EMU)) } : {}),
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function spToEl(node, styleCtx) {
|
|
472
|
+
const { colorOf } = styleCtx
|
|
473
|
+
const nv = first(node, 'nvSpPr') ?? node
|
|
474
|
+
const cNvPr = first(nv, 'cNvPr')
|
|
475
|
+
const id = cNvPr?.attrs?.name ?? cNvPr?.attrs?.id ?? 'sp'
|
|
476
|
+
const bounds = xfrmOf(node)
|
|
477
|
+
const tx = first(node, 'txBody')
|
|
478
|
+
const spPr = first(node, 'spPr')
|
|
479
|
+
const prstGeom = spPr ? first(spPr, 'prstGeom') : undefined
|
|
480
|
+
const prst = prstGeom?.attrs?.prst
|
|
481
|
+
const custGeom = spPr ? first(spPr, 'custGeom') : undefined
|
|
482
|
+
const text = tx ? allText(tx).replace(/[\t ]+/g, ' ').trim().split(/\n\s*\n|(?<=。)\s*/) : []
|
|
483
|
+
const textNonEmpty = text.join(' ').trim().length > 0
|
|
484
|
+
// v0.11:custGeom 形状即使带文本也是形状(文本走 _shapeText 提取)——避免"弧形内文字"把形状整个变文本
|
|
485
|
+
const isText = textNonEmpty && !custGeom && (!prst || (prst === 'rect' && !!tx))
|
|
486
|
+
// P0-1:原始样式(import-styles.json 用)
|
|
487
|
+
const raw = {}
|
|
488
|
+
const fill = fillOf(node, colorOf)
|
|
489
|
+
if (fill.color) raw.fill = fill.color
|
|
490
|
+
if (fill.gradient) raw.gradient = fill.gradient
|
|
491
|
+
const ln = lineOf(node, colorOf)
|
|
492
|
+
if (ln) raw.line = ln
|
|
493
|
+
const spPrFx = spPr ? first(spPr, 'effectLst') || first(spPr, 'effectDag') : undefined
|
|
494
|
+
if (spPrFx) raw.shadow = true
|
|
495
|
+
if (isText) {
|
|
496
|
+
// 文本元素(P0-1:字体/斜体/对齐/行高一并提取)
|
|
497
|
+
const tc = textContentOf(tx, text.join(' '), colorOf)
|
|
498
|
+
Object.assign(raw, tc.raw)
|
|
499
|
+
return { elementId: sanitize(id), elementType: 'text', bounds: safeBounds(bounds ?? {x: 0, y: 0, w: 200, h: 50}), content: tc.content, _styleRaw: raw }
|
|
500
|
+
}
|
|
501
|
+
// v0.11 候选 C:kind 判定——custGeom 优先,然后 prst 白名单,然后 rect
|
|
502
|
+
let kind = 'rect'
|
|
503
|
+
let path = null
|
|
504
|
+
if (custGeom) {
|
|
505
|
+
path = custPathOf(custGeom)
|
|
506
|
+
if (path) kind = 'custGeom'
|
|
507
|
+
}
|
|
508
|
+
if (kind === 'rect' && PRST_MAP.has(prst)) kind = prst
|
|
509
|
+
const out = {
|
|
510
|
+
elementId: sanitize(id), elementType: 'shape', kind,
|
|
511
|
+
bounds: safeBounds(bounds ?? {x: 0, y: 0, w: 100, h: 100}),
|
|
512
|
+
}
|
|
513
|
+
if (path) out.path = path
|
|
514
|
+
if (fill.gradient) {
|
|
515
|
+
// v0.9.1:渐变闭环——fill 直通对象(render/export 双端支持);color 兜底 = 末 stop(旧兼容)
|
|
516
|
+
out.fill = { type: 'gradient', stops: fill.gradient.stops, ...(fill.gradient.angle !== undefined ? { angle: fill.gradient.angle } : {}) }
|
|
517
|
+
out._styleRaw = { ...raw, gradient: fill.gradient, prst: prst !== kind ? prst : undefined }
|
|
518
|
+
} else if (fill.color) {
|
|
519
|
+
out.fill = fill.alpha !== undefined ? { color: fill.color, alpha: fill.alpha } : fill.color
|
|
520
|
+
if (prst && prst !== 'rect' && !PRST_MAP.has(prst)) raw.prst = prst // 未映射 prst 记录(兜底 rect)
|
|
521
|
+
if (Object.keys(raw).length) out._styleRaw = raw
|
|
522
|
+
} else {
|
|
523
|
+
if (prst && prst !== 'rect' && !PRST_MAP.has(prst)) raw.prst = prst
|
|
524
|
+
if (Object.keys(raw).length) out._styleRaw = raw
|
|
525
|
+
}
|
|
526
|
+
if (ln) out.line = ln
|
|
527
|
+
// v0.11 候选 C:非文本框形状内文本(椭圆/自定义几何/序号等)→ 提取为独立文本元素(形状居中释放原文本)
|
|
528
|
+
if (textNonEmpty && kind !== 'rect' && tx) {
|
|
529
|
+
const tc = textContentOf(tx, text.join(' '), colorOf)
|
|
530
|
+
const b = safeBounds(bounds ?? {x: 0, y: 0, w: 100, h: 100})
|
|
531
|
+
const extra = {
|
|
532
|
+
elementId: sanitize(`${id}_txt`), elementType: 'text',
|
|
533
|
+
bounds: { x: b.x, y: b.y, w: b.w, h: b.h },
|
|
534
|
+
content: { ...tc.content, align: tc.content.align ?? 'center' },
|
|
535
|
+
_styleRaw: { ...tc.raw, shapeText: true },
|
|
536
|
+
}
|
|
537
|
+
out._shapeText = extra // 主流程合并(去重)
|
|
538
|
+
}
|
|
539
|
+
return out
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function picToEl(node, slideRels, mediaNames) {
|
|
543
|
+
const nv = first(node, 'nvPicPr') ?? node
|
|
544
|
+
const cNvPr = first(nv, 'cNvPr')
|
|
545
|
+
const id = cNvPr?.attrs?.name ?? cNvPr?.attrs?.id ?? 'pic'
|
|
546
|
+
const blipFill = first(node, 'blipFill')
|
|
547
|
+
const blip = blipFill ? first(blipFill, 'blip') : undefined
|
|
548
|
+
const rId = blip?.attrs?.['r:embed'] ?? blip?.attrs?.embed
|
|
549
|
+
let src = 'media/placeholder.png'
|
|
550
|
+
if (rId && slideRels) {
|
|
551
|
+
const t = slideRels.get(rId)
|
|
552
|
+
if (t) {
|
|
553
|
+
const bn = basename(t)
|
|
554
|
+
mediaNames.add(bn)
|
|
555
|
+
src = 'media/' + bn
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
return { elementId: sanitize(id), elementType: 'image', bounds: safeBounds(xfrmOf(node) ?? {x: 0, y: 0, w: 200, h: 150}), src, fit: 'cover' }
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function tableToEl(node, tbl) {
|
|
562
|
+
const nv = first(node, 'nvGraphicFramePr') ?? node
|
|
563
|
+
const cNvPr = first(nv, 'cNvPr')
|
|
564
|
+
const id = cNvPr?.attrs?.name ?? cNvPr?.attrs?.id ?? 'table'
|
|
565
|
+
const grid = first(tbl, 'tblGrid')
|
|
566
|
+
const cols = (grid?.children ?? []).map((g, i) => `Col${i + 1}`)
|
|
567
|
+
const rows = []
|
|
568
|
+
for (const tr of children(tbl, 'tr')) {
|
|
569
|
+
const row = []
|
|
570
|
+
for (const tc of children(tr, 'tc')) {
|
|
571
|
+
row.push(allText(tc).trim())
|
|
572
|
+
}
|
|
573
|
+
rows.push(row)
|
|
574
|
+
}
|
|
575
|
+
return { elementId: sanitize(id), elementType: 'table', bounds: safeBounds(xfrmOf(node) ?? {x: 0, y: 0, w: 400, h: 200}), cols, rows }
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function chartToEl(node) {
|
|
579
|
+
const nv = first(node, 'nvGraphicFramePr') ?? node
|
|
580
|
+
const cNvPr = first(nv, 'cNvPr')
|
|
581
|
+
const id = cNvPr?.attrs?.name ?? cNvPr?.attrs?.id ?? 'chart'
|
|
582
|
+
return {
|
|
583
|
+
elementId: sanitize(id), elementType: 'text',
|
|
584
|
+
bounds: safeBounds(xfrmOf(node) ?? {x: 0, y: 0, w: 400, h: 220}),
|
|
585
|
+
content: { text: `[chart: ${id}] 图表已降级为文本占位,请重新生成图表样式` },
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function sanitize(id) {
|
|
590
|
+
return String(id).replace(/[^\w\u4e00-\u9fff-]+/g, '_').slice(0, 40) || 'el'
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/** 从 <p:cSld><p:bg> 解析背景(背景色/背景图;图经 rels 提取到 media)。node:sld/sldLayout 根或 #root。 */
|
|
594
|
+
function bgOfSlide(node, rels, mediaNames) {
|
|
595
|
+
const sld = first(node, 'sld') ?? first(node, 'sldLayout') ?? node
|
|
596
|
+
const bg = first(first(sld, 'cSld'), 'bg')
|
|
597
|
+
if (!bg) return null
|
|
598
|
+
const bgPr = first(bg, 'bgPr')
|
|
599
|
+
if (!bgPr) return null
|
|
600
|
+
const solidFill = first(bgPr, 'solidFill')
|
|
601
|
+
if (solidFill) {
|
|
602
|
+
const c = first(solidFill, 'srgbClr')
|
|
603
|
+
if (c) return { type: 'solid', color: '#' + c.attrs.val }
|
|
604
|
+
const scheme = first(solidFill, 'schemeClr')
|
|
605
|
+
if (scheme) return { type: 'solid', color: '#' + ({ lt1: 'FFFFFF', dk1: '000000', tx1: '000000', bg1: 'FFFFFF' }[scheme.attrs.val] ?? 'FFFFFF') }
|
|
606
|
+
}
|
|
607
|
+
const blipFill = first(bgPr, 'blipFill')
|
|
608
|
+
const blip = blipFill ? first(blipFill, 'blip') : undefined
|
|
609
|
+
const rId = blip?.attrs?.['r:embed'] ?? blip?.attrs?.embed
|
|
610
|
+
if (rId && rels) {
|
|
611
|
+
const t = rels.get(rId)
|
|
612
|
+
if (t) {
|
|
613
|
+
const bn = basename(t)
|
|
614
|
+
mediaNames.add(bn)
|
|
615
|
+
return { type: 'image', src: 'media/' + bn, fit: 'cover' }
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
return null
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/** 满页图(≥95% 页面)识别为页面背景,从 elements 移除。 */
|
|
622
|
+
function isFullPagePic(bounds, size) {
|
|
623
|
+
return bounds && bounds.w >= size.width * 0.95 && bounds.h >= size.height * 0.95
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function yamlDeck({ title, width, height, pageRefs, band, stats }) {
|
|
627
|
+
const pages = pageRefs.map((r) => ` - ${r}`).join('\n')
|
|
628
|
+
const bandNote = band
|
|
629
|
+
? `# 检测到跨页页眉/页脚带(模板特征):建议安全区如下(注释呈现,未启用;确认为模板后取消注释并微调)\n# safeArea: {${['top', 'bottom'].filter((k) => band[k] !== undefined).map((k) => `${k}: ${band[k]}`).join(', ')}}\n`
|
|
630
|
+
: ''
|
|
631
|
+
// P0-1:样式聚合 → theme 建议块(聚合自原稿;页面本身已带内联样式,此块供"改版求一致"直接复用)
|
|
632
|
+
const themeNote = stats ? themePresetOf(stats) : null
|
|
633
|
+
return `# 由 dsh-ppt-studio 从 pptx 导入(内容保真,版式可从新)
|
|
634
|
+
${bandNote}${themeNote ? themeNote + '\n' : ''}version: 1
|
|
635
|
+
title: ${JSON.stringify(title)}
|
|
636
|
+
size: [${width}, ${height}]
|
|
637
|
+
pages:
|
|
638
|
+
${pages}
|
|
639
|
+
`
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/** P0-1:样式聚合 → theme 建议(colors 高频 top7 + 全量扩展色板 + textStyles)。 */
|
|
643
|
+
/** A7 修复(反馈二 ★):全量色板并入(含低频但原稿真实使用的颜色)——否则审阅
|
|
644
|
+
* theme-conformance 把原稿色全报"不在主题色板",作者被迫手工补色。 */
|
|
645
|
+
function themePresetOf(stats) {
|
|
646
|
+
const colorRank = Object.entries(stats.colors).sort((a, b) => b[1] - a[1])
|
|
647
|
+
if (!colorRank.length) return null
|
|
648
|
+
const ink = colorRank[0][0]
|
|
649
|
+
const prim = colorRank.slice(0, 7).map(([c], i) => ` c${i + 1}: "${c}"${i === 0 ? ' # 最高频(建议作 ink/主色)' : ''}`).join('\n')
|
|
650
|
+
const ext = colorRank.slice(7).map(([c], i) => ` c${i + 8}: "${c}" # 原稿色板扩展(低频但审阅不再误报)`).join('\n')
|
|
651
|
+
const colors = prim + (ext ? '\n' + ext : '')
|
|
652
|
+
const szRank = Object.entries(stats.sizes).sort((a, b) => b[1] - a[1])
|
|
653
|
+
const fontRank = Object.entries(stats.fonts).sort((a, b) => b[1] - a[1])[0]?.[0]
|
|
654
|
+
const maxSz = szRank.length ? Math.max(...szRank.map(([s]) => Number(s)), 12) : 18
|
|
655
|
+
const minSz = szRank.length ? Math.min(...szRank.map(([s]) => Number(s)), 12) : 12
|
|
656
|
+
const bodySz = szRank[0]?.[0] ?? 14
|
|
657
|
+
return [
|
|
658
|
+
`# 建议主题(P0-1 聚合自原稿样式,可调;页面字段已内联,无需引用也可渲染)`,
|
|
659
|
+
'# 色板为原稿全量(c1-c7 高频 + c8 起扩展低频色;theme-conformance 以全集为基准,不再误报原稿色)',
|
|
660
|
+
'theme:',
|
|
661
|
+
' colors:',
|
|
662
|
+
colors,
|
|
663
|
+
' textStyles:',
|
|
664
|
+
` title: {fontSize: ${maxSz}, color: "${ink}", bold: true}${fontRank ? ` # 字体 ${fontRank}` : ''}`,
|
|
665
|
+
` body: {fontSize: ${bodySz}, color: "${ink}"}`,
|
|
666
|
+
` label: {fontSize: ${minSz}, color: "${ink}"}`,
|
|
667
|
+
].join('\n')
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function pageYaml(page, name) {
|
|
671
|
+
const els = page.elements.map((el, i) => {
|
|
672
|
+
const b = el.bounds
|
|
673
|
+
const raw = el._styleRaw ?? {}
|
|
674
|
+
const lines = []
|
|
675
|
+
lines.push(` - elementId: ${JSON.stringify(el.elementId ?? `el${i + 1}`)}`)
|
|
676
|
+
lines.push(` elementType: ${el.elementType}`)
|
|
677
|
+
lines.push(` bounds: [${b.x}, ${b.y}, ${b.w}, ${b.h}]`)
|
|
678
|
+
if (el.kind) lines.push(` kind: ${el.kind}`)
|
|
679
|
+
if (el.elementType === 'line') {
|
|
680
|
+
const pts = el.points ?? [[el.x1, el.y1], [el.x2, el.y2]]
|
|
681
|
+
lines.push(` points: ${JSON.stringify(pts)}`)
|
|
682
|
+
if (el.arrow) lines.push(` arrow: true`)
|
|
683
|
+
// line 颜色/宽度由下方通用 el.line 行输出
|
|
684
|
+
}
|
|
685
|
+
if (el.fit) lines.push(` fit: ${el.fit}`)
|
|
686
|
+
if (el.src) lines.push(` src: ${JSON.stringify(el.src)}`)
|
|
687
|
+
// P0-1:样式保留;v0.9.1 渐变闭环(fill 对象直通,不再单一归主色);v0.11 alpha 直通
|
|
688
|
+
if (el.fill && typeof el.fill === 'object' && el.fill.type === 'gradient') {
|
|
689
|
+
lines.push(' fill:')
|
|
690
|
+
lines.push(' type: gradient')
|
|
691
|
+
lines.push(' stops:')
|
|
692
|
+
for (const s of el.fill.stops) lines.push(` - {pos: ${s.pos}, color: "${s.color}"${s.alpha !== undefined ? `, alpha: ${s.alpha}` : ''}}`)
|
|
693
|
+
if (el.fill.angle !== undefined) lines.push(` angle: ${el.fill.angle} # OOXML lin@ang 顺时针`)
|
|
694
|
+
} else if (el.fill) {
|
|
695
|
+
lines.push(` fill: ${JSON.stringify(el.fill)}`)
|
|
696
|
+
}
|
|
697
|
+
// v0.11 候选 C:custGeom 路径(commands 多行)
|
|
698
|
+
if (el.path) {
|
|
699
|
+
lines.push(' path:')
|
|
700
|
+
if (el.path.w !== undefined) lines.push(` w: ${el.path.w}`)
|
|
701
|
+
if (el.path.h !== undefined) lines.push(` h: ${el.path.h}`)
|
|
702
|
+
lines.push(' commands:')
|
|
703
|
+
for (const c of el.path.commands) {
|
|
704
|
+
if (c.cmd === 'close') lines.push(' - {cmd: close}')
|
|
705
|
+
else if (c.cmd === 'arcTo') lines.push(` - {cmd: arcTo, wR: ${c.wR}, hR: ${c.hR}, stAng: ${c.stAng}, swAng: ${c.swAng}}`)
|
|
706
|
+
else lines.push(` - {cmd: ${c.cmd}, pts: ${JSON.stringify(c.pts)}}`)
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
if (raw.gradient && typeof raw.gradient === 'object') {
|
|
710
|
+
const stopsTxt = (raw.gradient.stops ?? []).map((s) => s.color).join('→')
|
|
711
|
+
lines.push(` # import: 渐变 ${stopsTxt}(${raw.gradient.type ?? 'linear'}${raw.gradient.angle !== undefined ? `, angle ${raw.gradient.angle}°` : ''})直通 fill.gradient`)
|
|
712
|
+
}
|
|
713
|
+
if (el.line) lines.push(` line: {color: ${JSON.stringify(el.line.color ?? '#000000')}${el.line.width ? `, width: ${el.line.width}` : ''}}`)
|
|
714
|
+
if (el.cols) {
|
|
715
|
+
lines.push(` cols: [${el.cols.map((c) => JSON.stringify(c)).join(', ')}]`)
|
|
716
|
+
lines.push(` rows: ${JSON.stringify(el.rows)}`)
|
|
717
|
+
}
|
|
718
|
+
if (el.header !== undefined) lines.push(` header: ${el.header}`)
|
|
719
|
+
if (el.chart) lines.push(` chart: ${JSON.stringify(el.chart).replace(/^\{(.*)\}$/, '$1')}`)
|
|
720
|
+
if (el.content) {
|
|
721
|
+
lines.push(' content:')
|
|
722
|
+
lines.push(` text: ${JSON.stringify(el.content.text)}`)
|
|
723
|
+
if (el.content.fontSize) lines.push(` fontSize: ${el.content.fontSize}`)
|
|
724
|
+
if (el.content.color) lines.push(` color: ${JSON.stringify(el.content.color)}`)
|
|
725
|
+
if (el.content.bold) lines.push(` bold: true`)
|
|
726
|
+
if (el.content.italic) lines.push(` italic: true`)
|
|
727
|
+
if (el.content.fontFamily) lines.push(` fontFamily: ${JSON.stringify(el.content.fontFamily)}`)
|
|
728
|
+
if (el.content.align) lines.push(` align: ${el.content.align}`)
|
|
729
|
+
if (el.content.lineHeight) lines.push(` lineHeight: ${el.content.lineHeight}`)
|
|
730
|
+
}
|
|
731
|
+
return lines.join('\n')
|
|
732
|
+
}).join('\n')
|
|
733
|
+
return `# 导入自原 pptx(几何仅供参考,可由 layout 编辑器重排;样式已保留;v0.9.1 prst 形状/渐变直通)
|
|
734
|
+
pageType: content
|
|
735
|
+
${notesYaml(page.notes)}${page.background ? `background: ${JSON.stringify(page.background)}\n` : ''}elements:
|
|
736
|
+
${els}
|
|
737
|
+
`
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/** 讲稿 → 页面 yaml 的 notes 行:单行走引号,多行走 YAML 块标量(与导出侧对称)。 */
|
|
741
|
+
function notesYaml(notes) {
|
|
742
|
+
const s = typeof notes === 'string' ? notes.replace(/\s+$/, '') : ''
|
|
743
|
+
if (!s) return ''
|
|
744
|
+
if (!s.includes('\n')) return `notes: ${JSON.stringify(s)}\n`
|
|
745
|
+
return `notes: |\n${s.split(/\r?\n/).map((l) => ' ' + l).join('\n')}\n`
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/** slide rels 里的 notesSlide → 讲稿文本(段落还原成 \n)。无关系 / 部件缺失 → null。 */
|
|
749
|
+
function notesOfSlide(files, slidePart, slideRels) {
|
|
750
|
+
const target = [...slideRels.values()].find((t) => /notesSlides\//.test(String(t)))
|
|
751
|
+
if (!target) return null
|
|
752
|
+
const raw = files.get(resolveRelPath(slidePart, String(target)))
|
|
753
|
+
if (!raw) return null
|
|
754
|
+
const tree = parseXml(decodeXml(raw))
|
|
755
|
+
const paras = []
|
|
756
|
+
const visit = (n) => {
|
|
757
|
+
if (n.tag === 'p') { paras.push(allText(n).trim()); return }
|
|
758
|
+
for (const c of n.children) visit(c)
|
|
759
|
+
}
|
|
760
|
+
visit(tree)
|
|
761
|
+
const out = paras.join('\n').trim()
|
|
762
|
+
return out || null
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/** 把 slide 内部的相对 Target(../notesSlides/x.xml)解析成包内绝对路径。 */
|
|
766
|
+
function resolveRelPath(basePart, target) {
|
|
767
|
+
const segs = [...basePart.split('/').slice(0, -1), ...target.split('/')]
|
|
768
|
+
const out = []
|
|
769
|
+
for (const s of segs) {
|
|
770
|
+
if (s === '..') out.pop()
|
|
771
|
+
else if (s !== '.' && s !== '') out.push(s)
|
|
772
|
+
}
|
|
773
|
+
return out.join('/')
|
|
774
|
+
}
|