@huicai/cli 2026.814.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 (485) hide show
  1. package/LICENSE +391 -32
  2. package/README.md +137 -8
  3. package/bin/huicai.js +146 -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 -1
  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 -1
  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 -1
  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 -1
  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 -1
  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 -1
  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/visual.js +1 -1
  450. package/src/voice-form.js +1 -0
  451. package/src/voice.js +1 -0
  452. package/src/watch.js +1 -1
  453. package/src/web-audit.js +1 -0
  454. package/src/web-cdp.js +1 -0
  455. package/src/web-inspect.js +1 -0
  456. package/src/websearch.js +1 -0
  457. package/src/weekly.js +1 -0
  458. package/src/workflow.js +1 -0
  459. package/src/worklog.js +1 -0
  460. package/src/xlsx-tools.js +1 -0
  461. package/ui/command-picker.js +79 -0
  462. package/skills/ab-testing/scripts/__pycache__/ab_test.cpython-312.pyc +0 -0
  463. package/skills/accounting-ops/scripts/__pycache__/acct_tools.cpython-312.pyc +0 -0
  464. package/skills/anomaly-detection/scripts/__pycache__/anomaly.cpython-312.pyc +0 -0
  465. package/skills/audit-assist/scripts/__pycache__/audit_sampling.cpython-312.pyc +0 -0
  466. package/skills/banking-credit/scripts/__pycache__/credit_metrics.cpython-312.pyc +0 -0
  467. package/skills/data-report-pro/scripts/__pycache__/bigdata.cpython-312.pyc +0 -0
  468. package/skills/data-wrangling/scripts/__pycache__/join_tools.cpython-312.pyc +0 -0
  469. package/skills/ecommerce-ops/scripts/__pycache__/gmv_decompose.cpython-312.pyc +0 -0
  470. package/skills/funnel-cohort/scripts/__pycache__/funnel.cpython-312.pyc +0 -0
  471. package/skills/management-accounting/scripts/__pycache__/costing.cpython-312.pyc +0 -0
  472. package/skills/manufacturing-quality/scripts/__pycache__/spc.cpython-312.pyc +0 -0
  473. package/skills/marketing-analytics/scripts/__pycache__/rfm.cpython-312.pyc +0 -0
  474. package/skills/optimization-solver/scripts/__pycache__/solver.cpython-312.pyc +0 -0
  475. package/skills/pivot-crosstab/scripts/__pycache__/pivot_tools.cpython-312.pyc +0 -0
  476. package/skills/portfolio-analysis/scripts/__pycache__/portfolio.cpython-312.pyc +0 -0
  477. package/skills/procurement-supply/scripts/__pycache__/supply.cpython-312.pyc +0 -0
  478. package/skills/production-planning/scripts/__pycache__/mrp_capacity.cpython-312.pyc +0 -0
  479. package/skills/project-management/scripts/__pycache__/cpm.cpython-312.pyc +0 -0
  480. package/skills/quant-analysis/scripts/__pycache__/quant.cpython-312.pyc +0 -0
  481. package/skills/real-estate-property/scripts/__pycache__/property_calc.cpython-312.pyc +0 -0
  482. package/skills/regression-forecast/scripts/__pycache__/regress.cpython-312.pyc +0 -0
  483. package/skills/securities-research/scripts/__pycache__/valuation.cpython-312.pyc +0 -0
  484. package/skills/statistical-testing/scripts/__pycache__/stat_tests.cpython-312.pyc +0 -0
  485. package/skills/treasury-mgmt/scripts/__pycache__/cashflow_forecast.cpython-312.pyc +0 -0
@@ -0,0 +1,995 @@
1
+ # Word 技术文档建版器:结构化 blocks / Markdown → 成品 docx。
2
+ #
3
+ # 与 pptx_deck 同一条纪律:**模型只给「这一节讲什么」,尺寸由引擎算**。
4
+ # 人手写 python-docx 会犯的四类错,这里从写法上就不可能发生:
5
+ # · 中文字体只设 font.name 不设 w:eastAsia(换台电脑就变字体)→ _set_font 一次设齐三处;
6
+ # · 图片同时给宽和高把图拉变形 → 只给一个维度,另一维交给 Word 按原比例算;
7
+ # · 表格列宽拍脑袋,宽出版心右边一列被裁掉 → fit_table_widths 压进版心;
8
+ # · 首行缩进用全角空格顶(改字号就错位)→ 一律真缩进,且随字号走。
9
+ #
10
+ # 落盘后自动跑一遍 docx_check 体检,报告随返回值给出——「生成完就说做好了」是最常见的谎报。
11
+
12
+ import datetime
13
+ import json
14
+ import os
15
+ import re
16
+ import sys
17
+
18
+ from docx import Document
19
+ from docx.enum.section import WD_ORIENT, WD_SECTION
20
+ from docx.enum.table import WD_TABLE_ALIGNMENT
21
+ from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_BREAK, WD_LINE_SPACING
22
+ from docx.oxml import OxmlElement
23
+ from docx.oxml.ns import qn
24
+ from docx.shared import Cm, Pt, RGBColor
25
+
26
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
27
+ import docx_layout as L # noqa: E402
28
+
29
+ ALIGN = {'left': WD_ALIGN_PARAGRAPH.LEFT, 'center': WD_ALIGN_PARAGRAPH.CENTER,
30
+ 'right': WD_ALIGN_PARAGRAPH.RIGHT, 'justify': WD_ALIGN_PARAGRAPH.JUSTIFY}
31
+
32
+
33
+ def _set_font(obj, west, east, size=None, bold=None, color=None):
34
+ """把西文/东亚字体一次设齐。**只设 font.name 中文不生效**——这是 Word 生成脚本第一坑。"""
35
+ font = obj.font
36
+ font.name = west
37
+ el = obj.element if hasattr(obj, 'element') else obj._element
38
+ rpr = el.get_or_add_rPr() if hasattr(el, 'get_or_add_rPr') else el.rPr
39
+ rf = rpr.get_or_add_rFonts()
40
+ rf.set(qn('w:ascii'), west)
41
+ rf.set(qn('w:hAnsi'), west)
42
+ rf.set(qn('w:eastAsia'), east)
43
+ if size is not None:
44
+ font.size = Pt(L.pt_of(size))
45
+ if bold is not None:
46
+ font.bold = bold
47
+ if color is not None:
48
+ font.color.rgb = RGBColor(*color)
49
+
50
+
51
+ def _field(run, instr, placeholder=''):
52
+ """插入 Word 域(TOC / PAGE / NUMPAGES)。域是活的,静态目录是死的。"""
53
+ begin = OxmlElement('w:fldChar')
54
+ begin.set(qn('w:fldCharType'), 'begin')
55
+ it = OxmlElement('w:instrText')
56
+ it.set(qn('xml:space'), 'preserve')
57
+ it.text = instr
58
+ sep = OxmlElement('w:fldChar')
59
+ sep.set(qn('w:fldCharType'), 'separate')
60
+ t = OxmlElement('w:t')
61
+ t.text = placeholder
62
+ end = OxmlElement('w:fldChar')
63
+ end.set(qn('w:fldCharType'), 'end')
64
+ for el in (begin, it, sep, t, end):
65
+ run._r.append(el)
66
+
67
+
68
+ def _para_fmt(para, style, prof):
69
+ pf = para.paragraph_format
70
+ rule = style.get('line_rule', prof['body'].get('line_rule', 'multiple'))
71
+ val = style.get('line', prof['body'].get('line', 1.5))
72
+ if rule == 'exact':
73
+ pf.line_spacing_rule = WD_LINE_SPACING.EXACTLY
74
+ pf.line_spacing = Pt(float(val))
75
+ else:
76
+ pf.line_spacing_rule = WD_LINE_SPACING.MULTIPLE
77
+ pf.line_spacing = float(val)
78
+ pf.space_before = Pt(float(style.get('space_before', 0)))
79
+ pf.space_after = Pt(float(style.get('space_after', 0)))
80
+ ind = style.get('first_indent_chars', 0)
81
+ pf.first_line_indent = Pt(L.pt_of(style.get('size', prof['body']['size'])) * ind) if ind else Pt(0)
82
+ para.alignment = ALIGN.get(style.get('align', 'justify'), WD_ALIGN_PARAGRAPH.JUSTIFY)
83
+ pf.widow_control = True # 防孤行寡行:一段的最后一行不落单在下一页页首
84
+
85
+
86
+ BOLD_RE = re.compile(r'\*\*(.+?)\*\*')
87
+
88
+
89
+ def _runs(para, text, style, prof):
90
+ """按 **粗体** 拆 run,每个 run 都设齐字体(漏一个 run 就漏一处中文字体)。"""
91
+ east = style.get('east', prof['body']['east'])
92
+ west = style.get('west', prof['body']['west'])
93
+ size = style.get('size', prof['body']['size'])
94
+ base_bold = style.get('bold', False)
95
+ for i, seg in enumerate(BOLD_RE.split(str(text or ''))):
96
+ if not seg:
97
+ continue
98
+ r = para.add_run(seg)
99
+ _set_font(r, west, east, size, base_bold or (i % 2 == 1))
100
+ if not para.runs:
101
+ r = para.add_run('')
102
+ _set_font(r, west, east, size, base_bold)
103
+
104
+
105
+ class DocBuilder:
106
+ """技术文档建版器。所有尺寸走 docx_layout,调用方不需要(也不应该)自己算。"""
107
+
108
+ def __init__(self, profile='tech', *, numbering=None, caption_by_chapter=None):
109
+ self.prof = L.profile(profile)
110
+ if numbering:
111
+ self.prof['numbering'] = numbering
112
+ if caption_by_chapter is not None:
113
+ self.prof['caption_by_chapter'] = caption_by_chapter
114
+ self.doc = Document()
115
+ self.counters = [0, 0, 0, 0]
116
+ self.fig_n, self.tab_n = 0, 0
117
+ self.fig_by_chap, self.tab_by_chap = {}, {}
118
+ self.labels = [] # [(图/表, '图 1', 题注)]——正文引用要和它对得上
119
+ self.warnings = []
120
+ self.body_started = False
121
+ self._setup()
122
+
123
+ # ── 页面与样式 ────────────────────────────────────────────────────────
124
+ def _setup(self):
125
+ p, s = self.prof, self.doc.sections[0]
126
+ s.page_width, s.page_height = Cm(p['page_w_cm']), Cm(p['page_h_cm'])
127
+ m = p['margin_cm']
128
+ s.left_margin, s.right_margin = Cm(m['left']), Cm(m['right'])
129
+ s.top_margin, s.bottom_margin = Cm(m['top']), Cm(m['bottom'])
130
+ st = self.doc.styles
131
+ _set_font(st['Normal'], p['body']['west'], p['body']['east'], p['body']['size'])
132
+ _para_fmt_style(st['Normal'], p['body'], p)
133
+ for i, h in enumerate(p['headings'][:4], start=1):
134
+ name = f'Heading {i}'
135
+ if name not in [x.name for x in st]:
136
+ continue
137
+ style = st[name]
138
+ _set_font(style, h.get('west', p['body']['west']), h['east'], h['size'], h.get('bold', False))
139
+ style.font.color.rgb = RGBColor(0, 0, 0) # Word 默认标题是蓝色,中文正式文档要黑
140
+ _para_fmt_style(style, h, p)
141
+ for name, spec in (('Caption图表题注', p['caption']),):
142
+ try:
143
+ cs = st.add_style(name, 1) # 1 = WD_STYLE_TYPE.PARAGRAPH
144
+ _set_font(cs, spec.get('west', p['body']['west']), spec['east'], spec['size'])
145
+ _para_fmt_style(cs, spec, p)
146
+ except Exception:
147
+ pass
148
+
149
+ def body_width_cm(self):
150
+ return L.body_width_cm(self.prof)
151
+
152
+ # ── 封面 / 目录 / 页眉页脚 ────────────────────────────────────────────
153
+ def add_cover(self, title, subtitle='', org='', author='', date=None, doc_no='',
154
+ fields=None, fields_as_table=False):
155
+ """封面。fields = [['项目名称','…'],['编制单位','…']] 会排成对齐的信息栏。
156
+
157
+ fields_as_table=True 时排成**无框线两列表**——正式归档件的封面信息栏历来是表格,
158
+ 而且只有表格形态才能被工具按「键→值」读出来核对(写成居中段落,
159
+ 跨文档一致性检查就形同虚设:读不到字段,自然永远「没有不一致」)。"""
160
+ p = self.prof
161
+ if doc_no:
162
+ r = self._blank(0)
163
+ r.alignment = WD_ALIGN_PARAGRAPH.RIGHT
164
+ _runs(r, doc_no, {**p['body'], 'size': '四号', 'first_indent_chars': 0, 'align': 'right'}, p)
165
+ for _ in range(4 if doc_no else 5):
166
+ self._blank()
167
+ t = self.doc.add_paragraph()
168
+ t.alignment = WD_ALIGN_PARAGRAPH.CENTER
169
+ _runs(t, title, {'east': '黑体', 'west': p['body']['west'], 'size': '小初',
170
+ 'bold': False, 'align': 'center'}, p)
171
+ t.paragraph_format.space_after = Pt(18)
172
+ t.paragraph_format.first_line_indent = Pt(0)
173
+ if subtitle:
174
+ s = self.doc.add_paragraph()
175
+ s.alignment = WD_ALIGN_PARAGRAPH.CENTER
176
+ _runs(s, subtitle, {'east': '楷体', 'west': p['body']['west'], 'size': '三号', 'align': 'center'}, p)
177
+ s.paragraph_format.first_line_indent = Pt(0)
178
+ for _ in range(4):
179
+ self._blank()
180
+ rows = list(fields or [])
181
+ if org and not any(k in ('编制单位', '单位') for k, _ in rows):
182
+ rows.append(['编制单位', org])
183
+ if author and not any(k == '编制人' for k, _ in rows):
184
+ rows.append(['编制人', author])
185
+ today = datetime.date.today()
186
+ # 用「编制时间」而不是「编制日期」:真实模板体例是前者,
187
+ # 两边用词不一致会让档案体检提取不到这个字段(提取不到就等于永远没问题)。
188
+ rows.append(['编制时间', date or f'{today.year} 年 {today.month} 月 {today.day} 日'])
189
+ if fields_as_table and rows:
190
+ tb = self.doc.add_table(rows=len(rows), cols=2)
191
+ tb.alignment = WD_TABLE_ALIGNMENT.CENTER
192
+ tb.autofit = False
193
+ for i, (k, v) in enumerate(rows):
194
+ for j, (val, w, align) in enumerate(((f'{k}:', 4.2, 'right'),
195
+ (str(v), 8.0, 'left'))):
196
+ cell = tb.cell(i, j)
197
+ cell.width = Cm(w)
198
+ par = cell.paragraphs[0]
199
+ par.alignment = ALIGN[align]
200
+ par.paragraph_format.first_line_indent = Pt(0)
201
+ par.paragraph_format.space_before = par.paragraph_format.space_after = Pt(3)
202
+ _runs(par, val, {**p['body'], 'size': '四号', 'first_indent_chars': 0}, p)
203
+ _vcenter(cell)
204
+ _no_borders(tb)
205
+ grid = tb._tbl.tblGrid
206
+ if grid is not None:
207
+ for gc, w in zip(grid.findall(qn('w:gridCol')), (4.2, 8.0)):
208
+ gc.set(qn('w:w'), str(int(w * L.CM_PT * 20)))
209
+ else:
210
+ for k, v in rows:
211
+ line = self.doc.add_paragraph()
212
+ line.alignment = WD_ALIGN_PARAGRAPH.CENTER
213
+ line.paragraph_format.first_line_indent = Pt(0)
214
+ line.paragraph_format.space_after = Pt(6)
215
+ _runs(line, f'{_pad(k)}:{v}', {'east': p['body']['east'], 'west': p['body']['west'],
216
+ 'size': '四号', 'align': 'center'}, p)
217
+ self.add_page_break()
218
+ return self
219
+
220
+ def add_toc(self, title='目 录', levels=None):
221
+ h = self.doc.add_paragraph()
222
+ h.alignment = WD_ALIGN_PARAGRAPH.CENTER
223
+ h.paragraph_format.first_line_indent = Pt(0)
224
+ h.paragraph_format.space_after = Pt(12)
225
+ _runs(h, title, {'east': '黑体', 'west': self.prof['body']['west'], 'size': '三号', 'align': 'center'}, self.prof)
226
+ n = levels or self.prof.get('toc_levels', 3)
227
+ para = self.doc.add_paragraph()
228
+ para.paragraph_format.first_line_indent = Pt(0)
229
+ _field(para.add_run(), f'TOC \\o "1-{n}" \\h \\z \\u', '(右键→更新域,或按 F9 生成目录)')
230
+ self.add_page_break()
231
+ return self
232
+
233
+ def start_body(self, restart_page_numbers=True):
234
+ """正文另起一节:页码从 1 重新起算(封面与目录不计页)。正式交付件的标配。"""
235
+ sec = self.doc.add_section(WD_SECTION.NEW_PAGE)
236
+ p, m = self.prof, self.prof['margin_cm']
237
+ sec.page_width, sec.page_height = Cm(p['page_w_cm']), Cm(p['page_h_cm'])
238
+ sec.left_margin, sec.right_margin = Cm(m['left']), Cm(m['right'])
239
+ sec.top_margin, sec.bottom_margin = Cm(m['top']), Cm(m['bottom'])
240
+ if restart_page_numbers:
241
+ pg = OxmlElement('w:pgNumType')
242
+ pg.set(qn('w:start'), '1')
243
+ sec._sectPr.append(pg)
244
+ self.body_started = True
245
+ return self
246
+
247
+ def set_header_footer(self, header='', footer='', page_numbers=True, all_sections=True):
248
+ secs = self.doc.sections if all_sections else self.doc.sections[-1:]
249
+ for i, sec in enumerate(secs):
250
+ if i:
251
+ sec.header.is_linked_to_previous = False
252
+ sec.footer.is_linked_to_previous = False
253
+ if header:
254
+ hp = sec.header.paragraphs[0]
255
+ hp.alignment = WD_ALIGN_PARAGRAPH.CENTER
256
+ hp.paragraph_format.first_line_indent = Pt(0)
257
+ for r in list(hp.runs):
258
+ r.text = ''
259
+ _runs(hp, header, {'east': self.prof['body']['east'], 'west': self.prof['body']['west'],
260
+ 'size': '五号', 'align': 'center'}, self.prof)
261
+ _bottom_border(hp)
262
+ fp = sec.footer.paragraphs[0]
263
+ fp.alignment = WD_ALIGN_PARAGRAPH.CENTER
264
+ fp.paragraph_format.first_line_indent = Pt(0)
265
+ for r in list(fp.runs):
266
+ r.text = ''
267
+ if footer:
268
+ _runs(fp, footer + ('  ' if page_numbers else ''),
269
+ {'east': self.prof['body']['east'], 'west': self.prof['body']['west'],
270
+ 'size': '五号', 'align': 'center'}, self.prof)
271
+ if page_numbers:
272
+ for txt, instr in (('第 ', None), (None, 'PAGE'), (' 页 共 ', None), (None, 'NUMPAGES'), (' 页', None)):
273
+ r = fp.add_run(txt or '')
274
+ _set_font(r, self.prof['body']['west'], self.prof['body']['east'], '五号')
275
+ if instr:
276
+ _field(r, instr, '1')
277
+ return self
278
+
279
+ # ── 内容块 ────────────────────────────────────────────────────────────
280
+ def add_heading(self, text, level=1, numbered=None):
281
+ level = max(1, min(int(level), 4))
282
+ text = str(text or '').strip()
283
+ auto = self.prof.get('numbering', 'cn') != 'none' if numbered is None else numbered
284
+ if auto and L.parse_number(text):
285
+ auto = False # 文本自带编号就不再叠一层
286
+ if auto:
287
+ self.counters[level - 1] += 1
288
+ for i in range(level, 4):
289
+ self.counters[i] = 0
290
+ text = L.heading_number(self.prof, level, self.counters) + text
291
+ elif level == 1:
292
+ self.counters[0] += 1
293
+ for i in range(1, 4):
294
+ self.counters[i] = 0
295
+ para = self.doc.add_paragraph(style=f'Heading {level}')
296
+ st = self.prof['headings'][level - 1]
297
+ _para_fmt(para, {**st, 'first_indent_chars': 0}, self.prof)
298
+ _runs(para, text, st, self.prof)
299
+ para.paragraph_format.keep_with_next = True # 标题不落单在页底
300
+ return self
301
+
302
+ def add_para(self, text, style=None, indent=True):
303
+ para = self.doc.add_paragraph()
304
+ st = {**self.prof['body'], **(style or {})}
305
+ if not indent:
306
+ st = {**st, 'first_indent_chars': 0}
307
+ _para_fmt(para, st, self.prof)
308
+ _runs(para, text, st, self.prof)
309
+ return self
310
+
311
+ def add_bullets(self, items, ordered=False, level=0):
312
+ for i, it in enumerate(items, 1):
313
+ text, subs = (it, []) if not isinstance(it, dict) else (it.get('text', ''), it.get('sub', []))
314
+ mark = f'{i}.' if ordered else '·'
315
+ para = self.doc.add_paragraph()
316
+ st = {**self.prof['body'], 'first_indent_chars': 0}
317
+ _para_fmt(para, st, self.prof)
318
+ para.paragraph_format.left_indent = Pt(L.pt_of(st['size']) * (2 + level * 2))
319
+ para.paragraph_format.first_line_indent = Pt(-L.pt_of(st['size']) * (1 if ordered else 1))
320
+ _runs(para, f'{mark} {text}', st, self.prof)
321
+ for s in subs:
322
+ self.add_bullets([s], ordered=False, level=level + 1)
323
+ return self
324
+
325
+ def add_table(self, rows, caption='', note='', header=True, style='grid', align='center'):
326
+ """表格:列宽按内容算并压进版心,表头加粗+跨页重复,题注排在表**上方**(中文出版惯例)。"""
327
+ rows = [[('' if c is None else str(c)) for c in r] for r in rows if r is not None]
328
+ if not rows:
329
+ return self
330
+ cols = max(len(r) for r in rows)
331
+ rows = [r + [''] * (cols - len(r)) for r in rows]
332
+ if caption:
333
+ self.tab_n += 1
334
+ ch = self.counters[0] if self.prof.get('caption_by_chapter') else None
335
+ lab = L.caption_label(self.prof, 'tab', self._cap_idx('tab'), ch)
336
+ self.labels.append(('表', lab, caption))
337
+ self.last_label = lab
338
+ self._caption(f'{lab} {caption}')
339
+ tb = self.doc.add_table(rows=len(rows), cols=cols)
340
+ tb.alignment = WD_TABLE_ALIGNMENT.CENTER
341
+ tb.autofit = False
342
+ try:
343
+ tb.style = 'Table Grid'
344
+ except Exception:
345
+ pass
346
+ widths = L.fit_table_widths(rows, self.prof, self.prof['table']['size'])
347
+ tspec = self.prof['table']
348
+ for i, row in enumerate(rows):
349
+ for j, val in enumerate(row):
350
+ cell = tb.cell(i, j)
351
+ cell.width = Cm(widths[j])
352
+ par = cell.paragraphs[0]
353
+ par.alignment = ALIGN.get(tspec.get('align', 'center') if (i == 0 or j == 0) else 'left',
354
+ WD_ALIGN_PARAGRAPH.LEFT)
355
+ pf = par.paragraph_format
356
+ pf.first_line_indent = Pt(0)
357
+ pf.space_before, pf.space_after = Pt(1.5), Pt(1.5)
358
+ pf.line_spacing_rule = WD_LINE_SPACING.SINGLE
359
+ _runs(par, val, {**tspec, 'bold': bool(header and i == 0 and tspec.get('head_bold', True))}, self.prof)
360
+ _vcenter(cell)
361
+ grid = tb._tbl.tblGrid
362
+ if grid is not None:
363
+ for gc, w in zip(grid.findall(qn('w:gridCol')), widths):
364
+ gc.set(qn('w:w'), str(int(w * L.CM_PT * 20)))
365
+ if header and len(rows) > 1:
366
+ trpr = tb.rows[0]._tr.get_or_add_trPr()
367
+ th = OxmlElement('w:tblHeader')
368
+ th.set(qn('w:val'), 'true')
369
+ trpr.append(th)
370
+ if style == 'three-line':
371
+ _three_line(tb)
372
+ h = L.est_table_height(rows, self.prof, widths, self.prof['table']['size'])
373
+ if h > L.body_height_pt(self.prof) * 0.92:
374
+ self.warnings.append(f'表「{caption or rows[0][0]}」估算高 {h/L.CM_PT:.1f}cm 会跨页,'
375
+ f'已设跨页重复标题行;行数很多时建议拆表或改附件')
376
+ if note:
377
+ self._caption(f'注:{note}', align='left')
378
+ self.doc.add_paragraph().paragraph_format.space_after = Pt(0)
379
+ return self
380
+
381
+ def _new_section(self, landscape):
382
+ """另起一节并定方向(横排插图用)。页码接续;页眉页脚在 set_header_footer 里统一套到每一节。"""
383
+ p, m = self.prof, self.prof['margin_cm']
384
+ sec = self.doc.add_section(WD_SECTION.NEW_PAGE)
385
+ W, H = Cm(p['page_w_cm']), Cm(p['page_h_cm'])
386
+ sec.orientation = WD_ORIENT.LANDSCAPE if landscape else WD_ORIENT.PORTRAIT
387
+ sec.page_width, sec.page_height = (H, W) if landscape else (W, H)
388
+ sec.left_margin, sec.right_margin = Cm(m['left']), Cm(m['right'])
389
+ sec.top_margin, sec.bottom_margin = Cm(m['top']), Cm(m['bottom'])
390
+ return sec
391
+
392
+ def add_figure(self, image, caption='', width_cm=None, note='', landscape=False):
393
+ """插图:**只给一个维度**,另一维由 Word 按原比例算——从写法上杜绝变形。
394
+ landscape=True:图单独占一个横排页(版心宽 23.4cm),前后自动切回纵向——大架构图放纵向版心里字会小到看不清。"""
395
+ path = str(image)
396
+ if not os.path.exists(path):
397
+ self.warnings.append(f'插图不存在:{path}(已在正文留占位提示)')
398
+ self.add_para(f'[插图缺失:{path}]', {'align': 'center', 'size': '五号'}, indent=False)
399
+ return self
400
+ if landscape:
401
+ p, m = self.prof, self.prof['margin_cm']
402
+ self._new_section(True)
403
+ width_cm = width_cm or (p['page_h_cm'] - m['left'] - m['right'])
404
+ self._landscape_max_h = (p['page_w_cm'] - m['top'] - m['bottom']) * 0.82
405
+ try:
406
+ return self._add_figure_body(path, caption, width_cm, note)
407
+ finally:
408
+ self._landscape_max_h = None
409
+ self._new_section(False)
410
+ return self._add_figure_body(path, caption, width_cm, note)
411
+
412
+ def _legibility(self, path, w_cm):
413
+ """同名 SVG(render_diagram / render_chart 都会出)里的字号 × 插入后的缩放 = 纸上的实际字号。"""
414
+ svg = os.path.splitext(path)[0] + '.svg'
415
+ if not os.path.exists(svg):
416
+ return None
417
+ try:
418
+ text = open(svg, encoding='utf-8', errors='replace').read(4_000_000)
419
+ except OSError:
420
+ return None
421
+ m = re.search(r'viewBox="\s*[-\d.]+\s+[-\d.]+\s+([\d.]+)\s+[\d.]+', text)
422
+ sizes = sorted(float(x) for x in re.findall(r'font-size="([\d.]+)(?:px|pt)?"', text) if float(x) > 0)
423
+ if not m or not sizes:
424
+ return None # matplotlib 的 SVG 把字转成了路径:按版心出的图本就不用查
425
+ k = (w_cm / 2.54 * 72) / float(m.group(1))
426
+ return sizes[0] * k, sizes[len(sizes) // 2] * k
427
+
428
+ def _add_figure_body(self, path, caption, width_cm, note):
429
+ px_w = px_h = 0
430
+ try:
431
+ from docx.image.image import Image as DocxImage
432
+ img = DocxImage.from_file(path)
433
+ px_w, px_h, dpi = img.px_width, img.px_height, img.horz_dpi or 96
434
+ except Exception as e:
435
+ self.warnings.append(f'插图无法解析:{path}({e})')
436
+ dpi = 96
437
+ w_cm, h_cm = L.image_box(px_w, px_h, self.prof, max_w_cm=width_cm, target_dpi=max(dpi, 96),
438
+ max_h_cm=getattr(self, '_landscape_max_h', None))
439
+ para = self.doc.add_paragraph()
440
+ para.alignment = WD_ALIGN_PARAGRAPH.CENTER
441
+ para.paragraph_format.first_line_indent = Pt(0)
442
+ para.paragraph_format.space_before = Pt(6)
443
+ para.paragraph_format.space_after = Pt(3)
444
+ run = para.add_run()
445
+ max_h = L.body_height_pt(self.prof) * L.PT_CM * 0.8
446
+ try:
447
+ if h_cm <= max_h:
448
+ run.add_picture(path, width=Cm(w_cm)) # 只给宽 → 高度自动等比
449
+ else:
450
+ run.add_picture(path, height=Cm(max_h)) # 只给高 → 宽度自动等比
451
+ except Exception as e:
452
+ self.warnings.append(f'插图插入失败:{path}({e})')
453
+ _runs(para, f'[插图失败:{os.path.basename(path)}]', {**self.prof['body'], 'size': '五号'}, self.prof)
454
+ return self
455
+ leg = self._legibility(path, w_cm)
456
+ if leg and leg[0] < 5.5:
457
+ name = os.path.basename(path)
458
+ if getattr(self, '_landscape_max_h', None):
459
+ self.warnings.append(f'插图 {name} 横排插入后图中最小字号仍只有约 {leg[0]:.1f} 磅、多数文字约 {leg[1]:.1f} 磅,'
460
+ f'打印看不清:这张图对纸面来说太密,精简节点或拆成两张')
461
+ else:
462
+ # 横排后能放多大:与横排插入时同一套尺寸算法(竖长的图横排反而更小,就别建议了)
463
+ p_, m_ = self.prof, self.prof['margin_cm']
464
+ w_land, _h = L.image_box(px_w, px_h, p_, max_w_cm=p_['page_h_cm'] - m_['left'] - m_['right'],
465
+ max_h_cm=(p_['page_w_cm'] - m_['top'] - m_['bottom']) * 0.82,
466
+ target_dpi=max(dpi, 96))
467
+ land = leg[0] * (w_land / w_cm) if w_cm else 0
468
+ tail = (f';实在要整张放就横排插入——Markdown 写 ![图题](图){{#fig:x .landscape}},横排后最小约 {land:.1f} 磅'
469
+ if land > leg[0] + 0.5 else '(这张图竖长,横排也放不大)')
470
+ self.warnings.append(f'插图 {name} 按版心宽插入后,图中最小字号约 {leg[0]:.1f} 磅、多数文字约 {leg[1]:.1f} 磅,'
471
+ f'打印看不清(正文插图要 ≥5.5 磅):精简节点或拆成两张重画' + tail)
472
+ dpi_eff = L.effective_dpi(px_w, w_cm) if px_w else 0
473
+ if dpi_eff and dpi_eff < 120:
474
+ self.warnings.append(f'插图 {os.path.basename(path)} 有效分辨率仅 {dpi_eff:.0f}dpi,'
475
+ f'打印会发虚(建议用 render_diagram 出矢量或提高导出分辨率)')
476
+ if caption:
477
+ self.fig_n += 1
478
+ ch = self.counters[0] if self.prof.get('caption_by_chapter') else None
479
+ lab = L.caption_label(self.prof, 'fig', self._cap_idx('fig'), ch)
480
+ self.labels.append(('图', lab, caption))
481
+ self.last_label = lab
482
+ self._caption(f'{lab} {caption}')
483
+ if note:
484
+ self._caption(f'注:{note}', align='left')
485
+ return self
486
+
487
+ def resolve_refs(self, refs):
488
+ """把正文里的 @fig:xxx / @tbl:xxx 换成真实题注编号(图 3-1 / 表 5)。
489
+
490
+ 编号由引擎按版式算(按章或全文连续),模型写「如图 3-2 所示」时要自己数——
491
+ 插一张图、挪一节,后面的引用就全错了,而且「引用了一个存在但不对的图」体检查不出来。
492
+ 标签引用把这件事交还给引擎:写的是「哪张图」,编号永远对。"""
493
+ missing = []
494
+
495
+ def sub(m):
496
+ key = m.group(1)
497
+ if key in refs:
498
+ return refs[key]
499
+ missing.append('@' + key)
500
+ return m.group(0)
501
+
502
+ paras = list(self.doc.paragraphs)
503
+ for t in self.doc.tables:
504
+ for row in t.rows:
505
+ for cell in row.cells:
506
+ paras.extend(cell.paragraphs)
507
+ for para in paras:
508
+ if '@fig:' not in para.text and '@tbl:' not in para.text:
509
+ continue
510
+ for run in para.runs:
511
+ if '@' in run.text:
512
+ run.text = XREF_RE.sub(sub, run.text)
513
+ if XREF_RE.search(para.text) and para.runs: # 标签被加粗等格式拆进了两个 run:合并后再换
514
+ full = XREF_RE.sub(sub, para.text)
515
+ for r in para.runs[1:]:
516
+ r.text = ''
517
+ para.runs[0].text = full
518
+ for k in dict.fromkeys(missing):
519
+ self.warnings.append(f'引用了不存在的图表标签 {k}:检查对应图/表题注末尾是否写了 {{#{k[1:]}}}')
520
+
521
+ def add_kv_table(self, pairs, caption='', label_cm=4.0):
522
+ """表单式两列表(验收申请表、任务书封面信息栏这类)。左列固定宽、右列吃满剩余。"""
523
+ rows = [[str(k), str(v)] for k, v in pairs]
524
+ if caption:
525
+ self.tab_n += 1
526
+ self.last_label = L.caption_label(self.prof, "tab", self._cap_idx("tab"), None)
527
+ self._caption(f'{self.last_label} {caption}')
528
+ tb = self.doc.add_table(rows=len(rows), cols=2)
529
+ tb.alignment = WD_TABLE_ALIGNMENT.CENTER
530
+ tb.autofit = False
531
+ try:
532
+ tb.style = 'Table Grid'
533
+ except Exception:
534
+ pass
535
+ right = self.body_width_cm() - label_cm
536
+ for i, (k, v) in enumerate(rows):
537
+ for j, (val, w, bold) in enumerate(((k, label_cm, True), (v, right, False))):
538
+ cell = tb.cell(i, j)
539
+ cell.width = Cm(w)
540
+ par = cell.paragraphs[0]
541
+ par.alignment = WD_ALIGN_PARAGRAPH.CENTER if j == 0 else WD_ALIGN_PARAGRAPH.LEFT
542
+ par.paragraph_format.first_line_indent = Pt(0)
543
+ par.paragraph_format.space_before = par.paragraph_format.space_after = Pt(2)
544
+ _runs(par, val, {**self.prof['table'], 'size': self.prof['body']['size'], 'bold': bold}, self.prof)
545
+ _vcenter(cell)
546
+ grid = tb._tbl.tblGrid
547
+ if grid is not None:
548
+ for gc, w in zip(grid.findall(qn('w:gridCol')), (label_cm, right)):
549
+ gc.set(qn('w:w'), str(int(w * L.CM_PT * 20)))
550
+ self.doc.add_paragraph().paragraph_format.space_after = Pt(0)
551
+ return self
552
+
553
+ def add_signature(self, roles=('编制', '审核', '批准'), org='', date=True):
554
+ """签字栏:右对齐的「编制:____ 审核:____」+ 落款单位与日期。"""
555
+ for _ in range(2):
556
+ self._blank()
557
+ line = self.doc.add_paragraph()
558
+ line.alignment = WD_ALIGN_PARAGRAPH.RIGHT
559
+ line.paragraph_format.first_line_indent = Pt(0)
560
+ _runs(line, '  '.join(f'{r}:{"_" * 8}' for r in roles), self.prof['body'], self.prof)
561
+ if org:
562
+ o = self.doc.add_paragraph()
563
+ o.alignment = WD_ALIGN_PARAGRAPH.RIGHT
564
+ o.paragraph_format.first_line_indent = Pt(0)
565
+ _runs(o, org, {**self.prof['body'], 'align': 'right'}, self.prof)
566
+ if date:
567
+ d = self.doc.add_paragraph()
568
+ d.alignment = WD_ALIGN_PARAGRAPH.RIGHT
569
+ d.paragraph_format.first_line_indent = Pt(0)
570
+ _t = datetime.date.today()
571
+ _runs(d, f'{_t.year} 年 {_t.month} 月 {_t.day} 日',
572
+ {**self.prof['body'], 'align': 'right'}, self.prof)
573
+ return self
574
+
575
+ def add_page_break(self):
576
+ p = self.doc.add_paragraph()
577
+ p.paragraph_format.first_line_indent = Pt(0)
578
+ p.add_run().add_break(WD_BREAK.PAGE)
579
+ return self
580
+
581
+ # ── 内部 ──────────────────────────────────────────────────────────────
582
+ def _cap_idx(self, kind):
583
+ if not self.prof.get('caption_by_chapter'):
584
+ return self.fig_n if kind == 'fig' else self.tab_n
585
+ ch = self.counters[0] or 1
586
+ d = self.fig_by_chap if kind == 'fig' else self.tab_by_chap
587
+ d[ch] = d.get(ch, 0) + 1
588
+ return d[ch]
589
+
590
+ def _caption(self, text, align='center'):
591
+ para = self.doc.add_paragraph()
592
+ st = {**self.prof['caption'], 'align': align, 'first_indent_chars': 0}
593
+ _para_fmt(para, st, self.prof)
594
+ _runs(para, text, st, self.prof)
595
+ para.paragraph_format.keep_with_next = (align == 'center')
596
+ return para
597
+
598
+ def _blank(self, size=None):
599
+ p = self.doc.add_paragraph()
600
+ p.paragraph_format.first_line_indent = Pt(0)
601
+ p.paragraph_format.space_after = Pt(0)
602
+ if size is not None:
603
+ p.paragraph_format.line_spacing = 1.0
604
+ return p
605
+
606
+ def save(self, path, inspect=True):
607
+ self.doc.save(path)
608
+ res = None
609
+ if inspect:
610
+ try:
611
+ import docx_check
612
+ res = docx_check.inspect(path, self.prof['name'])
613
+ except Exception as e:
614
+ self.warnings.append(f'体检未能运行:{e}')
615
+ return path, res
616
+
617
+
618
+ def _para_fmt_style(style, spec, prof):
619
+ pf = style.paragraph_format
620
+ rule = spec.get('line_rule', prof['body'].get('line_rule', 'multiple'))
621
+ val = spec.get('line', prof['body'].get('line', 1.5))
622
+ if rule == 'exact':
623
+ pf.line_spacing_rule = WD_LINE_SPACING.EXACTLY
624
+ pf.line_spacing = Pt(float(val))
625
+ else:
626
+ pf.line_spacing_rule = WD_LINE_SPACING.MULTIPLE
627
+ pf.line_spacing = float(val)
628
+ pf.space_before = Pt(float(spec.get('space_before', 0)))
629
+ pf.space_after = Pt(float(spec.get('space_after', 0)))
630
+ ind = spec.get('first_indent_chars', 0)
631
+ pf.first_line_indent = Pt(L.pt_of(spec.get('size', prof['body']['size'])) * ind) if ind else Pt(0)
632
+ if spec.get('align'):
633
+ style.paragraph_format.alignment = ALIGN.get(spec['align'])
634
+
635
+
636
+ def _pad(s, width=5):
637
+ """封面信息栏标签右对齐到统一宽度,让冒号成一条竖线。
638
+
639
+ **必须左填充**:右填充会写出「编制单位 :」——全角空格顶在冒号前面,
640
+ 这是标点前留白,中文排版里属于硬伤(本项目的 docx_check 会直接判「标点前多了空格」)。"""
641
+ s = str(s)
642
+ return ' ' * max(0, width - len(s)) + s
643
+
644
+
645
+ def _vcenter(cell):
646
+ tcPr = cell._tc.get_or_add_tcPr()
647
+ va = OxmlElement('w:vAlign')
648
+ va.set(qn('w:val'), 'center')
649
+ tcPr.append(va)
650
+
651
+
652
+ def _bottom_border(para):
653
+ pPr = para._p.get_or_add_pPr()
654
+ bd = OxmlElement('w:pBdr')
655
+ b = OxmlElement('w:bottom')
656
+ for k, v in (('w:val', 'single'), ('w:sz', '6'), ('w:space', '1'), ('w:color', '808080')):
657
+ b.set(qn(k), v)
658
+ bd.append(b)
659
+ pPr.append(bd)
660
+
661
+
662
+ def _no_borders(tb):
663
+ """去掉全部框线(封面信息栏用)。"""
664
+ for row in tb.rows:
665
+ for cell in row.cells:
666
+ tcPr = cell._tc.get_or_add_tcPr()
667
+ bd = OxmlElement('w:tcBorders')
668
+ for edge in ('top', 'bottom', 'left', 'right'):
669
+ e = OxmlElement(f'w:{edge}')
670
+ e.set(qn('w:val'), 'none')
671
+ e.set(qn('w:sz'), '0')
672
+ bd.append(e)
673
+ tcPr.append(bd)
674
+
675
+
676
+ def _three_line(tb):
677
+ """三线表:只留顶线、表头下线、底线,去掉所有竖线与中间横线(科技报告惯例)。"""
678
+ n = len(tb.rows)
679
+ for i, row in enumerate(tb.rows):
680
+ for cell in row.cells:
681
+ tcPr = cell._tc.get_or_add_tcPr()
682
+ for old in tcPr.findall(qn('w:tcBorders')):
683
+ tcPr.remove(old)
684
+ bd = OxmlElement('w:tcBorders')
685
+ for edge, on, sz in (('top', i == 0, '12'), ('bottom', i in (0, n - 1), '12' if i == n - 1 else '6'),
686
+ ('left', False, '0'), ('right', False, '0')):
687
+ e = OxmlElement(f'w:{edge}')
688
+ e.set(qn('w:val'), 'single' if on else 'none')
689
+ e.set(qn('w:sz'), sz if on else '0')
690
+ e.set(qn('w:color'), '000000')
691
+ bd.append(e)
692
+ tcPr.append(bd)
693
+
694
+
695
+ # 交叉引用:题注末尾写 {#fig:arch} / {#tbl:invest},正文写 @fig:arch / @tbl:invest,成稿时换成真实编号。
696
+ # id 只许 ASCII——允许中文的话「@fig:arch所示」会把「所示」也吃进 id。
697
+ XREF_ID_RE = re.compile(r'\s*\{#((?:fig|tbl):[A-Za-z0-9_\-]+)\}\s*$')
698
+ # 前面顺手多写了「图 / 表」(「如图 @fig:x 所示」)也要认:一并吃掉,免得成稿变成「如图 图 3-1 所示」
699
+ XREF_RE = re.compile(r'(?:[图表]\s*)?@((?:fig|tbl):[A-Za-z0-9_\-]+)')
700
+
701
+
702
+ def _split_xref(caption):
703
+ m = XREF_ID_RE.search(caption or '')
704
+ return (caption[:m.start()].rstrip(), m.group(1)) if m else (caption or '', None)
705
+
706
+
707
+ # ── Markdown → blocks ──────────────────────────────────────────────────────
708
+ def md_to_blocks(md_text):
709
+ """Markdown 中转的解析端。约定:#/##/### 标题、- 列表、1. 有序、| 表格、![题注](图) 插图、
710
+ > 引注、--- 分页、普通行=正文段落。表格上一行若是 `表:xxx` 则作为题注。"""
711
+ # 吃掉 BOM:Windows 编辑器保存的 .md 常带 \ufeff,留着会让首行的 `# 标题`
712
+ # 变成 `\ufeff# 标题`,标题正则匹配不上,整份文档的第一个标题就此消失。
713
+ md_text = str(md_text or '').lstrip('\ufeff')
714
+ blocks, tbuf, lbuf, obuf, pending_cap = [], [], [], [], ''
715
+
716
+ def flush_list():
717
+ nonlocal lbuf, obuf
718
+ if lbuf:
719
+ blocks.append({'type': 'bullets', 'items': lbuf})
720
+ lbuf = []
721
+ if obuf:
722
+ blocks.append({'type': 'ordered', 'items': obuf})
723
+ obuf = []
724
+
725
+ def flush_table():
726
+ nonlocal tbuf, pending_cap
727
+ if not tbuf:
728
+ return
729
+ cells = [[c.strip() for c in r.strip().strip('|').split('|')] for r in tbuf]
730
+ cells = [r for r in cells if not all(re.fullmatch(r':?-{2,}:?', c or '---') for c in r)]
731
+ if cells:
732
+ blocks.append({'type': 'table', 'rows': cells, 'caption': pending_cap})
733
+ tbuf, pending_cap = [], ''
734
+
735
+ for raw in str(md_text).splitlines() + ['']:
736
+ s = raw.rstrip()
737
+ if s.lstrip().startswith('|'):
738
+ flush_list()
739
+ tbuf.append(s)
740
+ continue
741
+ flush_table()
742
+ if not s.strip():
743
+ flush_list()
744
+ continue
745
+ m = re.match(r'^(#{1,4})\s+(.*)', s)
746
+ if m:
747
+ flush_list()
748
+ blocks.append({'type': f'h{len(m.group(1))}', 'text': m.group(2).strip()})
749
+ continue
750
+ img = re.match(r'^\s*!\[(.*?)\]\(([^)]+)\)\s*(\{[^}]*\})?\s*$', s)
751
+ if img:
752
+ flush_list()
753
+ # pandoc 属性块:{#fig:x .landscape}——#id 作交叉引用标签,.landscape 表示横排插入
754
+ attrs = (img.group(3) or '').strip('{} ').split()
755
+ rid = next((a[1:] for a in attrs if a.startswith('#')), '')
756
+ cap = img.group(1).strip() + (f' {{#{rid}}}' if rid else '')
757
+ blk = {'type': 'figure', 'image': img.group(2).strip(), 'caption': cap}
758
+ if '.landscape' in attrs:
759
+ blk['landscape'] = True
760
+ blocks.append(blk)
761
+ continue
762
+ if re.match(r'^\s*(---|\*\*\*|===)\s*$', s):
763
+ flush_list()
764
+ blocks.append({'type': 'pagebreak'})
765
+ continue
766
+ cap = re.match(r'^\s*表\s*[::]\s*(.+)$', s)
767
+ if cap:
768
+ flush_list()
769
+ pending_cap = cap.group(1).strip()
770
+ continue
771
+ if re.match(r'^\s*[-*+]\s+', s):
772
+ if obuf:
773
+ flush_list()
774
+ lbuf.append(re.sub(r'^\s*[-*+]\s+', '', s))
775
+ continue
776
+ if re.match(r'^\s*\d+[.、]\s*', s):
777
+ if lbuf:
778
+ flush_list()
779
+ obuf.append(re.sub(r'^\s*\d+[.、]\s*', '', s))
780
+ continue
781
+ if s.lstrip().startswith('>'):
782
+ flush_list()
783
+ blocks.append({'type': 'note', 'text': s.lstrip('> ').strip()})
784
+ continue
785
+ flush_list()
786
+ blocks.append({'type': 'p', 'text': s.strip()})
787
+ flush_table()
788
+ flush_list()
789
+ return blocks
790
+
791
+
792
+ def _load_templates():
793
+ """按需取 tech-doc 的模板库(同源技能,缺席不影响建版——只是少了必备章节核对)。"""
794
+ here = os.path.dirname(os.path.abspath(__file__))
795
+ cand = os.path.join(os.path.dirname(os.path.dirname(here)), 'tech-doc', 'scripts')
796
+ if cand not in sys.path:
797
+ sys.path.insert(0, cand)
798
+ try:
799
+ import doc_templates
800
+ return doc_templates
801
+ except Exception:
802
+ return None
803
+
804
+
805
+ def _template_audit(spec, blocks):
806
+ """按文档类型核对必备章节。**在建版这一步就报缺节**,比出片后靠人翻目录靠谱。"""
807
+ name = spec.get('template')
808
+ T = _load_templates() if name else None
809
+ if not T:
810
+ return [], spec.get('profile', 'tech')
811
+ key = T.find(name)
812
+ if not key:
813
+ return [f'未收录文档类型「{name}」,已按通用技术文档处理;'
814
+ f'可选类型:{"、".join(list(T.TEMPLATES)[:8])}…'], spec.get('profile', 'tech')
815
+ t = T.TEMPLATES[key]
816
+ heads = [str(b.get('text', '')) for b in (blocks or []) if str(b.get('type', '')).startswith('h')]
817
+ joined = ' '.join(heads)
818
+ # 走 sections_for:有自家归档体例就以它为准,否则用通用库。
819
+ # 两边各判各的,会让「按 create_doc 补齐」的文档在档案体检里照样报缺章节。
820
+ secs = T.sections_for(key) if hasattr(T, 'sections_for') else \
821
+ [(x[0], x[1], x[2]) for x in t.get('sections', [])]
822
+ missing = [title for title, _subs, must in secs
823
+ if must and title not in joined and not any(_overlap(title, h) for h in heads)]
824
+ warns = []
825
+ if missing:
826
+ warns.append(f'【{key}】缺必备章节:{"、".join(missing)}'
827
+ f'——评审是按章节核对的,缺一节就是缺一项')
828
+ if t.get('form') and not any(str(b.get('type')) == 'kv' for b in (blocks or [])):
829
+ warns.append(f'【{key}】是表单式文档,主体应为字段表(blocks 里用 type:"kv"),'
830
+ f'当前没有任何 kv 表')
831
+ return warns, spec.get('profile') or t['profile']
832
+
833
+
834
+ def _overlap(a, b):
835
+ """标题近似匹配:去掉编号与标点后有 3 个以上连续汉字重合即认为是同一节。"""
836
+ import re as _re
837
+ ca = _re.sub(r'[^\u4e00-\u9fff]', '', a)
838
+ cb = _re.sub(r'[^\u4e00-\u9fff]', '', b)
839
+ if not ca or not cb:
840
+ return False
841
+ return any(ca[i:i + 3] in cb for i in range(max(len(ca) - 2, 1)))
842
+
843
+
844
+ def build(spec, out_path):
845
+ """spec(dict)→ docx。spec 见 create_doc 工具说明;返回 (path, 体检结果, warnings)。"""
846
+ blocks0 = spec.get('blocks')
847
+ if blocks0 is None and spec.get('markdown'):
848
+ blocks0 = md_to_blocks(spec['markdown'])
849
+ base = spec.get('_md_dir')
850
+ if base:
851
+ for blk in blocks0:
852
+ if blk.get('type') == 'figure' and not os.path.isabs(blk.get('image', '')):
853
+ cand = os.path.join(base, blk['image'])
854
+ # 草稿在 docs/ 下、图路径却按项目根写(docs/charts/x.png)是最常见的写法:两种都认
855
+ if not os.path.exists(cand) and os.path.exists(blk['image']):
856
+ cand = os.path.abspath(blk['image'])
857
+ blk['image'] = cand
858
+ # 标题层级归一:Markdown 习惯把「#」留给文档标题,章从「##」起——全文没有一级标题时整体上提一级,
859
+ # 否则目录、导航窗格、按章编号的题注(图 3-1)都会错一级(实测一份可研通篇 ## / ###,题注成了全文连续编号)
860
+ hl = [int(str(b.get('type'))[1]) for b in (blocks0 or []) if re.fullmatch(r'h[1-4]', str(b.get('type', '')))]
861
+ if hl and min(hl) > 1:
862
+ shift = min(hl) - 1
863
+ blocks0 = [({**b, 'type': f"h{int(str(b['type'])[1]) - shift}"} if re.fullmatch(r'h[1-4]', str(b.get('type', ''))) else b)
864
+ for b in blocks0]
865
+ # 父目录不存在就建——直接调 API 的场景(如归档生成器批量落盘到子目录)
866
+ # 拿不到工具层的 resolveOut 兜底,漏了这一句就会在第一个子目录上炸。
867
+ d = os.path.dirname(os.path.abspath(out_path))
868
+ if d:
869
+ os.makedirs(d, exist_ok=True)
870
+ tpl_warns, prof_name = _template_audit(spec, blocks0)
871
+ spec = {**spec, 'profile': prof_name}
872
+ b = DocBuilder(spec.get('profile', 'tech'),
873
+ numbering=spec.get('numbering'),
874
+ caption_by_chapter=spec.get('caption_by_chapter'))
875
+ if spec.get('cover', True) and spec.get('title'):
876
+ b.add_cover(spec['title'], spec.get('subtitle', ''), spec.get('org', ''),
877
+ spec.get('author', ''), spec.get('date'), spec.get('doc_no', ''),
878
+ spec.get('cover_fields'),
879
+ # 封面信息栏默认排成无框线两列表:冒号天然对齐,**而且机器读得出来**。
880
+ # 写成居中段落的话,跨文档一致性核对拿不到字段,整类检查静默失效。
881
+ spec.get('cover_table', True))
882
+ if spec.get('toc', True):
883
+ b.add_toc()
884
+ if spec.get('cover', True) or spec.get('toc', True):
885
+ b.start_body(spec.get('restart_page_numbers', True))
886
+ blocks = blocks0
887
+ refs = {}
888
+ for blk in (blocks or []):
889
+ t = str(blk.get('type', 'p')).lower()
890
+ if t in ('table', 'figure', 'kv'):
891
+ cap, rid = _split_xref(blk.get('caption', ''))
892
+ blk = {**blk, 'caption': cap}
893
+ rid = rid or blk.get('id')
894
+ b.last_label = None
895
+ else:
896
+ rid = None
897
+ if t in ('h1', 'h2', 'h3', 'h4'):
898
+ b.add_heading(blk.get('text', ''), int(t[1]), blk.get('numbered'))
899
+ elif t == 'p':
900
+ b.add_para(blk.get('text', ''))
901
+ elif t == 'bullets':
902
+ b.add_bullets(blk.get('items', []))
903
+ elif t == 'ordered':
904
+ b.add_bullets(blk.get('items', []), ordered=True)
905
+ elif t == 'table':
906
+ b.add_table(blk.get('rows', []), blk.get('caption', ''), blk.get('note', ''),
907
+ style=blk.get('style', 'grid'))
908
+ elif t == 'kv':
909
+ b.add_kv_table(blk.get('pairs', []), blk.get('caption', ''))
910
+ elif t == 'figure':
911
+ b.add_figure(blk.get('image', ''), blk.get('caption', ''), blk.get('width_cm'), blk.get('note', ''),
912
+ landscape=bool(blk.get('landscape')))
913
+ elif t == 'note':
914
+ b.add_para(blk.get('text', ''), {'size': '五号', 'east': '楷体'})
915
+ elif t == 'signature':
916
+ b.add_signature(blk.get('roles', ('编制', '审核', '批准')), blk.get('org', spec.get('org', '')))
917
+ elif t == 'pagebreak':
918
+ b.add_page_break()
919
+ if rid and getattr(b, 'last_label', None):
920
+ refs[str(rid)] = b.last_label
921
+ b.resolve_refs(refs)
922
+ b.set_header_footer(spec.get('header', ''), spec.get('footer', ''),
923
+ spec.get('page_numbers', True))
924
+ path, res = b.save(out_path, inspect=spec.get('inspect', True))
925
+ return path, res, tpl_warns + b.warnings, b.labels
926
+
927
+
928
+ def md_to_docx(md_path, out_path, title='', author='', subtitle='', toc=True,
929
+ profile='tech', org='', header='', footer='', **kw):
930
+ """Markdown 文件 → 成品 docx(向后兼容的老入口,内部已换成新引擎)。"""
931
+ text = open(md_path, encoding='utf-8-sig').read() # utf-8-sig 对无 BOM 文件是无害的
932
+ base = os.path.dirname(os.path.abspath(md_path))
933
+ blocks = md_to_blocks(text)
934
+ for blk in blocks:
935
+ if blk.get('type') == 'figure':
936
+ src = blk['image']
937
+ if not os.path.isabs(src):
938
+ blk['image'] = os.path.join(base, src)
939
+ spec = {'profile': profile, 'title': title, 'subtitle': subtitle, 'author': author,
940
+ 'org': org, 'toc': toc, 'cover': bool(title), 'header': header, 'footer': footer,
941
+ 'blocks': blocks}
942
+ spec.update(kw)
943
+ path = build(spec, out_path)[0]
944
+ return path
945
+
946
+
947
+ def main(argv):
948
+ if len(argv) < 2:
949
+ print('用法:python docx_build.py spec.json 输出.docx | python docx_build.py --demo')
950
+ return 2
951
+ if argv[0] == '--demo':
952
+ return _demo(argv[1])
953
+ spec = json.load(open(argv[0], encoding='utf-8'))
954
+ try:
955
+ path, res, warns, labels = build(spec, argv[1])
956
+ except Exception as e:
957
+ print(json.dumps({'error': f'{type(e).__name__}: {e}'}, ensure_ascii=False))
958
+ return 1
959
+ out = {'path': path, 'warnings': warns, 'labels': labels}
960
+ if res:
961
+ import docx_check
962
+ out['check'] = {'counts': res['counts'], 'stats': res['stats']}
963
+ out['report'] = docx_check.report(res, show_info=False)
964
+ print(json.dumps(out, ensure_ascii=False, default=str))
965
+ return 0
966
+
967
+
968
+ def _demo(out):
969
+ b = DocBuilder('tech')
970
+ b.add_cover('智能运维平台研发项目', '项目研发实施计划', org='数字技术研究院', doc_no='YF-2026-014')
971
+ b.add_toc().start_body()
972
+ b.add_heading('项目概述', 1)
973
+ b.add_para('本项目围绕设备故障的**提前发现**展开,目标是把平均故障发现时间从 42 分钟压到 8 分钟以内。')
974
+ b.add_heading('建设目标', 2)
975
+ b.add_bullets(['告警准确率 ≥ 92%', '单节点接入成本 ≤ 1.2 人日'])
976
+ b.add_para('三项考核指标的现状、目标与验证方式如表 1 所示,全部写入合同附件三。')
977
+ b.add_table([['指标', '现状', '目标值', '验证方式'],
978
+ ['平均故障发现时间', '42 分钟', '≤ 8 分钟', '生产环境连续 30 天采样'],
979
+ ['告警准确率', '61%', '≥ 92%', '与工单系统比对'],
980
+ ['单节点接入成本', '3.5 人日', '≤ 1.2 人日', '实施记录统计']],
981
+ caption='关键考核指标', note='口径以合同附件三为准')
982
+ b.add_heading('实施计划', 1)
983
+ b.add_para('分三个阶段推进,里程碑与交付物见下表。')
984
+ b.add_signature(org='数字技术研究院')
985
+ b.set_header_footer('智能运维平台研发项目 · 研发实施计划', '数字技术研究院')
986
+ p, res = b.save(out)
987
+ print(f'demo → {p}')
988
+ if res:
989
+ import docx_check
990
+ print(docx_check.report(res))
991
+ return 0
992
+
993
+
994
+ if __name__ == '__main__':
995
+ sys.exit(main(sys.argv[1:]))