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.
Files changed (179) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +480 -0
  3. package/agent-presets/ppt/agent.cordis.yml +308 -0
  4. package/agent-presets/ppt/preset.yml +3 -0
  5. package/cordis.patch.yml +29 -0
  6. package/docs/00-/350/256/276/350/256/241/346/272/220.md +537 -0
  7. package/docs/01-/351/234/200/346/261/202/344/270/216/347/233/256/346/240/207.md +136 -0
  8. package/docs/02-/346/212/200/346/234/257/346/212/245/345/221/212.md +483 -0
  9. package/docs/03-/346/233/264/346/226/260/346/227/245/345/277/227.md +2898 -0
  10. 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
  11. package/docs/05-/350/277/255/344/273/243/346/265/201/347/250/213.md +210 -0
  12. package/docs/06-/350/257/204/345/256/241/344/270/216/346/265/213/350/257/225.md +296 -0
  13. package/docs/fidelity-report.md +225 -0
  14. package/examples/fx/fx-mini/deck.yaml +39 -0
  15. package/examples/fx/fx-mini/pages/01.yaml +6 -0
  16. package/examples/fx/fx-mini/pages/02.yaml +2 -0
  17. package/examples/fx/fx-mini/pages/03.yaml +6 -0
  18. package/examples/fx/fx-pro/deck.yaml +48 -0
  19. package/examples/fx/fx-pro/pages/01_cover.yaml +40 -0
  20. package/examples/fx/fx-pro/pages/02_arch.yaml +55 -0
  21. package/examples/fx/fx-pro/pages/03_table.yaml +20 -0
  22. package/examples/fx/fx-pro/pages/04_chart.yaml +40 -0
  23. package/examples/fx/fx-pro/pages/05_timeline.yaml +71 -0
  24. package/examples/fx/fx-pro/pages/06_longtext.yaml +13 -0
  25. package/examples/fx/fx-pro/pages/07_safearea.yaml +33 -0
  26. package/examples/fx/fx-pro/pages/08_contrast.yaml +22 -0
  27. package/examples/fx/fx-pro/pages/09_custgeom.yaml +32 -0
  28. package/examples/fx/fx-pro/pages/10_numbers.yaml +25 -0
  29. package/examples/fx/fx-pro/pages/11_section.yaml +20 -0
  30. package/examples/fx/fx-pro/pages/12_summary.yaml +57 -0
  31. package/examples/fx/fx-pro/preview/01_pages_01_cover.html +1 -0
  32. package/examples/fx/fx-pro/preview/02_pages_02_arch.html +3 -0
  33. package/examples/fx/fx-pro/preview/03_pages_03_table.html +1 -0
  34. package/examples/fx/fx-pro/preview/04_pages_04_chart.html +3 -0
  35. package/examples/fx/fx-pro/preview/05_pages_05_timeline.html +2 -0
  36. package/examples/fx/fx-pro/preview/06_pages_06_longtext.html +4 -0
  37. package/examples/fx/fx-pro/preview/07_pages_07_safearea.html +2 -0
  38. package/examples/fx/fx-pro/preview/08_pages_08_contrast.html +1 -0
  39. package/examples/fx/fx-pro/preview/09_pages_09_custgeom.html +1 -0
  40. package/examples/fx/fx-pro/preview/10_pages_10_numbers.html +1 -0
  41. package/examples/fx/fx-pro/preview/11_pages_11_section.html +1 -0
  42. package/examples/fx/fx-pro/preview/12_pages_12_summary.html +1 -0
  43. package/examples/fx/fx-pro/preview/deck.html +10 -0
  44. package/examples/fx/fx-pro/preview/layout.json +2325 -0
  45. package/examples/fx/fx-pro/preview/measured.json +814 -0
  46. package/examples/fx/fx-pro.pptx +0 -0
  47. package/examples/fx/ren-single/01.png +0 -0
  48. package/examples/fx/ren-spliced/01.png +0 -0
  49. package/examples/fx/ren-spliced/03.png +0 -0
  50. package/examples/fx/seed.pptx +0 -0
  51. package/examples/fx/single.pptx +0 -0
  52. package/examples/fx/spliced.pptx +0 -0
  53. package/examples/smoke/deck.yaml +26 -0
  54. package/examples/smoke/imported/deck.yaml +30 -0
  55. package/examples/smoke/imported/import-styles.json +126 -0
  56. package/examples/smoke/imported/pages/slide_01.yaml +31 -0
  57. package/examples/smoke/imported/pages/slide_02.yaml +38 -0
  58. package/examples/smoke/imported/pages/slide_03.yaml +55 -0
  59. package/examples/smoke/imported/preview-rt/01_pages_slide_01.html +1 -0
  60. package/examples/smoke/imported/preview-rt/02_pages_slide_02.html +1 -0
  61. package/examples/smoke/imported/preview-rt/03_pages_slide_03.html +1 -0
  62. package/examples/smoke/imported/preview-rt/deck.html +1 -0
  63. package/examples/smoke/imported/preview-rt/layout.json +434 -0
  64. package/examples/smoke/imported/reference/previews/01.png +0 -0
  65. package/examples/smoke/imported/reference/previews/02.png +0 -0
  66. package/examples/smoke/imported/reference/previews/03.png +0 -0
  67. package/examples/smoke/imported/source.pptx +0 -0
  68. package/examples/smoke/media-test.png +0 -0
  69. package/examples/smoke/out-smoke.pptx +0 -0
  70. package/examples/smoke/pages/01_cover.yaml +27 -0
  71. package/examples/smoke/pages/02_content.yaml +42 -0
  72. package/examples/smoke/pages/03_chart.yaml +35 -0
  73. package/examples/smoke/preview/01_pages_01_cover.html +1 -0
  74. package/examples/smoke/preview/02_pages_02_content.html +1 -0
  75. package/examples/smoke/preview/03_pages_03_chart.html +1 -0
  76. package/examples/smoke/preview/deck.html +1 -0
  77. package/examples/smoke/preview/layout.json +378 -0
  78. package/lib/autodeclare.js +40 -0
  79. package/lib/commands.js +161 -0
  80. package/lib/crosscheck.js +300 -0
  81. package/lib/imgmeta.js +55 -0
  82. package/lib/index.js +239 -0
  83. package/lib/measurement.js +209 -0
  84. package/lib/msrender.js +123 -0
  85. package/lib/pptd/export-pptx.js +686 -0
  86. package/lib/pptd/import-pptx.js +774 -0
  87. package/lib/pptd/layout.js +209 -0
  88. package/lib/pptd/render-html.js +254 -0
  89. package/lib/pptd/schema.js +518 -0
  90. package/lib/pptd/svgCharts.js +147 -0
  91. package/lib/pptxPy.js +205 -0
  92. package/lib/preset-delivery.js +195 -0
  93. package/lib/preview-server.js +164 -0
  94. package/lib/router.js +165 -0
  95. package/lib/scaffold.js +315 -0
  96. package/lib/skill.js +174 -0
  97. package/lib/splice.js +219 -0
  98. package/lib/state.js +88 -0
  99. package/lib/surgical.js +369 -0
  100. package/lib/templates.js +400 -0
  101. package/lib/tools.js +955 -0
  102. package/lib/verify.js +828 -0
  103. package/lib/xmljs.js +112 -0
  104. package/lib/zips.js +117 -0
  105. package/package.json +81 -0
  106. package/scripts/audit-manual-facts.mjs +294 -0
  107. package/scripts/audit-template-fidelity.mjs +162 -0
  108. package/scripts/bench-m2.mjs +48 -0
  109. package/scripts/build-templates.mjs +63 -0
  110. package/scripts/build.mjs +14 -0
  111. package/scripts/build.sh +7 -0
  112. package/scripts/check-lib-freshness.mjs +70 -0
  113. package/scripts/check-preset.mjs +189 -0
  114. package/scripts/check-tracked-lf.mjs +84 -0
  115. package/scripts/dbg-import.mjs +13 -0
  116. package/scripts/dbg-media.mjs +10 -0
  117. package/scripts/dbg-tpl-inventory.mjs +23 -0
  118. package/scripts/dump-workflow-baseline.mjs +38 -0
  119. package/scripts/e2e-1.0.mjs +129 -0
  120. package/scripts/eval-skills-blind.mjs +91 -0
  121. package/scripts/eval-skills-score.mjs +129 -0
  122. package/scripts/export-orig-templates.ps1 +17 -0
  123. package/scripts/fixtures/workflow-baseline.json +300 -0
  124. package/scripts/fixtures.mjs +501 -0
  125. package/scripts/import-user-templates.mjs +60 -0
  126. package/scripts/install-desktop.mjs +312 -0
  127. package/scripts/install.mjs +209 -0
  128. package/scripts/migrate-templates-dualtrack.mjs +71 -0
  129. package/scripts/preflight-1.0.mjs +198 -0
  130. package/scripts/probe-asset-freshness.mjs +57 -0
  131. package/scripts/probe-effect-semantics.mjs +69 -0
  132. package/scripts/probe-reparse-safety.mjs +32 -0
  133. package/scripts/rebuild-templates-c11.mjs +69 -0
  134. package/scripts/regression-real.mjs +84 -0
  135. package/scripts/release-sync.mjs +425 -0
  136. package/scripts/shot-templates.mjs +49 -0
  137. package/scripts/smoke.mjs +2512 -0
  138. package/scripts/verify-bundle-install.mjs +281 -0
  139. package/scripts/verify-fresh-install.mjs +260 -0
  140. package/skills/ppt-studio-copy/SKILL.md +139 -0
  141. package/skills/ppt-studio-craft/SKILL.md +171 -0
  142. package/skills/ppt-studio-data/SKILL.md +173 -0
  143. package/skills/ppt-studio-manual/SKILL.md +104 -0
  144. package/templates/academic-white/deck.yaml +24 -0
  145. package/templates/academic-white/pages/_chart.yaml +43 -0
  146. package/templates/academic-white/pages/_compare.yaml +37 -0
  147. package/templates/academic-white/pages/_content.yaml +35 -0
  148. package/templates/academic-white/pages/_cover.yaml +28 -0
  149. package/templates/academic-white/pages/_ending.yaml +28 -0
  150. package/templates/academic-white/pages/_flow.yaml +61 -0
  151. package/templates/academic-white/preview.png +0 -0
  152. package/templates/academic-white/template.yaml +14 -0
  153. package/templates/business-blue/deck.yaml +28 -0
  154. package/templates/business-blue/pages/_chart.yaml +36 -0
  155. package/templates/business-blue/pages/_compare.yaml +45 -0
  156. package/templates/business-blue/pages/_content.yaml +80 -0
  157. package/templates/business-blue/pages/_cover.yaml +39 -0
  158. package/templates/business-blue/pages/_ending.yaml +32 -0
  159. package/templates/business-blue/pages/_flow.yaml +87 -0
  160. package/templates/business-blue/preview.png +0 -0
  161. package/templates/business-blue/template.yaml +14 -0
  162. package/templates/pitch-bold/deck.yaml +23 -0
  163. package/templates/pitch-bold/pages/_chart.yaml +32 -0
  164. package/templates/pitch-bold/pages/_compare.yaml +36 -0
  165. package/templates/pitch-bold/pages/_content.yaml +58 -0
  166. package/templates/pitch-bold/pages/_cover.yaml +31 -0
  167. package/templates/pitch-bold/pages/_ending.yaml +27 -0
  168. package/templates/pitch-bold/pages/_flow.yaml +46 -0
  169. package/templates/pitch-bold/preview.png +0 -0
  170. package/templates/pitch-bold/template.yaml +14 -0
  171. package/templates/tech-dark/deck.yaml +24 -0
  172. package/templates/tech-dark/pages/_chart.yaml +27 -0
  173. package/templates/tech-dark/pages/_compare.yaml +38 -0
  174. package/templates/tech-dark/pages/_content.yaml +51 -0
  175. package/templates/tech-dark/pages/_cover.yaml +30 -0
  176. package/templates/tech-dark/pages/_ending.yaml +25 -0
  177. package/templates/tech-dark/pages/_flow.yaml +80 -0
  178. package/templates/tech-dark/preview.png +0 -0
  179. 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
+ }