dsh-ppt-studio 1.1.4 → 1.2.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 (95) hide show
  1. package/README.md +5 -2
  2. package/docs/00-/350/256/276/350/256/241/346/272/220.md +4 -0
  3. package/docs/02-/346/212/200/346/234/257/346/212/245/345/221/212.md +114 -1
  4. package/docs/03-/346/233/264/346/226/260/346/227/245/345/277/227.md +171 -0
  5. package/docs/05-/350/277/255/344/273/243/346/265/201/347/250/213.md +24 -1
  6. package/docs/06-/350/257/204/345/256/241/344/270/216/346/265/213/350/257/225.md +2 -1
  7. package/docs/07-/345/244/215/346/235/202/345/233/276/350/203/275/345/212/233/347/253/213/351/241/271/344/270/216/351/243/216/351/231/251/347/231/273/350/256/260.md +319 -0
  8. package/docs/08-/351/230/266/346/256/265A/350/256/276/350/256/241/350/257/264/346/230/216-/347/273/223/346/236/204/345/205/263/347/263/273/344/270/216/345/233/276/344/270/223/345/261/236/346/240/241/351/252/214.md +306 -0
  9. package/docs/09-/344/273/243/347/240/201/345/234/260/345/233/276/344/270/216/346/250/241/345/235/227/350/201/214/350/264/243.md +148 -0
  10. package/docs/10-/345/256/277/344/270/273/350/200/246/345/220/210/347/202/271/346/270/205/345/215/225.md +49 -0
  11. package/docs/11-/346/234/257/350/257/255/350/241/250.md +42 -0
  12. package/docs/12-/345/244/215/346/235/202/345/233/276/351/200/232/350/267/257/347/232/204/351/200/211/346/213/251/344/270/216/351/252/214/346/224/266.md +65 -0
  13. package/docs/13-/345/217/221/347/211/210/345/207/206/345/244/207/350/256/241/345/210/222.md +79 -0
  14. package/docs/14-/345/217/221/345/270/203/346/270/205/345/215/225/357/274/210/350/215/211/347/250/277/357/274/211.md +37 -0
  15. package/docs/17-/346/211/213/347/224/27313/351/241/265/350/255/246/345/221/212/351/200/220/346/235/241/345/244/204/347/275/256.md +102 -0
  16. package/examples/fx/fx-pro/preview/layout.json +12 -0
  17. package/examples/fx/fx-pro/preview/measured.json +1 -1
  18. package/examples/fx/fx-pro.pptx +0 -0
  19. package/examples/fx/seed.pptx +0 -0
  20. package/examples/fx/single.pptx +0 -0
  21. package/examples/fx/spliced.pptx +0 -0
  22. package/examples/smoke/imported/preview-rt/layout.json +3 -0
  23. package/examples/smoke/imported/source.pptx +0 -0
  24. package/examples/smoke/out-smoke.pptx +0 -0
  25. package/examples/smoke/preview/layout.json +3 -0
  26. package/lib/group-render.js +177 -0
  27. package/lib/msrender.js +27 -12
  28. package/lib/pptd/blocks.js +52 -0
  29. package/lib/pptd/export-pptx.js +266 -11
  30. package/lib/pptd/layout.js +54 -3
  31. package/lib/pptd/line-rules.js +259 -0
  32. package/lib/pptd/line-text-rule.js +90 -0
  33. package/lib/pptd/relations.js +615 -0
  34. package/lib/pptd/render-html.js +19 -1
  35. package/lib/pptd/route-geometry.js +120 -0
  36. package/lib/pptd/schema.js +146 -11
  37. package/lib/pptd/style-profile.js +133 -0
  38. package/lib/router.js +2 -2
  39. package/lib/scaffold.js +54 -1
  40. package/lib/tools.js +22 -4
  41. package/lib/verify.js +69 -1
  42. package/package.json +20 -3
  43. package/scripts/audit-manual-facts.mjs +15 -3
  44. package/scripts/bench-diagram-scale.mjs +165 -0
  45. package/scripts/check-generators.mjs +22 -0
  46. package/scripts/check-lib-sync.mjs +33 -0
  47. package/scripts/check-line-text.mjs +30 -0
  48. package/scripts/check-package.mjs +60 -0
  49. package/scripts/check-removed-traces.mjs +37 -0
  50. package/scripts/check-syntax.mjs +44 -0
  51. package/scripts/check-unknown-keys.mjs +114 -0
  52. package/scripts/fixtures/handdrawn-baseline.json +177 -0
  53. package/scripts/fixtures/prompt-cost-baseline.json +178 -0
  54. package/scripts/fixtures/workflow-baseline.json +20 -15
  55. package/scripts/handdrawn-option-probe.mjs +78 -0
  56. package/scripts/probe-prompt-cost.mjs +127 -0
  57. package/scripts/red-probe.mjs +75 -0
  58. package/scripts/render-group.mjs +72 -0
  59. package/scripts/smoke.mjs +183 -12
  60. package/scripts/test-fast.mjs +108 -0
  61. package/scripts/test-map.mjs +162 -0
  62. package/scripts/test-release.mjs +64 -0
  63. package/scripts/verify-diagram-relations.mjs +405 -0
  64. package/scripts/verify-handdrawn.mjs +115 -0
  65. package/scripts/verify-handwritten.mjs +203 -0
  66. package/scripts/verify-native-charts.mjs +25 -10
  67. package/scripts/verify-primitives.mjs +714 -0
  68. package/scripts/verify-products.mjs +37 -0
  69. package/scripts/verify-shape.mjs +84 -0
  70. package/scripts/visual-pair-batch.mjs +77 -0
  71. package/skills/diagram-drawing/SKILL.md +23 -0
  72. package/skills/diagram-drawing/capability-matrix.md +22 -0
  73. package/skills/diagram-drawing/composition.md +22 -0
  74. package/skills/diagram-drawing/mechanisms.md +141 -0
  75. package/skills/diagram-drawing/pitfalls.md +89 -0
  76. package/skills/diagram-drawing/routing.md +17 -0
  77. package/skills/diagram-drawing/text-and-spacing.md +16 -0
  78. package/skills/diagram-drawing/verify.md +50 -0
  79. package/skills/diagrams/compare.md +24 -0
  80. package/skills/diagrams/flow.md +24 -0
  81. package/skills/diagrams/funnel.md +25 -0
  82. package/skills/diagrams/layers.md +18 -0
  83. package/skills/diagrams/loop.md +25 -0
  84. package/skills/diagrams/matrix.md +23 -0
  85. package/skills/diagrams/sequence.md +17 -0
  86. package/skills/diagrams/state.md +34 -0
  87. package/skills/diagrams/steps-ring.md +24 -0
  88. package/skills/diagrams/swimlane.md +23 -0
  89. package/skills/diagrams/timeline.md +24 -0
  90. package/skills/diagrams/tree.md +24 -0
  91. package/skills/ppt-studio-manual/SKILL.md +2 -2
  92. package/templates/academic-white/preview.png +0 -0
  93. package/templates/business-blue/preview.png +0 -0
  94. package/templates/pitch-bold/preview.png +0 -0
  95. package/templates/tech-dark/preview.png +0 -0
@@ -139,6 +139,7 @@
139
139
  ],
140
140
  "contrastExempt": [],
141
141
  "overlapMode": "declared",
142
+ "groups": [],
142
143
  "elements": [
143
144
  {
144
145
  "id": "band",
@@ -341,6 +342,7 @@
341
342
  "expectedOutOfSafeArea": [],
342
343
  "contrastExempt": [],
343
344
  "overlapMode": "declared",
345
+ "groups": [],
344
346
  "elements": [
345
347
  {
346
348
  "id": "panel",
@@ -566,6 +568,7 @@
566
568
  "expectedOutOfSafeArea": [],
567
569
  "contrastExempt": [],
568
570
  "overlapMode": "declared",
571
+ "groups": [],
569
572
  "elements": [
570
573
  {
571
574
  "id": "t_head",
@@ -676,6 +679,7 @@
676
679
  "expectedOutOfSafeArea": [],
677
680
  "contrastExempt": [],
678
681
  "overlapMode": "declared",
682
+ "groups": [],
679
683
  "elements": [
680
684
  {
681
685
  "id": "c_head",
@@ -874,6 +878,7 @@
874
878
  "expectedOutOfSafeArea": [],
875
879
  "contrastExempt": [],
876
880
  "overlapMode": "declared",
881
+ "groups": [],
877
882
  "elements": [
878
883
  {
879
884
  "id": "tl_head",
@@ -1189,6 +1194,7 @@
1189
1194
  "expectedOutOfSafeArea": [],
1190
1195
  "contrastExempt": [],
1191
1196
  "overlapMode": "declared",
1197
+ "groups": [],
1192
1198
  "elements": [
1193
1199
  {
1194
1200
  "id": "lt_head",
@@ -1289,6 +1295,7 @@
1289
1295
  ],
1290
1296
  "contrastExempt": [],
1291
1297
  "overlapMode": "declared",
1298
+ "groups": [],
1292
1299
  "elements": [
1293
1300
  {
1294
1301
  "id": "logo",
@@ -1459,6 +1466,7 @@
1459
1466
  "expectedOutOfSafeArea": [],
1460
1467
  "contrastExempt": [],
1461
1468
  "overlapMode": "declared",
1469
+ "groups": [],
1462
1470
  "elements": [
1463
1471
  {
1464
1472
  "id": "ct_head",
@@ -1597,6 +1605,7 @@
1597
1605
  ],
1598
1606
  "contrastExempt": [],
1599
1607
  "overlapMode": "declared",
1608
+ "groups": [],
1600
1609
  "elements": [
1601
1610
  {
1602
1611
  "id": "cg_head",
@@ -1780,6 +1789,7 @@
1780
1789
  "expectedOutOfSafeArea": [],
1781
1790
  "contrastExempt": [],
1782
1791
  "overlapMode": "declared",
1792
+ "groups": [],
1783
1793
  "elements": [
1784
1794
  {
1785
1795
  "id": "num_card",
@@ -1918,6 +1928,7 @@
1918
1928
  ],
1919
1929
  "contrastExempt": [],
1920
1930
  "overlapMode": "declared",
1931
+ "groups": [],
1921
1932
  "elements": [
1922
1933
  {
1923
1934
  "id": "sec_band",
@@ -2048,6 +2059,7 @@
2048
2059
  "expectedOutOfSafeArea": [],
2049
2060
  "contrastExempt": [],
2050
2061
  "overlapMode": "declared",
2062
+ "groups": [],
2051
2063
  "elements": [
2052
2064
  {
2053
2065
  "id": "sm_head",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "browser": "Edge/Chrome headless",
3
- "ts": "2026-09-26T14:56:19.133Z",
3
+ "ts": "2026-09-27T17:10:09.896Z",
4
4
  "size": {
5
5
  "width": 960,
6
6
  "height": 540
Binary file
Binary file
Binary file
Binary file
@@ -53,6 +53,7 @@
53
53
  "expectedOutOfSafeArea": [],
54
54
  "contrastExempt": [],
55
55
  "overlapMode": "declared",
56
+ "groups": [],
56
57
  "elements": [
57
58
  {
58
59
  "id": "hero-rect",
@@ -156,6 +157,7 @@
156
157
  "expectedOutOfSafeArea": [],
157
158
  "contrastExempt": [],
158
159
  "overlapMode": "declared",
160
+ "groups": [],
159
161
  "elements": [
160
162
  {
161
163
  "id": "page-title",
@@ -294,6 +296,7 @@
294
296
  "expectedOutOfSafeArea": [],
295
297
  "contrastExempt": [],
296
298
  "overlapMode": "declared",
299
+ "groups": [],
297
300
  "elements": [
298
301
  {
299
302
  "id": "page-title",
Binary file
Binary file
@@ -53,6 +53,7 @@
53
53
  "expectedOutOfSafeArea": [],
54
54
  "contrastExempt": [],
55
55
  "overlapMode": "declared",
56
+ "groups": [],
56
57
  "elements": [
57
58
  {
58
59
  "id": "hero-rect",
@@ -163,6 +164,7 @@
163
164
  "expectedOutOfSafeArea": [],
164
165
  "contrastExempt": [],
165
166
  "overlapMode": "declared",
167
+ "groups": [],
166
168
  "elements": [
167
169
  {
168
170
  "id": "page-title",
@@ -301,6 +303,7 @@
301
303
  "expectedOutOfSafeArea": [],
302
304
  "contrastExempt": [],
303
305
  "overlapMode": "declared",
306
+ "groups": [],
304
307
  "elements": [
305
308
  {
306
309
  "id": "page-title",
@@ -0,0 +1,177 @@
1
+ /**
2
+ * 组级裁剪渲染的**可复用核心**(阶段 C2),CLI 与 `ppt_render` 工具面共用同一套语义。
3
+ *
4
+ * 语义(与既有 CLI 逐条等价——"纯消重、不改行为",用元素清单 + 产物哈希比对验证):
5
+ * ① 取景:组成员 bbox + pad ⇒ frame;再纳入"完全落在 frame 内"的元素;容器的 contains
6
+ * 目标"要么已在取景内、要么确实落在容器内部"(组标题属后者)⇒ 纳入容器并把 frame 扩到容纳它。
7
+ * 最多 3 轮;容器不得离取景框太远(slack = pad*2,防误吞隔壁组的容器)。
8
+ * ② 容器元素**排到数组最前**(z-order:容器画在节点下面)。
9
+ * ③ 坐标**整体平移**(bbox 原点 → (0,0),两位小数)+ **页面尺寸 = 四舍五入后的 bbox**。
10
+ * ④ 剔除组外的 `contains` / `attach` 引用(并给出提示,不静默);图片元素提示"不复制媒体"。
11
+ *
12
+ * 为什么单独成模块:工具面与 CLI 必须看到**同一份**局部图(否则审阅结论会自相矛盾)。
13
+ */
14
+ import { mkdirSync, writeFileSync, rmSync } from 'node:fs'
15
+ import { join } from 'node:path'
16
+ import YAML from 'yaml'
17
+ import { resolveDeck } from './pptd/schema.js'
18
+ import { renderDeck } from './pptd/render-html.js'
19
+
20
+ /** 元素包围盒:bounds(数组或对象)优先,折线按其 points 求 AABB。 */
21
+ export function rectOf(e) {
22
+ const b = e?.bounds
23
+ if (Array.isArray(b)) return { x: b[0], y: b[1], w: b[2], h: b[3] }
24
+ if (Array.isArray(e?.points) && e.points.length) {
25
+ const xs = e.points.map((p) => p[0]); const ys = e.points.map((p) => p[1])
26
+ const x = Math.min(...xs); const y = Math.min(...ys)
27
+ return { x, y, w: Math.max(...xs) - x, h: Math.max(...ys) - y }
28
+ }
29
+ if (b && typeof b === 'object' && typeof b.x === 'number') return { x: b.x, y: b.y, w: b.w, h: b.h }
30
+ return null
31
+ }
32
+
33
+ const within = (inner, outer) => inner.x >= outer.x - 0.5 && inner.y >= outer.y - 0.5
34
+ && inner.x + inner.w <= outer.x + outer.w + 0.5 && inner.y + inner.h <= outer.y + outer.h + 0.5
35
+
36
+ /**
37
+ * 取景 + 造隔离工程(**不渲染**)。CLI 需要自己做导出/出图,所以把这两步分开。
38
+ * @returns {{ work: string, ctxIso: object, ids: string[], sub: object[], bbox: object,
39
+ * label: string, sourceLabel: string, notes: string[], pageNo: number, pageName: string, memberCount: number }}
40
+ */
41
+ export async function isolateGroup(dir, { page = 1, group, id: oneId, pad = 16, outDir } = {}) {
42
+ const ctx = await resolveDeck(dir)
43
+ const pg = ctx.pages[page - 1]
44
+ if (!pg) throw new Error(`第 ${page} 页不存在(共 ${ctx.pages.length} 页)`)
45
+ const src = pg.page
46
+ const els = src.elements ?? []
47
+ const byId = new Map(els.map((e) => [e.elementId, e]))
48
+
49
+ // ── 目标范围:组(成员并集)或单个元素 ──
50
+ const groups = src.groups ?? []
51
+ let memberIds = []
52
+ let label = ''
53
+ let key = ''
54
+ if (group) {
55
+ const g = groups.find((x) => x.id === group) ?? groups.find((x) => x.id.endsWith(`_${group}`) || x.id.endsWith(group))
56
+ if (!g) throw new Error(`第 ${page} 页没有组 "${group}"(现有:${groups.map((x) => x.id).join(', ') || '无'})`)
57
+ memberIds = g.members ?? []
58
+ label = `组 ${g.id}${g.label ? `(${g.label})` : ''}`
59
+ key = g.id
60
+ } else if (oneId) {
61
+ if (!byId.has(oneId)) throw new Error(`第 ${page} 页没有元素 "${oneId}"`)
62
+ memberIds = [oneId]
63
+ label = `元素 ${oneId}`
64
+ key = oneId
65
+ } else {
66
+ throw new Error('必须给 group <组id> 或 id <元素id>(组列表见 layout.json 或页面 YAML 的 groups 字段)')
67
+ }
68
+
69
+ const base = memberIds.map((mid) => byId.get(mid)).filter(Boolean)
70
+ if (!base.length) throw new Error(`${label} 没有可渲染的元素`)
71
+ const rs = base.map(rectOf).filter(Boolean)
72
+ let frame = {
73
+ x: Math.min(...rs.map((r) => r.x)) - pad,
74
+ y: Math.min(...rs.map((r) => r.y)) - pad,
75
+ w: 0, h: 0,
76
+ }
77
+ frame.w = Math.max(...rs.map((r) => r.x + r.w)) + pad - frame.x
78
+ frame.h = Math.max(...rs.map((r) => r.y + r.h)) + pad - frame.y
79
+
80
+ // ── 取景规则(② 完全落在框内;③ 容器:contains 至少一个已在框内、其余要么也在框内、
81
+ // 要么确实落在该容器内部(组标题属后者);容器不得离框太远;最多 3 轮)──
82
+ const inside = new Set(memberIds.filter((mid) => byId.has(mid)))
83
+ for (let round = 0; round < 3; round++) {
84
+ let grew = false
85
+ for (const e of els) {
86
+ if (inside.has(e.elementId)) continue
87
+ const r = rectOf(e)
88
+ if (r && within(r, frame)) { inside.add(e.elementId); grew = true }
89
+ }
90
+ const slack = pad * 2
91
+ const grown = { x: frame.x - slack, y: frame.y - slack, w: frame.w + slack * 2, h: frame.h + slack * 2 }
92
+ for (const e of els) {
93
+ if (inside.has(e.elementId)) continue
94
+ const cs = Array.isArray(e.contains) ? e.contains : []
95
+ if (!cs.length || !cs.some((m) => inside.has(m))) continue
96
+ const self = rectOf(e)
97
+ if (!self) continue
98
+ if (!cs.every((m) => { const t = byId.get(m); return inside.has(m) || (t && within(rectOf(t), self)) })) continue
99
+ if (!within(self, grown)) continue
100
+ inside.add(e.elementId)
101
+ for (const m of cs) if (!inside.has(m) && byId.has(m)) inside.add(m)
102
+ grew = true
103
+ const x0 = Math.min(frame.x, self.x)
104
+ const y0 = Math.min(frame.y, self.y)
105
+ frame = {
106
+ x: x0, y: y0,
107
+ w: Math.max(frame.x + frame.w, self.x + self.w) - x0,
108
+ h: Math.max(frame.y + frame.h, self.y + self.h) - y0,
109
+ }
110
+ }
111
+ if (!grew) break
112
+ }
113
+
114
+ const members = els.filter((e) => inside.has(e.elementId))
115
+ const bbox = frame
116
+ const shift = (v, d) => Math.round((v - d) * 100) / 100
117
+ const notes = []
118
+
119
+ // ── ② 容器排到最前(z-order)+ ③ 平移归零 + ④ 剔除组外引用 ──
120
+ const containerEls = members.filter((e) => (e.contains ?? []).some((m) => inside.has(m)))
121
+ const ordered = [...containerEls, ...members.filter((e) => !containerEls.includes(e))]
122
+ const sub = ordered.map((e) => {
123
+ const c = { ...e }
124
+ if (Array.isArray(c.bounds)) c.bounds = [shift(c.bounds[0], bbox.x), shift(c.bounds[1], bbox.y), c.bounds[2], c.bounds[3]]
125
+ else if (c.bounds && typeof c.bounds === 'object') c.bounds = { ...c.bounds, x: shift(c.bounds.x, bbox.x), y: shift(c.bounds.y, bbox.y) }
126
+ if (Array.isArray(c.points)) c.points = c.points.map(([x, y]) => [shift(x, bbox.x), shift(y, bbox.y)])
127
+ if (Array.isArray(c.contains)) {
128
+ const kept = c.contains.filter((m) => inside.has(m))
129
+ if (kept.length !== c.contains.length) notes.push(`元素 ${e.elementId} 的 contains 有 ${c.contains.length - kept.length} 项组外引用,已剔除(隔离版只含本组)`)
130
+ if (kept.length) c.contains = kept; else delete c.contains
131
+ }
132
+ const at = c.attach
133
+ if (at) {
134
+ const outEnds = ['from', 'to'].filter((k) => at[k] && !inside.has(at[k].ref))
135
+ if (outEnds.length) notes.push(`元素 ${e.elementId} 的 attach.${outEnds.join('/')} 指向组外,已丢弃该端声明(points 已按绝对坐标平移,形状不受影响)`)
136
+ const kept = {}
137
+ for (const k of ['from', 'to']) if (at[k] && inside.has(at[k].ref)) kept[k] = at[k]
138
+ if (Object.keys(kept).length) c.attach = kept; else delete c.attach
139
+ }
140
+ if (c.elementType === 'image') notes.push(`元素 ${e.elementId} 是图片:隔离工程不复制媒体(会缺图);要看图片请用 --page-render 出整页版`)
141
+ return c
142
+ })
143
+
144
+ const work = join(outDir ?? join(dir, '.group-render'), 'isolated')
145
+ rmSync(work, { recursive: true, force: true })
146
+ mkdirSync(join(work, 'pages'), { recursive: true })
147
+ writeFileSync(join(work, 'deck.yaml'), YAML.stringify({
148
+ version: 1,
149
+ title: `group-${key}`,
150
+ size: [Math.round(bbox.w), Math.round(bbox.h)],
151
+ theme: ctx.deck?.theme ?? {},
152
+ pages: ['pages/01.yaml'],
153
+ }))
154
+ const keptGroups = groups.filter((g) => g.id === key && (g.members ?? []).every((m) => inside.has(m)))
155
+ writeFileSync(join(work, 'pages', '01.yaml'), YAML.stringify({
156
+ pageType: src.pageType ?? 'content',
157
+ ...(keptGroups.length ? { groups: keptGroups } : {}),
158
+ elements: sub,
159
+ }))
160
+
161
+ const ctxIso = await resolveDeck(work)
162
+ const ids = sub.map((e) => e.elementId)
163
+ return {
164
+ work, ctxIso, sub, ids,
165
+ members: ids, // 别名:`ppt_render group=` 与 verify:handwritten 读的是 members(S2 重构时曾漏掉 ⇒ 回归)
166
+ key, bbox, label, notes,
167
+ sourceLabel: `第 ${page} 页(${pg.name})的「${label}」`,
168
+ pageNo: page, pageName: pg.name, memberCount: memberIds.length,
169
+ }
170
+ }
171
+
172
+ /** 取景 + 渲染隔离 preview(工具面用;CLI 用 isolateGroup 自己导出/出图)。 */
173
+ export async function renderGroupIsolated(dir, opts = {}) {
174
+ const r = await isolateGroup(dir, opts)
175
+ const out = await renderDeck(r.ctxIso, { out: 'preview' })
176
+ return { ...r, dir: r.work, groupId: r.key, htmlFiles: out.htmlFiles }
177
+ }
package/lib/msrender.js CHANGED
@@ -22,6 +22,11 @@ const PS1 = `param(
22
22
  )
23
23
  $ErrorActionPreference = 'Stop'
24
24
  $pp = $null
25
+ # 自动化残留清理(2026-09-28):上一次 COM 渲染异常退出会留下**无窗口**的 POWERPNT,
26
+ # 它会让下一次 Presentations.Open 报 "Failed"(实测偶发)。
27
+ # 只杀"没有主窗口"的实例 —— 用户正在用的 PowerPoint 一定有主窗口,绝不动它(不能为了渲染丢用户的活)。
28
+ Get-Process POWERPNT -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowHandle -eq 0 } | Stop-Process -Force -ErrorAction SilentlyContinue
29
+ Start-Sleep -Milliseconds 400
25
30
  try {
26
31
  $pp = New-Object -ComObject PowerPoint.Application
27
32
  $pp.Visible = -1
@@ -93,19 +98,29 @@ export async function renderPptxToPng(pptx, outDir, { width = 1920, height = 108
93
98
  // A5/P5:按页渲染(单参数 JSON 串,避免 PS 数组绑定把 "4,1" 解析成 41);水印默认开(P9)
94
99
  if (pages && pages.length) args.push('-PagesJson', pages.join(','))
95
100
  if (noWatermark) args.push('-NoWatermark')
96
- const stdout = await new Promise((resolve, reject) => {
97
- const child = spawn('powershell.exe', args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
98
- let out = ''
99
- let err = ''
100
- child.stdout.on('data', (d) => { out += d })
101
- child.stderr.on('data', (d) => { err += d })
102
- const killer = setTimeout(() => { child.kill('SIGKILL'); reject(new Error(`PowerPoint 渲染超时(${timeoutMs}ms)`)) }, timeoutMs)
103
- child.on('exit', (code) => {
104
- clearTimeout(killer)
105
- code === 0 ? resolve(out) : reject(new Error(`PowerPoint COM 渲染失败(code ${code}):${(err || out).slice(0, 500)}`))
101
+ const stdout = await (async () => {
102
+ const once = () => new Promise((resolve, reject) => {
103
+ const child = spawn('powershell.exe', args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
104
+ let out = ''
105
+ let err = ''
106
+ child.stdout.on('data', (d) => { out += d })
107
+ child.stderr.on('data', (d) => { err += d })
108
+ const killer = setTimeout(() => { child.kill('SIGKILL'); reject(new Error(`PowerPoint 渲染超时(${timeoutMs}ms)`)) }, timeoutMs)
109
+ child.on('exit', (code) => {
110
+ clearTimeout(killer)
111
+ code === 0 ? resolve(out) : reject(new Error(`PowerPoint COM 渲染失败(code ${code}):${(err || out).slice(0, 500)}`))
112
+ })
113
+ child.on('error', (e) => { clearTimeout(killer); reject(e) })
106
114
  })
107
- child.on('error', (e) => { clearTimeout(killer); reject(e) })
108
- })
115
+ try {
116
+ return await once()
117
+ } catch (e) {
118
+ // 偶发的 COM "Presentations.Open : Failed"(残留实例竞争):清理已在上面的脚本里做过,
119
+ // 这里再给一次机会——渲染失败通常是瞬时的,重试成本远低于让用户看到假失败。
120
+ await new Promise((r) => setTimeout(r, 800))
121
+ try { return await once() } catch (e2) { throw e2 ?? e }
122
+ }
123
+ })()
109
124
  const m = stdout.match(/OK:(\d+)/)
110
125
  const files = []
111
126
  if (m) {
@@ -0,0 +1,52 @@
1
+ /**
2
+ * 阶段 D-②:组合作为**可复用块**(跨页 / 跨稿复用的最小机制)。
3
+ *
4
+ * 语义(docs/07 §4/§9.4):块 = 一组元素的**拷贝**(copy 语义,不做隐式联动——改一处不影响另一处)。
5
+ * 复用发生在 `resolveDeck` 物化阶段:把块的元素复制一份、按 `at` 平移、给 id 加前缀,
6
+ * 并**重映射块内部的引用**(`contains` / `attach.ref` / `badgeOf` / `groups.members`)。
7
+ * 物化后就是普通元素 ⇒ 预览 / 导出 / 校验**零改动**(与页面编译产物同源)。
8
+ *
9
+ * 块的坐标以**块自身坐标系**给出(通常从 0,0 起);`at` 是放在页面上的左上角。
10
+ */
11
+
12
+ /** 平移一个矩形(兼容 [x,y,w,h] 与 {x,y,w,h})。 */
13
+ function shiftRect(b, dx, dy) {
14
+ if (Array.isArray(b)) return [b[0] + dx, b[1] + dy, b[2], b[3]]
15
+ if (b && typeof b === 'object') return { ...b, x: b.x + dx, y: b.y + dy }
16
+ return b
17
+ }
18
+
19
+ /**
20
+ * 物化一个块。
21
+ * @param {{elements?: Array, groups?: Array}} block
22
+ * @param {{at?: [number, number], prefix: string}} opts
23
+ * @returns {{elements: Array, groups: Array, idMap: Map<string,string>}}
24
+ */
25
+ export function materializeBlock(block, { at = [0, 0], prefix }) {
26
+ const [dx, dy] = at
27
+ const els = block?.elements ?? []
28
+ const idMap = new Map()
29
+ for (const el of els) if (el?.elementId) idMap.set(String(el.elementId), `${prefix}${el.elementId}`)
30
+ const remap = (id) => idMap.get(String(id)) ?? String(id)
31
+ const elements = els.map((el) => {
32
+ const c = structuredClone(el)
33
+ c.elementId = remap(el.elementId)
34
+ if (c.bounds) c.bounds = shiftRect(c.bounds, dx, dy)
35
+ if (Array.isArray(c.points)) c.points = c.points.map(([x, y]) => [x + dx, y + dy])
36
+ if (Array.isArray(c.contains)) c.contains = c.contains.map(remap)
37
+ if (c.badgeOf !== undefined) c.badgeOf = remap(c.badgeOf)
38
+ if (c.attach) {
39
+ const a = { ...c.attach }
40
+ for (const k of ['from', 'to']) if (a[k]) a[k] = { ...a[k], ref: remap(a[k].ref) }
41
+ c.attach = a
42
+ }
43
+ return c
44
+ })
45
+ // 块自带的分组:id 加前缀、成员重映射(组 id 与元素 id 共用命名空间,必须都带前缀)
46
+ const groups = (block?.groups ?? []).map((g) => ({
47
+ id: `${prefix}${g.id}`,
48
+ ...(g.label !== undefined ? { label: g.label } : {}),
49
+ members: (g.members ?? []).map(remap),
50
+ }))
51
+ return { elements, groups, idMap }
52
+ }