create-yss-spec 1.1.2 → 1.1.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.
- package/package.json +1 -1
- package/template/.codex/skills/data-analytics/.app.json +84 -0
- package/template/.codex/skills/data-analytics/.codex-plugin/plugin.json +66 -0
- package/template/.codex/skills/data-analytics/.mcp.json +30 -0
- package/template/.codex/skills/data-analytics/AGENTS.md +20 -0
- package/template/.codex/skills/data-analytics/DEPENDENCIES.MD +27 -0
- package/template/.codex/skills/data-analytics/README.md +60 -0
- package/template/.codex/skills/data-analytics/assets/datascience-artifact-widget.html +16 -0
- package/template/.codex/skills/data-analytics/assets/datascience-artifact-widget.html.gz.b64.part001 +1 -0
- package/template/.codex/skills/data-analytics/assets/datascience-artifact-widget.html.gz.b64.part002 +1 -0
- package/template/.codex/skills/data-analytics/assets/datascience-artifact-widget.html.gz.b64.part003 +1 -0
- package/template/.codex/skills/data-analytics/assets/datascience-artifact-widget.html.gz.b64.part004 +1 -0
- package/template/.codex/skills/data-analytics/assets/datascience-artifact-widget.html.gz.b64.part005 +1 -0
- package/template/.codex/skills/data-analytics/assets/datascience-artifact-widget.html.gz.b64.part006 +1 -0
- package/template/.codex/skills/data-analytics/assets/datascience-chart-widget.html +16 -0
- package/template/.codex/skills/data-analytics/assets/datascience-chart-widget.html.gz.b64.part001 +1 -0
- package/template/.codex/skills/data-analytics/assets/datascience-chart-widget.html.gz.b64.part002 +1 -0
- package/template/.codex/skills/data-analytics/assets/datascience-chart-widget.html.gz.b64.part003 +1 -0
- package/template/.codex/skills/data-analytics/assets/datascience-chart-widget.html.gz.b64.part004 +1 -0
- package/template/.codex/skills/data-analytics/assets/datascience-chart-widget.html.gz.b64.part005 +1 -0
- package/template/.codex/skills/data-analytics/assets/datascience-chart-widget.html.gz.b64.part006 +1 -0
- package/template/.codex/skills/data-analytics/assets/datascience-chart-widget.html.gz.b64.part007 +1 -0
- package/template/.codex/skills/data-analytics/assets/datascience-small.svg +5 -0
- package/template/.codex/skills/data-analytics/assets/datascience-table-widget.html +16 -0
- package/template/.codex/skills/data-analytics/assets/datascience-table-widget.html.gz.b64.part001 +1 -0
- package/template/.codex/skills/data-analytics/assets/datascience-table-widget.html.gz.b64.part002 +1 -0
- package/template/.codex/skills/data-analytics/assets/datascience-table-widget.html.gz.b64.part003 +1 -0
- package/template/.codex/skills/data-analytics/assets/datascience-table-widget.html.gz.b64.part004 +1 -0
- package/template/.codex/skills/data-analytics/assets/datascience.png +0 -0
- package/template/.codex/skills/data-analytics/assets/datascience.svg +10 -0
- package/template/.codex/skills/data-analytics/mcp/server.cjs +2963 -0
- package/template/.codex/skills/data-analytics/package-lock.json +3048 -0
- package/template/.codex/skills/data-analytics/package.json +43 -0
- package/template/.codex/skills/data-analytics/scripts/normalize-widget-assets.mjs +75 -0
- package/template/.codex/skills/data-analytics/skills/analyze-data-quality/SKILL.md +160 -0
- package/template/.codex/skills/data-analytics/skills/analyze-data-quality/agents/openai.yaml +6 -0
- package/template/.codex/skills/data-analytics/skills/build-dashboard/SKILL.md +148 -0
- package/template/.codex/skills/data-analytics/skills/build-dashboard/agents/openai.yaml +6 -0
- package/template/.codex/skills/data-analytics/skills/build-dashboard/specifications/bi-platform-dashboard.md +18 -0
- package/template/.codex/skills/data-analytics/skills/build-dashboard/specifications/html-dashboard.md +24 -0
- package/template/.codex/skills/data-analytics/skills/build-dashboard/specifications/mcp-artifact-dashboard.md +71 -0
- package/template/.codex/skills/data-analytics/skills/build-dashboard/specifications/streamlit-dashboard.md +85 -0
- package/template/.codex/skills/data-analytics/skills/build-report/SKILL.md +207 -0
- package/template/.codex/skills/data-analytics/skills/build-report/agents/openai.yaml +6 -0
- package/template/.codex/skills/data-analytics/skills/build-report/assets/executive-report-shell.html +70 -0
- package/template/.codex/skills/data-analytics/skills/build-report/assets/technical-report-shell.html +66 -0
- package/template/.codex/skills/data-analytics/skills/build-report/report-to-google-doc/SKILL.md +118 -0
- package/template/.codex/skills/data-analytics/skills/build-report/report-to-google-doc/agents/openai.yaml +6 -0
- package/template/.codex/skills/data-analytics/skills/build-report/report-to-google-doc/scripts/report_to_google_doc/__init__.py +1 -0
- package/template/.codex/skills/data-analytics/skills/build-report/report-to-google-doc/scripts/report_to_google_doc/cli.py +57 -0
- package/template/.codex/skills/data-analytics/skills/build-report/report-to-google-doc/scripts/report_to_google_doc/constants.py +61 -0
- package/template/.codex/skills/data-analytics/skills/build-report/report-to-google-doc/scripts/report_to_google_doc/docx_writer.py +362 -0
- package/template/.codex/skills/data-analytics/skills/build-report/report-to-google-doc/scripts/report_to_google_doc/html_parser.py +829 -0
- package/template/.codex/skills/data-analytics/skills/build-report/report-to-google-doc/scripts/report_to_google_doc/model.py +30 -0
- package/template/.codex/skills/data-analytics/skills/build-report/report-to-google-doc/scripts/report_to_google_doc/plan.py +129 -0
- package/template/.codex/skills/data-analytics/skills/build-report/report-to-google-doc/scripts/report_to_google_doc/quality.py +374 -0
- package/template/.codex/skills/data-analytics/skills/build-report/report-to-google-doc/scripts/report_to_google_doc/rendering.py +613 -0
- package/template/.codex/skills/data-analytics/skills/build-report/report-to-google-doc/scripts/report_to_google_doc/table_utils.py +54 -0
- package/template/.codex/skills/data-analytics/skills/build-report/report-to-google-doc/scripts/report_to_google_doc/utils.py +16 -0
- package/template/.codex/skills/data-analytics/skills/build-report/report-to-google-doc/scripts/report_to_google_doc_plan.py +9 -0
- package/template/.codex/skills/data-analytics/skills/build-report/report-to-google-slides/SKILL.md +78 -0
- package/template/.codex/skills/data-analytics/skills/build-report/report-to-google-slides/agents/openai.yaml +6 -0
- package/template/.codex/skills/data-analytics/skills/build-report/report-to-google-slides/scripts/report_to_google_slides.py +2379 -0
- package/template/.codex/skills/data-analytics/skills/build-report/report-to-pdf/SKILL.md +88 -0
- package/template/.codex/skills/data-analytics/skills/build-report/report-to-pdf/agents/openai.yaml +6 -0
- package/template/.codex/skills/data-analytics/skills/build-report/specifications/executive-report.md +97 -0
- package/template/.codex/skills/data-analytics/skills/build-report/specifications/mcp-app-report.md +59 -0
- package/template/.codex/skills/data-analytics/skills/build-report/specifications/technical-report.md +75 -0
- package/template/.codex/skills/data-analytics/skills/design-kpis/SKILL.md +103 -0
- package/template/.codex/skills/data-analytics/skills/design-kpis/agents/openai.yaml +6 -0
- package/template/.codex/skills/data-analytics/skills/gather-business-context/SKILL.md +68 -0
- package/template/.codex/skills/data-analytics/skills/gather-business-context/agents/openai.yaml +6 -0
- package/template/.codex/skills/data-analytics/skills/index/SKILL.md +251 -0
- package/template/.codex/skills/data-analytics/skills/index/agents/openai.yaml +6 -0
- package/template/.codex/skills/data-analytics/skills/jupyter-notebooks/SKILL.md +131 -0
- package/template/.codex/skills/data-analytics/skills/jupyter-notebooks/agents/openai.yaml +6 -0
- package/template/.codex/skills/data-analytics/skills/kpi-reporting/SKILL.md +141 -0
- package/template/.codex/skills/data-analytics/skills/kpi-reporting/agents/openai.yaml +6 -0
- package/template/.codex/skills/data-analytics/skills/kpi-reporting/references/report-templates.md +32 -0
- package/template/.codex/skills/data-analytics/skills/market-sizing/SKILL.md +106 -0
- package/template/.codex/skills/data-analytics/skills/market-sizing/agents/openai.yaml +6 -0
- package/template/.codex/skills/data-analytics/skills/metric-diagnostics/SKILL.md +130 -0
- package/template/.codex/skills/data-analytics/skills/metric-diagnostics/agents/openai.yaml +6 -0
- package/template/.codex/skills/data-analytics/skills/product-business-analysis/SKILL.md +141 -0
- package/template/.codex/skills/data-analytics/skills/product-business-analysis/agents/openai.yaml +6 -0
- package/template/.codex/skills/data-analytics/skills/spreadsheets/SKILL.md +178 -0
- package/template/.codex/skills/data-analytics/skills/spreadsheets/agents/openai.yaml +9 -0
- package/template/.codex/skills/data-analytics/skills/spreadsheets/assets/file-spreadsheet.png +0 -0
- package/template/.codex/skills/data-analytics/skills/spreadsheets/charts.md +31 -0
- package/template/.codex/skills/data-analytics/skills/spreadsheets/references/artifact_tool_api.md +466 -0
- package/template/.codex/skills/data-analytics/skills/spreadsheets/style_guidelines.md +99 -0
- package/template/.codex/skills/data-analytics/skills/user-context/SKILL.md +195 -0
- package/template/.codex/skills/data-analytics/skills/user-context/agents/openai.yaml +6 -0
- package/template/.codex/skills/data-analytics/skills/user-context/plugin-author-config/automation-config.md +26 -0
- package/template/.codex/skills/data-analytics/skills/user-context/plugin-author-config/source-category-config.json +51 -0
- package/template/.codex/skills/data-analytics/skills/user-context/plugin-author-config/user-context-config.md +66 -0
- package/template/.codex/skills/data-analytics/skills/user-context/references/automation.md +69 -0
- package/template/.codex/skills/data-analytics/skills/user-context/references/onboarding-examples.md +204 -0
- package/template/.codex/skills/data-analytics/skills/user-context/references/onboarding-state-template.json +87 -0
- package/template/.codex/skills/data-analytics/skills/user-context/references/onboarding.md +497 -0
- package/template/.codex/skills/data-analytics/skills/user-context/references/semantic-layer/connector-playbook.md +74 -0
- package/template/.codex/skills/data-analytics/skills/user-context/references/semantic-layer/setup.md +65 -0
- package/template/.codex/skills/data-analytics/skills/user-context/references/semantic-layer/skill-template.md +160 -0
- package/template/.codex/skills/data-analytics/skills/user-context/references/semantic-layer/source-intake.md +75 -0
- package/template/.codex/skills/data-analytics/skills/user-context/references/semantic-layer/weekly-polling-automation.md +96 -0
- package/template/.codex/skills/data-analytics/skills/user-context/references/source-category-runtime.md +263 -0
- package/template/.codex/skills/data-analytics/skills/user-context/scripts/data_analytics_preflight.py +1460 -0
- package/template/.codex/skills/data-analytics/skills/user-context/scripts/init_user_context_state.py +128 -0
- package/template/.codex/skills/data-analytics/skills/user-context/scripts/reset_user_context_state.py +101 -0
- package/template/.codex/skills/data-analytics/skills/user-context/scripts/validate_user_context_preflight.py +499 -0
- package/template/.codex/skills/data-analytics/skills/user-context/tests/test_state_helpers.py +978 -0
- package/template/.codex/skills/data-analytics/skills/validate-data/SKILL.md +190 -0
- package/template/.codex/skills/data-analytics/skills/validate-data/agents/openai.yaml +6 -0
- package/template/.codex/skills/data-analytics/skills/visualize-data/SKILL.md +157 -0
- package/template/.codex/skills/data-analytics/skills/visualize-data/agents/openai.yaml +6 -0
- package/template/.codex/skills/data-analytics/skills/visualize-data/references/seaborn-templates.md +774 -0
- package/template/.codex/skills/data-analytics/src/DESIGN.md +222 -0
- package/template/.codex/skills/data-analytics/src/analytics-app/App.tsx +3666 -0
- package/template/.codex/skills/data-analytics/src/analytics-app/analytics-layout.test.mjs +136 -0
- package/template/.codex/skills/data-analytics/src/analytics-app/charting/ChartFrame.tsx +54 -0
- package/template/.codex/skills/data-analytics/src/analytics-app/charting/ChartLegend.tsx +96 -0
- package/template/.codex/skills/data-analytics/src/analytics-app/charting/ChartRenderer.tsx +1647 -0
- package/template/.codex/skills/data-analytics/src/analytics-app/charting/ChartTooltip.tsx +245 -0
- package/template/.codex/skills/data-analytics/src/analytics-app/charting/chart-app-helpers.tsx +462 -0
- package/template/.codex/skills/data-analytics/src/analytics-app/charting/chart-capabilities.ts +107 -0
- package/template/.codex/skills/data-analytics/src/analytics-app/charting/chart-compatibility.ts +164 -0
- package/template/.codex/skills/data-analytics/src/analytics-app/charting/chart-contract.ts +192 -0
- package/template/.codex/skills/data-analytics/src/analytics-app/charting/chart-theme.ts +203 -0
- package/template/.codex/skills/data-analytics/src/analytics-app/charting/chart-tokens.css +619 -0
- package/template/.codex/skills/data-analytics/src/analytics-app/charting/chart-transforms.ts +402 -0
- package/template/.codex/skills/data-analytics/src/analytics-app/fonts/SystemSansVariableVF.woff2 +0 -0
- package/template/.codex/skills/data-analytics/src/analytics-app/imageExport.ts +370 -0
- package/template/.codex/skills/data-analytics/src/analytics-app/layout/AnalyticsLayoutCanvas.tsx +536 -0
- package/template/.codex/skills/data-analytics/src/analytics-app/layout/RichMarkdown.tsx +681 -0
- package/template/.codex/skills/data-analytics/src/analytics-app/layout/analyticsLayoutCore.ts +164 -0
- package/template/.codex/skills/data-analytics/src/analytics-app/main.tsx +21 -0
- package/template/.codex/skills/data-analytics/src/analytics-app/scripts/chart_contract.py +472 -0
- package/template/.codex/skills/data-analytics/src/analytics-app/scripts/check_analytics_app_runtime_links.py +75 -0
- package/template/.codex/skills/data-analytics/src/analytics-app/scripts/design_contract.py +142 -0
- package/template/.codex/skills/data-analytics/src/analytics-app/scripts/package_utils.py +142 -0
- package/template/.codex/skills/data-analytics/src/analytics-app/scripts/tests/test_package_utils.py +377 -0
- package/template/.codex/skills/data-analytics/src/analytics-app/styles.css +3468 -0
- package/template/.codex/skills/data-analytics/src/analytics-app/tables/DataTable.d.ts +44 -0
- package/template/.codex/skills/data-analytics/src/analytics-app/tables/DataTable.jsx +639 -0
- package/template/.codex/skills/data-analytics/src/analytics-app/tables/data-table.css +327 -0
- package/template/.codex/skills/data-analytics/src/analytics-app/tokens.css +225 -0
- package/template/.codex/skills/data-analytics/src/analytics-app/types.ts +204 -0
- package/template/.codex/skills/data-analytics/src/analytics-app-core.md +85 -0
- package/template/.codex/skills/data-analytics/src/codex-style-contract.md +30 -0
- package/template/.codex/skills/data-analytics/src/datascience-artifact-widget.html +12 -0
- package/template/.codex/skills/data-analytics/src/datascience-artifact-widget.jsx +1008 -0
- package/template/.codex/skills/data-analytics/src/datascience-chart-widget.css +2117 -0
- package/template/.codex/skills/data-analytics/src/datascience-chart-widget.html +122 -0
- package/template/.codex/skills/data-analytics/src/datascience-chart-widget.js +3588 -0
- package/template/.codex/skills/data-analytics/src/datascience-table-widget.css +483 -0
- package/template/.codex/skills/data-analytics/src/datascience-table-widget.html +32 -0
- package/template/.codex/skills/data-analytics/src/datascience-table-widget.js +333 -0
- package/template/.codex/skills/data-analytics/src/mcp-host.js +153 -0
- package/template/.codex/skills/data-analytics/src/recharts-config.js +315 -0
- package/template/.codex/skills/data-analytics/src/recharts-renderer.jsx +294 -0
- package/template/.codex/skills/data-analytics/src/sql-source-view.css +33 -0
- package/template/.codex/skills/data-analytics/src/sql-source-view.js +157 -0
- package/template/.codex/skills/data-analytics/src/styles/codex-theme.css +121 -0
- package/template/.codex/skills/data-analytics/src/table-renderer.jsx +28 -0
- package/template/.codex/skills/data-analytics/tests/chart-transforms.test.mjs +87 -0
- package/template/.codex/skills/data-analytics/tests/funnel-smoke.html +105 -0
- package/template/.codex/skills/data-analytics/tests/inline-widget-compare.html +233 -0
- package/template/.codex/skills/data-analytics/tests/mcp-server.test.mjs +1500 -0
- package/template/.codex/skills/data-analytics/tests/native-style-contract.test.mjs +219 -0
- package/template/.codex/skills/data-analytics/tests/recharts-config.test.mjs +144 -0
- package/template/.codex/skills/data-analytics/tests/recharts-renderer.test.mjs +11 -0
- package/template/.codex/skills/data-analytics/tests/rich-markdown.test.mjs +54 -0
- package/template/.codex/skills/data-analytics/tests/widget-render-harness.html +321 -0
- package/template/.codex/skills/data-analytics/tsconfig.json +19 -0
- package/template/.codex/skills/data-analytics/vite.config.ts +46 -0
- package/template/.codex/skills/product-design/.app.json +7 -0
- package/template/.codex/skills/product-design/.codex-plugin/plugin.json +50 -0
- package/template/.codex/skills/product-design/README.md +50 -0
- package/template/.codex/skills/product-design/agents/openai.yaml +4 -0
- package/template/.codex/skills/product-design/assets/composerIcon.svg +9 -0
- package/template/.codex/skills/product-design/assets/logo.png +0 -0
- package/template/.codex/skills/product-design/package.json +11 -0
- package/template/.codex/skills/product-design/references/browser-order.md +9 -0
- package/template/.codex/skills/product-design/references/communication-protocol.md +45 -0
- package/template/.codex/skills/product-design/references/critical-overrides.md +58 -0
- package/template/.codex/skills/product-design/references/local-prototype-preflight.md +16 -0
- package/template/.codex/skills/product-design/scripts/bootstrap-prototype.mjs +147 -0
- package/template/.codex/skills/product-design/skills/audit/SKILL.md +159 -0
- package/template/.codex/skills/product-design/skills/audit/agents/openai.yaml +6 -0
- package/template/.codex/skills/product-design/skills/audit/references/design-audit-framework.md +73 -0
- package/template/.codex/skills/product-design/skills/design-qa/SKILL.md +133 -0
- package/template/.codex/skills/product-design/skills/design-qa/agents/openai.yaml +6 -0
- package/template/.codex/skills/product-design/skills/design-qa/references/qa-rubric.md +61 -0
- package/template/.codex/skills/product-design/skills/get-context/SKILL.md +81 -0
- package/template/.codex/skills/product-design/skills/get-context/agents/openai.yaml +6 -0
- package/template/.codex/skills/product-design/skills/ideate/SKILL.md +179 -0
- package/template/.codex/skills/product-design/skills/ideate/agents/openai.yaml +6 -0
- package/template/.codex/skills/product-design/skills/image-to-code/SKILL.md +105 -0
- package/template/.codex/skills/product-design/skills/image-to-code/agents/openai.yaml +6 -0
- package/template/.codex/skills/product-design/skills/index/SKILL.md +126 -0
- package/template/.codex/skills/product-design/skills/index/agents/openai.yaml +6 -0
- package/template/.codex/skills/product-design/skills/prototype/SKILL.md +128 -0
- package/template/.codex/skills/product-design/skills/prototype/agents/openai.yaml +6 -0
- package/template/.codex/skills/product-design/skills/prototype/references/existing-codebase-edits.md +18 -0
- package/template/.codex/skills/product-design/skills/research/SKILL.md +92 -0
- package/template/.codex/skills/product-design/skills/research/agents/openai.yaml +6 -0
- package/template/.codex/skills/product-design/skills/share/SKILL.md +41 -0
- package/template/.codex/skills/product-design/skills/share/agents/openai.yaml +6 -0
- package/template/.codex/skills/product-design/skills/url-to-code/SKILL.md +124 -0
- package/template/.codex/skills/product-design/skills/url-to-code/agents/openai.yaml +6 -0
- package/template/.codex/skills/product-design/skills/user-context/SKILL.md +147 -0
- package/template/.codex/skills/product-design/skills/user-context/agents/openai.yaml +6 -0
- package/template/.codex/skills/product-design/skills/user-context/plugin-author-config/user-context-template.md +63 -0
- package/template/.codex/skills/product-design/skills/user-context/references/onboarding.md +66 -0
- package/template/.codex/skills/product-design/skills/user-context/scripts/init_user_context.py +86 -0
- package/template/.codex/skills/product-design/skills/user-context/scripts/user_context_preflight.py +204 -0
- package/template/.codex/skills/product-design/templates/prototype/AGENTS.md +7 -0
- package/template/.codex/skills/product-design/templates/prototype/index.html +12 -0
- package/template/.codex/skills/product-design/templates/prototype/package.json +18 -0
- package/template/.codex/skills/product-design/templates/prototype/src/App.jsx +5 -0
- package/template/.codex/skills/product-design/templates/prototype/src/main.jsx +10 -0
- package/template/.codex/skills/product-design/templates/prototype/src/styles.css +13 -0
- package/template/.codex/skills/product-design/templates/prototype/vite.config.mjs +14 -0
- package/template/.codex/skills/yss-product-lifecycle/SKILL.md +3 -3
- package/template/AGENTS.md +3 -3
- package/template/docs/adr/adr-product-design-prototype-entrypoint.md +54 -0
- package/template/docs/api/templates/openapi-draft-review-checklist.md +4 -2
- package/template/docs/design/README.md +8 -4
- package/template/docs/design/templates/interaction-spec-template.md +4 -1
- package/template/docs/design/templates/prototype-confirmation-template.md +57 -0
- package/template/docs/design/templates/prototype-review-checklist.md +6 -2
- package/template/docs/design/templates/state-matrix-template.md +1 -1
- package/template/docs/process/harness-executive-blueprint.md +2 -1
- package/template/docs/process/harness-process-tailoring.md +1 -1
- package/template/docs/process/harness-work-unit-map.md +1 -1
- package/template/docs/process/lifecycle-artifact-map.md +4 -3
- package/template/docs/process/product-prototype-workflow.md +82 -0
- package/template/docs/user-guide/product-lifecycle-workflow.md +2 -2
- package/template/docs/user-guide/product-rd-lifecycle-best-practices.md +15 -10
|
@@ -0,0 +1,2379 @@
|
|
|
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())
|