create-yss-spec 3.5.2 → 3.5.3

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 (438) hide show
  1. package/package.json +1 -1
  2. package/src/template/asset-runtime.js +3 -1
  3. package/template/.agents/skills/.strategic-design-skills-manifest.json +1 -1
  4. package/template/.agents/skills/codebase-design/SKILL.md +4 -0
  5. package/template/{.codex/skills/improve-codebase-architecture/SKILL.md → .agents/skills/codebase-design/references/architecture-audit.md} +3 -9
  6. package/template/.agents/skills/frontend-commit/SKILL.md +4 -93
  7. package/template/.agents/skills/git-commit-core/SKILL.md +18 -0
  8. package/template/.agents/skills/java-backend-commit/SKILL.md +4 -96
  9. package/template/.agents/skills/llm-wiki/SKILL.md +12 -13
  10. package/template/.agents/skills/llm-wiki/assets/CLAUDE.md.template +7 -1
  11. package/template/.agents/skills/llm-wiki/references/compile.md +21 -114
  12. package/template/.agents/skills/llm-wiki/references/ingest.md +8 -33
  13. package/template/.agents/skills/llm-wiki/references/lint.md +9 -46
  14. package/template/.agents/skills/llm-wiki/references/query.md +9 -18
  15. package/template/.agents/skills/llm-wiki/references/schema.md +56 -29
  16. package/template/.agents/skills/llm-wiki/references/transactions.md +48 -0
  17. package/template/.agents/skills/llm-wiki/references/writing.md +4 -4
  18. package/template/.agents/skills/llm-wiki/scripts/advise.mjs +46 -58
  19. package/template/.agents/skills/llm-wiki/scripts/advise.test.mjs +11 -16
  20. package/template/.agents/skills/llm-wiki/scripts/core.mjs +262 -0
  21. package/template/.agents/skills/llm-wiki/scripts/extract.mjs +35 -18
  22. package/template/.agents/skills/llm-wiki/scripts/extract.test.mjs +5 -8
  23. package/template/.agents/skills/llm-wiki/scripts/feedback.mjs +51 -0
  24. package/template/.agents/skills/llm-wiki/scripts/inventory.mjs +43 -171
  25. package/template/.agents/skills/llm-wiki/scripts/inventory.test.mjs +25 -18
  26. package/template/.agents/skills/llm-wiki/scripts/lint-wikilinks.mjs +69 -92
  27. package/template/.agents/skills/llm-wiki/scripts/lint-wikilinks.test.mjs +25 -37
  28. package/template/.agents/skills/llm-wiki/scripts/migrate.mjs +51 -0
  29. package/template/.agents/skills/llm-wiki/scripts/query.mjs +149 -0
  30. package/template/.agents/skills/llm-wiki/scripts/regressions.test.mjs +101 -0
  31. package/template/.agents/skills/llm-wiki/scripts/sources.mjs +166 -0
  32. package/template/.agents/skills/llm-wiki/scripts/transaction.mjs +384 -0
  33. package/template/.agents/skills/llm-wiki/scripts/v2.test.mjs +354 -0
  34. package/template/.agents/skills/prototype/LOGIC.md +1 -1
  35. package/template/.agents/skills/prototype/SKILL.md +5 -19
  36. package/template/.agents/skills/setup-matt-pocock-skills/SKILL.md +3 -3
  37. package/template/.agents/skills/yss-implementation-contract-compiler/SKILL.md +2 -0
  38. package/template/.agents/skills/yss-implementation-contract-compiler/references/boundaries.md +2 -3
  39. package/template/.agents/skills/yss-implementation-contract-compiler/references/yss-skill-execution-result.md +14 -0
  40. package/template/.agents/skills/yss-product-lifecycle/SKILL.md +8 -8
  41. package/template/.agents/skills/yss-product-lifecycle/references/external-input-questionnaire.md +19 -0
  42. package/template/.agents/skills/yss-product-lifecycle/references/matt-yss-adapter.md +5 -5
  43. package/template/.agents/skills/yss-product-lifecycle/references/orchestration-contract.yaml +20 -4
  44. package/template/.agents/skills/yss-product-lifecycle/references/orchestration.md +2 -2
  45. package/template/.agents/skills/yss-product-lifecycle/references/plan-requirements.md +10 -0
  46. package/template/.agents/skills/yss-product-lifecycle/references/state-model.md +1 -1
  47. package/template/.agents/skills/yss-research/SKILL.md +4 -0
  48. package/template/.codex/skills/codebase-design/SKILL.md +4 -0
  49. package/template/{.pi/skills/improve-codebase-architecture/SKILL.md → .codex/skills/codebase-design/references/architecture-audit.md} +3 -9
  50. package/template/.codex/skills/frontend-commit/SKILL.md +4 -93
  51. package/template/.codex/skills/git-commit-core/SKILL.md +18 -0
  52. package/template/.codex/skills/java-backend-commit/SKILL.md +4 -96
  53. package/template/.codex/skills/llm-wiki/SKILL.md +12 -13
  54. package/template/.codex/skills/llm-wiki/assets/CLAUDE.md.template +7 -1
  55. package/template/.codex/skills/llm-wiki/references/compile.md +21 -114
  56. package/template/.codex/skills/llm-wiki/references/ingest.md +8 -33
  57. package/template/.codex/skills/llm-wiki/references/lint.md +9 -46
  58. package/template/.codex/skills/llm-wiki/references/query.md +9 -18
  59. package/template/.codex/skills/llm-wiki/references/schema.md +56 -29
  60. package/template/.codex/skills/llm-wiki/references/transactions.md +48 -0
  61. package/template/.codex/skills/llm-wiki/references/writing.md +4 -4
  62. package/template/.codex/skills/llm-wiki/scripts/advise.mjs +46 -58
  63. package/template/.codex/skills/llm-wiki/scripts/advise.test.mjs +11 -16
  64. package/template/.codex/skills/llm-wiki/scripts/core.mjs +262 -0
  65. package/template/.codex/skills/llm-wiki/scripts/extract.mjs +35 -18
  66. package/template/.codex/skills/llm-wiki/scripts/extract.test.mjs +5 -8
  67. package/template/.codex/skills/llm-wiki/scripts/feedback.mjs +51 -0
  68. package/template/.codex/skills/llm-wiki/scripts/inventory.mjs +43 -171
  69. package/template/.codex/skills/llm-wiki/scripts/inventory.test.mjs +25 -18
  70. package/template/.codex/skills/llm-wiki/scripts/lint-wikilinks.mjs +69 -92
  71. package/template/.codex/skills/llm-wiki/scripts/lint-wikilinks.test.mjs +25 -37
  72. package/template/.codex/skills/llm-wiki/scripts/migrate.mjs +51 -0
  73. package/template/.codex/skills/llm-wiki/scripts/query.mjs +149 -0
  74. package/template/.codex/skills/llm-wiki/scripts/regressions.test.mjs +101 -0
  75. package/template/.codex/skills/llm-wiki/scripts/sources.mjs +166 -0
  76. package/template/.codex/skills/llm-wiki/scripts/transaction.mjs +384 -0
  77. package/template/.codex/skills/llm-wiki/scripts/v2.test.mjs +354 -0
  78. package/template/.codex/skills/prototype/LOGIC.md +1 -1
  79. package/template/.codex/skills/prototype/SKILL.md +5 -19
  80. package/template/.codex/skills/setup-matt-pocock-skills/SKILL.md +3 -3
  81. package/template/.codex/skills/yss-implementation-contract-compiler/SKILL.md +2 -0
  82. package/template/.codex/skills/yss-implementation-contract-compiler/references/boundaries.md +2 -3
  83. package/template/.codex/skills/yss-implementation-contract-compiler/references/yss-skill-execution-result.md +14 -0
  84. package/template/.codex/skills/yss-product-lifecycle/SKILL.md +8 -8
  85. package/template/.codex/skills/yss-product-lifecycle/references/external-input-questionnaire.md +19 -0
  86. package/template/.codex/skills/yss-product-lifecycle/references/matt-yss-adapter.md +5 -5
  87. package/template/.codex/skills/yss-product-lifecycle/references/orchestration-contract.yaml +20 -4
  88. package/template/.codex/skills/yss-product-lifecycle/references/orchestration.md +2 -2
  89. package/template/.codex/skills/yss-product-lifecycle/references/plan-requirements.md +10 -0
  90. package/template/.codex/skills/yss-product-lifecycle/references/state-model.md +1 -1
  91. package/template/.codex/skills/yss-research/SKILL.md +4 -0
  92. package/template/.cursor/skills/codebase-design/SKILL.md +4 -0
  93. package/template/.cursor/skills/{improve-codebase-architecture/SKILL.md → codebase-design/references/architecture-audit.md} +3 -9
  94. package/template/.cursor/skills/frontend-commit/SKILL.md +4 -93
  95. package/template/.cursor/skills/git-commit-core/SKILL.md +18 -0
  96. package/template/.cursor/skills/java-backend-commit/SKILL.md +4 -96
  97. package/template/.cursor/skills/llm-wiki/SKILL.md +12 -13
  98. package/template/.cursor/skills/llm-wiki/assets/CLAUDE.md.template +7 -1
  99. package/template/.cursor/skills/llm-wiki/references/compile.md +21 -114
  100. package/template/.cursor/skills/llm-wiki/references/ingest.md +8 -33
  101. package/template/.cursor/skills/llm-wiki/references/lint.md +9 -46
  102. package/template/.cursor/skills/llm-wiki/references/query.md +9 -18
  103. package/template/.cursor/skills/llm-wiki/references/schema.md +56 -29
  104. package/template/.cursor/skills/llm-wiki/references/transactions.md +48 -0
  105. package/template/.cursor/skills/llm-wiki/references/writing.md +4 -4
  106. package/template/.cursor/skills/llm-wiki/scripts/advise.mjs +46 -58
  107. package/template/.cursor/skills/llm-wiki/scripts/advise.test.mjs +11 -16
  108. package/template/.cursor/skills/llm-wiki/scripts/core.mjs +262 -0
  109. package/template/.cursor/skills/llm-wiki/scripts/extract.mjs +35 -18
  110. package/template/.cursor/skills/llm-wiki/scripts/extract.test.mjs +5 -8
  111. package/template/.cursor/skills/llm-wiki/scripts/feedback.mjs +51 -0
  112. package/template/.cursor/skills/llm-wiki/scripts/inventory.mjs +43 -171
  113. package/template/.cursor/skills/llm-wiki/scripts/inventory.test.mjs +25 -18
  114. package/template/.cursor/skills/llm-wiki/scripts/lint-wikilinks.mjs +69 -92
  115. package/template/.cursor/skills/llm-wiki/scripts/lint-wikilinks.test.mjs +25 -37
  116. package/template/.cursor/skills/llm-wiki/scripts/migrate.mjs +51 -0
  117. package/template/.cursor/skills/llm-wiki/scripts/query.mjs +149 -0
  118. package/template/.cursor/skills/llm-wiki/scripts/regressions.test.mjs +101 -0
  119. package/template/.cursor/skills/llm-wiki/scripts/sources.mjs +166 -0
  120. package/template/.cursor/skills/llm-wiki/scripts/transaction.mjs +384 -0
  121. package/template/.cursor/skills/llm-wiki/scripts/v2.test.mjs +354 -0
  122. package/template/.cursor/skills/prototype/LOGIC.md +1 -1
  123. package/template/.cursor/skills/prototype/SKILL.md +5 -19
  124. package/template/.cursor/skills/setup-matt-pocock-skills/SKILL.md +3 -3
  125. package/template/.cursor/skills/yss-implementation-contract-compiler/SKILL.md +2 -0
  126. package/template/.cursor/skills/yss-implementation-contract-compiler/references/boundaries.md +2 -3
  127. package/template/.cursor/skills/yss-implementation-contract-compiler/references/yss-skill-execution-result.md +14 -0
  128. package/template/.cursor/skills/yss-product-lifecycle/SKILL.md +8 -8
  129. package/template/.cursor/skills/yss-product-lifecycle/references/external-input-questionnaire.md +19 -0
  130. package/template/.cursor/skills/yss-product-lifecycle/references/matt-yss-adapter.md +5 -5
  131. package/template/.cursor/skills/yss-product-lifecycle/references/orchestration-contract.yaml +20 -4
  132. package/template/.cursor/skills/yss-product-lifecycle/references/orchestration.md +2 -2
  133. package/template/.cursor/skills/yss-product-lifecycle/references/plan-requirements.md +10 -0
  134. package/template/.cursor/skills/yss-product-lifecycle/references/state-model.md +1 -1
  135. package/template/.cursor/skills/yss-research/SKILL.md +4 -0
  136. package/template/.pi/skills/codebase-design/SKILL.md +4 -0
  137. package/template/{.agents/skills/improve-codebase-architecture/SKILL.md → .pi/skills/codebase-design/references/architecture-audit.md} +3 -9
  138. package/template/.pi/skills/frontend-commit/SKILL.md +4 -93
  139. package/template/.pi/skills/git-commit-core/SKILL.md +18 -0
  140. package/template/.pi/skills/java-backend-commit/SKILL.md +4 -96
  141. package/template/.pi/skills/llm-wiki/SKILL.md +12 -13
  142. package/template/.pi/skills/llm-wiki/assets/CLAUDE.md.template +7 -1
  143. package/template/.pi/skills/llm-wiki/references/compile.md +21 -114
  144. package/template/.pi/skills/llm-wiki/references/ingest.md +8 -33
  145. package/template/.pi/skills/llm-wiki/references/lint.md +9 -46
  146. package/template/.pi/skills/llm-wiki/references/query.md +9 -18
  147. package/template/.pi/skills/llm-wiki/references/schema.md +56 -29
  148. package/template/.pi/skills/llm-wiki/references/transactions.md +48 -0
  149. package/template/.pi/skills/llm-wiki/references/writing.md +4 -4
  150. package/template/.pi/skills/llm-wiki/scripts/advise.mjs +46 -58
  151. package/template/.pi/skills/llm-wiki/scripts/advise.test.mjs +11 -16
  152. package/template/.pi/skills/llm-wiki/scripts/core.mjs +262 -0
  153. package/template/.pi/skills/llm-wiki/scripts/extract.mjs +35 -18
  154. package/template/.pi/skills/llm-wiki/scripts/extract.test.mjs +5 -8
  155. package/template/.pi/skills/llm-wiki/scripts/feedback.mjs +51 -0
  156. package/template/.pi/skills/llm-wiki/scripts/inventory.mjs +43 -171
  157. package/template/.pi/skills/llm-wiki/scripts/inventory.test.mjs +25 -18
  158. package/template/.pi/skills/llm-wiki/scripts/lint-wikilinks.mjs +69 -92
  159. package/template/.pi/skills/llm-wiki/scripts/lint-wikilinks.test.mjs +25 -37
  160. package/template/.pi/skills/llm-wiki/scripts/migrate.mjs +51 -0
  161. package/template/.pi/skills/llm-wiki/scripts/query.mjs +149 -0
  162. package/template/.pi/skills/llm-wiki/scripts/regressions.test.mjs +101 -0
  163. package/template/.pi/skills/llm-wiki/scripts/sources.mjs +166 -0
  164. package/template/.pi/skills/llm-wiki/scripts/transaction.mjs +384 -0
  165. package/template/.pi/skills/llm-wiki/scripts/v2.test.mjs +354 -0
  166. package/template/.pi/skills/prototype/LOGIC.md +1 -1
  167. package/template/.pi/skills/prototype/SKILL.md +5 -19
  168. package/template/.pi/skills/setup-matt-pocock-skills/SKILL.md +3 -3
  169. package/template/.pi/skills/yss-implementation-contract-compiler/SKILL.md +2 -0
  170. package/template/.pi/skills/yss-implementation-contract-compiler/references/boundaries.md +2 -3
  171. package/template/.pi/skills/yss-implementation-contract-compiler/references/yss-skill-execution-result.md +14 -0
  172. package/template/.pi/skills/yss-product-lifecycle/SKILL.md +8 -8
  173. package/template/.pi/skills/yss-product-lifecycle/references/external-input-questionnaire.md +19 -0
  174. package/template/.pi/skills/yss-product-lifecycle/references/matt-yss-adapter.md +5 -5
  175. package/template/.pi/skills/yss-product-lifecycle/references/orchestration-contract.yaml +20 -4
  176. package/template/.pi/skills/yss-product-lifecycle/references/orchestration.md +2 -2
  177. package/template/.pi/skills/yss-product-lifecycle/references/plan-requirements.md +10 -0
  178. package/template/.pi/skills/yss-product-lifecycle/references/state-model.md +1 -1
  179. package/template/.pi/skills/yss-research/SKILL.md +4 -0
  180. package/template/.template-spec/agents/README.md +1 -1
  181. package/template/.template-spec/agents/digital-human-roles.yaml +1 -1
  182. package/template/.template-spec/agents/skill-migrations.md +15 -0
  183. package/template/.template-spec/agents/yss-skill-registry.yaml +10 -29
  184. package/template/.template-spec/architecture/README.md +3 -34
  185. package/template/.template-spec/plan/templates/competitive-analysis-template.md +2 -2
  186. package/template/.template-spec/process/contract-reading.md +24 -0
  187. package/template/.template-spec/process/lifecycle-registry-baseline.json +3 -1
  188. package/template/.template-spec/process/lifecycle-registry.yaml +6 -0
  189. package/template/.template-spec/process/research-completion.md +23 -0
  190. package/template/.template-spec/process/schemas/digital-human-task-package.schema.json +202 -12
  191. package/template/.template-spec/process/schemas/frontend-implementation-evidence.schema.json +7 -15
  192. package/template/.template-spec/process/subagent-collaboration.md +8 -0
  193. package/template/.template-spec/process/templates/frontend-implementation-verification-template.yaml +15 -1
  194. package/template/AGENTS.md +1 -1
  195. package/template/CONTEXT.md +5 -1
  196. package/template/README.md +12 -24
  197. package/template/scripts/contract +5 -5
  198. package/template/scripts/lib/contract-views.mjs +57 -2
  199. package/template/scripts/lib/execution-evidence.mjs +51 -0
  200. package/template/scripts/lib/harness-execution-scope.mjs +1 -1
  201. package/template/scripts/lib/implementation-contract-compiler.mjs +2 -0
  202. package/template/scripts/lib/lifecycle-context-query.mjs +15 -3
  203. package/template/scripts/lib/lifecycle-status.mjs +32 -6
  204. package/template/scripts/lib/lifecycle-transition.mjs +19 -1
  205. package/template/scripts/lib/maintenance-research.mjs +63 -0
  206. package/template/scripts/lib/read-only-intake.mjs +132 -0
  207. package/template/scripts/lib/skill-registry.mjs +2 -1
  208. package/template/scripts/lib/skill-supply-chain.mjs +4 -0
  209. package/template/scripts/lib/slice-task-package.mjs +1 -1
  210. package/template/scripts/lib/task-package.mjs +9 -2
  211. package/template/scripts/lifecycle-status +2 -2
  212. package/template/scripts/prepare-read-only-intake +16 -0
  213. package/template/scripts/run-read-only-intake +14 -0
  214. package/template/scripts/verify-digital-human-task-package +2 -2
  215. package/template/scripts/verify-frontend-implementation-evidence +24 -1
  216. package/template/scripts/verify-maintenance-research +13 -0
  217. package/template/skills-lock.json +19 -76
  218. package/template.manifest.json +118 -29
  219. package/template.snapshot.json +5 -6
  220. package/template/.agents/skills/grill-with-docs/SKILL.md +0 -20
  221. package/template/.agents/skills/grill-with-docs/agents/openai.yaml +0 -5
  222. package/template/.agents/skills/improve-codebase-architecture/agents/openai.yaml +0 -5
  223. package/template/.agents/skills/prototype/UI.md +0 -112
  224. package/template/.agents/skills/setup-matt-pocock-skills/domain.md +0 -37
  225. package/template/.agents/skills/to-questionnaire/SKILL.md +0 -55
  226. package/template/.agents/skills/to-questionnaire/agents/openai.yaml +0 -5
  227. package/template/.agents/skills/wait-what/SKILL.md +0 -7
  228. package/template/.agents/skills/wait-what/agents/openai.yaml +0 -5
  229. package/template/.codex/skills/data-analytics/.app.json +0 -84
  230. package/template/.codex/skills/data-analytics/.codex-plugin/plugin.json +0 -66
  231. package/template/.codex/skills/data-analytics/.mcp.json +0 -30
  232. package/template/.codex/skills/data-analytics/AGENTS.md +0 -20
  233. package/template/.codex/skills/data-analytics/DEPENDENCIES.MD +0 -27
  234. package/template/.codex/skills/data-analytics/README.md +0 -60
  235. package/template/.codex/skills/data-analytics/__yss_dotfile__.gitignore +0 -2
  236. package/template/.codex/skills/data-analytics/assets/datascience-artifact-widget.html +0 -16
  237. package/template/.codex/skills/data-analytics/assets/datascience-artifact-widget.html.gz.b64.part001 +0 -1
  238. package/template/.codex/skills/data-analytics/assets/datascience-artifact-widget.html.gz.b64.part002 +0 -1
  239. package/template/.codex/skills/data-analytics/assets/datascience-artifact-widget.html.gz.b64.part003 +0 -1
  240. package/template/.codex/skills/data-analytics/assets/datascience-artifact-widget.html.gz.b64.part004 +0 -1
  241. package/template/.codex/skills/data-analytics/assets/datascience-artifact-widget.html.gz.b64.part005 +0 -1
  242. package/template/.codex/skills/data-analytics/assets/datascience-artifact-widget.html.gz.b64.part006 +0 -1
  243. package/template/.codex/skills/data-analytics/assets/datascience-chart-widget.html +0 -16
  244. package/template/.codex/skills/data-analytics/assets/datascience-chart-widget.html.gz.b64.part001 +0 -1
  245. package/template/.codex/skills/data-analytics/assets/datascience-chart-widget.html.gz.b64.part002 +0 -1
  246. package/template/.codex/skills/data-analytics/assets/datascience-chart-widget.html.gz.b64.part003 +0 -1
  247. package/template/.codex/skills/data-analytics/assets/datascience-chart-widget.html.gz.b64.part004 +0 -1
  248. package/template/.codex/skills/data-analytics/assets/datascience-chart-widget.html.gz.b64.part005 +0 -1
  249. package/template/.codex/skills/data-analytics/assets/datascience-chart-widget.html.gz.b64.part006 +0 -1
  250. package/template/.codex/skills/data-analytics/assets/datascience-chart-widget.html.gz.b64.part007 +0 -1
  251. package/template/.codex/skills/data-analytics/assets/datascience-small.svg +0 -5
  252. package/template/.codex/skills/data-analytics/assets/datascience-table-widget.html +0 -16
  253. package/template/.codex/skills/data-analytics/assets/datascience-table-widget.html.gz.b64.part001 +0 -1
  254. package/template/.codex/skills/data-analytics/assets/datascience-table-widget.html.gz.b64.part002 +0 -1
  255. package/template/.codex/skills/data-analytics/assets/datascience-table-widget.html.gz.b64.part003 +0 -1
  256. package/template/.codex/skills/data-analytics/assets/datascience-table-widget.html.gz.b64.part004 +0 -1
  257. package/template/.codex/skills/data-analytics/assets/datascience.png +0 -0
  258. package/template/.codex/skills/data-analytics/assets/datascience.svg +0 -10
  259. package/template/.codex/skills/data-analytics/mcp/server.cjs +0 -2964
  260. package/template/.codex/skills/data-analytics/package-lock.json +0 -3048
  261. package/template/.codex/skills/data-analytics/package.json +0 -43
  262. package/template/.codex/skills/data-analytics/scripts/normalize-widget-assets.mjs +0 -75
  263. package/template/.codex/skills/data-analytics/skills/analyze-data-quality/SKILL.md +0 -137
  264. package/template/.codex/skills/data-analytics/skills/analyze-data-quality/agents/openai.yaml +0 -6
  265. package/template/.codex/skills/data-analytics/skills/analyze-data-quality/references/quality-checks.md +0 -29
  266. package/template/.codex/skills/data-analytics/skills/build-dashboard/SKILL.md +0 -148
  267. package/template/.codex/skills/data-analytics/skills/build-dashboard/agents/openai.yaml +0 -6
  268. package/template/.codex/skills/data-analytics/skills/build-dashboard/specifications/bi-platform-dashboard.md +0 -18
  269. package/template/.codex/skills/data-analytics/skills/build-dashboard/specifications/html-dashboard.md +0 -24
  270. package/template/.codex/skills/data-analytics/skills/build-dashboard/specifications/mcp-artifact-dashboard.md +0 -71
  271. package/template/.codex/skills/data-analytics/skills/build-dashboard/specifications/streamlit-dashboard.md +0 -85
  272. package/template/.codex/skills/data-analytics/skills/build-report/SKILL.md +0 -207
  273. package/template/.codex/skills/data-analytics/skills/build-report/agents/openai.yaml +0 -6
  274. package/template/.codex/skills/data-analytics/skills/build-report/assets/executive-report-shell.html +0 -70
  275. package/template/.codex/skills/data-analytics/skills/build-report/assets/technical-report-shell.html +0 -66
  276. package/template/.codex/skills/data-analytics/skills/build-report/report-to-google-doc/SKILL.md +0 -108
  277. package/template/.codex/skills/data-analytics/skills/build-report/report-to-google-doc/agents/openai.yaml +0 -6
  278. package/template/.codex/skills/data-analytics/skills/build-report/report-to-google-doc/scripts/report_to_google_doc/__init__.py +0 -1
  279. package/template/.codex/skills/data-analytics/skills/build-report/report-to-google-doc/scripts/report_to_google_doc/cli.py +0 -52
  280. package/template/.codex/skills/data-analytics/skills/build-report/report-to-google-doc/scripts/report_to_google_doc/constants.py +0 -61
  281. package/template/.codex/skills/data-analytics/skills/build-report/report-to-google-doc/scripts/report_to_google_doc/docx_writer.py +0 -362
  282. package/template/.codex/skills/data-analytics/skills/build-report/report-to-google-doc/scripts/report_to_google_doc/html_parser.py +0 -829
  283. package/template/.codex/skills/data-analytics/skills/build-report/report-to-google-doc/scripts/report_to_google_doc/model.py +0 -30
  284. package/template/.codex/skills/data-analytics/skills/build-report/report-to-google-doc/scripts/report_to_google_doc/plan.py +0 -127
  285. package/template/.codex/skills/data-analytics/skills/build-report/report-to-google-doc/scripts/report_to_google_doc/quality.py +0 -374
  286. package/template/.codex/skills/data-analytics/skills/build-report/report-to-google-doc/scripts/report_to_google_doc/rendering.py +0 -613
  287. package/template/.codex/skills/data-analytics/skills/build-report/report-to-google-doc/scripts/report_to_google_doc/table_utils.py +0 -54
  288. package/template/.codex/skills/data-analytics/skills/build-report/report-to-google-doc/scripts/report_to_google_doc/utils.py +0 -16
  289. package/template/.codex/skills/data-analytics/skills/build-report/report-to-google-doc/scripts/report_to_google_doc_plan.py +0 -9
  290. package/template/.codex/skills/data-analytics/skills/build-report/report-to-google-doc/tests/test_delivery_plan.py +0 -45
  291. package/template/.codex/skills/data-analytics/skills/build-report/report-to-google-slides/SKILL.md +0 -77
  292. package/template/.codex/skills/data-analytics/skills/build-report/report-to-google-slides/agents/openai.yaml +0 -6
  293. package/template/.codex/skills/data-analytics/skills/build-report/report-to-google-slides/scripts/report_to_google_slides.py +0 -2379
  294. package/template/.codex/skills/data-analytics/skills/build-report/report-to-pdf/SKILL.md +0 -88
  295. package/template/.codex/skills/data-analytics/skills/build-report/report-to-pdf/agents/openai.yaml +0 -6
  296. package/template/.codex/skills/data-analytics/skills/build-report/specifications/executive-report.md +0 -97
  297. package/template/.codex/skills/data-analytics/skills/build-report/specifications/mcp-app-report.md +0 -59
  298. package/template/.codex/skills/data-analytics/skills/build-report/specifications/technical-report.md +0 -75
  299. package/template/.codex/skills/data-analytics/skills/design-kpis/SKILL.md +0 -103
  300. package/template/.codex/skills/data-analytics/skills/design-kpis/agents/openai.yaml +0 -6
  301. package/template/.codex/skills/data-analytics/skills/gather-business-context/SKILL.md +0 -68
  302. package/template/.codex/skills/data-analytics/skills/gather-business-context/agents/openai.yaml +0 -6
  303. package/template/.codex/skills/data-analytics/skills/index/SKILL.md +0 -251
  304. package/template/.codex/skills/data-analytics/skills/index/agents/openai.yaml +0 -6
  305. package/template/.codex/skills/data-analytics/skills/jupyter-notebooks/SKILL.md +0 -131
  306. package/template/.codex/skills/data-analytics/skills/jupyter-notebooks/agents/openai.yaml +0 -6
  307. package/template/.codex/skills/data-analytics/skills/kpi-reporting/SKILL.md +0 -141
  308. package/template/.codex/skills/data-analytics/skills/kpi-reporting/agents/openai.yaml +0 -6
  309. package/template/.codex/skills/data-analytics/skills/kpi-reporting/references/report-templates.md +0 -32
  310. package/template/.codex/skills/data-analytics/skills/market-sizing/SKILL.md +0 -106
  311. package/template/.codex/skills/data-analytics/skills/market-sizing/agents/openai.yaml +0 -6
  312. package/template/.codex/skills/data-analytics/skills/metric-diagnostics/SKILL.md +0 -130
  313. package/template/.codex/skills/data-analytics/skills/metric-diagnostics/agents/openai.yaml +0 -6
  314. package/template/.codex/skills/data-analytics/skills/product-business-analysis/SKILL.md +0 -141
  315. package/template/.codex/skills/data-analytics/skills/product-business-analysis/agents/openai.yaml +0 -6
  316. package/template/.codex/skills/data-analytics/skills/spreadsheets/SKILL.md +0 -178
  317. package/template/.codex/skills/data-analytics/skills/spreadsheets/agents/openai.yaml +0 -9
  318. package/template/.codex/skills/data-analytics/skills/spreadsheets/assets/file-spreadsheet.png +0 -0
  319. package/template/.codex/skills/data-analytics/skills/spreadsheets/charts.md +0 -31
  320. package/template/.codex/skills/data-analytics/skills/spreadsheets/references/artifact_tool_api.md +0 -466
  321. package/template/.codex/skills/data-analytics/skills/spreadsheets/style_guidelines.md +0 -99
  322. package/template/.codex/skills/data-analytics/skills/user-context/SKILL.md +0 -197
  323. package/template/.codex/skills/data-analytics/skills/user-context/agents/openai.yaml +0 -6
  324. package/template/.codex/skills/data-analytics/skills/user-context/plugin-author-config/automation-config.md +0 -26
  325. package/template/.codex/skills/data-analytics/skills/user-context/plugin-author-config/source-category-config.json +0 -51
  326. package/template/.codex/skills/data-analytics/skills/user-context/plugin-author-config/user-context-config.md +0 -66
  327. package/template/.codex/skills/data-analytics/skills/user-context/references/automation.md +0 -69
  328. package/template/.codex/skills/data-analytics/skills/user-context/references/onboarding-examples.md +0 -204
  329. package/template/.codex/skills/data-analytics/skills/user-context/references/onboarding-state-template.json +0 -87
  330. package/template/.codex/skills/data-analytics/skills/user-context/references/onboarding.md +0 -497
  331. package/template/.codex/skills/data-analytics/skills/user-context/references/semantic-layer/connector-playbook.md +0 -74
  332. package/template/.codex/skills/data-analytics/skills/user-context/references/semantic-layer/setup.md +0 -65
  333. package/template/.codex/skills/data-analytics/skills/user-context/references/semantic-layer/skill-template.md +0 -160
  334. package/template/.codex/skills/data-analytics/skills/user-context/references/semantic-layer/source-intake.md +0 -75
  335. package/template/.codex/skills/data-analytics/skills/user-context/references/semantic-layer/weekly-polling-automation.md +0 -96
  336. package/template/.codex/skills/data-analytics/skills/user-context/references/source-category-runtime.md +0 -263
  337. package/template/.codex/skills/data-analytics/skills/user-context/scripts/data_analytics_preflight.py +0 -1462
  338. package/template/.codex/skills/data-analytics/skills/user-context/scripts/init_user_context_state.py +0 -128
  339. package/template/.codex/skills/data-analytics/skills/user-context/scripts/reset_user_context_state.py +0 -101
  340. package/template/.codex/skills/data-analytics/skills/user-context/scripts/validate_user_context_preflight.py +0 -499
  341. package/template/.codex/skills/data-analytics/skills/user-context/tests/test_state_helpers.py +0 -978
  342. package/template/.codex/skills/data-analytics/skills/validate-data/SKILL.md +0 -126
  343. package/template/.codex/skills/data-analytics/skills/validate-data/agents/openai.yaml +0 -6
  344. package/template/.codex/skills/data-analytics/skills/validate-data/references/validation-methods.md +0 -70
  345. package/template/.codex/skills/data-analytics/skills/visualize-data/SKILL.md +0 -157
  346. package/template/.codex/skills/data-analytics/skills/visualize-data/agents/openai.yaml +0 -6
  347. package/template/.codex/skills/data-analytics/skills/visualize-data/references/seaborn-templates.md +0 -774
  348. package/template/.codex/skills/data-analytics/src/DESIGN.md +0 -222
  349. package/template/.codex/skills/data-analytics/src/analytics-app/App.tsx +0 -3666
  350. package/template/.codex/skills/data-analytics/src/analytics-app/analytics-layout.test.mjs +0 -136
  351. package/template/.codex/skills/data-analytics/src/analytics-app/charting/ChartFrame.tsx +0 -54
  352. package/template/.codex/skills/data-analytics/src/analytics-app/charting/ChartLegend.tsx +0 -96
  353. package/template/.codex/skills/data-analytics/src/analytics-app/charting/ChartRenderer.tsx +0 -1647
  354. package/template/.codex/skills/data-analytics/src/analytics-app/charting/ChartTooltip.tsx +0 -245
  355. package/template/.codex/skills/data-analytics/src/analytics-app/charting/chart-app-helpers.tsx +0 -462
  356. package/template/.codex/skills/data-analytics/src/analytics-app/charting/chart-capabilities.ts +0 -107
  357. package/template/.codex/skills/data-analytics/src/analytics-app/charting/chart-compatibility.ts +0 -164
  358. package/template/.codex/skills/data-analytics/src/analytics-app/charting/chart-contract.ts +0 -192
  359. package/template/.codex/skills/data-analytics/src/analytics-app/charting/chart-theme.ts +0 -203
  360. package/template/.codex/skills/data-analytics/src/analytics-app/charting/chart-tokens.css +0 -619
  361. package/template/.codex/skills/data-analytics/src/analytics-app/charting/chart-transforms.ts +0 -402
  362. package/template/.codex/skills/data-analytics/src/analytics-app/fonts/SystemSansVariableVF.woff2 +0 -0
  363. package/template/.codex/skills/data-analytics/src/analytics-app/imageExport.ts +0 -370
  364. package/template/.codex/skills/data-analytics/src/analytics-app/layout/AnalyticsLayoutCanvas.tsx +0 -536
  365. package/template/.codex/skills/data-analytics/src/analytics-app/layout/RichMarkdown.tsx +0 -681
  366. package/template/.codex/skills/data-analytics/src/analytics-app/layout/analyticsLayoutCore.ts +0 -164
  367. package/template/.codex/skills/data-analytics/src/analytics-app/main.tsx +0 -21
  368. package/template/.codex/skills/data-analytics/src/analytics-app/scripts/chart_contract.py +0 -472
  369. package/template/.codex/skills/data-analytics/src/analytics-app/scripts/check_analytics_app_runtime_links.py +0 -75
  370. package/template/.codex/skills/data-analytics/src/analytics-app/scripts/design_contract.py +0 -142
  371. package/template/.codex/skills/data-analytics/src/analytics-app/scripts/package_utils.py +0 -142
  372. package/template/.codex/skills/data-analytics/src/analytics-app/scripts/tests/test_package_utils.py +0 -377
  373. package/template/.codex/skills/data-analytics/src/analytics-app/styles.css +0 -3468
  374. package/template/.codex/skills/data-analytics/src/analytics-app/tables/DataTable.d.ts +0 -44
  375. package/template/.codex/skills/data-analytics/src/analytics-app/tables/DataTable.jsx +0 -639
  376. package/template/.codex/skills/data-analytics/src/analytics-app/tables/data-table.css +0 -327
  377. package/template/.codex/skills/data-analytics/src/analytics-app/tokens.css +0 -225
  378. package/template/.codex/skills/data-analytics/src/analytics-app/types.ts +0 -204
  379. package/template/.codex/skills/data-analytics/src/analytics-app-core.md +0 -85
  380. package/template/.codex/skills/data-analytics/src/codex-style-contract.md +0 -30
  381. package/template/.codex/skills/data-analytics/src/datascience-artifact-widget.html +0 -12
  382. package/template/.codex/skills/data-analytics/src/datascience-artifact-widget.jsx +0 -1008
  383. package/template/.codex/skills/data-analytics/src/datascience-chart-widget.css +0 -2117
  384. package/template/.codex/skills/data-analytics/src/datascience-chart-widget.html +0 -122
  385. package/template/.codex/skills/data-analytics/src/datascience-chart-widget.js +0 -3588
  386. package/template/.codex/skills/data-analytics/src/datascience-table-widget.css +0 -483
  387. package/template/.codex/skills/data-analytics/src/datascience-table-widget.html +0 -32
  388. package/template/.codex/skills/data-analytics/src/datascience-table-widget.js +0 -333
  389. package/template/.codex/skills/data-analytics/src/mcp-host.js +0 -153
  390. package/template/.codex/skills/data-analytics/src/recharts-config.js +0 -315
  391. package/template/.codex/skills/data-analytics/src/recharts-renderer.jsx +0 -294
  392. package/template/.codex/skills/data-analytics/src/sql-source-view.css +0 -33
  393. package/template/.codex/skills/data-analytics/src/sql-source-view.js +0 -157
  394. package/template/.codex/skills/data-analytics/src/styles/codex-theme.css +0 -121
  395. package/template/.codex/skills/data-analytics/src/table-renderer.jsx +0 -28
  396. package/template/.codex/skills/data-analytics/tests/chart-transforms.test.mjs +0 -87
  397. package/template/.codex/skills/data-analytics/tests/funnel-smoke.html +0 -105
  398. package/template/.codex/skills/data-analytics/tests/inline-widget-compare.html +0 -233
  399. package/template/.codex/skills/data-analytics/tests/mcp-server.test.mjs +0 -1500
  400. package/template/.codex/skills/data-analytics/tests/native-style-contract.test.mjs +0 -219
  401. package/template/.codex/skills/data-analytics/tests/recharts-config.test.mjs +0 -144
  402. package/template/.codex/skills/data-analytics/tests/recharts-renderer.test.mjs +0 -11
  403. package/template/.codex/skills/data-analytics/tests/rich-markdown.test.mjs +0 -54
  404. package/template/.codex/skills/data-analytics/tests/widget-render-harness.html +0 -321
  405. package/template/.codex/skills/data-analytics/tsconfig.json +0 -19
  406. package/template/.codex/skills/data-analytics/vite.config.ts +0 -46
  407. package/template/.codex/skills/grill-with-docs/SKILL.md +0 -20
  408. package/template/.codex/skills/grill-with-docs/agents/openai.yaml +0 -5
  409. package/template/.codex/skills/improve-codebase-architecture/agents/openai.yaml +0 -5
  410. package/template/.codex/skills/prototype/UI.md +0 -112
  411. package/template/.codex/skills/setup-matt-pocock-skills/domain.md +0 -37
  412. package/template/.codex/skills/to-questionnaire/SKILL.md +0 -55
  413. package/template/.codex/skills/to-questionnaire/agents/openai.yaml +0 -5
  414. package/template/.codex/skills/wait-what/SKILL.md +0 -7
  415. package/template/.codex/skills/wait-what/agents/openai.yaml +0 -5
  416. package/template/.cursor/skills/grill-with-docs/SKILL.md +0 -20
  417. package/template/.cursor/skills/grill-with-docs/agents/openai.yaml +0 -5
  418. package/template/.cursor/skills/improve-codebase-architecture/agents/openai.yaml +0 -5
  419. package/template/.cursor/skills/prototype/UI.md +0 -112
  420. package/template/.cursor/skills/setup-matt-pocock-skills/domain.md +0 -37
  421. package/template/.cursor/skills/to-questionnaire/SKILL.md +0 -55
  422. package/template/.cursor/skills/to-questionnaire/agents/openai.yaml +0 -5
  423. package/template/.cursor/skills/wait-what/SKILL.md +0 -7
  424. package/template/.cursor/skills/wait-what/agents/openai.yaml +0 -5
  425. package/template/.pi/skills/grill-with-docs/SKILL.md +0 -20
  426. package/template/.pi/skills/grill-with-docs/agents/openai.yaml +0 -5
  427. package/template/.pi/skills/improve-codebase-architecture/agents/openai.yaml +0 -5
  428. package/template/.pi/skills/prototype/UI.md +0 -112
  429. package/template/.pi/skills/setup-matt-pocock-skills/domain.md +0 -37
  430. package/template/.pi/skills/to-questionnaire/SKILL.md +0 -55
  431. package/template/.pi/skills/to-questionnaire/agents/openai.yaml +0 -5
  432. package/template/.pi/skills/wait-what/SKILL.md +0 -7
  433. package/template/.pi/skills/wait-what/agents/openai.yaml +0 -5
  434. package/template/.template-spec/agents/domain.md +0 -31
  435. /package/template/.agents/skills/{improve-codebase-architecture → codebase-design/references}/HTML-REPORT.md +0 -0
  436. /package/template/.codex/skills/{improve-codebase-architecture → codebase-design/references}/HTML-REPORT.md +0 -0
  437. /package/template/.cursor/skills/{improve-codebase-architecture → codebase-design/references}/HTML-REPORT.md +0 -0
  438. /package/template/.pi/skills/{improve-codebase-architecture → codebase-design/references}/HTML-REPORT.md +0 -0
@@ -1,2379 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Convert an HTML analytics report into an editable PPTX deck.
3
-
4
- The script does not call Google APIs. It builds a local PPTX plus deterministic
5
- planning and preflight artifacts that an agent can import into native Google
6
- Slides with the Google Drive connector.
7
- """
8
-
9
- from __future__ import annotations
10
-
11
- import argparse
12
- import json
13
- import math
14
- import re
15
- import sys
16
- import zipfile
17
- from dataclasses import asdict, dataclass, field
18
- from pathlib import Path
19
- from typing import Any
20
-
21
- try:
22
- from bs4 import BeautifulSoup, NavigableString, Tag
23
- except ImportError as exc: # pragma: no cover - dependency failure path
24
- raise SystemExit(
25
- "This helper requires beautifulsoup4. Install it in the active Python "
26
- "environment before running report_to_google_slides.py."
27
- ) from exc
28
-
29
- try:
30
- from PIL import Image, ImageChops, ImageFont
31
- except ImportError as exc: # pragma: no cover - dependency failure path
32
- raise SystemExit(
33
- "This helper requires pillow. Install it in the active Python "
34
- "environment before running report_to_google_slides.py."
35
- ) from exc
36
-
37
- try:
38
- from pptx import Presentation
39
- from pptx.dml.color import RGBColor
40
- from pptx.enum.shapes import MSO_SHAPE
41
- from pptx.enum.text import MSO_ANCHOR, PP_ALIGN
42
- from pptx.oxml.xmlchemy import OxmlElement
43
- from pptx.util import Inches, Pt
44
- except ImportError as exc: # pragma: no cover - dependency failure path
45
- raise SystemExit(
46
- "This helper requires python-pptx. Install it in the active Python "
47
- "environment before running report_to_google_slides.py."
48
- ) from exc
49
-
50
-
51
- SLIDE_W = 13.333333
52
- SLIDE_H = 7.5
53
- PX_PER_PT = 96 / 72
54
- DEFAULT_COLORS = {
55
- "surface": "#FCFCFD",
56
- "ink": "#1F2430",
57
- "muted": "#6F768A",
58
- "grid": "#E6E8F0",
59
- "axis": "#D7DBE7",
60
- "blue": "#2E4780",
61
- "blue_light": "#CEDFFE",
62
- "gold": "#B8A037",
63
- "neutral": "#C5CAD3",
64
- "neutral_dark": "#464C55",
65
- }
66
-
67
- EXECUTIVE_SECTION_CANDIDATES = ["Executive Summary", "Summary", "Overview"]
68
- CONCLUSION_SECTION_CANDIDATES = ["Conclusions", "Conclusion", "Key Takeaways", "Takeaways"]
69
- IMPLICATION_SECTION_CANDIDATES = [
70
- "Implications",
71
- "Recommendations",
72
- "Recommended Actions",
73
- "Next Steps",
74
- ]
75
- CAVEAT_SECTION_CANDIDATES = [
76
- "Caveats And Assumptions",
77
- "Caveats",
78
- "Assumptions",
79
- "Limitations",
80
- "Methodology Notes",
81
- ]
82
- SOURCE_SECTION_CANDIDATES = [
83
- "Sources And Reproducibility",
84
- "Sources",
85
- "Reproducibility",
86
- "Methodology",
87
- "Appendix",
88
- ]
89
- BLOCKED_SVG_TAGS = {
90
- "audio",
91
- "base",
92
- "canvas",
93
- "embed",
94
- "foreignobject",
95
- "iframe",
96
- "link",
97
- "meta",
98
- "object",
99
- "script",
100
- "video",
101
- }
102
-
103
-
104
- @dataclass
105
- class MetricCard:
106
- label: str
107
- value: str
108
- note: str = ""
109
-
110
-
111
- @dataclass
112
- class ChartItem:
113
- index: int
114
- title: str
115
- takeaway: str
116
- caveat: str
117
- svg_markup: str
118
- css_text: str = ""
119
- image_path: str = ""
120
- width_px: int = 0
121
- height_px: int = 0
122
-
123
-
124
- @dataclass
125
- class TableItem:
126
- headers: list[str]
127
- rows: list[list[str]]
128
- title: str = ""
129
- note: str = ""
130
-
131
-
132
- @dataclass
133
- class LinkItem:
134
- text: str
135
- href: str
136
-
137
-
138
- @dataclass
139
- class Section:
140
- title: str
141
- paragraphs: list[str] = field(default_factory=list)
142
- bullets: list[str] = field(default_factory=list)
143
- links: list[LinkItem] = field(default_factory=list)
144
-
145
-
146
- @dataclass
147
- class Report:
148
- source: str
149
- title: str
150
- eyebrow: str
151
- lede: str
152
- metrics: list[MetricCard]
153
- sections: dict[str, Section]
154
- charts: list[ChartItem]
155
- tables: list[TableItem]
156
- colors: dict[str, str]
157
-
158
-
159
- @dataclass
160
- class Check:
161
- name: str
162
- status: str
163
- message: str
164
- details: dict[str, Any] = field(default_factory=dict)
165
-
166
-
167
- @dataclass
168
- class Preflight:
169
- checks: list[Check] = field(default_factory=list)
170
-
171
- def add(self, name: str, ok: bool, message: str, details: dict[str, Any] | None = None) -> None:
172
- self.checks.append(
173
- Check(
174
- name=name,
175
- status="passed" if ok else "failed",
176
- message=message,
177
- details=details or {},
178
- )
179
- )
180
-
181
- @property
182
- def passed(self) -> bool:
183
- return all(check.status == "passed" for check in self.checks)
184
-
185
- def to_json(self) -> dict[str, Any]:
186
- failed = [check for check in self.checks if check.status != "passed"]
187
- return {
188
- "status": "passed" if not failed else "failed",
189
- "summary": {
190
- "checks": len(self.checks),
191
- "passed": len(self.checks) - len(failed),
192
- "failed": len(failed),
193
- },
194
- "checks": [asdict(check) for check in self.checks],
195
- }
196
-
197
-
198
- def collapse_ws(text: str) -> str:
199
- return re.sub(r"\s+", " ", text or "").strip()
200
-
201
-
202
- def first_sentence(text: str, max_chars: int = 140) -> str:
203
- text = collapse_ws(text)
204
- if not text:
205
- return ""
206
- match = re.search(r"(?<=[.!?])\s+", text)
207
- sentence = text[: match.start()].strip() if match else text
208
- if len(sentence) <= max_chars:
209
- return sentence
210
- return sentence[: max_chars - 3].rsplit(" ", 1)[0] + "..."
211
-
212
-
213
- def normalized_label(text: str) -> str:
214
- text = collapse_ws(text).lower().replace("&", " and ")
215
- return re.sub(r"[^a-z0-9]+", " ", text).strip()
216
-
217
-
218
- def find_section(report: Report, candidates: list[str]) -> Section | None:
219
- by_label = {normalized_label(title): section for title, section in report.sections.items()}
220
- for candidate in candidates:
221
- section = by_label.get(normalized_label(candidate))
222
- if section:
223
- return section
224
-
225
- candidate_words = [set(normalized_label(candidate).split()) for candidate in candidates]
226
- for title, section in report.sections.items():
227
- title_words = set(normalized_label(title).split())
228
- if any(words and words.issubset(title_words) for words in candidate_words):
229
- return section
230
- return None
231
-
232
-
233
- def section_items_by_candidates(report: Report, candidates: list[str]) -> list[str]:
234
- section = find_section(report, candidates)
235
- if not section:
236
- return []
237
- return section.paragraphs + section.bullets
238
-
239
-
240
- def section_title_by_candidates(report: Report, candidates: list[str], fallback: str) -> str:
241
- section = find_section(report, candidates)
242
- return section.title if section and section.title else fallback
243
-
244
-
245
- def has_section_content(report: Report, candidates: list[str]) -> bool:
246
- section = find_section(report, candidates)
247
- return bool(section and (section.paragraphs or section.bullets or section.links))
248
-
249
-
250
- def resolve_source(source: str) -> Path:
251
- candidate = Path(source).expanduser()
252
- if candidate.is_file():
253
- return candidate.resolve()
254
-
255
- if source.startswith(("http://", "https://")):
256
- raise SystemExit(
257
- "This helper only accepts a local HTML file path. Retrieve the report "
258
- "using the user's environment-specific instructions, then rerun with "
259
- "the local file path."
260
- )
261
-
262
- raise SystemExit(f"Source does not exist or is not a file: {source}")
263
-
264
-
265
- def read_html(path: Path) -> str:
266
- raw = path.read_text(encoding="utf-8", errors="replace")
267
- if len(raw) < 1000:
268
- raise SystemExit(f"HTML file is too small to be a report: {path}")
269
- return raw
270
-
271
-
272
- def validate_report_html(soup: BeautifulSoup, path: Path) -> None:
273
- title = collapse_ws(soup.title.get_text(" ", strip=True) if soup.title else "")
274
- body_text = collapse_ws(soup.get_text(" ", strip=True))
275
- has_report_shape = bool(soup.find("h1") and soup.find("h2")) or bool(
276
- soup.find_all(["svg", "table"])
277
- )
278
- auth_markers = [
279
- "sign in to your account",
280
- "login.microsoftonline.com",
281
- "convergedsignin",
282
- "jsdisabled",
283
- ]
284
- lowered = f"{title} {body_text[:1000]}".lower()
285
- if any(marker in lowered for marker in auth_markers) or not has_report_shape:
286
- raise SystemExit(
287
- f"{path} does not look like the report HTML. It appears to be an auth "
288
- "or non-report page; provide the actual local HTML report file and rerun."
289
- )
290
-
291
-
292
- def extract_report_css(soup: BeautifulSoup) -> str:
293
- return "\n".join(style.get_text() for style in soup.find_all("style"))
294
-
295
-
296
- def parse_css_vars(soup: BeautifulSoup, style_text: str | None = None) -> dict[str, str]:
297
- colors = DEFAULT_COLORS.copy()
298
- style_text = style_text if style_text is not None else extract_report_css(soup)
299
- for name, value in re.findall(r"--([a-zA-Z0-9_-]+)\s*:\s*(#[0-9a-fA-F]{3,6})", style_text):
300
- key = name.replace("-", "_")
301
- if key in colors or key in {
302
- "surface",
303
- "ink",
304
- "muted",
305
- "grid",
306
- "axis",
307
- "blue",
308
- "gold",
309
- "neutral",
310
- }:
311
- colors[key] = normalize_hex(value)
312
- return colors
313
-
314
-
315
- def normalize_hex(value: str) -> str:
316
- value = value.strip()
317
- if re.fullmatch(r"#[0-9a-fA-F]{3}", value):
318
- return "#" + "".join(ch * 2 for ch in value[1:]).upper()
319
- if re.fullmatch(r"#[0-9a-fA-F]{6}", value):
320
- return value.upper()
321
- return "#000000"
322
-
323
-
324
- def text_of(node: Tag | None) -> str:
325
- if not node:
326
- return ""
327
- return collapse_ws(node.get_text(" ", strip=True))
328
-
329
-
330
- def links_of(node: Tag) -> list[LinkItem]:
331
- links: list[LinkItem] = []
332
- for anchor in node.find_all("a", href=True):
333
- href = collapse_ws(str(anchor.get("href") or ""))
334
- if not href or href.startswith("#"):
335
- continue
336
- label = text_of(anchor) or href
337
- links.append(LinkItem(text=label, href=href))
338
- return links
339
-
340
-
341
- def add_section_links(section: Section, links: list[LinkItem]) -> None:
342
- seen = {(link.text, link.href) for link in section.links}
343
- for link in links:
344
- key = (link.text, link.href)
345
- if key not in seen:
346
- section.links.append(link)
347
- seen.add(key)
348
-
349
-
350
- def parse_metrics(soup: BeautifulSoup) -> list[MetricCard]:
351
- metrics: list[MetricCard] = []
352
- for metric in soup.select(".metric"):
353
- label = text_of(metric.select_one(".label")) or text_of(metric.find("span"))
354
- value = text_of(metric.select_one(".value")) or text_of(metric.find("strong"))
355
- note = text_of(metric.select_one(".note"))
356
- if not note:
357
- value_node = metric.select_one(".value") or metric.find("strong")
358
- label_node = metric.select_one(".label") or metric.find("span")
359
- excluded = {id(node) for node in (value_node, label_node) if node}
360
- note_parts = [
361
- collapse_ws(str(part))
362
- for part in metric.stripped_strings
363
- if collapse_ws(str(part))
364
- and collapse_ws(str(part)) not in {label, value}
365
- and id(getattr(part, "parent", None)) not in excluded
366
- ]
367
- note = " ".join(note_parts)
368
- metrics.append(
369
- MetricCard(
370
- label=label,
371
- value=value,
372
- note=note,
373
- )
374
- )
375
- return [m for m in metrics if m.label or m.value]
376
-
377
-
378
- def parse_table(table: Tag) -> TableItem:
379
- parsed_rows: list[tuple[list[str], bool]] = []
380
- for tr in table.find_all("tr"):
381
- cell_nodes = tr.find_all(["th", "td"])
382
- cells = [text_of(cell) for cell in cell_nodes]
383
- if cells:
384
- parsed_rows.append((cells, any(cell.name == "th" for cell in cell_nodes)))
385
- if not parsed_rows:
386
- return TableItem(headers=[], rows=[])
387
- width = max(len(row) for row, _ in parsed_rows)
388
- header_idx = next((idx for idx, (_, has_th) in enumerate(parsed_rows) if has_th), None)
389
- if header_idx is None:
390
- headers = [f"Column {idx}" for idx in range(1, width + 1)]
391
- rows = [row + [""] * (width - len(row)) for row, _ in parsed_rows]
392
- return TableItem(headers=headers, rows=rows)
393
- headers = parsed_rows[header_idx][0]
394
- headers = headers + [f"Column {idx}" for idx in range(len(headers) + 1, width + 1)]
395
- rows = [
396
- row + [""] * (width - len(row))
397
- for idx, (row, _) in enumerate(parsed_rows)
398
- if idx != header_idx
399
- ]
400
- return TableItem(headers=headers, rows=rows)
401
-
402
-
403
- def table_context_title(table: Tag, fallback: str = "") -> str:
404
- heading = table.find_previous(["h4", "h3", "h2"])
405
- return text_of(heading) or fallback
406
-
407
-
408
- def table_context_note(table: Tag) -> str:
409
- for sibling in table.next_siblings:
410
- if isinstance(sibling, NavigableString):
411
- continue
412
- if not isinstance(sibling, Tag):
413
- continue
414
- if sibling.name in {"h2", "h3", "h4", "table"}:
415
- return ""
416
- if sibling.name in {"p", "figcaption"} or "caption" in set(sibling.get("class") or []):
417
- return text_of(sibling)
418
- return ""
419
-
420
-
421
- def find_matching_table(tables: list[TableItem], parsed: TableItem) -> TableItem | None:
422
- for table in tables:
423
- if table.headers == parsed.headers and table.rows == parsed.rows:
424
- return table
425
- return None
426
-
427
-
428
- def parse_svg_length(value: Any, fallback: float) -> float:
429
- match = re.search(r"\d+(?:\.\d+)?", str(value or ""))
430
- if not match:
431
- return fallback
432
- try:
433
- return float(match.group(0))
434
- except ValueError:
435
- return fallback
436
-
437
-
438
- def chart_dimensions(svg: Tag) -> tuple[int, int]:
439
- view_box = svg.get("viewBox") or svg.get("viewbox")
440
- if view_box:
441
- raw_parts = [part for part in re.split(r"[,\s]+", str(view_box).strip()) if part]
442
- try:
443
- parts = [float(part) for part in raw_parts]
444
- except ValueError:
445
- parts = []
446
- if len(parts) == 4 and parts[2] > 0 and parts[3] > 0:
447
- return int(math.ceil(parts[2])), int(math.ceil(parts[3]))
448
- width = parse_svg_length(svg.get("width"), 900)
449
- height = parse_svg_length(svg.get("height"), 520)
450
- return int(math.ceil(width)), int(math.ceil(height))
451
-
452
-
453
- def looks_like_chart_title_candidate(text: str) -> bool:
454
- text = collapse_ws(text)
455
- if not 8 <= len(text) <= 180:
456
- return False
457
- if re.fullmatch(r"[a-z]+/[a-z0-9.+-]+", text.lower()):
458
- return False
459
- lowered = normalized_label(text)
460
- if "matplotlib" in lowered or "http" in text.lower():
461
- return False
462
- if len(re.findall(r"[A-Za-z]", text)) < 4:
463
- return False
464
- words = re.findall(r"[A-Za-z][A-Za-z0-9_-]*", text)
465
- if len(words) < 2:
466
- return False
467
- numeric_ratio = len(re.findall(r"[0-9]", text)) / max(1, len(text))
468
- if numeric_ratio > 0.45:
469
- return False
470
- axis_or_legend_labels = {
471
- "source",
472
- "notes",
473
- "legend",
474
- "date",
475
- "day",
476
- "week",
477
- "month",
478
- "year",
479
- "image svg xml",
480
- }
481
- return lowered not in axis_or_legend_labels
482
-
483
-
484
- def chart_text_candidates(svg: Tag) -> list[str]:
485
- candidates: list[str] = []
486
- for value in svg.stripped_strings:
487
- text = collapse_ws(str(value))
488
- if looks_like_chart_title_candidate(text):
489
- candidates.append(text)
490
-
491
- deduped: list[str] = []
492
- seen: set[str] = set()
493
- for candidate in candidates:
494
- key = normalized_label(candidate)
495
- if key and key not in seen:
496
- deduped.append(candidate)
497
- seen.add(key)
498
- return deduped
499
-
500
-
501
- def chart_semantic_label(svg: Tag) -> str:
502
- for attr in ("aria-label", "data-title", "title"):
503
- value = collapse_ws(str(svg.get(attr) or ""))
504
- if value and looks_like_chart_title_candidate(value):
505
- return value
506
- for tag_name in ("title", "desc"):
507
- for node in svg.find_all(tag_name):
508
- value = text_of(node)
509
- if value and looks_like_chart_title_candidate(value):
510
- return value
511
- return ""
512
-
513
-
514
- def chart_title_score(text: str) -> float:
515
- words = re.findall(r"[A-Za-z][A-Za-z0-9_-]*", text)
516
- score = min(len(words), 14) * 0.8
517
- score += min(len(text), 120) / 45
518
- if re.search(r"[.!?]$", text.strip()):
519
- score += 2.0
520
- if "," in text or ":" in text or ";" in text:
521
- score += 0.8
522
- if len(words) <= 4 and not re.search(r"[.!?]$", text.strip()):
523
- score -= 4.0
524
- if len(text) < 25:
525
- score -= 2.0
526
- return score
527
-
528
-
529
- def parse_chart_title(svg: Tag) -> str:
530
- semantic_label = chart_semantic_label(svg)
531
- if semantic_label:
532
- return first_sentence(semantic_label, 120)
533
- candidates = chart_text_candidates(svg)
534
- if candidates:
535
- return first_sentence(max(candidates, key=chart_title_score), 120)
536
- text = collapse_ws(svg.get_text(" ", strip=True))
537
- return first_sentence(text, 100) or "Source chart from report"
538
-
539
-
540
- def is_material_chart_svg(svg: Tag, width_px: int, height_px: int) -> bool:
541
- if width_px < 180 or height_px < 110:
542
- return False
543
- if chart_semantic_label(svg) or chart_text_candidates(svg):
544
- return True
545
- visual_marks = svg.find_all(
546
- ["circle", "ellipse", "line", "path", "polygon", "polyline", "rect"]
547
- )
548
- if width_px >= 280 and height_px >= 170 and len(visual_marks) >= 3:
549
- return True
550
- return width_px >= 420 and height_px >= 240 and bool(visual_marks)
551
-
552
-
553
- def is_chart_note_text(text: str) -> bool:
554
- normalized = normalized_label(text)
555
- note_prefixes = [
556
- "note",
557
- "notes",
558
- "source",
559
- "definition",
560
- "current window",
561
- "previous window",
562
- "bars are",
563
- "bars show",
564
- "values are",
565
- ]
566
- return any(normalized.startswith(prefix) for prefix in note_prefixes)
567
-
568
-
569
- def chart_takeaway(chart_title: str, next_para: str) -> str:
570
- candidate = first_sentence(next_para, 145)
571
- if not candidate or is_chart_note_text(candidate):
572
- return chart_title
573
- if normalized_label(candidate).startswith(("implication", "recommendation")):
574
- return chart_title
575
- return candidate
576
-
577
-
578
- def chart_context_text(
579
- section_title: str,
580
- chart_title: str,
581
- next_para: str,
582
- prior_items: list[str],
583
- ) -> str:
584
- candidates: list[str] = []
585
- if next_para:
586
- candidates.append(next_para)
587
- candidates.extend(reversed(prior_items))
588
- chart_key = normalize_sentence_key(chart_title)
589
-
590
- for allow_note_text in (False, True):
591
- for candidate in candidates:
592
- raw_candidate = collapse_ws(candidate)
593
- sentence = first_sentence(raw_candidate, 230)
594
- sentence_key = normalize_sentence_key(sentence)
595
- if not sentence or sentence_key == chart_key:
596
- continue
597
- if not allow_note_text and is_chart_note_text(sentence):
598
- continue
599
- return shorten_for_bullet(raw_candidate, 360)
600
- return f"This chart supports the report section: {section_title}."
601
-
602
-
603
- def iter_content_blocks(node: Tag) -> list[Tag]:
604
- if node.name in {"h2", "h3", "h4"}:
605
- return []
606
- classes = set(node.get("class") or [])
607
- if node.name in {"p", "ul", "ol", "table", "svg"}:
608
- return [node]
609
- if "figure" in classes and node.find("svg"):
610
- return [node]
611
- direct_svg = node.find("svg", recursive=False)
612
- direct_text_blocks = node.find(["p", "ul", "ol", "table"], recursive=False)
613
- if direct_svg and not direct_text_blocks:
614
- return [node]
615
-
616
- blocks: list[Tag] = []
617
- for child in node.children:
618
- if isinstance(child, Tag):
619
- blocks.extend(iter_content_blocks(child))
620
- return blocks
621
-
622
-
623
- def heading_level(node: Tag) -> int:
624
- if node.name and re.fullmatch(r"h[1-6]", node.name):
625
- return int(node.name[1])
626
- return 7
627
-
628
-
629
- def section_content_blocks(heading: Tag) -> list[Tag]:
630
- blocks: list[Tag] = []
631
- current_level = heading_level(heading)
632
- for sibling in heading.next_siblings:
633
- if isinstance(sibling, NavigableString):
634
- continue
635
- if not isinstance(sibling, Tag):
636
- continue
637
- if re.fullmatch(r"h[1-6]", sibling.name or "") and heading_level(sibling) <= current_level:
638
- break
639
- blocks.extend(iter_content_blocks(sibling))
640
- return blocks
641
-
642
-
643
- def context_text_from_block(block: Tag) -> str:
644
- if block.name == "p":
645
- return text_of(block)
646
- if block.name in {"ul", "ol"}:
647
- return " ".join(text_of(li) for li in block.find_all("li", recursive=False))
648
- return ""
649
-
650
-
651
- def following_context(blocks: list[Tag], start_idx: int) -> str:
652
- for block in blocks[start_idx + 1 :]:
653
- text = context_text_from_block(block)
654
- if text:
655
- return text
656
- return ""
657
-
658
-
659
- def nearest_heading_title(node: Tag, fallback: str) -> str:
660
- heading = node.find_previous(["h4", "h3", "h2", "h1"])
661
- heading_text = text_of(heading) if isinstance(heading, Tag) else ""
662
- return heading_text or fallback
663
-
664
-
665
- def nearby_prior_items(node: Tag, limit: int = 4) -> list[str]:
666
- items: list[str] = []
667
- for prev in node.find_all_previous(["p", "li"], limit=limit):
668
- text = text_of(prev)
669
- if text:
670
- items.append(text)
671
- return list(reversed(items))
672
-
673
-
674
- def nearby_next_context(node: Tag, limit: int = 4) -> str:
675
- for nxt in node.find_all_next(["p", "li"], limit=limit):
676
- text = text_of(nxt)
677
- if text:
678
- return text
679
- return ""
680
-
681
-
682
- def add_chart_item(
683
- charts: list[ChartItem],
684
- svg_container: Tag,
685
- section_title: str,
686
- next_para: str,
687
- prior_items: list[str],
688
- style_text: str,
689
- parsed_svg_ids: set[int],
690
- ) -> None:
691
- svg = svg_container if svg_container.name == "svg" else svg_container.find("svg")
692
- if not isinstance(svg, Tag) or id(svg) in parsed_svg_ids:
693
- return
694
- width_px, height_px = chart_dimensions(svg)
695
- if not is_material_chart_svg(svg, width_px, height_px):
696
- parsed_svg_ids.add(id(svg))
697
- return
698
- parsed_svg_ids.add(id(svg))
699
- chart_title = parse_chart_title(svg)
700
- if chart_title == "Source chart from report":
701
- chart_title = section_title
702
- context_text = chart_context_text(section_title, chart_title, next_para, prior_items)
703
- charts.append(
704
- ChartItem(
705
- index=len(charts) + 1,
706
- title=chart_title,
707
- takeaway=chart_takeaway(chart_title, context_text),
708
- caveat=context_text,
709
- svg_markup=str(svg),
710
- css_text=style_text,
711
- width_px=width_px,
712
- height_px=height_px,
713
- )
714
- )
715
-
716
-
717
- def parse_sections_and_charts(
718
- soup: BeautifulSoup,
719
- style_text: str,
720
- ) -> tuple[dict[str, Section], list[ChartItem], list[TableItem]]:
721
- sections: dict[str, Section] = {}
722
- charts: list[ChartItem] = []
723
- tables: list[TableItem] = []
724
- parsed_svg_ids: set[int] = set()
725
- parsed_table_ids: set[int] = set()
726
-
727
- for heading in soup.find_all(["h2", "h3"]):
728
- title = text_of(heading)
729
- if not title:
730
- continue
731
- section = Section(title=title)
732
- blocks = section_content_blocks(heading)
733
- for block_idx, block in enumerate(blocks):
734
- if block.name == "p":
735
- paragraph = text_of(block)
736
- if paragraph:
737
- section.paragraphs.append(paragraph)
738
- add_section_links(section, links_of(block))
739
- elif block.name in {"ul", "ol"}:
740
- for li in block.find_all("li", recursive=False):
741
- item = text_of(li)
742
- if item:
743
- section.bullets.append(item)
744
- add_section_links(section, links_of(li))
745
- elif block.name == "table":
746
- parsed_table_ids.add(id(block))
747
- table = parse_table(block)
748
- if table.headers or table.rows:
749
- table.title = table_context_title(block, title)
750
- table.note = table_context_note(block)
751
- tables.append(table)
752
- elif block.name == "svg" or block.find("svg"):
753
- add_chart_item(
754
- charts,
755
- block,
756
- title,
757
- following_context(blocks, block_idx),
758
- section.paragraphs + section.bullets,
759
- style_text,
760
- parsed_svg_ids,
761
- )
762
- sections[title] = section
763
-
764
- fallback_title = text_of(soup.find("h1")) or text_of(soup.title) or "Chart evidence"
765
- for svg in soup.find_all("svg"):
766
- if id(svg) in parsed_svg_ids:
767
- continue
768
- add_chart_item(
769
- charts,
770
- svg,
771
- nearest_heading_title(svg, fallback_title),
772
- nearby_next_context(svg),
773
- nearby_prior_items(svg),
774
- style_text,
775
- parsed_svg_ids,
776
- )
777
-
778
- for table in soup.find_all("table"):
779
- if id(table) in parsed_table_ids:
780
- continue
781
- parsed = parse_table(table)
782
- if not (parsed.headers or parsed.rows):
783
- continue
784
- parsed.title = table_context_title(table)
785
- parsed.note = table_context_note(table)
786
- match = find_matching_table(tables, parsed)
787
- if match:
788
- if parsed.title and not match.title:
789
- match.title = parsed.title
790
- if parsed.note and not match.note:
791
- match.note = parsed.note
792
- else:
793
- tables.append(parsed)
794
-
795
- return sections, charts, tables
796
-
797
-
798
- def parse_report(path: Path) -> Report:
799
- raw = read_html(path)
800
- soup = BeautifulSoup(raw, "html.parser")
801
- validate_report_html(soup, path)
802
- style_text = extract_report_css(soup)
803
- colors = parse_css_vars(soup, style_text)
804
- title = text_of(soup.find("h1")) or text_of(soup.title) or "Analytics Report"
805
- eyebrow = text_of(soup.select_one(".eyebrow"))
806
- lede = text_of(soup.select_one(".lede"))
807
- metrics = parse_metrics(soup)
808
- sections, charts, tables = parse_sections_and_charts(soup, style_text)
809
- if not lede:
810
- executive_labels = {normalized_label(label) for label in EXECUTIVE_SECTION_CANDIDATES}
811
- for section_title, section in sections.items():
812
- if normalized_label(section_title) in executive_labels and section.paragraphs:
813
- lede = first_sentence(section.paragraphs[0], 220)
814
- break
815
- return Report(
816
- source=str(path),
817
- title=title,
818
- eyebrow=eyebrow,
819
- lede=lede,
820
- metrics=metrics,
821
- sections=sections,
822
- charts=charts,
823
- tables=tables,
824
- colors=colors,
825
- )
826
-
827
-
828
- def write_json(path: Path, data: Any) -> None:
829
- path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
830
-
831
-
832
- def rgb(value: str) -> RGBColor:
833
- value = normalize_hex(value)
834
- return RGBColor(int(value[1:3], 16), int(value[3:5], 16), int(value[5:7], 16))
835
-
836
-
837
- def find_font_path(bold: bool = False) -> str | None:
838
- candidates = [
839
- "/System/Library/Fonts/Supplemental/Arial Bold.ttf"
840
- if bold
841
- else "/System/Library/Fonts/Supplemental/Arial.ttf",
842
- "/Library/Fonts/Arial Bold.ttf" if bold else "/Library/Fonts/Arial.ttf",
843
- "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"
844
- if bold
845
- else "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
846
- ]
847
- for candidate in candidates:
848
- if candidate and Path(candidate).exists():
849
- return candidate
850
- return None
851
-
852
-
853
- def pil_font(
854
- font_size_pt: float, bold: bool = False
855
- ) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
856
- path = find_font_path(bold)
857
- size_px = max(8, round(font_size_pt * PX_PER_PT))
858
- if path:
859
- return ImageFont.truetype(path, size_px)
860
- return ImageFont.load_default()
861
-
862
-
863
- def line_width_px(text: str, font: ImageFont.FreeTypeFont | ImageFont.ImageFont) -> float:
864
- bbox = font.getbbox(text)
865
- return max(0, bbox[2] - bbox[0])
866
-
867
-
868
- def wrap_lines(
869
- text: str, font_size_pt: float, max_width_pt: float, bold: bool = False
870
- ) -> list[str]:
871
- font = pil_font(font_size_pt, bold=bold)
872
- max_width_px = max_width_pt * PX_PER_PT
873
- lines: list[str] = []
874
- for paragraph in (text or "").splitlines() or [""]:
875
- words = paragraph.split()
876
- if not words:
877
- lines.append("")
878
- continue
879
- line = words[0]
880
- for word in words[1:]:
881
- candidate = f"{line} {word}"
882
- if line_width_px(candidate, font) <= max_width_px:
883
- line = candidate
884
- else:
885
- lines.append(line)
886
- line = word
887
- lines.append(line)
888
- return lines
889
-
890
-
891
- def measured_height_pt(
892
- text: str,
893
- font_size_pt: float,
894
- max_width_pt: float,
895
- bold: bool = False,
896
- line_spacing: float = 1.18,
897
- paragraph_gap_pt: float = 2.5,
898
- ) -> float:
899
- paragraphs = (text or "").splitlines() or [""]
900
- total = 0.0
901
- for paragraph in paragraphs:
902
- lines = wrap_lines(paragraph, font_size_pt, max_width_pt, bold=bold)
903
- total += max(1, len(lines)) * font_size_pt * line_spacing + paragraph_gap_pt
904
- return total
905
-
906
-
907
- def best_font_size(
908
- text: str,
909
- max_width_pt: float,
910
- max_height_pt: float,
911
- start_pt: float,
912
- min_pt: float,
913
- bold: bool = False,
914
- ) -> tuple[float, bool]:
915
- size = start_pt
916
- while size >= min_pt:
917
- if measured_height_pt(text, size, max_width_pt, bold=bold) <= max_height_pt:
918
- return size, True
919
- size -= 1
920
- return min_pt, measured_height_pt(text, min_pt, max_width_pt, bold=bold) <= max_height_pt
921
-
922
-
923
- def set_shape_fill(shape: Any, color: str, transparency: float = 0.0) -> None:
924
- shape.fill.solid()
925
- shape.fill.fore_color.rgb = rgb(color)
926
- if transparency:
927
- shape.fill.transparency = transparency
928
-
929
-
930
- def set_shape_line(
931
- shape: Any, color: str, width_pt: float = 1.0, transparency: float = 0.0
932
- ) -> None:
933
- shape.line.color.rgb = rgb(color)
934
- shape.line.width = Pt(width_pt)
935
- if transparency:
936
- shape.line.transparency = transparency
937
-
938
-
939
- def add_box(
940
- slide: Any,
941
- name: str,
942
- x: float,
943
- y: float,
944
- w: float,
945
- h: float,
946
- fill: str,
947
- line: str | None = None,
948
- radius: bool = False,
949
- ) -> Any:
950
- shape_type = MSO_SHAPE.ROUNDED_RECTANGLE if radius else MSO_SHAPE.RECTANGLE
951
- shape = slide.shapes.add_shape(shape_type, Inches(x), Inches(y), Inches(w), Inches(h))
952
- shape.name = name
953
- set_shape_fill(shape, fill)
954
- set_shape_line(shape, line or fill, width_pt=0.75)
955
- return shape
956
-
957
-
958
- def add_text(
959
- slide: Any,
960
- name: str,
961
- x: float,
962
- y: float,
963
- w: float,
964
- h: float,
965
- text: str,
966
- font_size: float,
967
- color: str,
968
- preflight: Preflight,
969
- *,
970
- bold: bool = False,
971
- min_font: float = 8,
972
- align: PP_ALIGN = PP_ALIGN.LEFT,
973
- valign: MSO_ANCHOR = MSO_ANCHOR.TOP,
974
- ) -> Any:
975
- text = collapse_ws(text)
976
- margin_pt = 4
977
- chosen, fits = best_font_size(
978
- text,
979
- max_width_pt=w * 72 - margin_pt * 2,
980
- max_height_pt=h * 72 - margin_pt * 2,
981
- start_pt=font_size,
982
- min_pt=min_font,
983
- bold=bold,
984
- )
985
- preflight.add(
986
- f"text_fit:{name}",
987
- fits,
988
- f"{name} text fits at {chosen:.0f}pt" if fits else f"{name} may overflow at minimum size",
989
- {"font_size_pt": chosen, "box": [x, y, w, h], "text": text[:180]},
990
- )
991
- shape = slide.shapes.add_textbox(Inches(x), Inches(y), Inches(w), Inches(h))
992
- shape.name = name
993
- tf = shape.text_frame
994
- tf.clear()
995
- tf.word_wrap = True
996
- tf.margin_left = Pt(margin_pt)
997
- tf.margin_right = Pt(margin_pt)
998
- tf.margin_top = Pt(1)
999
- tf.margin_bottom = Pt(1)
1000
- tf.vertical_anchor = valign
1001
- paragraph = tf.paragraphs[0]
1002
- paragraph.text = text
1003
- paragraph.alignment = align
1004
- run = paragraph.runs[0] if paragraph.runs else paragraph.add_run()
1005
- run.font.name = "Arial"
1006
- run.font.size = Pt(chosen)
1007
- run.font.bold = bold
1008
- run.font.color.rgb = rgb(color)
1009
- return shape
1010
-
1011
-
1012
- def apply_native_bullet(paragraph: Any) -> None:
1013
- p_pr = paragraph._p.get_or_add_pPr()
1014
- for child in list(p_pr):
1015
- if child.tag.endswith(("}buChar", "}buAutoNum", "}buBlip", "}buNone")):
1016
- p_pr.remove(child)
1017
- p_pr.set("marL", "285750")
1018
- p_pr.set("indent", "-142875")
1019
- bullet = OxmlElement("a:buChar")
1020
- bullet.set("char", "\u2022")
1021
- p_pr.insert(0, bullet)
1022
-
1023
-
1024
- def add_bullets(
1025
- slide: Any,
1026
- name: str,
1027
- x: float,
1028
- y: float,
1029
- w: float,
1030
- h: float,
1031
- bullets: list[str],
1032
- font_size: float,
1033
- color: str,
1034
- preflight: Preflight,
1035
- *,
1036
- max_items: int = 5,
1037
- min_font: float = 9,
1038
- links: list[str | None] | None = None,
1039
- link_color: str | None = None,
1040
- space_after_pt: float = 5.5,
1041
- ) -> Any:
1042
- clean = [collapse_ws(item) for item in bullets if collapse_ws(item)][:max_items]
1043
- clean_links: list[str | None] = []
1044
- if links:
1045
- clean_links = [links[idx] if idx < len(links) else None for idx in range(len(clean))]
1046
- else:
1047
- clean_links = [None] * len(clean)
1048
- bullet_text = "\n".join(clean)
1049
- chosen, fits = best_font_size(
1050
- bullet_text,
1051
- max_width_pt=w * 72 - 24,
1052
- max_height_pt=h * 72 - 8,
1053
- start_pt=font_size,
1054
- min_pt=min_font,
1055
- )
1056
- preflight.add(
1057
- f"text_fit:{name}",
1058
- fits,
1059
- f"{name} bullets fit at {chosen:.0f}pt" if fits else f"{name} bullets may overflow",
1060
- {"items": len(clean), "font_size_pt": chosen, "box": [x, y, w, h]},
1061
- )
1062
- shape = slide.shapes.add_textbox(Inches(x), Inches(y), Inches(w), Inches(h))
1063
- shape.name = name
1064
- tf = shape.text_frame
1065
- tf.clear()
1066
- tf.word_wrap = True
1067
- tf.margin_left = Pt(4)
1068
- tf.margin_right = Pt(4)
1069
- tf.margin_top = Pt(1)
1070
- tf.margin_bottom = Pt(1)
1071
- for idx, (item, href) in enumerate(zip(clean, clean_links, strict=False)):
1072
- p = tf.paragraphs[0] if idx == 0 else tf.add_paragraph()
1073
- p.text = ""
1074
- p.space_after = Pt(space_after_pt)
1075
- p.level = 0
1076
- apply_native_bullet(p)
1077
- run = p.add_run()
1078
- run.text = item
1079
- run.font.name = "Arial"
1080
- run.font.size = Pt(chosen)
1081
- run.font.color.rgb = rgb(link_color if href and link_color else color)
1082
- if href:
1083
- run.hyperlink.address = href
1084
- return shape
1085
-
1086
-
1087
- def add_source(slide: Any, text: str, colors: dict[str, str], preflight: Preflight) -> None:
1088
- add_text(
1089
- slide,
1090
- "source-note",
1091
- 0.62,
1092
- 7.02,
1093
- 12.1,
1094
- 0.34,
1095
- text,
1096
- 7.5,
1097
- colors.get("muted", DEFAULT_COLORS["muted"]),
1098
- preflight,
1099
- min_font=6,
1100
- )
1101
-
1102
-
1103
- def blank_slide(prs: Presentation, colors: dict[str, str]) -> Any:
1104
- slide = prs.slides.add_slide(prs.slide_layouts[6])
1105
- add_box(
1106
- slide,
1107
- "slide-background",
1108
- 0,
1109
- 0,
1110
- SLIDE_W,
1111
- SLIDE_H,
1112
- colors.get("surface", DEFAULT_COLORS["surface"]),
1113
- )
1114
- return slide
1115
-
1116
-
1117
- def sanitize_css(css_text: str) -> str:
1118
- css_text = re.sub(r"@import[^;]+;", "", css_text, flags=re.IGNORECASE)
1119
- css_text = re.sub(r"@font-face\s*\{[^}]*\}", "", css_text, flags=re.IGNORECASE | re.DOTALL)
1120
- css_text = re.sub(r"expression\s*\([^)]*\)", "none", css_text, flags=re.IGNORECASE)
1121
-
1122
- def replace_url(match: re.Match[str]) -> str:
1123
- target = match.group(1).strip().strip("\"'")
1124
- return match.group(0) if target.startswith("#") else "none"
1125
-
1126
- css_text = re.sub(r"url\(\s*([^)]*?)\s*\)", replace_url, css_text, flags=re.IGNORECASE)
1127
- return css_text.replace("</style", "<\\/style")
1128
-
1129
-
1130
- def sanitize_svg_markup(svg_markup: str) -> str:
1131
- soup = BeautifulSoup(svg_markup, "html.parser")
1132
- for tag in soup.find_all(BLOCKED_SVG_TAGS):
1133
- tag.decompose()
1134
- for tag in soup.find_all(True):
1135
- if tag.name == "style":
1136
- tag.string = sanitize_css(tag.get_text())
1137
- for attr in list(tag.attrs):
1138
- attr_lower = attr.lower()
1139
- value = tag.attrs[attr]
1140
- value_text = " ".join(value) if isinstance(value, list) else str(value)
1141
- if attr_lower.startswith("on"):
1142
- del tag.attrs[attr]
1143
- elif attr_lower in {"href", "xlink:href", "src"}:
1144
- target = collapse_ws(value_text)
1145
- if target and not target.startswith("#"):
1146
- del tag.attrs[attr]
1147
- elif attr_lower == "style":
1148
- tag.attrs[attr] = sanitize_css(value_text)
1149
- elif "url(" in value_text.lower():
1150
- tag.attrs[attr] = sanitize_css(value_text)
1151
- svg = soup.find("svg")
1152
- return str(svg) if svg else ""
1153
-
1154
-
1155
- def svg_with_embedded_css(svg_markup: str, css_text: str) -> str:
1156
- css_text = css_text.strip()
1157
- if not css_text:
1158
- return svg_markup
1159
- soup = BeautifulSoup(svg_markup, "html.parser")
1160
- svg = soup.find("svg")
1161
- if svg is None:
1162
- return svg_markup
1163
- style = soup.new_tag("style")
1164
- style.string = css_text
1165
- svg.insert(0, style)
1166
- return str(svg)
1167
-
1168
-
1169
- def render_svg_chart(chart: ChartItem, out_dir: Path) -> tuple[Path | None, dict[str, Any]]:
1170
- asset_dir = out_dir / "assets" / "charts"
1171
- asset_dir.mkdir(parents=True, exist_ok=True)
1172
- image_path = asset_dir / f"chart_{chart.index:02d}.png"
1173
- safe_svg = sanitize_svg_markup(chart.svg_markup)
1174
- if not safe_svg:
1175
- return None, {"error": "chart SVG was empty after sanitization"}
1176
- report_css = sanitize_css(chart.css_text)
1177
- html = (
1178
- "<!doctype html><html><head><meta charset='utf-8'>"
1179
- "<style>"
1180
- "html,body{margin:0;padding:0;background:white;}svg{display:block;}"
1181
- f"{report_css}"
1182
- "</style>"
1183
- "</head><body>" + safe_svg + "</body></html>"
1184
- )
1185
- try:
1186
- import cairosvg # type: ignore[import-not-found]
1187
-
1188
- cairosvg.svg2png(
1189
- bytestring=svg_with_embedded_css(safe_svg, report_css).encode("utf-8"),
1190
- write_to=str(image_path),
1191
- )
1192
- return image_path, {"sanitized": True, "renderer": "cairosvg"}
1193
- except Exception as cairosvg_error:
1194
- cairo_details = {"cairosvg_error": str(cairosvg_error)[:500]}
1195
-
1196
- try:
1197
- doc_skill_scripts = Path(__file__).resolve().parents[2] / "report-to-google-doc" / "scripts"
1198
- if str(doc_skill_scripts) not in sys.path:
1199
- sys.path.insert(0, str(doc_skill_scripts))
1200
- from report_to_google_doc.rendering import render_simple_svg_with_pillow
1201
-
1202
- render_simple_svg_with_pillow(
1203
- svg_with_embedded_css(safe_svg, report_css),
1204
- image_path,
1205
- max(360, chart.width_px),
1206
- max(220, chart.height_px),
1207
- )
1208
- return image_path, {"sanitized": True, "renderer": "pillow_svg_subset", **cairo_details}
1209
- except Exception as pillow_error:
1210
- pillow_details = {"pillow_svg_error": str(pillow_error)[:500]}
1211
-
1212
- try:
1213
- from playwright.sync_api import sync_playwright
1214
-
1215
- with sync_playwright() as playwright:
1216
- browser = playwright.chromium.launch(headless=True)
1217
- context = browser.new_context(
1218
- viewport={
1219
- "width": max(360, min(1600, chart.width_px + 8)),
1220
- "height": max(220, min(1100, chart.height_px + 8)),
1221
- "device_scale_factor": 2,
1222
- },
1223
- java_script_enabled=False,
1224
- )
1225
- page = context.new_page()
1226
- page.route("**/*", lambda route: route.abort())
1227
- page.set_content(html)
1228
- page.locator("svg").screenshot(path=str(image_path))
1229
- context.close()
1230
- browser.close()
1231
- except Exception as exc:
1232
- svg_path = asset_dir / f"chart_{chart.index:02d}.svg"
1233
- svg_path.write_text(safe_svg, encoding="utf-8")
1234
- return None, {
1235
- "error": str(exc)[:500],
1236
- "sanitized_svg": str(svg_path),
1237
- **cairo_details,
1238
- **pillow_details,
1239
- }
1240
- return image_path, {
1241
- "sanitized": True,
1242
- "external_resources_blocked": True,
1243
- **cairo_details,
1244
- **pillow_details,
1245
- }
1246
-
1247
-
1248
- def image_is_nonblank(path: Path) -> tuple[bool, dict[str, Any]]:
1249
- with Image.open(path).convert("RGB") as image:
1250
- extrema = image.getextrema()
1251
- diff = ImageChops.difference(image, Image.new("RGB", image.size, (255, 255, 255)))
1252
- bbox = diff.getbbox()
1253
- changed_ratio = 0.0
1254
- if bbox:
1255
- gray = diff.convert("L")
1256
- histogram = gray.histogram()
1257
- changed = sum(count for value, count in enumerate(histogram) if value > 8)
1258
- changed_ratio = changed / (image.width * image.height)
1259
- return bool(bbox and changed_ratio > 0.01), {
1260
- "size": image.size,
1261
- "extrema": extrema,
1262
- "changed_ratio": round(changed_ratio, 4),
1263
- }
1264
-
1265
-
1266
- def render_charts(report: Report, out_dir: Path, preflight: Preflight) -> None:
1267
- for chart in report.charts:
1268
- path, details = render_svg_chart(chart, out_dir)
1269
- if path is None:
1270
- preflight.add(
1271
- f"chart_render:{chart.index}",
1272
- False,
1273
- f"chart {chart.index} could not be rendered",
1274
- details,
1275
- )
1276
- continue
1277
- try:
1278
- ok, image_details = image_is_nonblank(path)
1279
- except Exception as exc:
1280
- preflight.add(
1281
- f"chart_render:{chart.index}",
1282
- False,
1283
- f"chart {chart.index} rendered unreadable image",
1284
- {"path": str(path), "error": str(exc)[:500], **details},
1285
- )
1286
- continue
1287
- chart.image_path = str(path)
1288
- preflight.add(
1289
- f"chart_render:{chart.index}",
1290
- ok,
1291
- f"chart {chart.index} rendered to {path.name}"
1292
- if ok
1293
- else f"chart {chart.index} rendered blank",
1294
- {**image_details, **details},
1295
- )
1296
-
1297
-
1298
- def table_slide_title(table: TableItem) -> str:
1299
- title = collapse_ws(table.title)
1300
- if title:
1301
- return title if "table" in normalized_label(title) else f"{title} table"
1302
- if table.headers:
1303
- return "Report table: " + ", ".join(table.headers[:3])
1304
- return "Report table"
1305
-
1306
-
1307
- def conclusions_slide_title(report: Report) -> str:
1308
- has_conclusions = has_section_content(report, CONCLUSION_SECTION_CANDIDATES)
1309
- has_implications = has_section_content(report, IMPLICATION_SECTION_CANDIDATES)
1310
- if has_conclusions and has_implications:
1311
- return "Conclusions and implications"
1312
- if has_conclusions:
1313
- return section_title_by_candidates(report, CONCLUSION_SECTION_CANDIDATES, "Conclusions")
1314
- if has_implications:
1315
- return section_title_by_candidates(report, IMPLICATION_SECTION_CANDIDATES, "Implications")
1316
- return "Key takeaways"
1317
-
1318
-
1319
- def slide_plan(report: Report) -> list[dict[str, Any]]:
1320
- executive = section_items_by_candidates(report, EXECUTIVE_SECTION_CANDIDATES)
1321
- conclusions = section_items_by_candidates(report, CONCLUSION_SECTION_CANDIDATES)
1322
- implications = section_items_by_candidates(report, IMPLICATION_SECTION_CANDIDATES)
1323
- caveats = section_items_by_candidates(report, CAVEAT_SECTION_CANDIDATES)
1324
- sources_section = find_section(report, SOURCE_SECTION_CANDIDATES)
1325
- sources = (sources_section.paragraphs + sources_section.bullets) if sources_section else []
1326
- plan: list[dict[str, Any]] = [
1327
- {
1328
- "kind": "cover",
1329
- "title": report.title,
1330
- "elements": ["slide-title", "lede", "metric-1", "metric-2", "metric-3", "metric-4"],
1331
- },
1332
- {
1333
- "kind": "executive_summary",
1334
- "title": "Executive summary",
1335
- "elements": [
1336
- "slide-title",
1337
- "summary-bullets",
1338
- "metric-card-1",
1339
- "metric-card-2",
1340
- "metric-card-3",
1341
- "metric-card-4",
1342
- ],
1343
- "source_paragraphs": executive,
1344
- },
1345
- ]
1346
- for chart in report.charts:
1347
- plan.append(
1348
- {
1349
- "kind": "chart_evidence",
1350
- "title": chart.takeaway,
1351
- "chart_index": chart.index,
1352
- "elements": ["slide-title", "chart-main", "chart-callout", "source-note"],
1353
- }
1354
- )
1355
- for table_index, table in enumerate(report.tables, start=1):
1356
- plan.append(
1357
- {
1358
- "kind": "table",
1359
- "title": table_slide_title(table),
1360
- "table_index": table_index,
1361
- "elements": ["slide-title", "table-main", "table-note", "source-note"],
1362
- }
1363
- )
1364
- if conclusions or implications:
1365
- plan.append(
1366
- {
1367
- "kind": "conclusions_implications",
1368
- "title": conclusions_slide_title(report),
1369
- "elements": ["slide-title", "conclusions", "implications"],
1370
- "source_paragraphs": {"conclusions": conclusions, "implications": implications},
1371
- }
1372
- )
1373
- if caveats:
1374
- plan.append(
1375
- {
1376
- "kind": "caveats",
1377
- "title": section_title_by_candidates(
1378
- report, CAVEAT_SECTION_CANDIDATES, "Caveats and assumptions"
1379
- ),
1380
- "elements": ["slide-title", "caveats"],
1381
- "source_paragraphs": caveats,
1382
- }
1383
- )
1384
- if sources_section and (sources or sources_section.links):
1385
- source_bullets, source_links = source_bullets_with_links(report)
1386
- source_title = section_title_by_candidates(
1387
- report, SOURCE_SECTION_CANDIDATES, "Sources and reproducibility"
1388
- )
1389
- plan.append(
1390
- {
1391
- "kind": "sources_reproducibility",
1392
- "title": source_title,
1393
- "elements": ["slide-title", "sources"],
1394
- "source_paragraphs": sources,
1395
- "source_links": [
1396
- {"text": bullet, "href": link}
1397
- for bullet, link in zip(source_bullets, source_links)
1398
- if link
1399
- ],
1400
- }
1401
- )
1402
- return plan
1403
-
1404
-
1405
- def add_rule(slide: Any, name: str, x: float, y: float, w: float, color: str) -> None:
1406
- shape = slide.shapes.add_shape(
1407
- MSO_SHAPE.RECTANGLE, Inches(x), Inches(y), Inches(w), Inches(0.025)
1408
- )
1409
- shape.name = name
1410
- set_shape_fill(shape, color)
1411
- set_shape_line(shape, color, width_pt=0)
1412
-
1413
-
1414
- def add_vertical_rule(slide: Any, name: str, x: float, y: float, h: float, color: str) -> None:
1415
- shape = slide.shapes.add_shape(
1416
- MSO_SHAPE.RECTANGLE, Inches(x), Inches(y), Inches(0.015), Inches(h)
1417
- )
1418
- shape.name = name
1419
- set_shape_fill(shape, color)
1420
- set_shape_line(shape, color, width_pt=0)
1421
-
1422
-
1423
- def add_metric_card(
1424
- slide: Any,
1425
- metric: MetricCard,
1426
- idx: int,
1427
- x: float,
1428
- y: float,
1429
- w: float,
1430
- h: float,
1431
- colors: dict[str, str],
1432
- preflight: Preflight,
1433
- ) -> None:
1434
- add_box(
1435
- slide,
1436
- f"metric-{idx}",
1437
- x,
1438
- y,
1439
- w,
1440
- h,
1441
- "#FFFFFF",
1442
- colors.get("grid", DEFAULT_COLORS["grid"]),
1443
- radius=True,
1444
- )
1445
- add_text(
1446
- slide,
1447
- f"metric-label-{idx}",
1448
- x + 0.12,
1449
- y + 0.12,
1450
- w - 0.24,
1451
- 0.32,
1452
- metric.label,
1453
- 7.5,
1454
- colors["muted"],
1455
- preflight,
1456
- bold=True,
1457
- min_font=6,
1458
- )
1459
- add_text(
1460
- slide,
1461
- f"metric-value-{idx}",
1462
- x + 0.12,
1463
- y + 0.46,
1464
- w - 0.24,
1465
- 0.46,
1466
- metric.value,
1467
- 20,
1468
- colors["blue"],
1469
- preflight,
1470
- bold=True,
1471
- min_font=14,
1472
- )
1473
- add_text(
1474
- slide,
1475
- f"metric-note-{idx}",
1476
- x + 0.12,
1477
- y + 0.85,
1478
- w - 0.24,
1479
- 0.36,
1480
- metric.note,
1481
- 8.5,
1482
- colors["muted"],
1483
- preflight,
1484
- min_font=6.5,
1485
- )
1486
-
1487
-
1488
- def executive_bullets(report: Report) -> list[str]:
1489
- items = section_items_by_candidates(report, EXECUTIVE_SECTION_CANDIDATES)
1490
- if not items:
1491
- return [report.lede]
1492
- bullets = [first_sentence(item, 320) for item in items[:4]]
1493
- return [bullet for bullet in bullets if bullet]
1494
-
1495
-
1496
- def shorten_for_bullet(text: str, max_chars: int) -> str:
1497
- text = collapse_ws(text)
1498
- if len(text) <= max_chars:
1499
- return text
1500
- sentences = re.split(r"(?<=[.!?])\s+", text)
1501
- if sentences and len(sentences[0]) <= max_chars:
1502
- return sentences[0]
1503
- return text[: max_chars - 3].rsplit(" ", 1)[0] + "..."
1504
-
1505
-
1506
- def split_sentences(paragraphs: list[str], max_items: int = 6, max_chars: int = 170) -> list[str]:
1507
- output: list[str] = []
1508
- for paragraph in paragraphs:
1509
- paragraph = collapse_ws(paragraph)
1510
- if not paragraph:
1511
- continue
1512
- if len(paragraph) <= max_chars:
1513
- output.append(paragraph)
1514
- if len(output) >= max_items:
1515
- return output
1516
- continue
1517
- parts = re.split(r"(?<=[.!?])\s+", paragraph)
1518
- for part in parts:
1519
- part = collapse_ws(part)
1520
- if part:
1521
- output.append(shorten_for_bullet(part, max_chars))
1522
- if len(output) >= max_items:
1523
- return output
1524
- return output
1525
-
1526
-
1527
- def list_slide_items(items: list[str], max_items: int = 6, max_chars: int = 220) -> list[str]:
1528
- cleaned = [collapse_ws(item) for item in items if collapse_ws(item)]
1529
- if len(cleaned) <= max_items:
1530
- return [shorten_for_bullet(item, max_chars) for item in cleaned]
1531
- return split_sentences(cleaned, max_items=max_items, max_chars=max_chars)
1532
-
1533
-
1534
- def normalize_sentence_key(text: str) -> str:
1535
- return re.sub(r"[^a-z0-9]+", " ", collapse_ws(text).lower()).strip()
1536
-
1537
-
1538
- def chart_callout_text(chart: ChartItem) -> str:
1539
- text = collapse_ws(chart.caveat)
1540
- if not text:
1541
- return ""
1542
- sentences = [
1543
- collapse_ws(part) for part in re.split(r"(?<=[.!?])\s+", text) if collapse_ws(part)
1544
- ]
1545
- if not sentences:
1546
- return text
1547
-
1548
- first_key = normalize_sentence_key(sentences[0])
1549
- takeaway_key = normalize_sentence_key(chart.takeaway)
1550
- title_key = normalize_sentence_key(chart.title)
1551
- if (
1552
- first_key
1553
- and (takeaway_key or title_key)
1554
- and (
1555
- first_key == takeaway_key
1556
- or first_key.startswith((takeaway_key, title_key))
1557
- or takeaway_key.startswith(first_key)
1558
- or first_key == title_key
1559
- or title_key.startswith(first_key)
1560
- )
1561
- ):
1562
- trimmed = " ".join(sentences[1:]).strip()
1563
- if trimmed:
1564
- return trimmed
1565
- return text
1566
- return text
1567
-
1568
-
1569
- def build_cover(prs: Presentation, report: Report, preflight: Preflight) -> None:
1570
- colors = report.colors
1571
- slide = blank_slide(prs, colors)
1572
- add_text(
1573
- slide,
1574
- "eyebrow",
1575
- 0.72,
1576
- 0.46,
1577
- 5.2,
1578
- 0.36,
1579
- report.eyebrow or "Analytics report",
1580
- 9.5,
1581
- colors["muted"],
1582
- preflight,
1583
- bold=True,
1584
- min_font=7,
1585
- )
1586
- add_text(
1587
- slide,
1588
- "slide-title",
1589
- 0.68,
1590
- 0.95,
1591
- 11.7,
1592
- 1.55,
1593
- report.title,
1594
- 28,
1595
- colors["ink"],
1596
- preflight,
1597
- bold=True,
1598
- min_font=20,
1599
- )
1600
- add_rule(slide, "title-rule", 0.72, 2.7, 1.45, colors["gold"])
1601
- add_text(
1602
- slide,
1603
- "lede",
1604
- 0.72,
1605
- 3.02,
1606
- 10.7,
1607
- 1.0,
1608
- report.lede,
1609
- 14,
1610
- colors["neutral_dark"],
1611
- preflight,
1612
- min_font=10,
1613
- )
1614
- metric_count = min(4, len(report.metrics))
1615
- if metric_count:
1616
- gap = 0.18
1617
- card_w = (11.9 - gap * (metric_count - 1)) / metric_count
1618
- for idx, metric in enumerate(report.metrics[:metric_count], start=1):
1619
- add_metric_card(
1620
- slide,
1621
- metric,
1622
- idx,
1623
- 0.72 + (idx - 1) * (card_w + gap),
1624
- 5.45,
1625
- card_w,
1626
- 1.24,
1627
- colors,
1628
- preflight,
1629
- )
1630
- add_source(slide, "Source: HTML report conversion inventory.", colors, preflight)
1631
-
1632
-
1633
- def build_executive(prs: Presentation, report: Report, preflight: Preflight) -> None:
1634
- colors = report.colors
1635
- slide = blank_slide(prs, colors)
1636
- add_text(
1637
- slide,
1638
- "slide-title",
1639
- 0.65,
1640
- 0.45,
1641
- 12.0,
1642
- 0.6,
1643
- "Executive summary",
1644
- 25,
1645
- colors["ink"],
1646
- preflight,
1647
- bold=True,
1648
- min_font=18,
1649
- )
1650
- add_rule(slide, "summary-rule", 0.72, 1.17, 1.2, colors["gold"])
1651
- if not report.metrics:
1652
- add_bullets(
1653
- slide,
1654
- "summary-bullets",
1655
- 0.72,
1656
- 1.48,
1657
- 11.55,
1658
- 4.75,
1659
- executive_bullets(report),
1660
- 16.2,
1661
- colors["ink"],
1662
- preflight,
1663
- max_items=5,
1664
- min_font=11,
1665
- )
1666
- add_source(slide, "Source: report executive summary.", colors, preflight)
1667
- return
1668
- add_bullets(
1669
- slide,
1670
- "summary-bullets",
1671
- 0.72,
1672
- 1.48,
1673
- 7.08,
1674
- 4.55,
1675
- executive_bullets(report),
1676
- 15.7,
1677
- colors["ink"],
1678
- preflight,
1679
- max_items=4,
1680
- min_font=11,
1681
- )
1682
- add_text(
1683
- slide,
1684
- "metrics-heading",
1685
- 8.32,
1686
- 1.22,
1687
- 4.25,
1688
- 0.34,
1689
- "Key metrics",
1690
- 10.5,
1691
- colors["muted"],
1692
- preflight,
1693
- bold=True,
1694
- min_font=8,
1695
- )
1696
- metric_w = 2.02
1697
- metric_h = 1.38
1698
- gap_x = 0.2
1699
- gap_y = 0.26
1700
- for idx, metric in enumerate(report.metrics[:4], start=1):
1701
- col = (idx - 1) % 2
1702
- row = (idx - 1) // 2
1703
- x = 8.32 + col * (metric_w + gap_x)
1704
- y = 1.68 + row * (metric_h + gap_y)
1705
- add_box(
1706
- slide,
1707
- f"metric-card-{idx}",
1708
- x,
1709
- y,
1710
- metric_w,
1711
- metric_h,
1712
- "#FFFFFF",
1713
- colors["grid"],
1714
- radius=True,
1715
- )
1716
- add_text(
1717
- slide,
1718
- f"summary-metric-label-{idx}",
1719
- x + 0.14,
1720
- y + 0.12,
1721
- metric_w - 0.28,
1722
- 0.28,
1723
- metric.label,
1724
- 8.4,
1725
- colors["muted"],
1726
- preflight,
1727
- bold=True,
1728
- min_font=7,
1729
- )
1730
- add_text(
1731
- slide,
1732
- f"summary-metric-value-{idx}",
1733
- x + 0.14,
1734
- y + 0.46,
1735
- metric_w - 0.28,
1736
- 0.42,
1737
- metric.value,
1738
- 19,
1739
- colors["blue"],
1740
- preflight,
1741
- bold=True,
1742
- min_font=14,
1743
- )
1744
- add_text(
1745
- slide,
1746
- f"summary-metric-note-{idx}",
1747
- x + 0.14,
1748
- y + 0.93,
1749
- metric_w - 0.28,
1750
- 0.3,
1751
- metric.note,
1752
- 8.8,
1753
- colors["muted"],
1754
- preflight,
1755
- min_font=7,
1756
- )
1757
- add_source(slide, "Source: report executive summary and top metric cards.", colors, preflight)
1758
-
1759
-
1760
- def build_chart_slide(
1761
- prs: Presentation, report: Report, chart: ChartItem, preflight: Preflight
1762
- ) -> None:
1763
- colors = report.colors
1764
- slide = blank_slide(prs, colors)
1765
- add_text(
1766
- slide,
1767
- "slide-title",
1768
- 0.62,
1769
- 0.38,
1770
- 12.0,
1771
- 0.82,
1772
- chart.takeaway,
1773
- 20,
1774
- colors["ink"],
1775
- preflight,
1776
- bold=True,
1777
- min_font=13.5,
1778
- )
1779
- chart_box = add_box(
1780
- slide, "chart-frame", 0.72, 1.28, 8.0, 5.05, "#FFFFFF", colors["grid"], radius=True
1781
- )
1782
- chart_box.shadow.inherit = False
1783
- if not chart.image_path:
1784
- preflight.add(f"chart_image:{chart.index}", False, "chart image path missing")
1785
- return
1786
- try:
1787
- picture = slide.shapes.add_picture(
1788
- chart.image_path, Inches(0.95), Inches(1.55), width=Inches(7.55)
1789
- )
1790
- except Exception as exc:
1791
- preflight.add(
1792
- f"chart_image:{chart.index}",
1793
- False,
1794
- "chart image could not be inserted into PPTX",
1795
- {"path": chart.image_path, "error": str(exc)[:500]},
1796
- )
1797
- return
1798
- picture.name = "chart-main"
1799
- if picture.height > Inches(4.45):
1800
- picture.height = Inches(4.45)
1801
- picture.left = Inches(0.95 + (7.55 - picture.width / 914400) / 2)
1802
- picture.top = Inches(1.55 + (4.45 - picture.height / 914400) / 2)
1803
- add_box(
1804
- slide, "chart-callout-frame", 9.0, 1.28, 3.55, 5.05, "#FFFFFF", colors["grid"], radius=True
1805
- )
1806
- callout = chart_callout_text(chart)
1807
- add_text(
1808
- slide,
1809
- "chart-callout-heading",
1810
- 9.25,
1811
- 1.55,
1812
- 3.05,
1813
- 0.35,
1814
- "Context" if callout else "Source",
1815
- 10,
1816
- colors["muted"],
1817
- preflight,
1818
- bold=True,
1819
- min_font=7,
1820
- )
1821
- if callout:
1822
- add_text(
1823
- slide,
1824
- "chart-callout",
1825
- 9.25,
1826
- 1.95,
1827
- 3.05,
1828
- 2.2,
1829
- callout,
1830
- 12.5,
1831
- colors["ink"],
1832
- preflight,
1833
- min_font=8.5,
1834
- )
1835
- add_text(
1836
- slide,
1837
- "chart-source",
1838
- 9.25,
1839
- 4.68 if callout else 1.95,
1840
- 3.05,
1841
- 0.75 if callout else 1.4,
1842
- chart.title,
1843
- 8.5,
1844
- colors["muted"],
1845
- preflight,
1846
- min_font=6.5,
1847
- )
1848
- add_source(
1849
- slide,
1850
- f"Source chart {chart.index}: extracted from report SVG and rendered as chart evidence.",
1851
- colors,
1852
- preflight,
1853
- )
1854
-
1855
-
1856
- def build_table_slide(
1857
- prs: Presentation, report: Report, table: TableItem, preflight: Preflight
1858
- ) -> None:
1859
- colors = report.colors
1860
- slide = blank_slide(prs, colors)
1861
- add_text(
1862
- slide,
1863
- "slide-title",
1864
- 0.62,
1865
- 0.42,
1866
- 12.0,
1867
- 0.55,
1868
- table_slide_title(table),
1869
- 22,
1870
- colors["ink"],
1871
- preflight,
1872
- bold=True,
1873
- min_font=16,
1874
- )
1875
- cols = max([1, len(table.headers), *[len(row) for row in table.rows]])
1876
- headers = table.headers + [f"Column {idx}" for idx in range(len(table.headers) + 1, cols + 1)]
1877
- body_rows = [row + [""] * (cols - len(row)) for row in table.rows]
1878
- rows = max(1, len(body_rows) + 1)
1879
- table_h = min(4.25, max(3.05, 0.58 + max(1, len(table.rows)) * 0.42))
1880
- shape = slide.shapes.add_table(
1881
- rows, cols, Inches(0.62), Inches(1.35), Inches(12.1), Inches(table_h)
1882
- )
1883
- shape.name = "table-main"
1884
- ppt_table = shape.table
1885
- if cols >= 1:
1886
- ppt_table.columns[0].width = Inches(1.95)
1887
- for c in range(1, cols):
1888
- ppt_table.columns[c].width = Inches((12.1 - 1.95) / max(1, cols - 1))
1889
- ppt_table.rows[0].height = Inches(0.52)
1890
- body_h = table_h - 0.52
1891
- if rows > 1:
1892
- body_h = (table_h - 0.52) / (rows - 1)
1893
- for r in range(1, rows):
1894
- ppt_table.rows[r].height = Inches(body_h)
1895
- body_font = 11.4 if body_h >= 0.38 else 10.2 if body_h >= 0.31 else 9.2
1896
- for c, header in enumerate(headers):
1897
- cell = ppt_table.cell(0, c)
1898
- cell.text = header
1899
- set_shape_fill(cell, colors["blue_light"])
1900
- cell.margin_left = Inches(0.08)
1901
- cell.margin_right = Inches(0.08)
1902
- cell.margin_top = Inches(0.04)
1903
- cell.margin_bottom = Inches(0.04)
1904
- for paragraph in cell.text_frame.paragraphs:
1905
- paragraph.alignment = PP_ALIGN.CENTER if c else PP_ALIGN.LEFT
1906
- for run in paragraph.runs:
1907
- run.font.size = Pt(10.2)
1908
- run.font.bold = True
1909
- run.font.color.rgb = rgb(colors["ink"])
1910
- for r, row in enumerate(body_rows, start=1):
1911
- for c in range(cols):
1912
- cell = ppt_table.cell(r, c)
1913
- cell.text = row[c] if c < len(row) else ""
1914
- set_shape_fill(cell, "#FFFFFF")
1915
- cell.margin_left = Inches(0.08)
1916
- cell.margin_right = Inches(0.08)
1917
- cell.margin_top = Inches(0.04)
1918
- cell.margin_bottom = Inches(0.04)
1919
- for paragraph in cell.text_frame.paragraphs:
1920
- paragraph.alignment = PP_ALIGN.CENTER if c else PP_ALIGN.LEFT
1921
- for run in paragraph.runs:
1922
- run.font.size = Pt(body_font if c else body_font + 0.4)
1923
- run.font.bold = c == 0
1924
- run.font.color.rgb = rgb(colors["ink"])
1925
- note = table.note or "Source table extracted from the report."
1926
- add_text(
1927
- slide,
1928
- "table-note",
1929
- 0.78,
1930
- 1.35 + table_h + 0.32,
1931
- 11.7,
1932
- 0.55,
1933
- note,
1934
- 11.5,
1935
- colors["muted"],
1936
- preflight,
1937
- min_font=9,
1938
- )
1939
- preflight.add(
1940
- "table_coverage:main",
1941
- bool(headers and body_rows),
1942
- f"main table has {len(body_rows)} body rows and {cols} columns",
1943
- {"rows": len(body_rows), "columns": cols},
1944
- )
1945
- add_source(slide, "Source: report metric table.", colors, preflight)
1946
-
1947
-
1948
- def build_conclusions(prs: Presentation, report: Report, preflight: Preflight) -> None:
1949
- colors = report.colors
1950
- slide = blank_slide(prs, colors)
1951
- add_text(
1952
- slide,
1953
- "slide-title",
1954
- 0.62,
1955
- 0.42,
1956
- 12.0,
1957
- 0.72,
1958
- conclusions_slide_title(report),
1959
- 21,
1960
- colors["ink"],
1961
- preflight,
1962
- bold=True,
1963
- min_font=15,
1964
- )
1965
- conclusion_source_items = section_items_by_candidates(report, CONCLUSION_SECTION_CANDIDATES)
1966
- implication_source_items = section_items_by_candidates(report, IMPLICATION_SECTION_CANDIDATES)
1967
- conclusions = list_slide_items(
1968
- conclusion_source_items,
1969
- max_items=5 if not implication_source_items else 4,
1970
- max_chars=320 if not implication_source_items else 220,
1971
- )
1972
- implications = list_slide_items(
1973
- implication_source_items,
1974
- max_items=6 if not conclusion_source_items else 5,
1975
- max_chars=320 if not conclusion_source_items else 220,
1976
- )
1977
- conclusion_heading = section_title_by_candidates(
1978
- report, CONCLUSION_SECTION_CANDIDATES, "Conclusions"
1979
- )
1980
- implication_heading = section_title_by_candidates(
1981
- report, IMPLICATION_SECTION_CANDIDATES, "Implications"
1982
- )
1983
- if conclusions and not implications:
1984
- add_bullets(
1985
- slide,
1986
- "conclusions",
1987
- 0.75,
1988
- 1.48,
1989
- 11.4,
1990
- 4.75,
1991
- conclusions,
1992
- 14.6,
1993
- colors["ink"],
1994
- preflight,
1995
- max_items=5,
1996
- min_font=10.5,
1997
- )
1998
- add_source(slide, "Source: report conclusion section.", colors, preflight)
1999
- return
2000
- if implications and not conclusions:
2001
- add_bullets(
2002
- slide,
2003
- "implications",
2004
- 0.75,
2005
- 1.48,
2006
- 11.4,
2007
- 4.75,
2008
- implications,
2009
- 14.6,
2010
- colors["ink"],
2011
- preflight,
2012
- max_items=5,
2013
- min_font=10.5,
2014
- )
2015
- add_source(slide, "Source: report implications section.", colors, preflight)
2016
- return
2017
- add_text(
2018
- slide,
2019
- "conclusions-heading",
2020
- 0.78,
2021
- 1.38,
2022
- 5.2,
2023
- 0.35,
2024
- conclusion_heading,
2025
- 13,
2026
- colors["blue"],
2027
- preflight,
2028
- bold=True,
2029
- min_font=10,
2030
- )
2031
- add_bullets(
2032
- slide,
2033
- "conclusions",
2034
- 0.75,
2035
- 1.82,
2036
- 5.55,
2037
- 4.35,
2038
- conclusions,
2039
- 14.2,
2040
- colors["ink"],
2041
- preflight,
2042
- max_items=4,
2043
- min_font=10,
2044
- )
2045
- add_text(
2046
- slide,
2047
- "implications-heading",
2048
- 7.02,
2049
- 1.38,
2050
- 5.2,
2051
- 0.35,
2052
- implication_heading,
2053
- 13,
2054
- colors["blue"],
2055
- preflight,
2056
- bold=True,
2057
- min_font=10,
2058
- )
2059
- add_bullets(
2060
- slide,
2061
- "implications",
2062
- 7.0,
2063
- 1.82,
2064
- 5.55,
2065
- 4.35,
2066
- implications,
2067
- 14.2,
2068
- colors["ink"],
2069
- preflight,
2070
- max_items=4,
2071
- min_font=10,
2072
- )
2073
- add_vertical_rule(slide, "column-divider", 6.67, 1.35, 4.95, colors["grid"])
2074
- add_source(slide, "Source: report conclusions and implications sections.", colors, preflight)
2075
-
2076
-
2077
- def source_bullets_with_links(report: Report) -> tuple[list[str], list[str | None]]:
2078
- section = find_section(report, SOURCE_SECTION_CANDIDATES) or Section("Sources")
2079
- bullets: list[str] = []
2080
- links: list[str | None] = []
2081
- for link in section.links:
2082
- label = collapse_ws(link.text)
2083
- href = collapse_ws(link.href)
2084
- if not href:
2085
- continue
2086
- display = href if not label or label == href else f"{label}: {href}"
2087
- bullets.append(display)
2088
- links.append(href)
2089
-
2090
- remaining_text_slots = max(0, 6 - len(bullets))
2091
- source_text = (
2092
- split_sentences(
2093
- section.paragraphs + section.bullets,
2094
- max_items=remaining_text_slots,
2095
- max_chars=300,
2096
- )
2097
- if remaining_text_slots
2098
- else []
2099
- )
2100
- for item in source_text:
2101
- if any(item in existing or existing in item for existing in bullets):
2102
- continue
2103
- bullets.append(item)
2104
- links.append(None)
2105
- return bullets, links
2106
-
2107
-
2108
- def build_caveats_slide(prs: Presentation, report: Report, preflight: Preflight) -> None:
2109
- colors = report.colors
2110
- slide = blank_slide(prs, colors)
2111
- add_text(
2112
- slide,
2113
- "slide-title",
2114
- 0.62,
2115
- 0.42,
2116
- 12.0,
2117
- 0.55,
2118
- section_title_by_candidates(report, CAVEAT_SECTION_CANDIDATES, "Caveats and assumptions"),
2119
- 23,
2120
- colors["ink"],
2121
- preflight,
2122
- bold=True,
2123
- min_font=17,
2124
- )
2125
- add_rule(slide, "caveats-rule", 0.72, 1.18, 1.2, colors["gold"])
2126
- caveats = split_sentences(
2127
- section_items_by_candidates(report, CAVEAT_SECTION_CANDIDATES),
2128
- max_items=5,
2129
- max_chars=205,
2130
- )
2131
- add_bullets(
2132
- slide,
2133
- "caveats",
2134
- 0.92,
2135
- 1.52,
2136
- 11.15,
2137
- 4.85,
2138
- caveats,
2139
- 15.1,
2140
- colors["ink"],
2141
- preflight,
2142
- max_items=5,
2143
- min_font=11,
2144
- )
2145
- add_source(slide, "Source: report caveats and assumptions section.", colors, preflight)
2146
-
2147
-
2148
- def build_sources_slide(
2149
- prs: Presentation,
2150
- report: Report,
2151
- preflight: Preflight,
2152
- bullets: list[str],
2153
- links: list[str | None],
2154
- ) -> None:
2155
- colors = report.colors
2156
- slide = blank_slide(prs, colors)
2157
- base_title = section_title_by_candidates(
2158
- report, SOURCE_SECTION_CANDIDATES, "Sources and reproducibility"
2159
- )
2160
- add_text(
2161
- slide,
2162
- "slide-title",
2163
- 0.62,
2164
- 0.42,
2165
- 12.0,
2166
- 0.55,
2167
- base_title,
2168
- 23,
2169
- colors["ink"],
2170
- preflight,
2171
- bold=True,
2172
- min_font=17,
2173
- )
2174
- add_rule(slide, "sources-rule", 0.72, 1.18, 1.2, colors["gold"])
2175
- add_bullets(
2176
- slide,
2177
- "sources",
2178
- 0.92,
2179
- 1.38,
2180
- 11.15,
2181
- 5.45,
2182
- bullets,
2183
- 10.6,
2184
- colors["ink"],
2185
- preflight,
2186
- max_items=len(bullets),
2187
- min_font=6,
2188
- links=links,
2189
- link_color=colors["blue"],
2190
- space_after_pt=2.0,
2191
- )
2192
- all_links = [link for link in source_bullets_with_links(report)[1] if link]
2193
- preflight.add(
2194
- "coverage:source_links",
2195
- True,
2196
- f"preserved {len(all_links)} source links from the report",
2197
- {"links": all_links},
2198
- )
2199
- add_source(slide, "Source: report sources and reproducibility section.", colors, preflight)
2200
-
2201
-
2202
- def create_pptx(report: Report, out_dir: Path, preflight: Preflight) -> Path:
2203
- prs = Presentation()
2204
- prs.slide_width = Inches(SLIDE_W)
2205
- prs.slide_height = Inches(SLIDE_H)
2206
- build_cover(prs, report, preflight)
2207
- build_executive(prs, report, preflight)
2208
- for chart in report.charts:
2209
- build_chart_slide(prs, report, chart, preflight)
2210
- for table in report.tables:
2211
- build_table_slide(prs, report, table, preflight)
2212
- if has_section_content(report, CONCLUSION_SECTION_CANDIDATES) or has_section_content(
2213
- report, IMPLICATION_SECTION_CANDIDATES
2214
- ):
2215
- build_conclusions(prs, report, preflight)
2216
- if has_section_content(report, CAVEAT_SECTION_CANDIDATES):
2217
- build_caveats_slide(prs, report, preflight)
2218
- if has_section_content(report, SOURCE_SECTION_CANDIDATES):
2219
- source_bullets, source_links = source_bullets_with_links(report)
2220
- build_sources_slide(prs, report, preflight, source_bullets, source_links)
2221
- pptx_path = out_dir / "deck.pptx"
2222
- prs.save(pptx_path)
2223
- return pptx_path
2224
-
2225
-
2226
- def pptx_package_ok(path: Path) -> tuple[bool, dict[str, Any]]:
2227
- try:
2228
- with zipfile.ZipFile(path) as archive:
2229
- bad = archive.testzip()
2230
- slide_files = [
2231
- name for name in archive.namelist() if re.match(r"ppt/slides/slide\d+\.xml", name)
2232
- ]
2233
- media_files = [name for name in archive.namelist() if name.startswith("ppt/media/")]
2234
- return bad is None and bool(slide_files), {
2235
- "bad_member": bad,
2236
- "slide_count": len(slide_files),
2237
- "media_count": len(media_files),
2238
- }
2239
- except zipfile.BadZipFile:
2240
- return False, {"error": "bad zip file"}
2241
-
2242
-
2243
- def build_manifest(report: Report) -> dict[str, Any]:
2244
- return {
2245
- "source": report.source,
2246
- "title": report.title,
2247
- "eyebrow": report.eyebrow,
2248
- "counts": {
2249
- "metrics": len(report.metrics),
2250
- "sections": len(report.sections),
2251
- "charts": len(report.charts),
2252
- "tables": len(report.tables),
2253
- },
2254
- "metrics": [asdict(metric) for metric in report.metrics],
2255
- "sections": {title: asdict(section) for title, section in report.sections.items()},
2256
- "charts": [
2257
- {
2258
- "index": chart.index,
2259
- "title": chart.title,
2260
- "takeaway": chart.takeaway,
2261
- "caveat": chart.caveat,
2262
- "image_path": chart.image_path,
2263
- "width_px": chart.width_px,
2264
- "height_px": chart.height_px,
2265
- }
2266
- for chart in report.charts
2267
- ],
2268
- "tables": [asdict(table) for table in report.tables],
2269
- "colors": report.colors,
2270
- }
2271
-
2272
-
2273
- def add_coverage_checks(report: Report, preflight: Preflight) -> None:
2274
- preflight.add(
2275
- "source:title", bool(report.title), "source report title extracted", {"title": report.title}
2276
- )
2277
- has_content = bool(
2278
- report.sections or report.metrics or report.charts or report.tables or report.lede
2279
- )
2280
- preflight.add(
2281
- "source:content",
2282
- has_content,
2283
- "source report body content extracted",
2284
- {
2285
- "sections": len(report.sections),
2286
- "metrics": len(report.metrics),
2287
- "charts": len(report.charts),
2288
- "tables": len(report.tables),
2289
- },
2290
- )
2291
- preflight.add(
2292
- "source:metrics",
2293
- True,
2294
- f"extracted {len(report.metrics)} metric cards",
2295
- {"metrics": len(report.metrics)},
2296
- )
2297
- preflight.add(
2298
- "source:charts",
2299
- True,
2300
- f"extracted {len(report.charts)} charts",
2301
- {"charts": len(report.charts)},
2302
- )
2303
- preflight.add(
2304
- "source:tables",
2305
- True,
2306
- f"extracted {len(report.tables)} tables",
2307
- {"tables": len(report.tables)},
2308
- )
2309
- preflight.add(
2310
- "coverage:executive_section",
2311
- True,
2312
- "executive-style section discovery completed",
2313
- {"found": bool(find_section(report, EXECUTIVE_SECTION_CANDIDATES))},
2314
- )
2315
- preflight.add(
2316
- "coverage:caveats",
2317
- True,
2318
- "caveat-style section discovery completed",
2319
- {"found": bool(find_section(report, CAVEAT_SECTION_CANDIDATES))},
2320
- )
2321
- preflight.add(
2322
- "coverage:sources",
2323
- True,
2324
- "source-style section discovery completed",
2325
- {"found": bool(find_section(report, SOURCE_SECTION_CANDIDATES))},
2326
- )
2327
-
2328
-
2329
- def run(source: str, out_dir: Path) -> int:
2330
- out_dir.mkdir(parents=True, exist_ok=True)
2331
- preflight = Preflight()
2332
- source_path = resolve_source(source)
2333
- report = parse_report(source_path)
2334
- add_coverage_checks(report, preflight)
2335
- render_charts(report, out_dir, preflight)
2336
- plan = slide_plan(report)
2337
- write_json(out_dir / "deck_plan.json", plan)
2338
- pptx_path = create_pptx(report, out_dir, preflight)
2339
- ok, details = pptx_package_ok(pptx_path)
2340
- preflight.add("pptx:package", ok, "PPTX package is readable", details)
2341
- manifest = build_manifest(report)
2342
- manifest["outputs"] = {
2343
- "deck_pptx": str(pptx_path),
2344
- "deck_plan": str(out_dir / "deck_plan.json"),
2345
- "preflight": str(out_dir / "preflight_checks.json"),
2346
- }
2347
- write_json(out_dir / "manifest.json", manifest)
2348
- write_json(out_dir / "preflight_checks.json", preflight.to_json())
2349
- print(
2350
- json.dumps(
2351
- {
2352
- "status": "passed" if preflight.passed else "failed",
2353
- "pptx": str(pptx_path),
2354
- "out_dir": str(out_dir),
2355
- },
2356
- indent=2,
2357
- )
2358
- )
2359
- return 0 if preflight.passed else 2
2360
-
2361
-
2362
- def parse_args(argv: list[str]) -> argparse.Namespace:
2363
- parser = argparse.ArgumentParser(
2364
- description="Convert an HTML analytics report into a local PPTX deck."
2365
- )
2366
- parser.add_argument("source", help="Local path to the HTML report file.")
2367
- parser.add_argument(
2368
- "--out-dir", required=True, help="Directory for deck.pptx and verification artifacts."
2369
- )
2370
- return parser.parse_args(argv)
2371
-
2372
-
2373
- def main(argv: list[str] | None = None) -> int:
2374
- args = parse_args(argv or sys.argv[1:])
2375
- return run(args.source, Path(args.out_dir).expanduser().resolve())
2376
-
2377
-
2378
- if __name__ == "__main__":
2379
- raise SystemExit(main())