minimal-agent 0.8.2 → 0.10.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 (484) hide show
  1. package/README.md +14 -12
  2. package/package.json +34 -1
  3. package/plugins/cron-runner/.claude-plugin/plugin.json +5 -0
  4. package/plugins/cron-runner/commands/cron.md +26 -0
  5. package/plugins/cron-runner/commands/crons.md +7 -0
  6. package/plugins/cron-runner/plugin.js +34 -0
  7. package/plugins/cron-runner/src/commands.js +348 -0
  8. package/plugins/cron-runner/src/cronTime.js +148 -0
  9. package/plugins/cron-runner/src/lock.js +68 -0
  10. package/plugins/cron-runner/src/runs.js +89 -0
  11. package/plugins/cron-runner/src/scheduleEngine.js +72 -0
  12. package/plugins/cron-runner/src/scheduler.js +355 -0
  13. package/plugins/cron-runner/src/schema.js +140 -0
  14. package/plugins/cron-runner/src/service.js +167 -0
  15. package/plugins/cron-runner/src/store.js +298 -0
  16. package/plugins/cron-runner/src/types.js +1 -0
  17. package/plugins/workflow-runner/commands/workflow.md +4 -4
  18. package/plugins/workflow-runner/commands/workflows.md +1 -1
  19. package/plugins/workflow-runner/plugin.js +3 -1
  20. package/plugins/workflow-runner/src/agentCommon.js +249 -51
  21. package/plugins/workflow-runner/src/dslManifest.js +72 -0
  22. package/plugins/workflow-runner/src/expressions.js +198 -15
  23. package/plugins/workflow-runner/src/index.js +40 -8
  24. package/plugins/workflow-runner/src/jsonPayload.js +1 -1
  25. package/plugins/workflow-runner/src/loader.js +286 -72
  26. package/plugins/workflow-runner/src/multimodal.js +37 -0
  27. package/plugins/workflow-runner/src/recovery.js +49 -39
  28. package/plugins/workflow-runner/src/runner.js +802 -287
  29. package/plugins/workflow-runner/src/stepExecutors/agent.js +157 -39
  30. package/plugins/workflow-runner/src/stepExecutors/assert.js +16 -3
  31. package/plugins/workflow-runner/src/stepExecutors/llm.js +60 -111
  32. package/plugins/workflow-runner/src/stepExecutors/skill.js +47 -47
  33. package/plugins/workflow-runner/src/stepExecutors/tool.js +76 -10
  34. package/plugins/workflow-runner/src/stepExecutors/verify.js +27 -10
  35. package/plugins/workflow-runner/src/taskIdentity.js +296 -0
  36. package/plugins/workflow-runner/src/types.js +19 -9
  37. package/plugins/workflow-runner/src/workflowState.js +516 -33
  38. package/skills/adversarial-review/SKILL.md +0 -1
  39. package/skills/ai-image/SKILL.md +89 -0
  40. package/skills/ai-image/scripts/generate.mjs +497 -0
  41. package/skills/ai-image/scripts/generate_from_plan.mjs +469 -0
  42. package/skills/ai-image/scripts/generate_from_plan_test.mjs +223 -0
  43. package/skills/ai-image/scripts/generate_test.mjs +74 -0
  44. package/skills/browser-automation/SKILL.md +47 -0
  45. package/skills/docx/SKILL.md +25 -3
  46. package/skills/docx/scripts/office/helpers/__pycache__/__init__.cpython-314.pyc +0 -0
  47. package/skills/docx/scripts/office/helpers/__pycache__/merge_runs.cpython-314.pyc +0 -0
  48. package/skills/docx/scripts/office/helpers/__pycache__/simplify_redlines.cpython-314.pyc +0 -0
  49. package/skills/docx/scripts/office/validators/__pycache__/__init__.cpython-314.pyc +0 -0
  50. package/skills/docx/scripts/office/validators/__pycache__/base.cpython-314.pyc +0 -0
  51. package/skills/docx/scripts/office/validators/__pycache__/docx.cpython-314.pyc +0 -0
  52. package/skills/docx/scripts/office/validators/__pycache__/pptx.cpython-314.pyc +0 -0
  53. package/skills/docx/scripts/office/validators/__pycache__/redlining.cpython-314.pyc +0 -0
  54. package/skills/docx/scripts/opc_check.py +253 -0
  55. package/skills/image-gen-openrouter/SKILL.md +10 -2
  56. package/skills/image-stats/SKILL.md +65 -0
  57. package/skills/image-stats/scripts/__pycache__/image_stats.cpython-314.pyc +0 -0
  58. package/skills/image-stats/scripts/__pycache__/image_stats_test.cpython-314.pyc +0 -0
  59. package/skills/image-stats/scripts/image_stats.py +368 -0
  60. package/skills/image-stats/scripts/image_stats_test.py +181 -0
  61. package/skills/pptx/SKILL.md +123 -95
  62. package/skills/pptx/design.md +82 -0
  63. package/skills/pptx/examples/README.md +29 -0
  64. package/skills/pptx/examples/hybrid_example.mjs +107 -0
  65. package/skills/pptx/examples/hybrid_example.pptx +0 -0
  66. package/skills/pptx/pptxgenjs.md +41 -0
  67. package/skills/pptx/scripts/deck_helpers.mjs +224 -0
  68. package/skills/pptx/scripts/layout_lint.py +293 -0
  69. package/skills/pptx/scripts/layout_lint_test.py +160 -0
  70. package/skills/pptx/scripts/opc_check.py +339 -0
  71. package/skills/pptx/scripts/svg_fallback.mjs +145 -0
  72. package/skills/pptx/svg.md +67 -0
  73. package/skills/tts-zh/SKILL.md +19 -2
  74. package/skills/ui-design/SKILL.md +158 -0
  75. package/skills/ui-design/claude_design.md +309 -0
  76. package/skills/ui-design/evals/evals.json +254 -0
  77. package/skills/ui-design/references/aesthetic-direction.md +83 -0
  78. package/skills/ui-design/references/claude-design-protocol.md +36 -0
  79. package/skills/ui-design/references/design-context.md +53 -0
  80. package/skills/ui-design/references/design-execution-playbook.md +82 -0
  81. package/skills/ui-design/references/export-pptx.md +124 -0
  82. package/skills/ui-design/references/layout-composition.md +654 -0
  83. package/skills/ui-design/references/preview-feedback.md +37 -0
  84. package/skills/ui-design/references/source-image-layout.md +189 -0
  85. package/skills/ui-design/references/starter-components.md +78 -0
  86. package/skills/ui-design/references/vi-aegis-vector.md +144 -0
  87. package/skills/ui-design/references/vi-amber-prism.md +133 -0
  88. package/skills/ui-design/references/vi-aqua-glass.md +200 -0
  89. package/skills/ui-design/references/vi-comfort-vision.md +147 -0
  90. package/skills/ui-design/references/vi-ember-prism.md +140 -0
  91. package/skills/ui-design/references/vi-nebula-console.md +151 -0
  92. package/skills/ui-design/references/vi-obsidian-optics.md +139 -0
  93. package/skills/ui-design/references/vi-obsidian-xui.md +143 -0
  94. package/skills/ui-design/references/vi-precision-horizon-dark.md +117 -0
  95. package/skills/ui-design/references/vi-precision-horizon.md +215 -0
  96. package/skills/ui-design/references/vi-solar-relay.md +130 -0
  97. package/skills/ui-design/references/vi-template-workflow.md +48 -0
  98. package/skills/ui-design/references/vi-verdant-grid.md +141 -0
  99. package/skills/ui-design/references/vi-violet-meridian.md +172 -0
  100. package/skills/ui-design/scripts/__pycache__/assemble_material.cpython-314.pyc +0 -0
  101. package/skills/ui-design/scripts/__pycache__/assemble_material_test.cpython-314.pyc +0 -0
  102. package/skills/ui-design/scripts/__pycache__/chart_sanity_check.cpython-314.pyc +0 -0
  103. package/skills/ui-design/scripts/__pycache__/check_language_policy.cpython-314.pyc +0 -0
  104. package/skills/ui-design/scripts/__pycache__/check_ui_artifact.cpython-314.pyc +0 -0
  105. package/skills/ui-design/scripts/__pycache__/check_ui_artifact_pptx_test.cpython-314.pyc +0 -0
  106. package/skills/ui-design/scripts/__pycache__/deck_pages_check.cpython-314.pyc +0 -0
  107. package/skills/ui-design/scripts/__pycache__/deck_pages_check_test.cpython-314.pyc +0 -0
  108. package/skills/ui-design/scripts/__pycache__/deck_visual_style_check.cpython-314.pyc +0 -0
  109. package/skills/ui-design/scripts/__pycache__/deck_visual_style_check_test.cpython-314.pyc +0 -0
  110. package/skills/ui-design/scripts/__pycache__/design_budget_check.cpython-314.pyc +0 -0
  111. package/skills/ui-design/scripts/__pycache__/design_budget_check_test.cpython-314.pyc +0 -0
  112. package/skills/ui-design/scripts/__pycache__/design_repair_hints.cpython-314.pyc +0 -0
  113. package/skills/ui-design/scripts/__pycache__/design_repair_hints_test.cpython-314.pyc +0 -0
  114. package/skills/ui-design/scripts/__pycache__/design_style_check.cpython-314.pyc +0 -0
  115. package/skills/ui-design/scripts/__pycache__/design_style_check_test.cpython-314.pyc +0 -0
  116. package/skills/ui-design/scripts/__pycache__/draft_coverage_check.cpython-314.pyc +0 -0
  117. package/skills/ui-design/scripts/__pycache__/extract_source_assets.cpython-314.pyc +0 -0
  118. package/skills/ui-design/scripts/__pycache__/extract_source_assets_test.cpython-314.pyc +0 -0
  119. package/skills/ui-design/scripts/__pycache__/generated_image_review_collect.cpython-314.pyc +0 -0
  120. package/skills/ui-design/scripts/__pycache__/generated_image_review_collect_test.cpython-314.pyc +0 -0
  121. package/skills/ui-design/scripts/__pycache__/image_plan_lint.cpython-314.pyc +0 -0
  122. package/skills/ui-design/scripts/__pycache__/image_plan_lint_test.cpython-314.pyc +0 -0
  123. package/skills/ui-design/scripts/__pycache__/image_prompt_rewrite.cpython-314.pyc +0 -0
  124. package/skills/ui-design/scripts/__pycache__/image_prompt_rewrite_test.cpython-314.pyc +0 -0
  125. package/skills/ui-design/scripts/__pycache__/layout_balance.cpython-314.pyc +0 -0
  126. package/skills/ui-design/scripts/__pycache__/layout_balance_test.cpython-314.pyc +0 -0
  127. package/skills/ui-design/scripts/__pycache__/normalize_design_json.cpython-314.pyc +0 -0
  128. package/skills/ui-design/scripts/__pycache__/normalize_design_json_test.cpython-314.pyc +0 -0
  129. package/skills/ui-design/scripts/__pycache__/plan_source_layout.cpython-314.pyc +0 -0
  130. package/skills/ui-design/scripts/__pycache__/plan_source_layout_test.cpython-314.pyc +0 -0
  131. package/skills/ui-design/scripts/__pycache__/resolve_source_hint.cpython-314.pyc +0 -0
  132. package/skills/ui-design/scripts/__pycache__/resolve_source_hint_test.cpython-314.pyc +0 -0
  133. package/skills/ui-design/scripts/__pycache__/resolve_source_input.cpython-314.pyc +0 -0
  134. package/skills/ui-design/scripts/__pycache__/resolve_source_input_test.cpython-314.pyc +0 -0
  135. package/skills/ui-design/scripts/__pycache__/scaffold_ui_artifact.cpython-314.pyc +0 -0
  136. package/skills/ui-design/scripts/__pycache__/source_usage_check.cpython-314.pyc +0 -0
  137. package/skills/ui-design/scripts/__pycache__/source_usage_check_test.cpython-314.pyc +0 -0
  138. package/skills/ui-design/scripts/__pycache__/text_readability_pixel.cpython-314.pyc +0 -0
  139. package/skills/ui-design/scripts/__pycache__/visual_quality_gate.cpython-314.pyc +0 -0
  140. package/skills/ui-design/scripts/__pycache__/visual_quality_gate_test.cpython-314.pyc +0 -0
  141. package/skills/ui-design/scripts/assemble_material.py +217 -0
  142. package/skills/ui-design/scripts/assemble_material_test.py +149 -0
  143. package/skills/ui-design/scripts/browser_check_ui_artifact.mjs +221 -0
  144. package/skills/ui-design/scripts/chart_sanity_check.py +261 -0
  145. package/skills/ui-design/scripts/chart_sanity_check_test.py +165 -0
  146. package/skills/ui-design/scripts/check_language_policy.py +351 -0
  147. package/skills/ui-design/scripts/check_ui_artifact.py +435 -0
  148. package/skills/ui-design/scripts/check_ui_artifact_pptx_test.py +251 -0
  149. package/skills/ui-design/scripts/deck_pages_check.py +158 -0
  150. package/skills/ui-design/scripts/deck_pages_check_test.py +134 -0
  151. package/skills/ui-design/scripts/deck_visual_style_check.py +767 -0
  152. package/skills/ui-design/scripts/deck_visual_style_check_test.py +713 -0
  153. package/skills/ui-design/scripts/design_budget_check.py +271 -0
  154. package/skills/ui-design/scripts/design_budget_check_test.py +183 -0
  155. package/skills/ui-design/scripts/design_repair_hints.py +162 -0
  156. package/skills/ui-design/scripts/design_repair_hints_test.py +54 -0
  157. package/skills/ui-design/scripts/design_style_check.py +397 -0
  158. package/skills/ui-design/scripts/design_style_check_test.py +233 -0
  159. package/skills/ui-design/scripts/draft_coverage_check.py +184 -0
  160. package/skills/ui-design/scripts/draft_coverage_check_test.py +131 -0
  161. package/skills/ui-design/scripts/export_pptx_from_html.mjs +2367 -0
  162. package/skills/ui-design/scripts/extract_source_assets.py +607 -0
  163. package/skills/ui-design/scripts/extract_source_assets_test.py +256 -0
  164. package/skills/ui-design/scripts/generated_image_review_collect.py +206 -0
  165. package/skills/ui-design/scripts/generated_image_review_collect_test.py +91 -0
  166. package/skills/ui-design/scripts/image_plan_lint.py +571 -0
  167. package/skills/ui-design/scripts/image_plan_lint_test.py +515 -0
  168. package/skills/ui-design/scripts/image_prompt_rewrite.py +713 -0
  169. package/skills/ui-design/scripts/image_prompt_rewrite_test.py +966 -0
  170. package/skills/ui-design/scripts/layout_balance.py +881 -0
  171. package/skills/ui-design/scripts/layout_balance_test.py +927 -0
  172. package/skills/ui-design/scripts/normalize_design_json.py +272 -0
  173. package/skills/ui-design/scripts/normalize_design_json_test.py +119 -0
  174. package/skills/ui-design/scripts/plan_source_layout.py +1450 -0
  175. package/skills/ui-design/scripts/plan_source_layout_test.py +1136 -0
  176. package/skills/ui-design/scripts/resolve_source_input.py +270 -0
  177. package/skills/ui-design/scripts/resolve_source_input_test.py +98 -0
  178. package/skills/ui-design/scripts/scaffold_ui_artifact.py +377 -0
  179. package/skills/ui-design/scripts/source_usage_check.py +581 -0
  180. package/skills/ui-design/scripts/source_usage_check_test.py +427 -0
  181. package/skills/ui-design/scripts/text_readability_pixel.py +340 -0
  182. package/skills/ui-design/scripts/text_readability_pixel_test.py +219 -0
  183. package/skills/ui-design/scripts/visual_quality_gate.py +301 -0
  184. package/skills/ui-design/scripts/visual_quality_gate_test.py +233 -0
  185. package/skills/ui-design/tests/e2e/prompts/deck.md +19 -0
  186. package/skills/ui-design/tests/e2e/prompts/webpage.md +17 -0
  187. package/skills/ui-design/tests/e2e/render_check.mjs +101 -0
  188. package/skills/ui-design/tests/e2e/rubric.json +54 -0
  189. package/skills/ui-design/tests/e2e/runs/aegis-vector-preview-1/checks/browser-check.json +49 -0
  190. package/skills/ui-design/tests/e2e/runs/aegis-vector-preview-1/checks/language-check.txt +12 -0
  191. package/skills/ui-design/tests/e2e/runs/aegis-vector-preview-1/checks/static-check.txt +15 -0
  192. package/skills/ui-design/tests/e2e/runs/aegis-vector-preview-1/outputs/index.html +837 -0
  193. package/skills/ui-design/tests/e2e/runs/aegis-vector-preview-1/prompt.md +12 -0
  194. package/skills/ui-design/tests/e2e/runs/aegis-vector-preview-1/review.md +21 -0
  195. package/skills/ui-design/tests/e2e/runs/aegis-vector-preview-1/screenshots/desktop.png +0 -0
  196. package/skills/ui-design/tests/e2e/runs/aegis-vector-preview-1/screenshots/mobile.png +0 -0
  197. package/skills/ui-design/tests/e2e/runs/amber-prism-screenshot-check-1/outputs/homepage.html +373 -0
  198. package/skills/ui-design/tests/e2e/runs/amber-prism-screenshot-check-1/outputs/platform.html +378 -0
  199. package/skills/ui-design/tests/e2e/runs/amber-prism-screenshot-check-1/prompt.md +13 -0
  200. package/skills/ui-design/tests/e2e/runs/amber-prism-screenshot-check-1/review.md +26 -0
  201. package/skills/ui-design/tests/e2e/runs/amber-prism-screenshot-check-1/screenshots/homepage/browser-check.json +49 -0
  202. package/skills/ui-design/tests/e2e/runs/amber-prism-screenshot-check-1/screenshots/homepage/desktop.png +0 -0
  203. package/skills/ui-design/tests/e2e/runs/amber-prism-screenshot-check-1/screenshots/homepage/mobile.png +0 -0
  204. package/skills/ui-design/tests/e2e/runs/amber-prism-screenshot-check-1/screenshots/platform/browser-check.json +49 -0
  205. package/skills/ui-design/tests/e2e/runs/amber-prism-screenshot-check-1/screenshots/platform/desktop.png +0 -0
  206. package/skills/ui-design/tests/e2e/runs/amber-prism-screenshot-check-1/screenshots/platform/mobile.png +0 -0
  207. package/skills/ui-design/tests/e2e/runs/aqua-glass-iteration-1/checks/static-check.txt +2 -0
  208. package/skills/ui-design/tests/e2e/runs/aqua-glass-iteration-1/outputs/index.html +656 -0
  209. package/skills/ui-design/tests/e2e/runs/aqua-glass-iteration-1/prompt.md +12 -0
  210. package/skills/ui-design/tests/e2e/runs/aqua-glass-iteration-1/render_check.mjs +67 -0
  211. package/skills/ui-design/tests/e2e/runs/aqua-glass-iteration-2/checks/static-check.txt +2 -0
  212. package/skills/ui-design/tests/e2e/runs/aqua-glass-iteration-2/outputs/index.html +587 -0
  213. package/skills/ui-design/tests/e2e/runs/aqua-glass-iteration-2/prompt.md +12 -0
  214. package/skills/ui-design/tests/e2e/runs/aqua-glass-iteration-2/review.md +36 -0
  215. package/skills/ui-design/tests/e2e/runs/aqua-glass-volcano-refresh-1/checks/browser-check.json +49 -0
  216. package/skills/ui-design/tests/e2e/runs/aqua-glass-volcano-refresh-1/checks/language-check.txt +8 -0
  217. package/skills/ui-design/tests/e2e/runs/aqua-glass-volcano-refresh-1/checks/static-check.txt +16 -0
  218. package/skills/ui-design/tests/e2e/runs/aqua-glass-volcano-refresh-1/outputs/index.html +739 -0
  219. package/skills/ui-design/tests/e2e/runs/aqua-glass-volcano-refresh-1/prompt.md +11 -0
  220. package/skills/ui-design/tests/e2e/runs/aqua-glass-volcano-refresh-1/review.md +22 -0
  221. package/skills/ui-design/tests/e2e/runs/aqua-glass-volcano-refresh-1/screenshots/desktop.png +0 -0
  222. package/skills/ui-design/tests/e2e/runs/aqua-glass-volcano-refresh-1/screenshots/mobile.png +0 -0
  223. package/skills/ui-design/tests/e2e/runs/comfort-vision-preview-1/checks/browser-check.json +52 -0
  224. package/skills/ui-design/tests/e2e/runs/comfort-vision-preview-1/outputs/index.html +641 -0
  225. package/skills/ui-design/tests/e2e/runs/comfort-vision-preview-1/screenshots/desktop.png +0 -0
  226. package/skills/ui-design/tests/e2e/runs/comfort-vision-preview-1/screenshots/mobile.png +0 -0
  227. package/skills/ui-design/tests/e2e/runs/ember-prism-red-preview-1/outputs/index.html +467 -0
  228. package/skills/ui-design/tests/e2e/runs/ember-prism-red-preview-1/screenshots/browser-check.json +49 -0
  229. package/skills/ui-design/tests/e2e/runs/ember-prism-red-preview-1/screenshots/desktop.png +0 -0
  230. package/skills/ui-design/tests/e2e/runs/ember-prism-red-preview-1/screenshots/mobile.png +0 -0
  231. package/skills/ui-design/tests/e2e/runs/ember-prism-screenshot-check-1/outputs/homepage.html +373 -0
  232. package/skills/ui-design/tests/e2e/runs/ember-prism-screenshot-check-1/outputs/platform.html +378 -0
  233. package/skills/ui-design/tests/e2e/runs/ember-prism-screenshot-check-1/prompt.md +13 -0
  234. package/skills/ui-design/tests/e2e/runs/ember-prism-screenshot-check-1/review.md +26 -0
  235. package/skills/ui-design/tests/e2e/runs/ember-prism-screenshot-check-1/screenshots/homepage/browser-check.json +49 -0
  236. package/skills/ui-design/tests/e2e/runs/ember-prism-screenshot-check-1/screenshots/homepage/desktop.png +0 -0
  237. package/skills/ui-design/tests/e2e/runs/ember-prism-screenshot-check-1/screenshots/homepage/mobile.png +0 -0
  238. package/skills/ui-design/tests/e2e/runs/ember-prism-screenshot-check-1/screenshots/platform/browser-check.json +49 -0
  239. package/skills/ui-design/tests/e2e/runs/ember-prism-screenshot-check-1/screenshots/platform/desktop.png +0 -0
  240. package/skills/ui-design/tests/e2e/runs/ember-prism-screenshot-check-1/screenshots/platform/mobile.png +0 -0
  241. package/skills/ui-design/tests/e2e/runs/empty-agent-protocol-check/checks/browser-check.json +49 -0
  242. package/skills/ui-design/tests/e2e/runs/empty-agent-protocol-check/checks/language-check.txt +4 -0
  243. package/skills/ui-design/tests/e2e/runs/empty-agent-protocol-check/checks/static-check.txt +2 -0
  244. package/skills/ui-design/tests/e2e/runs/empty-agent-protocol-check/execution_notes.md +10 -0
  245. package/skills/ui-design/tests/e2e/runs/empty-agent-protocol-check/outputs/index.html +587 -0
  246. package/skills/ui-design/tests/e2e/runs/empty-agent-protocol-check/prompt.md +14 -0
  247. package/skills/ui-design/tests/e2e/runs/empty-agent-protocol-check/review.md +13 -0
  248. package/skills/ui-design/tests/e2e/runs/empty-agent-protocol-check/screenshots/desktop.png +0 -0
  249. package/skills/ui-design/tests/e2e/runs/empty-agent-protocol-check/screenshots/mobile.png +0 -0
  250. package/skills/ui-design/tests/e2e/runs/iteration-1/deck/checks/browser-check.json +24 -0
  251. package/skills/ui-design/tests/e2e/runs/iteration-1/deck/checks/static-check.txt +2 -0
  252. package/skills/ui-design/tests/e2e/runs/iteration-1/deck/execution_notes.md +21 -0
  253. package/skills/ui-design/tests/e2e/runs/iteration-1/deck/outputs/deck.html +451 -0
  254. package/skills/ui-design/tests/e2e/runs/iteration-1/deck/screenshots/slide-01.png +0 -0
  255. package/skills/ui-design/tests/e2e/runs/iteration-1/deck/screenshots/slide-02.png +0 -0
  256. package/skills/ui-design/tests/e2e/runs/iteration-1/review.md +79 -0
  257. package/skills/ui-design/tests/e2e/runs/iteration-1/webpage/checks/browser-check.json +26 -0
  258. package/skills/ui-design/tests/e2e/runs/iteration-1/webpage/checks/static-check.txt +2 -0
  259. package/skills/ui-design/tests/e2e/runs/iteration-1/webpage/execution_notes.md +20 -0
  260. package/skills/ui-design/tests/e2e/runs/iteration-1/webpage/outputs/index.html +534 -0
  261. package/skills/ui-design/tests/e2e/runs/iteration-1/webpage/screenshots/desktop.png +0 -0
  262. package/skills/ui-design/tests/e2e/runs/iteration-1/webpage/screenshots/mobile.png +0 -0
  263. package/skills/ui-design/tests/e2e/runs/nebula-console-iteration-1/checks/browser-check.json +64 -0
  264. package/skills/ui-design/tests/e2e/runs/nebula-console-iteration-1/checks/language-check.txt +7 -0
  265. package/skills/ui-design/tests/e2e/runs/nebula-console-iteration-1/checks/quick-validate.txt +1 -0
  266. package/skills/ui-design/tests/e2e/runs/nebula-console-iteration-1/checks/static-console.txt +2 -0
  267. package/skills/ui-design/tests/e2e/runs/nebula-console-iteration-1/checks/static-homepage.txt +2 -0
  268. package/skills/ui-design/tests/e2e/runs/nebula-console-iteration-1/outputs/console.html +600 -0
  269. package/skills/ui-design/tests/e2e/runs/nebula-console-iteration-1/outputs/homepage.html +556 -0
  270. package/skills/ui-design/tests/e2e/runs/nebula-console-iteration-1/prompt.md +29 -0
  271. package/skills/ui-design/tests/e2e/runs/nebula-console-iteration-1/review.md +57 -0
  272. package/skills/ui-design/tests/e2e/runs/nebula-console-iteration-1/screenshots/console-desktop.png +0 -0
  273. package/skills/ui-design/tests/e2e/runs/nebula-console-iteration-1/screenshots/console-mobile.png +0 -0
  274. package/skills/ui-design/tests/e2e/runs/nebula-console-iteration-1/screenshots/homepage-desktop.png +0 -0
  275. package/skills/ui-design/tests/e2e/runs/nebula-console-iteration-1/screenshots/homepage-mobile.png +0 -0
  276. package/skills/ui-design/tests/e2e/runs/obsidian-optics-iteration-1/checks/browser-check.json +33 -0
  277. package/skills/ui-design/tests/e2e/runs/obsidian-optics-iteration-1/checks/language-check.txt +6 -0
  278. package/skills/ui-design/tests/e2e/runs/obsidian-optics-iteration-1/checks/quick-validate.txt +1 -0
  279. package/skills/ui-design/tests/e2e/runs/obsidian-optics-iteration-1/checks/static-check.txt +2 -0
  280. package/skills/ui-design/tests/e2e/runs/obsidian-optics-iteration-1/outputs/index.html +682 -0
  281. package/skills/ui-design/tests/e2e/runs/obsidian-optics-iteration-1/prompt.md +13 -0
  282. package/skills/ui-design/tests/e2e/runs/obsidian-optics-iteration-1/review.md +59 -0
  283. package/skills/ui-design/tests/e2e/runs/obsidian-optics-iteration-1/screenshots/desktop.png +0 -0
  284. package/skills/ui-design/tests/e2e/runs/obsidian-optics-iteration-1/screenshots/mobile.png +0 -0
  285. package/skills/ui-design/tests/e2e/runs/obsidian-xui-screenshot-check-1/outputs/page-01.html +466 -0
  286. package/skills/ui-design/tests/e2e/runs/obsidian-xui-screenshot-check-1/outputs/page-02.html +433 -0
  287. package/skills/ui-design/tests/e2e/runs/obsidian-xui-screenshot-check-1/prompt.md +13 -0
  288. package/skills/ui-design/tests/e2e/runs/obsidian-xui-screenshot-check-1/screenshots/page-01/browser-check.json +49 -0
  289. package/skills/ui-design/tests/e2e/runs/obsidian-xui-screenshot-check-1/screenshots/page-01/desktop.png +0 -0
  290. package/skills/ui-design/tests/e2e/runs/obsidian-xui-screenshot-check-1/screenshots/page-01/mobile.png +0 -0
  291. package/skills/ui-design/tests/e2e/runs/obsidian-xui-screenshot-check-1/screenshots/page-02/browser-check.json +49 -0
  292. package/skills/ui-design/tests/e2e/runs/obsidian-xui-screenshot-check-1/screenshots/page-02/desktop.png +0 -0
  293. package/skills/ui-design/tests/e2e/runs/obsidian-xui-screenshot-check-1/screenshots/page-02/mobile.png +0 -0
  294. package/skills/ui-design/tests/e2e/runs/precision-horizon-iteration-1/checks/browser-check.json +49 -0
  295. package/skills/ui-design/tests/e2e/runs/precision-horizon-iteration-1/checks/language-check.txt +3 -0
  296. package/skills/ui-design/tests/e2e/runs/precision-horizon-iteration-1/checks/static-check.txt +2 -0
  297. package/skills/ui-design/tests/e2e/runs/precision-horizon-iteration-1/execution_notes.md +24 -0
  298. package/skills/ui-design/tests/e2e/runs/precision-horizon-iteration-1/outputs/index.html +723 -0
  299. package/skills/ui-design/tests/e2e/runs/precision-horizon-iteration-1/prompt.md +15 -0
  300. package/skills/ui-design/tests/e2e/runs/precision-horizon-iteration-1/review.md +26 -0
  301. package/skills/ui-design/tests/e2e/runs/precision-horizon-iteration-1/screenshots/desktop.png +0 -0
  302. package/skills/ui-design/tests/e2e/runs/precision-horizon-iteration-1/screenshots/mobile.png +0 -0
  303. package/skills/ui-design/tests/e2e/runs/precision-horizon-iteration-2/checks/browser-dark.json +49 -0
  304. package/skills/ui-design/tests/e2e/runs/precision-horizon-iteration-2/checks/browser-light.json +49 -0
  305. package/skills/ui-design/tests/e2e/runs/precision-horizon-iteration-2/checks/language-check.txt +11 -0
  306. package/skills/ui-design/tests/e2e/runs/precision-horizon-iteration-2/checks/static-dark.txt +2 -0
  307. package/skills/ui-design/tests/e2e/runs/precision-horizon-iteration-2/checks/static-light.txt +3 -0
  308. package/skills/ui-design/tests/e2e/runs/precision-horizon-iteration-2/execution_notes.md +23 -0
  309. package/skills/ui-design/tests/e2e/runs/precision-horizon-iteration-2/outputs/dark.html +392 -0
  310. package/skills/ui-design/tests/e2e/runs/precision-horizon-iteration-2/outputs/light.html +476 -0
  311. package/skills/ui-design/tests/e2e/runs/precision-horizon-iteration-2/prompt.md +23 -0
  312. package/skills/ui-design/tests/e2e/runs/precision-horizon-iteration-2/review.md +41 -0
  313. package/skills/ui-design/tests/e2e/runs/precision-horizon-iteration-2/screenshots/dark/desktop.png +0 -0
  314. package/skills/ui-design/tests/e2e/runs/precision-horizon-iteration-2/screenshots/dark/mobile.png +0 -0
  315. package/skills/ui-design/tests/e2e/runs/precision-horizon-iteration-2/screenshots/light/desktop.png +0 -0
  316. package/skills/ui-design/tests/e2e/runs/precision-horizon-iteration-2/screenshots/light/mobile.png +0 -0
  317. package/skills/ui-design/tests/e2e/runs/solar-relay-iteration-1/checks/browser-check.json +54 -0
  318. package/skills/ui-design/tests/e2e/runs/solar-relay-iteration-1/checks/language-check.txt +5 -0
  319. package/skills/ui-design/tests/e2e/runs/solar-relay-iteration-1/checks/quick-validate.txt +1 -0
  320. package/skills/ui-design/tests/e2e/runs/solar-relay-iteration-1/checks/static-check.txt +2 -0
  321. package/skills/ui-design/tests/e2e/runs/solar-relay-iteration-1/outputs/index.html +765 -0
  322. package/skills/ui-design/tests/e2e/runs/solar-relay-iteration-1/prompt.md +15 -0
  323. package/skills/ui-design/tests/e2e/runs/solar-relay-iteration-1/review.md +59 -0
  324. package/skills/ui-design/tests/e2e/runs/solar-relay-iteration-1/screenshots/desktop.png +0 -0
  325. package/skills/ui-design/tests/e2e/runs/solar-relay-iteration-1/screenshots/mobile.png +0 -0
  326. package/skills/ui-design/tests/e2e/runs/verdant-grid-preview-1/outputs/index.html +508 -0
  327. package/skills/ui-design/tests/e2e/runs/verdant-grid-preview-1/screenshots/browser-check.json +49 -0
  328. package/skills/ui-design/tests/e2e/runs/verdant-grid-preview-1/screenshots/desktop.png +0 -0
  329. package/skills/ui-design/tests/e2e/runs/verdant-grid-preview-1/screenshots/mobile.png +0 -0
  330. package/skills/ui-design/tests/e2e/runs/violet-meridian-iteration-1/checks/browser-check.json +54 -0
  331. package/skills/ui-design/tests/e2e/runs/violet-meridian-iteration-1/checks/language-check.txt +6 -0
  332. package/skills/ui-design/tests/e2e/runs/violet-meridian-iteration-1/checks/quick-validate.txt +1 -0
  333. package/skills/ui-design/tests/e2e/runs/violet-meridian-iteration-1/checks/static-check.txt +2 -0
  334. package/skills/ui-design/tests/e2e/runs/violet-meridian-iteration-1/outputs/index.html +788 -0
  335. package/skills/ui-design/tests/e2e/runs/violet-meridian-iteration-1/prompt.md +14 -0
  336. package/skills/ui-design/tests/e2e/runs/violet-meridian-iteration-1/review.md +60 -0
  337. package/skills/ui-design/tests/e2e/runs/violet-meridian-iteration-1/screenshots/desktop.png +0 -0
  338. package/skills/ui-design/tests/e2e/runs/violet-meridian-iteration-1/screenshots/mobile.png +0 -0
  339. package/skills/video-compose/SKILL.md +8 -1
  340. package/skills/web-crawler/SKILL.md +70 -0
  341. package/skills/web-crawler/scripts/crawl.mjs +538 -0
  342. package/skills/web-crawler/scripts/crawl.test.mjs +204 -0
  343. package/skills/xlsx/SKILL.md +12 -0
  344. package/skills/xlsx/scripts/opc_check.py +253 -0
  345. package/src/advisorState.js +22 -0
  346. package/src/askBridge.js +97 -3
  347. package/src/bootstrap/workingDir.js +12 -1
  348. package/src/cli/args.js +17 -1
  349. package/src/cli/configMenu.js +236 -0
  350. package/src/cli/configWizard.js +7 -5
  351. package/src/cli/print.js +37 -4
  352. package/src/cli/streamJson.js +77 -24
  353. package/src/cli/wizards/advisorWizard.js +147 -0
  354. package/src/cli/wizards/fieldForm.js +149 -0
  355. package/src/cli/wizards/imageWizard.js +60 -0
  356. package/src/cli/wizards/openRouterWizard.js +23 -0
  357. package/src/cli/wizards/providerPresets.js +95 -0
  358. package/src/cli/wizards/providerWizard.js +219 -0
  359. package/src/cli/wizards/searchWizard.js +23 -0
  360. package/src/cli/wizards/ttsWizard.js +34 -0
  361. package/src/config/configFile.js +441 -83
  362. package/src/config/configService.js +446 -0
  363. package/src/config.js +24 -64
  364. package/src/context/agentHome.js +9 -0
  365. package/src/context/archive.js +7 -2
  366. package/src/context/compact.js +8 -5
  367. package/src/context/contextLock.js +81 -0
  368. package/src/context/persistContext.js +30 -11
  369. package/src/context/reactiveCompact.js +4 -2
  370. package/src/context/recentDirs.js +21 -61
  371. package/src/context/sessionPath.js +97 -23
  372. package/src/context/sessionRegistry.js +303 -24
  373. package/src/context/tokenCounter.js +18 -0
  374. package/src/context/workspaceIndex.js +731 -0
  375. package/src/infra/verification.js +28 -15
  376. package/src/llm/client.js +394 -159
  377. package/src/llm/providers/anthropicFormat.js +241 -0
  378. package/src/llm/providers/openaiFormat.js +190 -0
  379. package/src/loop.js +219 -32
  380. package/src/main.js +61 -11
  381. package/src/messageQueue.js +97 -0
  382. package/src/multimodal.js +203 -0
  383. package/src/plugin-sdk.js +15 -0
  384. package/src/plugins/commandRouter.js +3 -0
  385. package/src/plugins/hookEngine.js +5 -1
  386. package/src/plugins/pluginLifecycle.js +28 -0
  387. package/src/plugins/pluginLoader.js +2 -1
  388. package/src/plugins/pluginRunner.js +49 -21
  389. package/src/plugins/transcript.js +4 -3
  390. package/src/prompts/pluginList.js +5 -5
  391. package/src/prompts/projectInstructions.js +3 -3
  392. package/src/prompts/promptAssembly.js +133 -144
  393. package/src/prompts/skillList.js +12 -22
  394. package/src/prompts/system.js +0 -1
  395. package/src/session/runAgentTurn.js +70 -0
  396. package/src/session/runTurn.js +41 -6
  397. package/src/toolGate.js +17 -0
  398. package/src/tools/advisor/advisor.js +379 -0
  399. package/src/tools/agent/agent.js +555 -0
  400. package/src/tools/agent/agentTypes.js +59 -0
  401. package/src/tools/agent/coordinator.js +93 -0
  402. package/src/tools/agent/worktree.js +270 -0
  403. package/src/tools/ask/ask.js +7 -2
  404. package/src/tools/bash/bash.js +112 -18
  405. package/src/tools/download/download.js +144 -0
  406. package/src/tools/edit/edit.js +1 -0
  407. package/src/tools/edit/multi-edit.js +1 -0
  408. package/src/tools/genimage/genimage.js +242 -0
  409. package/src/tools/genspeech/genspeech.js +67 -0
  410. package/src/tools/glob/glob.js +1 -0
  411. package/src/tools/grep/grep.js +3 -0
  412. package/src/tools/grep/rgPath.js +2 -0
  413. package/src/tools/index.js +25 -2
  414. package/src/tools/read/read.js +1 -0
  415. package/src/tools/shared/killTree.js +52 -0
  416. package/src/tools/toolProgressBridge.js +48 -0
  417. package/src/tools/webbrowser/agentBrowserPath.js +258 -0
  418. package/src/tools/webbrowser/browser.js +59 -42
  419. package/src/tools/webbrowser/browserControl.js +118 -0
  420. package/src/tools/webbrowser/browserDaemon.js +97 -0
  421. package/src/tools/webbrowser/browserService.js +27 -0
  422. package/src/tools/webbrowser/browserStream.js +119 -0
  423. package/src/tools/webbrowser/webbrowser.js +699 -130
  424. package/src/tools/webfetch/webfetch.js +1 -0
  425. package/src/tools/websearch/websearch.js +134 -51
  426. package/src/tools/write/write.js +1 -0
  427. package/src/ui/App.js +249 -22
  428. package/src/ui/AskQuestion.js +29 -10
  429. package/src/ui/InputBox.js +104 -12
  430. package/src/ui/LiveArea.js +112 -19
  431. package/src/ui/MessageList.js +100 -14
  432. package/src/ui/ModelPicker.js +56 -0
  433. package/src/ui/QueuedMessagesBanner.js +14 -0
  434. package/src/ui/Root.js +18 -10
  435. package/src/ui/SessionPicker.js +102 -0
  436. package/src/ui/StatusLine.js +13 -4
  437. package/src/ui/WorkspacePicker.js +55 -0
  438. package/src/ui/figures.js +44 -0
  439. package/src/ui/hooks/askSlot.js +82 -0
  440. package/src/ui/hooks/useChat.js +358 -64
  441. package/src/ui/hooks/usePasteHandler.js +11 -1
  442. package/src/ui/hooks/useTextBuffer.js +1 -3
  443. package/src/ui/liveViewport.js +5 -1
  444. package/src/ui/markdown.js +206 -0
  445. package/src/ui/textBuffer.js +0 -7
  446. package/src/ui/theme.js +32 -0
  447. package/src/utils/debugLog.js +0 -4
  448. package/src/utils/resourcePaths.js +2 -2
  449. package/src/utils/stripThink.js +50 -0
  450. package/src/web/browserBridge.js +447 -0
  451. package/src/web/protocol.js +19 -0
  452. package/workflows/adaptive-research.yaml +13 -5
  453. package/workflows/book-review-short.yaml +36 -10
  454. package/workflows/e2e-write-greet.yaml +6 -2
  455. package/workflows/illustrate.yaml +227 -0
  456. package/workflows/plan.yaml +460 -179
  457. package/workflows/ppt.yaml +1659 -0
  458. package/workflows/ralph-loop.yaml +199 -0
  459. package/workflows/schema.json +81 -23
  460. package/workflows/webppt.yaml +496 -0
  461. package/workflows/word.yaml +710 -0
  462. package/workflows/youtube-shorts.yaml +46 -15
  463. package/MINIMAL-TRACE.md +0 -77
  464. package/plugins/HOW-TO-WRITE-A-PLUGIN.md +0 -186
  465. package/plugins/ralph-wiggum/.claude-plugin/plugin.json +0 -9
  466. package/plugins/ralph-wiggum/README.md +0 -197
  467. package/plugins/ralph-wiggum/commands/cancel-ralph.md +0 -18
  468. package/plugins/ralph-wiggum/commands/help.md +0 -137
  469. package/plugins/ralph-wiggum/commands/ralph-loop.md +0 -85
  470. package/plugins/ralph-wiggum/hooks/hooks.json +0 -15
  471. package/plugins/ralph-wiggum/hooks/stop-hook.sh +0 -191
  472. package/plugins/ralph-wiggum/plugin.js +0 -280
  473. package/plugins/ralph-wiggum/src/goalState.js +0 -258
  474. package/plugins/ralph-wiggum/src/sentinels.js +0 -76
  475. package/plugins/ralph-wiggum/src/stopHookRunner.js +0 -104
  476. package/plugins/ralph-wiggum/src/verificationGate.js +0 -202
  477. package/plugins/workflow-runner/src/miniReAct.js +0 -223
  478. package/plugins/workflow-runner/src/voteHelpers.js +0 -110
  479. package/skills/config/SKILL.md +0 -123
  480. package/skills/frontend-design/LICENSE.txt +0 -177
  481. package/skills/frontend-design/SKILL.md +0 -42
  482. package/src/ui/ProgressPanel.js +0 -54
  483. package/src/ui/StreamingBlock.js +0 -11
  484. package/src/ui/ToolStatus.js +0 -11
@@ -0,0 +1,2367 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Export ui-design HTML artifacts to PPTX —— 三层模型(背景 / 前景 / 文本)。
4
+ *
5
+ * 用 Playwright 做视觉捕获、PptxGenJS 生成 PPTX。**用 Node 跑,不要 Bun。**
6
+ *
7
+ * 三层(hybrid 模式):
8
+ * 1) 背景:页面底板/渐变/氛围底图(B 路线 background-image + scrim)—— 整页截图,垫 slide 最底层。
9
+ * 2) 前景:除背景与文本外的**所有**离散视觉块(img/svg/canvas/图表/卡片/色块/图标),标
10
+ * `data-pptx-layer="foreground"`。**取最外层**(去嵌套)逐个元素截图 → PPT 独立可编辑图片对象(中层)。
11
+ * 3) 文本:**自动提取全部可见文本到顶层可编辑框,不依赖逐个标注**(tagAutoText 在任何截图前,
12
+ * 按结构识别「块级且子元素全为行内的文本容器」+ 全部 SVG `<text>`,打 `data-pptx-auto-text`)。
13
+ * 背景截图 / 前景切图两态都隐藏 `[data-pptx-auto-text]` → 切图**永不含字**、文字**永远可编辑且在顶层**。
14
+ * 旧的 `data-pptx-kind="text"` / `data-pptx-layer="text"` 标注仍兼容(结构识别已覆盖,标了也不冲突);
15
+ * 要让某段文字**保留烧进图**(如嵌在图形里的艺术字)用 `data-pptx-export="ignore"` 显式排除。
16
+ * 导出时额外落 3 个 HTML 中间产物(pure-background/foreground/text.html)供检视。
17
+ * flat 模式:整页一张大图,不可编辑(最保真)。
18
+ *
19
+ * 已知边界(如实声明,刻意不改——改了弊大于利):
20
+ * - 切图按元素 **border-box** 取(page.screenshot + clip),几何同源 getBoundingClientRect → 摆放精确。
21
+ * 代价:**超出 border-box 的 box-shadow / 溢出子内容会被裁掉**。不扩边是有意的——扩了切图就比
22
+ * 记录的几何大,摆进 PPT 必然错位/糊边;宁可丢一点投影,也要保「切图尺寸 == 摆放尺寸」。
23
+ * 需要保留投影感就在 HTML 里把阴影做进 border-box 内(inset shadow / 给容器留 padding)。
24
+ * - 背景层 = 隐掉所有前景块(auto-fg/foreground) + 文本后剩下的:**全幅基底**(AI 底图 / 纯色 / 渐变 /
25
+ * scrim / 透明)。**半幅渐变带**(非全幅、有盒样式)会被当离散块切入中层;**极小装饰**
26
+ * (最小边 <24px 或面积 <2500px²)留在背景层烧进底图(不单独切,可接受)。
27
+ */
28
+
29
+ import { spawnSync } from 'node:child_process';
30
+ import { createHash } from 'node:crypto';
31
+ import { copyFile, mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
32
+ import { dirname, resolve, basename, extname } from 'node:path';
33
+ import { fileURLToPath, pathToFileURL } from 'node:url';
34
+ import { createRequire } from 'node:module';
35
+
36
+ const SLIDE_W = 10;
37
+ const SLIDE_H = 5.625;
38
+
39
+ function usage() {
40
+ return `导出 ui-design HTML 为 PPTX(三层:背景/前景/文本)
41
+
42
+ Usage:
43
+ node scripts/export_pptx_from_html.mjs <html_file> --out <output.pptx> [options]
44
+
45
+ Options:
46
+ --mode <flat|hybrid|native> 导出模式,默认 flat
47
+ flat = 整页大图,不可编辑(最保真)
48
+ hybrid = 三层:背景底图 + 前景逐元素切图 + 文本原生框(可编辑)
49
+ --slides <selector> slide 选择器,默认 [data-screen-label]
50
+ --width <px> 浏览器视口宽度,默认 1920
51
+ --height <px> 浏览器视口高度,默认 1080
52
+ --workdir <dir> 截图、切图、3 个 HTML 中间产物、manifest、报告输出目录
53
+ --node-modules <dir> 额外 Node 包目录,用于 Codex 内置运行时或本地依赖
54
+ --python <cmd> Python 命令,默认 python3
55
+ --no-opc-check 跳过 OPC 校验,不推荐
56
+ --measure-only 只渲染量构图(写 layout-report.json),不截图/不切图/不出 pptx。
57
+ 供「构图闸门前置到 HTML 层」用:秒级、零打包,循环复用同批图。
58
+ 此模式下 --out 可省(只需 --workdir 落 layout-report.json)。
59
+ --help 显示帮助
60
+ `;
61
+ }
62
+
63
+ function parseArgs(argv) {
64
+ const args = {
65
+ htmlFile: null,
66
+ out: null,
67
+ mode: 'flat',
68
+ slides: '[data-screen-label]',
69
+ width: 1920,
70
+ height: 1080,
71
+ workdir: null,
72
+ nodeModules: [],
73
+ python: 'python3',
74
+ opcCheck: true,
75
+ measureOnly: false,
76
+ withBgShots: false,
77
+ };
78
+
79
+ for (let i = 0; i < argv.length; i += 1) {
80
+ const arg = argv[i];
81
+ if (arg === '--help' || arg === '-h') {
82
+ args.help = true;
83
+ } else if (arg === '--out') {
84
+ args.out = argv[++i];
85
+ } else if (arg === '--mode') {
86
+ args.mode = argv[++i];
87
+ } else if (arg === '--slides') {
88
+ args.slides = argv[++i];
89
+ } else if (arg === '--width') {
90
+ args.width = Number(argv[++i]);
91
+ } else if (arg === '--height') {
92
+ args.height = Number(argv[++i]);
93
+ } else if (arg === '--workdir') {
94
+ args.workdir = argv[++i];
95
+ } else if (arg === '--node-modules') {
96
+ args.nodeModules.push(argv[++i]);
97
+ } else if (arg === '--python') {
98
+ args.python = argv[++i];
99
+ } else if (arg === '--no-opc-check') {
100
+ args.opcCheck = false;
101
+ } else if (arg === '--measure-only') {
102
+ args.measureOnly = true;
103
+ } else if (arg === '--with-bg-shots') {
104
+ // measure-only 时顺带落 slide-NN-background.png(背景态:隐前景+隐文字)+ slide-NN-foreground.png
105
+ // (foreground 态:隐文字、其余原样渲染,2026-07-03 新增)两张整页截图,不切图不出 pptx。
106
+ // 供 ppt 视觉门的像素级文字可读性检查(text_readability_pixel.py)采样文字底下的真实像素——
107
+ // 前者给 fgParent=null(直接压背景层)的文字用,后者给 fgParent 非空(卡内/前景块内)的文字用。
108
+ // 默认关:既有调用(webppt/导出)行为与产物不变。
109
+ args.withBgShots = true;
110
+ } else if (!args.htmlFile) {
111
+ args.htmlFile = arg;
112
+ } else {
113
+ throw new Error(`未知参数: ${arg}`);
114
+ }
115
+ }
116
+
117
+ if (args.help) {
118
+ return args;
119
+ }
120
+ if (!args.htmlFile) {
121
+ throw new Error('缺少 html_file。');
122
+ }
123
+ // measure-only 不出 pptx,--out 可省(只需 --workdir 落 layout-report.json);其余模式必须给 --out。
124
+ if (!args.out && !args.measureOnly) {
125
+ throw new Error('缺少 --out output.pptx。');
126
+ }
127
+ if (!['flat', 'hybrid', 'native'].includes(args.mode)) {
128
+ throw new Error('--mode 必须是 flat、hybrid 或 native。');
129
+ }
130
+ if (args.mode === 'native') {
131
+ throw new Error('native 模式仍是实验方向,首版请使用 flat 或 hybrid。');
132
+ }
133
+ if (!Number.isFinite(args.width) || !Number.isFinite(args.height)) {
134
+ throw new Error('--width 和 --height 必须是数字。');
135
+ }
136
+ return args;
137
+ }
138
+
139
+ function maskBlocksKeepNewlines(content, regex) {
140
+ return content.replace(regex, (match) => match.replace(/[^\n]/g, ' '));
141
+ }
142
+
143
+ function htmlStaticPreflight(content) {
144
+ const errors = [];
145
+ const headCount = (content.match(/<head\b/gi) || []).length;
146
+ const bodyCount = (content.match(/<body\b/gi) || []).length;
147
+ if (headCount > 1) {
148
+ errors.push(`发现 ${headCount} 个 <head> 标签:HTML 很可能被拼接坏了,禁止导出 PPTX。`);
149
+ }
150
+ if (bodyCount > 1) {
151
+ errors.push(`发现 ${bodyCount} 个 <body> 标签:HTML 很可能被拼接坏了,禁止导出 PPTX。`);
152
+ }
153
+
154
+ let masked = content;
155
+ masked = maskBlocksKeepNewlines(masked, /<style\b[^>]*>.*?<\/style>/gis);
156
+ masked = maskBlocksKeepNewlines(masked, /<script\b[^>]*>.*?<\/script>/gis);
157
+ masked = maskBlocksKeepNewlines(masked, /<!--.*?-->/gs);
158
+ if (/^\s*\/\*\s*=.{10,}/m.test(masked)) {
159
+ errors.push('发现裸露在 <style> 外的 CSS 注释块:它会作为页面文字被截图进 PPT 背景。');
160
+ }
161
+ if (/^\s*(?:\.[A-Za-z_-][\w-]*|#[A-Za-z_-][\w-]*|[A-Za-z_-][\w-]*\s+[.#A-Za-z_-][^{\n]*)[^{\n]{0,120}\{\s*$/m.test(masked)) {
162
+ errors.push('发现裸露在 <style> 外的 CSS 选择器:请先修复 HTML,再导出 PPTX。');
163
+ }
164
+ return errors;
165
+ }
166
+
167
+ // ---------------------------------------------------------------------------
168
+ // 纯函数(无 DOM/IO 依赖):导出供单测,main() 末尾做「直接运行才执行」守卫。
169
+ // ---------------------------------------------------------------------------
170
+
171
+ function pxToIn(value, totalPx, totalIn) {
172
+ return (value / totalPx) * totalIn;
173
+ }
174
+
175
+ function pxToPt(value, totalPx, totalIn) {
176
+ return Math.max(6, Math.round((value / totalPx) * totalIn * 72 * 10) / 10);
177
+ }
178
+
179
+ // px 矩形 → PPT 英寸矩形(归一化等比映射;1920×1080→10×5.625 两轴比例相等)。
180
+ function normalizeRect(rect, totalWidthPx, totalHeightPx) {
181
+ return {
182
+ x: pxToIn(rect.x, totalWidthPx, SLIDE_W),
183
+ y: pxToIn(rect.y, totalHeightPx, SLIDE_H),
184
+ w: pxToIn(rect.w, totalWidthPx, SLIDE_W),
185
+ h: pxToIn(rect.h, totalHeightPx, SLIDE_H),
186
+ };
187
+ }
188
+
189
+ // 前景切图文件名编码左上角 xy + 宽高(px 取整)——文件名即几何,便于人工核对/排错。
190
+ function geometryFilename(slideNo, fgNo, rect) {
191
+ const r = (n) => Math.round(n);
192
+ return `slide-${String(slideNo).padStart(2, '0')}-fg-${String(fgNo).padStart(2, '0')}`
193
+ + `-x${r(rect.x)}-y${r(rect.y)}-w${r(rect.w)}-h${r(rect.h)}.png`;
194
+ }
195
+
196
+ // ---------------------------------------------------------------------------
197
+ // 构图均衡的确定性指标(纯函数,供 layout_balance.py 闸门 + 单测)。
198
+ // 思路:把内容(**文本 + 已成功切图的前景块**,不含背景图)投到 16×8 网格,按「格中心是否落进
199
+ // 某内容框」算占用 → 覆盖率 / 四半区占用率 / 内容包围盒与质心 / 跨框显著重叠。背景渐变与全幅氛围
200
+ // 图**故意不计入**(用户的诉求正是「即便有底图,内容只占半张也算失衡」);全幅图是否铺满另由
201
+ // hasBgImage 标记,交由 Python 闸门按页型放宽。
202
+ // ---------------------------------------------------------------------------
203
+ const BALANCE_GRID_COLS = 16;
204
+ const BALANCE_GRID_ROWS = 8;
205
+ // 放映级字号下限(屏上视觉 px):低于此的文本段计入 balance.smallText,交 layout_balance.py 报 WARNING。
206
+ // 与 layout-composition.md §0.85 阶梯配套(正文 38 / 注释 30 / 药丸 26…);24 作下限只抓「明显偏小」的漏网。
207
+ const FONT_FLOOR_PX = 24;
208
+
209
+ function computeSlideBalance(slide, width, height) {
210
+ const boxes = [];
211
+ for (const r of slide.textRuns || []) {
212
+ const textRect = r.pptPxRect || pptTextBoxPx(r, width, height);
213
+ if (textRect.w > 1 && textRect.h > 1) {
214
+ boxes.push({
215
+ x: textRect.x, y: textRect.y, w: textRect.w, h: textRect.h, kind: 'text',
216
+ name: (r.text || 'text').slice(0, 16),
217
+ fgParent: r.fgParent || null,
218
+ });
219
+ }
220
+ }
221
+ for (const f of slide.foreground || []) {
222
+ // 只计入真正切出图的前景块(imagePath 在);截图失败的块不会出现在 PPT 里。
223
+ if (f.imagePath && f.w > 1 && f.h > 1) {
224
+ boxes.push({ x: f.x, y: f.y, w: f.w, h: f.h, kind: 'fg', name: f.fgId || 'fg' });
225
+ }
226
+ }
227
+
228
+ const cols = BALANCE_GRID_COLS;
229
+ const rows = BALANCE_GRID_ROWS;
230
+ const cellW = width / cols;
231
+ const cellH = height / rows;
232
+ const occupied = new Array(cols * rows).fill(false);
233
+ for (let cy = 0; cy < rows; cy += 1) {
234
+ for (let cx = 0; cx < cols; cx += 1) {
235
+ const px = (cx + 0.5) * cellW;
236
+ const py = (cy + 0.5) * cellH;
237
+ for (const b of boxes) {
238
+ if (px >= b.x && px <= b.x + b.w && py >= b.y && py <= b.y + b.h) {
239
+ occupied[cy * cols + cx] = true;
240
+ break;
241
+ }
242
+ }
243
+ }
244
+ }
245
+ const totalCells = cols * rows;
246
+ const occCount = occupied.reduce((n, v) => n + (v ? 1 : 0), 0);
247
+ const coverage = totalCells ? occCount / totalCells : 0;
248
+
249
+ const halfFrac = (test) => {
250
+ let total = 0;
251
+ let occ = 0;
252
+ for (let cy = 0; cy < rows; cy += 1) {
253
+ for (let cx = 0; cx < cols; cx += 1) {
254
+ if (!test(cx, cy)) continue;
255
+ total += 1;
256
+ if (occupied[cy * cols + cx]) occ += 1;
257
+ }
258
+ }
259
+ return total ? occ / total : 0;
260
+ };
261
+ const midCol = cols / 2;
262
+ const midRow = rows / 2;
263
+ const halfOccupancy = {
264
+ top: halfFrac((_cx, cy) => cy < midRow),
265
+ bottom: halfFrac((_cx, cy) => cy >= midRow),
266
+ left: halfFrac((cx) => cx < midCol),
267
+ right: halfFrac((cx) => cx >= midCol),
268
+ };
269
+
270
+ let bbox = null;
271
+ let centroid = null;
272
+ if (boxes.length) {
273
+ let minX = Infinity;
274
+ let minY = Infinity;
275
+ let maxX = -Infinity;
276
+ let maxY = -Infinity;
277
+ let areaSum = 0;
278
+ let cxSum = 0;
279
+ let cySum = 0;
280
+ for (const b of boxes) {
281
+ minX = Math.min(minX, b.x);
282
+ minY = Math.min(minY, b.y);
283
+ maxX = Math.max(maxX, b.x + b.w);
284
+ maxY = Math.max(maxY, b.y + b.h);
285
+ const a = b.w * b.h;
286
+ areaSum += a;
287
+ cxSum += (b.x + b.w / 2) * a;
288
+ cySum += (b.y + b.h / 2) * a;
289
+ }
290
+ bbox = { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
291
+ centroid = areaSum ? { x: cxSum / areaSum, y: cySum / areaSum } : null;
292
+ }
293
+
294
+ // 跨框显著重叠(专抓封面那种「标签/副标题压在大标题上」):排除包含关系(卡内文字属正常)。
295
+ // 归一化坐标 = 像素 ÷ 画布宽高(原点左上、范围 0–1),供闸门精确报「哪两个元素 × 重叠区在哪」。
296
+ const norm = (bx, by, bw, bh) => ({
297
+ x: Math.round((bx / width) * 1000) / 1000,
298
+ y: Math.round((by / height) * 1000) / 1000,
299
+ w: Math.round((bw / width) * 1000) / 1000,
300
+ h: Math.round((bh / height) * 1000) / 1000,
301
+ });
302
+ const overlaps = [];
303
+ for (let i = 0; i < boxes.length; i += 1) {
304
+ for (let j = i + 1; j < boxes.length; j += 1) {
305
+ const a = boxes[i];
306
+ const b = boxes[j];
307
+ const ix = Math.max(a.x, b.x);
308
+ const iy = Math.max(a.y, b.y);
309
+ const ix2 = Math.min(a.x + a.w, b.x + b.w);
310
+ const iy2 = Math.min(a.y + a.h, b.y + b.h);
311
+ if (ix2 <= ix || iy2 <= iy) continue;
312
+ const aInB = a.x >= b.x && a.y >= b.y && a.x + a.w <= b.x + b.w && a.y + a.h <= b.y + b.h;
313
+ const bInA = b.x >= a.x && b.y >= a.y && b.x + b.w <= a.x + a.w && b.y + b.h <= a.y + a.h;
314
+ // 三层 PPT 导出中,文本层覆盖其承载前景块是正常结构(卡片底图 + 可编辑文字),
315
+ // 不能当成视觉碰撞;文本框为避免 Office 孤字换行会略宽于 HTML 原框,更不能只靠包含关系判断。
316
+ if (a.kind === 'text' && b.kind === 'fg' && a.fgParent && a.fgParent === b.name) continue;
317
+ if (b.kind === 'text' && a.kind === 'fg' && b.fgParent && b.fgParent === a.name) continue;
318
+ // 包含关系**仅当外层是前景卡片(fg)**才算正常(卡内文字/图标);text-on-text、text-on-image
319
+ // 这类「小框压在大框上」是真碰撞(封面小标签压大标题即此),照常标记、不豁免。
320
+ if (aInB && b.kind === 'fg') continue;
321
+ if (bInA && a.kind === 'fg') continue;
322
+ // 同一前景块内的两段文本互叠 = 图内固有间距(如 SVG 架构图密排标签)→ 豁免,不算缺陷。
323
+ if (a.kind === 'text' && b.kind === 'text' && a.fgParent && a.fgParent === b.fgParent) continue;
324
+ const interIn = pxToIn(ix2 - ix, width, SLIDE_W) * pxToIn(iy2 - iy, height, SLIDE_H);
325
+ const smallerPx = Math.min(a.w * a.h, b.w * b.h);
326
+ const fracOfSmaller = smallerPx ? ((ix2 - ix) * (iy2 - iy)) / smallerPx : 0;
327
+ if (interIn > 0.02 && fracOfSmaller > 0.08) {
328
+ overlaps.push({
329
+ a: a.name,
330
+ b: b.name,
331
+ aKind: a.kind,
332
+ bKind: b.kind,
333
+ areaIn: Math.round(interIn * 1000) / 1000,
334
+ fracOfSmaller: Math.round(fracOfSmaller * 1000) / 1000,
335
+ // 归一化坐标(0–1,原点左上):两元素各自外框 + 相交区,供闸门精确报位置/范围。
336
+ aRect: norm(a.x, a.y, a.w, a.h),
337
+ bRect: norm(b.x, b.y, b.w, b.h),
338
+ rect: norm(ix, iy, ix2 - ix, iy2 - iy),
339
+ });
340
+ }
341
+ }
342
+ }
343
+
344
+ // ── 卡片/分组填充度:非媒体前景卡(hasMedia=false,含递归切的底板 plate)的「卡内内容面积 ÷ 卡面积」。
345
+ // 内容面积 = 卡内**文本** + 卡内**其它 auto-fg 子块**(递归切后子块独立成块)。
346
+ // 媒体卡(含 img/svg/底图)豁免。供闸门抓「大卡装一句话」**与**「图元挤一角、旁边大片留白」(crammed group)。
347
+ const cards = [];
348
+ for (const f of slide.foreground || []) {
349
+ if (!f.imagePath || f.w <= 1 || f.h <= 1 || f.hasMedia) continue;
350
+ const cardArea = f.w * f.h;
351
+ let contentArea = 0;
352
+ const addInside = (b) => {
353
+ const cx = b.x + b.w / 2;
354
+ const cy = b.y + b.h / 2;
355
+ if (cx < f.x || cx > f.x + f.w || cy < f.y || cy > f.y + f.h) return; // 中心在卡内才计
356
+ const ix = Math.max(b.x, f.x);
357
+ const iy = Math.max(b.y, f.y);
358
+ const ix2 = Math.min(b.x + b.w, f.x + f.w);
359
+ const iy2 = Math.min(b.y + b.h, f.y + f.h);
360
+ if (ix2 > ix && iy2 > iy) contentArea += (ix2 - ix) * (iy2 - iy); // 裁到卡内,防越界高估
361
+ };
362
+ for (const r of slide.textRuns || []) {
363
+ if (r.w > 1 && r.h > 1) addInside(r); // 卡内文本
364
+ }
365
+ for (const g of slide.foreground || []) {
366
+ if (g === f || !g.imagePath || g.w <= 1 || g.h <= 1) continue;
367
+ addInside(g); // 卡内其它切图子块(递归切的圆/层/节点等)
368
+ }
369
+ cards.push({
370
+ fgId: f.fgId || 'fg',
371
+ fill: cardArea ? Math.round((contentArea / cardArea) * 1000) / 1000 : 0,
372
+ rect: norm(f.x, f.y, f.w, f.h),
373
+ });
374
+ }
375
+
376
+ // ── 文本溢出(仅报**显著裁切**):内容超出自身框 > 框高一半且 > 10px,才算真裁切。
377
+ // 单行标题普遍 +4px 是行盒 vs 内容盒舍入噪声(line-height/降部),用占比阈值滤掉,避免满屏误报。
378
+ const overflowRuns = [];
379
+ for (const r of slide.textRuns || []) {
380
+ const o = r.overflow || 0;
381
+ if (o > 10 && r.h > 0 && o > r.h * 0.5) {
382
+ overflowRuns.push({
383
+ text: (r.text || 'text').slice(0, 16),
384
+ overflowPx: Math.round(o),
385
+ rect: norm(r.x, r.y, r.w, r.h),
386
+ });
387
+ }
388
+ }
389
+
390
+ // ── 超出画布:文本/前景块右/下越界(x+w>宽 或 y+h>高,留 8px 容差)→ 内容比页面大、会被裁出可视区。
391
+ // 抓「一列层块/节点堆到 >1080」这类(slide13 架构图 1267px)。供闸门 WARNING + 提示拆页/分栏。
392
+ const OFF_TOL = 8;
393
+ const offCanvas = [];
394
+ const offers = (x, y, w, h) =>
395
+ x + w > width + OFF_TOL || y + h > height + OFF_TOL || x < -OFF_TOL || y < -OFF_TOL;
396
+ for (const r of slide.textRuns || []) {
397
+ if (r.w > 1 && r.h > 1 && offers(r.x, r.y, r.w, r.h)) {
398
+ offCanvas.push({ kind: 'text', name: (r.text || 'text').slice(0, 16), rect: norm(r.x, r.y, r.w, r.h) });
399
+ }
400
+ }
401
+ for (const f of slide.foreground || []) {
402
+ if (f.imagePath && f.w > 1 && f.h > 1 && offers(f.x, f.y, f.w, f.h)) {
403
+ offCanvas.push({ kind: 'fg', name: f.fgId || 'fg', rect: norm(f.x, f.y, f.w, f.h) });
404
+ }
405
+ }
406
+
407
+ // ── 文字/背景对比度(WCAG):text.color vs 解析出的 text.bgColor。ratio < 3.0(放映级大字 AA 阈)才收。
408
+ // 治「深紫底 + 黑字」(实测对比 ~1.1 不可读)。bgSolid=true 是高置信纯底(闸门可硬拦),渐变/图底低置信只提示。
409
+ const parseRGB = (c) => {
410
+ if (!c) return null;
411
+ const m = /rgba?\(([^)]+)\)/.exec(c);
412
+ if (m) {
413
+ const p = m[1].split(',').map((s) => parseFloat(s));
414
+ return p.length >= 3 && p.every((n) => Number.isFinite(n)) ? [p[0], p[1], p[2]] : null;
415
+ }
416
+ const h = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec((c || '').trim());
417
+ if (h) {
418
+ const x = h[1].length === 3 ? h[1].split('').map((d) => d + d).join('') : h[1];
419
+ return [parseInt(x.slice(0, 2), 16), parseInt(x.slice(2, 4), 16), parseInt(x.slice(4, 6), 16)];
420
+ }
421
+ return null;
422
+ };
423
+ const relLum = (rgb) => {
424
+ const f = (v) => {
425
+ const c = v / 255;
426
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
427
+ };
428
+ return 0.2126 * f(rgb[0]) + 0.7152 * f(rgb[1]) + 0.0722 * f(rgb[2]);
429
+ };
430
+ const lowContrast = [];
431
+ for (const r of slide.textRuns || []) {
432
+ if (!(r.w > 1 && r.h > 1)) continue;
433
+ const fgc = parseRGB(r.color);
434
+ const bgc = parseRGB(r.bgColor);
435
+ if (!fgc || !bgc) continue;
436
+ const l1 = relLum(fgc);
437
+ const l2 = relLum(bgc);
438
+ const ratio = Math.round(((Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05)) * 100) / 100;
439
+ if (ratio < 3.0) {
440
+ lowContrast.push({
441
+ text: (r.text || 'text').slice(0, 16),
442
+ ratio,
443
+ color: r.color,
444
+ bgColor: r.bgColor,
445
+ bgSolid: !!r.bgSolid,
446
+ rect: norm(r.x, r.y, r.w, r.h),
447
+ });
448
+ }
449
+ }
450
+
451
+ // ── 字号下限:低于放映级下限的文本段(投影上偏小)。供闸门 WARNING + 督促设计阶段把字号做大。
452
+ // run.fontSize 已是屏上视觉 px(SVG 已乘 viewBox→视口缩放,见 extractLayers),直接比下限。
453
+ const smallText = [];
454
+ let minFontSize = null;
455
+ for (const r of slide.textRuns || []) {
456
+ const fs = r.fontSize || 0;
457
+ if (fs > 0) minFontSize = minFontSize == null ? fs : Math.min(minFontSize, fs);
458
+ if (fs > 0 && fs < FONT_FLOOR_PX && r.w > 1 && r.h > 1) {
459
+ smallText.push({
460
+ text: (r.text || 'text').slice(0, 16),
461
+ fontSize: Math.round(fs * 10) / 10,
462
+ rect: norm(r.x, r.y, r.w, r.h),
463
+ });
464
+ }
465
+ }
466
+
467
+ // ── 截图夹带文本:背景截图态下「仍可见且未被标注隐藏」的 HTML/SVG 文本(含带文字的 ::before/::after)。
468
+ // 由 renderSlides 的背景态探针 probeBakedText 采集后挂在 slide.bakedText;此处仅透传给闸门。
469
+ // 底图/中层切图**绝不能带 HTML 文字**(用户硬要求)——有命中 = 该文字会烧进截图且不可编辑 → 闸门 ERROR。
470
+ const bakedText = Array.isArray(slide.bakedText) ? slide.bakedText : [];
471
+
472
+ // 本页是否有「前景视觉填充块」(切出图的 img/svg/图标/大视觉块,hasMedia=true)。
473
+ // 供构图闸门「有图不算空」放宽用:纯文本/色块卡片不算(hasMedia=false),唯有真视觉元素才让
474
+ // 「页面过空 / 空半」降级——与「每页 ≥1 视觉元素」的设计同向(有图标/图表的页天然不空)。
475
+ const hasForegroundVisual = (slide.foreground || []).some(
476
+ (f) => f.imagePath && f.hasMedia,
477
+ );
478
+
479
+ // ── 媒体块矩形(加性字段):hasMedia 前景块的归一化外框,供 layout_balance --ppt-strict 判
480
+ // 「大空卡是否其实被图盖着」、以及按 sourceFile 反查 min_canvas_fraction 是否达标(真实渲染框,
481
+ // 非静态正则估算)。默认闸门不读此字段(webppt 行为不变)。
482
+ // BARE_MEDIA_TAGS 兜底(2026-07-07 真机 p8 复盘坐实):hasMedia = node.querySelector('img,...')
483
+ // 只查**后代**,裸 <img>/<svg> 自身作前景块(无 div 包裹)时恒 false(与 layout_balance.py 的
484
+ // PPT_STRICT_MEDIA_TAGS 同一个已知盲区,那边在别的检查里已用 tag 兜底)——不改 hasMedia 本体
485
+ // (它驱动默认档空心卡/有图不算空判定,动了会漂移 webppt 行为),只在 mediaRects 这个加性字段的
486
+ // 判定里额外认 tag 本身是媒体标签的情况,否则这类图永远不会被计入 mediaRects。
487
+ const BARE_MEDIA_TAGS = new Set(['img', 'svg', 'picture', 'video', 'canvas']);
488
+ const mediaRects = [];
489
+ for (const f of slide.foreground || []) {
490
+ const isMedia = f.hasMedia || BARE_MEDIA_TAGS.has(f.tag);
491
+ if (f.imagePath && isMedia && f.w > 1 && f.h > 1) {
492
+ mediaRects.push({ ...norm(f.x, f.y, f.w, f.h), sourceFile: f.sourceFile || null });
493
+ }
494
+ }
495
+
496
+ // ── 全部前景块矩形(加性字段):含媒体卡。供 layout_balance --ppt-strict 的
497
+ // 【文字溢出卡片】containment 检查按 textRun.fgParent 反查父卡外框——cards[] 排除了
498
+ // 媒体卡(hasMedia 跳过),文字溢出「带图卡片」时在 cards 里查不到父 rect。
499
+ // 默认闸门不读此字段(webppt 行为不变)。
500
+ const fgRects = [];
501
+ for (const f of slide.foreground || []) {
502
+ if (f.imagePath && f.w > 1 && f.h > 1) {
503
+ fgRects.push({
504
+ name: f.fgId || 'fg',
505
+ rect: norm(f.x, f.y, f.w, f.h),
506
+ hasMedia: !!f.hasMedia,
507
+ // tag(加性):hasMedia 的 querySelector 只查后代——裸 <img>/<svg> 自身作前景块时
508
+ // 恒 false(2026-07-03 真机 03 页图卡案)。--ppt-strict 侧靠 tag 识别媒体块,
509
+ // 不改 hasMedia 本体(它驱动默认档空心卡/有图不算空判定,动了会漂移 webppt 行为)。
510
+ tag: f.tag || null,
511
+ });
512
+ }
513
+ }
514
+
515
+ const round3 = (n) => (n == null ? null : Math.round(n * 1000) / 1000);
516
+ return {
517
+ grid: { cols, rows },
518
+ contentBoxes: boxes.length,
519
+ hasBgImage: !!slide.hasBgImage,
520
+ hasForegroundVisual,
521
+ coverage: round3(coverage),
522
+ halfOccupancy: {
523
+ top: round3(halfOccupancy.top),
524
+ bottom: round3(halfOccupancy.bottom),
525
+ left: round3(halfOccupancy.left),
526
+ right: round3(halfOccupancy.right),
527
+ },
528
+ bbox: bbox
529
+ ? { x: Math.round(bbox.x), y: Math.round(bbox.y), w: Math.round(bbox.w), h: Math.round(bbox.h) }
530
+ : null,
531
+ centroid: centroid ? { x: Math.round(centroid.x), y: Math.round(centroid.y) } : null,
532
+ cards,
533
+ overflowRuns,
534
+ overlaps,
535
+ offCanvas,
536
+ lowContrast,
537
+ minFontSize: minFontSize == null ? null : Math.round(minFontSize * 10) / 10,
538
+ smallText,
539
+ bakedText,
540
+ mediaRects,
541
+ fgRects,
542
+ };
543
+ }
544
+
545
+ async function imageInfo(path) {
546
+ const buffer = await readFile(path);
547
+ const hash = createHash('sha256').update(buffer).digest('hex');
548
+ let width = null;
549
+ let height = null;
550
+ let colorType = null; // PNG IHDR 第 25 字节:6 = truecolor+alpha(切图应带 alpha 通道)
551
+ if (
552
+ buffer.length >= 26
553
+ && buffer[0] === 0x89
554
+ && buffer[1] === 0x50
555
+ && buffer[2] === 0x4e
556
+ && buffer[3] === 0x47
557
+ ) {
558
+ width = buffer.readUInt32BE(16);
559
+ height = buffer.readUInt32BE(20);
560
+ colorType = buffer[25];
561
+ }
562
+ return { hash, width, height, colorType };
563
+ }
564
+
565
+ function colorDistance(a, b) {
566
+ return Math.max(
567
+ Math.abs(a[0] - b[0]),
568
+ Math.abs(a[1] - b[1]),
569
+ Math.abs(a[2] - b[2]),
570
+ );
571
+ }
572
+
573
+ function parseRgbColor(c) {
574
+ if (!c) return null;
575
+ const m = /rgba?\(([^)]+)\)/.exec(c);
576
+ if (m) {
577
+ const p = m[1].split(',').map((s) => Number.parseFloat(s));
578
+ return p.length >= 3 && p.slice(0, 3).every((n) => Number.isFinite(n))
579
+ ? [p[0], p[1], p[2]]
580
+ : null;
581
+ }
582
+ const h = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(String(c).trim());
583
+ if (!h) return null;
584
+ const x = h[1].length === 3 ? h[1].split('').map((d) => d + d).join('') : h[1];
585
+ return [parseInt(x.slice(0, 2), 16), parseInt(x.slice(2, 4), 16), parseInt(x.slice(4, 6), 16)];
586
+ }
587
+
588
+ function contrastRatio(a, b) {
589
+ const f = (v) => {
590
+ const c = v / 255;
591
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
592
+ };
593
+ const relLum = (rgb) => 0.2126 * f(rgb[0]) + 0.7152 * f(rgb[1]) + 0.0722 * f(rgb[2]);
594
+ const l1 = relLum(a);
595
+ const l2 = relLum(b);
596
+ return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
597
+ }
598
+
599
+ function hasVisibleTextRisk(textRuns = []) {
600
+ return textRuns.some((run) => {
601
+ if (!(run.w > 1 && run.h > 1)) return false;
602
+ const fg = parseRgbColor(run.color);
603
+ const bg = parseRgbColor(run.bgColor);
604
+ // 低置信图底/渐变底无法精确判断,交给 bakedText 与真实截图检查;这里不误杀透明/图片底夹具。
605
+ if (!fg || !bg || !run.bgSolid) return false;
606
+ return contrastRatio(fg, bg) >= 1.5;
607
+ });
608
+ }
609
+
610
+ async function transparentizeCornerBackground(path, extraPaths = []) {
611
+ const { PNG } = await importPackage('pngjs', extraPaths);
612
+ const buffer = await readFile(path);
613
+ const png = PNG.sync.read(buffer);
614
+ const { width, height, data } = png;
615
+ if (!width || !height) return false;
616
+
617
+ const pixelAt = (x, y) => {
618
+ const i = (y * width + x) * 4;
619
+ return [data[i], data[i + 1], data[i + 2], data[i + 3]];
620
+ };
621
+ const corner = pixelAt(0, 0);
622
+ const center = pixelAt(Math.floor(width / 2), Math.floor(height / 2));
623
+ // 若中心与角落几乎同色,通常是普通矩形白卡/同底色卡,透明化会误挖整块,直接跳过。
624
+ if (colorDistance(corner, center) < 8) return false;
625
+
626
+ const tolerance = 14;
627
+ const seen = new Uint8Array(width * height);
628
+ const stack = [
629
+ [0, 0],
630
+ [width - 1, 0],
631
+ [0, height - 1],
632
+ [width - 1, height - 1],
633
+ ];
634
+ let changed = false;
635
+ while (stack.length) {
636
+ const [x, y] = stack.pop();
637
+ if (x < 0 || y < 0 || x >= width || y >= height) continue;
638
+ const key = y * width + x;
639
+ if (seen[key]) continue;
640
+ seen[key] = 1;
641
+ const i = key * 4;
642
+ const px = [data[i], data[i + 1], data[i + 2], data[i + 3]];
643
+ if (px[3] === 0 || colorDistance(px, corner) <= tolerance) {
644
+ if (data[i + 3] !== 0) {
645
+ data[i + 3] = 0;
646
+ changed = true;
647
+ }
648
+ stack.push([x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]);
649
+ }
650
+ }
651
+ if (changed) {
652
+ await writeFile(path, PNG.sync.write(png, { colorType: 6 }));
653
+ }
654
+ return changed;
655
+ }
656
+
657
+ function cssColorToPpt(color) {
658
+ const transparent = { color: 'FFFFFF', transparency: 100 };
659
+ if (!color || color === 'transparent') return transparent;
660
+
661
+ const rgba = color.match(/rgba?\(([^)]+)\)/i);
662
+ if (!rgba) {
663
+ const hex = color.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
664
+ if (!hex) return transparent;
665
+ const raw = hex[1].length === 3
666
+ ? hex[1].split('').map((char) => char + char).join('')
667
+ : hex[1];
668
+ return { color: raw.toUpperCase(), transparency: 0 };
669
+ }
670
+
671
+ const parts = rgba[1].split(',').map((part) => part.trim());
672
+ const r = Number(parts[0]);
673
+ const g = Number(parts[1]);
674
+ const b = Number(parts[2]);
675
+ const alpha = parts.length > 3 ? Number(parts[3]) : 1;
676
+ if (![r, g, b, alpha].every(Number.isFinite)) return transparent;
677
+ const hex = [r, g, b]
678
+ .map((num) => Math.max(0, Math.min(255, Math.round(num))).toString(16).padStart(2, '0'))
679
+ .join('')
680
+ .toUpperCase();
681
+ return {
682
+ color: hex,
683
+ transparency: Math.max(0, Math.min(100, Math.round((1 - alpha) * 100))),
684
+ };
685
+ }
686
+
687
+ function normalizeText(text) {
688
+ return text.replace(/\s+/g, ' ').trim();
689
+ }
690
+
691
+ // 按导出机器平台选「中文兜底字体」:写进 PPTX 的 fontFace 必须是该机器**真实存在**的字体名,
692
+ // 否则 PowerPoint 打开时缺字 → 自动替换 → 可编辑文字与背景截图(Chromium 用本机字体渲染)字体/
693
+ // 度量错位。曾固定返回 Mac 专有的 'Hiragino Sans GB',在 Windows(绿色版主要分发面)上必被替换。
694
+ // 与 createPptx 里 theme 的 headFontFace/bodyFontFace 保持同一套默认。
695
+ const CJK_FALLBACK = process.platform === 'win32'
696
+ ? 'Microsoft YaHei'
697
+ : process.platform === 'darwin'
698
+ ? 'Hiragino Sans GB'
699
+ : 'Noto Sans CJK SC';
700
+
701
+ function pickFontFace(fontFamily = '') {
702
+ const normalized = fontFamily.toLowerCase();
703
+ // PingFang 是 Mac 专有名;非 Mac 机器上落地为本平台 CJK 兜底。
704
+ if (normalized.includes('pingfang')) {
705
+ return process.platform === 'darwin' ? 'Hiragino Sans GB' : CJK_FALLBACK;
706
+ }
707
+ // 显式命中的具体字体名按命中返回(含西文 Aptos/Arial);CJK 名命中也尊重 CSS 声明。
708
+ const candidates = [
709
+ 'Hiragino Sans GB',
710
+ 'Microsoft YaHei',
711
+ 'Noto Sans CJK SC',
712
+ 'Source Han Sans SC',
713
+ 'Aptos',
714
+ 'Arial',
715
+ ];
716
+ for (const candidate of candidates) {
717
+ if (normalized.includes(candidate.toLowerCase())) {
718
+ return candidate;
719
+ }
720
+ }
721
+ return CJK_FALLBACK;
722
+ }
723
+
724
+ // ---------------------------------------------------------------------------
725
+ // 模块加载(playwright-core / pptxgenjs;支持额外 node_modules 搜索路径)
726
+ // ---------------------------------------------------------------------------
727
+
728
+ function packageSearchPaths(extraPaths = []) {
729
+ const scriptDir = dirname(fileURLToPath(import.meta.url));
730
+ const envPaths = (process.env.NODE_PATH || '')
731
+ .split(':')
732
+ .map((item) => item.trim())
733
+ .filter(Boolean);
734
+ return [
735
+ process.cwd(),
736
+ scriptDir,
737
+ ...extraPaths.map((item) => resolve(item)),
738
+ ...envPaths,
739
+ ];
740
+ }
741
+
742
+ async function importPackage(name, extraPaths = []) {
743
+ try {
744
+ return await import(name);
745
+ } catch (firstError) {
746
+ const require = createRequire(import.meta.url);
747
+ try {
748
+ const resolved = require.resolve(name, { paths: packageSearchPaths(extraPaths) });
749
+ return await import(pathToFileURL(resolved).href);
750
+ } catch {
751
+ throw firstError;
752
+ }
753
+ }
754
+ }
755
+
756
+ async function loadModules(extraPaths = []) {
757
+ let chromium;
758
+ let pptxgenModule;
759
+ try {
760
+ ({ chromium } = await importPackage('playwright-core', extraPaths));
761
+ } catch (error) {
762
+ // 优雅降级:deck/PPT 导出是可选能力(playwright-core 列 optionalDependencies)。缺它时给可操作指引,不裸抛。
763
+ throw new Error(
764
+ 'deck/PPT 导出需要 playwright-core(渲染截图)。请安装:\n' +
765
+ ' npm i playwright-core pptxgenjs (或 bun add playwright-core pptxgenjs)\n' +
766
+ ' npx playwright install chromium (playwright-core 不含浏览器二进制,须单独装;或用系统 Chrome:launch({channel:"chrome"}))\n' +
767
+ `原始错误: ${error.message}`,
768
+ );
769
+ }
770
+ try {
771
+ pptxgenModule = await importPackage('pptxgenjs', extraPaths);
772
+ } catch (error) {
773
+ throw new Error(
774
+ 'deck/PPT 导出需要 pptxgenjs(生成 .pptx)。请安装:npm i pptxgenjs(或 bun add pptxgenjs),并用 Node 运行。' +
775
+ `原始错误: ${error.message}`,
776
+ );
777
+ }
778
+ const PptxGenJS = pptxgenModule.default ?? pptxgenModule;
779
+ return { chromium, PptxGenJS };
780
+ }
781
+
782
+ // ---------------------------------------------------------------------------
783
+ // 导出态 CSS:data-export-mode 取值 full / background / foreground / text
784
+ // full —— 仅隐 ignore 控件(flat 整页截图、量几何都在此态)
785
+ // background —— 隐前景 + 隐文本 → 纯背景整页截图
786
+ // foreground —— 隐文本(保留前景块自身底色)→ 逐元素切图(再叠 solo 隔离只显当前块)
787
+ // text —— 隐前景、底透明 → 仅用于落 pure-text.html 快照
788
+ // 文本选择器统一吃 data-pptx-kind="text" 与 data-pptx-layer="text" 两种写法。
789
+ // ---------------------------------------------------------------------------
790
+ function exportCss(width, height) {
791
+ return `
792
+ html[data-ui-pptx-export="true"],
793
+ html[data-ui-pptx-export="true"] body {
794
+ width: ${width}px !important;
795
+ height: ${height}px !important;
796
+ min-width: ${width}px !important;
797
+ min-height: ${height}px !important;
798
+ margin: 0 !important;
799
+ overflow: hidden !important;
800
+ }
801
+ html[data-ui-pptx-export="true"] .stage-shell {
802
+ width: ${width}px !important;
803
+ height: ${height}px !important;
804
+ min-height: ${height}px !important;
805
+ display: block !important;
806
+ overflow: hidden !important;
807
+ }
808
+ html[data-ui-pptx-export="true"] .scaled-stage {
809
+ width: ${width}px !important;
810
+ height: ${height}px !important;
811
+ position: relative !important;
812
+ inset: auto !important;
813
+ margin: 0 !important;
814
+ transform: none !important;
815
+ transform-origin: top left !important;
816
+ }
817
+ html[data-ui-pptx-export="true"] [data-ui-export-hidden="true"] {
818
+ display: none !important;
819
+ visibility: hidden !important;
820
+ }
821
+ html[data-ui-pptx-export="true"] [data-ui-export-stray="true"] {
822
+ display: none !important;
823
+ visibility: hidden !important;
824
+ opacity: 0 !important;
825
+ }
826
+ /* 流式 deck 适配:deck.html 现在是「每页 position:relative 纵向排开、人可滚动审阅」(像 PPT 导出的 PDF)。
827
+ 导出时 prepare() 逐页隐藏其余页(data-ui-export-hidden="true"),这里把「当前页」(未隐藏的那张,
828
+ data-ui-export-hidden="false")钉回视口左上、清零外边距/缩放 —— 不论 deck 浏览版式如何(流式/居中/页间距),
829
+ 导出都按真实 1920×1080 截这一页、几何零偏移(getBoundingClientRect 仍落在 0..宽/0..高,构图门/切图代码一行不改)。 */
830
+ html[data-ui-pptx-export="true"] [data-ui-export-hidden="false"] {
831
+ position: fixed !important;
832
+ top: 0 !important;
833
+ left: 0 !important;
834
+ margin: 0 !important;
835
+ transform: none !important;
836
+ }
837
+ /* 任意导出态都隐藏浏览器专用控件/调试条 */
838
+ html[data-export-mode] [data-pptx-export="ignore"] {
839
+ visibility: hidden !important;
840
+ }
841
+ /* 背景态:隐前景(中层离散块)+ 隐文本 → 纯背景(= AI 底图 / CSS 纯色渐变页级基底 / scrim,垫底)。
842
+ 前景以 [data-pptx-auto-fg](tagLayers 自动标的离散块)+ [data-pptx-layer="foreground"](显式)为准;
843
+ 文本以 [data-pptx-auto-text](含 SVG <text>)为准,兼容旧 kind/layer 标注。 */
844
+ html[data-export-mode="background"] [data-pptx-auto-fg],
845
+ html[data-export-mode="background"] [data-pptx-layer="foreground"],
846
+ html[data-export-mode="background"] [data-pptx-auto-text],
847
+ html[data-export-mode="background"] [data-pptx-kind="text"],
848
+ html[data-export-mode="background"] [data-pptx-layer="text"] {
849
+ visibility: hidden !important;
850
+ opacity: 0 !important;
851
+ color: transparent !important;
852
+ -webkit-text-fill-color: transparent !important;
853
+ text-shadow: none !important;
854
+ }
855
+ /* 前景态:**只显前景块**(隐背景/文本/其余一切 + 底透明)→ 真正的「纯前景」快照。
856
+ 前景块内的文字仍隐(交文本层还原)。 */
857
+ html[data-export-mode="foreground"] body * {
858
+ visibility: hidden !important;
859
+ }
860
+ html[data-export-mode="foreground"] [data-pptx-auto-fg],
861
+ html[data-export-mode="foreground"] [data-pptx-auto-fg] *,
862
+ html[data-export-mode="foreground"] [data-pptx-layer="foreground"],
863
+ html[data-export-mode="foreground"] [data-pptx-layer="foreground"] * {
864
+ visibility: visible !important;
865
+ }
866
+ html[data-export-mode="foreground"] [data-pptx-auto-fg] [data-pptx-auto-text],
867
+ html[data-export-mode="foreground"] [data-pptx-layer="foreground"] [data-pptx-auto-text],
868
+ html[data-export-mode="foreground"] [data-pptx-layer="foreground"] [data-pptx-kind="text"],
869
+ html[data-export-mode="foreground"] [data-pptx-layer="foreground"] [data-pptx-layer="text"] {
870
+ visibility: hidden !important;
871
+ opacity: 0 !important;
872
+ color: transparent !important;
873
+ -webkit-text-fill-color: transparent !important;
874
+ text-shadow: none !important;
875
+ }
876
+ html[data-export-mode="foreground"] body {
877
+ background: transparent !important;
878
+ }
879
+ /* 切图态(逐元素):**只显当前块**(data-fg-current)及其子树 + 底透明,隐其内文字 →
880
+ 配 locator.screenshot({omitBackground}) 切出纯净、透明区真透明的 PNG。与 foreground 态分开,
881
+ 避免「显所有前景」与「只显当前块」两套规则优先级互搏。 */
882
+ html[data-export-mode="fg-cut"] body * {
883
+ visibility: hidden !important;
884
+ }
885
+ /* 根 + body 都置透明(不止 body):omitBackground 只去 Playwright 默认白底,作者写在 html/:root 上的
886
+ 底色会从圆角/圆形切图的透明区透出 → 暗角/黑角。这里把根底色一并清掉,切图四角真透明。 */
887
+ html[data-export-mode="fg-cut"],
888
+ html[data-export-mode="fg-cut"] body {
889
+ background: transparent !important;
890
+ background-image: none !important;
891
+ }
892
+ html[data-export-mode="fg-cut"] [data-fg-current="true"],
893
+ html[data-export-mode="fg-cut"] [data-fg-current="true"] * {
894
+ visibility: visible !important;
895
+ }
896
+ /* 递归切图:切「当前块」时隐藏其**后代切图块**(各自有 data-pptx-fg-id)及其子树 →
897
+ 底板 plate 的切图只含自身底色/边框,子块在原位透出 plate 底、由各自切图还原 → 零重复、各自可拖。
898
+ 叶子块无后代切图块、此规则不影响(照常整切)。 */
899
+ html[data-export-mode="fg-cut"] [data-fg-current="true"] [data-pptx-fg-id],
900
+ html[data-export-mode="fg-cut"] [data-fg-current="true"] [data-pptx-fg-id] * {
901
+ visibility: hidden !important;
902
+ }
903
+ /* 隐藏当前块内的文本——**必须带子树 ` *`**,否则上面 [data-fg-current] *(规格 0,2,0) 会把
904
+ 「auto-text 容器」的子元素重新显示出来(真机:药丸 .pill 套在无样式 wrapper 里、wrapper 被标 auto-text,
905
+ hide 了 wrapper 但 .pill 子被 * 规则复显 → 文字+药丸底烧进中层切图,就是用户反复反馈的 4/16 页残留文字)。
906
+ 带 ` *` 的规格 (0,3,0) > [data-fg-current] *(0,2,0) → 容器及其整棵子树都隐藏。probe 查不到此类(DOM 上文字确已标
907
+ auto-text,只是切图态被 * 复显)——靠这条 CSS 子树规则根治。 */
908
+ html[data-export-mode="fg-cut"] [data-fg-current="true"] [data-pptx-auto-text],
909
+ html[data-export-mode="fg-cut"] [data-fg-current="true"] [data-pptx-auto-text] *,
910
+ html[data-export-mode="fg-cut"] [data-fg-current="true"] [data-pptx-kind="text"],
911
+ html[data-export-mode="fg-cut"] [data-fg-current="true"] [data-pptx-kind="text"] *,
912
+ html[data-export-mode="fg-cut"] [data-fg-current="true"] [data-pptx-layer="text"],
913
+ html[data-export-mode="fg-cut"] [data-fg-current="true"] [data-pptx-layer="text"] * {
914
+ visibility: hidden !important;
915
+ opacity: 0 !important;
916
+ color: transparent !important;
917
+ -webkit-text-fill-color: transparent !important;
918
+ text-shadow: none !important;
919
+ }
920
+ /* 文本态:**只显文本**(隐前景/背景/其余一切 + 底透明)→ 真正的「纯文本」快照 */
921
+ html[data-export-mode="text"] body * {
922
+ visibility: hidden !important;
923
+ }
924
+ html[data-export-mode="text"] [data-pptx-auto-text],
925
+ html[data-export-mode="text"] [data-pptx-auto-text] *,
926
+ html[data-export-mode="text"] [data-pptx-kind="text"],
927
+ html[data-export-mode="text"] [data-pptx-layer="text"],
928
+ html[data-export-mode="text"] [data-pptx-kind="text"] *,
929
+ html[data-export-mode="text"] [data-pptx-layer="text"] * {
930
+ visibility: visible !important;
931
+ }
932
+ html[data-export-mode="text"],
933
+ html[data-export-mode="text"] body {
934
+ background: transparent !important;
935
+ background-image: none !important;
936
+ }
937
+ `;
938
+ }
939
+
940
+ // 落「纯背景/前景/文本」HTML 中间产物:序列化当前 mode 的 documentElement,
941
+ // 注 <base> 指回原 HTML 目录(相对 assets/、url(...) 都能在单独打开时解析)。
942
+ async function writeLayerSnapshot(page, mode, outPath, htmlDir) {
943
+ await page.evaluate((m) => {
944
+ document.documentElement.setAttribute('data-export-mode', m);
945
+ document.documentElement.setAttribute('data-ui-pptx-export', 'true');
946
+ }, mode);
947
+ const html = await page.evaluate(() => `<!DOCTYPE html>\n${document.documentElement.outerHTML}`);
948
+ const baseHref = `${pathToFileURL(htmlDir).href}/`;
949
+ const baseTag = `<base href="${baseHref}">`;
950
+ const withBase = /<head(\s[^>]*)?>/i.test(html)
951
+ ? html.replace(/<head(\s[^>]*)?>/i, (m) => `${m}\n${baseTag}`)
952
+ : html.replace(/<html(\s[^>]*)?>/i, (m) => `${m}\n<head>${baseTag}</head>`);
953
+ await writeFile(outPath, withBase, 'utf8');
954
+ }
955
+
956
+ async function renderSlides({ chromium, htmlPath, selector, width, height, workdir, mode, measureOnly = false, withBgShots = false, nodeModules = [] }) {
957
+ let browser;
958
+ try {
959
+ browser = await chromium.launch({ headless: true });
960
+ } catch (error) {
961
+ try {
962
+ browser = await chromium.launch({ headless: true, channel: 'chrome' });
963
+ } catch (fallbackError) {
964
+ // playwright-core 装了但缺浏览器二进制——优雅降级给可操作指引(绿色版须把 chromium 打进分发)。
965
+ throw new Error(
966
+ '无法启动 Chromium:playwright-core 已装但缺浏览器二进制,且系统 Chrome fallback 也不可用。' +
967
+ '请跑 `npx playwright install chromium`,或安装 Google Chrome。绿色版需把 chromium 一并打进分发。' +
968
+ `原始错误: ${error.message}; fallback: ${fallbackError.message}`,
969
+ );
970
+ }
971
+ }
972
+ const page = await browser.newPage({ viewport: { width, height }, deviceScaleFactor: 1 });
973
+ const consoleErrors = [];
974
+ page.on('console', (message) => {
975
+ if (message.type() === 'error') {
976
+ consoleErrors.push(message.text());
977
+ }
978
+ });
979
+ page.on('pageerror', (error) => {
980
+ consoleErrors.push(error.message);
981
+ });
982
+
983
+ await page.goto(pathToFileURL(htmlPath).href, { waitUntil: 'networkidle' });
984
+ await page.addStyleTag({ content: String(exportCss(width, height)) });
985
+ await page.evaluate(() => {
986
+ document.documentElement.setAttribute('data-ui-pptx-export', 'true');
987
+ });
988
+
989
+ const layerSnapshots = {};
990
+ let wroteLayerSnapshots = false;
991
+
992
+ const slideMeta = await page.evaluate((slideSelector) => {
993
+ const nodes = Array.from(document.querySelectorAll(slideSelector));
994
+ if (nodes.length) {
995
+ const nodeSet = new Set(nodes);
996
+ for (const child of Array.from(document.body.childNodes)) {
997
+ if (child.nodeType === Node.TEXT_NODE && child.textContent.trim()) {
998
+ const span = document.createElement('span');
999
+ span.setAttribute('data-ui-export-stray', 'true');
1000
+ child.parentNode.insertBefore(span, child);
1001
+ span.appendChild(child);
1002
+ } else if (
1003
+ child.nodeType === Node.ELEMENT_NODE
1004
+ && !nodeSet.has(child)
1005
+ && !child.matches('script,style,template,noscript')
1006
+ && !child.querySelector(slideSelector)
1007
+ ) {
1008
+ child.setAttribute('data-ui-export-stray', 'true');
1009
+ }
1010
+ }
1011
+ }
1012
+ if (!nodes.length) {
1013
+ const label = document.body.getAttribute('data-screen-label') || document.title || '01 页面';
1014
+ return [{ index: 0, label, fallback: true }];
1015
+ }
1016
+ return nodes.map((node, index) => ({
1017
+ index,
1018
+ label: node.getAttribute('data-screen-label') || `${String(index + 1).padStart(2, '0')} 页面`,
1019
+ fallback: false,
1020
+ }));
1021
+ }, selector);
1022
+
1023
+ const slides = [];
1024
+ for (const meta of slideMeta) {
1025
+ // 逐页进度(stderr):导出多页时不再「无声」,便于看到卡在哪一页/哪一阶段。
1026
+ console.error(`[export] slide ${meta.index + 1}/${slideMeta.length}:${meta.label} 渲染中…`);
1027
+ const fullPath = resolve(workdir, `slide-${String(meta.index + 1).padStart(2, '0')}-full.png`);
1028
+ const backgroundPath = resolve(workdir, `slide-${String(meta.index + 1).padStart(2, '0')}-background.png`);
1029
+ // foreground 态整页截图(仅 --with-bg-shots 时补拍):文字隐藏、卡片/SVG/渐变/图片原样渲染——
1030
+ // 给「卡内文字」对比度采样提供真实像素背衬,绕开 CSS resolveBg 猜色的局限(渐变/SVG fill 猜不准)。
1031
+ const foregroundShotPath = resolve(workdir, `slide-${String(meta.index + 1).padStart(2, '0')}-foreground.png`);
1032
+
1033
+ async function prepare(exportMode, options = {}) {
1034
+ const visibleCount = await page.evaluate(({ slideSelector, index, exportMode: exportModeValue }) => {
1035
+ const INLINE_PROPS = [
1036
+ 'visibility',
1037
+ 'opacity',
1038
+ 'color',
1039
+ '-webkit-text-fill-color',
1040
+ 'text-shadow',
1041
+ 'pointer-events',
1042
+ ];
1043
+ const restoreInlineExportStyles = () => {
1044
+ for (const el of document.querySelectorAll('[data-ui-pptx-inline-hidden="true"]')) {
1045
+ for (const prop of INLINE_PROPS) {
1046
+ const key = prop.replace(/[^a-z]/gi, '_');
1047
+ const value = el.getAttribute(`data-ui-pptx-prev-${key}`) || '';
1048
+ const priority = el.getAttribute(`data-ui-pptx-prev-${key}-priority`) || '';
1049
+ if (value) el.style.setProperty(prop, value, priority);
1050
+ else el.style.removeProperty(prop);
1051
+ el.removeAttribute(`data-ui-pptx-prev-${key}`);
1052
+ el.removeAttribute(`data-ui-pptx-prev-${key}-priority`);
1053
+ }
1054
+ el.removeAttribute('data-ui-pptx-inline-hidden');
1055
+ }
1056
+ };
1057
+ const forceHide = (el) => {
1058
+ if (!el || el.nodeType !== 1 || el.getAttribute('data-pptx-export') === 'ignore') return;
1059
+ if (el.getAttribute('data-ui-pptx-inline-hidden') !== 'true') {
1060
+ el.setAttribute('data-ui-pptx-inline-hidden', 'true');
1061
+ for (const prop of INLINE_PROPS) {
1062
+ const key = prop.replace(/[^a-z]/gi, '_');
1063
+ el.setAttribute(`data-ui-pptx-prev-${key}`, el.style.getPropertyValue(prop) || '');
1064
+ el.setAttribute(`data-ui-pptx-prev-${key}-priority`, el.style.getPropertyPriority(prop) || '');
1065
+ }
1066
+ }
1067
+ el.style.setProperty('visibility', 'hidden', 'important');
1068
+ el.style.setProperty('opacity', '0', 'important');
1069
+ el.style.setProperty('color', 'transparent', 'important');
1070
+ el.style.setProperty('-webkit-text-fill-color', 'transparent', 'important');
1071
+ el.style.setProperty('text-shadow', 'none', 'important');
1072
+ el.style.setProperty('pointer-events', 'none', 'important');
1073
+ };
1074
+ const forceHideTree = (node) => {
1075
+ forceHide(node);
1076
+ for (const child of node.querySelectorAll?.('*') || []) forceHide(child);
1077
+ };
1078
+ const hideTextIn = (root) => {
1079
+ for (const el of root.querySelectorAll([
1080
+ '[data-pptx-auto-text]',
1081
+ '[data-pptx-kind="text"]',
1082
+ '[data-pptx-layer="text"]',
1083
+ ].join(','))) {
1084
+ forceHideTree(el);
1085
+ }
1086
+ };
1087
+ restoreInlineExportStyles();
1088
+ document.documentElement.setAttribute('data-export-mode', exportModeValue);
1089
+ document.documentElement.setAttribute('data-ui-pptx-export', 'true');
1090
+ // 进任意 mode 先清掉上一轮切图的 current 标记,避免残留影响可见性。
1091
+ for (const el of document.querySelectorAll('[data-fg-current]')) {
1092
+ el.removeAttribute('data-fg-current');
1093
+ }
1094
+ for (const el of document.querySelectorAll('[data-fg-child-hidden]')) {
1095
+ el.style.visibility = el.getAttribute('data-fg-prev-visibility') || '';
1096
+ el.removeAttribute('data-fg-prev-visibility');
1097
+ el.removeAttribute('data-fg-child-hidden');
1098
+ }
1099
+ const nodes = Array.from(document.querySelectorAll(slideSelector));
1100
+ if (nodes.length > 1) {
1101
+ nodes.forEach((node, nodeIndex) => {
1102
+ const shouldHide = nodeIndex !== index;
1103
+ node.hidden = shouldHide;
1104
+ node.setAttribute('data-ui-export-hidden', shouldHide ? 'true' : 'false');
1105
+ });
1106
+ } else if (nodes.length === 1) {
1107
+ nodes[0].hidden = false;
1108
+ nodes[0].setAttribute('data-ui-export-hidden', 'false');
1109
+ }
1110
+ const root = nodes.length ? nodes[index] : document.body;
1111
+ if (exportModeValue === 'background') {
1112
+ for (const el of root.querySelectorAll([
1113
+ '[data-pptx-auto-fg]',
1114
+ '[data-pptx-layer="foreground"]',
1115
+ '[data-pptx-auto-text]',
1116
+ '[data-pptx-kind="text"]',
1117
+ '[data-pptx-layer="text"]',
1118
+ ].join(','))) {
1119
+ forceHideTree(el);
1120
+ }
1121
+ } else if (exportModeValue === 'foreground') {
1122
+ hideTextIn(root);
1123
+ } else if (exportModeValue === 'fg-cut') {
1124
+ hideTextIn(root);
1125
+ for (const el of root.querySelectorAll('[data-pptx-auto-text], [data-pptx-kind="text"], [data-pptx-layer="text"]')) {
1126
+ forceHideTree(el);
1127
+ }
1128
+ } else if (exportModeValue === 'full') {
1129
+ // full 态用于量几何和最终对照图,必须恢复作者原始视觉。
1130
+ restoreInlineExportStyles();
1131
+ }
1132
+ window.dispatchEvent(new Event('resize'));
1133
+ const visibleNodes = nodes.filter((node) => {
1134
+ const style = window.getComputedStyle(node);
1135
+ const rect = node.getBoundingClientRect();
1136
+ return !node.hidden
1137
+ && node.getAttribute('data-ui-export-hidden') !== 'true'
1138
+ && style.display !== 'none'
1139
+ && style.visibility !== 'hidden'
1140
+ && rect.width > 1
1141
+ && rect.height > 1;
1142
+ });
1143
+ return nodes.length ? visibleNodes.length : 1;
1144
+ }, { slideSelector: selector, index: meta.index, exportMode });
1145
+ await page.evaluate(async () => {
1146
+ if (document.fonts?.ready) {
1147
+ await document.fonts.ready;
1148
+ }
1149
+ await new Promise((resolveFrame) => requestAnimationFrame(() => requestAnimationFrame(resolveFrame)));
1150
+ });
1151
+ await page.waitForTimeout(120);
1152
+ return visibleCount;
1153
+ }
1154
+
1155
+ const fullVisibleCount = await prepare('full');
1156
+ if (!measureOnly) {
1157
+ await page.screenshot({ path: fullPath, fullPage: false });
1158
+ }
1159
+ // measure-only 不截图:fullInfo 用画布尺寸占位(构图量不依赖它,仅 manifest 校验用)。
1160
+ const fullInfo = measureOnly ? { width, height, hash: '' } : await imageInfo(fullPath);
1161
+ console.error(`[export] slide ${meta.index + 1} ${measureOnly ? '量构图(measure-only)' : 'full 截图完成'}`);
1162
+
1163
+ let foreground = [];
1164
+ let textRuns = [];
1165
+ let hasBgImage = false;
1166
+ let backgroundInfo = null;
1167
+ let backgroundVisibleCount = null;
1168
+ let bakedText = [];
1169
+ // hybrid 走全套三层;measure-only 也抽几何(不论 mode),但跳过截图/切图。
1170
+ if (mode === 'hybrid' || measureOnly) {
1171
+ // 0) 自动分层打标(**必须早于背景/前景截图**):按结构标离散块(data-pptx-auto-fg→中层切图)
1172
+ // 与文本(data-pptx-auto-text→顶层可编辑),背景态隐藏二者 → 底图只剩 AI 图/CSS 纯色渐变基底。
1173
+ await prepare('full');
1174
+ await tagLayers(page, selector, meta.index);
1175
+
1176
+ // 0.5) 文本夹带探针(**full 态查标注覆盖率**,与截图态无关 → 同时覆盖背景层与中层切图的夹带;
1177
+ // 旧版只在 background 态查、看不到隐藏前景块内的文本,漏检中层切图夹带——这正是 16 页老毛病根因)。
1178
+ bakedText = await probeBakedText(page, selector, meta.index);
1179
+
1180
+ // 1) 抽取三层几何(在 full 态量 bbox,layout 不受 visibility 影响)。
1181
+ // 必须早于 background / fg-cut 截图:后续截图态需要知道哪些节点是文本、哪些节点是前景块。
1182
+ await prepare('full');
1183
+ const layers = await extractLayers(page, selector, meta.index);
1184
+ foreground = layers.foregroundBlocks;
1185
+ textRuns = layers.textRuns;
1186
+ hasBgImage = !!layers.hasBgImage;
1187
+
1188
+ // hybrid 下落 3 个整文档层快照(中间产物,仅供检视;功能截图取自实时页面各 mode)。
1189
+ // 必须在 tagLayers + extractLayers 之后生成,否则 pure-*.html 看不到 fgId 与自动分层结果,排查会被误导。
1190
+ // 只保存首个导出页的分层 HTML 快照;每页真实截图仍由 slide-XX-*.png 与 fg 切图承担。
1191
+ if (mode === 'hybrid' && !measureOnly && !wroteLayerSnapshots) {
1192
+ const htmlDir = dirname(htmlPath);
1193
+ for (const m of ['background', 'foreground', 'text']) {
1194
+ const snapPath = resolve(workdir, `pure-${m}.html`);
1195
+ try {
1196
+ await prepare(m);
1197
+ await writeLayerSnapshot(page, m, snapPath, htmlDir);
1198
+ layerSnapshots[m] = snapPath;
1199
+ } catch (error) {
1200
+ layerSnapshots[m] = null;
1201
+ consoleErrors.push(`落 pure-${m}.html 失败:${error.message}`);
1202
+ }
1203
+ }
1204
+ await prepare('full');
1205
+ wroteLayerSnapshots = true;
1206
+ }
1207
+
1208
+ // 2) 纯背景整页截图(隐前景 + 隐全部文本)—— measure-only 默认跳过;
1209
+ // --with-bg-shots 时补拍(像素可读性门要采样文字底下的真实背景像素)。
1210
+ backgroundVisibleCount = await prepare('background');
1211
+ if (!measureOnly) {
1212
+ await page.screenshot({ path: backgroundPath, fullPage: false });
1213
+ backgroundInfo = await imageInfo(backgroundPath);
1214
+ } else if (withBgShots) {
1215
+ await page.screenshot({ path: backgroundPath, fullPage: false });
1216
+ }
1217
+ // 2.5) foreground 态整页截图(隐文字,其余原样渲染)—— 仅 measure-only + --with-bg-shots;
1218
+ // 供像素可读性门给 fgParent 非空的文本(卡内/前景块内文字)采样真实背衬。
1219
+ if (measureOnly && withBgShots) {
1220
+ await prepare('foreground');
1221
+ await page.screenshot({ path: foregroundShotPath, fullPage: false });
1222
+ await prepare('background');
1223
+ }
1224
+ console.error(
1225
+ `[export] slide ${meta.index + 1} ${measureOnly ? '几何' : 'background 截图完成、'}前景 ${foreground.length} 块、文本 ${textRuns.length} 段`,
1226
+ );
1227
+
1228
+ if (measureOnly) {
1229
+ // 不切图,但声明的前景块都将进 PPT;标 imagePath 哨兵让 computeSlideBalance 按几何计入内容
1230
+ //(full 导出里 imagePath 是「切图成功」证据,measure-only 用哨兵等价表达「该块占位」)。
1231
+ for (const item of foreground) {
1232
+ item.imagePath = '<measure-only>';
1233
+ }
1234
+ } else if (foreground.length) {
1235
+ // 3) 切图态(fg-cut)逐元素切图:每块只显当前块(data-fg-current)→ omitBackground 截图 → 独立可编辑图片。
1236
+ await prepare('fg-cut');
1237
+ let fgIndex = 0;
1238
+ for (const item of foreground) {
1239
+ fgIndex += 1;
1240
+ const fgImagePath = resolve(workdir, geometryFilename(meta.index + 1, fgIndex, item));
1241
+ try {
1242
+ await page.evaluate((fgId) => {
1243
+ for (const el of document.querySelectorAll('[data-fg-child-hidden]')) {
1244
+ el.style.visibility = el.getAttribute('data-fg-prev-visibility') || '';
1245
+ el.removeAttribute('data-fg-prev-visibility');
1246
+ el.removeAttribute('data-fg-child-hidden');
1247
+ }
1248
+ for (const el of document.querySelectorAll('[data-pptx-fg-id]')) {
1249
+ if (el.getAttribute('data-pptx-fg-id') === fgId) {
1250
+ el.setAttribute('data-fg-current', 'true');
1251
+ for (const child of el.querySelectorAll('[data-pptx-fg-id]')) {
1252
+ child.setAttribute('data-fg-prev-visibility', child.style.visibility || '');
1253
+ child.setAttribute('data-fg-child-hidden', 'true');
1254
+ child.style.visibility = 'hidden';
1255
+ }
1256
+ } else {
1257
+ el.removeAttribute('data-fg-current');
1258
+ }
1259
+ }
1260
+ void document.body.offsetHeight; // 强制同步重排,确保 data-fg-current 引发的样式重算立即生效
1261
+ }, item.fgId);
1262
+ // 消竞态(治偶发「文字还没隐藏就被切进图」=中层切图残留文字):文本已由 fg-cut 全局规则一次性隐藏、
1263
+ // 全程不变(见 exportCss);这里再等字体就绪 + 2 帧 + 80ms,保证当前块内容重绘落定再截图。
1264
+ await page.evaluate(async () => {
1265
+ if (document.fonts?.ready) await document.fonts.ready;
1266
+ await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
1267
+ });
1268
+ await page.waitForTimeout(80);
1269
+ await page.screenshot({
1270
+ path: fgImagePath,
1271
+ clip: {
1272
+ x: Math.max(0, item.x),
1273
+ y: Math.max(0, item.y),
1274
+ width: Math.max(1, Math.min(item.w, width - Math.max(0, item.x))),
1275
+ height: Math.max(1, Math.min(item.h, height - Math.max(0, item.y))),
1276
+ },
1277
+ omitBackground: true,
1278
+ });
1279
+ if (item.needsAlphaMask) {
1280
+ item.alphaMaskApplied = await transparentizeCornerBackground(fgImagePath, nodeModules);
1281
+ }
1282
+ item.imagePath = fgImagePath;
1283
+ item.imageFile = basename(fgImagePath);
1284
+ } catch (error) {
1285
+ item.imageError = error.message;
1286
+ }
1287
+ }
1288
+ await prepare('full'); // prepare 会清掉 data-fg-current
1289
+ }
1290
+ }
1291
+
1292
+ // 讲稿(speaker notes):build 步按 design.speaker_notes 写入 <aside class="speaker-notes" hidden>。
1293
+ // display:none 不渲染、不进 textRuns/前景切图;此处取纯文本 → createPptx 写进 PPT 备注栏(演讲者视图)。
1294
+ // 无 aside 的 deck(webppt/旧产物)取到空串,导出行为与现状一致。
1295
+ const speakerNotes = await page.evaluate(({ slideSelector, slideIndex, fallback }) => {
1296
+ const node = fallback
1297
+ ? document.body
1298
+ : Array.from(document.querySelectorAll(slideSelector))[slideIndex];
1299
+ const aside = node && node.querySelector && node.querySelector('aside.speaker-notes');
1300
+ if (!aside) return '';
1301
+ return (aside.textContent || '').replace(/[ \t]+/g, ' ').replace(/\s*\n\s*/g, '\n').trim();
1302
+ }, { slideSelector: selector, slideIndex: meta.index, fallback: meta.fallback });
1303
+
1304
+ slides.push({
1305
+ ...meta,
1306
+ fullPath,
1307
+ backgroundPath: mode === 'hybrid' ? backgroundPath : null,
1308
+ fullInfo,
1309
+ backgroundInfo,
1310
+ fullVisibleCount,
1311
+ backgroundVisibleCount,
1312
+ hasBgImage,
1313
+ foreground,
1314
+ textRuns,
1315
+ bakedText,
1316
+ speakerNotes,
1317
+ });
1318
+ }
1319
+
1320
+ await browser.close();
1321
+ const validationErrors = [];
1322
+ const validationWarnings = [];
1323
+ const hashGroups = new Map();
1324
+ // measure-only 不产截图,跳过所有截图相关校验(占位 hash 全为空会误报「重复截图」)。
1325
+ if (!measureOnly) for (const slide of slides) {
1326
+ if (slide.fullVisibleCount !== 1) {
1327
+ validationErrors.push(`${slide.label} 导出 full 截图时可见 slide 数量为 ${slide.fullVisibleCount},应该为 1。`);
1328
+ }
1329
+ if (slide.fullInfo.width !== width || slide.fullInfo.height !== height) {
1330
+ validationErrors.push(`${slide.label} full 截图尺寸为 ${slide.fullInfo.width}x${slide.fullInfo.height},应该为 ${width}x${height}。`);
1331
+ }
1332
+ if (mode === 'hybrid') {
1333
+ if (slide.backgroundVisibleCount !== 1) {
1334
+ validationErrors.push(`${slide.label} 导出 background 截图时可见 slide 数量为 ${slide.backgroundVisibleCount},应该为 1。`);
1335
+ }
1336
+ if (slide.bakedText?.length) {
1337
+ const samples = slide.bakedText
1338
+ .slice(0, 4)
1339
+ .map((item) => `「${String(item.text || '').slice(0, 24)}」/${item.source || 'text'}`)
1340
+ .join('、');
1341
+ validationErrors.push(
1342
+ `${slide.label} 检测到 ${slide.bakedText.length} 段 HTML/SVG/伪元素文字会被截图夹带:${samples}。`
1343
+ + '这会导致图片文字不可编辑并与 PPT 文本层重影,必须先修 HTML 分层或用 data-pptx-export="ignore" 明确保留。',
1344
+ );
1345
+ }
1346
+ if (slide.backgroundInfo?.hash === slide.fullInfo.hash) {
1347
+ const riskyText = hasVisibleTextRisk(slide.textRuns || []);
1348
+ const riskyForeground = (slide.foreground || []).some((item) =>
1349
+ !['img', 'svg', 'canvas', 'video', 'picture'].includes(String(item.tag || '').toLowerCase()));
1350
+ const hasEditableLayers = riskyText || riskyForeground;
1351
+ const message = `${slide.label} background 截图与 full 截图完全相同,说明文本/前景没有从截图层剥离。`;
1352
+ if (hasEditableLayers) {
1353
+ const textClause = riskyText
1354
+ ? `本页有 ${slide.textRuns?.length || 0} 段可能可见文本`
1355
+ : '本页主要风险来自非媒体类前景块';
1356
+ validationErrors.push(`${message} ${textClause}、${slide.foreground?.length || 0} 个前景块;继续导出可能造成图片文字/前景与 PPT 顶层重影,已阻断。`);
1357
+ } else {
1358
+ validationWarnings.push(`${message} 但未检测到可见文本风险,可能是纯色/透明测试页。`);
1359
+ }
1360
+ }
1361
+ for (const item of slide.foreground) {
1362
+ if (item.imageError) {
1363
+ validationWarnings.push(`${slide.label} 前景块 ${item.fgId} 元素截图失败:${item.imageError}(该块未插入)。`);
1364
+ }
1365
+ }
1366
+ }
1367
+ const group = hashGroups.get(slide.fullInfo.hash) || [];
1368
+ group.push(slide.label);
1369
+ hashGroups.set(slide.fullInfo.hash, group);
1370
+ }
1371
+ for (const labels of hashGroups.values()) {
1372
+ if (labels.length > 1 && slides.length > 1) {
1373
+ validationErrors.push(`检测到重复截图 hash:${labels.join('、')} 完全相同。`);
1374
+ }
1375
+ }
1376
+ return { slides, consoleErrors, validationErrors, validationWarnings, layerSnapshots };
1377
+ }
1378
+
1379
+ // 自动分层打标(**必须在任何截图前跑**):按结构把一页分成三层,不再依赖作者逐个标注——
1380
+ // 标注漏一个,过去就会烧进底图、不可编辑(真机:序号/方框/色块/选项卡进底图、卡内文字烧死)。
1381
+ // ① 前景(中层):离散视觉块——replaced 内容(img/svg/canvas/video)或有「盒样式」(实色底/位图底/
1382
+ // 边框/阴影/圆角实色)的块,且**非全幅**(全幅=页/章节基底,留背景)、**够大**(滤掉点/线装饰)。
1383
+ // 打 data-pptx-auto-fg;去嵌套(命中即停,内层块烘进外层切图)。每块按 HTML 元素范围单独切图、
1384
+ // 可在 PPT 单独拖拽/复制/删除。**显式 data-pptx-layer="foreground" 一律视为块(用户 override)。**
1385
+ // ② 文本(顶层):「有文字 && 子元素全行内」的最紧容器 → 永远提成可编辑框、永不烧进图。
1386
+ // 纯文本元素整体打 data-pptx-auto-text;**带盒样式的 chip(如药丸 .card-tag)则把文字搬进内层
1387
+ // span[data-pptx-auto-text]**——这样切图里 chip 底色还在、只有文字被隐藏并提到顶层(修「色块上面
1388
+ // 文字」分层不清:过去整 chip 被当文本隐藏,连底色一起丢)。SVG `<text>` 各成一段。
1389
+ // ③ 背景(底层):其余一切——AI 底图、CSS 纯色/透明/渐变页级背景、scrim、纯布局空壳——整页截图垫底。
1390
+ // data-pptx-export="ignore" 子树全程跳过(显式保留烧进图,如嵌进图形的艺术字)。
1391
+ async function tagLayers(page, selector, index) {
1392
+ return page.evaluate(({ slideSelector, slideIndex }) => {
1393
+ const slideNodes = Array.from(document.querySelectorAll(slideSelector));
1394
+ const root = slideNodes.length ? slideNodes[slideIndex] : document.body;
1395
+ const rootRect = root.getBoundingClientRect();
1396
+ const SLIDE_W = rootRect.width || window.innerWidth;
1397
+ const SLIDE_H = rootRect.height || window.innerHeight;
1398
+
1399
+ const INLINE = /^(inline|inline-block|inline-flex|inline-grid|contents|ruby)/;
1400
+ const norm = (s) => (s || '').replace(/\s+/g, ' ').trim();
1401
+ const isSvgNs = (el) => el.namespaceURI === 'http://www.w3.org/2000/svg';
1402
+ const isIgnored = (el) => el.getAttribute && el.getAttribute('data-pptx-export') === 'ignore';
1403
+ const isMarkedFg = (el) => el.getAttribute && el.getAttribute('data-pptx-layer') === 'foreground';
1404
+ const NON_CONTENT_TAGS = new Set([
1405
+ 'SCRIPT', 'STYLE', 'TEMPLATE', 'NOSCRIPT',
1406
+ 'HEAD', 'TITLE', 'META', 'LINK', 'BASE',
1407
+ ]);
1408
+ const isNonContent = (el) => {
1409
+ if (!el || el.nodeType !== 1) return false;
1410
+ const tag = (el.tagName || '').toUpperCase();
1411
+ return NON_CONTENT_TAGS.has(tag) || !!el.closest?.('script,style,template,noscript,head,title,meta,link,base');
1412
+ };
1413
+ const REPLACED = new Set(['IMG', 'SVG', 'CANVAS', 'VIDEO', 'PICTURE']);
1414
+ const isReplaced = (el) => REPLACED.has((el.tagName || '').toUpperCase());
1415
+
1416
+ function visible(el, style, rect) {
1417
+ return style.display !== 'none' && style.visibility !== 'hidden'
1418
+ && rect.width > 1 && rect.height > 1;
1419
+ }
1420
+ function allChildrenInline(el) {
1421
+ for (const c of el.children) {
1422
+ if (!INLINE.test(getComputedStyle(c).display)) return false;
1423
+ }
1424
+ return true;
1425
+ }
1426
+ function bgAlpha(c) {
1427
+ const m = /rgba?\(([^)]+)\)/.exec(c || '');
1428
+ if (!m) return c && c !== 'transparent' ? 1 : 0;
1429
+ const p = m[1].split(',');
1430
+ return p.length >= 4 ? parseFloat(p[3]) : 1;
1431
+ }
1432
+ // 「盒样式」= 实色底 / 位图或渐变底 / 可见边框 / 阴影。背景裁切到文字(背景渐变当字色)不算盒。
1433
+ function hasBoxStyling(style) {
1434
+ const clipText = style.webkitBackgroundClip === 'text' || style.backgroundClip === 'text';
1435
+ const bgImg = !clipText && style.backgroundImage && style.backgroundImage !== 'none';
1436
+ const opaqueBg = bgAlpha(style.backgroundColor) > 0.1;
1437
+ const shadow = style.boxShadow && style.boxShadow !== 'none';
1438
+ const border = ['Top', 'Right', 'Bottom', 'Left'].some((s) =>
1439
+ parseFloat(style['border' + s + 'Width']) > 0 && style['border' + s + 'Style'] !== 'none');
1440
+ return bgImg || opaqueBg || shadow || border;
1441
+ }
1442
+ const isFullBleed = (rect) => rect.width >= SLIDE_W * 0.9 && rect.height >= SLIDE_H * 0.9;
1443
+ const sizeOK = (rect) => Math.min(rect.width, rect.height) >= 24 && rect.width * rect.height >= 2500;
1444
+
1445
+ // 一个元素是否「够格做独立切图块」(replaced 图,或盒样式块),且非全幅、够大。
1446
+ function isBlockEl(el) {
1447
+ if (!el || el.nodeType !== 1 || isIgnored(el)) return false;
1448
+ const t = el.tagName;
1449
+ if (isNonContent(el) || isSvgNs(el)) return false;
1450
+ const s = getComputedStyle(el);
1451
+ const r = el.getBoundingClientRect();
1452
+ if (!visible(el, s, r)) return false;
1453
+ if (isReplaced(el)) return !isFullBleed(r) && sizeOK(r);
1454
+ return hasBoxStyling(s) && !isFullBleed(r) && sizeOK(r);
1455
+ }
1456
+ // 数 el 子树里「最外层」的独立块后代个数(命中块即不再深入,透过非块容器继续数):
1457
+ // ≥2 → el 是「底板 plate」(递归切其子块);≤1 → el 叶子整切(防过碎,原行为)。
1458
+ function blockKidCount(el) {
1459
+ let n = 0;
1460
+ for (const c of el.children) {
1461
+ if (c.nodeType !== 1 || isIgnored(c)) continue;
1462
+ if (isBlockEl(c)) n += 1; // 最外层块 → 计 1、不深入(c 内更深的块归 c 自己决策)
1463
+ else n += blockKidCount(c); // 非块容器(透明 wrapper/grid)→ 透传其内最外层块数
1464
+ }
1465
+ return n;
1466
+ }
1467
+
1468
+ let fgCount = 0;
1469
+ let textCount = 0;
1470
+
1471
+ // ── 趟 1:标离散块(中层)——按 HTML 结构「递归切到底」:底板 plate 标记后继续切其子块 ──
1472
+ function walkFg(el) {
1473
+ if (!el || el.nodeType !== 1 || isIgnored(el)) return;
1474
+ if (isNonContent(el)) return;
1475
+ const style = getComputedStyle(el);
1476
+ const rect = el.getBoundingClientRect();
1477
+ if (!visible(el, style, rect)) return;
1478
+ if (isMarkedFg(el)) return; // 显式前景:用户 override=整切,停(extractLayers 对显式祖先去嵌套)
1479
+ if (isReplaced(el)) {
1480
+ if (!isFullBleed(rect) && sizeOK(rect)) { el.setAttribute('data-pptx-auto-fg', '1'); fgCount += 1; }
1481
+ return; // svg/img 是叶子、整切不拆(矢量/位图无法拆节点);svg <text> 由趟2提取
1482
+ }
1483
+ if (isSvgNs(el)) return;
1484
+ if (hasBoxStyling(style) && !isFullBleed(rect) && sizeOK(rect)) {
1485
+ el.setAttribute('data-pptx-auto-fg', '1');
1486
+ fgCount += 1;
1487
+ // ≥2 个独立块后代 → 这是底板 plate:继续递归把子块各自标成块(可多层嵌套)。
1488
+ // 切图时 fg-cut CSS 会在切 plate 时隐藏其后代切图块 → plate 只含自身底色/边框,子块各自切、零重复。
1489
+ // ≤1 → 叶子,整切(命中即停,防过碎;单子块的卡照旧吸收)。
1490
+ if (blockKidCount(el) >= 2) {
1491
+ for (const c of el.children) walkFg(c);
1492
+ }
1493
+ return;
1494
+ }
1495
+ for (const c of el.children) walkFg(c);
1496
+ }
1497
+ for (const c of root.children) walkFg(c);
1498
+
1499
+ // ── 趟 2:标/包裹文本(顶层)──
1500
+ // SVG <text> 各成一段(不下探 tspan)。
1501
+ for (const t of root.querySelectorAll('svg text')) {
1502
+ if ((t.closest && t.closest('[data-pptx-export="ignore"]')) || isNonContent(t) || !norm(t.textContent)) continue;
1503
+ t.setAttribute('data-pptx-auto-text', '1');
1504
+ textCount += 1;
1505
+ }
1506
+ function wrapText(el) {
1507
+ // 把 el 的子节点搬进 inline span[data-pptx-auto-text]:chip 底色留在 el、文字交给 span(可隐藏/提取)。
1508
+ if (el.querySelector('[data-pptx-auto-text]')) return;
1509
+ const span = document.createElement('span');
1510
+ span.setAttribute('data-pptx-auto-text', '1');
1511
+ span.style.display = 'inline';
1512
+ while (el.firstChild) span.appendChild(el.firstChild);
1513
+ el.appendChild(span);
1514
+ }
1515
+ function wrapStrayTextNodes(el) {
1516
+ // 把 el 的【直接挂的】非空文本节点各包进 inline span[data-pptx-auto-text]:
1517
+ // 图标/媒体或块级元素与文字混排时(如选项卡 <div><svg/>概览</div>、标签 <div><i></i>新功能</div>),
1518
+ // 这些裸文本既非元素子(walkText 的 el.children 遍历不到)、又因 hasMedia 跳过整体提取 → 过去烧进切图、不可编辑。
1519
+ // 这里把它们提到顶层可编辑文本,不影响同级图标/媒体(仍各自成块切图)。
1520
+ for (const node of Array.from(el.childNodes)) {
1521
+ if (node.nodeType === 3 && norm(node.textContent)) { // TEXT_NODE 且有可见文字
1522
+ if (isNonContent(node.parentElement)) continue;
1523
+ const span = document.createElement('span');
1524
+ span.setAttribute('data-pptx-auto-text', '1');
1525
+ span.style.display = 'inline';
1526
+ el.insertBefore(span, node);
1527
+ span.appendChild(node);
1528
+ textCount += 1;
1529
+ }
1530
+ }
1531
+ }
1532
+ function walkText(el) {
1533
+ if (!el || el.nodeType !== 1 || isIgnored(el)) return;
1534
+ if (el.getAttribute('data-pptx-auto-text') === '1') return; // 已提取(防御 + 不重复处理新插的 span)
1535
+ if (isNonContent(el)) return;
1536
+ if (isSvgNs(el)) return; // svg <text> 已处理
1537
+ const style = getComputedStyle(el);
1538
+ const rect = el.getBoundingClientRect();
1539
+ if (!visible(el, style, rect)) return;
1540
+ const hasMedia = !!el.querySelector('img, svg, canvas, video, picture');
1541
+ if (norm(el.textContent) && allChildrenInline(el) && !hasMedia) {
1542
+ if (hasBoxStyling(style)) {
1543
+ wrapText(el); // 带底色的 chip:文字进内层 span,底色留 el(切图保留底色、文字提顶层)
1544
+ } else {
1545
+ el.setAttribute('data-pptx-auto-text', '1'); // 纯文本:整体提为可编辑框
1546
+ }
1547
+ textCount += 1;
1548
+ return; // 命中即停
1549
+ }
1550
+ // 混排(含媒体/块级子,未整体命中):先提本元素【直接挂的】裸文本(图标+文字、容器直挂的说明),
1551
+ // 再下探【原有】元素子(快照,避免把刚插入的 auto-text span 又处理一遍)。
1552
+ const elemChildren = Array.from(el.children);
1553
+ wrapStrayTextNodes(el);
1554
+ for (const c of elemChildren) walkText(c);
1555
+ }
1556
+ for (const c of root.children) walkText(c);
1557
+
1558
+ // ── 趟 2.5 兜底(穷尽标注):凡 root 下仍未被 auto-text 覆盖的可见文本节点,一律包进 span[data-pptx-auto-text]。
1559
+ // 堵 walkText 条件分支可能漏标的边角(深层混排/异常结构)→ 保证「任何截图(背景/中层切图)都绝不夹带 HTML 文本」。
1560
+ // svg <text> 已在趟 2 标过(其文本节点的 closest auto-text 命中);ignore 子树显式保留烧图,跳过。
1561
+ const strayNodes = [];
1562
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);
1563
+ let tnode;
1564
+ while ((tnode = walker.nextNode())) {
1565
+ if (!norm(tnode.textContent)) continue;
1566
+ const el = tnode.parentElement;
1567
+ if (!el || isSvgNs(el) || isNonContent(el)) continue;
1568
+ if (el.closest('[data-pptx-auto-text]') || el.closest('[data-pptx-export="ignore"]')) continue;
1569
+ const st = getComputedStyle(el);
1570
+ const r = el.getBoundingClientRect();
1571
+ if (!visible(el, st, r)) continue;
1572
+ strayNodes.push(tnode);
1573
+ }
1574
+ for (const tnode of strayNodes) {
1575
+ const span = document.createElement('span');
1576
+ span.setAttribute('data-pptx-auto-text', '1');
1577
+ span.style.display = 'inline';
1578
+ tnode.parentNode.insertBefore(span, tnode);
1579
+ span.appendChild(tnode);
1580
+ textCount += 1;
1581
+ }
1582
+
1583
+ return { fgCount, textCount };
1584
+ }, { slideSelector: selector, slideIndex: index });
1585
+ }
1586
+
1587
+ // 文本夹带探针(**在 full 态调,查「标注覆盖率」——与具体截图态无关,故同时覆盖背景层与中层切图**):
1588
+ // 不变量「标了 data-pptx-auto-text 的文本在 background / fg-cut 两态都被隐藏 → 不会烧进任何截图;没标且可见的文本
1589
+ // 必会烧进某张截图(背景层 或 它所在前景块的中层切图)」。所以只需查:root 下**有没有可见、却未被 auto-text 覆盖、
1590
+ // 且不在 ignore 子树**的文本——有就是会夹带(旧版只在 background 态查,看不到隐藏的前景块内文本 → 漏检中层切图夹带)。
1591
+ // ① 真实 DOM 文本节点:未被 auto-text 覆盖(tagLayers 漏标,含前景块内的)→ 会烧进图。
1592
+ // ② `::before/::after` 伪元素可读文字 = 无法标注/隐藏/提取,**必**烧进图(应改成真实 DOM 文本)。
1593
+ // ignore 子树(data-pptx-export="ignore")是显式保留烧图(如嵌图形的艺术字),跳过。AI `<img>` 无文本节点、不误报。
1594
+ // 返回归一化坐标,挂到 slide.bakedText → computeSlideBalance 透传 → layout_balance.py 报 ERROR。
1595
+ async function probeBakedText(page, selector, index) {
1596
+ return page.evaluate(({ slideSelector, slideIndex }) => {
1597
+ const slideNodes = Array.from(document.querySelectorAll(slideSelector));
1598
+ const root = slideNodes.length ? slideNodes[slideIndex] : document.body;
1599
+ const vw = window.innerWidth;
1600
+ const vh = window.innerHeight;
1601
+ const norm = (s) => (s || '').replace(/\s+/g, ' ').trim();
1602
+ const nrect = (r) => ({
1603
+ x: Math.round((r.left / vw) * 1000) / 1000,
1604
+ y: Math.round((r.top / vh) * 1000) / 1000,
1605
+ w: Math.round((r.width / vw) * 1000) / 1000,
1606
+ h: Math.round((r.height / vh) * 1000) / 1000,
1607
+ });
1608
+ // full 态可见 = 渲染出非零区域且在视口内(不看 visibility——查的是「未隐藏前是否有这段文本」)。
1609
+ const isShown = (el) => {
1610
+ const st = getComputedStyle(el);
1611
+ if (st.display === 'none' || parseFloat(st.opacity) === 0) return false;
1612
+ const r = el.getBoundingClientRect();
1613
+ return r.width > 1 && r.height > 1 && r.right > 0 && r.bottom > 0 && r.left < vw && r.top < vh;
1614
+ };
1615
+ const covered = (el) =>
1616
+ el.closest('[data-pptx-auto-text]') || el.closest('[data-pptx-export="ignore"]');
1617
+ const out = [];
1618
+ const seen = new Set();
1619
+ // ① 未被 auto-text 覆盖的可见真实文本节点(含前景块内的 → 会烧进中层切图)。
1620
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);
1621
+ let node;
1622
+ while ((node = walker.nextNode())) {
1623
+ const t = norm(node.textContent);
1624
+ if (!t) continue;
1625
+ const el = node.parentElement;
1626
+ if (!el || covered(el) || !isShown(el)) continue;
1627
+ const r = el.getBoundingClientRect();
1628
+ const key = `${t}@${Math.round(r.left)},${Math.round(r.top)}`;
1629
+ if (seen.has(key)) continue;
1630
+ seen.add(key);
1631
+ out.push({ text: t.slice(0, 24), source: 'dom', rect: nrect(r) });
1632
+ }
1633
+ // ② ::before/::after 伪元素的可读文字(仅抓含字母/数字/CJK 且 ≥2 字符的真实文字,放过 •·—→ 等装饰符号)。
1634
+ for (const el of [root, ...root.querySelectorAll('*')]) {
1635
+ if (el.closest('[data-pptx-export="ignore"]') || !isShown(el)) continue;
1636
+ for (const pe of ['::before', '::after']) {
1637
+ const c = getComputedStyle(el, pe).content;
1638
+ if (!c || c === 'none' || c === 'normal') continue;
1639
+ const m = /^["'](.*)["']$/.exec(c);
1640
+ const txt = m ? norm(m[1]) : '';
1641
+ if (txt.length < 2 || !/[\p{L}\p{N}]/u.test(txt)) continue;
1642
+ out.push({ text: txt.slice(0, 24), source: 'pseudo', rect: nrect(el.getBoundingClientRect()) });
1643
+ }
1644
+ }
1645
+ return out.slice(0, 12);
1646
+ }, { slideSelector: selector, slideIndex: index });
1647
+ }
1648
+
1649
+ // 抽取三层几何:
1650
+ // foregroundBlocks —— 最外层 foreground(祖先链无另一 foreground,去嵌套),打稳定 fgId 供 locator。
1651
+ // textRuns —— 最外层 text(data-pptx-kind="text" 或 data-pptx-layer="text"),带文字与样式。
1652
+ // 几何用 raw getBoundingClientRect(不做视口钳制 → 切图与摆放同源一致;isVisible 仅做相交门控)。
1653
+ async function extractLayers(page, selector, index) {
1654
+ return page.evaluate(({ slideSelector, slideIndex }) => {
1655
+ const slideNodes = Array.from(document.querySelectorAll(slideSelector));
1656
+ const root = slideNodes.length ? slideNodes[slideIndex] : document.body;
1657
+ const viewport = { width: window.innerWidth, height: window.innerHeight };
1658
+
1659
+ function isVisible(node, rect, style) {
1660
+ return rect.width > 1
1661
+ && rect.height > 1
1662
+ && rect.right > 0
1663
+ && rect.bottom > 0
1664
+ && rect.left < viewport.width
1665
+ && rect.top < viewport.height
1666
+ && style.visibility !== 'hidden'
1667
+ && style.display !== 'none'
1668
+ && node.getAttribute('data-pptx-export') !== 'ignore';
1669
+ }
1670
+
1671
+ const isText = (node) => node.getAttribute('data-pptx-kind') === 'text'
1672
+ || node.getAttribute('data-pptx-layer') === 'text';
1673
+ // 只对**显式** data-pptx-layer="foreground" 祖先去嵌套(用户 override=整切,子块烘进它);
1674
+ // auto-fg 之间的嵌套**保留**(递归切图:plate 与子块各自成块、各自 fgId、切 plate 时隐后代块)。
1675
+ const isExplicitFg = (node) => node.getAttribute('data-pptx-layer') === 'foreground';
1676
+
1677
+ // 去嵌套:node 的祖先链(到 root 为止)里是否已有同类标注元素。
1678
+ function hasAncestorMatching(node, predicate) {
1679
+ let parent = node.parentElement;
1680
+ while (parent && parent !== root.parentElement) {
1681
+ if (parent === root) return false;
1682
+ if (predicate(parent)) return true;
1683
+ parent = parent.parentElement;
1684
+ }
1685
+ return false;
1686
+ }
1687
+
1688
+ // --- 前景块:最外层 foreground(显式 layer="foreground" ∪ tagLayers 自动标的 auto-fg)---
1689
+ const fgCandidates = Array.from(
1690
+ root.querySelectorAll('[data-pptx-layer="foreground"], [data-pptx-auto-fg]'),
1691
+ );
1692
+ const foregroundBlocks = [];
1693
+ let nodeIndex = 0;
1694
+ for (const node of fgCandidates) {
1695
+ const rect = node.getBoundingClientRect();
1696
+ const style = getComputedStyle(node);
1697
+ if (!isVisible(node, rect, style)) continue;
1698
+ // 文本优先:标了 text 或被 tagLayers 认成纯文本 run 的元素归文本层(顶层原生框),不当前景切图。
1699
+ if (isText(node) || node.getAttribute('data-pptx-auto-text') === '1') continue;
1700
+ if (hasAncestorMatching(node, isExplicitFg)) continue; // 仅显式 foreground 祖先去嵌套;auto-fg 嵌套保留(递归切)
1701
+ const fgId = `fg-${slideIndex}-${nodeIndex}`;
1702
+ nodeIndex += 1;
1703
+ node.setAttribute('data-pptx-fg-id', fgId);
1704
+ // hasMedia:块内有「实质视觉填充」→ 是「媒体/图显卡」,空心卡片闸门豁免它(视觉在填版面、文字少属正常);
1705
+ // 纯文本框/色块卡(无视觉填充)才量文字填充率。视觉填充 = 含 img/svg/canvas/video/底图,
1706
+ // **或含一个覆盖 ≥40% 卡面积的带样式子块**(如深色 code-block、内嵌图显示区——只数文字会误判其为空心)。
1707
+ // 注意:只看**子块**的样式底,卡自身的纯色底不算(白卡装一句话正是要抓的空心);卡内小药丸/标签太小不触发。
1708
+ const cardArea = rect.width * rect.height;
1709
+ const _alpha = (c) => {
1710
+ const m = /rgba?\(([^)]+)\)/.exec(c || '');
1711
+ if (!m) return c && c !== 'transparent' ? 1 : 0;
1712
+ const p = m[1].split(',');
1713
+ return p.length >= 4 ? parseFloat(p[3]) : 1;
1714
+ };
1715
+ const _boxFill = (s) => /url\(/i.test(s.backgroundImage) || _alpha(s.backgroundColor) > 0.1
1716
+ || (s.boxShadow && s.boxShadow !== 'none')
1717
+ || ['Top', 'Right', 'Bottom', 'Left'].some((k) =>
1718
+ parseFloat(s['border' + k + 'Width']) > 0 && s['border' + k + 'Style'] !== 'none');
1719
+ let bigInnerVisual = false;
1720
+ for (const d of node.querySelectorAll('*')) {
1721
+ if (d.getAttribute('data-pptx-auto-text') === '1') continue; // 文字不算视觉填充
1722
+ const ds = getComputedStyle(d);
1723
+ if (ds.display === 'none' || ds.visibility === 'hidden' || !_boxFill(ds)) continue;
1724
+ const dr = d.getBoundingClientRect();
1725
+ if (dr.width * dr.height >= cardArea * 0.4) { bigInnerVisual = true; break; }
1726
+ }
1727
+ const hasMedia = !!node.querySelector('img, svg, canvas, video, picture')
1728
+ || /url\(/i.test(style.backgroundImage)
1729
+ || bigInnerVisual;
1730
+ const radiusText = [
1731
+ style.borderTopLeftRadius,
1732
+ style.borderTopRightRadius,
1733
+ style.borderBottomRightRadius,
1734
+ style.borderBottomLeftRadius,
1735
+ ].join(' ');
1736
+ const needsAlphaMask = radiusText !== '0px 0px 0px 0px'
1737
+ || (style.clipPath && style.clipPath !== 'none')
1738
+ || (style.webkitClipPath && style.webkitClipPath !== 'none');
1739
+ // sourceFile(加性字段):客户图溯源。优先 data-source-file(少数页会标),
1740
+ // 真机绝大多数页未标注此属性——回落取内部 <img> 的 src 文件名(如
1741
+ // "assets/enhanced_src_8.png" → "enhanced_src_8.png",Python 侧再按 stem 去
1742
+ // "enhanced_" 前缀比对,与 source_usage_check.py::_derived_candidates 同一套约定)。
1743
+ // 供 layout_balance --ppt-strict 的 min_canvas_fraction 检查反查真实渲染槽位宽度
1744
+ // 是否达标(webppt 无客户图/无 source_layout_plan.json,此字段恒 null 不影响其行为)。
1745
+ const sourceImg = node.matches('img') ? node : node.querySelector('img');
1746
+ const sourceFile = node.getAttribute('data-source-file')
1747
+ || sourceImg?.getAttribute('data-source-file')
1748
+ || (sourceImg?.getAttribute('src') ? sourceImg.getAttribute('src').split('/').pop() : null)
1749
+ || null;
1750
+ foregroundBlocks.push({
1751
+ fgId,
1752
+ x: rect.left,
1753
+ y: rect.top,
1754
+ w: rect.width,
1755
+ h: rect.height,
1756
+ tag: node.tagName.toLowerCase(),
1757
+ kind: node.getAttribute('data-pptx-kind') || null,
1758
+ hasMedia,
1759
+ needsAlphaMask,
1760
+ sourceFile,
1761
+ });
1762
+ }
1763
+
1764
+ // --- 文本段:tagAutoText 已按结构打全 data-pptx-auto-text(含 SVG <text>),这里只取几何+样式 ---
1765
+ const isAutoText = (node) => node.getAttribute('data-pptx-auto-text') === '1';
1766
+ // 解析文本背后的「有效背景色」(供对比度门):向上找第一个不透明 backgroundColor 的祖先——
1767
+ // 白卡上的字→白卡色、深底页直接字→页底深色;纯渐变(无不透明底)继续上找(渐变多是 tint);
1768
+ // 位图底(AI 图)解析不出纯色→低置信(bgSolid=false,闸门只 WARNING 不误杀);走到根回退 body 底色。
1769
+ const _bgAlpha = (c) => {
1770
+ const m = /rgba?\(([^)]+)\)/.exec(c || '');
1771
+ if (!m) return c && c !== 'transparent' ? 1 : 0;
1772
+ const p = m[1].split(',');
1773
+ return p.length >= 4 ? parseFloat(p[3]) : 1;
1774
+ };
1775
+ const resolveBg = (el) => {
1776
+ let p = el;
1777
+ while (p && p !== document.documentElement) {
1778
+ const s = getComputedStyle(p);
1779
+ if (_bgAlpha(s.backgroundColor) > 0.5) return { color: s.backgroundColor, solid: true };
1780
+ if (s.backgroundImage && s.backgroundImage !== 'none' && !/gradient/i.test(s.backgroundImage)) {
1781
+ return { color: null, solid: false }; // 位图底(AI 图)→ 解析不出纯色
1782
+ }
1783
+ p = p.parentElement;
1784
+ }
1785
+ const bs = getComputedStyle(document.body);
1786
+ return { color: _bgAlpha(bs.backgroundColor) > 0.5 ? bs.backgroundColor : 'rgb(255,255,255)', solid: true };
1787
+ };
1788
+ const textNodes = Array.from(root.querySelectorAll('[data-pptx-auto-text]'));
1789
+ const textRuns = [];
1790
+ for (const node of textNodes) {
1791
+ const rect = node.getBoundingClientRect();
1792
+ const style = getComputedStyle(node);
1793
+ if (!isVisible(node, rect, style)) continue;
1794
+ // 讲稿 aside(display:none,isVisible 已天然排除)——显式再拦一道,保证讲稿绝不变成幻灯片上的文本框。
1795
+ if (node.closest && node.closest('aside.speaker-notes')) continue;
1796
+ // 打标已保证 run 间不嵌套;防御性再去一次(极端手写 HTML)。
1797
+ if (hasAncestorMatching(node, isAutoText)) continue;
1798
+ const text = (node.getAttribute('aria-label') || node.textContent || '').replace(/\s+/g, ' ').trim();
1799
+ if (!text) continue;
1800
+ const isSvgText = node.namespaceURI === 'http://www.w3.org/2000/svg';
1801
+ let fontSize = Number.parseFloat(style.fontSize) || 16;
1802
+ let align = style.textAlign;
1803
+ let color = style.color;
1804
+ let fontWeight = style.fontWeight;
1805
+ if (isSvgText) {
1806
+ // SVG 字号是 user-unit,须乘 viewBox→视口缩放才是视觉像素;text-anchor 决定对齐;颜色取 fill。
1807
+ const ownerSvg = node.ownerSVGElement || (node.closest && node.closest('svg'));
1808
+ let scale = 1;
1809
+ if (ownerSvg && ownerSvg.viewBox && ownerSvg.viewBox.baseVal && ownerSvg.viewBox.baseVal.height > 0) {
1810
+ const svgRect = ownerSvg.getBoundingClientRect();
1811
+ if (svgRect.height > 0) scale = svgRect.height / ownerSvg.viewBox.baseVal.height;
1812
+ }
1813
+ fontSize *= scale;
1814
+ const anchor = style.textAnchor || node.getAttribute('text-anchor') || 'start';
1815
+ align = anchor === 'middle' ? 'center' : anchor === 'end' ? 'right' : 'left';
1816
+ color = style.fill || style.color;
1817
+ fontWeight = style.fontWeight || node.getAttribute('font-weight') || '400';
1818
+ }
1819
+ // 溢出量(像素):内容比框高/宽出多少——HTML 受限框(fixed height + overflow)才 >0,
1820
+ // SVG/内容自适应框为 0。供构图闸门「文本溢出」硬门用(HTML 阶段就保证装得下,导出不靠缩字)。
1821
+ const overflow = Math.max(
1822
+ 0,
1823
+ (node.scrollHeight || 0) - (node.clientHeight || 0),
1824
+ (node.scrollWidth || 0) - (node.clientWidth || 0),
1825
+ );
1826
+ // 文本所属前景块(如 SVG 图框/卡片):同一前景块内的文本互相重叠是「图内固有间距」(如
1827
+ // 架构图标签密排),不算缺陷 → 闸门豁免同 fgParent 的 text-text 重叠(跨块/无块的真重叠仍报)。
1828
+ const fgAnc = node.closest && node.closest('[data-pptx-fg-id]');
1829
+ const fgParent = fgAnc ? fgAnc.getAttribute('data-pptx-fg-id') : null;
1830
+ const bg = resolveBg(node);
1831
+ textRuns.push({
1832
+ text,
1833
+ x: rect.left,
1834
+ y: rect.top,
1835
+ w: rect.width,
1836
+ h: rect.height,
1837
+ fontSize,
1838
+ lineHeight: Number.parseFloat(style.lineHeight) || null,
1839
+ letterSpacing: Number.parseFloat(style.letterSpacing) || null, // normal/0 → null(不强加字距)
1840
+ fontWeight,
1841
+ fontFamily: style.fontFamily,
1842
+ color,
1843
+ bgColor: bg.color,
1844
+ bgSolid: bg.solid,
1845
+ textAlign: align,
1846
+ overflow,
1847
+ fgParent,
1848
+ });
1849
+ }
1850
+
1851
+ // --- 是否有「真实图片填满背景」(B 路线全幅氛围图 / 大幅未标 foreground 的 <img>)---
1852
+ // 用于构图闸门区分「靠大图铺满的稀疏文字页(合法)」与「纯文字/卡片却空半的页(缺陷)」。
1853
+ // 渐变/纯色背景不算(只认 url(...) 的位图与足够大的裸 <img>)。
1854
+ let hasBgImage = false;
1855
+ const bgScan = [root, ...root.querySelectorAll('*')];
1856
+ for (const el of bgScan) {
1857
+ const bg = getComputedStyle(el).backgroundImage;
1858
+ if (bg && /url\(/i.test(bg)) { hasBgImage = true; break; }
1859
+ }
1860
+ if (!hasBgImage) {
1861
+ const rootR = root.getBoundingClientRect();
1862
+ const tolX = rootR.width * 0.02;
1863
+ const tolY = rootR.height * 0.02;
1864
+ for (const img of root.querySelectorAll('img')) {
1865
+ let p = img;
1866
+ let inFg = false;
1867
+ while (p && p !== root.parentElement) {
1868
+ if (p.getAttribute && p.getAttribute('data-pptx-layer') === 'foreground') { inFg = true; break; }
1869
+ p = p.parentElement;
1870
+ }
1871
+ if (inFg) continue; // A 路线插图已算前景,不算背景
1872
+ const r = img.getBoundingClientRect();
1873
+ if (!(r.width > 200 && r.height > 200)) continue;
1874
+ // Gap A 修复(s5/s9/s10 事故):撑爆 grid/flex 轨道或越出画布的大图**不是背景**,而是布局缺陷本身
1875
+ //(.fig-contain 内 <img> 无 min-width:0,原始像素撑爆 .85fr 轨道,图与内容一起被挤出画布)。
1876
+ // 真背景图应铺在页内、不显著超出画布或其容器 —— 越界的大图若被误当"有背景",会让 layout_balance
1877
+ // 据此把"内容全飞出的空页"误判为"纯图封面"降级放行(Gap B)。故此处排除越界大图,不计为背景。
1878
+ const overflowsCanvas =
1879
+ r.width > rootR.width + tolX ||
1880
+ r.height > rootR.height + tolY ||
1881
+ r.left < rootR.left - tolX ||
1882
+ r.top < rootR.top - tolY ||
1883
+ r.right > rootR.right + tolX ||
1884
+ r.bottom > rootR.bottom + tolY;
1885
+ if (overflowsCanvas) continue;
1886
+ hasBgImage = true;
1887
+ break;
1888
+ }
1889
+ }
1890
+
1891
+ return { foregroundBlocks, textRuns, hasBgImage };
1892
+ }, { slideSelector: selector, slideIndex: index });
1893
+ }
1894
+
1895
+ async function createPptx({ PptxGenJS, slides, outPath, mode, width, height }) {
1896
+ const pptx = new PptxGenJS();
1897
+ pptx.author = 'ui-design export-pptx';
1898
+ pptx.subject = 'ui-design HTML 导出(三层:背景/前景/文本)';
1899
+ pptx.title = basename(outPath, extname(outPath));
1900
+ pptx.company = 'ui-design';
1901
+ pptx.lang = 'zh-CN';
1902
+ pptx.defineLayout({ name: 'UI_DESIGN_16_9', width: SLIDE_W, height: SLIDE_H });
1903
+ pptx.layout = 'UI_DESIGN_16_9';
1904
+ pptx.theme = {
1905
+ headFontFace: CJK_FALLBACK,
1906
+ bodyFontFace: CJK_FALLBACK,
1907
+ lang: 'zh-CN',
1908
+ };
1909
+
1910
+ for (const item of slides) {
1911
+ const slide = pptx.addSlide();
1912
+ slide.background = { color: 'FFFFFF' };
1913
+ // ① 背景:底图整页(flat 用 full 整图,hybrid 用纯背景截图)。
1914
+ const imagePath = mode === 'hybrid' ? item.backgroundPath : item.fullPath;
1915
+ slide.addImage({ path: imagePath, x: 0, y: 0, w: SLIDE_W, h: SLIDE_H });
1916
+
1917
+ if (mode === 'hybrid') {
1918
+ // ② 前景:逐元素切图(独立可编辑图片对象,中层)。
1919
+ addForegroundCutouts(slide, item.foreground, width, height);
1920
+ // ③ 文本:原生可编辑文本框(顶层;后插即在上 → 压住前景/背景)。
1921
+ addTextRuns(slide, item.textRuns, width, height);
1922
+ }
1923
+ // 讲稿进 PPT 备注栏(演讲者视图可见);无讲稿的 deck 保持原样只写溯源。
1924
+ slide.addNotes(
1925
+ item.speakerNotes ? `${item.speakerNotes}\n\n导出来源: ${item.label}` : `导出来源: ${item.label}`,
1926
+ );
1927
+ }
1928
+
1929
+ await pptx.writeFile({ fileName: outPath });
1930
+ }
1931
+
1932
+ // 前景切图 → PPT 独立图片对象,归一化等比摆放(位置/大小相对页面比例不变)。
1933
+ function addForegroundCutouts(slide, blocks, width, height) {
1934
+ for (const block of blocks) {
1935
+ if (!block.imagePath) continue; // 截图失败的块跳过(已记 warning)
1936
+ const { x, y, w, h } = normalizeRect(block, width, height);
1937
+ if (w <= 0 || h <= 0) continue;
1938
+ slide.addImage({ path: block.imagePath, x, y, w, h });
1939
+ }
1940
+ }
1941
+
1942
+ function estimateTextWidthPx(text, fontSize) {
1943
+ const size = fontSize || 16;
1944
+ let units = 0;
1945
+ for (const char of text || '') {
1946
+ // Latin/\u6570\u5b57/\u7a7a\u683c/\u5176\u5b83\u7cfb\u6570\u8f83\u65e7\u503c\u7565\u4e0a\u8c03\uff08\u7559\u5b89\u5168\u4f59\u91cf\uff09\uff1aOffice \u7aef\u66ff\u6362\u5b57\u4f53\uff08\u5fae\u8f6f\u96c5\u9ed1\u7b49\uff09\u6e32\u67d3\u897f\u6587\u5e38\u6bd4
1947
+ // \u5bfc\u51fa\u7aef\u5b57\u4f53\u5bbd \u2192 \u4f30\u7a84\u4f1a\u8ba9\u5355\u884c\u672b\u5c3e\u5b57\u88ab\u91cd\u6298\uff1bCJK \u4fdd\u6301 1.0\uff08\u4e2d\u6587\u6807\u9898\u91cc CJK \u5360\u591a\u6570\uff0c\u4e0a\u8c03\u4f1a\u8fc7\u5ea6\u6269\u6846\u81f4\u5047\u91cd\u53e0\uff09\u3002
1948
+ if (/\s/.test(char)) units += 0.4;
1949
+ else if (/[\u3400-\u9fff\u3000-\u303f\uff00-\uffef]/u.test(char)) units += 1;
1950
+ else if (/[A-Z0-9]/.test(char)) units += 0.7;
1951
+ else if (/[a-z]/.test(char)) units += 0.6;
1952
+ else units += 0.55;
1953
+ }
1954
+ return units * size;
1955
+ }
1956
+
1957
+ // \u884c\u8ddd\u500d\u6570\uff1aCSS line-height(px) / fontSize(px) \u2192 PPT lineSpacingMultiple\uff0c\u5939\u5230 [0.8, 2.2]\u3002\u7eaf\u51fd\u6570\uff08\u5bfc\u51fa\u4f9b\u5355\u6d4b\uff09\u3002
1958
+ // \u6cbb\u300cline-height \u63d0\u53d6\u4e86\u5374\u6ca1\u5199\u8fdb addText \u2192 Office \u7528\u9ed8\u8ba4\u884c\u8ddd \u2192 \u4e0e web 1.4~1.8 \u884c\u9ad8\u4e0d\u7b26\u3001\u591a\u884c\u6846\u7b97\u4e0d\u51c6\u300d\u3002
1959
+ // normal/\u7f3a\u5931\uff08run.lineHeight \u4e3a null\uff09\u8fd4\u56de undefined \u2192 \u7528 Office \u9ed8\u8ba4\uff0c\u4e0d\u5f3a\u52a0\u3002
1960
+ function lineSpacingMultipleFor(run) {
1961
+ if (!run || !run.lineHeight || !run.fontSize) return undefined;
1962
+ const ratio = run.lineHeight / run.fontSize;
1963
+ if (!Number.isFinite(ratio) || ratio <= 0) return undefined;
1964
+ return Math.min(2.2, Math.max(0.8, Math.round(ratio * 100) / 100));
1965
+ }
1966
+
1967
+ // \u5b57\u8ddd\uff1aCSS letter-spacing(px) \u2192 PPT charSpacing(pt)\uff0c\u4e0e\u5b57\u53f7\u540c\u4e00 px\u2192pt \u7cfb\u6570\uff08\u00d7totalIn\u00d772/totalPx\uff09\uff0c\u65e0\u4e0b\u9650\u94b3\u3002
1968
+ // \u7eaf\u51fd\u6570\uff08\u5bfc\u51fa\u4f9b\u5355\u6d4b\uff09\u3002normal/0/\u7f3a\u5931\u8fd4\u56de undefined\uff1b\u8d1f\u503c\uff08\u7d27\u6392\uff09\u900f\u4f20\u3002\u6cbb\u300c\u5b57\u8ddd\u4e22\u5931\u81f4 PPT \u6298\u884c\u70b9\u4e0e web \u4e0d\u4e00\u81f4\u300d\u3002
1969
+ function charSpacingPt(letterSpacingPx, totalPx, totalIn) {
1970
+ if (!letterSpacingPx || !totalPx) return undefined;
1971
+ return Math.round((letterSpacingPx / totalPx) * totalIn * 72 * 10) / 10;
1972
+ }
1973
+
1974
+ function expandTextBoxXW({ x, w }, targetW, align) {
1975
+ const desiredW = Math.max(w, Math.min(targetW, SLIDE_W));
1976
+ const slack = Math.max(0, desiredW - w);
1977
+ let xBox = x;
1978
+ if (align === 'center') xBox = x - slack / 2;
1979
+ else if (align === 'right') xBox = x - slack;
1980
+ // 只防左越界;右越界改「缩框宽」而非把 xBox 往左拽——否则左对齐的整宽标题/副标框(w≈整页)被加宽
1981
+ // 后会触发 `SLIDE_W - desiredW < x` 把左边拽到 ≈0,导出成标题贴画布最左甚至被裁(真机事故:
1982
+ // HTML x=112px → 导出 x=0)。左对齐 x 永远不左移;超宽只在右侧封顶(左对齐文本用不到右侧余量、不可见)。
1983
+ xBox = Math.max(0, xBox);
1984
+ return { x: xBox, w: Math.min(desiredW, SLIDE_W - xBox) };
1985
+ }
1986
+
1987
+ // 文本框横向余量(纯函数,导出供单测)——治 Office/PowerPoint 用本机字体重排后
1988
+ // 「最后只换行一个字」的问题。Chromium 量到的是 HTML 视觉框;PPT 会重新按字体度量折行,
1989
+ // 如果框太贴,会把末尾 1–2 个字挤到下一行。策略:
1990
+ // - 单行:尽量按估算文本全长留足宽度,最低也保留旧版 12% 余量。
1991
+ // - 多行:至少给「估算全文宽度 × 60%」的框宽,让折行更接近中间,而不是末尾孤字。
1992
+ // 扩展按对齐锚点进行,并夹到 slide 边界内。
1993
+ // 入参 rect={x,w}(英寸,已 normalizeRect),run 带 text/w/h/lineHeight/fontSize/textAlign(px)。
1994
+ function textBoxXW({ x, w }, run) {
1995
+ const lineHpx = run.lineHeight || (run.fontSize ? run.fontSize * 1.4 : (run.h || 0));
1996
+ const isSingleLine = run.h > 0 && lineHpx > 0 && run.h <= lineHpx * 1.5;
1997
+ const align = ['left', 'center', 'right', 'justify'].includes(run.textAlign) ? run.textAlign : 'left';
1998
+ const pxToInScale = run.w > 0 ? w / run.w : 0;
1999
+ const estimatedW = pxToInScale
2000
+ ? estimateTextWidthPx(run.text || '', run.fontSize || 16) * pxToInScale
2001
+ : w;
2002
+ const targetW = isSingleLine
2003
+ ? Math.max(w * 1.18, estimatedW * 1.12) // 单行(尤标题)多留余量抗 Office 字体替换重折,扩宽只在本行、几乎不撞邻块
2004
+ : Math.max(w * 1.12, estimatedW * 0.6);
2005
+ return expandTextBoxXW({ x, w }, targetW, align);
2006
+ }
2007
+
2008
+ function textBoxHeight(h, y, run) {
2009
+ const lineHpx = run.lineHeight || (run.fontSize ? run.fontSize * 1.4 : (run.h || 0));
2010
+ const isSingleLine = run.h > 0 && lineHpx > 0 && run.h <= lineHpx * 1.5;
2011
+ const multiplier = isSingleLine ? 1.18 : 2;
2012
+ return Math.min(h * multiplier, Math.max(h, SLIDE_H - y));
2013
+ }
2014
+
2015
+ function pptTextBoxIn(run, width, height) {
2016
+ const { x, y, w, h } = normalizeRect(run, width, height);
2017
+ if (w <= 0 || h <= 0) return { x, y, w, h };
2018
+ const { x: xBox, w: wBox } = textBoxXW({ x, w }, run);
2019
+ const hBox = textBoxHeight(h, y, run);
2020
+ return { x: xBox, y, w: wBox, h: hBox };
2021
+ }
2022
+
2023
+ function pptTextBoxPx(run, width, height) {
2024
+ const rect = pptTextBoxIn(run, width, height);
2025
+ return {
2026
+ x: rect.x / SLIDE_W * width,
2027
+ y: rect.y / SLIDE_H * height,
2028
+ w: rect.w / SLIDE_W * width,
2029
+ h: rect.h / SLIDE_H * height,
2030
+ };
2031
+ }
2032
+
2033
+ // 文本 → PPT 原生可编辑文本框,还原字体/字号/颜色/粗细/对齐,归一化定位。
2034
+ function addTextRuns(slide, runs, width, height) {
2035
+ for (const run of runs) {
2036
+ const text = normalizeText(run.text || '');
2037
+ if (!text) continue;
2038
+ const { x, y, w, h } = normalizeRect(run, width, height);
2039
+ if (w <= 0 || h <= 0) continue;
2040
+ // 字号保真:HTML 用 Chromium 度量、Office 用本机字体度量**重新折行**,同样文字 Office 常多折一行。
2041
+ // 过去文本框按 Chromium 紧贴定高 → Office 多一行即溢出 → 'shrink' 把整段字号大幅缩小(真机 10 页
2042
+ // 「卡内字太小」根因)。对策:给框**下向留 ~18% 余量**(封顶到页底),吸收一行折行余量——常规内容
2043
+ // 在 Office 折行后仍装得下、shrink **不触发**、字号按 pxToPt 原值保真;余量透明、仅真溢出才用,不造成
2044
+ // 视觉重叠。'shrink' 只留作极端兜底(内容远超框时微缩,胜过外溢压住下方元素)。
2045
+ // 治本仍是 HTML 阶段「文本溢出」闸门保证文字装得下(见 layout_balance.py),导出只忠实搬运。
2046
+ const pptRect = pptTextBoxIn(run, width, height);
2047
+ const color = cssColorToPpt(run.color);
2048
+ const align = ['left', 'center', 'right', 'justify'].includes(run.textAlign)
2049
+ ? run.textAlign
2050
+ : 'left';
2051
+ // 保真:把 web 的行距/字距映射进 PPT(缺失→undefined 用 Office 默认,不强加)。
2052
+ const lineSpacingMultiple = lineSpacingMultipleFor(run);
2053
+ const charSpacing = charSpacingPt(run.letterSpacing, height, SLIDE_H);
2054
+ slide.addText(text, {
2055
+ x: pptRect.x,
2056
+ y: pptRect.y,
2057
+ w: pptRect.w,
2058
+ h: pptRect.h,
2059
+ fontFace: pickFontFace(run.fontFamily),
2060
+ fontSize: pxToPt(run.fontSize, height, SLIDE_H),
2061
+ color: color.color,
2062
+ transparency: color.transparency,
2063
+ bold: Number.parseInt(run.fontWeight, 10) >= 600 || run.fontWeight === 'bold',
2064
+ align,
2065
+ valign: 'top',
2066
+ margin: 0,
2067
+ breakLine: true,
2068
+ fit: 'shrink',
2069
+ ...(lineSpacingMultiple ? { lineSpacingMultiple } : {}),
2070
+ ...(charSpacing !== undefined ? { charSpacing } : {}),
2071
+ });
2072
+ }
2073
+ }
2074
+
2075
+ function runOpcCheck({ outPath, python, skillsRoot }) {
2076
+ const checker = resolve(skillsRoot, 'pptx/scripts/opc_check.py');
2077
+ const result = spawnSync(python, [checker, outPath], { encoding: 'utf8' });
2078
+ const combined = `${result.stdout || ''}${result.stderr || ''}`.trim();
2079
+ if (result.status === 0) {
2080
+ return { ok: true, checker, output: combined || 'OPC check passed.' };
2081
+ }
2082
+
2083
+ const fixedPath = outPath.replace(/\.pptx$/i, '.fixed.pptx');
2084
+ const repack = spawnSync(python, [checker, outPath, '--repack', fixedPath], { encoding: 'utf8' });
2085
+ const repackText = `${repack.stdout || ''}${repack.stderr || ''}`.trim();
2086
+ if (repack.status === 0) {
2087
+ return { ok: true, repacked: true, fixedPath, checker, output: `${combined}\n${repackText}`.trim() };
2088
+ }
2089
+ return { ok: false, checker, output: `${combined}\n${repackText}`.trim() };
2090
+ }
2091
+
2092
+ function buildReport({ args, slides, consoleErrors, opc, scale, layerSnapshots = {}, validationErrors = [], validationWarnings = [] }) {
2093
+ const editableTextCount = slides.reduce((sum, slide) => sum + slide.textRuns.length, 0);
2094
+ const editableImageCount = slides.reduce(
2095
+ (sum, slide) => sum + slide.foreground.filter((item) => item.imagePath).length,
2096
+ 0,
2097
+ );
2098
+ const modeLabel = args.mode === 'flat'
2099
+ ? '高保真图片版:每页为完整截图,基本不可编辑。'
2100
+ : '三层可编辑版:背景为整页底图(不可编辑),前景每个元素是独立可编辑图片对象,文本是原生可编辑文本框。';
2101
+
2102
+ const lines = [
2103
+ '# PPTX 导出报告(三层:背景 / 前景 / 文本)',
2104
+ '',
2105
+ `- 输入 HTML:${args.htmlFile}`,
2106
+ `- 输出 PPTX:${args.out}`,
2107
+ `- 导出模式:${args.mode}`,
2108
+ `- 可编辑边界:${modeLabel}`,
2109
+ `- 截图→PPT 缩放比例:${scale ? scale.toFixed(6) : 'n/a'} in/px(${args.width}×${args.height} → ${SLIDE_W}×${SLIDE_H} in)`,
2110
+ `- 幻灯片数量:${slides.length}`,
2111
+ `- 可编辑文本段数量:${editableTextCount}`,
2112
+ `- 前景独立图片对象数量:${editableImageCount}`,
2113
+ `- 中间产物:${['background', 'foreground', 'text'].map((m) => layerSnapshots[m] ? `pure-${m}.html` : `pure-${m}.html(缺)`).join('、')}`,
2114
+ `- OPC 校验:${opc?.ok ? (opc.repacked ? '初始未通过,已重打包通过' : '通过') : '未通过或已跳过'}`,
2115
+ `- 导出错误:${validationErrors.length}`,
2116
+ `- 导出警告:${validationWarnings.length}`,
2117
+ '',
2118
+ '## 页面',
2119
+ ...slides.map((slide) => `- ${String(slide.index + 1).padStart(2, '0')}:${slide.label};前景 ${slide.foreground.length} 块 / 文本 ${slide.textRuns.length} 段;截图 ${slide.fullInfo.width}x${slide.fullInfo.height};hash ${slide.fullInfo.hash.slice(0, 12)}`),
2120
+ '',
2121
+ '## 导出诊断',
2122
+ validationErrors.length ? validationErrors.map((item) => `- ERROR: ${item}`).join('\n') : '- 未发现阻断性导出错误。',
2123
+ validationWarnings.length ? validationWarnings.map((item) => `- WARNING: ${item}`).join('\n') : '- 未发现导出警告。',
2124
+ '',
2125
+ '## 构图均衡指标(确定性;交 layout_balance.py 闸门判定)',
2126
+ args.mode === 'hybrid'
2127
+ ? slides
2128
+ .map((slide) => {
2129
+ const b = computeSlideBalance(slide, args.width, args.height);
2130
+ const empties = ['top', 'bottom', 'left', 'right'].filter((k) => (b.halfOccupancy[k] ?? 1) < 0.06);
2131
+ return `- ${String(slide.index + 1).padStart(2, '0')}:覆盖率 ${(b.coverage * 100).toFixed(0)}%`
2132
+ + `;半区占用 上${(b.halfOccupancy.top * 100).toFixed(0)}/下${(b.halfOccupancy.bottom * 100).toFixed(0)}/左${(b.halfOccupancy.left * 100).toFixed(0)}/右${(b.halfOccupancy.right * 100).toFixed(0)}`
2133
+ + `${empties.length ? `;空半区[${empties.join('/')}]` : ''}`
2134
+ + `${b.overlaps.length ? `;⚠重叠 ${b.overlaps.length} 处` : ''}`
2135
+ + `;底图${b.hasBgImage ? '有' : '无'};内容块 ${b.contentBoxes}`;
2136
+ })
2137
+ .join('\n')
2138
+ : '- flat 模式整页一张图,无构图均衡指标。',
2139
+ '',
2140
+ '## 浏览器检查',
2141
+ consoleErrors.length ? consoleErrors.map((item) => `- Console error: ${item}`).join('\n') : '- 未发现 console error。',
2142
+ '',
2143
+ '## 注意',
2144
+ '- `flat` 版最保真,但不可编辑。',
2145
+ '- `hybrid` 三层会自动分层:离散视觉块自动切前景图片,文字自动提为顶层可编辑文本框,页面级底色/渐变/氛围图留在背景。',
2146
+ '- `data-pptx-layer="foreground"` 和 `data-pptx-export="ignore"` 只作为 override 使用;不要为了导出而给所有元素手工乱标。',
2147
+ '- 文本在背景截图/前景切图阶段会被隐藏,再在顶层还原为原生文本框;若导出报告出现截图夹带文本,应先修 HTML 或分层规则。',
2148
+ '- 导出后仍需抽查 PowerPoint 中的中文字体、断行、背景裁切和前景切图边界。',
2149
+ ];
2150
+ if (opc?.output) {
2151
+ lines.push('', '## OPC 输出', '```', opc.output, '```');
2152
+ }
2153
+ return `${lines.join('\n')}\n`;
2154
+ }
2155
+
2156
+ async function main() {
2157
+ const args = parseArgs(process.argv.slice(2));
2158
+ if (args.help) {
2159
+ process.stdout.write(usage());
2160
+ return;
2161
+ }
2162
+
2163
+ args.htmlFile = resolve(args.htmlFile);
2164
+ // measure-only 可无 --out:workdir 默认落 html 同级的 <html名>-layout/。
2165
+ if (args.out) {
2166
+ args.out = resolve(args.out);
2167
+ }
2168
+ const defaultWorkdir = args.out
2169
+ ? resolve(dirname(args.out), `${basename(args.out, extname(args.out))}-export`)
2170
+ : resolve(dirname(args.htmlFile), `${basename(args.htmlFile, extname(args.htmlFile))}-layout`);
2171
+ args.workdir = resolve(args.workdir || defaultWorkdir);
2172
+ await mkdir(args.workdir, { recursive: true });
2173
+
2174
+ const htmlContent = await readFile(args.htmlFile, 'utf8');
2175
+ const preflightErrors = htmlStaticPreflight(htmlContent);
2176
+ if (preflightErrors.length) {
2177
+ throw new Error(`HTML 静态预检失败,已停止 PPTX 导出:\n- ${preflightErrors.join('\n- ')}`);
2178
+ }
2179
+
2180
+ const scriptDir = dirname(fileURLToPath(import.meta.url));
2181
+ const uiSkillRoot = resolve(scriptDir, '..');
2182
+ const skillsRoot = resolve(uiSkillRoot, '..');
2183
+
2184
+ const { chromium, PptxGenJS } = await loadModules(args.nodeModules);
2185
+ const { slides, consoleErrors, validationErrors, validationWarnings, layerSnapshots } = await renderSlides({
2186
+ chromium,
2187
+ htmlPath: args.htmlFile,
2188
+ selector: args.slides,
2189
+ width: args.width,
2190
+ height: args.height,
2191
+ workdir: args.workdir,
2192
+ mode: args.mode,
2193
+ measureOnly: args.measureOnly,
2194
+ withBgShots: args.withBgShots,
2195
+ nodeModules: args.nodeModules,
2196
+ });
2197
+
2198
+ // ── measure-only:只写 layout-report.json(构图闸门前置到 HTML 层),不出 pptx、不切图、不 OPC ──
2199
+ // layout_balance.py 既能读 export-manifest.json 也能读这份(同 schema:mode + slides[].balance)。
2200
+ if (args.measureOnly) {
2201
+ const report = {
2202
+ mode: 'hybrid', // 量了三层几何 → 按 hybrid 交 layout_balance.py 判(flat 不闸门)
2203
+ measureOnly: true,
2204
+ input: args.htmlFile,
2205
+ width: args.width,
2206
+ height: args.height,
2207
+ slides: slides.map((slide) => ({
2208
+ index: slide.index + 1,
2209
+ label: slide.label,
2210
+ balance: computeSlideBalance(slide, args.width, args.height),
2211
+ // 加性字段:文本段原始几何+颜色(HTML 实测 px 矩形,非 Office 加宽框)。
2212
+ // 供 text_readability_pixel.py 按矩形采样文字底下的真实像素:fgParent=null 从
2213
+ // slide-NN-background.png 采(直接压背景层),fgParent 非空从 slide-NN-foreground.png 采
2214
+ // (卡内/前景块内文字)——CSS 对比度检查两类都测不准(图片背衬/渐变/SVG fill)。
2215
+ // layout_balance.py 只读 balance,不受影响。
2216
+ textRuns: (slide.textRuns || []).map((run) => ({
2217
+ text: (run.text || '').slice(0, 24),
2218
+ x: Math.round(run.x),
2219
+ y: Math.round(run.y),
2220
+ w: Math.round(run.w),
2221
+ h: Math.round(run.h),
2222
+ fontSize: Math.round((run.fontSize || 0) * 10) / 10,
2223
+ color: run.color || null,
2224
+ bgColor: run.bgColor || null,
2225
+ bgSolid: !!run.bgSolid,
2226
+ fgParent: run.fgParent || null,
2227
+ })),
2228
+ })),
2229
+ consoleErrors,
2230
+ };
2231
+ const reportPath = resolve(args.workdir, 'layout-report.json');
2232
+ await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
2233
+ console.log(`构图量完成(measure-only,未出 pptx): ${reportPath}`);
2234
+ if (consoleErrors.length) {
2235
+ console.error(`渲染中 ${consoleErrors.length} 个 console error(见 layout-report.json)。`);
2236
+ }
2237
+ return;
2238
+ }
2239
+
2240
+ await createPptx({
2241
+ PptxGenJS,
2242
+ slides,
2243
+ outPath: args.out,
2244
+ mode: args.mode,
2245
+ width: args.width,
2246
+ height: args.height,
2247
+ });
2248
+
2249
+ let opc = null;
2250
+ if (args.opcCheck) {
2251
+ opc = runOpcCheck({ outPath: args.out, python: args.python, skillsRoot });
2252
+ if (opc.repacked) {
2253
+ // 把合规版内容写回 args.out,再删掉中间的 *.fixed.pptx——最终目录只留干净的
2254
+ // args.out,绝不留带 "fixed" 字样的文件(产品交付时这种文件名很不专业)。
2255
+ await copyFile(opc.fixedPath, args.out);
2256
+ await unlink(opc.fixedPath).catch(() => {});
2257
+ }
2258
+ }
2259
+
2260
+ // 缩放比例(「记住缩放比例」):两轴应相等(16:9 → 10:5.625)。
2261
+ const scaleX = SLIDE_W / args.width;
2262
+ const scaleY = SLIDE_H / args.height;
2263
+ const scale = scaleX;
2264
+ if (Math.abs(scaleX - scaleY) > 1e-6) {
2265
+ validationWarnings.push(`缩放比例两轴不一致(x=${scaleX.toFixed(6)} y=${scaleY.toFixed(6)}):--width/--height 不是 16:9,前景切图会非等比拉伸。`);
2266
+ }
2267
+
2268
+ const manifest = {
2269
+ input: args.htmlFile,
2270
+ output: args.out,
2271
+ mode: args.mode,
2272
+ width: args.width,
2273
+ height: args.height,
2274
+ scale,
2275
+ scaleAxes: { x: scaleX, y: scaleY },
2276
+ layerSnapshots,
2277
+ slides: slides.map((slide) => ({
2278
+ index: slide.index + 1,
2279
+ label: slide.label,
2280
+ fullScreenshot: slide.fullPath,
2281
+ backgroundScreenshot: slide.backgroundPath,
2282
+ foregroundCount: slide.foreground.length,
2283
+ editableImageCount: slide.foreground.filter((item) => item.imagePath).length,
2284
+ textRunCount: slide.textRuns.length,
2285
+ // 构图均衡指标(仅 hybrid 有意义:flat 是整页一张图,无前景/文本几何可量)。
2286
+ balance: args.mode === 'hybrid' ? computeSlideBalance(slide, args.width, args.height) : null,
2287
+ foreground: slide.foreground.map((item) => ({
2288
+ fgId: item.fgId,
2289
+ file: item.imageFile || null,
2290
+ kind: item.kind,
2291
+ tag: item.tag,
2292
+ needsAlphaMask: !!item.needsAlphaMask,
2293
+ alphaMaskApplied: !!item.alphaMaskApplied,
2294
+ pxRect: { x: item.x, y: item.y, w: item.w, h: item.h },
2295
+ inRect: normalizeRect(item, args.width, args.height),
2296
+ error: item.imageError || null,
2297
+ })),
2298
+ textRuns: slide.textRuns.map((run) => ({
2299
+ text: run.text,
2300
+ pxRect: { x: run.x, y: run.y, w: run.w, h: run.h },
2301
+ inRect: normalizeRect(run, args.width, args.height),
2302
+ pptPxRect: pptTextBoxPx(run, args.width, args.height),
2303
+ pptInRect: pptTextBoxIn(run, args.width, args.height),
2304
+ fontSize: run.fontSize,
2305
+ fontWeight: run.fontWeight,
2306
+ color: run.color,
2307
+ textAlign: run.textAlign,
2308
+ })),
2309
+ fullVisibleCount: slide.fullVisibleCount,
2310
+ backgroundVisibleCount: slide.backgroundVisibleCount,
2311
+ fullInfo: slide.fullInfo,
2312
+ backgroundInfo: slide.backgroundInfo,
2313
+ })),
2314
+ consoleErrors,
2315
+ validationErrors,
2316
+ validationWarnings,
2317
+ opc,
2318
+ };
2319
+
2320
+ await writeFile(resolve(args.workdir, 'export-manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
2321
+ await writeFile(
2322
+ resolve(args.workdir, 'export-report.md'),
2323
+ buildReport({ args, slides, consoleErrors, opc, scale, layerSnapshots, validationErrors, validationWarnings }),
2324
+ 'utf8',
2325
+ );
2326
+
2327
+ if (consoleErrors.length) {
2328
+ console.error(`导出完成,但发现 ${consoleErrors.length} 个 console error。详情见 export-report.md。`);
2329
+ } else {
2330
+ console.log(`导出完成: ${args.out}`);
2331
+ }
2332
+ console.log(`报告目录: ${args.workdir}`);
2333
+ if (opc && !opc.ok) {
2334
+ console.error('OPC 校验未通过。');
2335
+ process.exitCode = 1;
2336
+ }
2337
+ if (validationErrors.length) {
2338
+ console.error(`导出诊断发现 ${validationErrors.length} 个阻断问题。详情见 export-report.md。`);
2339
+ process.exitCode = 1;
2340
+ }
2341
+ }
2342
+
2343
+ // 「直接运行才跑 main」守卫:被测试 import 时不自跑(process.argv[1] 是测试 runner 而非本脚本)。
2344
+ const invokedDirectly = process.argv[1]
2345
+ && import.meta.url === pathToFileURL(process.argv[1]).href;
2346
+ if (invokedDirectly) {
2347
+ main().catch((error) => {
2348
+ console.error(error.message);
2349
+ process.exit(1);
2350
+ });
2351
+ }
2352
+
2353
+ export {
2354
+ pxToIn,
2355
+ pxToPt,
2356
+ normalizeRect,
2357
+ geometryFilename,
2358
+ cssColorToPpt,
2359
+ pickFontFace,
2360
+ normalizeText,
2361
+ computeSlideBalance,
2362
+ htmlStaticPreflight,
2363
+ textBoxXW,
2364
+ lineSpacingMultipleFor,
2365
+ charSpacingPt,
2366
+ CJK_FALLBACK,
2367
+ };