@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,1306 @@
1
+ # Word 技术文档体检与自动校正。
2
+ #
3
+ # 定位:Word 文档的排版事故与 PPT 不同——PPT 是「看得见的几何事故」(文字出框、元素重叠),
4
+ # Word 是「翻过去才发现的一致性事故」:这一段首行缩进了下一段没缩、正文里混着三种字号、
5
+ # 图表编号跳号、表格宽出版心被裁掉右边一列、通篇是「有效提升」却没有一个数字。
6
+ # 这些在保存时都不报错,打印出来才丢人。所以本模块干三件事:
7
+ #
8
+ # 1. 逐段还原**实际生效的排版属性**(run → 段落样式 → 样式链 → Normal),按规范档位比对;
9
+ # 2. 几何一律用**文档自己的页面设置**算(不是用规范里的理想值),超版心/变形/发虚都是算术题;
10
+ # 3. 文字与 AI 味交给 zh_prose(同一套判据也能用在 Markdown 草稿上,写作中途就能预检)。
11
+ #
12
+ # fix=true 只修**可确定性修**的那几类,且一律另存 `-已校正.docx` 不覆盖原件。
13
+ # 改内容才能解决的(套话太多、缺数字、章节失衡)它会如实说「需要人改」——不假装修好了。
14
+
15
+ import json
16
+ import os
17
+ import re
18
+ import sys
19
+ from collections import Counter, defaultdict
20
+
21
+ from docx import Document
22
+ from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_LINE_SPACING
23
+ from docx.oxml import OxmlElement
24
+ from docx.oxml.ns import qn
25
+ from docx.shared import Cm, Pt, Emu
26
+
27
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
28
+ import docx_layout as L # noqa: E402
29
+ import zh_prose as P # noqa: E402
30
+
31
+ EMU_PT = 12700.0
32
+ EMU_CM = 360000.0
33
+
34
+
35
+ def _issue(level, group, kind, loc, msg, detail='', fixable=False):
36
+ return {'level': level, 'group': group, 'kind': kind, 'loc': loc,
37
+ 'msg': msg, 'detail': detail, 'fixable': fixable}
38
+
39
+
40
+ # ── 一、解析:把 docx 还原成「实际生效属性」的段落模型 ──────────────────────
41
+ def _style_chain(style, limit=8):
42
+ out, s, n = [], style, 0
43
+ while s is not None and n < limit:
44
+ out.append(s)
45
+ s = getattr(s, 'base_style', None)
46
+ n += 1
47
+ return out
48
+
49
+
50
+ def _eff_size(run, para, doc):
51
+ """实际生效字号(磅):run → 段落样式链 → Normal → docDefaults。"""
52
+ if run is not None and run.font.size is not None:
53
+ return run.font.size.pt
54
+ for s in _style_chain(para.style):
55
+ if s.font.size is not None:
56
+ return s.font.size.pt
57
+ try:
58
+ if doc.styles['Normal'].font.size is not None:
59
+ return doc.styles['Normal'].font.size.pt
60
+ except KeyError:
61
+ pass
62
+ d = doc.styles.element.find(qn('w:docDefaults'))
63
+ if d is not None:
64
+ sz = d.find('.//' + qn('w:sz'))
65
+ if sz is not None and sz.get(qn('w:val')):
66
+ return float(sz.get(qn('w:val'))) / 2.0
67
+ return 10.5
68
+
69
+
70
+ def _rfonts(el):
71
+ rpr = getattr(el, 'rPr', None) if el is not None else None
72
+ return rpr.rFonts if rpr is not None and rpr.rFonts is not None else None
73
+
74
+
75
+ def _eff_fonts(run, para, doc):
76
+ """实际生效的(西文名, 东亚名)。东亚名取不到=中文字体没设对,这是最高频的 Word 坑。"""
77
+ west = east = None
78
+ for el in ([run._element] if run is not None else []) + \
79
+ [s.element for s in _style_chain(para.style)] + \
80
+ [doc.styles['Normal'].element]:
81
+ rf = _rfonts(el)
82
+ if rf is None:
83
+ continue
84
+ west = west or rf.get(qn('w:ascii')) or rf.get(qn('w:hAnsi'))
85
+ east = east or rf.get(qn('w:eastAsia'))
86
+ if west and east:
87
+ break
88
+ return west, east
89
+
90
+
91
+ def _eff_pf(para, attr, doc):
92
+ """实际生效的段落格式属性(直接设置 → 样式链)。"""
93
+ v = getattr(para.paragraph_format, attr, None)
94
+ if v is not None:
95
+ return v
96
+ for s in _style_chain(para.style):
97
+ pf = getattr(s, 'paragraph_format', None)
98
+ if pf is not None and getattr(pf, attr, None) is not None:
99
+ return getattr(pf, attr)
100
+ return None
101
+
102
+
103
+ HEAD_RE = re.compile(r'^Heading\s*(\d)$|^标题\s*(\d)$')
104
+
105
+
106
+ def _head_level(para):
107
+ m = HEAD_RE.match(str(para.style.name or ''))
108
+ if m:
109
+ return int(m.group(1) or m.group(2))
110
+ try:
111
+ lv = para._p.find(qn('w:pPr'))
112
+ if lv is not None:
113
+ o = lv.find(qn('w:outlineLvl'))
114
+ if o is not None and o.get(qn('w:val')) is not None:
115
+ return int(o.get(qn('w:val'))) + 1
116
+ except Exception:
117
+ pass
118
+ return 0
119
+
120
+
121
+ def _fake_indent(text):
122
+ """假缩进:用全角/半角空格顶出来的首行缩进。改字号就会错位,是排版返工的常客。"""
123
+ m = re.match(r'^([  ]{2,})', str(text or ''))
124
+ return len(m.group(1)) if m else 0
125
+
126
+
127
+ # 段落「角色」识别:体检必须先分清正文与结构件,否则会对**完全合规的文档**喊狼来了——
128
+ # 封面标题当然是小初、目录当然不缩进、列表项当然没有句号、签字栏当然右对齐。
129
+ # 一个天天误报的体检器,用两次就没人看了,所以这一步比任何一条检查规则都重要。
130
+ LIST_RE = re.compile(r'^\s*(?:[·•▪◦‣*+\-–—]|\(?\d+[.)、]|[((][一二三四五六七八九十\d]+[))]|[a-zA-Z][.)])\s+')
131
+ SIGN_RE = re.compile(r'_{3,}|^\s*(?:编制|审核|批准|签字|签章|会签|日期|单位|负责人|项目负责人|盖章)\s*[::]')
132
+ NOTE_RE = re.compile(r'^\s*(?:注|说明|备注)\s*[::]')
133
+
134
+
135
+ def _role(rec, first_head_idx, n_total, is_toc, front_end=None):
136
+ """给段落定角色:front 前置件 / toc 目录 / heading 标题 / caption 题注 / list 列项 /
137
+ signature 签字栏 / blank 空段 / body 正文。只有 body 才吃「正文该有的样子」那套检查。
138
+
139
+ front_end = 前置件(封面/目录)的最后一段下标,由分节符定位。**必须有这条**:
140
+ 「首个标题之前算前置件」这条规则在**没有任何标题的短文档**(决议、通知、签到表)上完全失效,
141
+ 于是封面大标题(小初)、文号(四号)全被当成正文,报一堆「字号不统一/缩进忽有忽无」——
142
+ 对一份完全合规的文档。"""
143
+ text = rec['text']
144
+ if is_toc:
145
+ return 'toc'
146
+ if front_end is not None and rec['index'] <= front_end:
147
+ return 'blank' if not text.strip() else 'front'
148
+ if rec['level']:
149
+ return 'heading'
150
+ if not text.strip():
151
+ return 'blank'
152
+ if rec['is_caption'] or NOTE_RE.match(text) or 'aption' in str(rec['style']) or '题注' in str(rec['style']):
153
+ return 'caption'
154
+ if first_head_idx is not None and rec['index'] < first_head_idx:
155
+ return 'front'
156
+ if SIGN_RE.search(text):
157
+ return 'signature'
158
+ if LIST_RE.match(text):
159
+ return 'list'
160
+ ind = rec['indent']
161
+ if ind is not None and ind < 0: # 悬挂缩进 = 列项排版
162
+ return 'list'
163
+ if (rec['align'] == WD_ALIGN_PARAGRAPH.RIGHT and len(text) <= 30
164
+ and rec['index'] >= n_total - 8): # 文末右对齐短行 = 落款
165
+ return 'signature'
166
+ return 'body'
167
+
168
+
169
+ class Doc:
170
+ """解析后的文档模型:段落、表格、插图、页面几何,全部按文档实际值还原。"""
171
+
172
+ def __init__(self, path, profile_name='auto'):
173
+ self.path = path
174
+ self.doc = Document(path)
175
+ self.xml = self.doc.element.body.xml
176
+ self._geom()
177
+ self.paras, self.blocks, self.tables, self.figures = [], [], [], []
178
+ self._walk()
179
+ self._section_widths()
180
+ self._assign_roles()
181
+ self.profile = L.profile(self._detect(profile_name))
182
+
183
+ def _geom(self):
184
+ s = self.doc.sections[0]
185
+ self.page_w_pt = (s.page_width or Cm(21).emu * 0 + Cm(21)) / EMU_PT if s.page_width else 21 * L.CM_PT
186
+ self.page_h_pt = s.page_height / EMU_PT if s.page_height else 29.7 * L.CM_PT
187
+ ml = (s.left_margin or 0) / EMU_PT
188
+ mr = (s.right_margin or 0) / EMU_PT
189
+ mt = (s.top_margin or 0) / EMU_PT
190
+ mb = (s.bottom_margin or 0) / EMU_PT
191
+ self.margin_pt = {'left': ml, 'right': mr, 'top': mt, 'bottom': mb}
192
+ self.body_w_pt = max(self.page_w_pt - ml - mr, 72.0)
193
+ self.body_h_pt = max(self.page_h_pt - mt - mb, 72.0)
194
+ self.landscape = self.page_w_pt > self.page_h_pt
195
+
196
+ def _walk(self):
197
+ body = self.doc.element.body
198
+ pmap = {p._p: p for p in self.doc.paragraphs}
199
+ tmap = {t._tbl: t for t in self.doc.tables}
200
+ chapter = 0
201
+ for child in body.iterchildren():
202
+ tag = child.tag.split('}')[-1]
203
+ if tag == 'p' and child in pmap:
204
+ para = pmap[child]
205
+ rec = self._para_rec(para, len(self.paras), chapter)
206
+ if rec['level'] == 1:
207
+ chapter += 1
208
+ rec['chapter'] = chapter
209
+ self.paras.append(rec)
210
+ self.blocks.append(('p', rec))
211
+ self._collect_figs(para, rec)
212
+ elif tag == 'tbl' and child in tmap:
213
+ t = tmap[child]
214
+ rec = self._table_rec(t, len(self.tables), chapter)
215
+ rec['after_para'] = len(self.paras) - 1
216
+ self.tables.append(rec)
217
+ self.blocks.append(('tbl', rec))
218
+
219
+ def _section_widths(self):
220
+ """每张图所在那一节的版心宽高(磅)。横排插图单独占一节、版心比纵向宽——按第一节判就会把它误报成超宽。
221
+ OOXML 里 sectPr 挂在一节的最后一段上,最后一节的挂在 body 末尾。"""
222
+ def dims(sp):
223
+ if sp is None:
224
+ return self.body_w_pt, self.body_h_pt
225
+ sz, mar = sp.find(qn('w:pgSz')), sp.find(qn('w:pgMar'))
226
+ try:
227
+ w, h = int(sz.get(qn('w:w'))) / 20.0, int(sz.get(qn('w:h'))) / 20.0
228
+ ml, mr = int(mar.get(qn('w:left'))) / 20.0, int(mar.get(qn('w:right'))) / 20.0
229
+ mt, mb = int(mar.get(qn('w:top'))) / 20.0, int(mar.get(qn('w:bottom'))) / 20.0
230
+ return max(w - ml - mr, 72.0), max(h - mt - mb, 72.0)
231
+ except (AttributeError, TypeError, ValueError):
232
+ return self.body_w_pt, self.body_h_pt
233
+ spans = []
234
+ for p in self.paras:
235
+ pPr = p['para']._p.find(qn('w:pPr'))
236
+ sp = pPr.find(qn('w:sectPr')) if pPr is not None else None
237
+ if sp is not None:
238
+ spans.append((p['index'], *dims(sp)))
239
+ spans.append((10 ** 9, *dims(self.doc.element.body.find(qn('w:sectPr')))))
240
+ for f in self.figures:
241
+ _, f['sec_w_pt'], f['sec_h_pt'] = next(x for x in spans if x[0] >= f['para_index'])
242
+
243
+ def _assign_roles(self):
244
+ heads = [p['index'] for p in self.paras if p['level']]
245
+ first = heads[0] if heads else None
246
+ n = len(self.paras)
247
+ front_end = self._front_end()
248
+ for p in self.paras:
249
+ is_toc = 'TOC' in (p['para']._p.xml or '')
250
+ p['role'] = _role(p, first, n, is_toc, front_end)
251
+ p['kind'] = {'heading': 'heading', 'caption': 'caption'}.get(p['role'],
252
+ 'body' if p['role'] == 'body' else p['role'])
253
+ self.front_end = front_end
254
+ for t in self.tables:
255
+ t['front'] = front_end is not None and t.get('after_para', 10 ** 9) <= front_end
256
+
257
+ def _front_end(self):
258
+ """用分节符定位前置件的末段:正式交付件把封面与目录放在第一节,正文另起一节
259
+ (为的是页码从第 1 页重算)。这条信号在没有标题的短文档上也成立,
260
+ 而「首个标题之前」那条不成立——两条并用才盖得全。
261
+
262
+ 三道护栏防止把正文首节误当封面:段数不多、文字很少、标题不超过一个(「目 录」)。"""
263
+ end = None
264
+ for p in self.paras:
265
+ pPr = p['para']._p.find(qn('w:pPr'))
266
+ if pPr is not None and pPr.find(qn('w:sectPr')) is not None:
267
+ end = p['index']
268
+ break
269
+ if end is None or end > 40:
270
+ return None
271
+ head = [p for p in self.paras[:end + 1] if p['level']]
272
+ chars = sum(len(p['text']) for p in self.paras[:end + 1])
273
+ if len(head) > 1 or chars > 400:
274
+ return None
275
+ return end
276
+
277
+ def _para_rec(self, para, idx, chapter):
278
+ runs = [r for r in para.runs if r.text]
279
+ first = runs[0] if runs else (para.runs[0] if para.runs else None)
280
+ size = _eff_size(first, para, self.doc)
281
+ west, east = _eff_fonts(first, para, self.doc)
282
+ sizes = {round(_eff_size(r, para, self.doc), 1) for r in runs} or {round(size, 1)}
283
+ text = para.text
284
+ level = _head_level(para)
285
+ cap = L.CAPTION_RE.match(text.strip()) if text.strip() else None
286
+ return {
287
+ 'index': idx, 'para': para, 'text': text, 'level': level, 'chapter': chapter,
288
+ 'style': para.style.name, 'size': size, 'sizes': sizes, 'east': east, 'west': west,
289
+ 'bold': bool(first.bold) if first is not None else False,
290
+ 'align': _eff_pf(para, 'alignment', self.doc),
291
+ 'indent': _eff_pf(para, 'first_line_indent', self.doc),
292
+ 'line': _eff_pf(para, 'line_spacing', self.doc),
293
+ 'line_rule': _eff_pf(para, 'line_spacing_rule', self.doc),
294
+ 'space_before': _eff_pf(para, 'space_before', self.doc),
295
+ 'space_after': _eff_pf(para, 'space_after', self.doc),
296
+ 'fake_indent': _fake_indent(text),
297
+ 'n_draw': len(para._p.findall('.//' + qn('w:drawing'))),
298
+ 'is_caption': bool(cap), 'caption': cap.groups() if cap else None,
299
+ 'empty': not text.strip(),
300
+ 'kind': 'heading' if level else ('caption' if cap else 'body'),
301
+ }
302
+
303
+ def _collect_figs(self, para, rec):
304
+ for dr in para._p.findall('.//' + qn('w:drawing')):
305
+ blip = dr.find('.//' + qn('a:blip'))
306
+ ext = dr.find('.//' + qn('wp:extent'))
307
+ w_pt = float(ext.get('cx')) / EMU_PT if ext is not None and ext.get('cx') else 0.0
308
+ h_pt = float(ext.get('cy')) / EMU_PT if ext is not None and ext.get('cy') else 0.0
309
+ px_w = px_h = dpi = 0
310
+ ok, err = True, ''
311
+ if blip is not None:
312
+ rid = blip.get(qn('r:embed'))
313
+ try:
314
+ img = self.doc.part.related_parts[rid].image
315
+ px_w, px_h, dpi = img.px_width, img.px_height, img.horz_dpi or 72
316
+ except Exception as e: # 关系断了/图片损坏:Word 里显示成红叉
317
+ ok, err = False, str(e)[:80]
318
+ else:
319
+ ok, err = False, '未找到图片引用(可能是被删除的嵌入对象)'
320
+ self.figures.append({'para_index': rec['index'], 'chapter': rec['chapter'],
321
+ 'w_pt': w_pt, 'h_pt': h_pt, 'px_w': px_w, 'px_h': px_h,
322
+ 'dpi': dpi, 'ok': ok, 'err': err,
323
+ 'anchor': dr.find(qn('wp:anchor')) is not None})
324
+
325
+ def _table_rec(self, t, idx, chapter):
326
+ rows = []
327
+ for r in t.rows:
328
+ try:
329
+ rows.append([c.text for c in r.cells])
330
+ except Exception:
331
+ rows.append([])
332
+ # 表宽有两个来源,**必须都量**:
333
+ # · tblGrid 的 gridCol —— Word 自己排版时写的列宽;
334
+ # · 每个单元格的 tcW —— 代码里 `cell.width = Cm(6)` 设的那个。
335
+ # 只读 tblGrid 会漏掉最典型的翻车件:手写 python-docx 时只设了 cell.width,
336
+ # tblGrid 还留着当初自动算的窄值,于是「体检说没超宽、打印出来右边一列没了」。
337
+ # 取两者较大的那个来判——渲染时以更宽的那份为准。
338
+ widths, grid_w = [], []
339
+ try:
340
+ grid = t._tbl.tblGrid
341
+ if grid is not None:
342
+ for gc in grid.findall(qn('w:gridCol')):
343
+ w = gc.get(qn('w:w'))
344
+ grid_w.append(float(w) / 20.0 if w else 0.0) # twips → 磅
345
+ except Exception:
346
+ pass
347
+ cell_w = []
348
+ try:
349
+ for row in t.rows:
350
+ cur = [(c.width / EMU_PT) if c.width else 0.0 for c in row.cells]
351
+ if sum(cur) > sum(cell_w):
352
+ cell_w = cur
353
+ except Exception:
354
+ pass
355
+ widths = cell_w if sum(cell_w) > sum(grid_w) else grid_w
356
+ head_repeat = False
357
+ try:
358
+ tr = t.rows[0]._tr
359
+ trpr = tr.find(qn('w:trPr'))
360
+ head_repeat = trpr is not None and trpr.find(qn('w:tblHeader')) is not None
361
+ except Exception:
362
+ pass
363
+ head_bold = False
364
+ try:
365
+ head_bold = all(any(r.bold for p in c.paragraphs for r in p.runs if r.text)
366
+ for c in t.rows[0].cells if c.text.strip())
367
+ except Exception:
368
+ pass
369
+ # 表单式两列表(键在左、值在右,如验收申请表/交付物清单)没有「表头行」,
370
+ # 它的加粗在**第一列**。按普通表判会误报「表头未加粗」,那是把表单当成了数据表。
371
+ form_like = False
372
+ try:
373
+ if len(rows) >= 2 and max((len(r) for r in rows), default=0) == 2:
374
+ left_bold = [any(r.bold for p in row.cells[0].paragraphs for r in p.runs if r.text)
375
+ for row in t.rows if row.cells[0].text.strip()]
376
+ form_like = bool(left_bold) and all(left_bold)
377
+ except Exception:
378
+ pass
379
+ return {'index': idx, 'tbl': t, 'rows': rows, 'chapter': chapter,
380
+ 'n_rows': len(rows), 'n_cols': max((len(r) for r in rows), default=0),
381
+ 'widths_pt': widths, 'total_pt': sum(widths),
382
+ 'head_repeat': head_repeat, 'head_bold': head_bold, 'form_like': form_like,
383
+ 'style': t.style.name if t.style is not None else ''}
384
+
385
+ def bodies(self):
386
+ """真正的正文段(结构件一律排除)——所有「正文该长什么样」的检查都以它为准。"""
387
+ return [p for p in self.paras if p['role'] == 'body' and p['text'].strip()]
388
+
389
+ def _detect(self, name):
390
+ if name and name != 'auto':
391
+ return name
392
+ sizes = Counter(round(p['size']) for p in self.bodies())
393
+ easts = Counter(p['east'] for p in self.bodies() if p['east'])
394
+ body_size = sizes.most_common(1)[0][0] if sizes else 12
395
+ east = easts.most_common(1)[0][0] if easts else ''
396
+ if body_size >= 15 and ('仿宋' in str(east) or self.margin_pt['top'] / L.CM_PT > 3.2):
397
+ return 'gov'
398
+ heads = [p['text'] for p in self.paras if p['level']]
399
+ dec = sum(1 for h in heads if (L.parse_number(h) or ('', (), ''))[0] == 'dec')
400
+ if heads and dec / len(heads) > 0.5:
401
+ return 'report'
402
+ return 'tech'
403
+
404
+ # 估算总页数:所有块的高度累加 ÷ 版心高。粗但够用(用于「文档约几页」与超长提醒)。
405
+ def est_pages(self):
406
+ h = 0.0
407
+ for kind, b in self.blocks:
408
+ if kind == 'p':
409
+ st = {'size': b['size'], 'line': b['line'] or 1.0,
410
+ 'line_rule': 'exact' if b['line_rule'] == WD_LINE_SPACING.EXACTLY else 'multiple',
411
+ 'first_indent_chars': 0,
412
+ 'space_before': (b['space_before'] or 0) / EMU_PT,
413
+ 'space_after': (b['space_after'] or 0) / EMU_PT}
414
+ n = L.wrap_count(b['text'], b['size'], self.body_w_pt,
415
+ (b['indent'] or 0) / EMU_PT)
416
+ ln = b['line']
417
+ if isinstance(ln, int):
418
+ # 固定值/最小值行距:python-docx 给的是 Length(EMU),不是磅——
419
+ # 当磅用会把一页估成十几万页(20 磅 = 254000 EMU)
420
+ lh = float(ln) / EMU_PT
421
+ if b['line_rule'] != WD_LINE_SPACING.EXACTLY:
422
+ lh = max(lh, b['size'] * L.LINE_H)
423
+ else:
424
+ lh = b['size'] * L.LINE_H * float(ln or 1.0)
425
+ h += n * lh + st['space_before'] + st['space_after']
426
+ for f in self.figures:
427
+ if f['para_index'] == b['index']:
428
+ h += f['h_pt']
429
+ else:
430
+ h += L.est_table_height(b['rows'], self.profile,
431
+ [w / L.CM_PT for w in b['widths_pt']] or None,
432
+ self.profile['table']['size'])
433
+ return max(1, round(h / self.body_h_pt + 0.5))
434
+
435
+
436
+ # ── 二、检查族 ─────────────────────────────────────────────────────────────
437
+ def _loc(rec):
438
+ return f"第 {rec['index']+1} 段"
439
+
440
+
441
+ def check_structure(D):
442
+ out = []
443
+ heads = [p for p in D.paras if p['level']]
444
+ if not heads:
445
+ out.append(_issue('warn', 'structure', 'no-heading', '全文',
446
+ '全文没有使用标题样式(Heading 1/2/3)',
447
+ '没有大纲级别 = 生不出目录、导航窗格空白、后续也没法自动编号'))
448
+ return out
449
+ prev = 0
450
+ for h in heads:
451
+ if prev and h['level'] > prev + 1:
452
+ out.append(_issue('error', 'structure', 'level-jump', _loc(h),
453
+ f"标题层级跳级:上一个是 {prev} 级,这里直接到 {h['level']} 级",
454
+ f"「{h['text'][:30]}」——中间缺一级标题,目录会塌一层"))
455
+ prev = h['level']
456
+
457
+ # 空章节:标题下面没有任何正文/表/图
458
+ order = D.blocks
459
+ for i, (kind, b) in enumerate(order):
460
+ if kind != 'p' or not b['level']:
461
+ continue
462
+ has = False
463
+ for kind2, b2 in order[i + 1:]:
464
+ if kind2 == 'p' and b2['level']:
465
+ break
466
+ if kind2 == 'tbl' or (kind2 == 'p' and (b2['text'].strip() or b2['n_draw'])):
467
+ has = True
468
+ break
469
+ if not has:
470
+ out.append(_issue('warn', 'structure', 'empty-section', _loc(b),
471
+ f"空章节:「{b['text'][:24]}」标题下没有任何内容",
472
+ '要么补内容,要么删掉这个标题——交付件里的空壳章节最扎眼'))
473
+
474
+ # 编号连续性:**同一父章节下**的同级标题才该逐一递增。
475
+ # 不按父节点分组会把「每章的(一)重新从一开始」误判成断号——那恰恰是正确写法。
476
+ groups, anc = defaultdict(list), {}
477
+ for h in heads:
478
+ lv = h['level']
479
+ parent = tuple(anc.get(i) for i in range(1, lv))
480
+ for i in list(anc):
481
+ if i >= lv:
482
+ del anc[i]
483
+ anc[lv] = h['index']
484
+ n = L.parse_number(h['text'])
485
+ if n:
486
+ groups[(lv, n[0], parent)].append((h, n[1]))
487
+ for (lv, kind, _parent), items in groups.items():
488
+ if len(items) < 2:
489
+ continue
490
+ for (h1, s1), (h2, s2) in zip(items, items[1:]):
491
+ a, b = s1[-1], s2[-1]
492
+ if len(s1) == len(s2) and b != a + 1 and s1[:-1] == s2[:-1]:
493
+ out.append(_issue('error', 'structure', 'number-gap', _loc(h2),
494
+ f"章节编号不连续:{'.'.join(map(str,s1))} 之后是 {'.'.join(map(str,s2))}",
495
+ f"「{h2['text'][:30]}」"))
496
+
497
+ if 'TOC' not in D.xml and 'instrText' not in D.xml:
498
+ out.append(_issue('info', 'structure', 'no-toc', '全文',
499
+ '没有插入目录域', '正式交付件建议插入 TOC 域(Word 中 F9 更新),不要手打静态目录'))
500
+ sec = D.doc.sections[0]
501
+ has_page = any('PAGE' in (p._p.xml or '') for p in list(sec.footer.paragraphs) + list(sec.header.paragraphs))
502
+ if not has_page:
503
+ out.append(_issue('warn', 'structure', 'no-pagenum', '全文', '页脚没有页码域',
504
+ '十几页以上的交付件没有页码,评审时无法引用「第几页」'))
505
+ return out
506
+
507
+
508
+ def check_typography(D, strict=False):
509
+ out = []
510
+ prof, bodies = D.profile, D.bodies()
511
+ if not bodies:
512
+ return out
513
+
514
+ # 页面设置是否合规
515
+ want = prof['margin_cm']
516
+ got = {k: v / L.CM_PT for k, v in D.margin_pt.items()}
517
+ bad = [f"{k} {got[k]:.2f}≠{want[k]:.2f}cm" for k in ('left', 'right', 'top', 'bottom')
518
+ if abs(got[k] - want[k]) > 0.45]
519
+ if bad:
520
+ out.append(_issue('info', 'layout', 'margin', '页面设置',
521
+ f"页边距与「{prof['label']}」不一致:{'、'.join(bad)}",
522
+ '不一致本身不是错,但同一套交付件里几份文档页边距不同会很难看', fixable=True))
523
+
524
+ # 字号一致性:正文里混着几种字号
525
+ sizes = Counter(round(p['size'], 1) for p in bodies)
526
+ main_size, main_n = sizes.most_common(1)[0]
527
+ if len(sizes) > 1:
528
+ others = [f'{L.size_name(s)}×{n}' for s, n in sizes.most_common()[1:6]]
529
+ lvl = 'error' if (main_n / len(bodies)) < 0.9 else 'warn'
530
+ out.append(_issue(lvl, 'layout', 'size-mix', '全文',
531
+ f'正文字号不统一:主用 {L.size_name(main_size)}×{main_n},另有 {"、".join(others)}',
532
+ '多半是从别处粘贴时把源格式带进来了', fixable=True))
533
+ want_size = L.pt_of(prof['body']['size'])
534
+ if abs(main_size - want_size) > 0.6:
535
+ out.append(_issue('info', 'layout', 'size-spec', '全文',
536
+ f'正文字号 {L.size_name(main_size)} 与规范 {prof["body"]["size"]} 不符',
537
+ f"(当前档位:{prof['label']})", fixable=True))
538
+ for p in bodies:
539
+ if len(p['sizes']) > 1:
540
+ out.append(_issue('warn', 'layout', 'size-in-para', _loc(p),
541
+ f"同一段里有 {len(p['sizes'])} 种字号:{'/'.join(L.size_name(s) for s in sorted(p['sizes']))}",
542
+ p['text'][:40], fixable=True))
543
+
544
+ # 东亚字体:只设 font.name 中文不生效,这是 Word 生成类脚本最高频的坑
545
+ miss = [p for p in D.paras if p['text'].strip() and not p['east'] and p['role'] != 'toc']
546
+ if miss:
547
+ out.append(_issue('error', 'layout', 'no-eastasia', _loc(miss[0]),
548
+ f'{len(miss)} 段没有设置东亚字体(w:eastAsia)',
549
+ '中文会退回主题默认字体,换台电脑打开就变样', fixable=True))
550
+ easts = Counter(p['east'] for p in bodies if p['east'])
551
+ if len(easts) > 2:
552
+ out.append(_issue('warn', 'layout', 'font-mix', '全文',
553
+ f"正文中文字体有 {len(easts)} 种:{'、'.join(f'{k}×{v}' for k, v in easts.most_common(4))}",
554
+ '', fixable=True))
555
+
556
+ # 首行缩进:缺失 / 假缩进 / 忽有忽无
557
+ want_ind = L.indent_pt(prof)
558
+ indented = sum(1 for p in bodies if (p['indent'] or 0) / EMU_PT >= want_ind * 0.6)
559
+ fake = [p for p in bodies if p['fake_indent']]
560
+ if fake:
561
+ out.append(_issue('error', 'layout', 'fake-indent', _loc(fake[0]),
562
+ f'{len(fake)} 段用空格顶出首行缩进,不是真缩进',
563
+ '换字号或换设备就会错位;正确做法是段落格式→首行缩进 2 字符', fixable=True))
564
+ if want_ind > 0 and bodies:
565
+ r = indented / len(bodies)
566
+ if 0.15 < r < 0.85:
567
+ out.append(_issue('error', 'layout', 'indent-mix', '全文',
568
+ f'首行缩进时有时无:{indented}/{len(bodies)} 段有缩进({r*100:.0f}%)',
569
+ '这是打印出来最刺眼的一类问题', fixable=True))
570
+ elif r <= 0.15:
571
+ out.append(_issue('warn', 'layout', 'no-indent', '全文',
572
+ f'正文普遍没有首行缩进({indented}/{len(bodies)} 段)',
573
+ '中文正式文档惯例是首行缩进 2 字符', fixable=True))
574
+
575
+ # 行距一致性
576
+ lines = Counter((p['line_rule'], round(float(p['line']), 2) if p['line'] else None) for p in bodies)
577
+ if len(lines) > 2:
578
+ out.append(_issue('warn', 'layout', 'line-mix', '全文',
579
+ f'正文行距有 {len(lines)} 种设置', '同一份文档里行距要一致', fixable=True))
580
+
581
+ # 对齐:正文两端对齐,忽左忽中最难看
582
+ aligns = Counter(p['align'] for p in bodies)
583
+ centered = aligns.get(WD_ALIGN_PARAGRAPH.CENTER, 0)
584
+ if centered and centered < len(bodies) * 0.5:
585
+ out.append(_issue('warn', 'layout', 'align-mix', '全文',
586
+ f'{centered} 段正文被设成了居中', '正文应两端对齐,居中只留给标题与题注', fixable=True))
587
+
588
+ # 连续空段:用回车拉版式,改一个字全篇错位
589
+ run_empty, first = 0, None
590
+ for p in D.paras:
591
+ if p['role'] in ('front', 'toc') or (first and first['role'] in ('front', 'toc')):
592
+ run_empty, first = 0, None
593
+ continue
594
+ if p['empty'] and not p['n_draw']:
595
+ run_empty += 1
596
+ first = first or p
597
+ else:
598
+ if run_empty >= 3:
599
+ out.append(_issue('warn', 'layout', 'blank-runs', _loc(first),
600
+ f'连续 {run_empty} 个空段落', '用「段前/段后间距」或分页符代替连打回车', fixable=True))
601
+ run_empty, first = 0, None
602
+
603
+ # 段末多余空格
604
+ trail = [p for p in bodies if p['text'] != p['text'].rstrip()]
605
+ if trail:
606
+ out.append(_issue('info', 'layout', 'trailing-space', _loc(trail[0]),
607
+ f'{len(trail)} 段结尾有多余空格', '两端对齐时会把最后一行拉散', fixable=True))
608
+
609
+ # 标题过长导致折行(会把目录撑坏)
610
+ for h in [p for p in D.paras if p['level']]:
611
+ st = prof['headings'][min(h['level'], len(prof['headings'])) - 1]
612
+ if L.wrap_count(h['text'], h['size'], D.body_w_pt) > 2:
613
+ out.append(_issue('warn', 'layout', 'heading-wrap', _loc(h),
614
+ f"标题折成 {L.wrap_count(h['text'], h['size'], D.body_w_pt)} 行",
615
+ f"「{h['text'][:34]}」——标题应能一行放下,长的挪到正文首句"))
616
+ if strict:
617
+ for p in bodies:
618
+ if p['bold'] and len(p['text']) > 60:
619
+ out.append(_issue('info', 'layout', 'bold-para', _loc(p),
620
+ '整段加粗(>60 字)', '加粗用于点睛,整段加粗等于没有重点'))
621
+ return out
622
+
623
+
624
+ def check_tables(D):
625
+ out = []
626
+ for t in D.tables:
627
+ if t.get('front'):
628
+ continue # 封面信息栏是排版件不是数据表:无框线、无表头、无题注都是设计
629
+ loc = f"第 {t['index']+1} 张表"
630
+ if t['n_rows'] == 0 or t['n_cols'] == 0:
631
+ out.append(_issue('error', 'table', 'empty-table', loc, '空表格', '', fixable=False))
632
+ continue
633
+ widths = [len(r) for r in t['rows']]
634
+ if len(set(widths)) > 1:
635
+ out.append(_issue('warn', 'table', 'ragged', loc,
636
+ f'各行列数不一致:{sorted(set(widths))}', '通常是合并单元格造成,确认是否为预期'))
637
+ if t['total_pt'] and t['total_pt'] > D.body_w_pt * L.TOL:
638
+ over = t['total_pt'] - D.body_w_pt
639
+ out.append(_issue('error', 'table', 'over-width', loc,
640
+ f"表格宽 {t['total_pt']/L.CM_PT:.2f}cm 超出版心 {D.body_w_pt/L.CM_PT:.2f}cm(超 {over/L.CM_PT:.2f}cm)",
641
+ '打印时右侧列会被裁掉——这是最常见的「表格显示不正常」', fixable=True))
642
+ if t['n_cols'] >= 7 and not D.landscape:
643
+ out.append(_issue('info', 'table', 'many-cols', loc,
644
+ f"{t['n_cols']} 列的表格在纵向 A4 上会很挤",
645
+ '考虑本节改横向页,或拆成两张表'))
646
+ if not t['head_bold'] and t['n_rows'] > 1 and not t['form_like']:
647
+ out.append(_issue('warn', 'table', 'head-not-bold', loc, '表头未加粗', '', fixable=True))
648
+ if t['n_rows'] > 12 and not t['head_repeat']:
649
+ out.append(_issue('warn', 'table', 'no-head-repeat', loc,
650
+ f"{t['n_rows']} 行的长表格未设置「跨页重复标题行」",
651
+ '翻页后读者不知道每列是什么', fixable=True))
652
+ cells = [c for r in t['rows'] for c in r]
653
+ blank = sum(1 for c in cells if not str(c).strip())
654
+ if cells and blank / len(cells) > 0.3:
655
+ out.append(_issue('warn', 'table', 'many-blank', loc,
656
+ f'空单元格占 {blank/len(cells)*100:.0f}%({blank}/{len(cells)})',
657
+ '空着不如填「—」或「不适用」,评审会当成漏填'))
658
+ longest = max((len(str(c)) for c in cells), default=0)
659
+ if longest > 120:
660
+ out.append(_issue('info', 'table', 'long-cell', loc,
661
+ f'最长单元格 {longest} 字', '表格里塞长段落会把行撑得很高,长文本应放正文'))
662
+ if t['style'] in ('Normal Table', 'Table Normal', ''):
663
+ out.append(_issue('warn', 'table', 'no-border', loc,
664
+ '表格未套用带框线的样式', '默认「普通表格」是无框线的,打印出来只有文字', fixable=True))
665
+ out.extend(_check_captions(D, 'tbl'))
666
+ return out
667
+
668
+
669
+ def check_figures(D):
670
+ out = []
671
+ for i, f in enumerate(D.figures):
672
+ loc = f"第 {i+1} 张图(第 {f['para_index']+1} 段)"
673
+ if not f['ok']:
674
+ out.append(_issue('error', 'figure', 'broken', loc, f"图片无法读取:{f['err']}",
675
+ 'Word 里会显示成红叉或空白框'))
676
+ continue
677
+ bw, bh = f.get('sec_w_pt', D.body_w_pt), f.get('sec_h_pt', D.body_h_pt)
678
+ if f['w_pt'] > bw * L.TOL:
679
+ out.append(_issue('error', 'figure', 'over-width', loc,
680
+ f"图宽 {f['w_pt']/L.CM_PT:.2f}cm 超出版心 {bw/L.CM_PT:.2f}cm",
681
+ '右侧会被裁掉', fixable=True))
682
+ if f['px_w'] and f['px_h'] and f['w_pt'] and f['h_pt']:
683
+ nat = f['px_w'] / f['px_h']
684
+ shown = f['w_pt'] / f['h_pt']
685
+ dev = abs(shown - nat) / nat
686
+ if dev > 0.03:
687
+ out.append(_issue('error', 'figure', 'distorted', loc,
688
+ f'图片被拉变形:原始比例 {nat:.3f},显示比例 {shown:.3f}(偏差 {dev*100:.1f}%)',
689
+ '同时给了宽和高就会这样;只给宽度,高度让它自己算', fixable=True))
690
+ dpi_eff = L.effective_dpi(f['px_w'], f['w_pt'] * L.PT_CM)
691
+ if dpi_eff and dpi_eff < 120:
692
+ out.append(_issue('warn', 'figure', 'low-dpi', loc,
693
+ f'有效分辨率仅 {dpi_eff:.0f} dpi({f["px_w"]}px 放到 {f["w_pt"]*L.PT_CM:.1f}cm)',
694
+ '打印会发虚;工程图请用 render_diagram 重出矢量再转高清 PNG'))
695
+ if f['w_pt'] * L.PT_CM < 4 and f['px_w'] > 1400:
696
+ out.append(_issue('info', 'figure', 'tiny', loc,
697
+ f"图只显示 {f['w_pt']*L.PT_CM:.1f}cm 宽,里面的字会看不清", ''))
698
+ if f['h_pt'] > bh:
699
+ out.append(_issue('warn', 'figure', 'over-height', loc,
700
+ f"图高 {f['h_pt']/L.CM_PT:.1f}cm 超过版心高 {bh/L.CM_PT:.1f}cm",
701
+ '会独占一页并留下大片空白', fixable=True))
702
+ if f['anchor']:
703
+ out.append(_issue('info', 'figure', 'floating', loc,
704
+ '浮动图片(环绕方式非嵌入型)', '编辑正文时会乱跑;技术文档建议改成「嵌入型」'))
705
+ out.extend(_check_captions(D, 'fig'))
706
+ out.extend(_check_refs(D))
707
+ return out
708
+
709
+
710
+ def _caption_paras(D, kind):
711
+ """找出图/表的题注段:图题在图的下一段,表题在表的上一段(中文出版惯例)。"""
712
+ want = D.profile['fig_prefix'] if kind == 'fig' else D.profile['tab_prefix']
713
+ caps = []
714
+ for p in D.paras:
715
+ m = L.CAPTION_RE.match(p['text'].strip())
716
+ if m and m.group(1).lower().startswith(('图', 'fig') if kind == 'fig' else ('表', 'tab')):
717
+ caps.append((p, m.group(2), m.group(3)))
718
+ return caps, want
719
+
720
+
721
+ def _check_captions(D, kind):
722
+ out = []
723
+ caps, want = _caption_paras(D, kind)
724
+ n_obj = (len(D.figures) if kind == 'fig'
725
+ else sum(1 for t in D.tables if not t.get('front')))
726
+ label = '插图' if kind == 'fig' else '表格'
727
+ if n_obj and not caps:
728
+ out.append(_issue('warn', 'caption', f'{kind}-no-caption', '全文',
729
+ f'{n_obj} 个{label}全部没有题注',
730
+ f'技术文档的{label}必须有「{want} X 名称」,否则正文无法引用'))
731
+ return out
732
+ if n_obj and len(caps) < n_obj:
733
+ out.append(_issue('warn', 'caption', f'{kind}-caption-missing', '全文',
734
+ f'{n_obj} 个{label}只有 {len(caps)} 条题注',
735
+ '题注文字要人来写,fix 只能重排已有题注的编号'))
736
+ seq = []
737
+ for p, num, _t in caps:
738
+ parts = re.split(r'[-–.]', num)
739
+ try:
740
+ seq.append((p, tuple(int(x) for x in parts)))
741
+ except ValueError:
742
+ continue
743
+ for (p1, s1), (p2, s2) in zip(seq, seq[1:]):
744
+ if len(s1) != len(s2):
745
+ continue
746
+ if s1[:-1] == s2[:-1] and s2[-1] != s1[-1] + 1:
747
+ out.append(_issue('error', 'caption', f'{kind}-number-gap', _loc(p2),
748
+ f"{label}编号不连续:{want} {'-'.join(map(str,s1))} 之后是 {want} {'-'.join(map(str,s2))}",
749
+ f"「{p2['text'][:30]}」", fixable=True))
750
+ for p, num, title in caps:
751
+ if not title.strip():
752
+ out.append(_issue('warn', 'caption', f'{kind}-no-title', _loc(p),
753
+ f'{label}题注只有编号没有名称', p['text'][:30]))
754
+ if p['align'] != WD_ALIGN_PARAGRAPH.CENTER and not NOTE_RE.match(p['text']):
755
+ out.append(_issue('info', 'caption', f'{kind}-align', _loc(p),
756
+ f'{label}题注未居中', p['text'][:30], fixable=True))
757
+ return out
758
+
759
+
760
+ def _check_refs(D):
761
+ """正文有没有引用图表——「插图精准无误」的第一层:图放了但正文一次没提,等于没放。"""
762
+ out = []
763
+ body = '\n'.join(p['text'] for p in D.paras if p['kind'] == 'body')
764
+ refs = {(m.group(1), m.group(2)) for m in L.REF_RE.finditer(body)}
765
+ for kind, label in (('fig', '图'), ('tbl', '表')):
766
+ caps, want = _caption_paras(D, kind)
767
+ nums = {c[1] for c in caps}
768
+ if not nums:
769
+ continue
770
+ unref = sorted(n for n in nums if (label, n) not in refs)
771
+ if unref and len(unref) == len(nums):
772
+ out.append(_issue('warn', 'caption', f'{kind}-unreferenced', '全文',
773
+ f'{len(unref)} 个{label}在正文中从未被引用',
774
+ f'正文应有「如{label} {unref[0]} 所示」之类的指引,'
775
+ f'否则读者不知道该在哪一段看这张{label}。用 create_doc 生成时不必自己数编号:'
776
+ f'题注末尾加 {{#{kind}:budget}}(标签只用英文字母、数字),正文写「如@{kind}:budget 所示」,'
777
+ f'成稿时自动换成真实编号'))
778
+ elif unref:
779
+ out.append(_issue('info', 'caption', f'{kind}-unreferenced', '全文',
780
+ f"正文未引用:{'、'.join(f'{label} {n}' for n in unref[:6])}", ''))
781
+ dangling = sorted(n for (lb, n) in refs if lb == label and n not in nums)
782
+ if dangling:
783
+ out.append(_issue('error', 'caption', f'{kind}-dangling-ref', '全文',
784
+ f"正文引用了不存在的{label}:{'、'.join(f'{label} {n}' for n in dangling[:6])}",
785
+ '删图/换图后忘了改正文,是改稿最常见的遗留'))
786
+ return out
787
+
788
+
789
+ def check_text(D, strict=False):
790
+ paras = [{'text': p['text'], 'kind': p['kind'], 'loc': _loc(p), 'index': p['index']}
791
+ for p in D.paras if p['text'].strip() and p['role'] != 'toc']
792
+ for t in D.tables:
793
+ for r in t['rows']:
794
+ for c in r:
795
+ if str(c).strip():
796
+ paras.append({'text': str(c), 'kind': 'cell',
797
+ 'loc': f"第 {t['index']+1} 张表", 'index': 10_000 + t['index']})
798
+ return P.check(paras, strict=strict)
799
+
800
+
801
+ # ── 报告型文档的内容硬指标(可研 / 建议书 / 立项 / 任务书 / 实施计划 / 申报书)──────────────
802
+ # 前面几族查「做得像不像一份合格的 Word」,这一族查「评审会不会一票否决」。都是从真实翻车里来的:
803
+ # · 可研写于 2026 年 9 月,进度计划却是 2025-10 ~ 2026-09——计划整体落在编制日期之前,
804
+ # 说明把「已经做完的事」当成了「打算做的事」,立项论证的前提就不成立了;
805
+ # · 可研里一张数据图、一个 NPV 都没有,财务评价只有一句「具有良好的经济效益」。
806
+ # 只对「向前看」的文档判时态;总结/验收/周报本来就写过去,不判。
807
+ FORWARD_DOC = re.compile(r'可行性研究|可研报告|项目建议书|立项申请|任务书|实施计划|实施方案|申报书|项目申请书|商业计划书')
808
+ FEAS_DOC = re.compile(r'可行性研究|可研报告')
809
+ YM_RE = re.compile(r'(20\d{2})\s*(?:年\s*(\d{1,2})\s*月|[-./](\d{1,2})(?!\d))')
810
+ DOC_DATE_LABEL = re.compile(r'编制(?:时间|日期)|编写日期|报告日期|提交日期|申报日期')
811
+ SCHED_HEAD = re.compile(r'进度|年度计划|实施计划|里程碑|计划安排|实施安排|工期安排')
812
+ PERIOD_LABEL = re.compile(r'(建设|实施|研发|项目|执行)(周期|期限)|起止(时间|日期)')
813
+
814
+
815
+ def _ym_all(text):
816
+ out = []
817
+ for m in YM_RE.finditer(str(text or '')):
818
+ mo = int(m.group(2) or m.group(3))
819
+ if 1 <= mo <= 12:
820
+ out.append((int(m.group(1)), mo))
821
+ return out
822
+
823
+
824
+ def _doc_title(D):
825
+ texts = [p['text'].strip() for p in D.paras[:12] if p['text'].strip()]
826
+ return ' '.join(texts[:4]) + ' ' + os.path.basename(D.path)
827
+
828
+
829
+ def _doc_date(D):
830
+ """编制日期:封面信息表「编制时间:2026-09-23」优先,其次前 60 段里的同类标签。
831
+ 不用 core_properties.created——python-docx 生成的文件那里是模板自带的 2013 年。"""
832
+ for t in D.tables[:3]:
833
+ for r in t['rows']:
834
+ for i, c in enumerate(r[:-1]):
835
+ if DOC_DATE_LABEL.search(c or ''):
836
+ ym = _ym_all(r[i + 1])
837
+ if ym:
838
+ return ym[0]
839
+ for p in D.paras[:60]:
840
+ m = DOC_DATE_LABEL.search(p['text'])
841
+ if m:
842
+ ym = _ym_all(p['text'][m.end():])
843
+ if ym:
844
+ return ym[0]
845
+ return None
846
+
847
+
848
+ def _schedule_dates(D):
849
+ """进度章节(含其子节与表格)里的年月,加上封面「建设周期:…(2025 年 10 月至 2026 年 9 月)」。"""
850
+ dates, lvl = [], None
851
+ for kind, b in D.blocks:
852
+ if kind == 'p':
853
+ if b['level']:
854
+ if lvl is not None and b['level'] <= lvl:
855
+ lvl = None
856
+ if lvl is None and SCHED_HEAD.search(b['text']):
857
+ lvl = b['level']
858
+ continue
859
+ if lvl is not None:
860
+ dates += _ym_all(b['text'])
861
+ elif lvl is not None:
862
+ for r in b['rows']:
863
+ for c in r:
864
+ dates += _ym_all(c)
865
+ for t in D.tables[:3]:
866
+ for r in t['rows']:
867
+ for i, c in enumerate(r[:-1]):
868
+ if PERIOD_LABEL.search(c or ''):
869
+ dates += _ym_all(r[i + 1])
870
+ return dates
871
+
872
+
873
+ REPORT_EXPECT = [
874
+ ('系统总体/技术架构图', 'fig', r'架构'),
875
+ ('功能模块图(功能结构图)', 'fig', r'功能|模块|组成'),
876
+ ('实施进度图或进度计划表', 'any', r'进度|甘特|里程碑|计划'),
877
+ ('投资估算/经费预算表', 'tbl', r'投资|经费|预算|资金'),
878
+ ('现金流量/财务评价表', 'any', r'现金流|财务|效益|评价指标'),
879
+ ('敏感性分析图表', 'any', r'敏感性'),
880
+ ('风险分析表或风险矩阵', 'any', r'风险'),
881
+ ('方案比选表', 'tbl', r'比选|对比|比较'),
882
+ ]
883
+ FIN_TERMS = [('财务净现值(NPV)', r'净现值|NPV'), ('财务内部收益率(IRR)', r'内部收益率|IRR'),
884
+ ('投资回收期', r'回收期'), ('敏感性分析', r'敏感性')]
885
+
886
+
887
+ PENDING_RE = re.compile(r'[[\[【((][^]\]】))]{0,8}?(?:待补|待填|待定|待确认|据实填写)[^]\]】))]{0,40}[]\]】))]')
888
+
889
+
890
+ def check_pending(D):
891
+ """「[待补:项目负责人]」这类有意留的空:不算错,但交付时必须逐条交代给用户——
892
+ 提示级列出来,免得它悄悄留在正式稿里。"""
893
+ hits = [m.group(0) for p in D.paras for m in PENDING_RE.finditer(p['text'])]
894
+ hits += [m.group(0) for t in D.tables for r in t['rows'] for c in r for m in PENDING_RE.finditer(str(c or ''))]
895
+ if not hits:
896
+ return []
897
+ uniq = list(dict.fromkeys(hits))
898
+ shown = '、'.join(uniq[:8]) + ('…' if len(uniq) > 8 else '')
899
+ return [_issue('info', 'content', 'pending-info', '全文', f'文中有 {len(hits)} 处待补信息:{shown}',
900
+ '交付前由用户补齐;交付说明里逐条列出')]
901
+
902
+
903
+ def check_report(D):
904
+ title = _doc_title(D)
905
+ if not FORWARD_DOC.search(title):
906
+ return []
907
+ out = []
908
+ feas = bool(FEAS_DOC.search(title))
909
+ # ① 时态:计划不能整体落在编制日期之前
910
+ doc_ym, dates = _doc_date(D), _schedule_dates(D)
911
+ if doc_ym and dates:
912
+ fmt = lambda ym: f'{ym[0]} 年 {ym[1]} 月'
913
+ past = [d for d in dates if d < doc_ym]
914
+ if max(dates) <= doc_ym:
915
+ out.append(_issue('error', 'content', 'plan-in-past', '进度安排',
916
+ f'进度计划整体早于编制时间:编制于 {fmt(doc_ym)},计划节点最晚 {fmt(max(dates))}',
917
+ '立项论证写的是「打算怎么做」:计划从编制之后排;已经完成的工作写进「现有工作基础」,'
918
+ '别把做完的事当计划写'))
919
+ elif len(past) >= max(2, len(dates) / 2):
920
+ out.append(_issue('warn', 'content', 'plan-mostly-past', '进度安排',
921
+ f'半数以上计划节点({len(past)}/{len(dates)})早于编制时间 {fmt(doc_ym)}',
922
+ '已发生的部分改写成「进展/现状」,计划部分从编制之后排'))
923
+ if not feas:
924
+ return out
925
+ # ② 可研该有的图表
926
+ fig_caps = [c[0]['text'] for c in _caption_paras(D, 'fig')[0]]
927
+ tbl_caps = [c[0]['text'] for c in _caption_paras(D, 'tbl')[0]]
928
+ miss = []
929
+ # 一个题注都没有的文档,按题注判「缺哪类图表」就是在瞎猜(表可能在,只是没题注)——
930
+ # 那种情况由题注检查(fig/tbl-no-caption)去报,这里不重复下一个站不住的结论。
931
+ for label, where, pat in (REPORT_EXPECT if (fig_caps or tbl_caps) else []):
932
+ pool = fig_caps if where == 'fig' else tbl_caps if where == 'tbl' else fig_caps + tbl_caps
933
+ if not any(re.search(pat, c) for c in pool):
934
+ miss.append(label)
935
+ if miss:
936
+ out.append(_issue('warn', 'content', 'report-visuals', '全文',
937
+ f'可研报告缺少评审常看的图表:{"、".join(miss)}',
938
+ '结构图用 render_diagram(架构/功能模块/部署/流程),数据图用 render_chart(甘特/投资构成/'
939
+ '累计现金流/敏感性龙卷风/风险矩阵),财务表与敏感性用 fin_model 一次算出'))
940
+ # ③ 财务评价指标
941
+ full = '\n'.join(p['text'] for p in D.paras) + '\n' + '\n'.join(' '.join(map(str, r)) for t in D.tables for r in t['rows'])
942
+ if not re.search(r'费用效果|非经营性', full):
943
+ lack = [lab for lab, pat in FIN_TERMS if not re.search(pat, full)]
944
+ if lack:
945
+ out.append(_issue('warn', 'content', 'report-finance', '经济效益',
946
+ f'财务评价缺少:{"、".join(lack)}',
947
+ '用 fin_model 按《建设项目经济评价方法与参数》口径测算(回收期基于净现金流、含建设期),'
948
+ '它会同时给出现金流量表、评价指标表、敏感性表与结论句;非经营性项目改写费用效果分析'))
949
+ return out
950
+
951
+
952
+ # ── 三、自动校正 ───────────────────────────────────────────────────────────
953
+ def _set_font(run, west, east, size=None, bold=None):
954
+ run.font.name = west
955
+ rpr = run._element.get_or_add_rPr()
956
+ rf = rpr.get_or_add_rFonts()
957
+ rf.set(qn('w:eastAsia'), east)
958
+ rf.set(qn('w:ascii'), west)
959
+ rf.set(qn('w:hAnsi'), west)
960
+ if size is not None:
961
+ run.font.size = Pt(size)
962
+ if bold is not None:
963
+ run.font.bold = bold
964
+
965
+
966
+ def _replace_same_length(para, new_text):
967
+ """把整段文本替换成等长的新文本,逐 run 按偏移回填——长度相等才能保住原有格式。"""
968
+ if len(new_text) != len(para.text) or new_text == para.text:
969
+ return False
970
+ i = 0
971
+ for r in para.runs:
972
+ n = len(r.text)
973
+ if n:
974
+ r.text = new_text[i:i + n]
975
+ i += n
976
+ return True
977
+
978
+
979
+ HALF2FULL = [(re.compile(rf'([{P.CJK}]),(?![\d])'), ','), (re.compile(rf'([{P.CJK}]);'), ';'),
980
+ (re.compile(rf'([{P.CJK}]):(?![\d/\\])'), ':'), (re.compile(rf'([{P.CJK}])\?'), '?'),
981
+ (re.compile(rf'([{P.CJK}])!'), '!')]
982
+
983
+
984
+ def fix(path, out_path=None, profile_name='auto', inplace=False):
985
+ """修可确定性修的那几类,另存后复检。返回 (out_path, 修复计数, 复检报告)。"""
986
+ D = Doc(path, profile_name)
987
+ prof = D.profile
988
+ done = Counter()
989
+ body_size = L.pt_of(prof['body']['size'])
990
+ bodies = D.bodies()
991
+ sizes = Counter(round(p['size'], 1) for p in bodies)
992
+ main_size = sizes.most_common(1)[0][0] if sizes else body_size
993
+ want_ind = L.indent_pt(prof)
994
+ indented = sum(1 for p in bodies if (p['indent'] or 0) / EMU_PT >= want_ind * 0.6)
995
+ unify_indent = bool(bodies) and want_ind > 0 and indented / len(bodies) > 0.15
996
+
997
+ for p in D.paras:
998
+ para, txt = p['para'], p['text']
999
+ if not txt.strip():
1000
+ continue
1001
+ if p['role'] in ('front', 'toc'):
1002
+ # 封面与目录有自己的版式,不套正文规范;但**中文字体缺失是全文通病**,这里也要补。
1003
+ if not p['east']:
1004
+ for r in para.runs:
1005
+ rpr = r._element.get_or_add_rPr()
1006
+ rpr.get_or_add_rFonts().set(qn('w:eastAsia'), prof['body']['east'])
1007
+ done['字体'] += 1
1008
+ continue
1009
+ st = (prof['headings'][min(p['level'], len(prof['headings'])) - 1] if p['level']
1010
+ else prof['caption'] if p['role'] == 'caption' else prof['body'])
1011
+ east = st.get('east', prof['body']['east'])
1012
+ west = st.get('west', prof['body']['west'])
1013
+ # 1) 东亚字体缺失 → 补齐(不改已经设对的)
1014
+ if not p['east']:
1015
+ for r in para.runs:
1016
+ _set_font(r, west, east)
1017
+ done['字体'] += 1
1018
+ # 2) 段内字号混杂 → 归一到该段主字号
1019
+ if len(p['sizes']) > 1:
1020
+ target = max(p['sizes'], key=lambda s: sum(len(r.text) for r in para.runs
1021
+ if abs(_eff_size(r, para, D.doc) - s) < 0.1))
1022
+ for r in para.runs:
1023
+ r.font.size = Pt(target)
1024
+ done['字号'] += 1
1025
+ # 3) 假缩进(空格顶出来的)→ 删空格 + 设真缩进
1026
+ if p['fake_indent'] and para.runs:
1027
+ new = re.sub(r'^[  ]+', '', txt)
1028
+ for r in para.runs:
1029
+ if r.text.strip() or re.match(r'^[  ]+$', r.text):
1030
+ stripped = re.sub(r'^[  ]+', '', r.text)
1031
+ if stripped != r.text:
1032
+ r.text = stripped
1033
+ break
1034
+ done['假缩进'] += 1
1035
+ # 4) 正文首行缩进补齐
1036
+ if p['role'] == 'body' and unify_indent and (p['indent'] or 0) / EMU_PT < want_ind * 0.6:
1037
+ para.paragraph_format.first_line_indent = Pt(L.pt_of(st['size']) * st.get('first_indent_chars', 2))
1038
+ done['首行缩进'] += 1
1039
+ # 5) 半角标点 → 全角(等长替换,格式不丢)
1040
+ # 注意读**实时** para.text:上一步删假缩进已经改过段落内容,
1041
+ # 再拿解析时缓存的 txt 去比长度,等长判定必然落空,标点就悄悄没修——
1042
+ # 这类「前一步改了状态、后一步还用旧快照」的顺序坑,只有跑闭环才测得出来。
1043
+ live = para.text
1044
+ new = live
1045
+ for pat, ch in HALF2FULL:
1046
+ new = pat.sub(lambda m, c=ch: m.group(1) + c, new)
1047
+ if new != live and _replace_same_length(para, new):
1048
+ done['标点'] += 1
1049
+ # 6) 段末多余空格
1050
+ if para.runs and para.runs[-1].text != para.runs[-1].text.rstrip():
1051
+ para.runs[-1].text = para.runs[-1].text.rstrip()
1052
+ done['行尾空格'] += 1
1053
+ # 7) 正文误居中 → 两端对齐
1054
+ if p['role'] == 'body' and p['align'] == WD_ALIGN_PARAGRAPH.CENTER:
1055
+ para.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY
1056
+ done['对齐'] += 1
1057
+ # 8) 题注居中
1058
+ if p['role'] == 'caption' and not NOTE_RE.match(p['text']) and p['align'] != WD_ALIGN_PARAGRAPH.CENTER:
1059
+ para.alignment = WD_ALIGN_PARAGRAPH.CENTER
1060
+ done['题注居中'] += 1
1061
+
1062
+ # 9) 正文字号归一(只在混杂比例明显时做,避免把有意的小字注释一起改了)
1063
+ if len(sizes) > 1 and sizes.most_common(1)[0][1] / max(sum(sizes.values()), 1) >= 0.6:
1064
+ for p in bodies:
1065
+ # 门槛与 check_typography 的 size-mix 保持一致:那边报了这边就得能修,
1066
+ # 否则用户会看到「体检说不统一、修复说无可修」这种自相矛盾。
1067
+ if abs(p['size'] - main_size) > 0.6:
1068
+ for r in p['para'].runs:
1069
+ r.font.size = Pt(main_size)
1070
+ done['字号统一'] += 1
1071
+
1072
+ # 10) 连续空段收成一个
1073
+ empties, kill = [], []
1074
+ for p in D.paras:
1075
+ if p['role'] in ('front', 'toc'):
1076
+ empties = []
1077
+ continue
1078
+ if p['empty'] and not p['n_draw']:
1079
+ empties.append(p)
1080
+ else:
1081
+ if len(empties) >= 3:
1082
+ kill.extend(empties[1:])
1083
+ empties = []
1084
+ if len(empties) >= 3:
1085
+ kill.extend(empties[1:])
1086
+ for p in kill:
1087
+ el = p['para']._p
1088
+ el.getparent().remove(el)
1089
+ done['空段'] += 1
1090
+
1091
+ # 11) 表格:加框线样式 / 表头加粗 / 长表跨页重复标题行 / 超版心缩宽
1092
+ for t in D.tables:
1093
+ if t.get('front'):
1094
+ continue
1095
+ tb = t['tbl']
1096
+ if t['style'] in ('Normal Table', 'Table Normal', ''):
1097
+ try:
1098
+ tb.style = 'Table Grid'
1099
+ done['表格框线'] += 1
1100
+ except Exception:
1101
+ pass
1102
+ if not t['head_bold'] and t['n_rows'] > 1 and not t['form_like']:
1103
+ for c in tb.rows[0].cells:
1104
+ for par in c.paragraphs:
1105
+ for r in par.runs:
1106
+ r.font.bold = True
1107
+ done['表头加粗'] += 1
1108
+ if t['n_rows'] > 12 and not t['head_repeat']:
1109
+ tr = tb.rows[0]._tr
1110
+ trpr = tr.get_or_add_trPr()
1111
+ th = OxmlElement('w:tblHeader')
1112
+ th.set(qn('w:val'), 'true')
1113
+ trpr.append(th)
1114
+ done['跨页表头'] += 1
1115
+ if t['total_pt'] and t['total_pt'] > D.body_w_pt * L.TOL:
1116
+ widths = L.fit_table_widths(t['rows'], prof, prof['table']['size'])
1117
+ widths = [w * (D.body_w_pt * L.PT_CM) / max(sum(widths), 1e-6) for w in widths]
1118
+ try:
1119
+ tb.autofit = False
1120
+ for j, w in enumerate(widths):
1121
+ for row in tb.rows:
1122
+ if j < len(row.cells):
1123
+ row.cells[j].width = Cm(round(w, 3))
1124
+ grid = tb._tbl.tblGrid
1125
+ if grid is not None:
1126
+ for gc, w in zip(grid.findall(qn('w:gridCol')), widths):
1127
+ gc.set(qn('w:w'), str(int(w * L.CM_PT * 20)))
1128
+ done['表格缩宽'] += 1
1129
+ except Exception:
1130
+ pass
1131
+
1132
+ # 12) 插图:恢复原始比例 + 缩回版心
1133
+ for i, f in enumerate(D.figures):
1134
+ if not f['ok']:
1135
+ continue
1136
+ try:
1137
+ sh = D.doc.inline_shapes[i]
1138
+ except (IndexError, KeyError):
1139
+ continue
1140
+ w_pt, h_pt = f['w_pt'], f['h_pt']
1141
+ if f['px_w'] and f['px_h'] and w_pt and h_pt:
1142
+ nat = f['px_w'] / f['px_h']
1143
+ if abs((w_pt / h_pt) - nat) / nat > 0.03:
1144
+ h_pt = w_pt / nat
1145
+ done['图片比例'] += 1
1146
+ bw, bh = f.get('sec_w_pt', D.body_w_pt), f.get('sec_h_pt', D.body_h_pt) # 横排页按横排版心算
1147
+ if w_pt > bw:
1148
+ scale = bw / w_pt
1149
+ w_pt, h_pt = w_pt * scale, h_pt * scale
1150
+ done['图片缩宽'] += 1
1151
+ if h_pt > bh * 0.92:
1152
+ scale = (bh * 0.92) / h_pt
1153
+ w_pt, h_pt = w_pt * scale, h_pt * scale
1154
+ done['图片缩高'] += 1
1155
+ if abs(w_pt - f['w_pt']) > 0.5 or abs(h_pt - f['h_pt']) > 0.5:
1156
+ sh.width = Emu(int(w_pt * EMU_PT))
1157
+ sh.height = Emu(int(h_pt * EMU_PT))
1158
+
1159
+ # 13) 图表题注重排编号
1160
+ done += _renumber_captions(D)
1161
+
1162
+ if not out_path:
1163
+ base, ext = os.path.splitext(path)
1164
+ out_path = path if inplace else base + '-已校正' + ext
1165
+ D.doc.save(out_path)
1166
+ return out_path, done
1167
+
1168
+
1169
+ def _renumber_captions(D):
1170
+ done = Counter()
1171
+ for kind in ('fig', 'tbl'):
1172
+ caps, want = _caption_paras(D, kind)
1173
+ by_chap = defaultdict(int)
1174
+ n = 0
1175
+ for p, num, title in caps:
1176
+ if D.profile.get('caption_by_chapter'):
1177
+ by_chap[p['chapter']] += 1
1178
+ new_num = f"{p['chapter']}-{by_chap[p['chapter']]}"
1179
+ else:
1180
+ n += 1
1181
+ new_num = str(n)
1182
+ if new_num == num:
1183
+ continue
1184
+ old = p['text']
1185
+ new = L.CAPTION_RE.sub(lambda m: f"{m.group(1)} {new_num} {m.group(3)}".rstrip(), old.strip())
1186
+ if p['para'].runs:
1187
+ for r in p['para'].runs[1:]:
1188
+ r.text = ''
1189
+ p['para'].runs[0].text = new
1190
+ done['题注编号'] += 1
1191
+ return done
1192
+
1193
+
1194
+ # ── 四、汇总与报告 ─────────────────────────────────────────────────────────
1195
+ LEVEL_LABEL = {'error': '必修', 'warn': '建议', 'info': '提示'}
1196
+ GROUP_LABEL = {'structure': '结构', 'layout': '排版', 'table': '表格', 'figure': '插图', 'content': '内容',
1197
+ 'caption': '图表题注', 'text': '文字', 'punct': '标点', 'ai': 'AI 味',
1198
+ 'consistency': '一致性'}
1199
+ ORDER = {'error': 0, 'warn': 1, 'info': 2}
1200
+
1201
+
1202
+ def inspect(path, profile_name='auto', strict=False):
1203
+ D = Doc(path, profile_name)
1204
+ issues = []
1205
+ issues += check_structure(D)
1206
+ issues += check_typography(D, strict)
1207
+ issues += check_tables(D)
1208
+ issues += check_figures(D)
1209
+ issues += check_text(D, strict)
1210
+ issues += check_report(D)
1211
+ issues += check_pending(D)
1212
+ counts = Counter(i['level'] for i in issues)
1213
+ return {
1214
+ 'path': path,
1215
+ 'profile': D.profile['name'],
1216
+ 'profile_label': D.profile['label'],
1217
+ 'stats': {'paragraphs': len(D.paras), 'headings': sum(1 for p in D.paras if p['level']),
1218
+ 'tables': len(D.tables), 'figures': len(D.figures),
1219
+ 'chars': sum(len(p['text']) for p in D.paras), 'pages_est': D.est_pages(),
1220
+ 'body_width_cm': round(D.body_w_pt / L.CM_PT, 2)},
1221
+ 'counts': {'error': counts.get('error', 0), 'warn': counts.get('warn', 0),
1222
+ 'info': counts.get('info', 0)},
1223
+ 'issues': issues,
1224
+ }
1225
+
1226
+
1227
+ def report(res, limit=5, show_info=True):
1228
+ s, c = res['stats'], res['counts']
1229
+ lines = [f"【文档体检】{os.path.basename(res['path'])} · {res['profile_label']}",
1230
+ f" 规模:{s['paragraphs']} 段 / {s['headings']} 标题 / {s['tables']} 表 / "
1231
+ f"{s['figures']} 图 / {s['chars']} 字 / 约 {s['pages_est']} 页(版心 {s['body_width_cm']}cm)",
1232
+ f" 结论:必修 {c['error']} · 建议 {c['warn']} · 提示 {c['info']}"]
1233
+ grouped = defaultdict(list)
1234
+ for it in res['issues']:
1235
+ grouped[(it['level'], it['group'], it['kind'])].append(it)
1236
+ for key in sorted(grouped, key=lambda k: (ORDER[k[0]], k[1])):
1237
+ lvl, grp, _ = key
1238
+ if lvl == 'info' and not show_info:
1239
+ continue
1240
+ items = grouped[key]
1241
+ head = f" [{LEVEL_LABEL[lvl]}·{GROUP_LABEL.get(grp, grp)}] {items[0]['msg']}"
1242
+ if len(items) > 1:
1243
+ head += f'({len(items)} 处)'
1244
+ if items[0].get('fixable'):
1245
+ head += ' ⟨可自动修⟩'
1246
+ lines.append(head)
1247
+ for it in items[:limit]:
1248
+ d = f" — {it['detail']}" if it.get('detail') else ''
1249
+ if it['loc'] and it['loc'] != '全文':
1250
+ lines.append(f" · {it['loc']}{d}")
1251
+ elif d:
1252
+ lines.append(f" ·{d}")
1253
+ if len(items) > limit:
1254
+ lines.append(f' · …另有 {len(items)-limit} 处')
1255
+ if not res['issues']:
1256
+ lines.append(' 未发现问题。')
1257
+ return '\n'.join(lines)
1258
+
1259
+
1260
+ def main(argv):
1261
+ args = [a for a in argv if not a.startswith('--')]
1262
+ if not args:
1263
+ print('用法:python docx_check.py 文档.docx [--fix] [--inplace] [--strict] '
1264
+ '[--profile tech|gov|report] [--json]')
1265
+ return 2
1266
+ path = args[0]
1267
+ prof = 'auto'
1268
+ for a in argv:
1269
+ if a.startswith('--profile'):
1270
+ prof = a.split('=', 1)[1] if '=' in a else (args[1] if len(args) > 1 else 'auto')
1271
+ strict = '--strict' in argv
1272
+ if not os.path.exists(path):
1273
+ print(f'文件不存在:{path}')
1274
+ return 2
1275
+ if os.path.isdir(path):
1276
+ print('目录不是单份文档:整套项目文档的档案体检请用 tech-doc/scripts/archive_check.py,或 docx_inspect 直接传目录')
1277
+ return 2
1278
+ if path.lower().endswith('.doc'):
1279
+ # 排版体检要的是 docx 里的字体、缩进、列宽这些**格式事实**,
1280
+ # 旧版 .doc 是另一种二进制容器,取不到——甩一个 python-docx 的
1281
+ # 「is not a Word file」堆栈只会让人以为文件坏了。
1282
+ print(f'{os.path.basename(path)} 是旧版二进制 .doc,排版体检需要 .docx:\n'
1283
+ ' · 用 Word/WPS 另存为 .docx 后重跑,可拿到完整的七类排版体检;\n'
1284
+ ' · 只想查文字与 AI 味的话,先转成纯文本再传(.md/.txt 也支持);\n'
1285
+ ' · 整套归档体检(archive_check)已能读 .doc 的正文,不受此限。')
1286
+ return 2
1287
+ res = inspect(path, prof, strict)
1288
+ if '--json' in argv:
1289
+ print(json.dumps(res, ensure_ascii=False, default=str))
1290
+ return 0
1291
+ print(report(res))
1292
+ if '--fix' in argv:
1293
+ out, done = fix(path, None, prof, inplace='--inplace' in argv)
1294
+ print('\n【自动校正】' + ('、'.join(f'{k} {v} 处' for k, v in done.items()) if done else '无可自动修复项'))
1295
+ print(f' 已另存:{out}' if not '--inplace' in argv else f' 已就地修改:{out}')
1296
+ res2 = inspect(out, prof, strict)
1297
+ print('\n' + report(res2))
1298
+ rest = [i for i in res2['issues'] if i['level'] == 'error']
1299
+ if rest:
1300
+ print(f"\n 仍有 {len(rest)} 项必修需要人来定:" +
1301
+ '、'.join(sorted({i['msg'][:20] for i in rest})[:4]))
1302
+ return 0
1303
+
1304
+
1305
+ if __name__ == '__main__':
1306
+ sys.exit(main(sys.argv[1:]))