@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
@@ -0,0 +1,1182 @@
1
+ # PPT 版式体检(对任意 .pptx 都能跑,不限于本技能生成的)。
2
+ #
3
+ # 解决的真问题:幻灯片是"盲写"的——python-pptx 把文字塞进一个固定大小的框,
4
+ # 塞不塞得下没人知道;图片给了 width 又给 height 就被悄悄拉成长脸;
5
+ # 两个框坐标算错就直接压在一起。这些在保存那一刻全都不报错,
6
+ # 等人打开 PPT 才看见,而那时模型早就说"已完成"了。
7
+ #
8
+ # 本模块把这些做成确定性检查:逐页遍历形状 → 还原几何与文字度量 →
9
+ # 判溢出/重叠/变形/越界/字号/对比度/表格/内容密度/跨页一致性,
10
+ # 每条都带页码、元素名与具体数字(估算高 4.62" vs 可用高 3.90"),
11
+ # 而不是一句"排版有问题"。
12
+ #
13
+ # 用法:
14
+ # python pptx_check.py 汇报.pptx # 人读报告
15
+ # python pptx_check.py 汇报.pptx --json # 机读
16
+ # python pptx_check.py 汇报.pptx --fix # 自动修可安全修的,另存 -已修复.pptx
17
+ # python pptx_check.py 汇报.pptx --fix --inplace
18
+ # 或在 run_python 里:from pptx_check import inspect, report, fix
19
+
20
+ import json
21
+ import os
22
+ import sys
23
+
24
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
25
+
26
+ from pptx import Presentation # noqa: E402
27
+ from pptx.enum.shapes import MSO_SHAPE_TYPE # noqa: E402
28
+ from pptx.enum.text import MSO_ANCHOR, MSO_AUTO_SIZE, PP_ALIGN # noqa: E402
29
+ from pptx.oxml.ns import qn # noqa: E402
30
+ from pptx.util import Emu, Pt # noqa: E402
31
+
32
+ import pptx_layout as L # noqa: E402
33
+
34
+ # ── 阈值(集中在这里,便于按场合调紧调松)────────────────────────────────────
35
+ SAFE_MARGIN = 0.22 # 安全边距(英寸):内容离页边小于它就算贴边
36
+ OVERLAP_MIN = 0.06 # 重叠面积占比低于它视为误差,不报
37
+ DISTORT_WARN = 0.02 # 图片宽高比偏差 >2% 提醒
38
+ DISTORT_ERR = 0.06 # >6% 判为明显变形
39
+ MIN_DPI = 96 # 图片渲染分辨率下限(低于此贴出来是糊的)
40
+ FONT_ERR = 10.0 # 字号下限(磅):低于此投影上根本看不清
41
+ FONT_WARN = 14.0
42
+ CONTRAST_ERR = 3.0
43
+ CONTRAST_WARN = 4.5
44
+ MAX_BULLETS = 6
45
+ MAX_BULLET_CHARS = 25
46
+ MAX_SLIDE_CHARS = 220
47
+ MAX_TITLE_CHARS = 24
48
+ TITLE_JITTER = 0.06 # 各页标题基线抖动上限(英寸)
49
+
50
+ LEVEL_ORDER = {'error': 0, 'warn': 1, 'info': 2}
51
+ LEVEL_CN = {'error': '必修', 'warn': '建议', 'info': '提示'}
52
+
53
+ CODE_CN = {
54
+ 'overflow_v': '文字溢出',
55
+ 'overflow_h': '横向超宽',
56
+ 'out_of_slide': '超出页面',
57
+ 'off_grid': '贴边过近',
58
+ 'overlap': '元素重叠',
59
+ 'image_distort': '图片变形',
60
+ 'image_blurry': '图片发虚',
61
+ 'table_overflow': '表格超界',
62
+ 'tiny_shape': '元素过小',
63
+ 'font_too_small': '字号过小',
64
+ 'no_ea_font': '中文字体未设',
65
+ 'font_zoo': '字体过杂',
66
+ 'low_contrast': '对比度不足',
67
+ 'autofit_shrunk': '被迫缩字',
68
+ 'bullet_overload': '要点过多',
69
+ 'bullet_too_long': '要点过长',
70
+ 'title_too_long': '标题过长',
71
+ 'text_wall': '文字过密',
72
+ 'empty_slide': '空白页',
73
+ 'dup_title': '标题重复',
74
+ 'title_jitter': '标题错位',
75
+ 'palette_zoo': '配色过杂',
76
+ 'size_odd': '页面比例',
77
+ }
78
+ FIXABLE = {'image_distort', 'out_of_slide', 'off_grid', 'overflow_v', 'table_overflow'}
79
+
80
+
81
+ class Finding(dict):
82
+ def __init__(self, page, code, level, detail, hint='', where='', rect=None, fixable=None):
83
+ super().__init__(
84
+ page=page, code=code, level=level, name=CODE_CN.get(code, code),
85
+ detail=detail, hint=hint, where=where,
86
+ fixable=(code in FIXABLE) if fixable is None else bool(fixable),
87
+ rect=None if rect is None else [round(rect.x, 2), round(rect.y, 2), round(rect.w, 2), round(rect.h, 2)],
88
+ )
89
+
90
+
91
+ # ── 几何还原 ────────────────────────────────────────────────────────────────
92
+ def _in(v):
93
+ return None if v is None else v / L.EMU_IN
94
+
95
+
96
+ def _rect_of(sh, inherit=None):
97
+ """形状矩形(英寸)。占位符缺尺寸时回退到版式里同 idx 的占位符。"""
98
+ x, y, w, h = sh.left, sh.top, sh.width, sh.height
99
+ if None in (x, y, w, h) and inherit is not None:
100
+ x = x if x is not None else inherit.left
101
+ y = y if y is not None else inherit.top
102
+ w = w if w is not None else inherit.width
103
+ h = h if h is not None else inherit.height
104
+ if None in (x, y, w, h):
105
+ return None
106
+ return L.Rect(_in(x), _in(y), _in(w), _in(h))
107
+
108
+
109
+ def _rot_aabb(r, deg):
110
+ """旋转后的外接矩形(重叠判定用;0/180 度原样返回)。"""
111
+ d = float(deg or 0) % 360
112
+ if d < 0.01 or abs(d - 180) < 0.01:
113
+ return r
114
+ import math
115
+ a = math.radians(d)
116
+ cx, cy = r.x + r.w / 2, r.y + r.h / 2
117
+ hw = (abs(r.w * math.cos(a)) + abs(r.h * math.sin(a))) / 2
118
+ hh = (abs(r.w * math.sin(a)) + abs(r.h * math.cos(a))) / 2
119
+ return L.Rect(cx - hw, cy - hh, hw * 2, hh * 2)
120
+
121
+
122
+ def _group_xf(grp):
123
+ """组合的子坐标变换(chOff/chExt → off/ext)。取不到返回恒等。"""
124
+ try:
125
+ xfrm = grp._element.find(qn('p:grpSpPr')).find(qn('a:xfrm'))
126
+ off, ext = xfrm.find(qn('a:off')), xfrm.find(qn('a:ext'))
127
+ ch_off, ch_ext = xfrm.find(qn('a:chOff')), xfrm.find(qn('a:chExt'))
128
+ ox, oy = int(off.get('x')), int(off.get('y'))
129
+ cx, cy = int(ext.get('cx')), int(ext.get('cy'))
130
+ gx, gy = int(ch_off.get('x')), int(ch_off.get('y'))
131
+ gw, gh = int(ch_ext.get('cx')), int(ch_ext.get('cy'))
132
+ sx = cx / gw if gw else 1.0
133
+ sy = cy / gh if gh else 1.0
134
+ return (ox / L.EMU_IN, oy / L.EMU_IN, gx / L.EMU_IN, gy / L.EMU_IN, sx, sy)
135
+ except Exception:
136
+ return None
137
+
138
+
139
+ def _layout_ph(slide, sh):
140
+ """占位符在版式里的对应形状(继承尺寸用)。"""
141
+ try:
142
+ if not sh.is_placeholder:
143
+ return None
144
+ idx = sh.placeholder_format.idx
145
+ for p in slide.slide_layout.placeholders:
146
+ if p.placeholder_format.idx == idx:
147
+ return p
148
+ except Exception:
149
+ pass
150
+ return None
151
+
152
+
153
+ def walk(slide):
154
+ """展开一页的所有形状(组合按变换递归展开),按 z 序返回 [(shape, Rect, z)]。"""
155
+ out = []
156
+
157
+ def rec(container, xf, depth):
158
+ for sh in container.shapes:
159
+ if sh.shape_type == MSO_SHAPE_TYPE.GROUP:
160
+ sub = _group_xf(sh)
161
+ out.append((sh, _apply(_rect_of(sh), xf), len(out), True))
162
+ if sub and depth < 4:
163
+ rec(sh, _compose(xf, sub), depth + 1)
164
+ continue
165
+ r = _rect_of(sh, _layout_ph(slide, sh))
166
+ if r is None:
167
+ continue
168
+ out.append((sh, _apply(r, xf), len(out), False))
169
+
170
+ def _apply(r, xf):
171
+ if r is None or xf is None:
172
+ return r
173
+ ox, oy, gx, gy, sx, sy = xf
174
+ return L.Rect(ox + (r.x - gx) * sx, oy + (r.y - gy) * sy, r.w * sx, r.h * sy)
175
+
176
+ def _compose(outer, inner):
177
+ if outer is None:
178
+ return inner
179
+ ox, oy, gx, gy, sx, sy = outer
180
+ ix, iy, igx, igy, isx, isy = inner
181
+ nx = ox + (ix - gx) * sx
182
+ ny = oy + (iy - gy) * sy
183
+ return (nx, ny, igx, igy, isx * sx, isy * sy)
184
+
185
+ rec(slide, None, 0)
186
+ return [(sh, r, z) for sh, r, z, is_grp in out if r is not None and not is_grp]
187
+
188
+
189
+ # ── 文字度量还原 ────────────────────────────────────────────────────────────
190
+ def _run_size(run, para, sh):
191
+ if run.font.size is not None:
192
+ return run.font.size.pt
193
+ try:
194
+ if para.font.size is not None:
195
+ return para.font.size.pt
196
+ except Exception:
197
+ pass
198
+ # 占位符按类型给经验默认;普通文本框 PowerPoint 默认 18pt
199
+ try:
200
+ if sh.is_placeholder and sh.placeholder_format.idx == 0:
201
+ return 44.0
202
+ except Exception:
203
+ pass
204
+ return 18.0
205
+
206
+
207
+ def _bodypr(tf):
208
+ try:
209
+ return tf._txBody.find(qn('a:bodyPr'))
210
+ except Exception:
211
+ return None
212
+
213
+
214
+ def _insets(tf):
215
+ """文本框内边距(磅):左右、上下。"""
216
+ bp = _bodypr(tf)
217
+ lr, tb = L.INSET_LR, L.INSET_TB
218
+ if bp is not None:
219
+ try:
220
+ li = bp.get('lIns')
221
+ ri = bp.get('rIns')
222
+ ti = bp.get('tIns')
223
+ bi = bp.get('bIns')
224
+ lv = int(li) / L.EMU_PT if li is not None else L.INSET_LR
225
+ rv = int(ri) / L.EMU_PT if ri is not None else L.INSET_LR
226
+ tv = int(ti) / L.EMU_PT if ti is not None else L.INSET_TB
227
+ bv = int(bi) / L.EMU_PT if bi is not None else L.INSET_TB
228
+ lr, tb = (lv + rv) / 2, (tv + bv) / 2
229
+ except Exception:
230
+ pass
231
+ return lr, tb
232
+
233
+
234
+ def _autofit(tf):
235
+ """PowerPoint 自动缩放:返回 (字号比例, 行距压缩)。none/spAutoFit 返回 (1,0)。"""
236
+ bp = _bodypr(tf)
237
+ if bp is None:
238
+ return 1.0, 0.0
239
+ na = bp.find(qn('a:normAutofit'))
240
+ if na is None:
241
+ return 1.0, 0.0
242
+ fs = na.get('fontScale')
243
+ lr = na.get('lnSpcReduction')
244
+ return (int(fs) / 100000.0 if fs else 1.0), (int(lr) / 100000.0 if lr else 0.0)
245
+
246
+
247
+ def _word_wrap(tf):
248
+ bp = _bodypr(tf)
249
+ if bp is not None and bp.get('wrap') == 'none':
250
+ return False
251
+ return True
252
+
253
+
254
+ def _para_indent(p):
255
+ """段落左缩进(磅):优先读 pPr@marL(建版侧写入的挂起缩进),否则按层级估算。"""
256
+ try:
257
+ pPr = p._p.find(qn('a:pPr'))
258
+ if pPr is not None and pPr.get('marL') is not None:
259
+ return int(pPr.get('marL')) / L.EMU_PT
260
+ except Exception:
261
+ pass
262
+ return (p.level or 0) * 0.28 * L.PT_IN
263
+
264
+
265
+ def para_lines(sh, rect, tf=None):
266
+ """还原一个文本框的排版:返回 (总高磅, 最大行宽磅, 可用宽磅, 可用高磅, 段落明细)。"""
267
+ tf = tf or sh.text_frame
268
+ lr, tb = _insets(tf)
269
+ scale, ln_red = _autofit(tf)
270
+ avail_w = rect.w * L.PT_IN - 2 * lr
271
+ avail_h = rect.h * L.PT_IN - 2 * tb
272
+ total = 0.0
273
+ max_w = 0.0
274
+ detail = []
275
+ for p in tf.paragraphs:
276
+ segs = []
277
+ for r in p.runs:
278
+ if r.text:
279
+ segs.append((r.text, _run_size(r, p, sh) * scale, bool(r.font.bold)))
280
+ if not segs:
281
+ # 空段落也占一行高
282
+ sz = 18.0
283
+ try:
284
+ if p.font.size is not None:
285
+ sz = p.font.size.pt
286
+ except Exception:
287
+ pass
288
+ total += sz * scale * L.LINE_H
289
+ detail.append(dict(text='', lines=1, size=sz))
290
+ continue
291
+ indent = _para_indent(p)
292
+ w = max(10.0, avail_w - indent)
293
+ lines = L.wrap_segments(segs, w) if _word_wrap(tf) else [(''.join(s[0] for s in segs), max(s[1] for s in segs))]
294
+ ls = 1.0
295
+ try:
296
+ if p.line_spacing is not None:
297
+ ls = float(p.line_spacing) if isinstance(p.line_spacing, (int, float)) else p.line_spacing.pt / max(1.0, max(s[1] for s in segs)) / L.LINE_H
298
+ except Exception:
299
+ ls = 1.0
300
+ ls = max(0.5, ls * (1 - ln_red))
301
+ h = L.text_height(lines, ls)
302
+ try:
303
+ if p.space_before is not None:
304
+ h += p.space_before.pt
305
+ if p.space_after is not None:
306
+ h += p.space_after.pt
307
+ except Exception:
308
+ pass
309
+ total += h
310
+ for txt, sz in lines:
311
+ max_w = max(max_w, L.measure(txt, sz) + indent)
312
+ detail.append(dict(text=''.join(s[0] for s in segs), lines=len(lines),
313
+ size=max(s[1] for s in segs)))
314
+ return total, max_w, avail_w, avail_h, detail
315
+
316
+
317
+ def ink_box(sh, rect):
318
+ """文字实际占据的区域(不是文本框本身)。
319
+
320
+ 重叠判定必须用它:真实 PPT 里大量文本框远大于文字本身,
321
+ 按框判会把"两个空框挨着"报成重叠,一屏假阳性之后没人再看报告。
322
+ """
323
+ try:
324
+ tf = sh.text_frame
325
+ except Exception:
326
+ return rect
327
+ if not (sh.has_text_frame and tf.text.strip()):
328
+ return rect
329
+ total, max_w, avail_w, avail_h, _ = para_lines(sh, rect, tf)
330
+ lr, tb = _insets(tf)
331
+ w = min(rect.w, (min(max_w, avail_w) + 2 * lr) / L.PT_IN)
332
+ h = min(rect.h, (total + 2 * tb) / L.PT_IN)
333
+ # 水平锚点
334
+ al = None
335
+ for p in tf.paragraphs:
336
+ if p.alignment is not None:
337
+ al = p.alignment
338
+ break
339
+ if al == PP_ALIGN.CENTER:
340
+ x = rect.x + (rect.w - w) / 2
341
+ elif al == PP_ALIGN.RIGHT:
342
+ x = rect.right - w
343
+ else:
344
+ x = rect.x
345
+ # 垂直锚点
346
+ anc = None
347
+ try:
348
+ anc = tf.vertical_anchor
349
+ except Exception:
350
+ pass
351
+ if anc == MSO_ANCHOR.MIDDLE:
352
+ y = rect.y + (rect.h - h) / 2
353
+ elif anc == MSO_ANCHOR.BOTTOM:
354
+ y = rect.bottom - h
355
+ else:
356
+ y = rect.y
357
+ return L.Rect(x, y, max(w, 0.02), max(h, 0.02))
358
+
359
+
360
+ # ── 颜色还原 ────────────────────────────────────────────────────────────────
361
+ def _theme_map(prs):
362
+ """主题配色 clrScheme → {'dk1':'000000', 'accent1':'4472C4', ...}"""
363
+ out = {}
364
+ try:
365
+ for part in prs.part.package.iter_parts():
366
+ if 'theme' not in str(part.partname):
367
+ continue
368
+ root = part._element if hasattr(part, '_element') else None
369
+ if root is None:
370
+ from lxml import etree
371
+ root = etree.fromstring(part.blob)
372
+ scheme = root.find('.//' + qn('a:clrScheme'))
373
+ if scheme is None:
374
+ continue
375
+ for child in scheme:
376
+ key = child.tag.split('}')[-1]
377
+ srgb = child.find(qn('a:srgbClr'))
378
+ if srgb is not None:
379
+ out[key] = srgb.get('val')
380
+ continue
381
+ sysc = child.find(qn('a:sysClr'))
382
+ if sysc is not None:
383
+ out[key] = sysc.get('lastClr') or ('000000' if 'Text' in (sysc.get('val') or '') else 'FFFFFF')
384
+ break
385
+ except Exception:
386
+ pass
387
+ return out
388
+
389
+
390
+ _THEME_ALIAS = {
391
+ 'TEXT_1': 'dk1', 'BACKGROUND_1': 'lt1', 'TEXT_2': 'dk2', 'BACKGROUND_2': 'lt2',
392
+ 'DARK_1': 'dk1', 'LIGHT_1': 'lt1', 'DARK_2': 'dk2', 'LIGHT_2': 'lt2',
393
+ 'ACCENT_1': 'accent1', 'ACCENT_2': 'accent2', 'ACCENT_3': 'accent3',
394
+ 'ACCENT_4': 'accent4', 'ACCENT_5': 'accent5', 'ACCENT_6': 'accent6',
395
+ }
396
+
397
+
398
+ def _color_hex(color, theme):
399
+ """ColorFormat → 'RRGGBB';解析不出返回 None(宁可跳过检查也不猜)。"""
400
+ if color is None:
401
+ return None
402
+ try:
403
+ if color.type is None:
404
+ return None
405
+ if str(color.type).startswith('RGB') or color.type == 1:
406
+ return str(color.rgb)
407
+ except Exception:
408
+ return None
409
+ try:
410
+ name = str(color.theme_color).split('.')[-1].split(' ')[0]
411
+ base = theme.get(_THEME_ALIAS.get(name, ''), None)
412
+ if not base:
413
+ return None
414
+ try:
415
+ b = color.brightness
416
+ except Exception:
417
+ b = 0
418
+ rgb = L.hex_rgb(base)
419
+ if rgb and b:
420
+ if b > 0:
421
+ rgb = tuple(int(c + (255 - c) * b) for c in rgb)
422
+ else:
423
+ rgb = tuple(int(c * (1 + b)) for c in rgb)
424
+ return '%02X%02X%02X' % rgb
425
+ return base
426
+ except Exception:
427
+ return None
428
+
429
+
430
+ def _fill_hex(sh, theme):
431
+ """形状填充色(纯色/渐变首停靠色);无填充返回 None。"""
432
+ try:
433
+ f = sh.fill
434
+ t = str(f.type)
435
+ if 'SOLID' in t or f.type == 1:
436
+ return _color_hex(f.fore_color, theme)
437
+ if 'GRADIENT' in t:
438
+ try:
439
+ return _color_hex(f.gradient_stops[0].color, theme)
440
+ except Exception:
441
+ return None
442
+ except Exception:
443
+ pass
444
+ return None
445
+
446
+
447
+ # ── 主检查 ──────────────────────────────────────────────────────────────────
448
+ def inspect(path, strict=False):
449
+ """体检一份 pptx,返回 {ok, path, slides, size, findings, stats}。"""
450
+ prs = Presentation(path)
451
+ sw, sh_in = _in(prs.slide_width), _in(prs.slide_height)
452
+ page = L.Rect(0, 0, sw, sh_in)
453
+ theme = _theme_map(prs)
454
+ findings = []
455
+ titles = []
456
+ title_tops = []
457
+ fonts = set()
458
+ fills = set()
459
+ stats = dict(shapes=0, texts=0, pictures=0, tables=0, charts=0, chars=0)
460
+
461
+ for pno, slide in enumerate(prs.slides, 1):
462
+ items = walk(slide)
463
+ stats['shapes'] += len(items)
464
+ boxes = [] # (shape, rect, ink, kind, z)
465
+ slide_chars = 0
466
+ bullet_count = 0
467
+ slide_title = ''
468
+
469
+ for sh, rect, z in items:
470
+ kind = 'shape'
471
+ if sh.shape_type == MSO_SHAPE_TYPE.PICTURE:
472
+ kind = 'pic'
473
+ stats['pictures'] += 1
474
+ elif getattr(sh, 'has_table', False):
475
+ kind = 'table'
476
+ stats['tables'] += 1
477
+ elif getattr(sh, 'has_chart', False):
478
+ kind = 'chart'
479
+ stats['charts'] += 1
480
+ elif sh.has_text_frame and sh.text_frame.text.strip():
481
+ kind = 'text'
482
+ stats['texts'] += 1
483
+
484
+ label = _label(sh, kind)
485
+ arect = _rot_aabb(rect, getattr(sh, 'rotation', 0))
486
+
487
+ # ① 越界 / 贴边
488
+ if arect.w > 0.02 and arect.h > 0.02:
489
+ out_l = max(0.0, page.x - arect.x)
490
+ out_t = max(0.0, page.y - arect.y)
491
+ out_r = max(0.0, arect.right - page.right)
492
+ out_b = max(0.0, arect.bottom - page.bottom)
493
+ worst = max(out_l, out_t, out_r, out_b)
494
+ full_bleed = kind not in ('text', 'table') and arect.w >= sw * 0.98 and arect.h >= sh_in * 0.98
495
+ if worst > 0.02 and not full_bleed:
496
+ findings.append(Finding(
497
+ pno, 'out_of_slide', 'error' if worst > 0.12 or kind in ('text', 'table') else 'warn',
498
+ f'{label} 超出页面 {worst:.2f}"('
499
+ + '、'.join(x for x in [
500
+ f'左溢 {out_l:.2f}"' if out_l > 0.02 else '',
501
+ f'上溢 {out_t:.2f}"' if out_t > 0.02 else '',
502
+ f'右溢 {out_r:.2f}"' if out_r > 0.02 else '',
503
+ f'下溢 {out_b:.2f}"' if out_b > 0.02 else '',
504
+ ] if x) + ')',
505
+ '把元素移回页内,或等比缩小', label, rect))
506
+ elif worst <= 0.02 and kind in ('text', 'pic', 'table', 'chart'):
507
+ near = min(arect.x - page.x, arect.y - page.y, page.right - arect.right, page.bottom - arect.bottom)
508
+ if near < SAFE_MARGIN and arect.w < sw * 0.98:
509
+ findings.append(Finding(
510
+ pno, 'off_grid', 'warn',
511
+ f'{label} 距页边仅 {max(0.0, near):.2f}"(安全边距 {SAFE_MARGIN}")',
512
+ '往内收,四边留白一致才显整齐', label, rect))
513
+ # 细长条是分隔线/色带(设计件),只有两边都小才是误建的碎片
514
+ if (arect.w < 0.08 and arect.h < 0.08) or (kind in ('text', 'pic', 'table', 'chart')
515
+ and (arect.w < 0.12 or arect.h < 0.1)):
516
+ findings.append(Finding(pno, 'tiny_shape', 'warn',
517
+ f'{label} 尺寸仅 {arect.w:.2f}"×{arect.h:.2f}",几乎不可见',
518
+ '删掉或给足尺寸', label, rect, fixable=False))
519
+
520
+ # ② 图片变形 / 发虚
521
+ if kind == 'pic':
522
+ findings.extend(_check_picture(pno, sh, rect, label))
523
+
524
+ # ③ 文字度量
525
+ ink = rect
526
+ if kind == 'text':
527
+ ink = ink_box(sh, rect)
528
+ findings.extend(_check_text(pno, sh, rect, ink, label, theme, boxes, page, strict))
529
+ txt = sh.text_frame.text
530
+ slide_chars += len(txt.replace('\n', ''))
531
+ stats['chars'] += len(txt.replace('\n', ''))
532
+ bullet_count += _bullet_paras(sh, rect, sh_in)
533
+ for p in sh.text_frame.paragraphs:
534
+ for r in p.runs:
535
+ if r.font.name:
536
+ fonts.add(r.font.name)
537
+ if not slide_title and _is_title(sh, rect, page):
538
+ slide_title = txt.strip().split('\n')[0]
539
+ # 满版底色的页(封面/章节页/结尾页)本来就是另一种构图,
540
+ # 把它们的标题拉进"各页标题该对齐"的比对里只会误报
541
+ if not _full_bleed_page(items, page):
542
+ title_tops.append((pno, round(rect.y, 3)))
543
+
544
+ # ④ 表格
545
+ if kind == 'table':
546
+ findings.extend(_check_table(pno, sh, rect, page, label))
547
+
548
+ f = _fill_hex(sh, theme)
549
+ if f:
550
+ fills.add(f)
551
+ boxes.append((sh, rect, ink, kind, z, label))
552
+
553
+ # ⑤ 重叠(页内两两比对,用 ink 而非框)
554
+ findings.extend(_check_overlap(pno, boxes))
555
+
556
+ # ⑥ 内容密度
557
+ content = [b for b in boxes if b[3] in ('text', 'pic', 'table', 'chart')]
558
+ if not content:
559
+ findings.append(Finding(pno, 'empty_slide', 'warn', '整页没有任何正文/图/表', '补内容或删页'))
560
+ if bullet_count > MAX_BULLETS + 1:
561
+ findings.append(Finding(pno, 'bullet_overload', 'warn',
562
+ f'本页 {bullet_count} 个段落/要点(建议 ≤{MAX_BULLETS})',
563
+ '一页一个观点,多的拆页或进备注'))
564
+ if slide_chars > MAX_SLIDE_CHARS:
565
+ findings.append(Finding(pno, 'text_wall', 'warn',
566
+ f'本页正文 {slide_chars} 字(建议 ≤{MAX_SLIDE_CHARS})',
567
+ 'PPT 不是文档:留结论进正文,论证进备注'))
568
+ if slide_title:
569
+ titles.append((pno, slide_title))
570
+ if len(slide_title) > MAX_TITLE_CHARS:
571
+ findings.append(Finding(pno, 'title_too_long', 'warn',
572
+ f'标题 {len(slide_title)} 字:{slide_title[:30]}…',
573
+ f'压到 {MAX_TITLE_CHARS} 字内,长的挪到副标题'))
574
+
575
+ # ⑦ 跨页一致性
576
+ findings.extend(_check_consistency(titles, title_tops, fonts, fills, sw, sh_in))
577
+
578
+ order = sorted(findings, key=lambda f: (LEVEL_ORDER.get(f['level'], 3), f['page']))
579
+ return dict(
580
+ ok=not any(f['level'] == 'error' for f in order),
581
+ path=os.path.abspath(path),
582
+ slides=len(prs.slides),
583
+ size=f'{sw:.2f}"×{sh_in:.2f}"',
584
+ ratio=_ratio(sw, sh_in),
585
+ findings=order,
586
+ stats=stats,
587
+ counts={k: sum(1 for f in order if f['level'] == k) for k in ('error', 'warn', 'info')},
588
+ )
589
+
590
+
591
+ def _ratio(w, h):
592
+ r = w / h if h else 0
593
+ for name, v in (('16:9', 16 / 9), ('4:3', 4 / 3), ('16:10', 16 / 10), ('A4横', 297 / 210)):
594
+ if abs(r - v) < 0.02:
595
+ return name
596
+ return f'{r:.2f}:1'
597
+
598
+
599
+ def _label(sh, kind):
600
+ n = (sh.name or '').strip()
601
+ base = {'pic': '图片', 'table': '表格', 'chart': '图表', 'text': '文本', 'shape': '形状'}[kind]
602
+ if kind == 'text':
603
+ t = sh.text_frame.text.strip().split('\n')[0][:12]
604
+ return f'「{t}」' if t else base
605
+ return f'{base}({n[:16]})' if n and not n.startswith(('TextBox', 'Rectangle')) else base
606
+
607
+
608
+ def _is_title(sh, rect, page):
609
+ try:
610
+ if sh.is_placeholder and sh.placeholder_format.idx == 0:
611
+ return True
612
+ except Exception:
613
+ pass
614
+ if rect.y > page.h * 0.22:
615
+ return False
616
+ for p in sh.text_frame.paragraphs:
617
+ for r in p.runs:
618
+ if r.font.size and r.font.size.pt >= 24:
619
+ return True
620
+ return False
621
+
622
+
623
+ def _full_bleed_page(items, page):
624
+ """整页铺底的构图(封面/章节/结尾)。"""
625
+ for sh, r, _z in items:
626
+ if r.w >= page.w * 0.95 and r.h >= page.h * 0.95 and sh.shape_type != MSO_SHAPE_TYPE.PICTURE:
627
+ try:
628
+ if sh.fill.type is not None and 'BACKGROUND' not in str(sh.fill.type):
629
+ return True
630
+ except Exception:
631
+ continue
632
+ return False
633
+
634
+
635
+ def _check_picture(pno, sh, rect, label):
636
+ out = []
637
+ try:
638
+ img = sh.image
639
+ nw, nh = img.size
640
+ except Exception:
641
+ return out
642
+ # 裁剪后的有效原始比例
643
+ cl = getattr(sh, 'crop_left', 0) or 0
644
+ cr = getattr(sh, 'crop_right', 0) or 0
645
+ ct = getattr(sh, 'crop_top', 0) or 0
646
+ cb = getattr(sh, 'crop_bottom', 0) or 0
647
+ ew = nw * max(0.01, 1 - cl - cr)
648
+ eh = nh * max(0.01, 1 - ct - cb)
649
+ if ew <= 0 or eh <= 0 or rect.w <= 0 or rect.h <= 0:
650
+ return out
651
+ nat = ew / eh
652
+ cur = rect.w / rect.h
653
+ dev = abs(cur / nat - 1)
654
+ if dev > DISTORT_WARN:
655
+ want_h = rect.w / nat
656
+ out.append(Finding(
657
+ pno, 'image_distort', 'error' if dev > DISTORT_ERR else 'warn',
658
+ f'{label} 被{"拉宽" if cur > nat else "压扁"} {dev * 100:.0f}%'
659
+ f'(当前 {cur:.2f}:1,原图 {nat:.2f}:1)',
660
+ f'保持原比例:宽 {rect.w:.2f}" 时高应为 {want_h:.2f}"(或只给 width/height 之一)',
661
+ label, rect))
662
+ dpi_x = ew / rect.w if rect.w else 999
663
+ dpi_y = eh / rect.h if rect.h else 999
664
+ dpi = min(dpi_x, dpi_y)
665
+ if dpi < MIN_DPI:
666
+ out.append(Finding(pno, 'image_blurry', 'warn',
667
+ f'{label} 渲染分辨率仅 {dpi:.0f} dpi(原图 {int(ew)}×{int(eh)}px,占 {rect.w:.2f}"×{rect.h:.2f}")',
668
+ '换高分辨率原图,或缩小显示尺寸', label, rect))
669
+ return out
670
+
671
+
672
+ def _check_text(pno, sh, rect, ink, label, theme, prior, page, strict):
673
+ out = []
674
+ tf = sh.text_frame
675
+ total, max_w, avail_w, avail_h, detail = para_lines(sh, rect, tf)
676
+ scale, _ = _autofit(tf)
677
+
678
+ # 纵向溢出。两种结局要分开判:
679
+ # · 普通框 → 文字被切掉(下半截看不见);
680
+ # · spAutoFit 框(python-pptx add_textbox 的默认!)→ 框自己长高,
681
+ # 不切字但会一路顶下去,超过页底就是纸面外,比切字更难发现。
682
+ if avail_h > 0 and total > avail_h * L.TOL:
683
+ over = total / avail_h - 1
684
+ auto_grow = False
685
+ try:
686
+ auto_grow = tf.auto_size == MSO_AUTO_SIZE.SHAPE_TO_FIT_TEXT
687
+ except Exception:
688
+ pass
689
+ need_h = (total + 2 * _insets(tf)[1]) / L.PT_IN
690
+ grown_bottom = rect.y + need_h
691
+ if auto_grow:
692
+ spill = grown_bottom - page.bottom
693
+ lvl = 'error' if spill > 0.02 else 'warn'
694
+ why = (f';框是「随文字增高」,会一路长到 y={grown_bottom:.2f}",'
695
+ f'{"顶出页底 %.2f\"(这部分永远看不到)" % spill if spill > 0.02 else "把下方元素挤开"}')
696
+ else:
697
+ lvl = 'error' if over >= 0.12 else 'warn'
698
+ why = ';超出部分会被直接切掉'
699
+ out.append(Finding(
700
+ pno, 'overflow_v', lvl,
701
+ f'{label} 文字装不下:估算需 {need_h:.2f}",框高仅 {rect.h:.2f}"(超 {over * 100:.0f}%,共 {sum(d["lines"] for d in detail)} 行)' + why,
702
+ '减少内容 / 拆页 / 调小字号 / 加高文本框(pptx_check --fix 可自动缩字或加高)', label, rect))
703
+ if scale < 0.92:
704
+ out.append(Finding(pno, 'autofit_shrunk', 'warn',
705
+ f'{label} 被 PowerPoint 自动缩到原字号的 {scale * 100:.0f}%(说明内容本来就塞不下)',
706
+ '内容拆页,别靠自动缩字硬塞', label, rect))
707
+ # 横向超宽(关了自动换行时)
708
+ if not _word_wrap(tf) and max_w > avail_w * L.TOL:
709
+ out.append(Finding(pno, 'overflow_h', 'error',
710
+ f'{label} 关闭了自动换行且单行宽 {max_w / L.PT_IN:.2f}" > 可用 {avail_w / L.PT_IN:.2f}"',
711
+ '打开 word_wrap 或加宽文本框', label, rect))
712
+
713
+ # 字号 / 东亚字体 / 对比度
714
+ bg = _bg_under(ink, prior) or 'FFFFFF'
715
+ seen_small = False
716
+ for p in tf.paragraphs:
717
+ for r in p.runs:
718
+ if not r.text.strip():
719
+ continue
720
+ size = _run_size(r, p, sh) * scale
721
+ if size < FONT_ERR and not seen_small:
722
+ seen_small = True
723
+ out.append(Finding(pno, 'font_too_small', 'error',
724
+ f'{label} 出现 {size:.0f}pt 文字(投影/远看不清)',
725
+ f'正文不低于 {FONT_WARN:.0f}pt,注释不低于 12pt', label, rect))
726
+ elif size < FONT_WARN and not seen_small and strict:
727
+ seen_small = True
728
+ out.append(Finding(pno, 'font_too_small', 'warn',
729
+ f'{label} 出现 {size:.0f}pt 文字', f'建议 ≥{FONT_WARN:.0f}pt', label, rect))
730
+ if _has_cjk(r.text) and r.font.name and not _has_ea(r):
731
+ out.append(Finding(pno, 'no_ea_font', 'warn',
732
+ f'{label} 中文未设东亚字体(只设了 {r.font.name})',
733
+ '同时设 <a:ea typeface>,否则中文回退到别的字体、实际行宽与预期不符', label, rect))
734
+ fg = _color_hex(r.font.color, theme)
735
+ c = L.contrast(L.hex_rgb(fg), L.hex_rgb(bg)) if fg else None
736
+ if c is not None:
737
+ lim = CONTRAST_ERR if size >= 24 else CONTRAST_WARN
738
+ if c < lim:
739
+ out.append(Finding(pno, 'low_contrast', 'error' if c < CONTRAST_ERR else 'warn',
740
+ f'{label} 文字 #{fg} 压在 #{bg} 上,对比度仅 {c:.1f}:1(需 ≥{lim})',
741
+ f'改用 #{L.readable_on(bg)} 一类的可读色', label, rect))
742
+ break
743
+ else:
744
+ continue
745
+ break
746
+
747
+ # 要点过长
748
+ for d in detail:
749
+ t = d['text'].strip().lstrip('•·-—  ')
750
+ if len(t) > MAX_BULLET_CHARS and d['size'] < 28:
751
+ out.append(Finding(pno, 'bullet_too_long', 'warn',
752
+ f'要点 {len(t)} 字:{t[:24]}…',
753
+ f'一条要点压到 {MAX_BULLET_CHARS} 字内,展开的话进备注', label, rect))
754
+ break
755
+ return out
756
+
757
+
758
+ def _bullet_paras(sh, rect, page_h):
759
+ """这个文本框算几条要点。
760
+
761
+ 只认"真正的要点列表":同一个框里 ≥2 段有内容的正文。页码、页脚、KPI 卡片标签
762
+ 都是单段短文本,把它们数进来会让"本页 15 个要点"这种告警天天误报,
763
+ 而一个天天误报的检查等于没有检查。
764
+ """
765
+ if rect.y > page_h - 0.62: # 页脚带
766
+ return 0
767
+ paras = [p for p in sh.text_frame.paragraphs if p.text.strip()]
768
+ if len(paras) < 2:
769
+ return 0
770
+ sizes = [r.font.size.pt for p in paras for r in p.runs if r.font.size]
771
+ if sizes and max(sizes) < 12: # 注释/脚注一类的小字块
772
+ return 0
773
+ return len(paras)
774
+
775
+
776
+ def _has_cjk(s):
777
+ return any(0x2E80 <= ord(c) <= 0x9FFF or 0xF900 <= ord(c) <= 0xFAFF for c in s)
778
+
779
+
780
+ def _has_ea(run):
781
+ try:
782
+ rPr = run._r.find(qn('a:rPr'))
783
+ return rPr is not None and rPr.find(qn('a:ea')) is not None
784
+ except Exception:
785
+ return False
786
+
787
+
788
+ def _bg_under(ink, prior):
789
+ """ink 底下最靠上的一块纯色填充(z 序更小者在下);找不到返回 None。"""
790
+ best = None
791
+ for sh, rect, _ink, kind, z, _label in prior:
792
+ if kind == 'pic':
793
+ continue
794
+ if rect.contains(ink, 0.02) or rect.overlap_ratio(ink) > 0.6:
795
+ f = _fill_hex(sh, {})
796
+ if f:
797
+ best = f
798
+ return best
799
+
800
+
801
+ def _check_table(pno, sh, rect, page, label):
802
+ out = []
803
+ try:
804
+ tbl = sh.table
805
+ except Exception:
806
+ return out
807
+ cols = [c.width / L.EMU_IN for c in tbl.columns]
808
+ heights = _table_need(tbl, cols) # 与 _fix_table 同一把尺
809
+ need_h = sum(max(h, (row.height / L.EMU_IN) if row.height else 0) for h, row in zip(heights, tbl.rows))
810
+ for ri, row in enumerate(tbl.rows):
811
+ for ci, cell in enumerate(row.cells):
812
+ if ci >= len(cols) or not cell.text.strip():
813
+ continue
814
+ sizes = [r.font.size.pt for p in cell.text_frame.paragraphs for r in p.runs if r.font.size]
815
+ size = max(sizes) if sizes else 14.0
816
+ ml = cell.margin_left / L.EMU_PT if cell.margin_left is not None else 7.2
817
+ mr = cell.margin_right / L.EMU_PT if cell.margin_right is not None else 7.2
818
+ w = max(10.0, cols[ci] * L.PT_IN - ml - mr)
819
+ lines = L.wrap_segments([(cell.text, size, False)], w)
820
+ if len(lines) > 2 and size >= 12:
821
+ out.append(Finding(pno, 'table_overflow', 'warn',
822
+ f'{label} 第{ri + 1}行第{ci + 1}列「{cell.text[:14]}」要折 {len(lines)} 行',
823
+ '加宽该列、缩短文案或减小表格字号', label, rect, fixable=False))
824
+ if rect.y + need_h > page.bottom + 0.02:
825
+ out.append(Finding(pno, 'table_overflow', 'error',
826
+ f'{label} 实际需高 {need_h:.2f}",从 y={rect.y:.2f}" 起会顶出页面(页高 {page.h:.2f}")',
827
+ f'行数减到 {max(2, int((page.bottom - rect.y) / max(0.28, need_h / max(1, len(tbl.rows)))))} 行以内,或分两页', label, rect))
828
+ if len(tbl.rows) > 12:
829
+ out.append(Finding(pno, 'table_overflow', 'warn',
830
+ f'{label} 共 {len(tbl.rows)} 行,投影上看不清',
831
+ '摘要上页、明细另附 xlsx 并在页脚注明', label, rect, fixable=False))
832
+ return out
833
+
834
+
835
+ # 装饰性形状:无文字的纯色块(卡片底/色条),与其它元素重叠是设计而非缺陷
836
+ def _is_decor(kind, sh):
837
+ if kind != 'shape':
838
+ return False
839
+ try:
840
+ return not (sh.has_text_frame and sh.text_frame.text.strip())
841
+ except Exception:
842
+ return True
843
+
844
+
845
+ def _check_overlap(pno, boxes):
846
+ out = []
847
+ n = len(boxes)
848
+ for i in range(n):
849
+ sh_a, rect_a, ink_a, kind_a, z_a, lab_a = boxes[i]
850
+ if kind_a not in ('text', 'pic', 'table', 'chart'):
851
+ continue
852
+ for j in range(i + 1, n):
853
+ sh_b, rect_b, ink_b, kind_b, z_b, lab_b = boxes[j]
854
+ if kind_b not in ('text', 'pic', 'table', 'chart'):
855
+ continue
856
+ a = _rot_aabb(ink_a, getattr(sh_a, 'rotation', 0))
857
+ b = _rot_aabb(ink_b, getattr(sh_b, 'rotation', 0))
858
+ ratio = a.overlap_ratio(b)
859
+ if ratio <= OVERLAP_MIN:
860
+ continue
861
+ # 文字压在图/图表上:只有当上层是图(把字盖住)才算问题;字在图上是常见设计
862
+ if {kind_a, kind_b} == {'text', 'pic'} or {kind_a, kind_b} == {'text', 'chart'}:
863
+ txt_i = (kind_a == 'text')
864
+ z_txt, z_img = (z_a, z_b) if txt_i else (z_b, z_a)
865
+ lab_txt, lab_img = (lab_a, lab_b) if txt_i else (lab_b, lab_a)
866
+ if z_img > z_txt and ratio > 0.12:
867
+ out.append(Finding(pno, 'overlap', 'error',
868
+ f'{lab_img} 盖住了 {lab_txt}(压住 {ratio * 100:.0f}%,图在文字上层)',
869
+ '把图移开或置于底层', lab_txt, a.intersect(b)))
870
+ continue
871
+ lvl = 'error' if ratio > 0.12 else 'warn'
872
+ out.append(Finding(pno, 'overlap', lvl,
873
+ f'{lab_a} 与 {lab_b} 重叠 {ratio * 100:.0f}%'
874
+ f'(相交区 {a.intersect(b).w:.2f}"×{a.intersect(b).h:.2f}")',
875
+ '错开位置或缩小其一;同一行的多块内容用等分栅格排', f'{lab_a}/{lab_b}',
876
+ a.intersect(b)))
877
+ return out
878
+
879
+
880
+ def _check_consistency(titles, title_tops, fonts, fills, sw, sh_in):
881
+ out = []
882
+ if len(title_tops) >= 3:
883
+ ys = [y for _, y in title_tops]
884
+ lo, hi = min(ys), max(ys)
885
+ if hi - lo > TITLE_JITTER:
886
+ worst = max(title_tops, key=lambda t: abs(t[1] - sorted(ys)[len(ys) // 2]))
887
+ out.append(Finding(worst[0], 'title_jitter', 'warn',
888
+ f'各页标题上边距在 {lo:.2f}"~{hi:.2f}" 之间跳动(第 {worst[0]} 页 {worst[1]:.2f}")',
889
+ '所有内容页标题用同一 y 坐标,翻页时标题不该乱动'))
890
+ if len(fonts) > 3:
891
+ out.append(Finding(0, 'font_zoo', 'warn', f'全篇用了 {len(fonts)} 种字体:{"、".join(sorted(fonts)[:6])}',
892
+ '一套 PPT 两种字体足够(标题一种、正文一种)'))
893
+ if len(fills) > 10:
894
+ out.append(Finding(0, 'palette_zoo', 'info', f'全篇出现 {len(fills)} 种填充色',
895
+ '收敛到一套主题色(主色/强调/浅底/中性)'))
896
+ seen = {}
897
+ for pno, t in titles:
898
+ seen.setdefault(t, []).append(pno)
899
+ for t, pages in seen.items():
900
+ if len(pages) > 1 and len(t) > 2:
901
+ out.append(Finding(pages[1], 'dup_title', 'info',
902
+ f'第 {"、".join(map(str, pages))} 页标题都是「{t[:16]}」',
903
+ '续页标题加「(续)」,或换成各页真正的结论'))
904
+ r = sw / sh_in if sh_in else 0
905
+ if abs(r - 16 / 9) > 0.02:
906
+ out.append(Finding(0, 'size_odd', 'info', f'页面比例 {_ratio(sw, sh_in)}({sw:.2f}"×{sh_in:.2f}")',
907
+ '现在的投影/电视几乎都是 16:9;4:3 会两侧留黑边'))
908
+ return out
909
+
910
+
911
+ # ── 报告 ────────────────────────────────────────────────────────────────────
912
+ def report(res, max_per_code=4, max_lines=60):
913
+ """把体检结果写成给人/给模型看的紧凑报告。"""
914
+ c = res['counts']
915
+ head = (f'【版式体检】{os.path.basename(res["path"])} · {res["slides"]} 页 · {res["ratio"]} '
916
+ f'· 必修 {c["error"]} / 建议 {c["warn"]} / 提示 {c["info"]}')
917
+ if not res['findings']:
918
+ return head + '\n✓ 未发现溢出、重叠、变形、越界等版式问题。'
919
+ lines = [head]
920
+ shown = 0
921
+ for lvl in ('error', 'warn', 'info'):
922
+ group = [f for f in res['findings'] if f['level'] == lvl]
923
+ if not group:
924
+ continue
925
+ lines.append(f'\n{LEVEL_CN[lvl]}({len(group)})')
926
+ per = {}
927
+ for f in group:
928
+ per.setdefault(f['code'], []).append(f)
929
+ for code, fs in per.items():
930
+ for f in fs[:max_per_code]:
931
+ loc = f'p{f["page"]}' if f['page'] else '全篇'
932
+ lines.append(f' {loc} {f["name"]} {f["detail"]}')
933
+ if f['hint']:
934
+ lines.append(f' → {f["hint"]}')
935
+ shown += 1
936
+ if shown >= max_lines:
937
+ break
938
+ if len(fs) > max_per_code:
939
+ rest = fs[max_per_code:]
940
+ lines.append(f' (同类还有 {len(rest)} 处:{"、".join("p" + str(x["page"]) for x in rest[:12])})')
941
+ if shown >= max_lines:
942
+ lines.append(' …(更多问题已省略,修完上面这些再跑一次)')
943
+ break
944
+ if shown >= max_lines:
945
+ break
946
+ fixable = sorted({f['code'] for f in res['findings'] if f.get('fixable')})
947
+ if fixable:
948
+ lines.append(f'\n可自动修复:{"、".join(CODE_CN[x] for x in fixable)} '
949
+ f'→ fix("{os.path.basename(res["path"])}");其余需改内容/版式后重新生成。')
950
+ return '\n'.join(lines)
951
+
952
+
953
+ # ── 自动修复 ────────────────────────────────────────────────────────────────
954
+ def fix(path, out=None, inplace=False, font_floor=14.0):
955
+ """自动修可安全修的问题(默认另存,绝不覆盖原件)。
956
+
957
+ 只做**确定性且不改内容**的修复:
958
+ · 图片变形 → 按原图比例等比重排(在原框内居中)
959
+ · 超出页面 / 贴边 → 移回页内,过大的等比缩小
960
+ · 文字溢出 → 先在 [font_floor, 原字号] 内逐级缩字;仍不够则在页内向下加高;
961
+ 还不够则打开「自动缩排」兜底,并如实报出"建议拆页"
962
+ 绝不删除任何元素、绝不改文案——那是人的决定。
963
+ """
964
+ prs = Presentation(path)
965
+ sw, sh_in = _in(prs.slide_width), _in(prs.slide_height)
966
+ page = L.Rect(0, 0, sw, sh_in)
967
+ changes = []
968
+ still = []
969
+
970
+ for pno, slide in enumerate(prs.slides, 1):
971
+ items = walk(slide)
972
+ for sh, rect, z in items:
973
+ # 图片变形
974
+ if sh.shape_type == MSO_SHAPE_TYPE.PICTURE:
975
+ try:
976
+ nw, nh = sh.image.size
977
+ cl = (sh.crop_left or 0) + (sh.crop_right or 0)
978
+ ct = (sh.crop_top or 0) + (sh.crop_bottom or 0)
979
+ nat = (nw * max(0.01, 1 - cl)) / (nh * max(0.01, 1 - ct))
980
+ cur = rect.w / rect.h if rect.h else nat
981
+ if abs(cur / nat - 1) > DISTORT_WARN:
982
+ box = L.contain_fit(nat, 1.0, rect)
983
+ sh.left, sh.top = Emu(int(box.x * L.EMU_IN)), Emu(int(box.y * L.EMU_IN))
984
+ sh.width, sh.height = Emu(int(box.w * L.EMU_IN)), Emu(int(box.h * L.EMU_IN))
985
+ changes.append(f'p{pno} 图片恢复原比例 {nat:.2f}:1({rect.w:.2f}×{rect.h:.2f} → {box.w:.2f}×{box.h:.2f}")')
986
+ rect = box
987
+ except Exception:
988
+ pass
989
+
990
+ # 越界:先等比缩到页内,再平移回来
991
+ r = L.Rect(rect.x, rect.y, rect.w, rect.h)
992
+ full_bleed = r.w >= sw * 0.98 and r.h >= sh_in * 0.98
993
+ if not full_bleed and (r.x < -0.02 or r.y < -0.02 or r.right > page.right + 0.02 or r.bottom > page.bottom + 0.02):
994
+ m = SAFE_MARGIN
995
+ inner = L.Rect(m, m, sw - 2 * m, sh_in - 2 * m)
996
+ s = min(1.0, inner.w / r.w if r.w else 1, inner.h / r.h if r.h else 1)
997
+ nw_, nh_ = r.w * s, r.h * s
998
+ nx = min(max(r.x, m), max(m, sw - m - nw_))
999
+ ny = min(max(r.y, m), max(m, sh_in - m - nh_))
1000
+ sh.left, sh.top = Emu(int(nx * L.EMU_IN)), Emu(int(ny * L.EMU_IN))
1001
+ if s < 1.0:
1002
+ sh.width, sh.height = Emu(int(nw_ * L.EMU_IN)), Emu(int(nh_ * L.EMU_IN))
1003
+ changes.append(f'p{pno} {_label(sh, _kind_of(sh))} 移回页内'
1004
+ + (f'并缩到 {s * 100:.0f}%' if s < 1 else ''))
1005
+ rect = L.Rect(nx, ny, nw_, nh_)
1006
+
1007
+ # 表格顶出页面:缩字 + 收边距 + 回写行高
1008
+ if getattr(sh, 'has_table', False):
1009
+ r3 = _fix_table(pno, sh, rect, page, changes)
1010
+ if r3:
1011
+ still.append(r3)
1012
+ continue
1013
+
1014
+ # 文字溢出:缩字 → 加高 → 自动缩排
1015
+ if sh.has_text_frame and sh.text_frame.text.strip():
1016
+ r2 = _fix_overflow(pno, sh, rect, page, font_floor, changes)
1017
+ if r2:
1018
+ still.append(r2)
1019
+
1020
+ dst = path if inplace else (out or _sibling(path, '-已修复'))
1021
+ prs.save(dst)
1022
+ return dict(saved=dst, changed=changes, unresolved=still)
1023
+
1024
+
1025
+ def _fix_overflow(pno, sh, rect, page, floor, changes):
1026
+ tf = sh.text_frame
1027
+ total, max_w, avail_w, avail_h, _ = para_lines(sh, rect, tf)
1028
+ if avail_h <= 0 or total <= avail_h * L.TOL:
1029
+ return None
1030
+ sizes = [(p, r, _run_size(r, p, sh)) for p in tf.paragraphs for r in p.runs if r.text.strip()]
1031
+ if not sizes:
1032
+ return None
1033
+ base = max(s for _, _, s in sizes)
1034
+ # ① 逐级缩字(等比缩所有 run,保持字号层级)
1035
+ k = 1.0
1036
+ while k > 0.55:
1037
+ k -= 0.05
1038
+ if base * k < floor:
1039
+ break
1040
+ for p, r, s0 in sizes:
1041
+ r.font.size = Pt(round(s0 * k, 1))
1042
+ total, _, _, avail_h, _ = para_lines(sh, rect, tf)
1043
+ if total <= avail_h:
1044
+ changes.append(f'p{pno} {_label(sh, "text")} 字号缩到 {k * 100:.0f}%({base:.0f}→{base * k:.0f}pt)以消除溢出')
1045
+ return None
1046
+ for p, r, s0 in sizes: # 缩不动就还原,改走加高
1047
+ r.font.size = Pt(s0)
1048
+ # ② 页内向下加高
1049
+ total, _, _, avail_h, _ = para_lines(sh, rect, tf)
1050
+ need = total / L.PT_IN + 2 * L.INSET_TB / L.PT_IN
1051
+ room = page.bottom - SAFE_MARGIN - rect.y
1052
+ if need <= room:
1053
+ sh.height = Emu(int(need * L.EMU_IN))
1054
+ changes.append(f'p{pno} {_label(sh, "text")} 文本框加高到 {need:.2f}"(原 {rect.h:.2f}")')
1055
+ return None
1056
+ # ③ 兜底:打开自动缩排,并如实报出该拆页
1057
+ try:
1058
+ tf.word_wrap = True
1059
+ tf.auto_size = MSO_AUTO_SIZE.TEXT_TO_FIT_SHAPE
1060
+ changes.append(f'p{pno} {_label(sh, "text")} 已打开「自动缩排」兜底')
1061
+ except Exception:
1062
+ pass
1063
+ return (f'p{pno} {_label(sh, "text")} 内容确实太多(需 {need:.2f}",页内最多 {room:.2f}"):'
1064
+ '已开自动缩排避免溢出,但正确做法是拆成两页或把细节挪进备注')
1065
+
1066
+
1067
+ def _kind_of(sh):
1068
+ if sh.shape_type == MSO_SHAPE_TYPE.PICTURE:
1069
+ return 'pic'
1070
+ if getattr(sh, 'has_table', False):
1071
+ return 'table'
1072
+ if getattr(sh, 'has_chart', False):
1073
+ return 'chart'
1074
+ try:
1075
+ if sh.has_text_frame and sh.text_frame.text.strip():
1076
+ return 'text'
1077
+ except Exception:
1078
+ pass
1079
+ return 'shape'
1080
+
1081
+
1082
+ def _table_need(tbl, cols):
1083
+ """按单元格实际折行算出每行需要的高度(英寸)。"""
1084
+ heights = []
1085
+ for row in tbl.rows:
1086
+ need = 0.22
1087
+ for ci, cell in enumerate(row.cells):
1088
+ if ci >= len(cols) or not cell.text.strip():
1089
+ continue
1090
+ sizes = [r.font.size.pt for p in cell.text_frame.paragraphs for r in p.runs if r.font.size]
1091
+ size = max(sizes) if sizes else 14.0
1092
+ ml = cell.margin_left / L.EMU_PT if cell.margin_left is not None else 7.2
1093
+ mr = cell.margin_right / L.EMU_PT if cell.margin_right is not None else 7.2
1094
+ mt = cell.margin_top / L.EMU_PT if cell.margin_top is not None else 3.6
1095
+ mb = cell.margin_bottom / L.EMU_PT if cell.margin_bottom is not None else 3.6
1096
+ w = max(10.0, cols[ci] * L.PT_IN - ml - mr)
1097
+ h = (L.text_height(L.wrap_segments([(cell.text, size, False)], w)) + mt + mb) / L.PT_IN
1098
+ need = max(need, h)
1099
+ heights.append(need)
1100
+ return heights
1101
+
1102
+
1103
+ def _fix_table(pno, sh, rect, page, changes):
1104
+ """表格顶出页面:先缩字、再收紧单元格上下边距,最后把算出的行高写回。"""
1105
+ try:
1106
+ tbl = sh.table
1107
+ except Exception:
1108
+ return None
1109
+ cols = [c.width / L.EMU_IN for c in tbl.columns]
1110
+ avail = page.bottom - SAFE_MARGIN - rect.y
1111
+ need = sum(_table_need(tbl, cols))
1112
+ if need <= avail:
1113
+ return None
1114
+ runs = [r for row in tbl.rows for cell in row.cells
1115
+ for p in cell.text_frame.paragraphs for r in p.runs if r.text.strip()]
1116
+ base = max((r.font.size.pt for r in runs if r.font.size), default=14.0)
1117
+ orig = [(r, r.font.size.pt if r.font.size else 14.0) for r in runs]
1118
+ k = 1.0
1119
+ while k > 0.6 and need > avail:
1120
+ k -= 0.05
1121
+ if base * k < 10.0:
1122
+ break
1123
+ for r, s0 in orig:
1124
+ r.font.size = Pt(round(s0 * k, 1))
1125
+ need = sum(_table_need(tbl, cols))
1126
+ if need > avail: # 再收紧上下内边距
1127
+ for row in tbl.rows:
1128
+ for cell in row.cells:
1129
+ cell.margin_top = Pt(1.5)
1130
+ cell.margin_bottom = Pt(1.5)
1131
+ need = sum(_table_need(tbl, cols))
1132
+ hs = _table_need(tbl, cols)
1133
+ if need <= avail:
1134
+ for row, h in zip(tbl.rows, hs):
1135
+ row.height = Emu(int(h * L.EMU_IN))
1136
+ changes.append(f'p{pno} 表格缩到 {k * 100:.0f}% 字号并回写行高,总高 {need:.2f}" 装进可用 {avail:.2f}"')
1137
+ return None
1138
+ for r, s0 in orig: # 缩不动就还原,如实报该分页
1139
+ r.font.size = Pt(s0)
1140
+ per = need / max(1, len(tbl.rows))
1141
+ return (f'p{pno} 表格 {len(tbl.rows)} 行需 {need:.2f}",页内只有 {avail:.2f}":'
1142
+ f'缩到 10pt 仍装不下,请拆成每页 ≤{max(2, int(avail / per))} 行,或只上摘要、明细另附 xlsx')
1143
+
1144
+
1145
+ def _sibling(path, suffix):
1146
+ root, ext = os.path.splitext(path)
1147
+ return root + suffix + ext
1148
+
1149
+
1150
+ # ── CLI ─────────────────────────────────────────────────────────────────────
1151
+ def main(argv):
1152
+ if not argv or argv[0] in ('-h', '--help'):
1153
+ print(__doc__ or '用法:python pptx_check.py <文件.pptx> [--json] [--fix] [--inplace] [--strict]')
1154
+ return 2
1155
+ path = argv[0]
1156
+ as_json = '--json' in argv
1157
+ do_fix = '--fix' in argv
1158
+ strict = '--strict' in argv
1159
+ if not os.path.exists(path):
1160
+ print(f'文件不存在:{path}')
1161
+ return 1
1162
+ res = inspect(path, strict=strict)
1163
+ if do_fix:
1164
+ fr = fix(path, inplace='--inplace' in argv)
1165
+ res2 = inspect(fr['saved'], strict=strict)
1166
+ if as_json:
1167
+ print(json.dumps(dict(before=res, fixed=fr, after=res2), ensure_ascii=False, indent=1))
1168
+ else:
1169
+ print(report(res))
1170
+ print('\n【自动修复】已改 %d 处 → %s' % (len(fr['changed']), fr['saved']))
1171
+ for c in fr['changed'][:20]:
1172
+ print(' ·', c)
1173
+ for u in fr['unresolved'][:10]:
1174
+ print(' ⚠', u)
1175
+ print('\n' + report(res2))
1176
+ return 0
1177
+ print(json.dumps(res, ensure_ascii=False, indent=1) if as_json else report(res))
1178
+ return 0 if res['ok'] else 1
1179
+
1180
+
1181
+ if __name__ == '__main__':
1182
+ sys.exit(main(sys.argv[1:]))