@heylemon/lemonade 0.1.6 → 0.1.7
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/dist/build-info.json +3 -3
- package/dist/canvas-host/a2ui/.bundle.hash +1 -1
- package/package.json +1 -1
- package/skills/docx/SKILL.md +595 -22
- package/skills/docx/references/templates.md +669 -33
- package/skills/docx/scripts/create_doc.py +289 -52
- package/skills/docx/scripts/validate.py +237 -0
- package/skills/docx/scripts/validate_doc.py +103 -22
- package/skills/pptx/SKILL.md +169 -12
- package/skills/pptx/editing.md +270 -0
- package/skills/pptx/pptxgenjs.md +624 -0
- package/skills/pptx/references/spec-format.md +106 -31
- package/skills/pptx/scripts/create_pptx.js +419 -186
- package/skills/xlsx/SKILL.md +502 -14
- package/skills/xlsx/references/spec-format.md +238 -40
- package/skills/xlsx/scripts/create_xlsx.py +130 -54
- package/skills/xlsx/scripts/recalc.py +157 -147
- package/skills/xlsx/scripts/validate_xlsx.py +31 -6
|
@@ -1,53 +1,251 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Financial Model Standards
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Professional Excel financial models follow industry conventions for colors, formatting, and structure. These standards ensure models are readable, auditable, and suitable for investor presentations.
|
|
4
|
+
|
|
5
|
+
## Color Coding Reference
|
|
6
|
+
|
|
7
|
+
All financial models use consistent color coding for cell types:
|
|
8
|
+
|
|
9
|
+
| Color | Meaning | Cell Type | RGB (Excel) | Example |
|
|
10
|
+
|---|---|---|---|---|
|
|
11
|
+
| Blue text | Input values | Hardcoded numbers user changes | (0, 0, 255) | Revenue growth rates, unit prices |
|
|
12
|
+
| Black text | Formulas | Calculated values | (0, 0, 0) | SUM totals, margin calculations |
|
|
13
|
+
| Green text | Cross-sheet references | Formulas linking other sheets | (0, 128, 0) | Pulling actuals from Data sheet |
|
|
14
|
+
| Red text | External references | Data from outside sources | (255, 0, 0) | Market data, benchmark rates |
|
|
15
|
+
| Yellow background | Key assumptions | Critical driver cells | Yellow | Growth rate, discount rate, conversion |
|
|
16
|
+
|
|
17
|
+
## Number Formatting Conventions
|
|
18
|
+
|
|
19
|
+
Apply these formats to match industry standards and improve readability:
|
|
20
|
+
|
|
21
|
+
| Data Type | Format String | Example | Use Case |
|
|
22
|
+
|---|---|---|---|
|
|
23
|
+
| Currency (standard) | `$#,##0` | $45,000 | Individual amounts |
|
|
24
|
+
| Currency (thousands) | `$#,##0,"K"` | $45K | Large amounts, summaries |
|
|
25
|
+
| Currency (millions) | `$#,##0,,"M"` | $1,245M | Very large amounts |
|
|
26
|
+
| Percentage | `0.0%` | 15.2% | Margins, growth rates |
|
|
27
|
+
| Percentage (no decimals) | `0%` | 15% | When precision not needed |
|
|
28
|
+
| Multiples | `0.0x` | 3.2x | Revenue multiples, LTV/CAC |
|
|
29
|
+
| Years (text) | Text format | 2026 | Period headers (never 2,026) |
|
|
30
|
+
| Zero display | `_($* #,##0_);_($* (#,##0);_($* "-"_);_(@_)` | – | Display zeros as dashes |
|
|
31
|
+
| Negatives in parens | `($#,##0)` | ($5,000) | Cash flows, deficits |
|
|
32
|
+
|
|
33
|
+
## Cell Types by Use Case
|
|
34
|
+
|
|
35
|
+
### Revenue/Income Statement Model
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
Row 1: [Metric] [2024A] [2025E] [2026E]
|
|
39
|
+
Row 2: Revenue ($K) [1000] [1500] [2100] <- Blue inputs
|
|
40
|
+
Row 3: Growth Rate — 50% 40% <- Blue inputs + Yellow bg
|
|
41
|
+
Row 4: COGS ($K) [400] [600] [820] <- Formulas (black)
|
|
42
|
+
Row 5: Gross Margin % 40.0% 40.0% 39.0% <- Blue inputs + Yellow bg
|
|
43
|
+
Row 6: OpEx ($K) [300] [350] [420] <- Blue inputs
|
|
44
|
+
Row 7: EBITDA ($K) [300] [550] [860] <- Formulas (black)
|
|
45
|
+
Row 8: EBITDA Margin 30.0% 36.7% 41.0% <- Formulas (black)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### Cash Flow Model
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
Row 1: [Item] [Month 1] [Month 2] [Month 3]
|
|
52
|
+
Row 2: Beginning Cash [100,000] [formula] [formula] <- Blue for Month 1, black after
|
|
53
|
+
Row 3: Operating Cash In [50,000] [50,000] [60,000] <- Blue inputs
|
|
54
|
+
Row 4: Operating Cash Out [30,000] [28,000] [32,000] <- Blue inputs
|
|
55
|
+
Row 5: Operating Cash Flow [formula] [formula] [formula] <- Formulas (black)
|
|
56
|
+
Row 6: Capex [10,000] [10,000] [—] <- Blue inputs
|
|
57
|
+
Row 7: Ending Cash [formula] [formula] [formula] <- Formulas (black)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### 3-Statement Waterfall Model
|
|
4
61
|
|
|
5
|
-
```json
|
|
6
|
-
{
|
|
7
|
-
"brand": {},
|
|
8
|
-
"sheets": []
|
|
9
|
-
}
|
|
10
62
|
```
|
|
63
|
+
[2024A] [2025E] [2026E]
|
|
64
|
+
Income Statement
|
|
65
|
+
Revenue 1000 1500 2100 <- Blue inputs
|
|
66
|
+
COGS -400 -600 -820 <- Formulas (black) or blue
|
|
67
|
+
Operating Exp -300 -350 -420 <- Blue inputs
|
|
68
|
+
EBITDA 300 550 860 <- Formulas (black)
|
|
69
|
+
D&A -50 -50 -50 <- Blue inputs
|
|
70
|
+
EBIT 250 500 810 <- Formulas (black)
|
|
71
|
+
Interest -25 -25 -20 <- Blue inputs or formulas
|
|
72
|
+
PBT 225 475 790 <- Formulas (black)
|
|
73
|
+
Tax (35%) -79 -166 -277 <- Formulas (black)
|
|
74
|
+
Net Income 146 309 513 <- Formulas (black)
|
|
11
75
|
|
|
12
|
-
|
|
76
|
+
Balance Sheet
|
|
77
|
+
Cash 100 300 [formula] <- Blue or cross-sheet link (green)
|
|
78
|
+
AR [formula] [formula] [formula] <- Formulas based on sales
|
|
79
|
+
Assets [formula] [formula] [formula] <- Formulas (sum)
|
|
80
|
+
AP [formula] [formula] [formula] <- Formulas based on COGS
|
|
81
|
+
Debt 500 400 [formula] <- Blue or calculation
|
|
82
|
+
Equity [formula] [formula] [formula] <- Formulas
|
|
13
83
|
|
|
14
|
-
|
|
84
|
+
Cash Flow
|
|
85
|
+
Operating CF [formula] [formula] [formula] <- Formulas from I/S + BS changes
|
|
86
|
+
Investing CF -100 -100 -150 <- Blue inputs (capex)
|
|
87
|
+
Financing CF [formula] [formula] [formula] <- Formulas (debt/equity changes)
|
|
88
|
+
Net Cash Change [formula] [formula] [formula] <- Formulas (sum of above)
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Assumptions Sheet Structure
|
|
92
|
+
|
|
93
|
+
Always create a dedicated "Assumptions" sheet:
|
|
94
|
+
|
|
95
|
+
```
|
|
96
|
+
Row 1: [Category] [Value] [Comment]
|
|
97
|
+
Row 2: ========== FINANCIAL DRIVERS ==========
|
|
98
|
+
Row 3: Revenue Growth 50% <- Blue input + yellow bg
|
|
99
|
+
Row 4: Gross Margin 40.0% <- Blue input + yellow bg
|
|
100
|
+
Row 5: Tax Rate 35.0% <- Blue input + yellow bg
|
|
101
|
+
Row 6:
|
|
102
|
+
Row 7: ========== BALANCE SHEET DRIVERS ==========
|
|
103
|
+
Row 8: Days Sales Outstanding 45 <- Blue input + yellow bg
|
|
104
|
+
Row 9: Days Payable 30 <- Blue input + yellow bg
|
|
105
|
+
Row 10: Capex as % of Revenue 5% <- Blue input + yellow bg
|
|
106
|
+
Row 11:
|
|
107
|
+
Row 12: ========== WORKING CAPITAL ==========
|
|
108
|
+
Row 13: AR Days 45 <- Blue input
|
|
109
|
+
Row 14: Inventory Days 30 <- Blue input
|
|
110
|
+
Row 15: AP Days 30 <- Blue input
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Formula Best Practices
|
|
114
|
+
|
|
115
|
+
### Hardcoded vs. Referenced
|
|
15
116
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
}
|
|
117
|
+
**WRONG** — Constants baked into formulas:
|
|
118
|
+
```
|
|
119
|
+
C4 = C2 * 0.40 (hardcoded 40% margin)
|
|
120
|
+
D7 = C7 + C7 * 0.15 (hardcoded 15% tax rate)
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
**RIGHT** — References to assumption cells:
|
|
124
|
+
```
|
|
125
|
+
C4 = C2 * $B$5 (references cell B5: Gross Margin)
|
|
126
|
+
D7 = C7 + C7 * $B$6 (references cell B6: Tax Rate)
|
|
27
127
|
```
|
|
28
128
|
|
|
29
|
-
|
|
129
|
+
Use absolute references ($B$5) for constants so they don't shift when copying formulas.
|
|
130
|
+
|
|
131
|
+
### Cross-Sheet References
|
|
132
|
+
|
|
133
|
+
Format: `='Sheet Name'!Cell`
|
|
30
134
|
|
|
31
|
-
```json
|
|
32
|
-
{
|
|
33
|
-
"name": "Dashboard",
|
|
34
|
-
"type": "dashboard",
|
|
35
|
-
"title": "Q1 Performance Dashboard",
|
|
36
|
-
"kpis": [{"value": "$156K", "label": "Monthly Revenue", "change": "+32%"}],
|
|
37
|
-
"sections": [{"title": "Top Deals", "headers": ["Client", "Value"], "rows": [["Enterprise Co", "$120K"]]}]
|
|
38
|
-
}
|
|
39
135
|
```
|
|
136
|
+
=SUM('P&L'!B7:B100) (Sum from P&L sheet)
|
|
137
|
+
='Assumptions'!B3 (Pull growth rate from assumptions)
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### Handling Zeros and Blanks
|
|
40
141
|
|
|
41
|
-
|
|
142
|
+
Avoid errors with defensive formulas:
|
|
42
143
|
|
|
43
|
-
```json
|
|
44
|
-
{
|
|
45
|
-
"name": "Financial Model",
|
|
46
|
-
"type": "financial",
|
|
47
|
-
"headers": ["", "2024A", "2025E", "2026E"],
|
|
48
|
-
"rows": [["Revenue", 480, 960, 1920]],
|
|
49
|
-
"input_cells": ["B2"],
|
|
50
|
-
"assumption_cells": ["C2"],
|
|
51
|
-
"formulas": [{"cell": "D2", "formula": "=C2*(1+C3)"}]
|
|
52
|
-
}
|
|
53
144
|
```
|
|
145
|
+
=IF(B2=0, 0, A2/B2) (Guard division by zero)
|
|
146
|
+
=IF(ISBLANK(B2), 0, B2 * C2) (Handle missing data)
|
|
147
|
+
=IFERROR(A2/B2, 0) (Catch any error)
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## Documentation in Cells
|
|
151
|
+
|
|
152
|
+
Use Excel comments (not cell notes) for formulas needing explanation:
|
|
153
|
+
|
|
154
|
+
```
|
|
155
|
+
Cell C7 (EBITDA calculation):
|
|
156
|
+
Comment: "EBITDA = Revenue - COGS - OpEx. Based on full-year actuals from Finance system."
|
|
157
|
+
|
|
158
|
+
Cell B5 (Gross Margin assumption):
|
|
159
|
+
Comment: "Historical 40% based on 3-year average. See Data!B:B for backup calculations."
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
## Common Financial Model Patterns
|
|
163
|
+
|
|
164
|
+
### Scenario Analysis
|
|
165
|
+
|
|
166
|
+
Create separate sections or sheets for Base, Bull, Bear cases:
|
|
167
|
+
|
|
168
|
+
```
|
|
169
|
+
[Base] [Bull] [Bear]
|
|
170
|
+
Revenue Growth 40% 60% 20% <- Blue inputs
|
|
171
|
+
EBITDA Margin 35% 40% 30% <- Blue inputs
|
|
172
|
+
[All metrics below use formulas referencing above]
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
### Waterfall from Revenue to Free Cash Flow
|
|
176
|
+
|
|
177
|
+
```
|
|
178
|
+
Revenue <- Input or formula from sales forecast
|
|
179
|
+
- COGS <- Input or % of revenue
|
|
180
|
+
= Gross Profit <- Formula
|
|
181
|
+
- Operating Expenses <- Input or calculated
|
|
182
|
+
= EBITDA <- Formula
|
|
183
|
+
- D&A <- Input or calculated
|
|
184
|
+
= EBIT <- Formula
|
|
185
|
+
- Interest <- Input or calculated
|
|
186
|
+
- Taxes <- Formula (EBIT * tax rate)
|
|
187
|
+
= Net Income <- Formula
|
|
188
|
+
+ D&A (add back) <- Formula
|
|
189
|
+
- Capex <- Input
|
|
190
|
+
- Working Capital Change <- Formula
|
|
191
|
+
= Free Cash Flow <- Formula
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
## Checkpoints for Model Validation
|
|
195
|
+
|
|
196
|
+
Before sharing a financial model:
|
|
197
|
+
|
|
198
|
+
1. **Formula integrity**: Run recalc.py and verify zero errors
|
|
199
|
+
2. **Color consistency**: All inputs blue, all formulas black, assumptions yellow
|
|
200
|
+
3. **Documentation**: Complex formulas have comments, sources cited
|
|
201
|
+
4. **Formatting**: Currency formatted, percentages 0.0%, years as text
|
|
202
|
+
5. **Cross-checks**: Income statement ties to cash flow, balance sheet balances
|
|
203
|
+
6. **Scenario testing**: Change an assumption, verify all dependents update
|
|
204
|
+
7. **Reasonableness**: Review outputs for logic errors (negative revenue, etc.)
|
|
205
|
+
|
|
206
|
+
## Example: Simple 3-Year Revenue Model
|
|
207
|
+
|
|
208
|
+
```python
|
|
209
|
+
from openpyxl import Workbook
|
|
210
|
+
from openpyxl.styles import Font, PatternFill
|
|
211
|
+
|
|
212
|
+
wb = Workbook()
|
|
213
|
+
ws = wb.active
|
|
214
|
+
ws.title = "P&L"
|
|
215
|
+
|
|
216
|
+
# Headers
|
|
217
|
+
ws['A1'] = "Metric"
|
|
218
|
+
ws['B1'] = "2024A"
|
|
219
|
+
ws['C1'] = "2025E"
|
|
220
|
+
ws['D1'] = "2026E"
|
|
221
|
+
|
|
222
|
+
# Revenue (Blue inputs for projections)
|
|
223
|
+
ws['A2'] = "Revenue ($K)"
|
|
224
|
+
ws['B2'] = 1000 # Actual
|
|
225
|
+
ws['B2'].font = Font(color="000000") # Black for actual
|
|
226
|
+
ws['C2'].value = "=B2*(1+$B$5)" # Formula
|
|
227
|
+
ws['C2'].font = Font(color="000000")
|
|
228
|
+
ws['D2'].value = "=C2*(1+$B$6)"
|
|
229
|
+
ws['D2'].font = Font(color="000000")
|
|
230
|
+
|
|
231
|
+
# Growth Rate (Blue inputs + Yellow assumption)
|
|
232
|
+
ws['A5'] = "2025 Growth Rate"
|
|
233
|
+
ws['B5'] = 0.50
|
|
234
|
+
ws['B5'].font = Font(color="0000FF")
|
|
235
|
+
ws['B5'].fill = PatternFill(start_color="FFFF00", fill_type="solid")
|
|
236
|
+
ws['B5'].number_format = "0.0%"
|
|
237
|
+
|
|
238
|
+
ws['A6'] = "2026 Growth Rate"
|
|
239
|
+
ws['B6'] = 0.40
|
|
240
|
+
ws['B6'].font = Font(color="0000FF")
|
|
241
|
+
ws['B6'].fill = PatternFill(start_color="FFFF00", fill_type="solid")
|
|
242
|
+
ws['B6'].number_format = "0.0%"
|
|
243
|
+
|
|
244
|
+
# Format revenue cells
|
|
245
|
+
for col in ['B', 'C', 'D']:
|
|
246
|
+
ws[f'{col}2'].number_format = "$#,##0"
|
|
247
|
+
|
|
248
|
+
wb.save('p_and_l.xlsx')
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
Then run: `python scripts/recalc.py p_and_l.xlsx`
|
|
@@ -1,53 +1,63 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""
|
|
3
3
|
Pro Sheets — Spreadsheet Creation Engine
|
|
4
|
+
Takes a JSON spec and produces a professionally formatted .xlsx file.
|
|
5
|
+
|
|
6
|
+
Usage: python create_xlsx.py spec.json output.xlsx
|
|
7
|
+
|
|
8
|
+
JSON spec format: see references/spec-format.md
|
|
4
9
|
"""
|
|
5
10
|
|
|
6
11
|
import json
|
|
7
12
|
import sys
|
|
13
|
+
import os
|
|
14
|
+
from datetime import datetime
|
|
8
15
|
|
|
9
16
|
try:
|
|
10
17
|
import openpyxl
|
|
11
|
-
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
|
|
18
|
+
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side, numbers
|
|
12
19
|
from openpyxl.utils import get_column_letter
|
|
13
20
|
except ImportError:
|
|
14
21
|
print("ERROR: openpyxl not installed. Run: pip install openpyxl --break-system-packages")
|
|
15
22
|
sys.exit(1)
|
|
16
23
|
|
|
24
|
+
|
|
25
|
+
# ═══════════════════════════════════════════
|
|
26
|
+
# DEFAULT BRAND
|
|
27
|
+
# ═══════════════════════════════════════════
|
|
17
28
|
DEFAULT_BRAND = {
|
|
18
|
-
"primary": "1B3A5C",
|
|
19
|
-
"accent": "2E86AB",
|
|
20
|
-
"light_bg": "F8FAFC",
|
|
21
|
-
"text_dark": "2C3E50",
|
|
22
|
-
"text_light": "FFFFFF",
|
|
23
|
-
"border": "D5D8DC",
|
|
24
|
-
"positive": "27AE60",
|
|
25
|
-
"negative": "E74C3C",
|
|
26
|
-
"highlight": "FFF8E1",
|
|
29
|
+
"primary": "1B3A5C", # Dark navy headers
|
|
30
|
+
"accent": "2E86AB", # Teal accents
|
|
31
|
+
"light_bg": "F8FAFC", # Alternating row
|
|
32
|
+
"text_dark": "2C3E50", # Body text
|
|
33
|
+
"text_light": "FFFFFF", # White on dark
|
|
34
|
+
"border": "D5D8DC", # Light gray borders
|
|
35
|
+
"positive": "27AE60", # Green for positive
|
|
36
|
+
"negative": "E74C3C", # Red for negative
|
|
37
|
+
"highlight": "FFF8E1", # Gold highlight
|
|
27
38
|
"font": "Arial",
|
|
28
39
|
}
|
|
29
40
|
|
|
41
|
+
# ═══════════════════════════════════════════
|
|
42
|
+
# STYLE HELPERS
|
|
43
|
+
# ═══════════════════════════════════════════
|
|
30
44
|
|
|
31
45
|
def hex_color(c):
|
|
32
|
-
return c.lstrip(
|
|
33
|
-
|
|
46
|
+
return c.lstrip('#')
|
|
34
47
|
|
|
35
48
|
def make_border(color="D5D8DC"):
|
|
36
|
-
side = Side(style=
|
|
49
|
+
side = Side(style='thin', color=hex_color(color))
|
|
37
50
|
return Border(left=side, right=side, top=side, bottom=side)
|
|
38
51
|
|
|
39
|
-
|
|
40
52
|
def header_font(brand, size=11):
|
|
41
53
|
return Font(bold=True, size=size, color=hex_color(brand["text_light"]), name=brand["font"])
|
|
42
54
|
|
|
43
|
-
|
|
44
55
|
def header_fill(brand):
|
|
45
56
|
return PatternFill("solid", fgColor=hex_color(brand["primary"]))
|
|
46
57
|
|
|
47
|
-
|
|
48
58
|
def body_font(brand, size=11, bold=False, color=None):
|
|
49
|
-
return Font(size=size, name=brand["font"], bold=bold,
|
|
50
|
-
|
|
59
|
+
return Font(size=size, name=brand["font"], bold=bold,
|
|
60
|
+
color=hex_color(color or brand["text_dark"]))
|
|
51
61
|
|
|
52
62
|
def alt_fill(brand, row_idx):
|
|
53
63
|
if row_idx % 2 == 0:
|
|
@@ -56,6 +66,8 @@ def alt_fill(brand, row_idx):
|
|
|
56
66
|
|
|
57
67
|
|
|
58
68
|
class ProSheetBuilder:
|
|
69
|
+
"""Builds professional spreadsheets from a JSON spec."""
|
|
70
|
+
|
|
59
71
|
def __init__(self, spec):
|
|
60
72
|
self.spec = spec
|
|
61
73
|
self.brand = {**DEFAULT_BRAND, **(spec.get("brand", {}))}
|
|
@@ -74,6 +86,7 @@ class ProSheetBuilder:
|
|
|
74
86
|
ws.title = sheet_spec.get("name", "Sheet1")
|
|
75
87
|
else:
|
|
76
88
|
ws = self.wb.create_sheet(sheet_spec.get("name", f"Sheet{si+1}"))
|
|
89
|
+
|
|
77
90
|
self._build_sheet(ws, sheet_spec)
|
|
78
91
|
|
|
79
92
|
self.wb.save(output_path)
|
|
@@ -81,6 +94,7 @@ class ProSheetBuilder:
|
|
|
81
94
|
|
|
82
95
|
def _build_sheet(self, ws, sheet_spec):
|
|
83
96
|
sheet_type = sheet_spec.get("type", "data")
|
|
97
|
+
|
|
84
98
|
if sheet_type == "dashboard":
|
|
85
99
|
self._build_dashboard(ws, sheet_spec)
|
|
86
100
|
elif sheet_type == "financial":
|
|
@@ -88,34 +102,42 @@ class ProSheetBuilder:
|
|
|
88
102
|
else:
|
|
89
103
|
self._build_data_sheet(ws, sheet_spec)
|
|
90
104
|
|
|
105
|
+
# Freeze panes
|
|
91
106
|
freeze = sheet_spec.get("freeze_panes")
|
|
92
107
|
if freeze:
|
|
93
108
|
ws.freeze_panes = freeze
|
|
109
|
+
|
|
110
|
+
# Auto-filter
|
|
94
111
|
if sheet_spec.get("auto_filter", False) and ws.max_row > 1:
|
|
95
112
|
ws.auto_filter.ref = f"A1:{get_column_letter(ws.max_column)}{ws.max_row}"
|
|
96
113
|
|
|
97
114
|
def _build_data_sheet(self, ws, sheet_spec):
|
|
115
|
+
"""Standard data table with headers and rows."""
|
|
98
116
|
headers = sheet_spec.get("headers", [])
|
|
99
117
|
rows = sheet_spec.get("rows", [])
|
|
100
118
|
col_widths = sheet_spec.get("col_widths", [])
|
|
101
119
|
title = sheet_spec.get("title")
|
|
102
120
|
start_row = 1
|
|
103
121
|
|
|
122
|
+
# Title row
|
|
104
123
|
if title:
|
|
105
124
|
ws.cell(row=1, column=1, value=title).font = Font(
|
|
106
|
-
bold=True, size=16, color=hex_color(self.brand["text_dark"]),
|
|
125
|
+
bold=True, size=16, color=hex_color(self.brand["text_dark"]),
|
|
126
|
+
name=self.brand["font"]
|
|
107
127
|
)
|
|
108
128
|
ws.merge_cells(start_row=1, start_column=1, end_row=1, end_column=max(len(headers), 1))
|
|
109
129
|
start_row = 3
|
|
110
130
|
|
|
131
|
+
# Headers
|
|
111
132
|
if headers:
|
|
112
133
|
for ci, h in enumerate(headers, 1):
|
|
113
134
|
cell = ws.cell(row=start_row, column=ci, value=h)
|
|
114
135
|
cell.font = header_font(self.brand)
|
|
115
136
|
cell.fill = header_fill(self.brand)
|
|
116
|
-
cell.alignment = Alignment(horizontal=
|
|
137
|
+
cell.alignment = Alignment(horizontal='center', wrap_text=True)
|
|
117
138
|
cell.border = self.border
|
|
118
139
|
|
|
140
|
+
# Data rows
|
|
119
141
|
for ri, row_data in enumerate(rows):
|
|
120
142
|
r = start_row + 1 + ri
|
|
121
143
|
for ci, val in enumerate(row_data, 1):
|
|
@@ -123,99 +145,153 @@ class ProSheetBuilder:
|
|
|
123
145
|
cell.font = body_font(self.brand)
|
|
124
146
|
cell.fill = alt_fill(self.brand, ri)
|
|
125
147
|
cell.border = self.border
|
|
126
|
-
cell.alignment = Alignment(wrap_text=True, vertical=
|
|
148
|
+
cell.alignment = Alignment(wrap_text=True, vertical='top')
|
|
149
|
+
|
|
150
|
+
# Auto-detect numeric alignment
|
|
127
151
|
if isinstance(val, (int, float)):
|
|
128
|
-
cell.alignment = Alignment(horizontal=
|
|
152
|
+
cell.alignment = Alignment(horizontal='right', vertical='top')
|
|
129
153
|
if isinstance(val, float) and abs(val) < 1:
|
|
130
|
-
cell.number_format =
|
|
154
|
+
cell.number_format = '0.0%'
|
|
131
155
|
elif isinstance(val, float):
|
|
132
|
-
cell.number_format =
|
|
156
|
+
cell.number_format = '#,##0.00'
|
|
133
157
|
elif isinstance(val, int) and val > 999:
|
|
134
|
-
cell.number_format =
|
|
158
|
+
cell.number_format = '#,##0'
|
|
135
159
|
|
|
160
|
+
# Column widths
|
|
136
161
|
if col_widths:
|
|
137
162
|
for ci, w in enumerate(col_widths, 1):
|
|
138
163
|
ws.column_dimensions[get_column_letter(ci)].width = w
|
|
139
164
|
else:
|
|
165
|
+
# Auto-size based on header length
|
|
140
166
|
for ci, h in enumerate(headers, 1):
|
|
141
167
|
ws.column_dimensions[get_column_letter(ci)].width = max(len(str(h)) + 4, 12)
|
|
142
168
|
|
|
169
|
+
# Formulas
|
|
143
170
|
for formula_spec in sheet_spec.get("formulas", []):
|
|
144
|
-
|
|
145
|
-
|
|
171
|
+
cell_ref = formula_spec["cell"]
|
|
172
|
+
formula = formula_spec["formula"]
|
|
173
|
+
cell = ws[cell_ref]
|
|
174
|
+
cell.value = formula
|
|
146
175
|
if formula_spec.get("bold", False):
|
|
147
176
|
cell.font = body_font(self.brand, bold=True)
|
|
148
177
|
cell.border = self.border
|
|
149
178
|
|
|
150
179
|
def _build_dashboard(self, ws, sheet_spec):
|
|
180
|
+
"""Dashboard with KPI cards and summary tables."""
|
|
151
181
|
title = sheet_spec.get("title", "Dashboard")
|
|
152
182
|
kpis = sheet_spec.get("kpis", [])
|
|
153
183
|
sections = sheet_spec.get("sections", [])
|
|
154
184
|
|
|
185
|
+
# Title
|
|
155
186
|
ws.cell(row=1, column=1, value=title).font = Font(
|
|
156
|
-
bold=True, size=18, color=hex_color(self.brand["text_dark"]),
|
|
187
|
+
bold=True, size=18, color=hex_color(self.brand["text_dark"]),
|
|
188
|
+
name=self.brand["font"]
|
|
157
189
|
)
|
|
158
190
|
ws.merge_cells(start_row=1, start_column=1, end_row=1, end_column=max(len(kpis) * 2, 6))
|
|
159
191
|
|
|
192
|
+
# KPI row
|
|
160
193
|
if kpis:
|
|
161
194
|
r = 3
|
|
162
195
|
for ki, kpi in enumerate(kpis):
|
|
163
196
|
col = ki * 2 + 1
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
197
|
+
# Value
|
|
198
|
+
val_cell = ws.cell(row=r, column=col, value=kpi.get("value", ""))
|
|
199
|
+
val_cell.font = Font(bold=True, size=22, name=self.brand["font"],
|
|
200
|
+
color=hex_color(self.brand["accent"]))
|
|
201
|
+
val_cell.alignment = Alignment(horizontal='center')
|
|
167
202
|
ws.merge_cells(start_row=r, start_column=col, end_row=r, end_column=col + 1)
|
|
168
203
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
204
|
+
# Label
|
|
205
|
+
lbl_cell = ws.cell(row=r + 1, column=col, value=kpi.get("label", ""))
|
|
206
|
+
lbl_cell.font = Font(size=9, color="7F8C8D", name=self.brand["font"])
|
|
207
|
+
lbl_cell.alignment = Alignment(horizontal='center')
|
|
172
208
|
ws.merge_cells(start_row=r + 1, start_column=col, end_row=r + 1, end_column=col + 1)
|
|
173
209
|
|
|
210
|
+
# Change indicator
|
|
211
|
+
change = kpi.get("change", "")
|
|
212
|
+
if change:
|
|
213
|
+
chg_cell = ws.cell(row=r + 2, column=col, value=change)
|
|
214
|
+
is_positive = change.startswith("+") or change.startswith("↑")
|
|
215
|
+
chg_cell.font = Font(size=9, name=self.brand["font"], bold=True,
|
|
216
|
+
color=hex_color(self.brand["positive"] if is_positive else self.brand["negative"]))
|
|
217
|
+
chg_cell.alignment = Alignment(horizontal='center')
|
|
218
|
+
ws.merge_cells(start_row=r + 2, start_column=col, end_row=r + 2, end_column=col + 1)
|
|
219
|
+
|
|
220
|
+
# KPI card border
|
|
174
221
|
for dr in range(3):
|
|
175
222
|
for dc in range(2):
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
223
|
+
cell = ws.cell(row=r + dr, column=col + dc)
|
|
224
|
+
cell.fill = PatternFill("solid", fgColor=hex_color(self.brand["highlight"]))
|
|
225
|
+
cell.border = self.border
|
|
179
226
|
|
|
227
|
+
ws.column_dimensions[get_column_letter(col)].width = 14
|
|
228
|
+
ws.column_dimensions[get_column_letter(col + 1)].width = 14
|
|
229
|
+
|
|
230
|
+
# Sections (mini tables below KPIs)
|
|
180
231
|
current_row = 8 if kpis else 3
|
|
181
232
|
for section in sections:
|
|
182
233
|
ws.cell(row=current_row, column=1, value=section.get("title", "")).font = Font(
|
|
183
|
-
bold=True, size=13, color=hex_color(self.brand["text_dark"]),
|
|
234
|
+
bold=True, size=13, color=hex_color(self.brand["text_dark"]),
|
|
235
|
+
name=self.brand["font"]
|
|
184
236
|
)
|
|
185
237
|
current_row += 1
|
|
238
|
+
|
|
186
239
|
headers = section.get("headers", [])
|
|
187
240
|
rows = section.get("rows", [])
|
|
188
241
|
for ci, h in enumerate(headers, 1):
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
242
|
+
cell = ws.cell(row=current_row, column=ci, value=h)
|
|
243
|
+
cell.font = header_font(self.brand, size=10)
|
|
244
|
+
cell.fill = header_fill(self.brand)
|
|
245
|
+
cell.border = self.border
|
|
246
|
+
cell.alignment = Alignment(horizontal='center')
|
|
247
|
+
ws.column_dimensions[get_column_letter(ci)].width = max(len(str(h)) + 4, 14)
|
|
248
|
+
|
|
194
249
|
for ri, row_data in enumerate(rows):
|
|
195
250
|
r = current_row + 1 + ri
|
|
196
251
|
for ci, val in enumerate(row_data, 1):
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
252
|
+
cell = ws.cell(row=r, column=ci, value=val)
|
|
253
|
+
cell.font = body_font(self.brand, size=10)
|
|
254
|
+
cell.fill = alt_fill(self.brand, ri)
|
|
255
|
+
cell.border = self.border
|
|
256
|
+
|
|
201
257
|
current_row += len(rows) + 3
|
|
202
258
|
|
|
203
259
|
def _build_financial(self, ws, sheet_spec):
|
|
260
|
+
"""Financial model with blue inputs, black formulas, yellow assumptions."""
|
|
204
261
|
self._build_data_sheet(ws, sheet_spec)
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
262
|
+
|
|
263
|
+
# Apply financial color coding
|
|
264
|
+
input_cells = sheet_spec.get("input_cells", [])
|
|
265
|
+
assumption_cells = sheet_spec.get("assumption_cells", [])
|
|
266
|
+
|
|
267
|
+
for ref in input_cells:
|
|
268
|
+
cell = ws[ref]
|
|
269
|
+
cell.font = Font(size=11, name=self.brand["font"], color="0000FF")
|
|
270
|
+
|
|
271
|
+
for ref in assumption_cells:
|
|
272
|
+
cell = ws[ref]
|
|
273
|
+
cell.fill = PatternFill("solid", fgColor="FFFF00")
|
|
209
274
|
|
|
210
275
|
|
|
211
276
|
def main():
|
|
212
277
|
if len(sys.argv) < 3:
|
|
213
278
|
print("Usage: python create_xlsx.py spec.json output.xlsx")
|
|
214
279
|
sys.exit(1)
|
|
215
|
-
|
|
280
|
+
|
|
281
|
+
spec_path = sys.argv[1]
|
|
282
|
+
output_path = sys.argv[2]
|
|
283
|
+
|
|
284
|
+
with open(spec_path, 'r') as f:
|
|
216
285
|
spec = json.load(f)
|
|
217
|
-
|
|
218
|
-
|
|
286
|
+
|
|
287
|
+
builder = ProSheetBuilder(spec)
|
|
288
|
+
builder.build(output_path)
|
|
289
|
+
print(f"Spreadsheet created: {output_path}")
|
|
290
|
+
print(f" Sheets: {len(spec.get('sheets', []))}")
|
|
291
|
+
for s in spec.get("sheets", []):
|
|
292
|
+
rows = len(s.get("rows", []))
|
|
293
|
+
kpis = len(s.get("kpis", []))
|
|
294
|
+
print(f" {s.get('name', 'Sheet')}: {rows} rows" + (f", {kpis} KPIs" if kpis else ""))
|
|
219
295
|
|
|
220
296
|
|
|
221
297
|
if __name__ == "__main__":
|