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
package/lib/pptxPy.js
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* python-pptx 引擎:把中间层(deck.yaml)映射为 python-pptx 脚本(兜底引擎)。
|
|
3
|
+
* 覆盖 text / shape / image / table;chart 降级为表格+说明。
|
|
4
|
+
* 需要 python 环境 + python-pptx(运行前探测,缺失则报错提示)。
|
|
5
|
+
*/
|
|
6
|
+
import { readFile } from 'node:fs/promises'
|
|
7
|
+
import { join, isAbsolute } from 'node:path'
|
|
8
|
+
import { spawnSync } from 'node:child_process'
|
|
9
|
+
import YAML from 'yaml'
|
|
10
|
+
import { normalizePage, measureText } from './pptd/layout.js'
|
|
11
|
+
|
|
12
|
+
export function genPythonScript(ctx) {
|
|
13
|
+
const py = []
|
|
14
|
+
py.push('from pptx import Presentation')
|
|
15
|
+
py.push('from pptx.util import Emu, Pt')
|
|
16
|
+
py.push('from pptx.dml.color import RGBColor')
|
|
17
|
+
py.push('from pptx.enum.shapes import MSO_SHAPE, MSO_CONNECTOR')
|
|
18
|
+
py.push('from pptx.enum.text import PP_ALIGN, MSO_ANCHOR')
|
|
19
|
+
py.push('import json, sys')
|
|
20
|
+
py.push('')
|
|
21
|
+
// OUT 由 runPythonExport 替换成字面量路径(见文件末尾的 replace)。
|
|
22
|
+
// 旧实现是 `prs.save(sys.argv[1])` + `print("saved:", sys.argv[1])`,导出时只替换了 save 那一行,
|
|
23
|
+
// 于是脚本**存完文件后**在 print 处 IndexError(`python -c` 下没有 argv[1])——文件其实已经写出来了,
|
|
24
|
+
// 调用方却收到"执行失败"。改成变量后再不会有这个错位。
|
|
25
|
+
py.push('OUT = sys.argv[1] if len(sys.argv) > 1 else "out.pptx"')
|
|
26
|
+
py.push('')
|
|
27
|
+
py.push(`W, H = ${ctx.size.width}, ${ctx.size.height}`)
|
|
28
|
+
py.push('prs = Presentation()')
|
|
29
|
+
py.push('prs.slide_width = Emu(W * 12700)')
|
|
30
|
+
py.push('prs.slide_height = Emu(H * 12700)')
|
|
31
|
+
py.push('blank = prs.slide_layouts[6]')
|
|
32
|
+
py.push('')
|
|
33
|
+
py.push('def rgb(c):')
|
|
34
|
+
py.push(' if not c: return RGBColor(0, 0, 0)')
|
|
35
|
+
py.push(' c = c.lstrip("#").upper()')
|
|
36
|
+
py.push(' return RGBColor(int(c[0:2], 16), int(c[2:4], 16), int(c[4:6], 16))')
|
|
37
|
+
py.push('')
|
|
38
|
+
py.push('def fit_font(font, text, box_w, box_h):')
|
|
39
|
+
py.push(' # 保守估算:CJK 1em / latin 0.55em')
|
|
40
|
+
py.push(' fs = font.size.pt if font.size else 18')
|
|
41
|
+
py.push(' lines = max(1, int(box_h // (fs * 1.2)))')
|
|
42
|
+
py.push(' chars = 0')
|
|
43
|
+
py.push(' for ch in text:')
|
|
44
|
+
py.push(' chars += 1 if ord(ch) > 0x2E7F else 0.55')
|
|
45
|
+
py.push(' need = chars * fs')
|
|
46
|
+
py.push(' if need > box_w * lines:')
|
|
47
|
+
py.push(' fs = max(int(fs * box_w * lines / need), int(fs * 0.6))')
|
|
48
|
+
py.push(' font.size = Pt(fs)')
|
|
49
|
+
py.push('')
|
|
50
|
+
py.push('def add_text(slide, b, text, st):')
|
|
51
|
+
py.push(' box = slide.shapes.add_textbox(Emu(b[0]*12700), Emu(b[1]*12700), Emu(b[2]*12700), Emu(b[3]*12700))')
|
|
52
|
+
py.push(' tf = box.text_frame')
|
|
53
|
+
py.push(' tf.word_wrap = True')
|
|
54
|
+
py.push(' tf.margin_left = tf.margin_right = 0')
|
|
55
|
+
py.push(' tf.margin_top = tf.margin_bottom = 0')
|
|
56
|
+
py.push(' fs = st.get("fontSize", 18)')
|
|
57
|
+
py.push(' for i, line in enumerate(str(text).split("\\n")):')
|
|
58
|
+
py.push(' p = tf.paragraphs[0] if i == 0 else tf.add_paragraph()')
|
|
59
|
+
py.push(' run = p.add_run()')
|
|
60
|
+
py.push(' run.text = line')
|
|
61
|
+
// 样式必须落在 **run** 上:`paragraph.font` 只写 pPr/defRPr,而 `p.text = …` 会新建 run,
|
|
62
|
+
// 之前那种"先设 paragraph.font 再 p.text=" 的写法会把字号/颜色/粗体全部丢掉(导出看着有字、样式全无)。
|
|
63
|
+
py.push(' f = run.font')
|
|
64
|
+
py.push(' f.size = Pt(fs)')
|
|
65
|
+
py.push(' if st.get("bold"): f.bold = True')
|
|
66
|
+
py.push(' f.color.rgb = rgb(st.get("color"))')
|
|
67
|
+
py.push(' if st.get("fontFamily"): f.name = st["fontFamily"]')
|
|
68
|
+
py.push(' p.alignment = {"left": PP_ALIGN.LEFT, "center": PP_ALIGN.CENTER, "right": PP_ALIGN.RIGHT}.get(st.get("align"), PP_ALIGN.LEFT)')
|
|
69
|
+
py.push(' p.line_spacing = st.get("lineHeight", 1.2)')
|
|
70
|
+
py.push(' fit_font(f, line, b[2], b[3])')
|
|
71
|
+
py.push('')
|
|
72
|
+
|
|
73
|
+
for (const page of ctx.pages) {
|
|
74
|
+
const els = normalizePage(page, ctx)
|
|
75
|
+
py.push(`slide = prs.slides.add_slide(blank)`)
|
|
76
|
+
for (const el of els) {
|
|
77
|
+
const [x, y, w, h] = [el.bounds.x, el.bounds.y, el.bounds.w, el.bounds.h]
|
|
78
|
+
switch (el.type) {
|
|
79
|
+
case 'text': {
|
|
80
|
+
py.push(`add_text(slide, [${x}, ${y}, ${w}, ${h}], ${JSON.stringify(el.content?.text ?? '')}, ${pyLit({ ...el.style })})`)
|
|
81
|
+
break
|
|
82
|
+
}
|
|
83
|
+
case 'shape': {
|
|
84
|
+
const map = { rect: 'MSO_SHAPE.RECTANGLE', ellipse: 'MSO_SHAPE.OVAL', triangle: 'MSO_SHAPE.ISOCELES_TRIANGLE' }
|
|
85
|
+
const mode = map[el.kind] ?? 'MSO_SHAPE.RECTANGLE'
|
|
86
|
+
py.push(`sh = slide.shapes.add_shape(${mode}, Emu(${x}*12700), Emu(${y}*12700), Emu(${w}*12700), Emu(${h}*12700))`)
|
|
87
|
+
// 填充/描边必须生成**语句**,不能写成单行条件表达式。原实现写的是
|
|
88
|
+
// `sh.line.fill.background() if not {...} else (sh.line.color.rgb = …, sh.line.width = …)`
|
|
89
|
+
// 一次踩三种雷:① `else (a = b, …)`——赋值是**语句**不是表达式 ⇒ 直接 **SyntaxError**(连编译都过不去);
|
|
90
|
+
// ② fill/line 缺省时插值出 JS 的 `undefined` / `null`,Python 里不存在 ⇒ NameError;
|
|
91
|
+
// ③ `{color, alpha}` 被 JSON.stringify 成 dict 直接喂给 `rgb()` ⇒ AttributeError。
|
|
92
|
+
// 兜底引擎的全部意义是"pptd 硬失败时还能出片",它自己语法错就等于没有兜底。
|
|
93
|
+
const solid = fillColorOf(el.fill)
|
|
94
|
+
if (solid) {
|
|
95
|
+
py.push('sh.fill.solid()')
|
|
96
|
+
py.push(`sh.fill.fore_color.rgb = rgb(${JSON.stringify(solid)})`)
|
|
97
|
+
if (isApproxFill(el.fill)) py.push('# 注意:alpha / 渐变在兜底引擎里按纯色近似(原生引擎 pptd 保留原样)')
|
|
98
|
+
} else {
|
|
99
|
+
py.push('sh.fill.background()')
|
|
100
|
+
}
|
|
101
|
+
if (el.line?.color) {
|
|
102
|
+
py.push(`sh.line.color.rgb = rgb(${JSON.stringify(el.line.color)})`)
|
|
103
|
+
py.push(`sh.line.width = Pt(${Number(el.line.width) || 1})`)
|
|
104
|
+
} else {
|
|
105
|
+
py.push('sh.line.fill.background()')
|
|
106
|
+
}
|
|
107
|
+
break
|
|
108
|
+
}
|
|
109
|
+
case 'line': {
|
|
110
|
+
const p1 = el.points[0]
|
|
111
|
+
const p2 = el.points[1]
|
|
112
|
+
py.push(`ln = slide.shapes.add_connector(MSO_CONNECTOR.STRAIGHT, Emu(${p1[0]}*12700), Emu(${p1[1]}*12700), Emu(${p2[0]}*12700), Emu(${p2[1]}*12700))`)
|
|
113
|
+
py.push(`ln.line.color.rgb = rgb(${JSON.stringify(el.line?.color ?? '#000000')}); ln.line.width = Pt(${el.line?.width ?? 1})`)
|
|
114
|
+
break
|
|
115
|
+
}
|
|
116
|
+
case 'image':
|
|
117
|
+
py.push(`slide.shapes.add_picture(${JSON.stringify(join(ctx.dir, el.src))}, Emu(${x}*12700), Emu(${y}*12700), Emu(${w}*12700), Emu(${h}*12700))`)
|
|
118
|
+
break
|
|
119
|
+
case 'table': {
|
|
120
|
+
py.push(`rows, cols = ${JSON.stringify(el.rows.length + (el.header ? 1 : 0))}, ${Math.max(1, el.cols.length)}`)
|
|
121
|
+
py.push(`tbl = slide.shapes.add_table(rows, cols, Emu(${x}*12700), Emu(${y}*12700), Emu(${w}*12700), Emu(${h}*12700)).table`)
|
|
122
|
+
if (el.header) py.push(`for j, v in enumerate(${pyLit(el.cols)}): tbl.cell(0, j).text = str(v)`)
|
|
123
|
+
py.push(`for i, row in enumerate(${pyLit(el.rows)}):`)
|
|
124
|
+
py.push(` for j, v in enumerate(row): tbl.cell(${el.header ? 'i + 1' : 'i'}, j).text = str(v)`)
|
|
125
|
+
if (el.header) py.push(`for j in range(cols): tbl.cell(0, j).text_frame.paragraphs[0].font.bold = True`)
|
|
126
|
+
break
|
|
127
|
+
}
|
|
128
|
+
case 'chart': {
|
|
129
|
+
py.push(`# chart 降级:引擎 B 以表格表达(引擎 A 支持矢量拼绘)`)
|
|
130
|
+
break
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
py.push('')
|
|
136
|
+
py.push(`prs.save(OUT)`)
|
|
137
|
+
py.push(`print("saved:", OUT)`)
|
|
138
|
+
return py.join('\n')
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** JS 值 → **Python** 字面量。
|
|
142
|
+
* 为什么不能直接用 JSON.stringify:它产出的是 **JSON**,而 JSON 的 true/false/null 在 Python 里非法。
|
|
143
|
+
* 真 bug 现场:文本元素的样式字典 `{"wrap":true}` 原样进脚本 ⇒ `NameError: name 'true' is not defined`——
|
|
144
|
+
* 而文本是最高频的路径,等于兜底引擎对几乎任何 deck 都不可用。 */
|
|
145
|
+
function pyLit(v) {
|
|
146
|
+
if (v === null || v === undefined) return 'None'
|
|
147
|
+
if (typeof v === 'boolean') return v ? 'True' : 'False'
|
|
148
|
+
if (typeof v === 'number') return Number.isFinite(v) ? String(v) : 'None'
|
|
149
|
+
if (typeof v === 'string') return JSON.stringify(v)
|
|
150
|
+
if (Array.isArray(v)) return `[${v.map(pyLit).join(', ')}]`
|
|
151
|
+
if (typeof v === 'object') return `{${Object.entries(v).map(([k, val]) => `${JSON.stringify(k)}: ${pyLit(val)}`).join(', ')}}`
|
|
152
|
+
return 'None'
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** fill 归一化后的取色:'#hex' | {color,alpha} | {type:'gradient',stops} → '#hex' | null(无填充)。 */
|
|
156
|
+
function fillColorOf(fill) {
|
|
157
|
+
if (fill == null) return null
|
|
158
|
+
if (typeof fill === 'string') return /^#/.test(fill) ? fill : null
|
|
159
|
+
if (Array.isArray(fill.stops)) return fillColorOf(fill.stops[0]?.color ?? null)
|
|
160
|
+
if (fill.color != null) return fillColorOf(fill.color)
|
|
161
|
+
return null
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** 兜底引擎会**近似**的填充(alpha / 渐变)——用于在生成脚本里留下诚实标注,而不是悄悄改变观感。 */
|
|
165
|
+
function isApproxFill(fill) {
|
|
166
|
+
return Boolean(fill && typeof fill === 'object'
|
|
167
|
+
&& (fill.type === 'gradient' || fill.alpha != null || Array.isArray(fill.stops)))
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function findPython() {
|
|
171
|
+
for (const cmd of ['python', 'py']) {
|
|
172
|
+
try {
|
|
173
|
+
const r = spawnSync(cmd, ['-c', 'import pptx; print("ok")'], { encoding: 'utf8', timeout: 15000 })
|
|
174
|
+
if (r.status === 0 && r.stdout.includes('ok')) return { cmd, has: true }
|
|
175
|
+
if (r.status === 0 || (r.error === undefined && r.stderr === '')) return { cmd, has: false }
|
|
176
|
+
} catch { /* try next */ }
|
|
177
|
+
}
|
|
178
|
+
return { cmd: null, has: false }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function runPythonScript(ctx, script) {
|
|
182
|
+
const py = findPython()
|
|
183
|
+
if (!py.has) throw new Error('python-pptx 引擎不可用:未检测到带 python-pptx 的 python 环境(pip install python-pptx)——可改用默认 pptd 引擎')
|
|
184
|
+
return startPython(py.cmd, script)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function startPython(cmd, script) {
|
|
188
|
+
const r = spawnSync(cmd, ['-c', script], { encoding: 'utf8', timeout: 120000 })
|
|
189
|
+
if (r.status !== 0) {
|
|
190
|
+
// A2b 修复(反馈二):空消息无法定位 → 带 exit code / spawn 错误 / stderr 全文
|
|
191
|
+
const detail = String(r.error?.code ?? r.error?.message ?? '').trim() || String(r.stderr ?? r.stdout ?? '').trim().slice(0, 1500)
|
|
192
|
+
throw new Error(`python-pptx 执行失败(exit ${r.status ?? 'unknown'}${r.signal ? `, signal ${r.signal}` : ''})${detail ? `:${detail}` : ':解释器无任何输出(疑似 WindowsApps 假 python 桩或环境损坏)'}`)
|
|
193
|
+
}
|
|
194
|
+
return r.stdout
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export async function runPythonExport(ctx, out) {
|
|
198
|
+
// out:绝对路径原样使用;相对路径相对 deck 目录(与 pptd 引擎同语义,E1)
|
|
199
|
+
const outPath = isAbsolute(out) ? out : join(ctx.dir, out)
|
|
200
|
+
// 替换 OUT 那一行(不再替换 save 行——旧写法只换 save,导致 print 仍在读 argv[1]:
|
|
201
|
+
// `python -c` 下没有 argv[1] ⇒ **文件已写出却报执行失败**)
|
|
202
|
+
const script = genPythonScript(ctx).replace(/^OUT = .*$/m, `OUT = ${JSON.stringify(outPath)}`)
|
|
203
|
+
const result = await runPythonScript(ctx, script)
|
|
204
|
+
return { file: outPath, engine: 'python-pptx', note: result.trim() }
|
|
205
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 预设交付(2026-09-24 适配 DSH **0.1.7-rc.1**):从"写预设目录"改为"向 agent-preset 注册表声明"。
|
|
3
|
+
*
|
|
4
|
+
* ── 为什么必须改(上游结构性变更,不是可选优化)────────────────────────────────
|
|
5
|
+
* 0.1.6 及以前:宿主启动时**扫描** `<dshHome>/.agent-presets/<id>/agent.cordis.yml`,
|
|
6
|
+
* 所以插件只要把文件写进去,预设就会出现在新建会话的选择器里(`dsh-agent-presets` 的 discover 路径)。
|
|
7
|
+
* 0.1.7:那条发现路径**整个被删掉了**。上游换成两个包——
|
|
8
|
+
* `@deepseek-ai/dsh-agent-preset`(一条 YAML 声明)+ `@deepseek-ai/dsh-agent-preset-registry`
|
|
9
|
+
* (只有 `register/list/resolve/...`,**没有任何目录扫描**);随包预设也改为
|
|
10
|
+
* `dsh-web-app/presets/*.patch.yml` 里 insert 一行 `@deepseek-ai/dsh-agent-preset`。
|
|
11
|
+
* 社区插件自己对这条的表述(dsh-client-ui-preset-center README):
|
|
12
|
+
* "Nothing scans that directory: the harness discovers no preset on disk."
|
|
13
|
+
* ⇒ 继续写目录 = 预设**永远不会出现**,而且失败是静默的(没有报错、没有日志)。
|
|
14
|
+
*
|
|
15
|
+
* ── 现在的做法 ────────────────────────────────────────────────────────────────
|
|
16
|
+
* 插件激活时把包内 `agent-presets/ppt/` 那份组合**声明**给注册表:
|
|
17
|
+
* ctx.inject(['agentPresets'], child => child.agentPresets.register({id,name,description,order,plugins}))
|
|
18
|
+
* `plugins` = `agent.cordis.yml` 的条目数组(Loader 方言),`name`/`description`/`order` = `preset.yml`。
|
|
19
|
+
*
|
|
20
|
+
* 三个必须守住的细节(都有源码依据):
|
|
21
|
+
* ① **`!!js` 必须保真**:`disabled: !!js process.platform === 'win32'` 在 Loader 方言里是
|
|
22
|
+
* `{ __jsExpr: "<源码>" }`(`dsh-app-boot` 的 `JsExpr` = `tag:yaml.org,2002:js`,
|
|
23
|
+
* `construct: data => ({ __jsExpr: data })`;`cordis-plugin-loader` 用
|
|
24
|
+
* `evaluate(ctx, value.__jsExpr)` 求值)。自己读 YAML 时若把它降级成普通字符串,
|
|
25
|
+
* `Boolean(<非空字符串>) === true` ⇒ **Windows 上 tool-pwsh 行会被误停用**(没有 shell)。
|
|
26
|
+
* 本模块用 `yaml` 包注册同名 tag,产出的就是 Loader 认的那个形状。
|
|
27
|
+
* ② **相对模块名要变成 file URL**:注册表 mount 时用
|
|
28
|
+
* `scope.ctx.extend({ baseUrl: <注册表所在 ctx 的 baseUrl> })`(= harness base,**不是**预设目录),
|
|
29
|
+
* 所以 `name: ./foo.mjs` 会解析到 harness 里去。社区插件(dsh-liangshen)对同一问题的处理
|
|
30
|
+
* 就是把相对名展开成 `file://` 绝对 URL。本模块照做(当前组合里没有相对名,属防御)。
|
|
31
|
+
* ③ **disposer 归声明者所有**:`register()` 返回注销函数。插件卸载(HMR/停用行)后若不注销,
|
|
32
|
+
* 再次装配会撞 `Duplicate agent preset: ppt`(`register` 对重复 id 直接 throw)。
|
|
33
|
+
* 所以整段挂在 `ctx.effect` 上,随插件同生共死。
|
|
34
|
+
*
|
|
35
|
+
* ── 安全边界 ────────────────────────────────────────────────────────────────
|
|
36
|
+
* · 可被 `config.autoPreset === false` 关闭(保留旧配置键,不破坏既有 profile patch);
|
|
37
|
+
* · 注册表缺失(极简部署 / 低于 0.1.7 的宿主)只告警,**绝不抛**——预设没了也不能让插件挂不上;
|
|
38
|
+
* · 组合里出现解析不了的相对名时**拒绝声明**(宁可没有预设,也不声明一条注定 broken 的声明:
|
|
39
|
+
* broken 的预设会被 `presetOptions()` 过滤掉,等于白声明还多一条误导日志)。
|
|
40
|
+
*/
|
|
41
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
42
|
+
import { dirname, join, resolve } from 'node:path'
|
|
43
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
44
|
+
import { parse } from 'yaml'
|
|
45
|
+
|
|
46
|
+
/** 预设 id(选择器里的稳定标识;`agent-presets/ppt/` 目录名一致)。 */
|
|
47
|
+
export const PRESET_ID = 'ppt'
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Loader 方言里的 `!!js` tag —— 与 `dsh-app-boot` 的 `JsExpr` 同定义(`construct` → `{__jsExpr}`)。
|
|
51
|
+
* 用我们自己的 `yaml` 包注册(宿主那份在 harness base,profile 里 import 不到)。
|
|
52
|
+
*/
|
|
53
|
+
const JS_TAG = {
|
|
54
|
+
tag: 'tag:yaml.org,2002:js',
|
|
55
|
+
resolve: (value) => ({ __jsExpr: value }),
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 包内自带的预设目录(`lib/` 的上一级 = 包根)。 */
|
|
59
|
+
export function builtinPresetDir() {
|
|
60
|
+
return join(dirname(fileURLToPath(import.meta.url)), '..', 'agent-presets', PRESET_ID)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 最近一次声明的结果(供 ppt_state 与测试观测;进程内单例)。 */
|
|
64
|
+
const STATUS = { id: PRESET_ID, declared: false, via: '', reason: '尚未装配', rows: 0 }
|
|
65
|
+
export function presetDeliveryStatus() {
|
|
66
|
+
return { ...STATUS }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function loggerOf(ctx) {
|
|
70
|
+
try { return typeof ctx.logger === 'function' ? ctx.logger('ppt-studio') : ctx.logger } catch { return undefined }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 读 Loader 方言的条目数组(`!!js` 保真)。 */
|
|
74
|
+
function readEntryList(file) {
|
|
75
|
+
const doc = parse(readFileSync(file, 'utf8'), { customTags: [JS_TAG] })
|
|
76
|
+
if (!Array.isArray(doc)) throw new Error(`${file} 不是 Loader 方言的条目数组`)
|
|
77
|
+
return doc
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 把相对模块名(`./x.mjs` / `../x.mjs`)展开为 `file://` 绝对 URL,递归进 `config` 数组(group 行)。
|
|
82
|
+
* 解析不到目标文件时抛错(调用方据此拒绝声明)。
|
|
83
|
+
*/
|
|
84
|
+
function absolutizeRows(rows, baseDir) {
|
|
85
|
+
return rows.map((row) => {
|
|
86
|
+
const out = { ...row }
|
|
87
|
+
if (typeof out.name === 'string' && /^\.\.?\//.test(out.name)) {
|
|
88
|
+
const target = resolve(baseDir, out.name)
|
|
89
|
+
if (!existsSync(target)) throw new Error(`预设行 ${String(out.id)} 的相对模块名解析不到:${out.name}(在 ${baseDir} 下)`)
|
|
90
|
+
out.name = pathToFileURL(target).href
|
|
91
|
+
}
|
|
92
|
+
if (Array.isArray(out.config)) out.config = absolutizeRows(out.config, baseDir)
|
|
93
|
+
return out
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 组装交给 `agentPresets.register()` 的声明。
|
|
99
|
+
* @param {string} [dir] 预设目录(缺省 = 包内自带那份)
|
|
100
|
+
* @returns {{id: string, name: string, description: string, order: number, plugins: object[]}}
|
|
101
|
+
*/
|
|
102
|
+
export function presetDeclaration(dir = builtinPresetDir()) {
|
|
103
|
+
const composition = join(dir, 'agent.cordis.yml')
|
|
104
|
+
if (!existsSync(composition)) throw new Error(`包内预设组合缺失:${composition}`)
|
|
105
|
+
let meta = {}
|
|
106
|
+
const metaFile = join(dir, 'preset.yml')
|
|
107
|
+
if (existsSync(metaFile)) {
|
|
108
|
+
try { meta = parse(readFileSync(metaFile, 'utf8')) ?? {} } catch { meta = {} }
|
|
109
|
+
}
|
|
110
|
+
const plugins = absolutizeRows(readEntryList(composition), dir)
|
|
111
|
+
return {
|
|
112
|
+
id: PRESET_ID,
|
|
113
|
+
name: typeof meta.name === 'string' ? meta.name : 'PPT 工作室',
|
|
114
|
+
description: typeof meta.description === 'string' ? meta.description : '',
|
|
115
|
+
order: typeof meta.order === 'number' ? meta.order : 2,
|
|
116
|
+
plugins,
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function readRegistry(ctx) {
|
|
121
|
+
try { return ctx.get('agentPresets') } catch { return undefined }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* 幂等地把「PPT 工作室」预设声明给 agent-preset 注册表。
|
|
126
|
+
* @returns {{id: string, declared: boolean, via: string, reason: string, rows: number}} 当前(可能尚未完成的)状态
|
|
127
|
+
*/
|
|
128
|
+
export function declareAgentPreset(ctx, config = {}) {
|
|
129
|
+
if (config?.autoPreset === false) {
|
|
130
|
+
STATUS.declared = false
|
|
131
|
+
STATUS.via = ''
|
|
132
|
+
STATUS.reason = 'autoPreset=false(配置关闭)'
|
|
133
|
+
return presetDeliveryStatus()
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
let declaration
|
|
137
|
+
try {
|
|
138
|
+
declaration = presetDeclaration()
|
|
139
|
+
} catch (e) {
|
|
140
|
+
STATUS.declared = false
|
|
141
|
+
STATUS.via = ''
|
|
142
|
+
STATUS.reason = `预设组合不可用(未声明):${String(e?.message ?? e)}`
|
|
143
|
+
loggerOf(ctx)?.warn?.(`[ppt-studio] ${STATUS.reason}`)
|
|
144
|
+
return presetDeliveryStatus()
|
|
145
|
+
}
|
|
146
|
+
STATUS.rows = declaration.plugins.length
|
|
147
|
+
STATUS.reason = '等待 agentPresets 注册表…'
|
|
148
|
+
|
|
149
|
+
const warn = (message) => { try { loggerOf(ctx)?.warn?.(`[ppt-studio] ${message}`) } catch { /* 无 logger 时静默 */ } }
|
|
150
|
+
let release
|
|
151
|
+
let closed = false
|
|
152
|
+
let queue = Promise.resolve()
|
|
153
|
+
|
|
154
|
+
const declare = (registry) => {
|
|
155
|
+
queue = queue.then(async () => {
|
|
156
|
+
if (closed || release !== undefined) return
|
|
157
|
+
if (registry === undefined) {
|
|
158
|
+
STATUS.reason = 'agentPresets 服务不可见(本部署未装配预设注册表?)——预设未声明'
|
|
159
|
+
warn(STATUS.reason)
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
try {
|
|
163
|
+
release = await registry.register(declaration)
|
|
164
|
+
STATUS.declared = true
|
|
165
|
+
STATUS.via = 'agentPresets.register'
|
|
166
|
+
STATUS.reason = `已声明(${declaration.plugins.length} 行)——新建会话的预设选择器里可选「${declaration.name}」`
|
|
167
|
+
try { loggerOf(ctx)?.info?.(`[ppt-studio] ${STATUS.reason}`) } catch { /* 忽略 */ }
|
|
168
|
+
} catch (e) {
|
|
169
|
+
STATUS.declared = false
|
|
170
|
+
STATUS.via = ''
|
|
171
|
+
STATUS.reason = `声明失败(不影响插件本身):${String(e?.message ?? e)}`
|
|
172
|
+
warn(STATUS.reason)
|
|
173
|
+
}
|
|
174
|
+
}).catch((e) => { warn(`预设声明队列异常:${String(e?.message ?? e)}`) })
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
ctx.effect(() => {
|
|
178
|
+
if (typeof ctx.inject === 'function') {
|
|
179
|
+
// 注册表可能比本插件行晚激活:`inject` 等它出现再声明,服务消失时子 ctx 一并释放。
|
|
180
|
+
ctx.inject(['agentPresets'], (child) => { declare(readRegistry(child)) })
|
|
181
|
+
} else {
|
|
182
|
+
declare(readRegistry(ctx))
|
|
183
|
+
}
|
|
184
|
+
return () => {
|
|
185
|
+
closed = true
|
|
186
|
+
const dispose = release
|
|
187
|
+
release = undefined
|
|
188
|
+
STATUS.declared = false
|
|
189
|
+
STATUS.reason = '插件卸载——预设已注销'
|
|
190
|
+
return dispose === undefined ? queue : Promise.resolve(dispose()).catch(() => {})
|
|
191
|
+
}
|
|
192
|
+
}, 'ppt-studio: agent preset declaration')
|
|
193
|
+
|
|
194
|
+
return presetDeliveryStatus()
|
|
195
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PPT 预览服务(需求 A「对话内看 PPT」,v0.6.0):
|
|
3
|
+
* host 侧:webServer 注册 `/ppt-preview/` 前缀路由,静态服务于"预览根"。
|
|
4
|
+
* - 预览根 = ~/.dsh/ppt-studio/preview/<token>/:pages/(render 的 html)+ media/(deck 媒体)。
|
|
5
|
+
* render 输出的 html 引用 `../media/`(相对 deck 根)→ 预览根内 pages/../media/ 恰好解析。
|
|
6
|
+
* - URL 为同源相对路径(/ppt-preview/<token>/pages/deck.html):GUI 页面本身就在 host 上,
|
|
7
|
+
* 点击/iframe 均同源,不依赖端口探测。
|
|
8
|
+
*/
|
|
9
|
+
import { mkdir, rm, copyFile, readFile, stat } from 'node:fs/promises'
|
|
10
|
+
import { existsSync } from 'node:fs'
|
|
11
|
+
import { join, dirname, extname, normalize } from 'node:path'
|
|
12
|
+
import { homedir } from 'node:os'
|
|
13
|
+
import { createHash } from 'node:crypto'
|
|
14
|
+
import { resolveDeck } from './pptd/schema.js'
|
|
15
|
+
import { renderDeck } from './pptd/render-html.js'
|
|
16
|
+
|
|
17
|
+
export const PREVIEW_ROOT = join(homedir(), '.dsh', 'ppt-studio', 'preview')
|
|
18
|
+
const tokens = new Map() // token -> previewRoot
|
|
19
|
+
|
|
20
|
+
const MIME = {
|
|
21
|
+
'.html': 'text/html; charset=utf-8',
|
|
22
|
+
'.png': 'image/png',
|
|
23
|
+
'.jpg': 'image/jpeg',
|
|
24
|
+
'.jpeg': 'image/jpeg',
|
|
25
|
+
'.gif': 'image/gif',
|
|
26
|
+
'.webp': 'image/webp',
|
|
27
|
+
'.svg': 'image/svg+xml',
|
|
28
|
+
'.css': 'text/css; charset=utf-8',
|
|
29
|
+
'.json': 'application/json',
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 构建预览根并分配 token。返回 { token, url, pages, previewRoot }。
|
|
34
|
+
* render 复用预览管道(preview/*.html + layout.json),再拷贝到隔离预览根。
|
|
35
|
+
*/
|
|
36
|
+
export async function buildPreview(dir) {
|
|
37
|
+
const ctx = await resolveDeck(dir)
|
|
38
|
+
const r = await renderDeck(ctx, {})
|
|
39
|
+
const token = createHash('md5').update(dir).digest('hex').slice(0, 10)
|
|
40
|
+
const root = join(PREVIEW_ROOT, token)
|
|
41
|
+
await rm(root, { recursive: true, force: true })
|
|
42
|
+
await mkdir(join(root, 'pages'), { recursive: true })
|
|
43
|
+
for (const f of r.htmlFiles) {
|
|
44
|
+
await copyFile(join(dir, 'preview', f), join(root, 'pages', f))
|
|
45
|
+
}
|
|
46
|
+
await copyFile(join(dir, 'preview', 'deck.html'), join(root, 'pages', 'deck.html'))
|
|
47
|
+
// media(含页面背景图;html 以 ../media/ 引用)
|
|
48
|
+
if (existsSync(join(dir, 'media'))) {
|
|
49
|
+
const mediaDir = join(root, 'media')
|
|
50
|
+
await mkdir(mediaDir, { recursive: true })
|
|
51
|
+
for (const name of await import('node:fs/promises').then((f) => f.readdir(join(dir, 'media')))) {
|
|
52
|
+
await copyFile(join(dir, 'media', name), join(mediaDir, name))
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
tokens.set(token, root)
|
|
56
|
+
// 持久化映射(跨进程/重启后链接仍可用):.meta.json 记录源目录
|
|
57
|
+
try {
|
|
58
|
+
const { writeFile } = await import('node:fs/promises')
|
|
59
|
+
await writeFile(join(root, '.meta.json'), JSON.stringify({ sourceDir: dir, token }))
|
|
60
|
+
} catch { /* 非致命 */ }
|
|
61
|
+
return {
|
|
62
|
+
token,
|
|
63
|
+
url: `/ppt-preview/${token}/pages/${r.htmlFiles[0]}`,
|
|
64
|
+
overviewUrl: `/ppt-preview/${token}/pages/deck.html`,
|
|
65
|
+
pages: r.htmlFiles.length,
|
|
66
|
+
previewRoot: root,
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** token → 预览根(内存优先,miss 时从 .meta.json 恢复并回填)。 */
|
|
71
|
+
async function resolveToken(token) {
|
|
72
|
+
if (tokens.has(token)) return tokens.get(token)
|
|
73
|
+
const root = join(PREVIEW_ROOT, token)
|
|
74
|
+
try {
|
|
75
|
+
const meta = JSON.parse(await readFile(join(root, '.meta.json'), 'utf8'))
|
|
76
|
+
if (meta?.sourceDir) {
|
|
77
|
+
tokens.set(token, root)
|
|
78
|
+
return root
|
|
79
|
+
}
|
|
80
|
+
} catch { /* miss */ }
|
|
81
|
+
return null
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 装配双源(注入器 registry 恢复 + agent preset 行挂载)可能同时挂载本插件——
|
|
86
|
+
* 「duplicate /ppt-preview」崩溃的根因(时序竞态)。路由注册幂等化,**双重防线**:
|
|
87
|
+
* ① **以 webServer 前缀表为皇帝位**——注册前先查表:已有 /ppt-preview(无论谁注册的、
|
|
88
|
+
* refcount 状态如何)→ 跳过注册(只计数);防任何时序的双注册。
|
|
89
|
+
* ② refcount 管理(globalThis,跨模块实例共享;双源经 junction/真实路径可能是两个
|
|
90
|
+
* ESM 实例,模块级变量不可靠)。
|
|
91
|
+
* **v0.14.5 补**:卸载器 u() 是异步的(u() 返回 Promise,未 await 会导致"卸载未完成时
|
|
92
|
+
* 新注册 → duplicate"——v0.14.4 取消路径实测复现);卸载路径全部 await。
|
|
93
|
+
*/
|
|
94
|
+
const ROUTE_REG = globalThis.__pptRouteReg ?? { count: 0, unreg: null }
|
|
95
|
+
globalThis.__pptRouteReg = ROUTE_REG
|
|
96
|
+
|
|
97
|
+
/** 卸载决策:count 归零且有卸载器 → await 卸载(失败保留残留,由表判据挡住重复注册)。 */
|
|
98
|
+
async function releaseRouteWhenZero() {
|
|
99
|
+
if (ROUTE_REG.count === 0 && ROUTE_REG.unreg) {
|
|
100
|
+
const u = ROUTE_REG.unreg
|
|
101
|
+
ROUTE_REG.unreg = null
|
|
102
|
+
try { await u() } catch { /* 幂等;残留由注册前表判据兜底 */ }
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** 注册预览路由(返回 disposer;无 webServer 环境返回 null)。 */
|
|
107
|
+
export function registerPreviewRoute(ctx) {
|
|
108
|
+
const ws = ctx.get('webServer')
|
|
109
|
+
if (!ws) return null
|
|
110
|
+
const hasRoute = () => (ws.prefixes?.has?.('/ppt-preview') ?? false) === true
|
|
111
|
+
if (hasRoute() || ROUTE_REG.count > 0) {
|
|
112
|
+
// 幂等分支:表已注册(任何来源)或本组已发起注册 → 只计数,不重复注册
|
|
113
|
+
ROUTE_REG.count++
|
|
114
|
+
// Cordis 语义:`ctx.effect(cb)` 立即执行 cb、用其**返回值**当 disposer。
|
|
115
|
+
// 这里必须"返回一个函数"——原实现把 `count--` 写在 cb 体里,等于注册当刻就递减,
|
|
116
|
+
// 于是本文档承诺的"首个注册者真注册、最后卸载者真卸载"实际不成立(路由永不释放)。
|
|
117
|
+
// 2026-09-14 修正(与 index.js 的装配防重同一处语义错误)。
|
|
118
|
+
return ctx.effect(() => () => { ROUTE_REG.count--; void releaseRouteWhenZero() }, 'ppt-studio: preview route (ref)')
|
|
119
|
+
}
|
|
120
|
+
let cancelled = false
|
|
121
|
+
const unregPromise = ws.register({
|
|
122
|
+
kind: 'prefix',
|
|
123
|
+
path: '/ppt-preview', // prefix 语义:path 不带尾斜杠(实测:带斜杠不命中)
|
|
124
|
+
async handler(req, res) {
|
|
125
|
+
try {
|
|
126
|
+
const u = new URL(req.url ?? '/', 'http://localhost') // 仅作为解析相对路径的基准,不用于输出
|
|
127
|
+
// token 允许字母/数字/连字符(hex 预览 token 与固定 token 均匹配;v0.14.1 曾因 [a-zA-Z0-9]+ 不含 '-' 导致画廊 404)
|
|
128
|
+
const m = u.pathname.match(/^\/ppt-preview\/([a-zA-Z0-9-]+)\/(.*)$/)
|
|
129
|
+
if (!m) return notFound(res)
|
|
130
|
+
const root = await resolveToken(m[1])
|
|
131
|
+
const rel = normalize(m[2]).replace(/^([/\\])+/, '')
|
|
132
|
+
if (!root || rel.includes('..')) return notFound(res)
|
|
133
|
+
const file = join(root, rel)
|
|
134
|
+
if (!existsSync(file)) return notFound(res)
|
|
135
|
+
const st = await stat(file)
|
|
136
|
+
if (st.isDirectory()) return notFound(res)
|
|
137
|
+
const buf = await readFile(file)
|
|
138
|
+
res.writeHead(200, { 'Content-Type': MIME[extname(file).toLowerCase()] ?? 'application/octet-stream', 'Content-Length': buf.length, 'Cache-Control': 'no-store' })
|
|
139
|
+
res.end(buf)
|
|
140
|
+
} catch {
|
|
141
|
+
res.writeHead(500)
|
|
142
|
+
res.end('preview error')
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
}).then(async (u) => {
|
|
146
|
+
if (cancelled) { try { await u() } catch { /* 幂等 */ } ; return null }
|
|
147
|
+
ROUTE_REG.unreg = u
|
|
148
|
+
return u
|
|
149
|
+
}).catch(() => null)
|
|
150
|
+
ROUTE_REG.count++
|
|
151
|
+
// 同上:cb 立即执行、返回值是 disposer —— 清理逻辑必须包在**返回的函数**里。
|
|
152
|
+
return ctx.effect(() => () => {
|
|
153
|
+
ROUTE_REG.count--
|
|
154
|
+
if (cancelled) return
|
|
155
|
+
if (ROUTE_REG.unreg) { void releaseRouteWhenZero(); return }
|
|
156
|
+
cancelled = true
|
|
157
|
+
unregPromise.then(() => releaseRouteWhenZero())
|
|
158
|
+
}, 'ppt-studio: preview route')
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function notFound(res) {
|
|
162
|
+
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' })
|
|
163
|
+
res.end('not found')
|
|
164
|
+
}
|