@huicai/cli 2026.804.0 → 2026.1019.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 (486) hide show
  1. package/LICENSE +391 -32
  2. package/README.md +176 -10
  3. package/bin/huicai.js +166 -73
  4. package/package.json +27 -8
  5. package/skills/ab-testing/trigger-cases.json +30 -0
  6. package/skills/academic-writing/trigger-cases.json +30 -0
  7. package/skills/accounting-ops/trigger-cases.json +30 -0
  8. package/skills/ai-agent/trigger-cases.json +30 -0
  9. package/skills/ai-aiops/trigger-cases.json +30 -0
  10. package/skills/ai-coding/trigger-cases.json +30 -0
  11. package/skills/ai-compliance/trigger-cases.json +30 -0
  12. package/skills/ai-eval/trigger-cases.json +30 -0
  13. package/skills/ai-llm-app/trigger-cases.json +30 -0
  14. package/skills/ai-mlops/trigger-cases.json +30 -0
  15. package/skills/ai-multimodal/trigger-cases.json +30 -0
  16. package/skills/ai-prompt/trigger-cases.json +30 -0
  17. package/skills/ai-rag/trigger-cases.json +30 -0
  18. package/skills/anomaly-detection/trigger-cases.json +30 -0
  19. package/skills/api-design/trigger-cases.json +31 -0
  20. package/skills/api-integration/trigger-cases.json +31 -0
  21. package/skills/arch-design/trigger-cases.json +12 -0
  22. package/skills/arch-diagram/SKILL.md +118 -0
  23. package/skills/arch-diagram/trigger-cases.json +30 -0
  24. package/skills/arch-distributed/trigger-cases.json +30 -0
  25. package/skills/arch-multitenant/trigger-cases.json +30 -0
  26. package/skills/audio-brief/SKILL.md +65 -0
  27. package/skills/audio-brief/trigger-cases.json +30 -0
  28. package/skills/audit-assist/trigger-cases.json +30 -0
  29. package/skills/audit-chain/trigger-cases.json +31 -0
  30. package/skills/banking-credit/trigger-cases.json +30 -0
  31. package/skills/brand-design/trigger-cases.json +30 -0
  32. package/skills/business-plan/trigger-cases.json +30 -0
  33. package/skills/capital-ops/trigger-cases.json +30 -0
  34. package/skills/cicd-pipeline/trigger-cases.json +31 -0
  35. package/skills/code-archaeology/SKILL.md +55 -0
  36. package/skills/code-archaeology/trigger-cases.json +9 -0
  37. package/skills/code-quality/SKILL.md +138 -0
  38. package/skills/code-quality/references/languages.md +107 -0
  39. package/skills/code-quality/scripts/__pycache__/code_battery.cpython-312.pyc +0 -0
  40. package/skills/code-quality/scripts/__pycache__/code_check.cpython-312.pyc +0 -0
  41. package/skills/code-quality/scripts/__pycache__/code_check.cpython-313.pyc +0 -0
  42. package/skills/code-quality/scripts/__pycache__/code_csharp.cpython-312.pyc +0 -0
  43. package/skills/code-quality/scripts/__pycache__/code_csharp.cpython-313.pyc +0 -0
  44. package/skills/code-quality/scripts/__pycache__/code_fix.cpython-312.pyc +0 -0
  45. package/skills/code-quality/scripts/__pycache__/code_fix.cpython-313.pyc +0 -0
  46. package/skills/code-quality/scripts/__pycache__/code_java.cpython-312.pyc +0 -0
  47. package/skills/code-quality/scripts/__pycache__/code_java.cpython-313.pyc +0 -0
  48. package/skills/code-quality/scripts/__pycache__/code_jsts.cpython-312.pyc +0 -0
  49. package/skills/code-quality/scripts/__pycache__/code_jsts.cpython-313.pyc +0 -0
  50. package/skills/code-quality/scripts/__pycache__/code_lens.cpython-312.pyc +0 -0
  51. package/skills/code-quality/scripts/__pycache__/code_lens.cpython-313.pyc +0 -0
  52. package/skills/code-quality/scripts/__pycache__/code_python.cpython-312.pyc +0 -0
  53. package/skills/code-quality/scripts/__pycache__/code_python.cpython-313.pyc +0 -0
  54. package/skills/code-quality/scripts/__pycache__/code_testplan.cpython-312.pyc +0 -0
  55. package/skills/code-quality/scripts/code_battery.py +1072 -0
  56. package/skills/code-quality/scripts/code_check.py +1269 -0
  57. package/skills/code-quality/scripts/code_csharp.py +371 -0
  58. package/skills/code-quality/scripts/code_fix.py +201 -0
  59. package/skills/code-quality/scripts/code_java.py +695 -0
  60. package/skills/code-quality/scripts/code_jsts.py +313 -0
  61. package/skills/code-quality/scripts/code_lens.py +1079 -0
  62. package/skills/code-quality/scripts/code_python.py +536 -0
  63. package/skills/code-quality/scripts/code_testplan.py +239 -0
  64. package/skills/code-quality/trigger-cases.json +45 -0
  65. package/skills/code-review/SKILL.md +24 -0
  66. package/skills/code-review/trigger-cases.json +35 -0
  67. package/skills/commit-and-pr/trigger-cases.json +31 -0
  68. package/skills/content-creation/trigger-cases.json +30 -0
  69. package/skills/corporate-governance/trigger-cases.json +30 -0
  70. package/skills/crawl-chain/trigger-cases.json +31 -0
  71. package/skills/customer-service/trigger-cases.json +30 -0
  72. package/skills/data-analysis/trigger-cases.json +30 -0
  73. package/skills/data-chain/SKILL.md +1 -1
  74. package/skills/data-chain/trigger-cases.json +31 -0
  75. package/skills/data-governance/trigger-cases.json +30 -0
  76. package/skills/data-report-pro/trigger-cases.json +30 -0
  77. package/skills/data-streaming/trigger-cases.json +30 -0
  78. package/skills/data-wrangling/SKILL.md +1 -1
  79. package/skills/data-wrangling/trigger-cases.json +30 -0
  80. package/skills/db-es/trigger-cases.json +30 -0
  81. package/skills/db-migration/trigger-cases.json +30 -0
  82. package/skills/db-mysql/trigger-cases.json +30 -0
  83. package/skills/db-postgres/trigger-cases.json +30 -0
  84. package/skills/db-redis/trigger-cases.json +30 -0
  85. package/skills/debug-root-cause/trigger-cases.json +30 -0
  86. package/skills/dependency-audit/SKILL.md +56 -0
  87. package/skills/dependency-audit/trigger-cases.json +7 -0
  88. package/skills/dependency-upgrade/trigger-cases.json +31 -0
  89. package/skills/dev-cpp/trigger-cases.json +30 -0
  90. package/skills/dev-cross-platform/trigger-cases.json +30 -0
  91. package/skills/dev-csharp/SKILL.md +23 -0
  92. package/skills/dev-csharp/trigger-cases.json +30 -0
  93. package/skills/dev-frontend/SKILL.md +19 -0
  94. package/skills/dev-frontend/trigger-cases.json +30 -0
  95. package/skills/dev-go/trigger-cases.json +30 -0
  96. package/skills/dev-harmony/trigger-cases.json +30 -0
  97. package/skills/dev-java/SKILL.md +20 -0
  98. package/skills/dev-java/trigger-cases.json +30 -0
  99. package/skills/dev-mobile/trigger-cases.json +30 -0
  100. package/skills/dev-node/trigger-cases.json +30 -0
  101. package/skills/dev-php/trigger-cases.json +30 -0
  102. package/skills/dev-python/SKILL.md +21 -0
  103. package/skills/dev-python/trigger-cases.json +30 -0
  104. package/skills/dev-rust/trigger-cases.json +30 -0
  105. package/skills/dev-toolchain/trigger-cases.json +31 -0
  106. package/skills/doc-chain/trigger-cases.json +31 -0
  107. package/skills/doc-compare/trigger-cases.json +30 -0
  108. package/skills/docx-author/SKILL.md +127 -25
  109. package/skills/docx-author/scripts/__pycache__/docx_battery.cpython-312.pyc +0 -0
  110. package/skills/docx-author/scripts/__pycache__/docx_build.cpython-312.pyc +0 -0
  111. package/skills/docx-author/scripts/__pycache__/docx_check.cpython-312.pyc +0 -0
  112. package/skills/docx-author/scripts/__pycache__/docx_layout.cpython-312.pyc +0 -0
  113. package/skills/docx-author/scripts/__pycache__/zh_prose.cpython-312.pyc +0 -0
  114. package/skills/docx-author/scripts/docx_battery.py +586 -0
  115. package/skills/docx-author/scripts/docx_build.py +995 -0
  116. package/skills/docx-author/scripts/docx_check.py +1306 -0
  117. package/skills/docx-author/scripts/docx_doc.py +14 -154
  118. package/skills/docx-author/scripts/docx_layout.py +490 -0
  119. package/skills/docx-author/scripts/zh_prose.py +639 -0
  120. package/skills/docx-author/trigger-cases.json +58 -0
  121. package/skills/echo-brief/SKILL.md +84 -0
  122. package/skills/echo-brief/references/echo-format.md +68 -0
  123. package/skills/echo-brief/trigger-cases.json +40 -0
  124. package/skills/ecommerce-ops/trigger-cases.json +30 -0
  125. package/skills/education-training/trigger-cases.json +30 -0
  126. package/skills/ehs-safety/trigger-cases.json +30 -0
  127. package/skills/email-writing/trigger-cases.json +30 -0
  128. package/skills/eng-ai/trigger-cases.json +30 -0
  129. package/skills/eng-architecture/trigger-cases.json +31 -0
  130. package/skills/eng-automation/trigger-cases.json +31 -0
  131. package/skills/eng-collaboration/trigger-cases.json +30 -0
  132. package/skills/eng-complex-env/trigger-cases.json +30 -0
  133. package/skills/eng-data-platform/trigger-cases.json +31 -0
  134. package/skills/eng-database/trigger-cases.json +31 -0
  135. package/skills/eng-develop/trigger-cases.json +30 -0
  136. package/skills/eng-docs/SKILL.md +24 -0
  137. package/skills/eng-docs/trigger-cases.json +30 -0
  138. package/skills/eng-efficiency/trigger-cases.json +30 -0
  139. package/skills/eng-infra/trigger-cases.json +30 -0
  140. package/skills/eng-integration/trigger-cases.json +31 -0
  141. package/skills/eng-legacy/trigger-cases.json +31 -0
  142. package/skills/eng-observability/trigger-cases.json +31 -0
  143. package/skills/eng-ops/trigger-cases.json +31 -0
  144. package/skills/eng-performance/trigger-cases.json +31 -0
  145. package/skills/eng-planning/trigger-cases.json +31 -0
  146. package/skills/eng-quality/SKILL.md +8 -0
  147. package/skills/eng-quality/trigger-cases.json +31 -0
  148. package/skills/eng-release/trigger-cases.json +31 -0
  149. package/skills/eng-reliability/trigger-cases.json +31 -0
  150. package/skills/eng-requirements/trigger-cases.json +12 -0
  151. package/skills/eng-research/trigger-cases.json +30 -0
  152. package/skills/eng-security/trigger-cases.json +31 -0
  153. package/skills/eng-ux/trigger-cases.json +30 -0
  154. package/skills/engineering-chain/trigger-cases.json +30 -0
  155. package/skills/env-offline/trigger-cases.json +31 -0
  156. package/skills/env-private-deploy/trigger-cases.json +31 -0
  157. package/skills/env-xinchuang/trigger-cases.json +31 -0
  158. package/skills/equipment-tpm/trigger-cases.json +30 -0
  159. package/skills/esg-report/trigger-cases.json +30 -0
  160. package/skills/event-management/trigger-cases.json +30 -0
  161. package/skills/excel-analysis-chain/SKILL.md +17 -4
  162. package/skills/excel-analysis-chain/trigger-cases.json +31 -0
  163. package/skills/explain-codebase/trigger-cases.json +11 -0
  164. package/skills/file-ingest/trigger-cases.json +30 -0
  165. package/skills/finance-analysis/trigger-cases.json +31 -0
  166. package/skills/finance-chain/trigger-cases.json +31 -0
  167. package/skills/financial-modeling/trigger-cases.json +31 -0
  168. package/skills/find-simplifications/SKILL.md +43 -0
  169. package/skills/find-simplifications/trigger-cases.json +23 -0
  170. package/skills/foreign-trade/trigger-cases.json +30 -0
  171. package/skills/funnel-cohort/trigger-cases.json +30 -0
  172. package/skills/gov-document/trigger-cases.json +30 -0
  173. package/skills/gui-change-evidence/SKILL.md +47 -0
  174. package/skills/gui-change-evidence/trigger-cases.json +23 -0
  175. package/skills/hr-chain/trigger-cases.json +31 -0
  176. package/skills/hr-operations/trigger-cases.json +30 -0
  177. package/skills/info-disclosure/trigger-cases.json +31 -0
  178. package/skills/infra-gateway/trigger-cases.json +31 -0
  179. package/skills/infra-k8s/trigger-cases.json +31 -0
  180. package/skills/infra-mq/trigger-cases.json +30 -0
  181. package/skills/insurance-advisory/trigger-cases.json +30 -0
  182. package/skills/internal-audit/trigger-cases.json +30 -0
  183. package/skills/ip-management/trigger-cases.json +31 -0
  184. package/skills/ipo-listing/trigger-cases.json +31 -0
  185. package/skills/it-audit/trigger-cases.json +31 -0
  186. package/skills/itsm-ops/trigger-cases.json +31 -0
  187. package/skills/knowledge-sop/trigger-cases.json +31 -0
  188. package/skills/legal-chain/trigger-cases.json +31 -0
  189. package/skills/legal-contract/trigger-cases.json +30 -0
  190. package/skills/litigation-support/trigger-cases.json +31 -0
  191. package/skills/logistics-warehouse/trigger-cases.json +30 -0
  192. package/skills/long-doc-analysis/trigger-cases.json +31 -0
  193. package/skills/management-accounting/trigger-cases.json +30 -0
  194. package/skills/manufacturing-chain/trigger-cases.json +31 -0
  195. package/skills/manufacturing-quality/trigger-cases.json +30 -0
  196. package/skills/marketing-analytics/trigger-cases.json +31 -0
  197. package/skills/marketing-chain/trigger-cases.json +31 -0
  198. package/skills/meeting-minutes/trigger-cases.json +30 -0
  199. package/skills/metrics-system/trigger-cases.json +31 -0
  200. package/skills/negotiation-prep/trigger-cases.json +30 -0
  201. package/skills/obs-prometheus/trigger-cases.json +31 -0
  202. package/skills/office-automation/trigger-cases.json +31 -0
  203. package/skills/okr-review/trigger-cases.json +30 -0
  204. package/skills/ops-fleet/trigger-cases.json +31 -0
  205. package/skills/ops-linux/trigger-cases.json +31 -0
  206. package/skills/optimization-solver/trigger-cases.json +30 -0
  207. package/skills/patent-disclosure/SKILL.md +105 -0
  208. package/skills/patent-disclosure/references/figures.md +110 -0
  209. package/skills/patent-disclosure/references/outline.md +162 -0
  210. package/skills/patent-disclosure/scripts/patent_docx.py +230 -0
  211. package/skills/patent-disclosure/trigger-cases.json +31 -0
  212. package/skills/patent-drafting/SKILL.md +108 -0
  213. package/skills/patent-drafting/trigger-cases.json +31 -0
  214. package/skills/patent-portfolio/SKILL.md +81 -0
  215. package/skills/patent-portfolio/trigger-cases.json +31 -0
  216. package/skills/pdf-toolkit/trigger-cases.json +31 -0
  217. package/skills/perf-optimize/trigger-cases.json +31 -0
  218. package/skills/pivot-crosstab/trigger-cases.json +30 -0
  219. package/skills/policy-analysis/trigger-cases.json +31 -0
  220. package/skills/portfolio-analysis/trigger-cases.json +31 -0
  221. package/skills/ppt-chain/SKILL.md +14 -6
  222. package/skills/ppt-chain/trigger-cases.json +31 -0
  223. package/skills/pptx-author/SKILL.md +86 -33
  224. package/skills/pptx-author/scripts/__pycache__/pptx_check.cpython-312.pyc +0 -0
  225. package/skills/pptx-author/scripts/__pycache__/pptx_deck.cpython-312.pyc +0 -0
  226. package/skills/pptx-author/scripts/__pycache__/pptx_layout.cpython-312.pyc +0 -0
  227. package/skills/pptx-author/scripts/pptx_battery.py +178 -0
  228. package/skills/pptx-author/scripts/pptx_build.py +119 -0
  229. package/skills/pptx-author/scripts/pptx_check.py +1182 -0
  230. package/skills/pptx-author/scripts/pptx_deck.py +736 -145
  231. package/skills/pptx-author/scripts/pptx_layout.py +384 -0
  232. package/skills/pptx-author/trigger-cases.json +15 -0
  233. package/skills/pr-comms/trigger-cases.json +30 -0
  234. package/skills/procurement-supply/trigger-cases.json +31 -0
  235. package/skills/product-design/trigger-cases.json +31 -0
  236. package/skills/production-planning/trigger-cases.json +31 -0
  237. package/skills/project-management/trigger-cases.json +31 -0
  238. package/skills/project-proposal/trigger-cases.json +31 -0
  239. package/skills/prose-standard/SKILL.md +53 -0
  240. package/skills/prose-standard/trigger-cases.json +23 -0
  241. package/skills/qa-api-automation/trigger-cases.json +31 -0
  242. package/skills/qa-mobile-automation/trigger-cases.json +31 -0
  243. package/skills/qa-ui-automation/trigger-cases.json +31 -0
  244. package/skills/quant-analysis/trigger-cases.json +31 -0
  245. package/skills/rag-triage/SKILL.md +64 -0
  246. package/skills/rag-triage/evals.json +57 -0
  247. package/skills/rag-triage/references/triage-playbook.md +75 -0
  248. package/skills/rag-triage/trigger-cases.json +31 -0
  249. package/skills/real-estate-property/trigger-cases.json +30 -0
  250. package/skills/refactor-safely/SKILL.md +1 -1
  251. package/skills/refactor-safely/trigger-cases.json +30 -0
  252. package/skills/regression-forecast/trigger-cases.json +31 -0
  253. package/skills/release-checklist/trigger-cases.json +31 -0
  254. package/skills/report-craft/SKILL.md +76 -0
  255. package/skills/report-craft/references/feasibility.md +118 -0
  256. package/skills/report-craft/references/review-checklist.md +51 -0
  257. package/skills/report-craft/references/visuals.md +114 -0
  258. package/skills/report-craft/references/writing.md +80 -0
  259. package/skills/report-craft/scripts/__pycache__/chart_render.cpython-312.pyc +0 -0
  260. package/skills/report-craft/scripts/__pycache__/fin_model.cpython-312.pyc +0 -0
  261. package/skills/report-craft/scripts/__pycache__/project_facts.cpython-312.pyc +0 -0
  262. package/skills/report-craft/scripts/chart_render.py +1222 -0
  263. package/skills/report-craft/scripts/fin_model.py +602 -0
  264. package/skills/report-craft/scripts/project_facts.py +580 -0
  265. package/skills/report-craft/scripts/report_battery.py +349 -0
  266. package/skills/report-craft/trigger-cases.json +15 -0
  267. package/skills/retail-store-ops/trigger-cases.json +30 -0
  268. package/skills/risk-compliance/trigger-cases.json +31 -0
  269. package/skills/scope-creep/SKILL.md +45 -0
  270. package/skills/scope-creep/trigger-cases.json +7 -0
  271. package/skills/sec-auth/trigger-cases.json +31 -0
  272. package/skills/sec-compliance/trigger-cases.json +31 -0
  273. package/skills/securities-chain/trigger-cases.json +31 -0
  274. package/skills/securities-research/trigger-cases.json +31 -0
  275. package/skills/security-audit/trigger-cases.json +31 -0
  276. package/skills/slides-html/SKILL.md +25 -10
  277. package/skills/slides-html/trigger-cases.json +11 -0
  278. package/skills/sql-analysis/SKILL.md +110 -0
  279. package/skills/sql-analysis/trigger-cases.json +34 -0
  280. package/skills/sql-tuning/trigger-cases.json +31 -0
  281. package/skills/statistical-testing/trigger-cases.json +31 -0
  282. package/skills/strategy-planning/trigger-cases.json +30 -0
  283. package/skills/supply-chain/trigger-cases.json +31 -0
  284. package/skills/tax-compliance/trigger-cases.json +30 -0
  285. package/skills/tech-doc/SKILL.md +262 -0
  286. package/skills/tech-doc/scripts/__pycache__/archive_battery.cpython-312.pyc +0 -0
  287. package/skills/tech-doc/scripts/__pycache__/archive_build.cpython-312.pyc +0 -0
  288. package/skills/tech-doc/scripts/__pycache__/archive_check.cpython-312.pyc +0 -0
  289. package/skills/tech-doc/scripts/__pycache__/archive_spec.cpython-312.pyc +0 -0
  290. package/skills/tech-doc/scripts/__pycache__/doc_templates.cpython-312.pyc +0 -0
  291. package/skills/tech-doc/scripts/archive_battery.py +860 -0
  292. package/skills/tech-doc/scripts/archive_build.py +599 -0
  293. package/skills/tech-doc/scripts/archive_check.py +1307 -0
  294. package/skills/tech-doc/scripts/archive_spec.py +499 -0
  295. package/skills/tech-doc/scripts/doc_templates.py +624 -0
  296. package/skills/tech-doc/trigger-cases.json +57 -0
  297. package/skills/tech-research/trigger-cases.json +31 -0
  298. package/skills/tender-bidding/trigger-cases.json +31 -0
  299. package/skills/translation-pro/trigger-cases.json +30 -0
  300. package/skills/treasury-mgmt/trigger-cases.json +31 -0
  301. package/skills/trim-cot-leakage/SKILL.md +58 -0
  302. package/skills/trim-cot-leakage/trigger-cases.json +23 -0
  303. package/skills/web-craft/SKILL.md +103 -0
  304. package/skills/web-craft/references/craft-checklist.md +75 -0
  305. package/skills/web-craft/references/design-direction.md +122 -0
  306. package/skills/web-craft/references/h5-mobile.md +100 -0
  307. package/skills/web-craft/references/motion.md +136 -0
  308. package/skills/web-craft/references/recipes.md +74 -0
  309. package/skills/web-craft/templates/wedding-invitation.html +1180 -0
  310. package/skills/web-craft/trigger-cases.json +14 -0
  311. package/skills/web-scraping/trigger-cases.json +34 -0
  312. package/skills/web-search/SKILL.md +11 -10
  313. package/skills/web-search/trigger-cases.json +31 -0
  314. package/skills/weekly-report/SKILL.md +134 -0
  315. package/skills/weekly-report/trigger-cases.json +11 -0
  316. package/skills/write-tests/trigger-cases.json +31 -0
  317. package/skills/xlsx-analysis/SKILL.md +143 -77
  318. package/skills/xlsx-analysis/scripts/__pycache__/data_profile.cpython-312.pyc +0 -0
  319. package/skills/xlsx-analysis/scripts/__pycache__/xlsx_build.cpython-312.pyc +0 -0
  320. package/skills/xlsx-analysis/scripts/__pycache__/xlsx_check.cpython-312.pyc +0 -0
  321. package/skills/xlsx-analysis/scripts/__pycache__/xlsx_layout.cpython-312.pyc +0 -0
  322. package/skills/xlsx-analysis/scripts/__pycache__/xlsx_toolkit.cpython-312.pyc +0 -0
  323. package/skills/xlsx-analysis/scripts/data_profile.py +391 -0
  324. package/skills/xlsx-analysis/scripts/xlsx_battery.py +389 -0
  325. package/skills/xlsx-analysis/scripts/xlsx_build.py +522 -0
  326. package/skills/xlsx-analysis/scripts/xlsx_check.py +924 -0
  327. package/skills/xlsx-analysis/scripts/xlsx_layout.py +235 -0
  328. package/skills/xlsx-analysis/scripts/xlsx_style.py +28 -66
  329. package/skills/xlsx-analysis/scripts/xlsx_toolkit.py +61 -5
  330. package/skills/xlsx-analysis/trigger-cases.json +59 -0
  331. package/squads/hc-defense.md +47 -0
  332. package/squads/hc-rdarchive.md +110 -0
  333. package/squads/hc-redteam.md +50 -0
  334. package/squads/hc-weekly.md +125 -0
  335. package/src/account.js +1 -0
  336. package/src/acp.js +1 -0
  337. package/src/advisor.js +1 -1
  338. package/src/agent.js +1 -1
  339. package/src/api.js +1 -1
  340. package/src/archaeology.js +1 -0
  341. package/src/ask-ui.js +1 -0
  342. package/src/assets-import.js +1 -1
  343. package/src/autofix.js +1 -1
  344. package/src/automation-tools.js +1 -0
  345. package/src/automation.js +1 -1
  346. package/src/autorun.js +1 -1
  347. package/src/banner.js +1 -1
  348. package/src/capability-probe.js +1 -0
  349. package/src/checkpoints.js +1 -1
  350. package/src/code-tools.js +1 -0
  351. package/src/codeindex.js +1 -1
  352. package/src/codemode.js +1 -0
  353. package/src/command-bridge.js +1 -0
  354. package/src/command-ui.js +1 -0
  355. package/src/commands.js +1 -1
  356. package/src/compaction.js +1 -1
  357. package/src/computer.js +1 -0
  358. package/src/config.js +1 -1
  359. package/src/consult.js +1 -1
  360. package/src/context-report.js +1 -1
  361. package/src/context-window.js +1 -0
  362. package/src/context.js +1 -1
  363. package/src/crew.js +1 -0
  364. package/src/crush.js +1 -0
  365. package/src/daemon.js +1 -1
  366. package/src/decisions.js +1 -1
  367. package/src/depaudit.js +1 -0
  368. package/src/device.js +1 -1
  369. package/src/diagram-draw.js +1 -0
  370. package/src/diagram-layouts.js +1 -0
  371. package/src/diagram-raster.js +1 -0
  372. package/src/diagram.js +1 -0
  373. package/src/dictation.js +1 -0
  374. package/src/diffview.js +1 -1
  375. package/src/doctor.js +1 -1
  376. package/src/docx-tools.js +1 -0
  377. package/src/errkind.js +1 -0
  378. package/src/evidence.js +1 -0
  379. package/src/executors.js +1 -1
  380. package/src/expert-catalog.js +1 -1
  381. package/src/experts-store.js +1 -1
  382. package/src/experts.js +1 -1
  383. package/src/external-agents.js +1 -0
  384. package/src/forks.js +1 -1
  385. package/src/fs-observe.js +1 -0
  386. package/src/gitflow.js +1 -1
  387. package/src/goalround.js +1 -0
  388. package/src/hooks.js +1 -1
  389. package/src/input.js +1 -1
  390. package/src/insights.js +1 -1
  391. package/src/jobs.js +1 -0
  392. package/src/kbsync.js +1 -1
  393. package/src/lsp.js +1 -0
  394. package/src/mcp-apps.js +1 -0
  395. package/src/mcp.js +1 -1
  396. package/src/memory.js +1 -1
  397. package/src/notify.js +1 -0
  398. package/src/permissions.js +1 -1
  399. package/src/plan-limits.js +1 -0
  400. package/src/policy.js +1 -0
  401. package/src/pptx-tools.js +1 -0
  402. package/src/presence.js +1 -0
  403. package/src/privatedir.js +1 -0
  404. package/src/pyruntime.js +1 -1
  405. package/src/radar.js +1 -0
  406. package/src/render.js +1 -1
  407. package/src/repl.js +1 -1
  408. package/src/report-tools.js +1 -0
  409. package/src/routing.js +1 -0
  410. package/src/sandbox-probe.js +1 -0
  411. package/src/sandbox-win-launcher.mjs +156 -0
  412. package/src/sandbox.js +1 -0
  413. package/src/scan.js +1 -1
  414. package/src/schedule.js +1 -1
  415. package/src/scopecreep.js +1 -0
  416. package/src/sdk.js +1 -0
  417. package/src/selection.js +1 -0
  418. package/src/session-events.js +1 -0
  419. package/src/session.js +1 -1
  420. package/src/sessionindex.js +1 -0
  421. package/src/sessionlog.js +1 -0
  422. package/src/skill-catalog.js +1 -0
  423. package/src/skill-lint.js +1 -0
  424. package/src/skill-optimize.js +1 -0
  425. package/src/skill-scan.js +1 -0
  426. package/src/skills.js +1 -1
  427. package/src/spill.js +1 -0
  428. package/src/squad.js +1 -1
  429. package/src/subagent.js +1 -1
  430. package/src/surface-state.js +1 -0
  431. package/src/tapd.js +1 -0
  432. package/src/terminal-select.js +1 -0
  433. package/src/terminal.js +1 -0
  434. package/src/test-runner.js +1 -0
  435. package/src/todo.js +1 -1
  436. package/src/toolpipe.js +1 -0
  437. package/src/tools.js +1 -1
  438. package/src/toon.js +1 -0
  439. package/src/transcript.js +1 -1
  440. package/src/tui/app.js +1 -1
  441. package/src/tui/command-menu.js +1 -0
  442. package/src/tui/progress.js +1 -0
  443. package/src/tui/scroll.js +1 -0
  444. package/src/tui/welcome.js +1 -0
  445. package/src/tui/workbench.js +1 -0
  446. package/src/ui-spec.js +1 -0
  447. package/src/uitree.js +1 -0
  448. package/src/uninstall.js +1 -1
  449. package/src/vision.js +1 -0
  450. package/src/visual.js +1 -0
  451. package/src/voice-form.js +1 -0
  452. package/src/voice.js +1 -0
  453. package/src/watch.js +1 -1
  454. package/src/web-audit.js +1 -0
  455. package/src/web-cdp.js +1 -0
  456. package/src/web-inspect.js +1 -0
  457. package/src/websearch.js +1 -0
  458. package/src/weekly.js +1 -0
  459. package/src/workflow.js +1 -0
  460. package/src/worklog.js +1 -0
  461. package/src/xlsx-tools.js +1 -0
  462. package/ui/command-picker.js +79 -0
  463. package/skills/ab-testing/scripts/__pycache__/ab_test.cpython-312.pyc +0 -0
  464. package/skills/accounting-ops/scripts/__pycache__/acct_tools.cpython-312.pyc +0 -0
  465. package/skills/anomaly-detection/scripts/__pycache__/anomaly.cpython-312.pyc +0 -0
  466. package/skills/audit-assist/scripts/__pycache__/audit_sampling.cpython-312.pyc +0 -0
  467. package/skills/banking-credit/scripts/__pycache__/credit_metrics.cpython-312.pyc +0 -0
  468. package/skills/data-report-pro/scripts/__pycache__/bigdata.cpython-312.pyc +0 -0
  469. package/skills/data-wrangling/scripts/__pycache__/join_tools.cpython-312.pyc +0 -0
  470. package/skills/ecommerce-ops/scripts/__pycache__/gmv_decompose.cpython-312.pyc +0 -0
  471. package/skills/funnel-cohort/scripts/__pycache__/funnel.cpython-312.pyc +0 -0
  472. package/skills/management-accounting/scripts/__pycache__/costing.cpython-312.pyc +0 -0
  473. package/skills/manufacturing-quality/scripts/__pycache__/spc.cpython-312.pyc +0 -0
  474. package/skills/marketing-analytics/scripts/__pycache__/rfm.cpython-312.pyc +0 -0
  475. package/skills/optimization-solver/scripts/__pycache__/solver.cpython-312.pyc +0 -0
  476. package/skills/pivot-crosstab/scripts/__pycache__/pivot_tools.cpython-312.pyc +0 -0
  477. package/skills/portfolio-analysis/scripts/__pycache__/portfolio.cpython-312.pyc +0 -0
  478. package/skills/procurement-supply/scripts/__pycache__/supply.cpython-312.pyc +0 -0
  479. package/skills/production-planning/scripts/__pycache__/mrp_capacity.cpython-312.pyc +0 -0
  480. package/skills/project-management/scripts/__pycache__/cpm.cpython-312.pyc +0 -0
  481. package/skills/quant-analysis/scripts/__pycache__/quant.cpython-312.pyc +0 -0
  482. package/skills/real-estate-property/scripts/__pycache__/property_calc.cpython-312.pyc +0 -0
  483. package/skills/regression-forecast/scripts/__pycache__/regress.cpython-312.pyc +0 -0
  484. package/skills/securities-research/scripts/__pycache__/valuation.cpython-312.pyc +0 -0
  485. package/skills/statistical-testing/scripts/__pycache__/stat_tests.cpython-312.pyc +0 -0
  486. package/skills/treasury-mgmt/scripts/__pycache__/cashflow_forecast.cpython-312.pyc +0 -0
@@ -1,193 +1,784 @@
1
- # PPT 构建器(经 run_python 执行)。16:9 空白版式全手绘(不依赖模板文件),四套主题配色,
2
- # 八种版式:cover / agenda / section / bullets / kpi / image / table / end;notes 进演讲备注。
3
- # 直接执行本文件 = 自测:产出 pptx_deck_check.pptx 并重读校验。
4
- import base64
5
-
6
- from pptx import Presentation
7
- from pptx.dml.color import RGBColor
8
- from pptx.enum.text import PP_ALIGN
9
- from pptx.oxml.ns import qn
10
- from pptx.util import Emu, Inches, Pt
11
-
12
- THEMES = {
13
- 'navy': dict(primary='1F4E79', accent='2E75B6', light='DEEBF7', text='333333'), # 深蓝商务(默认)
14
- 'tech': dict(primary='5B2D90', accent='9A66CC', light='EDE4F7', text='333333'), # 科技紫
15
- 'mono': dict(primary='404040', accent='7F7F7F', light='F2F2F2', text='333333'), # 极简灰
16
- 'warm': dict(primary='C55A11', accent='ED7D31', light='FBE5D6', text='3B3B3B'), # 活力橙
17
- }
18
- FONT = '微软雅黑' # 播放端渲染(mac 自动回退苹方),不嵌字体
19
-
20
-
21
- def _style(run, size=18, bold=False, color='333333', font=FONT):
22
- f = run.font
23
- f.name = font
24
- f.size = Pt(size)
25
- f.bold = bold
26
- f.color.rgb = RGBColor.from_string(color)
27
- # 中文字体需另设 <a:ea>(run.font.name 只设拉丁 <a:latin>);用 lxml 直建,兼容各版本 python-pptx
28
- from lxml import etree
29
- rPr = run._r.get_or_add_rPr()
30
- ea = rPr.find(qn('a:ea'))
31
- if ea is None:
32
- ea = etree.SubElement(rPr, qn('a:ea'))
33
- ea.set('typeface', font)
1
+ # PPT 建版器:**先量后画**的 16:9 演示文稿构建器(经 run_python 执行)。
2
+ #
3
+ # 与"直接调 python-pptx"的根本差别:那样是盲写——把文字塞进一个拍脑袋定的框,
4
+ # 装不装得下没人知道,保存那一刻也不报错。本模块每放一段文字前先用 pptx_layout
5
+ # 量一遍:量不下就先降字号(在下限内),还不下就自动拆页,绝不硬塞、绝不出框。
6
+ # 图片一律等比 contain 进内容区(只给宽或只给高的写法根本不存在于这里,
7
+ # 所以不可能被拉成长脸)。所有元素落在同一套栅格上,位置由栅格算,不手填坐标,
8
+ # 因此天然不重叠——`save()` 之后还会自动跑一遍 pptx_check 体检兜底。
9
+ #
10
+ # 版式:cover/agenda/section/bullets/two_col/kpi/image/image_text/table/quote/
11
+ # compare/timeline/end,全部 16:9,六套主题配色。
12
+ #
13
+ # 直接执行 = 自测:产出 pptx_deck_check.pptx 并体检。
14
+
15
+ import os
16
+ import sys
17
+
18
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
19
+
20
+ from pptx import Presentation # noqa: E402
21
+ from pptx.dml.color import RGBColor # noqa: E402
22
+ from pptx.enum.shapes import MSO_SHAPE # noqa: E402
23
+ from pptx.enum.text import MSO_ANCHOR, MSO_AUTO_SIZE, PP_ALIGN # noqa: E402
24
+ from pptx.oxml.ns import qn # noqa: E402
25
+ from pptx.util import Emu, Pt # noqa: E402
26
+
27
+ import pptx_layout as L # noqa: E402
28
+ from pptx_layout import Rect, THEMES # noqa: E402
29
+
30
+ FONT = '微软雅黑' # 播放端渲染(mac 自动回退苹方),不嵌字体
31
+ FONT_NUM = 'Arial' # 大数字用西文字体,字形更挺
32
+
33
+ # 字号阶梯(磅):标题/正文/小字。正文给区间,装不下就在区间内降,降到底才拆页。
34
+ SZ_TITLE = 28
35
+ SZ_BODY_MAX = 22
36
+ SZ_BODY_MIN = 16
37
+ SZ_CAPTION = 12
38
+ SZ_FOOTER = 10
39
+ SZ_MIN = 10 # 字号地板:再挤也不许低于它(投影上 10pt 以下等于没写),撞地板就改截断
40
+
41
+
42
+ class Grid:
43
+ """页面栅格:所有版式都从这里取坐标,不手填数字——同源才谈得上对齐。"""
44
+
45
+ def __init__(self, w, h):
46
+ self.W, self.H = w, h
47
+ self.m = round(w * 0.056, 3) # 左右外边距
48
+ self.title_y = round(h * 0.056, 3)
49
+ self.title_h = round(h * 0.104, 3)
50
+ self.rule_y = round(self.title_y + self.title_h + 0.04, 3)
51
+ self.top = round(self.rule_y + 0.16, 3) # 内容区顶
52
+ self.footer_y = round(h - 0.52, 3)
53
+ self.bottom = round(h - 0.72, 3) # 内容区底(给页脚留出)
54
+ self.gut = 0.34 # 栏间距
55
+
56
+ @property
57
+ def content(self):
58
+ return Rect(self.m, self.top, self.W - 2 * self.m, self.bottom - self.top)
59
+
60
+ def cols(self, n, box=None):
61
+ """把一块区域等分成 n 栏(含栏间距)。"""
62
+ b = box or self.content
63
+ w = (b.w - self.gut * (n - 1)) / n
64
+ return [Rect(b.x + (w + self.gut) * i, b.y, w, b.h) for i in range(n)]
65
+
66
+ def rows(self, n, box=None, gap=0.24):
67
+ b = box or self.content
68
+ h = (b.h - gap * (n - 1)) / n
69
+ return [Rect(b.x, b.y + (h + gap) * i, b.w, h) for i in range(n)]
34
70
 
35
71
 
36
72
  class Deck:
37
- def __init__(self, theme='navy'):
38
- self.t = THEMES.get(theme, THEMES['navy'])
73
+ def __init__(self, theme='navy', size='16:9', footer='', page_numbers=True, font=FONT):
74
+ self.t = dict(THEMES.get(theme, THEMES['navy']))
75
+ self.theme_name = theme if theme in THEMES else 'navy'
76
+ self.font = font
39
77
  self.prs = Presentation()
40
- self.prs.slide_width = Inches(13.333)
41
- self.prs.slide_height = Inches(7.5)
78
+ w, h = (13.333, 7.5) if size != '4:3' else (10.0, 7.5)
79
+ self.prs.slide_width = Emu(int(w * L.EMU_IN))
80
+ self.prs.slide_height = Emu(int(h * L.EMU_IN))
81
+ self.g = Grid(w, h)
82
+ self.footer = footer or ''
83
+ self.page_numbers = page_numbers
84
+ self.warnings = [] # 建版过程中的降级记录(降字号/拆页),如实回报
85
+ self._occupied = [] # 当前页已占区域(防重叠自检)
86
+ self._outline = [] # [(页号, 版式, 标题)]
42
87
 
43
- # ── 内部小件 ──────────────────────────────────────────────
44
- def _slide(self, notes=None):
45
- s = self.prs.slides.add_slide(self.prs.slide_layouts[6]) # 空白版式
88
+ # ── 底层小件 ────────────────────────────────────────────────────────────
89
+ def _slide(self, notes=None, chrome=True, kind='', title=''):
90
+ s = self.prs.slides.add_slide(self.prs.slide_layouts[6]) # 空白版式
91
+ if self.t['bg'].upper() != 'FFFFFF': # 深色主题:先铺满页底
92
+ self._rect_shape(s, Rect(0, 0, self.g.W, self.g.H), self.t['bg'])
46
93
  if notes:
47
- s.notes_slide.notes_text_frame.text = notes
94
+ s.notes_slide.notes_text_frame.text = str(notes)
95
+ self._occupied = []
96
+ self._outline.append((len(self.prs.slides), kind, title))
97
+ s._wt_chrome = chrome
48
98
  return s
49
99
 
50
- def _rect(self, s, x, y, w, h, color):
51
- from pptx.enum.shapes import MSO_SHAPE
52
- r = s.shapes.add_shape(MSO_SHAPE.RECTANGLE, x, y, w, h)
53
- r.fill.solid()
54
- r.fill.fore_color.rgb = RGBColor.from_string(color)
55
- r.line.fill.background()
56
- return r
100
+ def _claim(self, rect, what):
101
+ """占位登记 + 重叠自检(引擎自己算错也当场发现,而不是等用户打开 PPT)。"""
102
+ for r, w in self._occupied:
103
+ if r.contains(rect, 0.01) or rect.contains(r, 0.01):
104
+ continue # 卡片底 ⊃ 卡片内文字:套着是版式,不是撞车
105
+ if r.overlap_ratio(rect) > 0.02:
106
+ self.warnings.append(f'内部版式冲突:{what} 与 {w} 重叠(引擎缺陷,请报告)')
107
+ break
108
+ self._occupied.append((rect, what))
57
109
 
58
- def _text(self, s, x, y, w, h, text, size=18, bold=False, color=None, align=PP_ALIGN.LEFT):
59
- tb = s.shapes.add_textbox(x, y, w, h)
110
+ def _rect_shape(self, s, rect, color, line=None, radius=None):
111
+ shp = s.shapes.add_shape(
112
+ MSO_SHAPE.ROUNDED_RECTANGLE if radius else MSO_SHAPE.RECTANGLE,
113
+ Emu(int(rect.x * L.EMU_IN)), Emu(int(rect.y * L.EMU_IN)),
114
+ Emu(int(rect.w * L.EMU_IN)), Emu(int(rect.h * L.EMU_IN)))
115
+ if radius:
116
+ try:
117
+ shp.adjustments[0] = radius
118
+ except Exception:
119
+ pass
120
+ if color:
121
+ shp.fill.solid()
122
+ shp.fill.fore_color.rgb = RGBColor.from_string(color)
123
+ else:
124
+ shp.fill.background()
125
+ if line:
126
+ shp.line.color.rgb = RGBColor.from_string(line)
127
+ shp.line.width = Pt(1)
128
+ else:
129
+ shp.line.fill.background()
130
+ shp.shadow.inherit = False
131
+ return shp
132
+
133
+ def _textbox(self, s, rect):
134
+ """受控文本框:零内边距、开自动换行、关自动改尺寸。
135
+
136
+ 这三项是 python-pptx 的默认坑:add_textbox 默认 wrap="none" + spAutoFit,
137
+ 长文字会拉成一整行冲出版心,而框还会自己长高把下面的东西顶掉。
138
+ """
139
+ tb = s.shapes.add_textbox(Emu(int(rect.x * L.EMU_IN)), Emu(int(rect.y * L.EMU_IN)),
140
+ Emu(int(rect.w * L.EMU_IN)), Emu(int(rect.h * L.EMU_IN)))
60
141
  tf = tb.text_frame
61
142
  tf.word_wrap = True
62
- p = tf.paragraphs[0]
63
- p.alignment = align
64
- _style(p.add_run(), size, bold, color or self.t['text'])
65
- p.runs[0].text = text
66
- return tf
67
-
68
- def _title(self, s, text):
69
- self._rect(s, 0, 0, self.prs.slide_width, Inches(0.12), self.t['accent'])
70
- self._text(s, Inches(0.6), Inches(0.35), Inches(12), Inches(0.9), text, size=28, bold=True, color=self.t['primary'])
71
-
72
- # ── 八种版式 ──────────────────────────────────────────────
73
- def add_cover(self, title, subtitle='', notes=None):
74
- s = self._slide(notes)
75
- self._rect(s, 0, 0, self.prs.slide_width, self.prs.slide_height, self.t['primary'])
76
- self._text(s, Inches(1), Inches(2.6), Inches(11.3), Inches(1.4), title, size=44, bold=True, color='FFFFFF', align=PP_ALIGN.CENTER)
143
+ tf.auto_size = MSO_AUTO_SIZE.NONE
144
+ tf.margin_left = tf.margin_right = tf.margin_top = tf.margin_bottom = Pt(0)
145
+ return tb, tf
146
+
147
+ def _fg(self, bg, prefer=(), min_ratio=4.5):
148
+ """在 bg 底色上挑一个对比度达标的前景色(配色可以随便改,可读性不由人肉保证)。"""
149
+ return L.pick_fg(bg, list(prefer) + [self.t['text'], self.t['on_primary']], min_ratio)
150
+
151
+ def _style(self, run, size, bold=False, color=None, font=None):
152
+ f = run.font
153
+ fam = font or self.font
154
+ f.name = fam
155
+ f.size = Pt(size)
156
+ f.bold = bold
157
+ f.color.rgb = RGBColor.from_string(color or self.t['text'])
158
+ # 中文必须另设 <a:ea>:run.font.name 只写 <a:latin>,不设 ea 的话中文会回退到
159
+ # 别的字体,实际行宽与我们量出来的对不上——溢出就是这么来的
160
+ rPr = run._r.get_or_add_rPr()
161
+ for tag in ('a:ea', 'a:cs'):
162
+ el = rPr.find(qn(tag))
163
+ if el is None:
164
+ from lxml import etree
165
+ el = etree.SubElement(rPr, qn(tag))
166
+ el.set('typeface', fam)
167
+
168
+ def _para(self, tf, first, text, size, bold=False, color=None, align=None,
169
+ space_after=0, indent=0.0, hang=0.0, font=None):
170
+ p = tf.paragraphs[0] if first else tf.add_paragraph()
171
+ if align is not None:
172
+ p.alignment = align
173
+ if space_after:
174
+ p.space_after = Pt(space_after)
175
+ if indent or hang:
176
+ pPr = p._p.get_or_add_pPr()
177
+ pPr.set('marL', str(int(indent * L.EMU_IN)))
178
+ if hang:
179
+ pPr.set('indent', str(int(-hang * L.EMU_IN)))
180
+ from lxml import etree
181
+ if pPr.find(qn('a:buNone')) is None:
182
+ etree.SubElement(pPr, qn('a:buNone'))
183
+ r = p.add_run()
184
+ r.text = str(text)
185
+ self._style(r, size, bold, color, font)
186
+ return p
187
+
188
+ def _fit_line(self, s, rect, text, size, color=None, align=None, bold=False,
189
+ lo=None, font=None, anchor=None, what='标签'):
190
+ """把一段**标签文字**放进固定高度的框:先降字号,仍不够就按行数裁断加省略号。
191
+
192
+ 固定高度 + 固定字号 = 盲写。标题、页脚、图注、时间轴的时间点这些看着"就一行"的地方,
193
+ 换个稍长的文案就会溢出——压力测试里 51 条事故全出在这类框上。
194
+ """
195
+ raw = str(text if text is not None else '')
196
+ w = max(20.0, rect.w * L.PT_IN)
197
+ floor = max(SZ_MIN, lo or round(size * 0.6))
198
+ sz = L.fit_size(raw, w, rect.h * L.PT_IN, hi=max(size, floor), lo=floor, bold=bold)
199
+ maxn = max(1, int(rect.h * L.PT_IN / (sz * L.LINE_H)))
200
+ txt = L.clip(raw, sz, w, maxn, bold)
201
+ if txt != raw:
202
+ # 撞地板了:宁可截断也不把字缩到看不清。截断是可见的降级,必须如实报出来。
203
+ self.warnings.append(f'{what}「{raw[:18]}…」放不进 {rect.w:.1f}"×{rect.h:.1f}" 的位置,'
204
+ f'已按 {sz:.0f}pt 截断显示——请把这句话压短')
205
+ tb, tf = self._textbox(s, rect)
206
+ if anchor is not None:
207
+ tf.vertical_anchor = anchor
208
+ self._para(tf, True, txt, sz, bold, color, align, font=font)
209
+ self._claim(rect, what)
210
+ return tb, tf
211
+
212
+ def _fit_text(self, s, rect, items, hi=SZ_BODY_MAX, lo=SZ_BODY_MIN, gap=0.55,
213
+ color=None, bold=False, align=None, bullet='• ', indent=0.0):
214
+ """把 items 放进 rect:在 [lo,hi] 内选能装下的最大字号;装不下返回 None(调用方拆页)。
215
+
216
+ items: [str] 或 [(text, level)]。gap = 段间距占字号的比例。
217
+ """
218
+ norm = [(t, 0) if isinstance(t, str) else (str(t[0]), int(t[1])) for t in items]
219
+ got = self._best_size(rect, norm, hi, lo, gap, bullet, indent, bold)
220
+ if got is None:
221
+ return None
222
+ size, plan = got
223
+ self._emit(s, rect, plan, size, gap, color, bold, align, bullet, indent)
224
+ return size
225
+
226
+ def _best_size(self, rect, norm, hi=SZ_BODY_MAX, lo=SZ_BODY_MIN, gap=0.55,
227
+ bullet='• ', indent=0.0, bold=False):
228
+ """只量不画:能装下就回 (字号, 排版方案),装不下回 None。"""
229
+ size = hi
230
+ while size >= lo:
231
+ total, plan = self._measure(norm, rect, size, gap, bullet, indent, bold)
232
+ if total <= rect.h:
233
+ return size, plan
234
+ size -= 1
235
+ return None
236
+
237
+ def _measure(self, norm, rect, size, gap, bullet, indent, bold):
238
+ total, plan = 0.0, []
239
+ for i, (text, lvl) in enumerate(norm):
240
+ sz = size if lvl == 0 else max(SZ_BODY_MIN - 2, size - 3)
241
+ pad = indent + lvl * 0.34
242
+ mark = (bullet if lvl == 0 else '– ') if bullet else ''
243
+ bw = L.measure(mark, sz, False) / L.PT_IN
244
+ # 连项目符号一起量、压在 marL 之后的净宽里——体检侧就是这么读的(见
245
+ # pptx_check._para_indent)。两边差一个符号宽,就会出现"建版说装得下、
246
+ # 体检说溢出 2%"这种自相矛盾。宁可保守一格,也不要两套尺。
247
+ avail = (rect.w - pad - bw) * L.PT_IN * L.WRAP_SAFETY
248
+ lines = L.wrap_segments([(mark + text, sz, bold)], max(20.0, avail))
249
+ h = L.text_height(lines) / L.PT_IN
250
+ total += h + (gap * sz / L.PT_IN if i < len(norm) - 1 else 0)
251
+ plan.append((text, lvl, sz, pad, bw, h))
252
+ return total, plan
253
+
254
+ def _emit(self, s, rect, plan, size, gap, color, bold, align, bullet, indent):
255
+ tb, tf = self._textbox(s, rect)
256
+ tf.vertical_anchor = MSO_ANCHOR.TOP
257
+ for i, (text, lvl, sz, pad, bw, h) in enumerate(plan):
258
+ mark = (bullet if lvl == 0 else '– ') if bullet else ''
259
+ self._para(tf, i == 0, mark + text, sz, bold,
260
+ color or (self.t['text'] if lvl == 0 else self.t['muted']),
261
+ align, space_after=(gap * sz if i < len(plan) - 1 else 0),
262
+ indent=pad + bw, hang=bw)
263
+ self._claim(rect, '正文')
264
+ return tb
265
+
266
+ def _title(self, s, text, sub=''):
267
+ g = self.g
268
+ r = Rect(g.m, g.title_y, g.W - 2 * g.m, g.title_h)
269
+ sub_h = (SZ_CAPTION * L.LINE_H) / L.PT_IN if sub else 0 # 副标题也占高,不能只按主标题量
270
+ main = Rect(r.x, r.y, r.w, r.h - sub_h)
271
+ size = L.fit_size(text, main.w * L.PT_IN, main.h * L.PT_IN, hi=SZ_TITLE, lo=18, bold=True)
272
+ maxn = max(1, int(main.h * L.PT_IN / (size * L.LINE_H)))
273
+ tb, tf = self._textbox(s, r)
274
+ tf.vertical_anchor = MSO_ANCHOR.BOTTOM
275
+ self._para(tf, True, L.clip(text, size, main.w * L.PT_IN, maxn, True), size, True, self.t['head'])
276
+ if sub:
277
+ self._para(tf, False, L.clip(sub, SZ_CAPTION, r.w * L.PT_IN, 1), SZ_CAPTION, False, self.t['muted'])
278
+ self._claim(r, '标题')
279
+ self._rect_shape(s, Rect(g.m, g.rule_y, 0.9, 0.045), self.t['accent'])
280
+ self._rect_shape(s, Rect(g.m + 0.9, g.rule_y + 0.017, g.W - 2 * g.m - 0.9, 0.011), self.t['light'])
281
+ return r
282
+
283
+ def _chrome(self):
284
+ """页脚与页码(最后统一刷,页码才知道总页数)。"""
285
+ g = self.g
286
+ n = len(self.prs.slides)
287
+ for i, s in enumerate(self.prs.slides, 1):
288
+ if not getattr(s, '_wt_chrome', True):
289
+ continue
290
+ if self.footer:
291
+ self._fit_line(s, Rect(g.m, g.footer_y, g.W * 0.6, 0.28), self.footer,
292
+ SZ_FOOTER, self.t['muted'], what='页脚')
293
+ if self.page_numbers:
294
+ self._fit_line(s, Rect(g.W - g.m - 1.2, g.footer_y, 1.2, 0.28), f'{i} / {n}',
295
+ SZ_FOOTER, self.t['muted'], PP_ALIGN.RIGHT, what='页码')
296
+
297
+ # ── 版式 ────────────────────────────────────────────────────────────────
298
+ def add_cover(self, title, subtitle='', meta='', notes=None):
299
+ g = self.g
300
+ s = self._slide(notes, chrome=False, kind='封面', title=title)
301
+ self._rect_shape(s, Rect(0, 0, g.W, g.H), self.t['primary'])
302
+ self._rect_shape(s, Rect(0, g.H * 0.62, g.W, 0.06), self.t['accent'])
303
+ box = Rect(g.m + 0.4, g.H * 0.30, g.W - 2 * g.m - 0.8, g.H * 0.26)
304
+ self._fit_line(s, box, title, 44, self.t['on_primary'], bold=True, lo=26,
305
+ anchor=MSO_ANCHOR.BOTTOM, what='封面标题')
306
+ dim = self._fg(self.t['primary'], [self.t['light'], self.t['on_primary']])
307
+ y = g.H * 0.66
77
308
  if subtitle:
78
- self._text(s, Inches(1), Inches(4.2), Inches(11.3), Inches(0.8), subtitle, size=18, color='DDE7F3', align=PP_ALIGN.CENTER)
309
+ self._fit_line(s, Rect(box.x, y, box.w, 0.5), subtitle, 20, dim, what='封面副标题')
310
+ y += 0.56
311
+ if meta:
312
+ self._fit_line(s, Rect(box.x, y, box.w, 0.4), meta, 14, dim, what='封面署名')
79
313
  return s
80
314
 
81
315
  def add_agenda(self, items, title='目录', notes=None):
82
- s = self._slide(notes)
316
+ s = self._slide(notes, kind='目录', title=title)
83
317
  self._title(s, title)
84
- tb = s.shapes.add_textbox(Inches(1.2), Inches(1.7), Inches(10.5), Inches(5))
85
- tf = tb.text_frame
86
- tf.word_wrap = True
87
- for i, it in enumerate(items):
88
- p = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
89
- p.space_after = Pt(14)
90
- _style(p.add_run(), 22, False, self.t['text'])
91
- p.runs[0].text = f'{i + 1} {it}'
318
+ g = self.g
319
+ n = len(items)
320
+ cols = 2 if n > 5 else 1
321
+ boxes = g.cols(cols)
322
+ per = (n + cols - 1) // cols
323
+ for ci in range(cols):
324
+ chunk = items[ci * per:(ci + 1) * per]
325
+ if not chunk:
326
+ continue
327
+ b = boxes[ci]
328
+ rows = g.rows(per, b, gap=0.12)
329
+ for i, it in enumerate(chunk):
330
+ idx = ci * per + i + 1
331
+ r = rows[i]
332
+ num_w = 0.62
333
+ self._rect_shape(s, Rect(r.x, r.y + (r.h - 0.42) / 2, 0.46, 0.42), self.t['light'])
334
+ tb, tf = self._textbox(s, Rect(r.x, r.y + (r.h - 0.42) / 2 + 0.06, 0.46, 0.32))
335
+ self._para(tf, True, f'{idx:02d}', 14, True, self.t['on_light'], PP_ALIGN.CENTER)
336
+ box = Rect(r.x + num_w, r.y, r.w - num_w, r.h)
337
+ self._fit_line(s, box, str(it), 20, self.t['text'], lo=14,
338
+ anchor=MSO_ANCHOR.MIDDLE, what=f'目录项{idx}')
92
339
  return s
93
340
 
94
- def add_section(self, title, notes=None):
95
- s = self._slide(notes)
96
- self._rect(s, 0, Inches(3.2), self.prs.slide_width, Inches(1.2), self.t['light'])
97
- self._text(s, Inches(1), Inches(3.35), Inches(11.3), Inches(0.9), title, size=32, bold=True, color=self.t['primary'], align=PP_ALIGN.CENTER)
341
+ def add_section(self, title, no=None, subtitle='', notes=None):
342
+ g = self.g
343
+ s = self._slide(notes, chrome=False, kind='章节', title=title)
344
+ self._rect_shape(s, Rect(0, 0, g.W, g.H), self.t['light'])
345
+ self._rect_shape(s, Rect(0, g.H * 0.36, g.W, g.H * 0.28), self.t['primary'])
346
+ band = Rect(g.m, g.H * 0.36, g.W - 2 * g.m, g.H * 0.28)
347
+ if no is not None:
348
+ self._fit_line(s, Rect(band.x, band.y + 0.20, 2.4, 0.40), str(no), 16,
349
+ self._fg(self.t['primary'], [self.t['accent'], self.t['on_primary']]),
350
+ bold=True, what='章节编号')
351
+ box = Rect(band.x, band.y + 0.70, band.w, band.h - 0.92)
352
+ else:
353
+ box = Rect(band.x, band.y + 0.35, band.w, band.h - 0.70)
354
+ self._fit_line(s, box, title, 34, self.t['on_primary'], bold=True, lo=22, what='章节标题')
355
+ if subtitle:
356
+ self._fit_line(s, Rect(band.x, band.bottom + 0.3, band.w, 0.5), subtitle, 14,
357
+ self._fg(self.t['light'], [self.t['muted'], self.t['on_light']]), what='章节副标题')
98
358
  return s
99
359
 
100
- def add_bullets(self, title, bullets, notes=None):
101
- s = self._slide(notes)
360
+ def add_bullets(self, title, bullets, notes=None, lead='', cont_mark='(续)'):
361
+ """要点页。装不下先降字号,降到下限仍不下则**自动拆页**(标题加「(续)」)。"""
362
+ items = _flat(bullets)
363
+ full = self.g.content
364
+ first_box = Rect(full.x, full.y + 0.74, full.w, full.h - 0.74) if lead else full
365
+ chunks = self._plan_pages(items, first_box, full)
366
+ if len(chunks) > 1:
367
+ self.warnings.append(
368
+ f'「{title}」{len(items)} 条要点一页放不下,已自动拆为 {len(chunks)} 页'
369
+ f'(每页 {"/".join(str(len(c)) for c in chunks)} 条);'
370
+ '更好的做法是按论点重新归并,而不是按条数切')
371
+ made = []
372
+ for i, chunk in enumerate(chunks):
373
+ t = title if i == 0 else f'{title}{cont_mark}'
374
+ s = self._slide(notes if i == 0 else None, kind='要点', title=t)
375
+ self._title(s, t)
376
+ box = full
377
+ if lead and i == 0:
378
+ lb = Rect(box.x, box.y, box.w, 0.62)
379
+ self._rect_shape(s, Rect(box.x, box.y, 0.05, 0.5), self.t['accent'])
380
+ self._fit_line(s, Rect(lb.x + 0.18, lb.y, lb.w - 0.18, 0.5), lead, 16,
381
+ self.t['head'], bold=True, what='导语')
382
+ box = first_box
383
+ if self._fit_text(s, box, chunk) is None: # 单条都超高:放下并交给体检报出来
384
+ self._fit_text(s, box, chunk, lo=SZ_BODY_MIN - 4)
385
+ made.append(s)
386
+ return made[0] if len(made) == 1 else made
387
+
388
+ def _plan_pages(self, items, first_box, rest_box):
389
+ """拆页规划:先看几页装得下,再**等分**——不是把前面塞满、剩一条孤零零占一页。"""
390
+ norm = [(t, 0) if isinstance(t, str) else (str(t[0]), int(t[1])) for t in items]
391
+ if not norm:
392
+ return [[]]
393
+ if self._best_size(first_box, norm) is not None:
394
+ return [norm]
395
+ for pages in range(2, len(norm) + 1):
396
+ per = -(-len(norm) // pages) # 上取整,均分
397
+ chunks = [norm[i:i + per] for i in range(0, len(norm), per)]
398
+ boxes = [first_box] + [rest_box] * (len(chunks) - 1)
399
+ if all(self._best_size(b, c) is not None for b, c in zip(boxes, chunks)):
400
+ return chunks
401
+ return [[x] for x in norm]
402
+
403
+ def add_two_col(self, title, left, right, left_title='', right_title='', notes=None):
404
+ s = self._slide(notes, kind='两栏', title=title)
102
405
  self._title(s, title)
103
- tb = s.shapes.add_textbox(Inches(0.9), Inches(1.6), Inches(11.5), Inches(5.3))
104
- tf = tb.text_frame
105
- tf.word_wrap = True
106
- for i, b in enumerate(bullets):
107
- p = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
108
- p.space_after = Pt(12)
109
- _style(p.add_run(), 20, False, self.t['text'])
110
- p.runs[0].text = '• ' + str(b)
406
+ a, b = self.g.cols(2)
407
+ for box, head, items in ((a, left_title, left), (b, right_title, right)):
408
+ inner = box
409
+ if head:
410
+ self._rect_shape(s, Rect(box.x, box.y, box.w, 0.44), self.t['light'])
411
+ self._fit_line(s, Rect(box.x + 0.16, box.y + 0.09, box.w - 0.32, 0.3), head, 16,
412
+ self.t['on_light'], bold=True, what=f'栏头{head[:6]}')
413
+ inner = Rect(box.x, box.y + 0.6, box.w, box.h - 0.6)
414
+ items = [x if isinstance(x, (list, tuple)) else (str(x), 0) for x in _flat(items)]
415
+ if self._fit_text(s, inner, items, hi=20, lo=14) is None:
416
+ self.warnings.append(f'「{title}」{head or ""}栏内容过多,已按下限字号放置,请精简')
417
+ self._fit_text(s, inner, items[:max(1, len(items) - 1)], hi=14, lo=12)
111
418
  return s
112
419
 
113
- def add_kpi(self, title, kpis, notes=None):
114
- """kpis: [(大数字, 说明)],最多 4 个卡片。"""
115
- s = self._slide(notes)
420
+ def add_kpi(self, title, kpis, notes=None, caption=''):
421
+ """大数字卡片页。kpis: [(数字, 说明)] 或 [(数字, 说明, 同比)],1~6 个自动排布。"""
422
+ s = self._slide(notes, kind='KPI', title=title)
116
423
  self._title(s, title)
117
- n = min(len(kpis), 4)
118
- w = Inches(2.7)
119
- gap = (self.prs.slide_width - n * w) / (n + 1)
120
- for i, (num, label) in enumerate(kpis[:4]):
121
- x = gap * (i + 1) + w * i
122
- self._rect(s, x, Inches(2.4), w, Inches(2.2), self.t['light'])
123
- self._text(s, x, Inches(2.7), w, Inches(1), str(num), size=36, bold=True, color=self.t['primary'], align=PP_ALIGN.CENTER)
124
- self._text(s, x, Inches(3.8), w, Inches(0.6), str(label), size=14, color=self.t['text'], align=PP_ALIGN.CENTER)
424
+ ks = list(kpis)[:6]
425
+ n = len(ks)
426
+ box = self.g.content
427
+ if caption:
428
+ box = Rect(box.x, box.y, box.w, box.h - 0.5)
429
+ rows = 1 if n <= 3 else 2
430
+ per = (n + rows - 1) // rows
431
+ row_boxes = self.g.rows(rows, box, gap=0.3) if rows > 1 else [box]
432
+ card_h = min(2.3, row_boxes[0].h)
433
+ for ri in range(rows):
434
+ chunk = ks[ri * per:(ri + 1) * per]
435
+ if not chunk:
436
+ continue
437
+ rb = row_boxes[ri]
438
+ cells = self.g.cols(len(chunk), Rect(rb.x, rb.y + (rb.h - card_h) / 2, rb.w, card_h))
439
+ for cell, k in zip(cells, chunk):
440
+ num, label = str(k[0]), str(k[1])
441
+ delta = str(k[2]) if len(k) > 2 else ''
442
+ self._rect_shape(s, cell, self.t['light'])
443
+ self._rect_shape(s, Rect(cell.x, cell.y, cell.w, 0.06), self.t['accent'])
444
+ # 卡片内按比例分三层,层与层之间留真实间隙——写死偏移量迟早把两层叠在一起
445
+ iw, h = cell.w - 0.28, cell.h
446
+ nb = Rect(cell.x + 0.14, cell.y + h * 0.13, iw, h * 0.36)
447
+ self._fit_line(s, nb, num, 40, self.t['on_light'], PP_ALIGN.CENTER, bold=True, lo=18,
448
+ font=FONT_NUM, anchor=MSO_ANCHOR.MIDDLE, what='大数字')
449
+ lb = Rect(cell.x + 0.14, cell.y + h * 0.53, iw, h * (0.20 if delta else 0.32))
450
+ self._fit_line(s, lb, label, 14,
451
+ self._fg(self.t['light'], [self.t['text'], self.t['on_light']]),
452
+ PP_ALIGN.CENTER, lo=10, what='卡片标签')
453
+ if delta:
454
+ self._fit_line(s, Rect(cell.x + 0.14, cell.y + h * 0.76, iw, h * 0.16), delta, 11,
455
+ self._fg(self.t['light'], [self.t['accent'], self.t['on_light']]),
456
+ PP_ALIGN.CENTER, bold=True, what='同比')
457
+ self._claim(cell, f'卡片{label[:6]}')
458
+ if caption:
459
+ self._fit_line(s, Rect(box.x, self.g.bottom - 0.4, box.w, 0.36), caption,
460
+ SZ_CAPTION, self.t['muted'], what='注解')
125
461
  return s
126
462
 
127
463
  def add_image(self, title, image_path, caption='', notes=None):
128
- s = self._slide(notes)
464
+ """整幅图页。**永远等比 contain**——只给一边尺寸的写法在这里不存在,图不可能被拉变形。"""
465
+ s = self._slide(notes, kind='图', title=title)
129
466
  self._title(s, title)
130
- pic = s.shapes.add_picture(image_path, Inches(1), Inches(1.6), height=Inches(4.6))
131
- if pic.width > self.prs.slide_width - Inches(1): # 超宽图改按宽度适配
132
- s.shapes._spTree.remove(pic._element)
133
- pic = s.shapes.add_picture(image_path, Inches(1), Inches(1.6), width=self.prs.slide_width - Inches(2))
134
- pic.left = int((self.prs.slide_width - pic.width) / 2)
467
+ box = self.g.content
468
+ if caption:
469
+ box = Rect(box.x, box.y, box.w, box.h - 0.46)
470
+ self._place_image(s, image_path, box)
135
471
  if caption:
136
- self._text(s, Inches(1), Inches(6.4), Inches(11.3), Inches(0.6), caption, size=14, color='777777', align=PP_ALIGN.CENTER)
472
+ self._fit_line(s, Rect(box.x, box.bottom + 0.12, box.w, 0.34), caption,
473
+ SZ_CAPTION, self.t['muted'], PP_ALIGN.CENTER, what='图注')
137
474
  return s
138
475
 
139
- def add_table(self, title, rows, notes=None):
140
- s = self._slide(notes)
476
+ def add_image_text(self, title, image_path, bullets, side='left', caption='', notes=None):
477
+ """图文页:一半图一半要点(图仍等比 contain)。"""
478
+ s = self._slide(notes, kind='图文', title=title)
141
479
  self._title(s, title)
142
- nr, nc = len(rows), len(rows[0])
143
- tb = s.shapes.add_table(nr, nc, Inches(0.9), Inches(1.7), Inches(11.5), Inches(0.5) * nr).table
144
- for i, row in enumerate(rows):
145
- for j, val in enumerate(row):
146
- cell = tb.cell(i, j)
147
- cell.text = str(val)
480
+ a, b = self.g.cols(2)
481
+ img_box, txt_box = (a, b) if side == 'left' else (b, a)
482
+ if caption:
483
+ img_box = Rect(img_box.x, img_box.y, img_box.w, img_box.h - 0.42)
484
+ self._place_image(s, image_path, img_box)
485
+ if caption:
486
+ self._fit_line(s, Rect(img_box.x, img_box.bottom + 0.1, img_box.w, 0.32), caption,
487
+ SZ_CAPTION, self.t['muted'], PP_ALIGN.CENTER, what='图注')
488
+ items = [x if isinstance(x, (list, tuple)) else (str(x), 0) for x in _flat(bullets)]
489
+ tb2 = Rect(txt_box.x, txt_box.y, txt_box.w, txt_box.h)
490
+ take = len(items)
491
+ while take > 0 and self._fit_text(s, tb2, items[:take], hi=20, lo=14) is None:
492
+ take -= 1
493
+ if take < len(items):
494
+ self.warnings.append(f'「{title}」右栏要点过多,只放下 {take} 条,其余请另开一页')
495
+ return s
496
+
497
+ def _place_image(self, s, path, box):
498
+ from PIL import Image
499
+ try:
500
+ with Image.open(path) as im:
501
+ nw, nh = im.size
502
+ except Exception:
503
+ nw, nh = 16, 9
504
+ fit = L.contain_fit(nw, nh, box)
505
+ pic = s.shapes.add_picture(path, Emu(int(fit.x * L.EMU_IN)), Emu(int(fit.y * L.EMU_IN)),
506
+ width=Emu(int(fit.w * L.EMU_IN)), height=Emu(int(fit.h * L.EMU_IN)))
507
+ dpi = min(nw / max(0.01, fit.w), nh / max(0.01, fit.h))
508
+ if dpi < 96:
509
+ self.warnings.append(f'{os.path.basename(str(path))} 原图 {nw}×{nh}px,铺到 {fit.w:.1f}"×{fit.h:.1f}" 仅 {dpi:.0f}dpi,贴出来会糊')
510
+ self._claim(fit, '图片')
511
+ return pic
512
+
513
+ def add_table(self, title, rows, notes=None, col_widths=None, note='', max_rows=None):
514
+ """表格页。列宽按内容自动分配,行高按折行实算;行数超出页面则**自动分页**。"""
515
+ rows = [[('' if c is None else str(c)) for c in r] for r in rows]
516
+ if not rows:
517
+ return self._slide(notes, kind='表', title=title)
518
+ header, body = rows[0], rows[1:]
519
+ box = self.g.content
520
+ if note:
521
+ box = Rect(box.x, box.y, box.w, box.h - 0.42)
522
+ widths = col_widths or _auto_widths(header, body, box.w)
523
+ fs_head, fs_body = 14, 13
524
+ rh = [_row_h(header, widths, fs_head, True)] + [_row_h(r, widths, fs_body, False) for r in body]
525
+ cap = max_rows or _max_rows(rh, box.h)
526
+ pages = [body[i:i + cap] for i in range(0, len(body), cap)] or [[]]
527
+ made = []
528
+ for pi, chunk in enumerate(pages):
529
+ t = title if pi == 0 else f'{title}({pi + 1}/{len(pages)})'
530
+ s = self._slide(notes if pi == 0 else None, kind='表', title=t)
531
+ self._title(s, t)
532
+ self._draw_table(s, box, header, chunk, widths, fs_head, fs_body)
533
+ if note:
534
+ self._fit_line(s, Rect(box.x, box.bottom + 0.1, box.w, 0.32), note,
535
+ SZ_CAPTION, self.t['muted'], what='表注')
536
+ made.append(s)
537
+ if len(pages) > 1:
538
+ self.warnings.append(f'「{title}」共 {len(body)} 行,已自动分为 {len(pages)} 页(每页 ≤{cap} 行)')
539
+ return made[0] if len(made) == 1 else made
540
+
541
+ def _draw_table(self, s, box, header, body, widths, fs_head, fs_body):
542
+ nr, nc = len(body) + 1, len(header)
543
+ heights = [_row_h(header, widths, fs_head, True)] + [_row_h(r, widths, fs_body, False) for r in body]
544
+ total = sum(heights)
545
+ shp = s.shapes.add_table(nr, nc, Emu(int(box.x * L.EMU_IN)), Emu(int(box.y * L.EMU_IN)),
546
+ Emu(int(box.w * L.EMU_IN)), Emu(int(total * L.EMU_IN)))
547
+ tbl = shp.table
548
+ tbl.first_row = True
549
+ for j, w in enumerate(widths):
550
+ tbl.columns[j].width = Emu(int(w * L.EMU_IN))
551
+ for i, h in enumerate(heights):
552
+ tbl.rows[i].height = Emu(int(h * L.EMU_IN))
553
+ for i, row in enumerate([header] + body):
554
+ for j in range(nc):
555
+ cell = tbl.cell(i, j)
556
+ cell.text = row[j] if j < len(row) else ''
557
+ cell.margin_left = cell.margin_right = Pt(7)
558
+ cell.margin_top = cell.margin_bottom = Pt(3)
559
+ cell.vertical_anchor = MSO_ANCHOR.MIDDLE
148
560
  p = cell.text_frame.paragraphs[0]
149
- _style(p.runs[0] if p.runs else p.add_run(), 14, i == 0, 'FFFFFF' if i == 0 else self.t['text'])
150
- if i == 0:
151
- cell.fill.solid()
152
- cell.fill.fore_color.rgb = RGBColor.from_string(self.t['primary'])
561
+ p.alignment = PP_ALIGN.LEFT if j == 0 else PP_ALIGN.RIGHT
562
+ r = p.runs[0] if p.runs else p.add_run()
563
+ band = self.t['primary'] if i == 0 else (self.t['bg'] if i % 2 else self.t['light'])
564
+ self._style(r, fs_head if i == 0 else fs_body, i == 0,
565
+ self.t['on_primary'] if i == 0 else self._fg(band, [self.t['text'], self.t['on_light']]))
566
+ cell.fill.solid()
567
+ cell.fill.fore_color.rgb = RGBColor.from_string(band)
568
+ self._claim(Rect(box.x, box.y, box.w, total), '表格')
569
+ return tbl
570
+
571
+ def add_quote(self, text, author='', notes=None):
572
+ g = self.g
573
+ s = self._slide(notes, chrome=False, kind='引言', title=text[:16])
574
+ self._rect_shape(s, Rect(0, 0, g.W, g.H), self.t['light'])
575
+ box = Rect(g.m + 0.8, g.H * 0.26, g.W - 2 * g.m - 1.6, g.H * 0.36)
576
+ tb, tf = self._textbox(s, Rect(box.x - 0.55, box.y - 0.15, 0.6, 1.2))
577
+ self._para(tf, True, '“', 60, True, self._fg(self.t['light'], [self.t['accent'], self.t['on_light']], 3.0))
578
+ self._fit_line(s, box, text, 28, self.t['on_light'], lo=16,
579
+ anchor=MSO_ANCHOR.MIDDLE, what='引言')
580
+ if author:
581
+ self._fit_line(s, Rect(box.x, box.bottom + 0.24, box.w, 0.4), '—— ' + author, 14,
582
+ self._fg(self.t['light'], [self.t['muted'], self.t['on_light']]),
583
+ PP_ALIGN.RIGHT, what='出处')
584
+ return s
585
+
586
+ def add_compare(self, title, left_title, left, right_title, right, notes=None):
587
+ """左右对照页(现状/目标、方案 A/B)。两栏底色不同,一眼看出这是对比。"""
588
+ s = self._slide(notes, kind='对照', title=title)
589
+ self._title(s, title)
590
+ a, b = self.g.cols(2)
591
+ for box, head, items, tone in ((a, left_title, left, self.t['muted']),
592
+ (b, right_title, right, self.t['primary'])):
593
+ self._rect_shape(s, box, self.t['light'])
594
+ self._rect_shape(s, Rect(box.x, box.y, box.w, 0.5), tone)
595
+ self._fit_line(s, Rect(box.x + 0.18, box.y + 0.11, box.w - 0.36, 0.32), head, 16,
596
+ self._fg(tone, [self.t['on_primary'], 'FFFFFF']), bold=True, what=f'对照头{head[:6]}')
597
+ inner = Rect(box.x + 0.24, box.y + 0.72, box.w - 0.48, box.h - 0.96)
598
+ its = [x if isinstance(x, (list, tuple)) else (str(x), 0) for x in _flat(items)]
599
+ take = len(its)
600
+ while take > 0 and self._fit_text(s, inner, its[:take], hi=18, lo=13,
601
+ color=self._fg(self.t['light'], [self.t['text'], self.t['on_light']])) is None:
602
+ take -= 1
603
+ if take < len(its):
604
+ self.warnings.append(f'「{title}」{head} 栏只放下 {take} 条,请精简')
605
+ self._claim(box, f'对照栏{head}')
153
606
  return s
154
607
 
155
- def add_end(self, text='谢谢', notes=None):
156
- s = self._slide(notes)
157
- self._rect(s, 0, 0, self.prs.slide_width, self.prs.slide_height, self.t['primary'])
158
- self._text(s, Inches(1), Inches(3.1), Inches(11.3), Inches(1.2), text, size=40, bold=True, color='FFFFFF', align=PP_ALIGN.CENTER)
608
+ def add_timeline(self, title, items, notes=None):
609
+ """时间轴页。items: [(时间, 事项)],最多 6 段。"""
610
+ s = self._slide(notes, kind='时间轴', title=title)
611
+ self._title(s, title)
612
+ its = list(items)[:6]
613
+ box = self.g.content
614
+ n = max(1, len(its))
615
+ y = box.y + box.h * 0.42
616
+ self._rect_shape(s, Rect(box.x, y, box.w, 0.035), self.t['light'])
617
+ cells = self.g.cols(n, box)
618
+ for i, (cell, it) in enumerate(zip(cells, its)):
619
+ when, what = (str(it[0]), str(it[1])) if isinstance(it, (list, tuple)) else ('', str(it))
620
+ cx = cell.x + cell.w / 2
621
+ self._rect_shape(s, Rect(cx - 0.09, y - 0.075, 0.18, 0.18), self.t['accent'])
622
+ self._fit_line(s, Rect(cell.x, y - 0.78, cell.w, 0.5), when, 16, self.t['head'],
623
+ PP_ALIGN.CENTER, bold=True, anchor=MSO_ANCHOR.BOTTOM, what=f'时间{i + 1}')
624
+ wb = Rect(cell.x + 0.06, y + 0.3, cell.w - 0.12, box.bottom - y - 0.34)
625
+ self._fit_line(s, wb, what, 15, self.t['text'], PP_ALIGN.CENTER, lo=11, what=f'事项{i + 1}')
626
+ return s
627
+
628
+ def add_end(self, text='谢谢', subtitle='', notes=None):
629
+ g = self.g
630
+ s = self._slide(notes, chrome=False, kind='结尾', title=text)
631
+ self._rect_shape(s, Rect(0, 0, g.W, g.H), self.t['primary'])
632
+ box = Rect(g.m, g.H * 0.38, g.W - 2 * g.m, g.H * 0.2)
633
+ self._fit_line(s, box, text, 40, self.t['on_primary'], PP_ALIGN.CENTER, bold=True, lo=24,
634
+ anchor=MSO_ANCHOR.MIDDLE, what='结尾语')
635
+ if subtitle:
636
+ self._fit_line(s, Rect(box.x, box.bottom + 0.16, box.w, 0.5), subtitle, 16,
637
+ self._fg(self.t['primary'], [self.t['light'], self.t['on_primary']]),
638
+ PP_ALIGN.CENTER, what='结尾副标题')
159
639
  return s
160
640
 
161
- def save(self, path):
641
+ # ── 出片 ────────────────────────────────────────────────────────────────
642
+ def save(self, path, check=True, verbose=True):
643
+ """保存并**自动体检**(check=False 才跳过)。返回体检结果 dict。
644
+
645
+ 自检不是可选项:生成完不查,等于把"没溢出/没重叠"这句话建立在祈祷上。
646
+ """
647
+ self._chrome()
162
648
  self.prs.save(path)
163
- return path
649
+ if verbose and self.warnings:
650
+ print('【建版提示】')
651
+ for w in self.warnings:
652
+ print(' ·', w)
653
+ if not check:
654
+ return None
655
+ import pptx_check
656
+ res = pptx_check.inspect(path)
657
+ if verbose:
658
+ print(pptx_check.report(res))
659
+ return res
660
+
661
+ def outline(self):
662
+ """已建页面的大纲(页号 / 版式 / 标题),核对用。"""
663
+ return list(self._outline)
664
+
665
+
666
+ # ── 工具函数 ────────────────────────────────────────────────────────────────
667
+ def _flat(bullets):
668
+ """接受 ['a', ('b',1), {'text':'c','sub':['d']}] 三种写法,摊平成 [(text, level)]。"""
669
+ out = []
670
+ for b in bullets or []:
671
+ if isinstance(b, dict):
672
+ out.append((str(b.get('text', '')), int(b.get('level', 0))))
673
+ for sub in b.get('sub', []) or []:
674
+ out.append((str(sub), 1))
675
+ elif isinstance(b, (list, tuple)) and len(b) == 2 and isinstance(b[1], int):
676
+ out.append((str(b[0]), b[1]))
677
+ elif isinstance(b, (list, tuple)):
678
+ out.append((str(b[0]), 0))
679
+ for sub in list(b[1:]):
680
+ out.append((str(sub), 1))
681
+ else:
682
+ out.append((str(b), 0))
683
+ return out
164
684
 
165
685
 
166
- def verify(path):
167
- """重读校验(验收步骤用):打印页数与每页首个文本。"""
686
+ def _auto_widths(header, body, total):
687
+ """按各列最长内容分配列宽(首列偏宽给标签,数值列收窄)。"""
688
+ nc = len(header)
689
+ need = []
690
+ for j in range(nc):
691
+ vals = [header[j]] + [r[j] for r in body if j < len(r)]
692
+ need.append(max(L.measure(str(v), 13) for v in vals) / L.PT_IN + 0.28)
693
+ s = sum(need) or 1
694
+ lo = total / nc * 0.55
695
+ w = [max(lo, min(total * 0.42, n / s * total)) for n in need]
696
+ k = total / sum(w)
697
+ return [x * k for x in w]
698
+
699
+
700
+ def _row_h(row, widths, size, bold):
701
+ h = 0.0
702
+ for j, w in enumerate(widths):
703
+ txt = row[j] if j < len(row) else ''
704
+ lines = L.wrap_segments([(str(txt), size, bold)], max(20.0, w * L.PT_IN - 14))
705
+ h = max(h, L.text_height(lines) / L.PT_IN + 0.12)
706
+ return max(0.32, h)
707
+
708
+
709
+ def _max_rows(rh, avail):
710
+ """表头 + 尽可能多的数据行(按最高的一行保守估)。"""
711
+ head = rh[0]
712
+ body = rh[1:] or [0.34]
713
+ per = max(body)
714
+ return max(2, int((avail - head) / per))
715
+
716
+
717
+ def verify(path, detail=False):
718
+ """重开产物核对:打印页数与每页标题(对大纲用),并跑一遍版式体检。"""
719
+ import pptx_check
168
720
  prs = Presentation(path)
169
- n = len(prs.slides)
170
- print(f'[verify] 共 {n} 页')
721
+ print(f'[verify] 共 {len(prs.slides)} 页')
171
722
  for i, s in enumerate(prs.slides, 1):
172
723
  first = next((sh.text_frame.text.split('\n')[0] for sh in s.shapes
173
724
  if sh.has_text_frame and sh.text_frame.text.strip()), '(无文本)')
174
725
  print(f' p{i}: {first[:40]}')
175
- return n
726
+ res = pptx_check.inspect(path)
727
+ print(pptx_check.report(res))
728
+ return res if detail else len(prs.slides)
729
+
730
+
731
+ def _demo_png(path='pptx_deck_check.png', w=1280, h=720):
732
+ """自测用示意图(真实尺寸,免得占位小图必然触发"图片发虚")。"""
733
+ from PIL import Image, ImageDraw
734
+ im = Image.new('RGB', (w, h), (245, 248, 251))
735
+ d = ImageDraw.Draw(im)
736
+ for i, v in enumerate([0.42, 0.68, 0.55, 0.81, 0.34]):
737
+ x = 120 + i * 210
738
+ d.rectangle([x, int(h * (1 - v)) - 60, x + 130, h - 90], fill=(46, 117, 182))
739
+ d.line([(90, h - 90), (w - 90, h - 90)], fill=(120, 130, 140), width=3)
740
+ im.save(path)
741
+ return path
176
742
 
177
743
 
178
744
  if __name__ == '__main__':
179
- png = 'pptx_deck_check.png' # 1x1 红点 png,占位自测(真实场景用 matplotlib 产图)
180
- open(png, 'wb').write(base64.b64decode(
181
- 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='))
182
- d = Deck('navy')
183
- d.add_cover('自测演示', 'pptx_deck.py 冒烟', notes='备注示例')
184
- d.add_agenda(['概览', '数据', '结论'])
185
- d.add_section('一、概览')
186
- d.add_bullets('要点', ['要点甲', '要点乙', '要点丙'])
187
- d.add_kpi('核心指标', [('3.6亿', '营收'), ('+18%', '同比'), ('32.5%', '毛利率')])
188
- d.add_image('示意图', png, caption='占位图')
189
- d.add_table('明细', [['地区', '销售额'], ['华东', '1200'], ['华南', '950']])
190
- d.add_end()
191
- p = d.save('pptx_deck_check.pptx')
192
- verify(p)
193
- print('OK', p)
745
+ png = _demo_png()
746
+ d = Deck('navy', footer='自测 · pptx_deck.py')
747
+ d.add_cover('2026 上半年经营分析', '数据一部', '2026-07-15', notes='备注示例')
748
+ d.add_agenda(['经营概览', '分区域表现', '问题与对策', '下半年计划', '资源需求', '风险提示'])
749
+ d.add_section('经营概览', no='PART 01', subtitle='数据截至 6 月 30 日')
750
+ d.add_kpi('核心指标', [('3.6亿', '营业收入', '同比 +18%'), ('32.5%', '毛利率', '环比 +1.2pct'),
751
+ ('1.02亿', '经营性现金流', '同比 +9%'), ('87', 'NPS', '较去年 +5')])
752
+ d.add_bullets('增长来自哪里', [
753
+ '华东新签 12 家大客户,贡献增量 4200 万',
754
+ '订阅制收入占比 21% → 34%',
755
+ ('产品侧', '智能报表 3 月上线,续费率 92%', '数据服务包提价 8%,未见流失'),
756
+ '渠道伙伴翻倍,但单伙伴产出降 15%',
757
+ '西南仍未扭亏,卡在交付人力',
758
+ ], lead='结论:增长靠华东与订阅制,风险在渠道与西南', notes='这里展开讲渠道的账')
759
+ d.add_two_col('机会与风险', ['订阅制续费盘子稳定', '智能报表口碑好', '政企预算在恢复'],
760
+ ['渠道单产下滑', '西南交付人力缺口 30%', '海外回款慢'],
761
+ left_title='机会', right_title='风险')
762
+ d.add_compare('渠道策略调整', '现状(半年)', ['伙伴 48 家,单产 76 万', '无分层,资源平均撒', '培训 2 场'],
763
+ '下半年', ['砍到 24 家精耕', 'A/B/C 三层差异化返点', '每月 1 场认证培训'])
764
+ d.add_image('区域销售分布', png, caption='华东领先,西南承压')
765
+ d.add_image_text('智能报表模块表现', png, ['上线 4 个月,MAU 1.2 万', '续费率 92%,高于均值 11pct',
766
+ '客户最常用的是自动周报'], caption='模块使用趋势')
767
+ d.add_table('分区域明细', [['地区', '销售额(万)', '同比', '毛利率', '回款天数'],
768
+ ['华东', '12000', '+21%', '35.2%', '48'],
769
+ ['华南', '9500', '+12%', '31.0%', '55'],
770
+ ['华北', '7200', '+8%', '29.8%', '61'],
771
+ ['西南', '3100', '-4%', '22.4%', '88'],
772
+ ['海外', '4200', '+35%', '38.1%', '120']],
773
+ note='口径:含税收入,与财务月结一致')
774
+ d.add_timeline('下半年节奏', [('7月', '渠道分层方案落地'), ('8月', '西南交付团队补齐'),
775
+ ('9月', '订阅 2.0 定价发布'), ('11月', '海外回款政策调整'),
776
+ ('12月', '年度复盘与预算')])
777
+ d.add_quote('增长不是把所有事都做一遍,是把对的事做到底。', '经营分析会')
778
+ d.add_end('谢谢,欢迎讨论', '数据一部 · 2026-07')
779
+ # 自动拆页演示:给 14 条要点,引擎会自己拆成「原标题 + (续)」两页
780
+ d.add_bullets('风险清单(演示自动拆页)', [f'风险项 {i + 1}:影响面与应对各一句' for i in range(14)])
781
+ res = d.save('pptx_deck_check.pptx')
782
+ print('\n大纲:')
783
+ for no, kind, title in d.outline():
784
+ print(f' p{no} [{kind}] {title}')