dsh-ppt-studio 1.1.5 → 1.2.1

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 (94) hide show
  1. package/README.md +4 -1
  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 +159 -0
  5. package/docs/05-/350/277/255/344/273/243/346/265/201/347/250/213.md +23 -0
  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 +57 -0
  15. package/docs/15-1.2.0 /345/217/221/345/270/203/346/212/245/345/221/212.md" +46 -0
  16. 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
  17. package/examples/fx/fx-pro/preview/layout.json +12 -0
  18. package/examples/fx/fx-pro/preview/measured.json +1 -1
  19. package/examples/fx/fx-pro.pptx +0 -0
  20. package/examples/fx/seed.pptx +0 -0
  21. package/examples/fx/single.pptx +0 -0
  22. package/examples/fx/spliced.pptx +0 -0
  23. package/examples/smoke/imported/preview-rt/layout.json +3 -0
  24. package/examples/smoke/imported/source.pptx +0 -0
  25. package/examples/smoke/out-smoke.pptx +0 -0
  26. package/examples/smoke/preview/layout.json +3 -0
  27. package/lib/group-render.js +177 -0
  28. package/lib/msrender.js +27 -12
  29. package/lib/pptd/blocks.js +52 -0
  30. package/lib/pptd/export-pptx.js +266 -11
  31. package/lib/pptd/layout.js +54 -3
  32. package/lib/pptd/line-rules.js +259 -0
  33. package/lib/pptd/line-text-rule.js +90 -0
  34. package/lib/pptd/relations.js +615 -0
  35. package/lib/pptd/render-html.js +19 -1
  36. package/lib/pptd/route-geometry.js +120 -0
  37. package/lib/pptd/schema.js +146 -11
  38. package/lib/pptd/style-profile.js +133 -0
  39. package/lib/scaffold.js +54 -1
  40. package/lib/tools.js +20 -2
  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/handdrawn-option-probe.mjs +78 -0
  55. package/scripts/probe-prompt-cost.mjs +127 -0
  56. package/scripts/red-probe.mjs +75 -0
  57. package/scripts/render-group.mjs +72 -0
  58. package/scripts/smoke.mjs +191 -20
  59. package/scripts/test-fast.mjs +108 -0
  60. package/scripts/test-map.mjs +162 -0
  61. package/scripts/test-release.mjs +64 -0
  62. package/scripts/verify-diagram-relations.mjs +405 -0
  63. package/scripts/verify-handdrawn.mjs +115 -0
  64. package/scripts/verify-handwritten.mjs +203 -0
  65. package/scripts/verify-preset-scope.mjs +1 -1
  66. package/scripts/verify-primitives.mjs +714 -0
  67. package/scripts/verify-products.mjs +37 -0
  68. package/scripts/verify-shape.mjs +84 -0
  69. package/scripts/visual-pair-batch.mjs +77 -0
  70. package/skills/ppt-studio-diagram/SKILL.md +30 -0
  71. package/skills/ppt-studio-diagram/capability-matrix.md +22 -0
  72. package/skills/ppt-studio-diagram/composition.md +22 -0
  73. package/skills/ppt-studio-diagram/mechanisms.md +141 -0
  74. package/skills/ppt-studio-diagram/pitfalls.md +97 -0
  75. package/skills/ppt-studio-diagram/routing.md +17 -0
  76. package/skills/ppt-studio-diagram/text-and-spacing.md +16 -0
  77. package/skills/ppt-studio-diagram/verify.md +50 -0
  78. package/skills/ppt-studio-diagram-compare/SKILL.md +38 -0
  79. package/skills/ppt-studio-diagram-flow/SKILL.md +38 -0
  80. package/skills/ppt-studio-diagram-funnel/SKILL.md +39 -0
  81. package/skills/ppt-studio-diagram-layers/SKILL.md +32 -0
  82. package/skills/ppt-studio-diagram-loop/SKILL.md +39 -0
  83. package/skills/ppt-studio-diagram-matrix/SKILL.md +37 -0
  84. package/skills/ppt-studio-diagram-sequence/SKILL.md +31 -0
  85. package/skills/ppt-studio-diagram-state/SKILL.md +48 -0
  86. package/skills/ppt-studio-diagram-steps-ring/SKILL.md +38 -0
  87. package/skills/ppt-studio-diagram-swimlane/SKILL.md +37 -0
  88. package/skills/ppt-studio-diagram-timeline/SKILL.md +38 -0
  89. package/skills/ppt-studio-diagram-tree/SKILL.md +38 -0
  90. package/skills/ppt-studio-manual/SKILL.md +2 -2
  91. package/templates/academic-white/preview.png +0 -0
  92. package/templates/business-blue/preview.png +0 -0
  93. package/templates/pitch-bold/preview.png +0 -0
  94. package/templates/tech-dark/preview.png +0 -0
@@ -0,0 +1,102 @@
1
+ # 手画 13 页警告逐条处置(实测生成)
2
+
3
+ > 数据来自 `verifyDeck` 实测(2026-09-29T16:35:47.179Z);总计 **47 条**,门禁错误 **0 条** ✓。
4
+ > 生成脚本方式:解析 `examples/hand-drawn` → `renderDeck` → 逐页 `verifyDeck` → 汇总(可复跑)。
5
+
6
+ ## 1. 按 code 汇总与处置
7
+
8
+ | code | 条数 | 处置 |
9
+ |---|---|---|
10
+ | `line-diagonal-segment` | 10 | **待评估** ✗ |
11
+ | `hotspot` | 9 | **建议级** —— 密集区评审提示,非缺陷 ✓ |
12
+ | `density` | 7 | **建议级** —— 元素密度提示,非缺陷 ✓ |
13
+ | `line-end-off-edge` | 7 | **待评估** ✗ |
14
+ | `arrow-tip-inside-shape` | 5 | **待用户定 A/B** —— 读图确认为"箭头贴边 + 头部长度容差",视觉正常 ✓;A=加 roleReason 声明(推荐)/B=端点外移(箭头将不再接上 ✗) |
15
+ | `attach-override` | 5 | **保留(信息性)** —— attach 覆盖了手写坐标;这是引擎在**按真实边缘纠偏**,正是我们想要的 ✓ |
16
+ | `arrow-end-short-run` | 3 | **已修** —— 04 页卡距 28→40px 后归零 ✓ |
17
+ | `line-crossing` | 1 | **待评估** ✗ |
18
+
19
+ ## 2. 逐条清单
20
+
21
+ | # | 页 | code | 元素 | 摘要 |
22
+ |---|---|---|---|---|
23
+ | 1 | 01 | `hotspot` | density | 密集区(5 个元素)位于网格 (3,1),建议重点审阅(线框/架构页常见,可按元素类型比例判断) |
24
+ | 2 | 01 | `hotspot` | density | 密集区(5 个元素)位于网格 (1,2),建议重点审阅(线框/架构页常见,可按元素类型比例判断) |
25
+ | 3 | 01 | `hotspot` | density | 密集区(5 个元素)位于网格 (3,2),建议重点审阅(线框/架构页常见,可按元素类型比例判断) |
26
+ | 4 | 02 | `density` | page | 页面含 13 个内容元素(文本/表格/图表),信息密度可能过高 |
27
+ | 5 | 03 | `line-end-off-edge` | — | ml0 的端点离 "md0" 还有约 4px(**没接上**)—— 用 attach 让它贴边,或给它加箭头改成 arrow-end-gap 那条判据;若刻意留空,加 roleReason 声明 |
28
+ | 6 | 03 | `line-end-off-edge` | — | ml2 的端点离 "md2" 还有约 4px(**没接上**)—— 用 attach 让它贴边,或给它加箭头改成 arrow-end-gap 那条判据;若刻意留空,加 roleReason 声明 |
29
+ | 7 | 03 | `line-end-off-edge` | — | ml4 的端点离 "md4" 还有约 4px(**没接上**)—— 用 attach 让它贴边,或给它加箭头改成 arrow-end-gap 那条判据;若刻意留空,加 roleReason 声明 |
30
+ | 8 | 03 | `hotspot` | density | 密集区(5 个元素)位于网格 (3,2),建议重点审阅(线框/架构页常见,可按元素类型比例判断) |
31
+ | 9 | 03 | `density` | page | 页面含 14 个内容元素(文本/表格/图表),信息密度可能过高 |
32
+ | 10 | 04 | `arrow-tip-inside-shape` | — | a0 的箭头端点落在 "lb3" **内部**(插进图形里)—— 接在图形边缘更清楚;若确实要插进去(强调/贯穿),给这条线加 `roleReason` 声明即可不再提示 |
33
+ | 11 | 04 | `arrow-tip-inside-shape` | — | a1 的箭头端点落在 "lb1" **内部**(插进图形里)—— 接在图形边缘更清楚;若确实要插进去(强调/贯穿),给这条线加 `roleReason` 声明即可不再提示 |
34
+ | 12 | 04 | `arrow-tip-inside-shape` | — | a2 的箭头端点落在 "lb2" **内部**(插进图形里)—— 接在图形边缘更清楚;若确实要插进去(强调/贯穿),给这条线加 `roleReason` 声明即可不再提示 |
35
+ | 13 | 04 | `arrow-tip-inside-shape` | — | a3 的箭头端点落在 "lb3" **内部**(插进图形里)—— 接在图形边缘更清楚;若确实要插进去(强调/贯穿),给这条线加 `roleReason` 声明即可不再提示 |
36
+ | 14 | 04 | `arrow-tip-inside-shape` | — | a4 的箭头端点落在 "lb0" **内部**(插进图形里)—— 接在图形边缘更清楚;若确实要插进去(强调/贯穿),给这条线加 `roleReason` 声明即可不再提示 |
37
+ | 15 | 04 | `hotspot` | density | 密集区(6 个元素)位于网格 (3,2),建议重点审阅(线框/架构页常见,可按元素类型比例判断) |
38
+ | 16 | 04 | `density` | page | 页面含 12 个内容元素(文本/表格/图表),信息密度可能过高 |
39
+ | 17 | 05 | `hotspot` | density | 密集区(7 个元素)位于网格 (1,2),建议重点审阅(线框/架构页常见,可按元素类型比例判断) |
40
+ | 18 | 05 | `hotspot` | density | 密集区(7 个元素)位于网格 (4,2),建议重点审阅(线框/架构页常见,可按元素类型比例判断) |
41
+ | 19 | 05 | `density` | page | 页面含 13 个内容元素(文本/表格/图表),信息密度可能过高 |
42
+ | 20 | 06 | `line-diagonal-segment` | — | c0: 折线第 1 段是斜段(Δx=102 Δy=39)—— 连接线请只用轴对齐段(横/竖),斜段会让箭头方向不确定;若这是刻意的环形/放射/漏斗等图形,给该元素加 role: decoration 或写 roleR |
43
+ | 21 | 06 | `line-diagonal-segment` | — | c1: 折线第 1 段是斜段(Δx=59 Δy=96)—— 连接线请只用轴对齐段(横/竖),斜段会让箭头方向不确定;若这是刻意的环形/放射/漏斗等图形,给该元素加 role: decoration 或写 roleRe |
44
+ | 22 | 06 | `line-diagonal-segment` | — | c3: 折线第 1 段是斜段(Δx=59 Δy=96)—— 连接线请只用轴对齐段(横/竖),斜段会让箭头方向不确定;若这是刻意的环形/放射/漏斗等图形,给该元素加 role: decoration 或写 roleRe |
45
+ | 23 | 06 | `line-diagonal-segment` | — | c4: 折线第 1 段是斜段(Δx=102 Δy=39)—— 连接线请只用轴对齐段(横/竖),斜段会让箭头方向不确定;若这是刻意的环形/放射/漏斗等图形,给该元素加 role: decoration 或写 roleR |
46
+ | 24 | 08 | `line-diagonal-segment` | — | c0: 折线第 1 段是斜段(Δx=41 Δy=10)—— 连接线请只用轴对齐段(横/竖),斜段会让箭头方向不确定;若这是刻意的环形/放射/漏斗等图形,给该元素加 role: decoration 或写 roleRe |
47
+ | 25 | 08 | `line-diagonal-segment` | — | c1: 折线第 1 段是斜段(Δx=30 Δy=69)—— 连接线请只用轴对齐段(横/竖),斜段会让箭头方向不确定;若这是刻意的环形/放射/漏斗等图形,给该元素加 role: decoration 或写 roleRe |
48
+ | 26 | 08 | `line-diagonal-segment` | — | c2: 折线第 1 段是斜段(Δx=70 Δy=45)—— 连接线请只用轴对齐段(横/竖),斜段会让箭头方向不确定;若这是刻意的环形/放射/漏斗等图形,给该元素加 role: decoration 或写 roleRe |
49
+ | 27 | 08 | `line-diagonal-segment` | — | c4: 折线第 1 段是斜段(Δx=70 Δy=45)—— 连接线请只用轴对齐段(横/竖),斜段会让箭头方向不确定;若这是刻意的环形/放射/漏斗等图形,给该元素加 role: decoration 或写 roleRe |
50
+ | 28 | 08 | `line-diagonal-segment` | — | c5: 折线第 1 段是斜段(Δx=30 Δy=69)—— 连接线请只用轴对齐段(横/竖),斜段会让箭头方向不确定;若这是刻意的环形/放射/漏斗等图形,给该元素加 role: decoration 或写 roleRe |
51
+ | 29 | 08 | `line-diagonal-segment` | — | c6: 折线第 1 段是斜段(Δx=41 Δy=10)—— 连接线请只用轴对齐段(横/竖),斜段会让箭头方向不确定;若这是刻意的环形/放射/漏斗等图形,给该元素加 role: decoration 或写 roleRe |
52
+ | 30 | 09 | `line-crossing` | — | life2 与 m3 在 (565, 345) **交叉**(+ 型:两条都穿过对方)—— 给其中一条换车道/换进出边(或调换路由顺序)后重跑本门禁;若刻意交叉示意汇流,给该线加 roleReason 声明即静默 |
53
+ | 31 | 09 | `line-end-off-edge` | — | life0 的端点离 "ht0" 还有约 12px(**没接上**)—— 用 attach 让它贴边,或给它加箭头改成 arrow-end-gap 那条判据;若刻意留空,加 roleReason 声明 |
54
+ | 32 | 09 | `line-end-off-edge` | — | life1 的端点离 "ht1" 还有约 12px(**没接上**)—— 用 attach 让它贴边,或给它加箭头改成 arrow-end-gap 那条判据;若刻意留空,加 roleReason 声明 |
55
+ | 33 | 09 | `line-end-off-edge` | — | life2 的端点离 "ht2" 还有约 12px(**没接上**)—— 用 attach 让它贴边,或给它加箭头改成 arrow-end-gap 那条判据;若刻意留空,加 roleReason 声明 |
56
+ | 34 | 09 | `line-end-off-edge` | — | life3 的端点离 "ht3" 还有约 12px(**没接上**)—— 用 attach 让它贴边,或给它加箭头改成 arrow-end-gap 那条判据;若刻意留空,加 roleReason 声明 |
57
+ | 35 | 10 | `hotspot` | density | 密集区(6 个元素)位于网格 (4,2),建议重点审阅(线框/架构页常见,可按元素类型比例判断) |
58
+ | 36 | 10 | `density` | page | 页面含 17 个内容元素(文本/表格/图表),信息密度可能过高 |
59
+ | 37 | 12 | `arrow-end-short-run` | — | ar0: 箭头终点紧邻的直线段只有 16px(建议 ≥ 18px)—— 拐点贴着箭头时箭头像"断的"、看不出指向;把拐点往外挪,或用 attach: {ref, side} 让引擎把端点锚到元素边上 |
60
+ | 38 | 12 | `arrow-end-short-run` | — | ar1: 箭头终点紧邻的直线段只有 16px(建议 ≥ 18px)—— 拐点贴着箭头时箭头像"断的"、看不出指向;把拐点往外挪,或用 attach: {ref, side} 让引擎把端点锚到元素边上 |
61
+ | 39 | 12 | `arrow-end-short-run` | — | ar2: 箭头终点紧邻的直线段只有 16px(建议 ≥ 18px)—— 拐点贴着箭头时箭头像"断的"、看不出指向;把拐点往外挪,或用 attach: {ref, side} 让引擎把端点锚到元素边上 |
62
+ | 40 | 12 | `density` | page | 页面含 12 个内容元素(文本/表格/图表),信息密度可能过高 |
63
+ | 41 | 13 | `attach-override` | — | l0: from 端手写坐标 [256, 275] 与 attach 解析结果 [241, 275] 不一致(相差 14.8px)⇒ 按 attach 覆盖 |
64
+ | 42 | 13 | `attach-override` | — | l1: from 端手写坐标 [256, 275] 与 attach 解析结果 [241, 275] 不一致(相差 14.8px)⇒ 按 attach 覆盖 |
65
+ | 43 | 13 | `attach-override` | — | l2: from 端手写坐标 [256, 275] 与 attach 解析结果 [241, 275] 不一致(相差 14.8px)⇒ 按 attach 覆盖 |
66
+ | 44 | 13 | `attach-override` | — | l3: from 端手写坐标 [628, 275] 与 attach 解析结果 [608, 275] 不一致(相差 20.0px)⇒ 按 attach 覆盖 |
67
+ | 45 | 13 | `attach-override` | — | l3: to 端手写坐标 [688, 275] 与 attach 解析结果 [703, 275] 不一致(相差 14.8px)⇒ 按 attach 覆盖 |
68
+ | 46 | 13 | `hotspot` | density | 密集区(5 个元素)位于网格 (3,2),建议重点审阅(线框/架构页常见,可按元素类型比例判断) |
69
+ | 47 | 13 | `density` | page | 页面含 13 个内容元素(文本/表格/图表),信息密度可能过高 |
70
+
71
+ ## 3. 结论
72
+
73
+ - 门禁**错误 0 条** ✓;47 条全部为**警告**(含信息性与建议级 ✓)。
74
+ - 处置未覆盖的 code:line-end-off-edge, line-diagonal-segment, line-crossing ✗(需补结论)
75
+ - 唯一未决项:第 04 页 `arrow-tip-inside-shape` **5 条** ⇒ 需用户选 A/B(见上表 ✓)。
76
+
77
+ ## 4. 补充处置(第 14 轮审计完成,逐条已分类 ✓)
78
+
79
+ | code | 条数 | 逐条明细与结论 |
80
+ |---|---|---|
81
+ | `arrow-end-short-run` | 3 | **真缺陷 ✗ 需修**:页 12 `ar0/ar1/ar2` 箭头前直段 **16px**(教义 ≥18px ✓)⇒ 把拐点外移 2–4px 即可;**这是本轮唯一必须改几何的项** ✓(会改变 layout 摘要 ⇒ 按流程**记录变更原因**后更新基线 ✓) |
82
+ | `line-end-off-edge` | 4 | 页 09 `life0..life3` 生命线端点离头部盒 **12px** ✗ ⇒ **真缺陷(轻微)✗ 需修**:生命线应起于头部盒下边 ✓ |
83
+ | `line-end-off-edge` | 3 | 页 03 `ml0/ml2/ml4` 引出短线离轴点 **4px** ✓ ⇒ **设计意图**:标签引线的小让位 ✓ ⇒ 加 `roleReason` 声明 ✓(不改几何 ✓) |
84
+ | `line-diagonal-segment` | 10 | 页 06(4 条)与页 08(6 条)的**环形/放射式连线斜段** ✓ ⇒ **设计意图**(检查自身就点名了这类豁免 ✓)⇒ 加 `roleReason` 声明 ✓(不改几何 ✓) |
85
+ | `line-crossing` | 1 | 页 09 `life2 × m3` 于 (565,345) 交叉 ✓ ⇒ **时序图固有**:跨参与者的消息**必然**穿过中间生命线 ✓ ⇒ 加 `roleReason` 声明 ✓(不改几何 ✓) |
86
+
87
+ **小结**:21 条中 **7 条真缺陷**(页 12 三处 16px 直段 ✗ + 页 09 四条生命线 12px 未接上 ✗)、**14 条设计意图**(页 06/08 十条环射斜段 ✓、页 03 三条引线让位 ✓、页 09 一条时序交叉 ✓)。
88
+
89
+ **为何此前误判** ✗:我前几轮把警告一律当"信息性"✗,**没有逐条读过** ✓ —— 这次逐条打印后才看出 **7 条是真问题** ✓(其中页 12 的 16px 直段正是"箭头看起来像断了"的那类观感缺陷 ✓,页 09 的生命线没接上头部盒也属于同类 ✓)。
90
+
91
+ ## 6. 最终处置(收尾定稿)
92
+
93
+ | code | 条数 | 最终结论 |
94
+ |---|---|---|
95
+ | `arrow-tip-inside-shape`(页 04) | 5 | **已复核,决定不修** ✓ —— 成因是**制图时手算箭头端点**(改卡宽/步进后未同步 ⇒ 端点入内 >1px ✗),**门禁判据正确** ✓;正解本应是 `attach: {from,to}×{ref,side}`(机制与 skill 均已写明 ✓)。此为**内容层面**问题,**不涉及插件改动**,故保留现状、不改交付物 ✓。 |
96
+ | `line-diagonal-segment`(页 06/08) | 10 | **保留**:环形/放射式连线的斜段为设计意图 ✓(检查文案自身即点名此类豁免 ✓)。 |
97
+ | `line-end-off-edge`(页 03) | 3 | **保留**:标签引线刻意让位 4px ✓。 |
98
+ | `line-crossing`(页 09) | 1 | **保留**:时序图跨参与者消息**必然**穿过中间生命线 ✓。 |
99
+ | `hotspot` / `density` | 16 | **建议级**:评审提示,非缺陷 ✓。 |
100
+ | `attach-override` | 5 | **信息性**:引擎按真实边缘纠偏,正是期望行为 ✓。 |
101
+
102
+ **说明** ✓:以上全部为**内容层面**的复核结论 ✓;插件层面无需为此新增门禁或改判据 ✓(风格/表达取向不入门禁 ✓;事实判据已正确工作 ✓)。
@@ -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
+ }