f8-engine 0.2.0__tar.gz → 0.3.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. {f8_engine-0.2.0 → f8_engine-0.3.1}/Cargo.lock +2 -2
  2. {f8_engine-0.2.0 → f8_engine-0.3.1}/Cargo.toml +3 -1
  3. {f8_engine-0.2.0 → f8_engine-0.3.1}/PKG-INFO +27 -25
  4. {f8_engine-0.2.0 → f8_engine-0.3.1}/README.md +26 -24
  5. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-core/src/compiler/emission/workbook.rs +41 -6
  6. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-core/src/compiler/eval/model.rs +1 -0
  7. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-core/src/compiler/graph/resolve.rs +199 -181
  8. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-core/src/compiler/ir/expand.rs +9 -0
  9. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-core/src/compiler/ir/symbol.rs +1 -0
  10. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-core/src/compiler/layout/error.rs +19 -0
  11. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-core/src/compiler/layout/mod.rs +245 -63
  12. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-core/src/compiler/lowering/address.rs +39 -7
  13. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-core/src/compiler/lowering/mod.rs +30 -34
  14. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-core/src/expr/ast.rs +170 -14
  15. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-core/src/expr/mod.rs +2 -1
  16. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-core/src/expr/parser.rs +114 -187
  17. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-core/src/lib.rs +1 -0
  18. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-core/src/model/mod.rs +3 -2
  19. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-core/src/model/table.rs +113 -0
  20. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-core/src/model/theme.rs +18 -7
  21. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-python/src/error.rs +11 -1
  22. {f8_engine-0.2.0 → f8_engine-0.3.1}/pyproject.toml +4 -0
  23. {f8_engine-0.2.0 → f8_engine-0.3.1}/python/f8/__init__.py +2 -1
  24. {f8_engine-0.2.0 → f8_engine-0.3.1}/python/f8/builder.py +204 -9
  25. f8_engine-0.3.1/python/f8/skills/f8-modeling/SKILL.md +45 -0
  26. f8_engine-0.3.1/python/f8/skills/f8-modeling/references/auditing-and-repair.md +170 -0
  27. f8_engine-0.3.1/python/f8/skills/f8-modeling/references/functions.md +128 -0
  28. f8_engine-0.3.1/python/f8/skills/f8-modeling/references/syntax-and-grid.md +324 -0
  29. {f8_engine-0.2.0 → f8_engine-0.3.1}/python/f8/types.py +36 -4
  30. f8_engine-0.2.0/python/f8/skills/f8-modeling/SKILL.md +0 -43
  31. f8_engine-0.2.0/python/f8/skills/f8-modeling/references/auditing-and-repair.md +0 -367
  32. f8_engine-0.2.0/python/f8/skills/f8-modeling/references/functions.md +0 -172
  33. f8_engine-0.2.0/python/f8/skills/f8-modeling/references/syntax-and-grid.md +0 -405
  34. {f8_engine-0.2.0 → f8_engine-0.3.1}/LICENSE +0 -0
  35. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-core/Cargo.toml +0 -0
  36. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-core/src/compiler/compiled/error.rs +0 -0
  37. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-core/src/compiler/compiled/mod.rs +0 -0
  38. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-core/src/compiler/compiled/model.rs +0 -0
  39. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-core/src/compiler/emission/error.rs +0 -0
  40. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-core/src/compiler/emission/format.rs +0 -0
  41. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-core/src/compiler/emission/mod.rs +0 -0
  42. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-core/src/compiler/error.rs +0 -0
  43. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-core/src/compiler/eval/context.rs +0 -0
  44. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-core/src/compiler/eval/error.rs +0 -0
  45. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-core/src/compiler/eval/expr.rs +0 -0
  46. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-core/src/compiler/eval/mod.rs +0 -0
  47. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-core/src/compiler/graph/cycle.rs +0 -0
  48. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-core/src/compiler/graph/dependency.rs +0 -0
  49. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-core/src/compiler/graph/error.rs +0 -0
  50. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-core/src/compiler/graph/mod.rs +0 -0
  51. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-core/src/compiler/ir/cell.rs +0 -0
  52. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-core/src/compiler/ir/error.rs +0 -0
  53. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-core/src/compiler/ir/id.rs +0 -0
  54. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-core/src/compiler/ir/mod.rs +0 -0
  55. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-core/src/compiler/layout/a1.rs +0 -0
  56. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-core/src/compiler/lowering/error.rs +0 -0
  57. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-core/src/compiler/mod.rs +0 -0
  58. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-core/src/expr/error.rs +0 -0
  59. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-core/src/functions/common.rs +0 -0
  60. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-core/src/functions/date/constructors.rs +0 -0
  61. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-core/src/functions/date/mod.rs +0 -0
  62. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-core/src/functions/date/parts.rs +0 -0
  63. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-core/src/functions/date/serial.rs +0 -0
  64. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-core/src/functions/date/yearfrac.rs +0 -0
  65. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-core/src/functions/error.rs +0 -0
  66. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-core/src/functions/financial/annuity.rs +0 -0
  67. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-core/src/functions/financial/discounting.rs +0 -0
  68. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-core/src/functions/financial/irr.rs +0 -0
  69. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-core/src/functions/financial/mod.rs +0 -0
  70. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-core/src/functions/logical/boolean.rs +0 -0
  71. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-core/src/functions/logical/branching.rs +0 -0
  72. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-core/src/functions/logical/error_handling.rs +0 -0
  73. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-core/src/functions/logical/mod.rs +0 -0
  74. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-core/src/functions/lookup/index.rs +0 -0
  75. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-core/src/functions/lookup/mod.rs +0 -0
  76. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-core/src/functions/lookup/search.rs +0 -0
  77. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-core/src/functions/lookup/xlookup.rs +0 -0
  78. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-core/src/functions/lookup/xmatch.rs +0 -0
  79. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-core/src/functions/math/aggregation.rs +0 -0
  80. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-core/src/functions/math/basic.rs +0 -0
  81. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-core/src/functions/math/conditional.rs +0 -0
  82. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-core/src/functions/math/criteria.rs +0 -0
  83. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-core/src/functions/math/mod.rs +0 -0
  84. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-core/src/functions/math/product.rs +0 -0
  85. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-core/src/functions/math/rounding.rs +0 -0
  86. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-core/src/functions/mod.rs +0 -0
  87. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-core/src/functions/registry.rs +0 -0
  88. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-core/src/functions/traits.rs +0 -0
  89. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-core/src/model/error.rs +0 -0
  90. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-core/src/model/format.rs +0 -0
  91. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-core/src/model/id.rs +0 -0
  92. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-core/src/model/manifest.rs +0 -0
  93. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-core/src/value/error.rs +0 -0
  94. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-core/src/value/format.rs +0 -0
  95. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-core/src/value/mod.rs +0 -0
  96. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-python/Cargo.toml +0 -0
  97. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-python/src/lib.rs +0 -0
  98. {f8_engine-0.2.0 → f8_engine-0.3.1}/crates/f8-python/src/model.rs +0 -0
  99. {f8_engine-0.2.0 → f8_engine-0.3.1}/python/f8/__main__.py +0 -0
  100. {f8_engine-0.2.0 → f8_engine-0.3.1}/python/f8/_core.pyi +0 -0
  101. {f8_engine-0.2.0 → f8_engine-0.3.1}/python/f8/audit.py +0 -0
  102. {f8_engine-0.2.0 → f8_engine-0.3.1}/python/f8/cli.py +0 -0
  103. {f8_engine-0.2.0 → f8_engine-0.3.1}/python/f8/py.typed +0 -0
@@ -37,7 +37,7 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
37
37
 
38
38
  [[package]]
39
39
  name = "f8-core"
40
- version = "0.2.0"
40
+ version = "0.3.1"
41
41
  dependencies = [
42
42
  "indexmap",
43
43
  "petgraph",
@@ -50,7 +50,7 @@ dependencies = [
50
50
 
51
51
  [[package]]
52
52
  name = "f8-python"
53
- version = "0.2.0"
53
+ version = "0.3.1"
54
54
  dependencies = [
55
55
  "f8-core",
56
56
  "pyo3",
@@ -3,7 +3,7 @@ resolver = "3"
3
3
  members = ["crates/f8-core", "crates/f8-python"]
4
4
 
5
5
  [workspace.package]
6
- version = "0.2.0"
6
+ version = "0.3.1"
7
7
  edition = "2024"
8
8
  description = "Financial modeling engine with compiler verification and live Excel formulas"
9
9
  authors = ["Michael (Chencheng) Hu, CFA"]
@@ -25,3 +25,5 @@ pythonize = "0.24"
25
25
  [workspace.lints.clippy]
26
26
  unwrap_used = "deny"
27
27
  expect_used = "deny"
28
+ wildcard_imports = "deny"
29
+ fn_params_excessive_bools = "warn"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: f8-engine
3
- Version: 0.2.0
3
+ Version: 0.3.1
4
4
  Classifier: Development Status :: 4 - Beta
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: Intended Audience :: Financial and Insurance Industry
@@ -26,7 +26,7 @@ Project-URL: Repository, https://github.com/mhcfa/f8
26
26
 
27
27
  # f8
28
28
 
29
- Financial modeling engine with compiler verification and live Excel formulas.
29
+ Author, verify, and automate financial models with code and AI agents, compiled into live Excel workbooks.
30
30
 
31
31
  ## Who is f8 for
32
32
 
@@ -71,35 +71,36 @@ f8 guide --all # streams the full DSL, function, and auditing guide to stdout
71
71
  ```python
72
72
  from f8 import Model
73
73
 
74
+ years = ["2025A", "2026E", "2027E"]
75
+ capex = {"2025A": 25.0, "2026E": 30.0, "2027E": 35.0}
76
+ depr = {"2025A": -15.0, "2026E": -18.0, "2027E": -22.0}
77
+
74
78
  model = Model("Operating Forecast")
75
79
  sheet = model.add_sheet("Summary")
76
80
 
77
- pnl = sheet.add_table("PnL", "Three-Year Projection", "($ in millions)")
78
- pnl.add_column("item", role="descriptor", header="Line Item")
79
- for year in ["2025A", "2026E", "2027E"]:
80
- pnl.add_column(year, header=year, format="currency", decimals=1)
81
+ ppe = sheet.add_table("PPE", "PP&E Corkscrew Schedule", "($ in millions)")
82
+ ppe.add_column("item", role="descriptor", header="Line Item")
83
+ for year in years:
84
+ ppe.add_column(year, header=year, format="currency", decimals=1)
81
85
 
82
- pnl.add_row(
83
- "revenue",
84
- cells={"item": "Revenue", "2025A": 100.0},
85
- expr='PnL["revenue", col.prev] * 1.25',
86
- )
87
- pnl.add_row(
88
- "cogs",
89
- cells={"item": "Cost of Revenue"},
90
- expr='PnL["revenue", @col] * 0.30',
86
+ ppe.add_row(
87
+ "beg_balance",
88
+ cells={"item": "Beginning Balance", "2025A": 100.0},
89
+ expr='PPE["end_balance", col.prev]',
91
90
  )
92
- pnl.add_row(
93
- "gross_profit",
94
- cells={"item": "Gross Profit"},
95
- expr='PnL["revenue", @col] - PnL["cogs", @col]',
91
+ ppe.add_row("capex", cells={"item": "Capital Expenditures", **capex})
92
+ ppe.add_row("depreciation", cells={"item": "Depreciation", **depr})
93
+ ppe.add_row(
94
+ "end_balance",
95
+ cells={"item": "Ending Balance"},
96
+ expr='SUM(PPE["beg_balance".."depreciation", @col])',
96
97
  style="total",
97
98
  )
98
99
 
99
100
  compiled = model.compile()
100
101
 
101
102
  # Audit evaluated values in memory (or pass view="formulas" to inspect lowered Excel formulas)
102
- print(compiled.table("PnL").to_markdown())
103
+ print(compiled.table("PPE").to_markdown())
103
104
 
104
105
  # Emit a live .xlsx workbook with native Excel formulas and styling (you don't have to)
105
106
  compiled.save("forecast.xlsx")
@@ -108,11 +109,12 @@ compiled.save("forecast.xlsx")
108
109
  Output:
109
110
 
110
111
  ```markdown
111
- | Line Item | 2025A | 2026E | 2027E |
112
- | :--------------- | --------: | --------: | ---------: |
113
- | Revenue | $100.0 | $125.0 | $156.2 |
114
- | Cost of Revenue | $30.0 | $37.5 | $46.9 |
115
- | **Gross Profit** | **$70.0** | **$87.5** | **$109.4** |
112
+ | Line Item | 2025A | 2026E | 2027E |
113
+ | :------------------- | ---------: | ---------: | ---------: |
114
+ | Beginning Balance | $100.0 | $110.0 | $122.0 |
115
+ | Capital Expenditures | $25.0 | $30.0 | $35.0 |
116
+ | Depreciation | ($15.0) | ($18.0) | ($22.0) |
117
+ | **Ending Balance** | **$110.0** | **$122.0** | **$135.0** |
116
118
  ```
117
119
 
118
120
  ## Sample deliverables
@@ -1,6 +1,6 @@
1
1
  # f8
2
2
 
3
- Financial modeling engine with compiler verification and live Excel formulas.
3
+ Author, verify, and automate financial models with code and AI agents, compiled into live Excel workbooks.
4
4
 
5
5
  ## Who is f8 for
6
6
 
@@ -45,35 +45,36 @@ f8 guide --all # streams the full DSL, function, and auditing guide to stdout
45
45
  ```python
46
46
  from f8 import Model
47
47
 
48
+ years = ["2025A", "2026E", "2027E"]
49
+ capex = {"2025A": 25.0, "2026E": 30.0, "2027E": 35.0}
50
+ depr = {"2025A": -15.0, "2026E": -18.0, "2027E": -22.0}
51
+
48
52
  model = Model("Operating Forecast")
49
53
  sheet = model.add_sheet("Summary")
50
54
 
51
- pnl = sheet.add_table("PnL", "Three-Year Projection", "($ in millions)")
52
- pnl.add_column("item", role="descriptor", header="Line Item")
53
- for year in ["2025A", "2026E", "2027E"]:
54
- pnl.add_column(year, header=year, format="currency", decimals=1)
55
+ ppe = sheet.add_table("PPE", "PP&E Corkscrew Schedule", "($ in millions)")
56
+ ppe.add_column("item", role="descriptor", header="Line Item")
57
+ for year in years:
58
+ ppe.add_column(year, header=year, format="currency", decimals=1)
55
59
 
56
- pnl.add_row(
57
- "revenue",
58
- cells={"item": "Revenue", "2025A": 100.0},
59
- expr='PnL["revenue", col.prev] * 1.25',
60
- )
61
- pnl.add_row(
62
- "cogs",
63
- cells={"item": "Cost of Revenue"},
64
- expr='PnL["revenue", @col] * 0.30',
60
+ ppe.add_row(
61
+ "beg_balance",
62
+ cells={"item": "Beginning Balance", "2025A": 100.0},
63
+ expr='PPE["end_balance", col.prev]',
65
64
  )
66
- pnl.add_row(
67
- "gross_profit",
68
- cells={"item": "Gross Profit"},
69
- expr='PnL["revenue", @col] - PnL["cogs", @col]',
65
+ ppe.add_row("capex", cells={"item": "Capital Expenditures", **capex})
66
+ ppe.add_row("depreciation", cells={"item": "Depreciation", **depr})
67
+ ppe.add_row(
68
+ "end_balance",
69
+ cells={"item": "Ending Balance"},
70
+ expr='SUM(PPE["beg_balance".."depreciation", @col])',
70
71
  style="total",
71
72
  )
72
73
 
73
74
  compiled = model.compile()
74
75
 
75
76
  # Audit evaluated values in memory (or pass view="formulas" to inspect lowered Excel formulas)
76
- print(compiled.table("PnL").to_markdown())
77
+ print(compiled.table("PPE").to_markdown())
77
78
 
78
79
  # Emit a live .xlsx workbook with native Excel formulas and styling (you don't have to)
79
80
  compiled.save("forecast.xlsx")
@@ -82,11 +83,12 @@ compiled.save("forecast.xlsx")
82
83
  Output:
83
84
 
84
85
  ```markdown
85
- | Line Item | 2025A | 2026E | 2027E |
86
- | :--------------- | --------: | --------: | ---------: |
87
- | Revenue | $100.0 | $125.0 | $156.2 |
88
- | Cost of Revenue | $30.0 | $37.5 | $46.9 |
89
- | **Gross Profit** | **$70.0** | **$87.5** | **$109.4** |
86
+ | Line Item | 2025A | 2026E | 2027E |
87
+ | :------------------- | ---------: | ---------: | ---------: |
88
+ | Beginning Balance | $100.0 | $110.0 | $122.0 |
89
+ | Capital Expenditures | $25.0 | $30.0 | $35.0 |
90
+ | Depreciation | ($15.0) | ($18.0) | ($22.0) |
91
+ | **Ending Balance** | **$110.0** | **$122.0** | **$135.0** |
90
92
  ```
91
93
 
92
94
  ## Sample deliverables
@@ -12,8 +12,9 @@ use super::format::{CellFormatKey, FormatCache, parse_hex_color};
12
12
  use crate::compiler::eval::EvaluatedModel;
13
13
  use crate::compiler::ir::cell::{CellGrid, CellPayload};
14
14
  use crate::compiler::ir::id::CellId;
15
- use crate::compiler::layout::LayoutMap;
15
+ use crate::compiler::layout::{LayoutMap, StyleRole};
16
16
  use crate::compiler::lowering::LoweredFormulaMap;
17
+ use crate::model::format::SemanticStyle;
17
18
  use crate::model::id::SheetId;
18
19
  use crate::model::manifest::ProjectManifest;
19
20
  use crate::model::table::{ColumnSpec, RowSpec, TableSpec, ValueLiteral};
@@ -274,7 +275,15 @@ impl WorkbookEmitter {
274
275
  let col_idx = (placement.start_col + c_idx as u32) as u16;
275
276
  match col_spec {
276
277
  ColumnSpec::Gutter => {
277
- // Gutter column: blank unstyled cell across all header tiers
278
+ // Gutter column: continue header bottom border and fill across gutter
279
+ if is_bottom_tier || format_cache.has_col_header_fill() {
280
+ let fmt = if is_bottom_tier {
281
+ format_cache.col_header_format(false)
282
+ } else {
283
+ format_cache.col_header_upper_format(false)
284
+ };
285
+ worksheet.write_blank(tier_row, col_idx, &fmt)?;
286
+ }
278
287
  }
279
288
  ColumnSpec::Data(d) => {
280
289
  if t == 0 && d.group.is_some() {
@@ -349,10 +358,36 @@ impl WorkbookEmitter {
349
358
  }
350
359
  }
351
360
  RowSpec::Data(row_data) => {
352
- for col_spec in &table.columns {
353
- let ColumnSpec::Data(col_data) = col_spec else {
354
- // Column gutter: remains blank without borders
355
- continue;
361
+ for (c_idx, col_spec) in table.columns.iter().enumerate() {
362
+ let col_data = match col_spec {
363
+ ColumnSpec::Data(d) => d,
364
+ ColumnSpec::Gutter => {
365
+ // Gutter column: continue row formatting (horizontal borders, shade, highlight)
366
+ let role = StyleRole {
367
+ horizontal: row_data
368
+ .style
369
+ .unwrap_or(SemanticStyle::Data),
370
+ vertical: SemanticStyle::Data,
371
+ is_highlight: row_data.highlight.unwrap_or(false),
372
+ shade: row_data.shade.filter(|s| !s.is_none()),
373
+ };
374
+
375
+ if role != StyleRole::data() {
376
+ let format_key = CellFormatKey::new(
377
+ role, None, None, None, false, false, 0,
378
+ );
379
+ let cell_format =
380
+ format_cache.get_cell_format(&format_key)?;
381
+ let phys_col =
382
+ (placement.start_col + c_idx as u32) as u16;
383
+ worksheet.write_blank(
384
+ current_phys_row,
385
+ phys_col,
386
+ &cell_format,
387
+ )?;
388
+ }
389
+ continue;
390
+ }
356
391
  };
357
392
 
358
393
  let cell_id = CellId::new(
@@ -144,6 +144,7 @@ mod tests {
144
144
  subtitle: None,
145
145
  currency: None,
146
146
  collapsible: false,
147
+ placement: crate::model::table::PlacementSpec::default(),
147
148
  columns: vec![
148
149
  ColumnSpec::new("c1"),
149
150
  ColumnSpec::new("c2"),