dataface 0.1.2__py3-none-any.whl

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 (455) hide show
  1. d3_format/__init__.py +14 -0
  2. d3_format/errors.py +19 -0
  3. d3_format/format.py +551 -0
  4. d3_format/spec.py +159 -0
  5. dataface/DATAFACE_SYNTAX.md +1135 -0
  6. dataface/__init__.py +93 -0
  7. dataface/_docs_site.py +20 -0
  8. dataface/_install_hint.py +26 -0
  9. dataface/agent_api/__init__.py +79 -0
  10. dataface/agent_api/_init_templates/__init__.py +0 -0
  11. dataface/agent_api/_init_templates/agents_dft_snippet.md +26 -0
  12. dataface/agent_api/_init_templates/dataface.yml +15 -0
  13. dataface/agent_api/_init_templates/faces-dataface.yml +144 -0
  14. dataface/agent_api/_init_templates/index.md +24 -0
  15. dataface/agent_api/_paths.py +118 -0
  16. dataface/agent_api/_project_agents_md.py +43 -0
  17. dataface/agent_api/_session_store.py +486 -0
  18. dataface/agent_api/_state.py +28 -0
  19. dataface/agent_api/chat.py +221 -0
  20. dataface/agent_api/dashboards.py +257 -0
  21. dataface/agent_api/describe.py +366 -0
  22. dataface/agent_api/describe_query.py +120 -0
  23. dataface/agent_api/docs/__init__.py +25 -0
  24. dataface/agent_api/docs/_loader.py +292 -0
  25. dataface/agent_api/docs/yaml-reference.md +2757 -0
  26. dataface/agent_api/file_refs.py +118 -0
  27. dataface/agent_api/init.py +126 -0
  28. dataface/agent_api/inspect.py +128 -0
  29. dataface/agent_api/mcp_install.py +170 -0
  30. dataface/agent_api/query.py +274 -0
  31. dataface/agent_api/schema.py +658 -0
  32. dataface/agent_api/schema_search.py +284 -0
  33. dataface/agent_api/search.py +270 -0
  34. dataface/agent_api/skill_install.py +141 -0
  35. dataface/agent_api/skill_render.py +90 -0
  36. dataface/agent_api/skills.py +293 -0
  37. dataface/agent_api/surface_aliases.yaml +128 -0
  38. dataface/agent_api/validate.py +175 -0
  39. dataface/agent_api/validate_query.py +84 -0
  40. dataface/ai/__init__.py +39 -0
  41. dataface/ai/agent.py +139 -0
  42. dataface/ai/context.py +45 -0
  43. dataface/ai/events.py +62 -0
  44. dataface/ai/external_mcp.py +610 -0
  45. dataface/ai/generate_sql.py +96 -0
  46. dataface/ai/llm.py +403 -0
  47. dataface/ai/mcp/__init__.py +51 -0
  48. dataface/ai/mcp/server.py +289 -0
  49. dataface/ai/memories.py +85 -0
  50. dataface/ai/prompts.py +177 -0
  51. dataface/ai/schema_context.py +138 -0
  52. dataface/ai/skills/before-after-comparison/SKILL.md +102 -0
  53. dataface/ai/skills/before-after-comparison/examples/before-after-comparison.yml +24 -0
  54. dataface/ai/skills/dashboard-build/SKILL.md +212 -0
  55. dataface/ai/skills/dashboard-build/examples/_smoke.yml +15 -0
  56. dataface/ai/skills/dashboard-design/SKILL.md +182 -0
  57. dataface/ai/skills/dashboard-review/SKILL.md +113 -0
  58. dataface/ai/skills/dashboard-structural-review/SKILL.md +173 -0
  59. dataface/ai/skills/dashboard-visual-review/SKILL.md +139 -0
  60. dataface/ai/skills/dataface-mcp-setup/SKILL.md +177 -0
  61. dataface/ai/skills/dataface-troubleshooting/SKILL.md +225 -0
  62. dataface/ai/skills/drill-down-link/SKILL.md +112 -0
  63. dataface/ai/skills/drill-down-link/examples/drill-down-link.yml +27 -0
  64. dataface/ai/skills/faceted-small-multiples/SKILL.md +116 -0
  65. dataface/ai/skills/faceted-small-multiples/examples/faceted-small-multiples.yml +33 -0
  66. dataface/ai/skills/filter-bar-with-variables/SKILL.md +105 -0
  67. dataface/ai/skills/filter-bar-with-variables/examples/filter-bar-with-variables.yml +49 -0
  68. dataface/ai/skills/kpi-row/SKILL.md +101 -0
  69. dataface/ai/skills/kpi-row/examples/kpi-row.yml +55 -0
  70. dataface/ai/skills/report-design/SKILL.md +184 -0
  71. dataface/ai/skills/single-metric-bignum/SKILL.md +90 -0
  72. dataface/ai/skills/single-metric-bignum/examples/single-metric-bignum.yml +27 -0
  73. dataface/ai/skills/table-heavy-ops-dashboard/SKILL.md +114 -0
  74. dataface/ai/skills/table-heavy-ops-dashboard/examples/table-heavy-ops-dashboard.yml +48 -0
  75. dataface/ai/skills/time-series-trend/SKILL.md +93 -0
  76. dataface/ai/skills/time-series-trend/examples/time-series-trend.yml +26 -0
  77. dataface/ai/skills/top-n-with-detail/SKILL.md +98 -0
  78. dataface/ai/skills/top-n-with-detail/examples/top-n-with-detail.yml +45 -0
  79. dataface/ai/skills/two-by-two-grid-overview/SKILL.md +78 -0
  80. dataface/ai/skills/two-by-two-grid-overview/examples/two-by-two-grid-overview.yml +64 -0
  81. dataface/ai/tool_schemas.py +132 -0
  82. dataface/ai/tools/__init__.py +312 -0
  83. dataface/ai/yaml_utils.py +57 -0
  84. dataface/cli/__init__.py +3 -0
  85. dataface/cli/_console.py +48 -0
  86. dataface/cli/_error_format.py +83 -0
  87. dataface/cli/_extras.py +190 -0
  88. dataface/cli/_json_output.py +8 -0
  89. dataface/cli/_parsing.py +17 -0
  90. dataface/cli/_version_info.py +56 -0
  91. dataface/cli/commands/__init__.py +3 -0
  92. dataface/cli/commands/_agent_input.py +205 -0
  93. dataface/cli/commands/_agent_server.py +115 -0
  94. dataface/cli/commands/chat.py +645 -0
  95. dataface/cli/commands/describe.py +107 -0
  96. dataface/cli/commands/docs.py +131 -0
  97. dataface/cli/commands/extension.py +179 -0
  98. dataface/cli/commands/init.py +240 -0
  99. dataface/cli/commands/inspect.py +94 -0
  100. dataface/cli/commands/mcp_init.py +167 -0
  101. dataface/cli/commands/query.py +386 -0
  102. dataface/cli/commands/render.py +291 -0
  103. dataface/cli/commands/schema.py +411 -0
  104. dataface/cli/commands/search.py +49 -0
  105. dataface/cli/commands/serve.py +114 -0
  106. dataface/cli/commands/skills.py +133 -0
  107. dataface/cli/commands/skills_init.py +161 -0
  108. dataface/cli/commands/validate.py +63 -0
  109. dataface/cli/main.py +1501 -0
  110. dataface/core/__init__.py +75 -0
  111. dataface/core/compile/__init__.py +244 -0
  112. dataface/core/compile/_jinja_helpers.py +78 -0
  113. dataface/core/compile/channel.py +222 -0
  114. dataface/core/compile/chart_focus.py +101 -0
  115. dataface/core/compile/chart_resolved.py +169 -0
  116. dataface/core/compile/chart_type_detection.py +489 -0
  117. dataface/core/compile/chart_update.py +261 -0
  118. dataface/core/compile/colors.py +64 -0
  119. dataface/core/compile/compiler.py +904 -0
  120. dataface/core/compile/config.py +823 -0
  121. dataface/core/compile/custom_chart_types.py +208 -0
  122. dataface/core/compile/data_table_attachment.py +1287 -0
  123. dataface/core/compile/detect.py +110 -0
  124. dataface/core/compile/errors.py +302 -0
  125. dataface/core/compile/filter_injection.py +319 -0
  126. dataface/core/compile/introspection.py +527 -0
  127. dataface/core/compile/jinja.py +511 -0
  128. dataface/core/compile/labels_env.py +52 -0
  129. dataface/core/compile/markdown.py +154 -0
  130. dataface/core/compile/meta.py +388 -0
  131. dataface/core/compile/models/__init__.py +0 -0
  132. dataface/core/compile/models/chart/__init__.py +0 -0
  133. dataface/core/compile/models/chart/authored.py +2137 -0
  134. dataface/core/compile/models/chart/compiled.py +398 -0
  135. dataface/core/compile/models/config.py +347 -0
  136. dataface/core/compile/models/face/__init__.py +0 -0
  137. dataface/core/compile/models/face/authored.py +659 -0
  138. dataface/core/compile/models/face/compiled.py +522 -0
  139. dataface/core/compile/models/factories.py +201 -0
  140. dataface/core/compile/models/markers.py +40 -0
  141. dataface/core/compile/models/palette.py +36 -0
  142. dataface/core/compile/models/primitives.py +415 -0
  143. dataface/core/compile/models/query/__init__.py +0 -0
  144. dataface/core/compile/models/query/authored.py +246 -0
  145. dataface/core/compile/models/query/compiled.py +710 -0
  146. dataface/core/compile/models/refs.py +137 -0
  147. dataface/core/compile/models/source.py +611 -0
  148. dataface/core/compile/models/style/__init__.py +0 -0
  149. dataface/core/compile/models/style/authored.py +481 -0
  150. dataface/core/compile/models/style/compiled.py +3399 -0
  151. dataface/core/compile/models/style/merged.py +1682 -0
  152. dataface/core/compile/models/theme.py +362 -0
  153. dataface/core/compile/models/variable/__init__.py +0 -0
  154. dataface/core/compile/models/variable/authored.py +254 -0
  155. dataface/core/compile/models/vega_lite/__init__.py +0 -0
  156. dataface/core/compile/models/vega_lite/config.py +510 -0
  157. dataface/core/compile/models/vega_lite/contracts.py +171 -0
  158. dataface/core/compile/normalize_charts.py +494 -0
  159. dataface/core/compile/normalize_layout.py +1000 -0
  160. dataface/core/compile/normalize_queries.py +297 -0
  161. dataface/core/compile/normalize_variables.py +489 -0
  162. dataface/core/compile/normalizer.py +543 -0
  163. dataface/core/compile/palette.py +1100 -0
  164. dataface/core/compile/parameterized.py +658 -0
  165. dataface/core/compile/parser.py +228 -0
  166. dataface/core/compile/schema.py +20 -0
  167. dataface/core/compile/schema_renderers/__init__.py +0 -0
  168. dataface/core/compile/schema_renderers/json_schema.py +163 -0
  169. dataface/core/compile/schema_renderers/prompt.py +152 -0
  170. dataface/core/compile/schema_renderers/vscode_schema.py +301 -0
  171. dataface/core/compile/sizing.py +2126 -0
  172. dataface/core/compile/sources.py +518 -0
  173. dataface/core/compile/sql_authoring_lint.py +56 -0
  174. dataface/core/compile/style_cascade.py +471 -0
  175. dataface/core/compile/typography.py +299 -0
  176. dataface/core/compile/validator.py +301 -0
  177. dataface/core/compile/variables.py +53 -0
  178. dataface/core/compile/vega_config.py +98 -0
  179. dataface/core/compile/vega_lite/__init__.py +6 -0
  180. dataface/core/compile/vega_lite/validation.py +95 -0
  181. dataface/core/compile/yaml_error_formatter.py +838 -0
  182. dataface/core/connections.py +38 -0
  183. dataface/core/dashboard.py +358 -0
  184. dataface/core/defaults/default_config.yml +101 -0
  185. dataface/core/defaults/palettes/categorical/category-10-dark.yml +32 -0
  186. dataface/core/defaults/palettes/categorical/category-10-light.yml +43 -0
  187. dataface/core/defaults/palettes/categorical/category-10.yml +31 -0
  188. dataface/core/defaults/palettes/categorical/category-6-tonal-blue.yml +22 -0
  189. dataface/core/defaults/palettes/categorical/category-6-tonal-brown.yml +29 -0
  190. dataface/core/defaults/palettes/categorical/category-6-tonal-green.yml +20 -0
  191. dataface/core/defaults/palettes/categorical/category-6-tonal-orange.yml +21 -0
  192. dataface/core/defaults/palettes/categorical/category-6-tonal-purple.yml +20 -0
  193. dataface/core/defaults/palettes/categorical/editorial-10-dark.yml +32 -0
  194. dataface/core/defaults/palettes/categorical/editorial-10.yml +40 -0
  195. dataface/core/defaults/palettes/categorical/hero-6.yml +17 -0
  196. dataface/core/defaults/palettes/categorical/single-blue.yml +11 -0
  197. dataface/core/defaults/palettes/categorical/tableau.yml +20 -0
  198. dataface/core/defaults/palettes/data/xkcd_colors.json +3803 -0
  199. dataface/core/defaults/palettes/diverging/blue-red.yml +25 -0
  200. dataface/core/defaults/palettes/diverging/coolwarm.yml +24 -0
  201. dataface/core/defaults/palettes/diverging/crimson-green.yml +23 -0
  202. dataface/core/defaults/palettes/diverging/orange-teal.yml +23 -0
  203. dataface/core/defaults/palettes/diverging/sunset.yml +24 -0
  204. dataface/core/defaults/palettes/scaffold/dft-creams.yml +38 -0
  205. dataface/core/defaults/palettes/scaffold/dft-grays.yml +53 -0
  206. dataface/core/defaults/palettes/sequential/amber.yml +22 -0
  207. dataface/core/defaults/palettes/sequential/blue.yml +22 -0
  208. dataface/core/defaults/palettes/sequential/brown.yml +22 -0
  209. dataface/core/defaults/palettes/sequential/gray.yml +22 -0
  210. dataface/core/defaults/palettes/sequential/green.yml +22 -0
  211. dataface/core/defaults/palettes/sequential/purple.yml +22 -0
  212. dataface/core/defaults/palettes/sequential/rust.yml +22 -0
  213. dataface/core/defaults/palettes/sequential/teal.yml +22 -0
  214. dataface/core/defaults/palettes/tone/negative.yml +32 -0
  215. dataface/core/defaults/palettes/tone/positive.yml +22 -0
  216. dataface/core/defaults/palettes/tone/warning.yml +22 -0
  217. dataface/core/defaults/themes/_base.yaml +786 -0
  218. dataface/core/defaults/themes/bi.yaml +16 -0
  219. dataface/core/defaults/themes/carbong100.yaml +41 -0
  220. dataface/core/defaults/themes/cream.yaml +122 -0
  221. dataface/core/defaults/themes/dark.yaml +40 -0
  222. dataface/core/defaults/themes/diagnostics-title-angle-extreme.yaml +9 -0
  223. dataface/core/defaults/themes/diagnostics-title-baseline-extreme.yaml +9 -0
  224. dataface/core/defaults/themes/diagnostics-title-baseline.yaml +24 -0
  225. dataface/core/defaults/themes/diagnostics-title-center.yaml +8 -0
  226. dataface/core/defaults/themes/diagnostics-title-color-extreme.yaml +24 -0
  227. dataface/core/defaults/themes/diagnostics-title-font-extreme.yaml +25 -0
  228. dataface/core/defaults/themes/diagnostics-title-left.yaml +8 -0
  229. dataface/core/defaults/themes/diagnostics-title-offset-extreme.yaml +9 -0
  230. dataface/core/defaults/themes/diagnostics-title-size-extreme.yaml +24 -0
  231. dataface/core/defaults/themes/diagnostics-title-weight-extreme.yaml +24 -0
  232. dataface/core/defaults/themes/editorial.yaml +147 -0
  233. dataface/core/defaults/themes/light.yaml +30 -0
  234. dataface/core/defaults/themes/looker.yaml +17 -0
  235. dataface/core/defaults/themes/stark.yaml +134 -0
  236. dataface/core/errors/__init__.py +67 -0
  237. dataface/core/errors/codes_compile.py +56 -0
  238. dataface/core/errors/codes_execute.py +177 -0
  239. dataface/core/errors/codes_render.py +106 -0
  240. dataface/core/errors/codes_unknown.py +15 -0
  241. dataface/core/errors/hints.py +74 -0
  242. dataface/core/errors/registry.py +42 -0
  243. dataface/core/errors/structured.py +92 -0
  244. dataface/core/execute/__init__.py +91 -0
  245. dataface/core/execute/adapters/__init__.py +49 -0
  246. dataface/core/execute/adapters/adapter_registry.py +400 -0
  247. dataface/core/execute/adapters/base.py +245 -0
  248. dataface/core/execute/adapters/csv_adapter.py +239 -0
  249. dataface/core/execute/adapters/dbt_adapter.py +283 -0
  250. dataface/core/execute/adapters/dbt_adapter_factory.py +212 -0
  251. dataface/core/execute/adapters/dbt_macro_loader.py +95 -0
  252. dataface/core/execute/adapters/dbt_utils.py +150 -0
  253. dataface/core/execute/adapters/http_adapter.py +224 -0
  254. dataface/core/execute/adapters/metricflow_adapter.py +94 -0
  255. dataface/core/execute/adapters/schema_resolver_adapter.py +144 -0
  256. dataface/core/execute/adapters/sql_adapter.py +710 -0
  257. dataface/core/execute/adapters/values_adapter.py +58 -0
  258. dataface/core/execute/batch.py +744 -0
  259. dataface/core/execute/cache_backend.py +135 -0
  260. dataface/core/execute/cache_keys.py +66 -0
  261. dataface/core/execute/dbt_jinja.py +21 -0
  262. dataface/core/execute/dialects/__init__.py +121 -0
  263. dataface/core/execute/dialects/athena.py +75 -0
  264. dataface/core/execute/dialects/base.py +302 -0
  265. dataface/core/execute/dialects/bigquery.py +38 -0
  266. dataface/core/execute/dialects/databricks.py +68 -0
  267. dataface/core/execute/dialects/duckdb.py +35 -0
  268. dataface/core/execute/dialects/mysql.py +68 -0
  269. dataface/core/execute/dialects/postgres.py +39 -0
  270. dataface/core/execute/dialects/redshift.py +12 -0
  271. dataface/core/execute/dialects/snowflake.py +51 -0
  272. dataface/core/execute/dialects/sqlserver.py +92 -0
  273. dataface/core/execute/duckdb_cache.py +712 -0
  274. dataface/core/execute/duckdb_config.py +26 -0
  275. dataface/core/execute/errors.py +213 -0
  276. dataface/core/execute/executor.py +1249 -0
  277. dataface/core/execute/parallel.py +162 -0
  278. dataface/core/execute/setup_sql.py +58 -0
  279. dataface/core/execute/source_registry.py +72 -0
  280. dataface/core/execute/source_resolver.py +255 -0
  281. dataface/core/execute/sql_guard.py +387 -0
  282. dataface/core/execute/sql_literals.py +199 -0
  283. dataface/core/fonts.py +52 -0
  284. dataface/core/inspect/__init__.py +32 -0
  285. dataface/core/inspect/cache_factory.py +98 -0
  286. dataface/core/inspect/db_types.py +162 -0
  287. dataface/core/inspect/dbt_schema.py +96 -0
  288. dataface/core/inspect/defaults.yml +37 -0
  289. dataface/core/inspect/fanout_risk.py +109 -0
  290. dataface/core/inspect/manifest_utils.py +77 -0
  291. dataface/core/inspect/partials/categorical.yml +40 -0
  292. dataface/core/inspect/partials/date.yml +40 -0
  293. dataface/core/inspect/partials/numeric.yml +55 -0
  294. dataface/core/inspect/partition_types.py +38 -0
  295. dataface/core/inspect/query_validator.py +975 -0
  296. dataface/core/inspect/renderer.py +354 -0
  297. dataface/core/inspect/resolver.py +808 -0
  298. dataface/core/inspect/search.py +461 -0
  299. dataface/core/inspect/sources/__init__.py +32 -0
  300. dataface/core/inspect/sources/dbt.py +738 -0
  301. dataface/core/inspect/sources/duckdb_utils.py +66 -0
  302. dataface/core/inspect/templates/__init__.py +1 -0
  303. dataface/core/inspect/templates/categorical_column.yml +196 -0
  304. dataface/core/inspect/templates/charts.yml +109 -0
  305. dataface/core/inspect/templates/date_column.yml +248 -0
  306. dataface/core/inspect/templates/model.yml +138 -0
  307. dataface/core/inspect/templates/numeric_column.yml +261 -0
  308. dataface/core/inspect/templates/quality.yml +80 -0
  309. dataface/core/inspect/templates/string_column.yml +263 -0
  310. dataface/core/project_roots.py +165 -0
  311. dataface/core/render/__init__.py +87 -0
  312. dataface/core/render/board_links.py +176 -0
  313. dataface/core/render/chart/__init__.py +27 -0
  314. dataface/core/render/chart/arc_attached_table.py +251 -0
  315. dataface/core/render/chart/artifacts.py +16 -0
  316. dataface/core/render/chart/callout.py +225 -0
  317. dataface/core/render/chart/decisions.py +358 -0
  318. dataface/core/render/chart/geo.py +700 -0
  319. dataface/core/render/chart/kpi.py +916 -0
  320. dataface/core/render/chart/labels.py +76 -0
  321. dataface/core/render/chart/pipeline.py +818 -0
  322. dataface/core/render/chart/presentation.py +36 -0
  323. dataface/core/render/chart/profile.py +3438 -0
  324. dataface/core/render/chart/render_single.py +347 -0
  325. dataface/core/render/chart/renderers.py +193 -0
  326. dataface/core/render/chart/rendering.py +565 -0
  327. dataface/core/render/chart/serialization.py +90 -0
  328. dataface/core/render/chart/spark.py +496 -0
  329. dataface/core/render/chart/spark_bar.py +370 -0
  330. dataface/core/render/chart/spec_builders.py +154 -0
  331. dataface/core/render/chart/standard_renderer.py +2645 -0
  332. dataface/core/render/chart/table.py +2957 -0
  333. dataface/core/render/chart/table_support.py +1452 -0
  334. dataface/core/render/chart/tick_values.py +66 -0
  335. dataface/core/render/chart/time_unit_detect.py +809 -0
  336. dataface/core/render/chart/title_overflow.py +157 -0
  337. dataface/core/render/chart/type_inference.py +122 -0
  338. dataface/core/render/chart/validation.py +99 -0
  339. dataface/core/render/chart/vega_lite.py +125 -0
  340. dataface/core/render/chart/vega_lite_types.py +268 -0
  341. dataface/core/render/chart/vl_field_maps.py +346 -0
  342. dataface/core/render/chart_interactivity.py +24 -0
  343. dataface/core/render/control_registry.py +287 -0
  344. dataface/core/render/converters/__init__.py +24 -0
  345. dataface/core/render/converters/chart.py +276 -0
  346. dataface/core/render/converters/html.py +98 -0
  347. dataface/core/render/converters/pdf.py +40 -0
  348. dataface/core/render/converters/png.py +41 -0
  349. dataface/core/render/errors.py +144 -0
  350. dataface/core/render/face_api.py +160 -0
  351. dataface/core/render/faces.py +1194 -0
  352. dataface/core/render/font_measurement.py +48 -0
  353. dataface/core/render/font_support.py +197 -0
  354. dataface/core/render/fonts/DFTSansTabular-Regular.ttf +0 -0
  355. dataface/core/render/fonts/DFTSansTabular-Regular.woff2 +0 -0
  356. dataface/core/render/fonts/DFTSerifOldstyleProportional-Regular.ttf +0 -0
  357. dataface/core/render/fonts/DFTSerifOldstyleTabular-Regular.ttf +0 -0
  358. dataface/core/render/fonts/InterVariable.ttf +0 -0
  359. dataface/core/render/fonts/InterVariable.woff2 +0 -0
  360. dataface/core/render/fonts/NOTO_COLOR_EMOJI_LICENSE.txt +93 -0
  361. dataface/core/render/fonts/NOTO_EMOJI_LICENSE.txt +93 -0
  362. dataface/core/render/fonts/NotoColorEmoji-Regular.ttf +0 -0
  363. dataface/core/render/fonts/NotoColorEmoji-Regular.woff2 +0 -0
  364. dataface/core/render/fonts/NotoEmoji-Regular.ttf +0 -0
  365. dataface/core/render/fonts/NotoEmoji-Regular.woff2 +0 -0
  366. dataface/core/render/fonts/SOURCE_CODE_PRO_LICENSE.txt +93 -0
  367. dataface/core/render/fonts/SOURCE_SERIF_4_LICENSE.txt +98 -0
  368. dataface/core/render/fonts/SourceCodePro-Regular.ttf +0 -0
  369. dataface/core/render/fonts/SourceSerif4-Regular.ttf +0 -0
  370. dataface/core/render/fonts/_emoji_font_face.css +43 -0
  371. dataface/core/render/fonts/source-serif-4-variable-latin.woff2 +0 -0
  372. dataface/core/render/format_utils.py +329 -0
  373. dataface/core/render/geo_defaults.yml +28 -0
  374. dataface/core/render/json_format.py +146 -0
  375. dataface/core/render/layout_sizing.py +865 -0
  376. dataface/core/render/layouts.py +541 -0
  377. dataface/core/render/markdown_defaults.yml +16 -0
  378. dataface/core/render/missing_vars_prompt.py +79 -0
  379. dataface/core/render/placeholder.py +389 -0
  380. dataface/core/render/render_result.py +14 -0
  381. dataface/core/render/renderer.py +467 -0
  382. dataface/core/render/script_embedding.py +16 -0
  383. dataface/core/render/svg_utils.py +212 -0
  384. dataface/core/render/template_loader.py +69 -0
  385. dataface/core/render/templates/controls/_styles.css +606 -0
  386. dataface/core/render/templates/controls/checkbox.html +16 -0
  387. dataface/core/render/templates/controls/date.html +16 -0
  388. dataface/core/render/templates/controls/number.html +19 -0
  389. dataface/core/render/templates/controls/readonly.html +9 -0
  390. dataface/core/render/templates/controls/select.html +21 -0
  391. dataface/core/render/templates/controls/slider.html +22 -0
  392. dataface/core/render/templates/controls/text.html +16 -0
  393. dataface/core/render/templates/scripts/chart_interactivity.js +191 -0
  394. dataface/core/render/templates/scripts/variables.js +976 -0
  395. dataface/core/render/templates/svg/grid_pattern.svg +3 -0
  396. dataface/core/render/templates/svg/styles.css +51 -0
  397. dataface/core/render/terminal.py +311 -0
  398. dataface/core/render/terminal_charts.py +563 -0
  399. dataface/core/render/terminal_defaults.yml +2 -0
  400. dataface/core/render/terminal_layouts.py +299 -0
  401. dataface/core/render/terminal_text.py +31 -0
  402. dataface/core/render/text/__init__.py +1 -0
  403. dataface/core/render/text/case.py +113 -0
  404. dataface/core/render/text_format.py +129 -0
  405. dataface/core/render/utils.py +106 -0
  406. dataface/core/render/variable_controls.py +946 -0
  407. dataface/core/render/variable_input_refinement.py +140 -0
  408. dataface/core/render/warnings/__init__.py +15 -0
  409. dataface/core/render/warnings/bar_color_1_to_1_with_x.py +80 -0
  410. dataface/core/render/warnings/base.py +44 -0
  411. dataface/core/render/warnings/fanout_risk.py +15 -0
  412. dataface/core/render/warnings/from_query_diagnostic.py +56 -0
  413. dataface/core/render/warnings/missing_join_predicate.py +13 -0
  414. dataface/core/render/warnings/query_parse_error.py +14 -0
  415. dataface/core/render/warnings/query_returned_zero_rows.py +42 -0
  416. dataface/core/render/warnings/reaggregation.py +14 -0
  417. dataface/core/render/warnings/registry.py +45 -0
  418. dataface/core/render/warnings/suppression.py +46 -0
  419. dataface/core/render/warnings/temporal_single_point.py +63 -0
  420. dataface/core/render/warnings/unreferenced_chart.py +15 -0
  421. dataface/core/render/warnings/y_encoding_mostly_null.py +76 -0
  422. dataface/core/render/yaml_format.py +167 -0
  423. dataface/core/resolve_face.py +195 -0
  424. dataface/core/schema/__init__.py +0 -0
  425. dataface/core/schema/guidance.py +151 -0
  426. dataface/core/scoped_paths.py +59 -0
  427. dataface/core/serve/__init__.py +14 -0
  428. dataface/core/serve/bootstrap.py +39 -0
  429. dataface/core/serve/embedded.py +57 -0
  430. dataface/core/serve/port.py +129 -0
  431. dataface/core/serve/server.py +938 -0
  432. dataface/core/serve/templates/__init__.py +0 -0
  433. dataface/core/serve/templates/directory.yml +6 -0
  434. dataface/core/serve/templates/error.html.j2 +217 -0
  435. dataface/core/utils.py +121 -0
  436. dataface/core/validate.py +64 -0
  437. dataface/integrations/__init__.py +0 -0
  438. dataface/integrations/highlighting.py +351 -0
  439. dataface/integrations/markdown.py +537 -0
  440. dataface/py.typed +0 -0
  441. dataface-0.1.2.dist-info/METADATA +375 -0
  442. dataface-0.1.2.dist-info/RECORD +455 -0
  443. dataface-0.1.2.dist-info/WHEEL +4 -0
  444. dataface-0.1.2.dist-info/entry_points.txt +2 -0
  445. dataface-0.1.2.dist-info/licenses/LICENSE +202 -0
  446. mdsvg/__init__.py +168 -0
  447. mdsvg/fonts.py +656 -0
  448. mdsvg/images.py +299 -0
  449. mdsvg/parser.py +629 -0
  450. mdsvg/playground.py +284 -0
  451. mdsvg/py.typed +2 -0
  452. mdsvg/renderer.py +1623 -0
  453. mdsvg/style.py +355 -0
  454. mdsvg/types.py +200 -0
  455. mdsvg/utils.py +86 -0
@@ -0,0 +1,2126 @@
1
+ """Layout sizing calculation module.
2
+
3
+ Purpose: Calculate dimensions for layout items using static/compile-time defaults.
4
+
5
+ Entry Points:
6
+ - calculate_layout(face) -> Face
7
+ Compile-time sizing — uses aspect ratios and config defaults for all heights.
8
+
9
+ Data-aware sizing (render-first Vega sizing, table row counts) lives in
10
+ render/layout_sizing.py and injects a HeightProvider callback into these
11
+ algorithms at render time.
12
+
13
+ Inputs:
14
+ - Face with layout but no dimensions
15
+
16
+ Outputs:
17
+ - Face with calculated width/height on all layout items
18
+
19
+ Key Principles:
20
+ 1. Height is determined by layout AND content type
21
+ - KPIs get smaller heights (100px default)
22
+ - Charts get standard heights (300px default)
23
+ - Titles/content heights are estimated based on text/font
24
+ - In cols: all items have the SAME height (max of required heights)
25
+ - In rows: each item gets its content-appropriate height
26
+
27
+ 2. Width is determined by layout type
28
+ - In cols: width is divided among items equally (by default)
29
+ - In rows: all items get full width
30
+
31
+ 3. Padding philosophy
32
+ - Charts have internal padding (handled by Vega-Lite config)
33
+ - Boards have NO padding unless explicitly set in style
34
+ - Root dashboard has page padding (handled in renderer)
35
+
36
+ 4. Nested layouts follow these rules recursively
37
+ - A nested rows layout inside a cols item gets the full height of that item
38
+ - Items within that nested layout then divide that height
39
+
40
+ Example:
41
+ cols:
42
+ - chart1 # Gets 50% width, 100% height
43
+ - rows: # Gets 50% width, 100% height
44
+ - chart2 # Gets 100% width of parent, 50% of parent height
45
+ - chart3 # Gets 100% width of parent, 50% of parent height
46
+
47
+ Result with 800x400 container:
48
+ ┌────────────────────┬────────────────────┐
49
+ │ │ chart2 │
50
+ │ chart1 │ 400x200px │
51
+ │ 400x400px ├────────────────────┤
52
+ │ │ chart3 │
53
+ │ │ 400x200px │
54
+ └────────────────────┴────────────────────┘
55
+
56
+ Dependencies:
57
+ - .models.face.compiled (Face, Layout, LayoutItem)
58
+ - .config (CompileConfig)
59
+
60
+ See also:
61
+ - compile/normalizer.py: Previous step
62
+ - compile/compiler.py: Orchestrates all steps
63
+
64
+ Cross-module API:
65
+ render/layout_sizing.py imports the following functions as part of the
66
+ HeightProvider injection pattern. They are sizing algorithm internals
67
+ intentionally exported for use by the data-aware render-time sizing pass:
68
+ calculate_layout_height, calculate_layout_items,
69
+ get_item_content_height
70
+ """
71
+
72
+ from __future__ import annotations
73
+
74
+ import functools
75
+ import re
76
+ from typing import TYPE_CHECKING, Any, Literal, Protocol
77
+
78
+ from PIL import ImageFont
79
+
80
+ if TYPE_CHECKING:
81
+ from dataface.core.compile.models.face.compiled import ResolvedLayoutItem
82
+ from dataface.core.compile.models.style.compiled import TextStyle
83
+
84
+ from dataface.core.compile.colors import is_dark_color
85
+ from dataface.core.compile.config import get_chart_rendering, get_config
86
+ from dataface.core.compile.jinja import resolve_jinja_template
87
+ from dataface.core.compile.models.chart.compiled import (
88
+ Chart,
89
+ )
90
+ from dataface.core.compile.models.face.compiled import (
91
+ Face,
92
+ Layout,
93
+ LayoutItem,
94
+ )
95
+ from dataface.core.compile.models.style.compiled import (
96
+ VariablesStyle,
97
+ compute_text_column_count,
98
+ )
99
+ from dataface.core.compile.models.style.merged import (
100
+ MergedStyle,
101
+ apply_emoji_to_family,
102
+ effective_padding as _effective_padding,
103
+ resolve_style,
104
+ )
105
+ from dataface.core.fonts import get_inter_font_path
106
+
107
+ # ============================================================================
108
+ # CONTENT-AWARE HEIGHT DEFAULTS
109
+ # ============================================================================
110
+
111
+ # Default heights for different content types
112
+ # WHY: compile-time static fallback used when no width for aspect-ratio sizing
113
+ # and no specific chart type match; real sizes come from width-driven aspect-ratio path.
114
+ DEFAULT_CHART_HEIGHT = 300.0
115
+ # WHY: compile-time placeholder only — replaced by data-aware row-count sizing at
116
+ # render time via HeightProvider; no cascade equivalent exists because table height
117
+ # is always data-driven.
118
+ DEFAULT_TABLE_HEIGHT = 250.0
119
+ # WHY: engine-intrinsic display floor — every layout item must be at least this tall
120
+ # to remain navigable. Not a theme value; it is a display-correctness invariant.
121
+ MIN_CONTENT_HEIGHT = 60.0
122
+
123
+ # Bottom breathing room under the title-inline header band before the first
124
+ # row of layout content. The band's natural height (max of title-block vs
125
+ # variable-controls heights) butts directly against the first card row, and
126
+ # the theme's effective inter-section gap (face.layout.gap, ~8px default) is
127
+ # too tight for a face title to read as a header rather than a column label.
128
+ # 24 px lands between the "8 px + theme gap reads tight" and "32 px reads
129
+ # disconnected" endpoints picked in the design exploration.
130
+ TITLE_INLINE_BAND_BOTTOM_PAD = 24.0
131
+
132
+
133
+ # ============================================================================
134
+ # HEIGHT PROVIDER PROTOCOL
135
+ # ============================================================================
136
+
137
+
138
+ class HeightProvider(Protocol):
139
+ """Callback that returns the content height for a layout item.
140
+
141
+ The compile-time path uses the built-in get_item_content_height (static,
142
+ aspect-ratio-based). The render-time path injects a data-aware provider
143
+ from render/layout_sizing.py that renders Vega charts and queries data.
144
+ """
145
+
146
+ def __call__(
147
+ self,
148
+ item: LayoutItem,
149
+ card_gap: float,
150
+ gap: float,
151
+ width: float,
152
+ variable_defaults: dict[str, Any] | None,
153
+ ) -> float: ...
154
+
155
+
156
+ def _resolve_height(
157
+ item: LayoutItem,
158
+ card_gap: float,
159
+ gap: float,
160
+ width: float,
161
+ variable_defaults: dict[str, Any] | None,
162
+ height_provider: HeightProvider | None,
163
+ resolved_style: MergedStyle,
164
+ ) -> float:
165
+ """Dispatch to height_provider if set, else use static get_item_content_height."""
166
+ if height_provider is not None:
167
+ return height_provider(item, card_gap, gap, width, variable_defaults)
168
+ return get_item_content_height(
169
+ item,
170
+ card_gap,
171
+ gap,
172
+ width,
173
+ variable_defaults,
174
+ height_provider=None,
175
+ resolved_style=resolved_style,
176
+ )
177
+
178
+
179
+ def _get_root_content_width(config: Any) -> float:
180
+ """Return the root face content width after page padding is removed."""
181
+ return max(
182
+ float(config.style.board.width) - (2 * float(config.style.board.margin)), 0.0
183
+ )
184
+
185
+
186
+ def get_chart_content_height(
187
+ chart: Chart | None,
188
+ *,
189
+ width: float | None = None,
190
+ resolved_style: MergedStyle,
191
+ ) -> float:
192
+ """Get the appropriate content height for a chart based on its type.
193
+
194
+ Resolution order for plot-style charts (bar, line, area, scatter, …):
195
+ 1. chart.height — explicit chart-root override; returned as-is (no clamping).
196
+ 2. chart.aspect_ratio — chart-root fallback; height = width / aspect_ratio,
197
+ clamped to theme min_height / max_height.
198
+ 3. Per-family theme aspect_ratio (e.g. style.charts.bar.aspect_ratio).
199
+ 4. Global theme aspect_ratio (style.charts.aspect_ratio).
200
+ 5. DEFAULT_CHART_HEIGHT when no width is available.
201
+
202
+ SVG-family renderers own their own sizing contract:
203
+ - kpi → resolved_style.charts.kpi.default_height (theme cascade)
204
+ - table → DEFAULT_TABLE_HEIGHT (replaced by row-count sizing at render)
205
+ - callout → MIN_CONTENT_HEIGHT (replaced by natural render-first sizing)
206
+ - spark_bar → config-derived upper bound from max_bars/bar.height
207
+
208
+ This is the compile-time (static) height calculator. It uses aspect ratios
209
+ and config defaults only — no executor, no data, no rendering.
210
+ Layout slot height (``LayoutItem.layout_height``) wins at render time when
211
+ authored; the render-first pass in ``render/layout_sizing.py`` uses this
212
+ value as a fallback when no explicit slot height is declared.
213
+
214
+ Args:
215
+ chart: The compiled chart to get height for
216
+ width: Available width in pixels; enables aspect-ratio-driven sizing
217
+ resolved_style: Face-resolved style; KPI height reads from
218
+ resolved_style.charts.kpi.default_height.
219
+
220
+ Returns:
221
+ Appropriate height in pixels for this chart type
222
+ """
223
+ if chart is None:
224
+ return DEFAULT_CHART_HEIGHT
225
+
226
+ chart_type = chart.type
227
+ if chart_type == "kpi":
228
+ return float(resolved_style.charts.kpi.default_height)
229
+ elif chart_type == "table":
230
+ return DEFAULT_TABLE_HEIGHT
231
+ elif chart_type == "callout":
232
+ # Render-first natural sizing overrides this at render time.
233
+ return MIN_CONTENT_HEIGHT
234
+ elif chart_type == "spark_bar":
235
+ # Compile-time upper bound using max_bars. Render-first data-aware sizing
236
+ # in layout_sizing.py overrides this with the actual natural height.
237
+ # Includes title_height when chart.title is set to match the renderer's
238
+ # body + title addition (spark_bar.py:265-269).
239
+ sb = get_config().style.charts.spark_bar
240
+ body = sb.max_bars * (sb.bar.height + sb.bar.padding) + sb.bar.padding
241
+ return (
242
+ body + get_chart_rendering().spark_bar.title_height if chart.title else body
243
+ )
244
+
245
+ # Step 1: explicit chart-root height wins everything (no clamping).
246
+ if chart.height is not None:
247
+ return float(chart.height)
248
+
249
+ # Steps 2–4: aspect-ratio-driven sizing for plot-style charts.
250
+ if width is not None and width > 0:
251
+ config = get_config()
252
+ # Resolve clamps: start from face/theme resolved_style, override with
253
+ # chart-root min_height / max_height when explicitly set by the author.
254
+ min_h = (
255
+ float(chart.min_height)
256
+ if chart.min_height is not None
257
+ else float(resolved_style.charts.min_height)
258
+ )
259
+ max_h = (
260
+ float(chart.max_height)
261
+ if chart.max_height is not None
262
+ else float(resolved_style.charts.max_height)
263
+ )
264
+ # Step 2: chart-root aspect_ratio (validated gt=0 at compile time).
265
+ if chart.aspect_ratio is not None:
266
+ h = width / chart.aspect_ratio
267
+ return max(min_h, min(max_h, h))
268
+ # Steps 3–4: per-family theme aspect_ratio, then global theme default.
269
+ style_key = chart_type
270
+ type_config = getattr(config.style.charts, style_key, None)
271
+ aspect = (
272
+ float(type_config.aspect_ratio)
273
+ if type_config
274
+ and hasattr(type_config, "aspect_ratio")
275
+ and type_config.aspect_ratio
276
+ else float(config.style.charts.aspect_ratio)
277
+ )
278
+ if aspect > 0:
279
+ h = width / aspect
280
+ return max(min_h, min(max_h, h))
281
+
282
+ return DEFAULT_CHART_HEIGHT
283
+
284
+
285
+ def is_item_collapsed_summary(
286
+ item: LayoutItem,
287
+ variable_defaults: dict[str, Any] | None,
288
+ ) -> bool:
289
+ return bool(
290
+ item.details_variable
291
+ and item.details_summary
292
+ and not _is_details_item_expanded(item, variable_defaults)
293
+ )
294
+
295
+
296
+ def _is_details_item_expanded(
297
+ item: LayoutItem, variables: dict[str, Any] | None
298
+ ) -> bool:
299
+ """Check if a details item is expanded based on variable value or default."""
300
+ if variables and item.details_variable in variables:
301
+ return str(variables[item.details_variable]).lower() == "true"
302
+ # Fall back to default from face meta
303
+ if item.face and item.face.meta:
304
+ return bool(item.face.meta.get("details_expanded_default", False))
305
+ return False
306
+
307
+
308
+ def details_chrome_height(
309
+ item: LayoutItem | ResolvedLayoutItem, gap: float, card_gap: float
310
+ ) -> float:
311
+ """Vertical overhead the details chrome adds above the nested face content.
312
+
313
+ Returns summary_height + gap + card_gap for details items, 0 otherwise.
314
+ Both the sizing pass and render pass must use this so they stay in sync.
315
+ """
316
+ if not (item.details_variable and item.details_summary):
317
+ return 0.0
318
+ return float(get_config().style.layout.details.summary_height) + gap + card_gap
319
+
320
+
321
+ def get_compact_style(
322
+ merged_style: MergedStyle | None = None,
323
+ text_align: Literal["left", "center", "right"] = "left",
324
+ heading_font_weight: int | str | None = None,
325
+ font_family: str | None = None,
326
+ h1_size: float | None = None,
327
+ ) -> Any:
328
+ """Get a compact mdsvg Style with reduced heading margins.
329
+
330
+ The default mdsvg style has large heading margins (1.5em top, 0.5em bottom)
331
+ which produces ~2x more vertical space than HTML. This compact style
332
+ reduces margins for tighter dashboard/UI layouts.
333
+
334
+ Heading sizes (h1–h6) are pinned to absolute pixel values from
335
+ ``style.title.sizes`` rather than computed as multiples of the body font
336
+ size. Heading weight defaults to ``style.title.font.weight``; heading
337
+ color comes from the markdown color set or ``style.title.font.color``.
338
+
339
+ Args:
340
+ merged_style: Optional MergedStyle for color resolution. Falls back to
341
+ the global config default when None.
342
+ heading_font_weight: Override the heading font weight. Face titles pass
343
+ the tier-specific weight (600 narrow, 500 medium/wide).
344
+ font_family: Override the body font family. Pass
345
+ ``config.style.title.font.family`` to render prose in serif.
346
+ h1_size: Override the h1 pixel size. Face titles pass the
347
+ width-resolved pixel size from ``face_title_spec`` so the rendered
348
+ h1 lands exactly on the responsive target. When ``None``, h1 uses
349
+ ``style.title.sizes[0]`` like the other levels.
350
+
351
+ Returns:
352
+ mdsvg Style object with compact margins and theme-appropriate colors
353
+ """
354
+ from dataface.core.compile.typography import _coerce_weight
355
+ from mdsvg import Style
356
+
357
+ config = get_config()
358
+ _ms = merged_style or resolve_style(config.style)
359
+ markdown_colors = (
360
+ config.markdown.dark if is_dark_color(_ms.background) else config.markdown.light
361
+ )
362
+ _text_font_size = config.style.text.font.size
363
+ assert _text_font_size is not None, "style.text.font.size must be configured"
364
+
365
+ _root_family = config.style.font.family
366
+ assert _root_family is not None, "style.font.family must be configured"
367
+ _default_family = apply_emoji_to_family(_root_family, config.style.font.emoji)
368
+
369
+ title_sizes = config.style.title.sizes
370
+ if len(title_sizes) != 6:
371
+ raise ValueError(
372
+ f"style.title.sizes must have 6 entries (H1-H6); got {len(title_sizes)}"
373
+ )
374
+
375
+ effective_weight = (
376
+ heading_font_weight
377
+ if heading_font_weight is not None
378
+ else _coerce_weight(config.style.title.font.weight or 500)
379
+ )
380
+
381
+ return Style(
382
+ font_family=font_family if font_family is not None else _default_family,
383
+ base_font_size=float(_text_font_size),
384
+ line_height=float(config.style.text.line_height),
385
+ heading_margin_top=0.3,
386
+ heading_margin_bottom=0.2,
387
+ paragraph_spacing=8.0,
388
+ text_color=getattr(markdown_colors, "text_color", _ms.font.color),
389
+ heading_color=(
390
+ getattr(markdown_colors, "heading_color", None) or _ms.title.font.color
391
+ ),
392
+ link_color=markdown_colors.link_color,
393
+ code_color=markdown_colors.code_color,
394
+ code_background=markdown_colors.code_background,
395
+ blockquote_color=markdown_colors.blockquote_color,
396
+ text_align=text_align,
397
+ # Top-align letterboxed markdown images (default xMidYMid centers them).
398
+ image_preserve_aspect_ratio="xMidYMin meet",
399
+ h1_size=float(h1_size) if h1_size is not None else float(title_sizes[0]),
400
+ h2_size=float(title_sizes[1]),
401
+ h3_size=float(title_sizes[2]),
402
+ h4_size=float(title_sizes[3]),
403
+ h5_size=float(title_sizes[4]),
404
+ h6_size=float(title_sizes[5]),
405
+ heading_font_weight=effective_weight,
406
+ heading_line_height=float(config.style.title.line_height),
407
+ )
408
+
409
+
410
+ def greedy_column_fill(
411
+ block_heights: list[float],
412
+ n_cols: int,
413
+ para_gap: float,
414
+ ) -> tuple[list[tuple[int, float]], float]:
415
+ """Assign blocks to columns using a greedy balanced fill.
416
+
417
+ Returns (assignments, actual_col_height) where assignments[i] = (col_idx, y_within_col).
418
+ Never breaks from an empty column (current_y > 0 guard).
419
+ """
420
+ if not block_heights:
421
+ return [], 0.0
422
+ total_content = sum(block_heights)
423
+ total_gaps = para_gap * (len(block_heights) - 1) if len(block_heights) > 1 else 0
424
+ target_col_height = (total_content + total_gaps) / n_cols
425
+
426
+ assignments: list[tuple[int, float]] = []
427
+ col_max_y = [0.0] * n_cols
428
+ current_col = 0
429
+ current_y = 0.0
430
+ for i, bh in enumerate(block_heights):
431
+ gap_before = para_gap if i > 0 and current_y > 0 else 0.0
432
+ if (
433
+ current_y > 0
434
+ and current_y + gap_before + bh > target_col_height
435
+ and current_col < n_cols - 1
436
+ ):
437
+ current_col += 1
438
+ current_y = 0.0
439
+ gap_before = 0.0
440
+ y_in_col = current_y + gap_before
441
+ assignments.append((current_col, y_in_col))
442
+ col_max_y[current_col] = max(col_max_y[current_col], y_in_col + bh)
443
+ current_y = y_in_col + bh
444
+
445
+ return assignments, max(col_max_y)
446
+
447
+
448
+ def columned_text_height_estimate(
449
+ resolved_text: str,
450
+ width: float,
451
+ text_style: TextStyle,
452
+ style: Any,
453
+ font_path: str,
454
+ ) -> float:
455
+ """Compute multi-column body-text height using the same greedy block-fill algorithm as the renderer."""
456
+ from dataface.core.fonts import get_mono_font_path
457
+ from mdsvg import measure as measure_markdown, parse as parse_markdown
458
+ from mdsvg.renderer import SVGRenderer
459
+
460
+ mono_font_path = str(get_mono_font_path())
461
+
462
+ col = text_style.column
463
+ column_gap = (
464
+ col.gap if col.gap is not None else float(get_config().style.layout.rows.gap)
465
+ )
466
+ n_cols = compute_text_column_count(col, width, column_gap)
467
+
468
+ if n_cols <= 1:
469
+ size = measure_markdown(
470
+ resolved_text,
471
+ width=width,
472
+ padding=0.0,
473
+ style=style,
474
+ font_path=font_path,
475
+ mono_font_path=mono_font_path,
476
+ )
477
+ return size.height
478
+
479
+ per_col_width = max(
480
+ (width - (n_cols - 1) * column_gap) / n_cols,
481
+ 1.0,
482
+ )
483
+ renderer = SVGRenderer(
484
+ style=style,
485
+ font_path=font_path,
486
+ mono_font_path=mono_font_path,
487
+ )
488
+ blocks = list(parse_markdown(resolved_text))
489
+ block_heights = [
490
+ renderer.measure([block], width=per_col_width, padding=0.0).height
491
+ for block in blocks
492
+ ]
493
+ _, actual_col_height = greedy_column_fill(
494
+ block_heights, n_cols, style.paragraph_spacing
495
+ )
496
+ return actual_col_height
497
+
498
+
499
+ def get_markdown_text_height(
500
+ text: str | None,
501
+ width: float,
502
+ variable_defaults: dict[str, Any] | None = None,
503
+ *,
504
+ text_style: TextStyle | None = None,
505
+ ) -> float:
506
+ """Get the actual height needed for markdown content by measuring it.
507
+
508
+ Uses mdsvg's measure() to calculate height, which accounts for
509
+ word-wrapping and all markdown features.
510
+
511
+ When text_style specifies multi-column layout (column.number > 1 or
512
+ column.width), delegates to columned_text_height_estimate for an accurate
513
+ per-column-width measurement.
514
+
515
+ Args:
516
+ text: The markdown text
517
+ width: Available width for the text
518
+ variable_defaults: Optional variable defaults for Jinja resolution
519
+ text_style: Optional face body-text style for multi-column layout
520
+
521
+ Returns:
522
+ Actual height in pixels for the rendered content
523
+ """
524
+ if not text:
525
+ return 0.0
526
+
527
+ from dataface.core.compile.jinja import resolve_jinja_template
528
+ from dataface.core.fonts import get_inter_font_path, get_mono_font_path
529
+ from mdsvg import measure as measure_markdown
530
+
531
+ resolved_content = resolve_jinja_template(
532
+ text, variable_defaults or {}, strict=False
533
+ )
534
+
535
+ font_path = str(get_inter_font_path())
536
+ style = get_compact_style(resolve_style(get_config().style))
537
+
538
+ if text_style is not None and text_style.column.has_overrides:
539
+ return columned_text_height_estimate(
540
+ resolved_content, width, text_style, style, font_path
541
+ )
542
+
543
+ # Measure the markdown to get actual dimensions (using compact style).
544
+ # text_align is intentionally omitted: mdsvg measure() only computes
545
+ # line-wrapped height, which is independent of horizontal alignment.
546
+ size = measure_markdown(
547
+ resolved_content,
548
+ width=width,
549
+ padding=0.0,
550
+ style=style,
551
+ font_path=font_path,
552
+ mono_font_path=str(get_mono_font_path()),
553
+ )
554
+ return size.height
555
+
556
+
557
+ def get_title_height(
558
+ title: str | None,
559
+ width: float,
560
+ variable_defaults: dict[str, Any] | None = None,
561
+ level: int = 1,
562
+ ) -> float:
563
+ """Get the actual height needed for a title by measuring it.
564
+
565
+ Uses mdsvg's measure() to calculate height for the title rendered
566
+ as a markdown heading, accounting for word-wrapping and font sizing.
567
+ Font size is determined by card pixel width (width-based tier).
568
+
569
+ Args:
570
+ title: The title text
571
+ width: Available width for the title (drives tier selection)
572
+ variable_defaults: Optional variable defaults for Jinja resolution
573
+
574
+ Returns:
575
+ Actual height in pixels for the rendered title
576
+ """
577
+ if not title:
578
+ return 0.0
579
+
580
+ from dataface.core.compile.jinja import resolve_jinja_template
581
+ from dataface.core.fonts import get_inter_font_path, get_mono_font_path
582
+ from mdsvg import measure as measure_markdown
583
+
584
+ # Resolve any Jinja templates using variable defaults
585
+ resolved_title = resolve_jinja_template(
586
+ title, variable_defaults or {}, strict=False
587
+ )
588
+
589
+ from dataface.core.compile.typography import face_title_markdown
590
+
591
+ markdown_title, h1_size, heading_weight = face_title_markdown(
592
+ resolved_title, width, level=level
593
+ )
594
+
595
+ # Measure the markdown to get actual dimensions (using compact style).
596
+ # text_align is intentionally omitted: mdsvg measure() only computes
597
+ # line-wrapped height, which is independent of horizontal alignment.
598
+ font_path = str(get_inter_font_path())
599
+ size = measure_markdown(
600
+ markdown_title,
601
+ width=width,
602
+ padding=0.0,
603
+ style=get_compact_style(
604
+ h1_size=h1_size,
605
+ heading_font_weight=heading_weight,
606
+ ),
607
+ font_path=font_path,
608
+ mono_font_path=str(get_mono_font_path()),
609
+ )
610
+
611
+ return size.height
612
+
613
+
614
+ def get_face_gap(face: Face) -> float:
615
+ """Return the row/col/grid gap for a face based on its layout type.
616
+
617
+ When card_gap is active the gap is always 0 — spacing is owned by the
618
+ margin, not the individual layout rows/cols/grid. Otherwise the gap comes
619
+ from the layout-type-specific config key.
620
+ """
621
+ config = get_config()
622
+ if face.card_gap:
623
+ return 0.0
624
+ if face.layout.type == "rows":
625
+ return float(config.style.layout.rows.gap)
626
+ if face.layout.type == "cols":
627
+ return float(config.style.layout.cols.gap)
628
+ if face.layout.type == "grid":
629
+ return float(config.style.layout.grid.gap)
630
+ return 0.0
631
+
632
+
633
+ # ============================================================================
634
+ # VARIABLE CONTROLS HEIGHT (moved from render/variable_controls.py)
635
+ # ============================================================================
636
+
637
+ # Minimum title column width when sharing a row with variables so titles never
638
+ # collapse to zero when the variable strip is very wide.
639
+ _MIN_TITLE_INLINE_TITLE_COLUMN_PX = 48.0
640
+ # Hard ceiling on title column width as a fraction of the band's inner width.
641
+ # At 0.8, pathologically long titles wrap before they can eliminate the
642
+ # variables column — the strip stays parseable even when authors give a face
643
+ # a 60-character title and 7 filters.
644
+ _TITLE_INLINE_TITLE_MAX_INNER_RATIO = 0.8
645
+
646
+ # Strip a leading `#+\s+` markdown heading prefix without eating standalone
647
+ # `#` characters or trailing punctuation — `lstrip("# ")` would mangle titles
648
+ # like "#1 Product" or "## Q3 ##".
649
+ _HEADING_PREFIX_RE = re.compile(r"^#+\s+")
650
+
651
+
652
+ def _measure_title_single_line_width(
653
+ title: str | None,
654
+ variable_defaults: dict[str, Any] | None = None,
655
+ ) -> float:
656
+ """Width the title text needs to render on a single line at its natural size.
657
+
658
+ Uses PIL's freetype-backed ImageFont on the wheel-shipped Inter font at
659
+ the largest title-tier size — measuring at the widest tier gives the
660
+ worst-case natural width, which is what the title-inline band reserves
661
+ at full inner width. mdsvg's measure() can't be used here — it returns
662
+ the constraint width when passed an unbounded width, not the natural
663
+ content extent.
664
+ """
665
+ if not title:
666
+ return 0.0
667
+
668
+ resolved = resolve_jinja_template(title, variable_defaults or {}, strict=False)
669
+ # Strip a leading `#+\s+` markdown heading prefix without eating standalone
670
+ # `#` characters or trailing punctuation — `lstrip("# ")` would mangle
671
+ # titles like "#1 Product" or "## Q3 ##".
672
+ plain = _HEADING_PREFIX_RE.sub("", resolved).strip()
673
+
674
+ h1_size = float(get_config().style.title.sizes[0])
675
+ return float(_load_title_font(h1_size).getlength(plain))
676
+
677
+
678
+ @functools.lru_cache(maxsize=8)
679
+ def _load_title_font(size_px: float) -> ImageFont.FreeTypeFont:
680
+ """Load the Inter title font at the requested size, cached.
681
+
682
+ `_measure_title_single_line_width` is called per-face during both sizing
683
+ and render; without caching, ImageFont.truetype reparses the .ttf file
684
+ on every call. The cache is bounded — `size_px` comes from the title
685
+ tier ramp (6 entries) plus the level-driven offsets, so `maxsize=8`
686
+ covers every realistic call site.
687
+ """
688
+ return ImageFont.truetype(str(get_inter_font_path()), size_px)
689
+
690
+
691
+ def resolve_title_variables_inline_widths(
692
+ inner: float,
693
+ variables_style: VariablesStyle,
694
+ visible_variables: dict[str, Any],
695
+ title: str | None = None,
696
+ variable_defaults: dict[str, Any] | None = None,
697
+ ) -> tuple[float, float]:
698
+ """Split inner title+variables width into (title_w, variables_w).
699
+
700
+ Title precedence: reserve the title's measured single-line width first,
701
+ give the rest to variables, and let the variables strip flex-wrap into
702
+ multiple rows when its share is too narrow for one row. ``compute_
703
+ variable_controls_height`` handles the multi-row height so the band
704
+ grows correctly.
705
+
706
+ ``title_inline_title_max_width`` (theme) still caps the title when > 0;
707
+ a hard ceiling of 80% of inner protects pathologically long titles from
708
+ eliminating the variables column entirely (title wraps before that).
709
+ """
710
+ col_gap = float(variables_style.gap)
711
+ if not visible_variables:
712
+ return max(inner, 1.0), 0.0
713
+
714
+ # Title's natural single-line width + a small breathing-room pad so the
715
+ # measured width doesn't wrap on sub-pixel rendering jitter.
716
+ natural = _measure_title_single_line_width(title, variable_defaults)
717
+ natural_with_pad = (
718
+ natural + 8.0 if natural > 0 else _MIN_TITLE_INLINE_TITLE_COLUMN_PX
719
+ )
720
+
721
+ # Hard ceiling: never let the title eat more than this fraction of inner,
722
+ # even when natural width is larger — keeps the variables column wide
723
+ # enough that authors see their filters before having to wrap.
724
+ title_w = min(natural_with_pad, inner * _TITLE_INLINE_TITLE_MAX_INNER_RATIO)
725
+ title_w = max(title_w, _MIN_TITLE_INLINE_TITLE_COLUMN_PX)
726
+
727
+ # Theme-level override still wins when set.
728
+ cap = float(variables_style.title_inline_title_max_width)
729
+ if cap > 0.0:
730
+ title_w = min(title_w, cap)
731
+
732
+ vars_w = max(inner - title_w - col_gap, 1.0)
733
+ return title_w, vars_w
734
+
735
+
736
+ def _estimate_control_width(
737
+ var_name: str, var_def: Any, variables_style: VariablesStyle
738
+ ) -> float:
739
+ """Estimate the rendered pixel width of a single variable control."""
740
+ label = (var_def.label or var_name.replace("_", " ").title()) + ":"
741
+ assert variables_style.font.size is not None
742
+ label_w = len(label) * variables_style.font.size * 0.6
743
+ input_cfg = variables_style.input
744
+ input_type = var_def.input
745
+
746
+ if input_type in ("select", "multiselect", "radio"):
747
+ input_w = 120.0
748
+ elif input_type in ("text", "input", "textarea"):
749
+ input_w = float(input_cfg.widths.text)
750
+ elif input_type == "number":
751
+ input_w = float(input_cfg.widths.number)
752
+ elif input_type in ("slider", "range"):
753
+ input_w = (
754
+ float(input_cfg.widths.range)
755
+ + variables_style.control_gap
756
+ + float(input_cfg.widths.slider_value_min)
757
+ )
758
+ elif input_type == "checkbox":
759
+ return float(input_cfg.widths.checkbox) + variables_style.control_gap + label_w
760
+ elif input_type in ("date", "datepicker"):
761
+ input_w = 120.0
762
+ elif input_type == "daterange":
763
+ input_w = float(input_cfg.widths.daterange)
764
+ else:
765
+ input_w = float(input_cfg.widths.text)
766
+
767
+ return label_w + variables_style.control_gap + input_w
768
+
769
+
770
+ def compute_title_variables_inline_baseline_layout(
771
+ title_h: float,
772
+ vars_h: float,
773
+ label_font_size: float,
774
+ ) -> tuple[float, float, float]:
775
+ """Baseline-aligned layout for the title-inline band.
776
+
777
+ Returns ``(title_dy, vars_dy, band_h)`` — the y-translation each column
778
+ needs to land its first text baseline at the same shared baseline, plus
779
+ the total band height that results.
780
+
781
+ The ratios below are empirical, measured against the actual mdsvg /
782
+ foreignObject output for face titles at the default Inter weight:
783
+
784
+ - **Title baseline at 81.25% of title-block height.** mdsvg renders heading
785
+ text with a 1.3-line-height box plus its own block padding, putting the
786
+ first baseline at ``font_size * 1.3`` from the title-block top. Divided
787
+ by the block height (``font_size * 1.6`` for the default tier), that's
788
+ ``0.8125``. Pinned to the measured ratio so the rendered title and the
789
+ variable-label baseline land within sub-pixel of each other across font
790
+ tiers; do not "guess" with a rounded 0.85.
791
+ - **Label baseline ≈ container vertical center + 35% of label font size.**
792
+ Variable controls use ``align-items: center`` inside a ``container_height``
793
+ flex row; the centered label's baseline lands roughly half a label-em
794
+ below the container's vertical center.
795
+
796
+ Both columns are then shifted so the deeper baseline becomes the shared
797
+ target, which guarantees neither column extends above the band's top edge.
798
+ """
799
+ title_baseline = title_h * 0.8125
800
+ vars_baseline = vars_h / 2.0 + label_font_size * 0.35
801
+ target = max(title_baseline, vars_baseline)
802
+ title_dy = target - title_baseline
803
+ vars_dy = target - vars_baseline
804
+ band_h = max(title_dy + title_h, vars_dy + vars_h) + TITLE_INLINE_BAND_BOTTOM_PAD
805
+ return title_dy, vars_dy, band_h
806
+
807
+
808
+ def compute_title_variables_inline_band_height(
809
+ face: Face,
810
+ content_width: float,
811
+ variable_defaults: dict[str, Any] | None = None,
812
+ ) -> float:
813
+ """Band height for ``variables.position: title-inline``.
814
+
815
+ Computes the height the baseline-aligned band needs to fit both columns.
816
+ Delegates to :func:`compute_title_variables_inline_baseline_layout` so the
817
+ sizing pass and the render pass share one source of truth.
818
+ """
819
+ vs = face.resolved_style.variables
820
+ card_pad = float(face.resolved_style.board.card_padding)
821
+ inner = max(content_width - 2 * card_pad, 1.0)
822
+ title_w, vars_w = resolve_title_variables_inline_widths(
823
+ inner,
824
+ vs,
825
+ face.visible_variables,
826
+ title=face.title,
827
+ variable_defaults=variable_defaults,
828
+ )
829
+ if not face.title:
830
+ return 0.0
831
+ title_h = max(
832
+ get_title_height(face.title, title_w, variable_defaults),
833
+ float(face.resolved_style.title.min_height),
834
+ )
835
+ if not face.visible_variables:
836
+ return title_h
837
+ var_h = compute_variable_controls_height(
838
+ face.visible_variables,
839
+ vars_w,
840
+ variables_style=vs,
841
+ )
842
+ assert vs.font.size is not None, "style.variables.font.size must be configured"
843
+ _title_dy, _vars_dy, band_h = compute_title_variables_inline_baseline_layout(
844
+ title_h, var_h, float(vs.font.size)
845
+ )
846
+ return band_h
847
+
848
+
849
+ def compute_variable_controls_height(
850
+ variable_defs: dict[str, Any],
851
+ width: float,
852
+ *,
853
+ variables_style: VariablesStyle,
854
+ ) -> float:
855
+ """Compute the height needed for variable controls rendered at the given width.
856
+
857
+ Simulates flex-wrap layout to determine how many rows the controls occupy,
858
+ then returns the total foreignObject height including container padding.
859
+ Returns the single-row config height when all controls fit on one row.
860
+
861
+ Args:
862
+ variable_defs: Variable definitions dict
863
+ width: Available container width
864
+ variables_style: Variables style from the face's resolved style.
865
+ """
866
+ if not variable_defs:
867
+ return 0.0
868
+
869
+ container_height = float(variables_style.container_height)
870
+ padding_x = float(variables_style.container_padding)
871
+ gap = float(variables_style.gap)
872
+
873
+ available = max(width - 2 * padding_x, 1.0)
874
+ widths = [
875
+ _estimate_control_width(name, v, variables_style)
876
+ for name, v in variable_defs.items()
877
+ ]
878
+
879
+ # Simulate flex-wrap: first item always starts row 1. The strict ">"
880
+ # comparison is float-precision-sensitive: a row that fits exactly in
881
+ # CSS at integer pixel widths can read as overflow in Python after the
882
+ # vars_w / available subtractions accumulate ~1e-14 of drift. A 0.5px
883
+ # tolerance gives sub-pixel slop the benefit of the doubt and matches
884
+ # the browser's own pixel-snapping behaviour.
885
+ epsilon = 0.5
886
+ n_rows = 1
887
+ current_row_w = widths[0] if widths else 0.0
888
+ for w in widths[1:]:
889
+ if current_row_w + gap + w > available + epsilon:
890
+ n_rows += 1
891
+ current_row_w = w
892
+ else:
893
+ current_row_w += gap + w
894
+
895
+ if n_rows == 1:
896
+ return container_height
897
+
898
+ # Multi-row: each row gets full container height plus gap between rows
899
+ return n_rows * container_height + (n_rows - 1) * gap
900
+
901
+
902
+ def calculate_layout(face: Face) -> Face:
903
+ """Calculate dimensions for all layout items.
904
+
905
+ Stage: COMPILE (Step 4 of 4: Layout Calculation)
906
+
907
+ This is the final compilation step. It calculates pixel dimensions
908
+ for all charts and nested faces based on layout type and nesting.
909
+
910
+ The sizing is content-aware:
911
+ - KPIs get heights from resolved_style.charts.kpi.default_height
912
+ - Charts get standard heights (~300px)
913
+ - Titles are measured for text wrapping
914
+ - Nested faces accumulate their content heights
915
+
916
+ Args:
917
+ face: Face with layout structure
918
+
919
+ Returns:
920
+ Face with calculated dimensions on layout items
921
+
922
+ Example:
923
+ >>> face = normalize_face(parsed_face)
924
+ >>> face = calculate_layout(face)
925
+ >>> print(face.layout.width, face.layout.height)
926
+ 1200.0 800.0
927
+ """
928
+ config = get_config()
929
+
930
+ container_width = float(config.style.board.width)
931
+ content_width = _get_root_content_width(config)
932
+ min_height = float(config.style.board.min_height)
933
+
934
+ card_gap = float(config.style.board.card_gap) if face.card_gap else 0.0
935
+ gap = get_face_gap(face)
936
+ effective_gap = gap + card_gap
937
+
938
+ # Use pre-computed variable defaults for Jinja resolution
939
+ # This allows markdown content with {{ variable }} to size correctly
940
+ variable_defaults = face.variable_defaults
941
+
942
+ # Calculate required height based on content (content-aware)
943
+ container_height = calculate_layout_height(
944
+ face.layout,
945
+ card_gap,
946
+ gap,
947
+ min_height,
948
+ available_width=content_width,
949
+ variable_defaults=variable_defaults,
950
+ resolved_style=face.resolved_style,
951
+ )
952
+
953
+ # Add height for root face title if present. title renders at
954
+ # content_width - 2*card_padding (matching the card_padding inset applied in render).
955
+ vs = face.resolved_style.variables
956
+ use_title_inline = (
957
+ vs.position == "title-inline"
958
+ and bool(face.title)
959
+ and bool(face.visible_variables)
960
+ )
961
+ if use_title_inline:
962
+ band_h = compute_title_variables_inline_band_height(
963
+ face, content_width, variable_defaults
964
+ )
965
+ container_height += band_h + effective_gap
966
+ elif face.title:
967
+ card_pad = float(config.style.board.card_padding)
968
+ title_measure_width = max(content_width - 2 * card_pad, 0.0)
969
+ title_height = get_title_height(
970
+ face.title, title_measure_width, variable_defaults
971
+ )
972
+ container_height += title_height + effective_gap
973
+
974
+ # Set root layout dimensions
975
+ face.layout.width = container_width
976
+ face.layout.height = container_height
977
+
978
+ # Root layout items should be sized against the root content box, not the
979
+ # full outer board width.
980
+ face.layout.content_width = content_width
981
+ # Vertical spacing is finalized during render after title/content/control
982
+ # placement, so root sizing preserves the outer height contract here.
983
+ face.layout.content_height = container_height
984
+
985
+ # Calculate all item dimensions in a single pass
986
+ calculate_layout_items(
987
+ face.layout,
988
+ content_width,
989
+ container_height,
990
+ card_gap,
991
+ gap,
992
+ variable_defaults,
993
+ resolved_style=face.resolved_style,
994
+ )
995
+
996
+ return face
997
+
998
+
999
+ def get_item_content_height(
1000
+ item: LayoutItem,
1001
+ card_gap: float,
1002
+ gap: float,
1003
+ width: float = 400.0,
1004
+ variable_defaults: dict[str, Any] | None = None,
1005
+ height_provider: HeightProvider | None = None,
1006
+ *,
1007
+ resolved_style: MergedStyle,
1008
+ ) -> float:
1009
+ """Get the content-appropriate height for a single layout item (static/compile-time).
1010
+
1011
+ This is the compile-time height calculator using aspect ratios and config
1012
+ defaults only. No executor, no data, no rendering. The render-time path
1013
+ uses a HeightProvider callback instead.
1014
+
1015
+ Args:
1016
+ item: The layout item
1017
+ card_gap: Gap between cards (inter-item spacing, applied N-1 times)
1018
+ gap: Gap between items (used for nested layouts)
1019
+ width: Available width for the item (used for text wrapping estimates)
1020
+ variable_defaults: Optional variable defaults for Jinja resolution
1021
+ resolved_style: Face-resolved style for cascade-aware height calculations.
1022
+
1023
+ Returns:
1024
+ Appropriate height for this item's content
1025
+ """
1026
+ if item.type == "chart" and item.chart:
1027
+ card_pad = float(get_config().style.board.card_padding)
1028
+ if item.chart.type == "callout":
1029
+ return get_chart_content_height(
1030
+ item.chart, width=width, resolved_style=resolved_style
1031
+ )
1032
+ # Vega-family charts render at full item width with card_pad as internal
1033
+ # Vega padding; SVG-family types ignore the width parameter anyway.
1034
+ # The + 2*card_pad accounts for Vega's top+bottom padding in its output height.
1035
+ return (
1036
+ get_chart_content_height(
1037
+ item.chart, width=width, resolved_style=resolved_style
1038
+ )
1039
+ + 2 * card_pad
1040
+ )
1041
+
1042
+ elif item.type == "face" and item.face:
1043
+ # Details (collapsible section): collapsed = summary bar only
1044
+ if is_item_collapsed_summary(item, variable_defaults):
1045
+ return float(get_config().style.layout.details.summary_height)
1046
+
1047
+ # For nested faces, calculate their content height using the nested face's
1048
+ # own resolved_style (each face can have its own theme cascade).
1049
+ nested_face = item.face
1050
+ nested_rs = nested_face.resolved_style
1051
+
1052
+ content_width, nested_non_layout_height, child_gap = nested_face_sizing_context(
1053
+ nested_face, width, card_gap, variable_defaults
1054
+ )
1055
+
1056
+ nested_height = nested_non_layout_height
1057
+ # Add layout content height (only if there are items)
1058
+ if nested_face.layout.items:
1059
+ nested_height += calculate_layout_height(
1060
+ nested_face.layout,
1061
+ card_gap,
1062
+ gap=child_gap,
1063
+ min_height=0,
1064
+ available_width=content_width,
1065
+ variable_defaults=variable_defaults,
1066
+ height_provider=height_provider,
1067
+ resolved_style=nested_rs,
1068
+ )
1069
+
1070
+ # Add effective padding and margin to total height
1071
+ nested_height += _effective_padding(nested_rs).vertical + (
1072
+ nested_rs.margin.vertical if nested_rs.margin else 0.0
1073
+ )
1074
+
1075
+ content_h = max(nested_height, MIN_CONTENT_HEIGHT)
1076
+
1077
+ # Expanded details: add chrome (summary bar + gap) above content
1078
+ if item.details_variable and item.details_summary:
1079
+ return details_chrome_height(item, gap, card_gap) + content_h
1080
+
1081
+ return content_h
1082
+
1083
+ # Fallback (leaf item — treat as card)
1084
+ return DEFAULT_CHART_HEIGHT + 2 * float(get_config().style.board.card_padding)
1085
+
1086
+
1087
+ def calculate_layout_height(
1088
+ layout: Layout,
1089
+ card_gap: float,
1090
+ gap: float,
1091
+ min_height: float,
1092
+ available_width: float = 400.0,
1093
+ variable_defaults: dict[str, Any] | None = None,
1094
+ height_provider: HeightProvider | None = None,
1095
+ *,
1096
+ resolved_style: MergedStyle,
1097
+ ) -> float:
1098
+ """Calculate required height for a layout.
1099
+
1100
+ Uses mdsvg to render and measure actual content heights:
1101
+ - KPI charts get smaller heights
1102
+ - Standard charts get normal heights
1103
+ - Tables use default height (data-aware sizing via HeightProvider at render time)
1104
+ - Nested boards accumulate their content heights
1105
+ - Titles are rendered and measured
1106
+ - Markdown content with word-wrapping is rendered and measured
1107
+
1108
+ For rows layout: sum of each item's height + gaps
1109
+ For cols layout: max of item heights (all items share same height)
1110
+ For grid layout: based on rows and content types
1111
+ For tabs layout: max of tab content heights + tab bar
1112
+
1113
+ Args:
1114
+ layout: The layout to calculate height for
1115
+ card_gap: Gap between cards (inter-item spacing)
1116
+ gap: Gap between items
1117
+ min_height: Minimum height to return
1118
+ available_width: Available width for text wrapping calculations
1119
+ variable_defaults: Optional variable defaults for Jinja resolution
1120
+
1121
+ Returns:
1122
+ Required height in pixels
1123
+ """
1124
+ if not layout.items:
1125
+ return min_height
1126
+
1127
+ if layout.type == "rows":
1128
+ measured_height = _measure_rows_layout_height(
1129
+ layout.items,
1130
+ card_gap,
1131
+ gap,
1132
+ available_width,
1133
+ variable_defaults,
1134
+ height_provider,
1135
+ resolved_style=resolved_style,
1136
+ )
1137
+ elif layout.type == "cols":
1138
+ measured_height = _measure_cols_layout_height(
1139
+ layout.items,
1140
+ card_gap,
1141
+ gap,
1142
+ available_width,
1143
+ variable_defaults,
1144
+ height_provider,
1145
+ resolved_style=resolved_style,
1146
+ )
1147
+ elif layout.type == "grid":
1148
+ measured_height = _measure_grid_layout_height(
1149
+ layout,
1150
+ card_gap,
1151
+ gap,
1152
+ available_width,
1153
+ variable_defaults,
1154
+ height_provider,
1155
+ resolved_style=resolved_style,
1156
+ )
1157
+ elif layout.type == "tabs":
1158
+ measured_height = _measure_tabs_layout_height(
1159
+ layout.items,
1160
+ card_gap,
1161
+ gap,
1162
+ available_width,
1163
+ variable_defaults,
1164
+ height_provider,
1165
+ resolved_style=resolved_style,
1166
+ )
1167
+ else:
1168
+ return min_height
1169
+ return max(measured_height, min_height)
1170
+
1171
+
1172
+ def _measure_rows_layout_height(
1173
+ items: list[LayoutItem],
1174
+ card_gap: float,
1175
+ gap: float,
1176
+ available_width: float,
1177
+ variable_defaults: dict[str, Any] | None,
1178
+ height_provider: HeightProvider | None = None,
1179
+ *,
1180
+ resolved_style: MergedStyle,
1181
+ ) -> float:
1182
+ """Measure total height for a rows layout."""
1183
+ total_height = 0.0
1184
+ for item in items:
1185
+ # User-specified height takes precedence over content-aware height.
1186
+ # Percentages resolve to 0 here (no available_height context) and
1187
+ # fall through; _calculate_rows_dimensions resolves them later.
1188
+ resolved = _resolve_layout_height(item, 0.0)
1189
+ if resolved is not None:
1190
+ total_height += resolved
1191
+ else:
1192
+ total_height += _resolve_height(
1193
+ item,
1194
+ card_gap,
1195
+ gap,
1196
+ available_width,
1197
+ variable_defaults,
1198
+ height_provider,
1199
+ resolved_style=resolved_style,
1200
+ )
1201
+ if len(items) > 1:
1202
+ total_height += (gap + card_gap) * (len(items) - 1)
1203
+ return total_height
1204
+
1205
+
1206
+ def _measure_cols_layout_height(
1207
+ items: list[LayoutItem],
1208
+ card_gap: float,
1209
+ gap: float,
1210
+ available_width: float,
1211
+ variable_defaults: dict[str, Any] | None,
1212
+ height_provider: HeightProvider | None = None,
1213
+ *,
1214
+ resolved_style: MergedStyle,
1215
+ ) -> float:
1216
+ """Measure shared row height for a cols layout."""
1217
+ max_height = MIN_CONTENT_HEIGHT
1218
+ n_items = len(items)
1219
+ total_gap = (gap + card_gap) * (n_items - 1) if n_items > 1 else 0
1220
+ content_width = available_width - total_gap
1221
+
1222
+ specified_widths: list[float | None] = []
1223
+ for item in items:
1224
+ parsed_width = parse_dimension(item.user_width, content_width)
1225
+ specified_widths.append(parsed_width)
1226
+
1227
+ assigned_widths = _resolve_cols_widths(
1228
+ specified_widths, content_width=content_width, item_count=n_items
1229
+ )
1230
+ for i, item in enumerate(items):
1231
+ # User-specified height takes precedence (px only here;
1232
+ # percentages need available_height, resolved in _calculate_cols_dimensions)
1233
+ resolved = _resolve_layout_height(item, 0.0)
1234
+ if resolved is not None:
1235
+ max_height = max(max_height, resolved)
1236
+ else:
1237
+ item_width = assigned_widths[i]
1238
+ item_height = _resolve_height(
1239
+ item,
1240
+ card_gap,
1241
+ gap,
1242
+ item_width,
1243
+ variable_defaults,
1244
+ height_provider,
1245
+ resolved_style=resolved_style,
1246
+ )
1247
+ max_height = max(max_height, item_height)
1248
+ return max_height
1249
+
1250
+
1251
+ def _measure_grid_layout_height(
1252
+ layout: Layout,
1253
+ card_gap: float,
1254
+ gap: float,
1255
+ available_width: float,
1256
+ variable_defaults: dict[str, Any] | None,
1257
+ height_provider: HeightProvider | None = None,
1258
+ *,
1259
+ resolved_style: MergedStyle,
1260
+ ) -> float:
1261
+ """Measure required height for a grid layout."""
1262
+ columns = layout.columns or 24
1263
+ max_row_end = 1
1264
+ for item in layout.items:
1265
+ item_row = item.row or 0
1266
+ item_rows = item.row_span or 1
1267
+ max_row_end = max(max_row_end, item_row + item_rows)
1268
+
1269
+ effective_gap = gap + card_gap
1270
+ total_gap_x = effective_gap * (columns - 1)
1271
+ col_width = (available_width - total_gap_x) / columns
1272
+
1273
+ total_item_height = 0.0
1274
+ for item in layout.items:
1275
+ resolved = _resolve_layout_height(item, 0.0)
1276
+ if resolved is not None:
1277
+ total_item_height += resolved
1278
+ continue
1279
+ item_col_span = item.col_span or 1
1280
+ item_width = col_width * item_col_span + effective_gap * (item_col_span - 1)
1281
+ total_item_height += _resolve_height(
1282
+ item,
1283
+ card_gap,
1284
+ gap,
1285
+ item_width,
1286
+ variable_defaults,
1287
+ height_provider,
1288
+ resolved_style=resolved_style,
1289
+ )
1290
+
1291
+ avg_item_height = (
1292
+ total_item_height / len(layout.items) if layout.items else DEFAULT_CHART_HEIGHT
1293
+ )
1294
+ total_gaps = effective_gap * (max_row_end - 1) if max_row_end > 1 else 0
1295
+ return (max_row_end * avg_item_height) + total_gaps
1296
+
1297
+
1298
+ def _measure_tabs_layout_height(
1299
+ items: list[LayoutItem],
1300
+ card_gap: float,
1301
+ gap: float,
1302
+ available_width: float,
1303
+ variable_defaults: dict[str, Any] | None,
1304
+ height_provider: HeightProvider | None = None,
1305
+ *,
1306
+ resolved_style: MergedStyle,
1307
+ ) -> float:
1308
+ """Measure required height for a tabs layout."""
1309
+ tab_bar_height = float(get_config().style.layout.tabs.bar_height)
1310
+ max_content_height = MIN_CONTENT_HEIGHT
1311
+ for item in items:
1312
+ resolved = _resolve_layout_height(item, 0.0)
1313
+ if resolved is not None:
1314
+ max_content_height = max(max_content_height, resolved)
1315
+ else:
1316
+ item_height = _resolve_height(
1317
+ item,
1318
+ card_gap,
1319
+ gap,
1320
+ available_width,
1321
+ variable_defaults,
1322
+ height_provider,
1323
+ resolved_style=resolved_style,
1324
+ )
1325
+ max_content_height = max(max_content_height, item_height)
1326
+ return max_content_height + tab_bar_height
1327
+
1328
+
1329
+ def _set_render_ready_sizing(item: LayoutItem) -> None:
1330
+ """Set render-ready sizing fields (calculated_width, calculated_height, aspect_ratio).
1331
+
1332
+ These fields preserve the calculated pixel dimensions for consistent rendering
1333
+ across SVG and HTML outputs. The aspect_ratio is used for responsive CSS rendering.
1334
+
1335
+ Args:
1336
+ item: LayoutItem to set sizing fields on
1337
+ """
1338
+ # Set calculated dimensions (same as width/height for now, but preserved separately)
1339
+ item.calculated_width = item.width if item.width > 0 else None
1340
+ item.calculated_height = item.height if item.height > 0 else None
1341
+
1342
+ # Calculate aspect ratio for responsive rendering
1343
+ if item.calculated_width and item.calculated_height and item.calculated_height > 0:
1344
+ item.aspect_ratio = item.calculated_width / item.calculated_height
1345
+ else:
1346
+ item.aspect_ratio = None
1347
+
1348
+
1349
+ def calculate_layout_items(
1350
+ layout: Layout,
1351
+ available_width: float,
1352
+ available_height: float,
1353
+ card_gap: float,
1354
+ gap: float,
1355
+ variable_defaults: dict[str, Any] | None = None,
1356
+ height_provider: HeightProvider | None = None,
1357
+ *,
1358
+ resolved_style: MergedStyle,
1359
+ ) -> None:
1360
+ """Calculate dimensions for all items in a layout.
1361
+
1362
+ Modifies items in place, setting their dimensions and positions.
1363
+
1364
+ Args:
1365
+ layout: Layout to calculate dimensions for
1366
+ available_width: Available container width in pixels
1367
+ available_height: Available container height in pixels
1368
+ card_gap: Gap between cards (inter-item spacing)
1369
+ gap: Gap between items in pixels
1370
+ variable_defaults: Optional variable defaults for Jinja resolution
1371
+ resolved_style: Face-resolved style for cascade-aware height calculations.
1372
+ """
1373
+ if not layout.items:
1374
+ return
1375
+
1376
+ _apply_layout_dimensions(
1377
+ layout,
1378
+ available_width,
1379
+ available_height,
1380
+ card_gap,
1381
+ gap,
1382
+ variable_defaults,
1383
+ height_provider,
1384
+ resolved_style=resolved_style,
1385
+ )
1386
+
1387
+ for item in layout.items:
1388
+ _calculate_nested_face_layout(
1389
+ item, card_gap, gap, variable_defaults, height_provider
1390
+ )
1391
+
1392
+
1393
+ def _apply_layout_dimensions(
1394
+ layout: Layout,
1395
+ available_width: float,
1396
+ available_height: float,
1397
+ card_gap: float,
1398
+ gap: float,
1399
+ variable_defaults: dict[str, Any] | None,
1400
+ height_provider: HeightProvider | None = None,
1401
+ *,
1402
+ resolved_style: MergedStyle,
1403
+ ) -> None:
1404
+ """Apply the correct layout dimension calculator based on layout type."""
1405
+ if layout.type == "rows":
1406
+ _calculate_rows_dimensions(
1407
+ layout.items,
1408
+ available_width,
1409
+ available_height,
1410
+ card_gap,
1411
+ gap,
1412
+ variable_defaults,
1413
+ height_provider,
1414
+ resolved_style=resolved_style,
1415
+ )
1416
+ return
1417
+ if layout.type == "cols":
1418
+ _calculate_cols_dimensions(
1419
+ layout.items,
1420
+ available_width,
1421
+ available_height,
1422
+ card_gap,
1423
+ gap,
1424
+ variable_defaults,
1425
+ height_provider,
1426
+ resolved_style=resolved_style,
1427
+ )
1428
+ return
1429
+ if layout.type == "grid":
1430
+ _calculate_grid_dimensions(
1431
+ layout.items,
1432
+ available_width,
1433
+ available_height,
1434
+ layout.columns or 24,
1435
+ card_gap,
1436
+ gap,
1437
+ variable_defaults,
1438
+ height_provider,
1439
+ resolved_style=resolved_style,
1440
+ )
1441
+ return
1442
+ if layout.type == "tabs":
1443
+ _calculate_tabs_dimensions(
1444
+ layout.items,
1445
+ available_width,
1446
+ available_height,
1447
+ card_gap,
1448
+ gap,
1449
+ variable_defaults,
1450
+ height_provider,
1451
+ resolved_style=resolved_style,
1452
+ )
1453
+ return
1454
+ _calculate_rows_dimensions(
1455
+ layout.items,
1456
+ available_width,
1457
+ available_height,
1458
+ card_gap,
1459
+ gap,
1460
+ variable_defaults,
1461
+ height_provider,
1462
+ resolved_style=resolved_style,
1463
+ )
1464
+
1465
+
1466
+ def nested_face_sizing_context(
1467
+ nested_face: Face,
1468
+ width: float,
1469
+ card_gap: float,
1470
+ variable_defaults: dict[str, Any] | None,
1471
+ ) -> tuple[float, float, float]:
1472
+ """Return (content_width, non_layout_height, child_gap).
1473
+
1474
+ non_layout_height includes title + text + variables + gaps between them,
1475
+ plus the gap before the layout block if layout items follow.
1476
+ """
1477
+ nrs = nested_face.resolved_style
1478
+ ep = _effective_padding(nrs)
1479
+ face_margin_horizontal = nrs.margin.horizontal if nrs.margin else 0.0
1480
+ content_width = width - ep.horizontal - face_margin_horizontal
1481
+ child_gap = nrs.gap if nrs.gap is not None else 0.0
1482
+ effective_child_gap = child_gap + card_gap
1483
+
1484
+ vs = nested_face.resolved_style.variables
1485
+ card_pad = float(nested_face.resolved_style.board.card_padding)
1486
+ inner = max(content_width - 2 * card_pad, 1.0)
1487
+
1488
+ use_title_inline = (
1489
+ vs.position == "title-inline"
1490
+ and bool(nested_face.title)
1491
+ and bool(nested_face.visible_variables)
1492
+ )
1493
+
1494
+ non_layout_height = 0.0
1495
+ if use_title_inline:
1496
+ non_layout_height += compute_title_variables_inline_band_height(
1497
+ nested_face, content_width, variable_defaults
1498
+ )
1499
+ elif nested_face.title:
1500
+ title_height = max(
1501
+ get_title_height(nested_face.title, inner, variable_defaults),
1502
+ float(nested_face.resolved_style.title.min_height),
1503
+ )
1504
+ non_layout_height += title_height
1505
+
1506
+ if nested_face.text:
1507
+ text_height = get_markdown_text_height(
1508
+ nested_face.text,
1509
+ content_width,
1510
+ variable_defaults,
1511
+ text_style=nested_face.resolved_style.text,
1512
+ )
1513
+ if non_layout_height > 0:
1514
+ non_layout_height += effective_child_gap
1515
+ non_layout_height += text_height
1516
+
1517
+ # Add variable controls height if this nested face has visible variables
1518
+ if nested_face.visible_variables and not use_title_inline:
1519
+ if non_layout_height > 0:
1520
+ non_layout_height += effective_child_gap
1521
+ non_layout_height += compute_variable_controls_height(
1522
+ nested_face.visible_variables,
1523
+ content_width,
1524
+ variables_style=nested_face.resolved_style.variables,
1525
+ )
1526
+
1527
+ if non_layout_height > 0 and nested_face.layout.items:
1528
+ non_layout_height += effective_child_gap
1529
+
1530
+ return content_width, non_layout_height, child_gap
1531
+
1532
+
1533
+ def _calculate_nested_face_layout(
1534
+ item: LayoutItem,
1535
+ card_gap: float,
1536
+ gap: float,
1537
+ variable_defaults: dict[str, Any] | None,
1538
+ height_provider: HeightProvider | None = None,
1539
+ ) -> None:
1540
+ """Recursively size a nested face once the parent item dimensions are known."""
1541
+ if is_item_collapsed_summary(item, variable_defaults) or not item.face:
1542
+ return
1543
+
1544
+ nested_face = item.face
1545
+ nested_face.layout.width = item.width
1546
+ nested_face.layout.height = item.height
1547
+
1548
+ content_width, non_layout_height, child_gap = nested_face_sizing_context(
1549
+ nested_face, item.width, card_gap, variable_defaults
1550
+ )
1551
+
1552
+ nrs2 = nested_face.resolved_style
1553
+ layout_available_height = (
1554
+ item.height
1555
+ - details_chrome_height(item, gap, card_gap)
1556
+ - _effective_padding(nrs2).vertical
1557
+ - (nrs2.margin.vertical if nrs2.margin else 0.0)
1558
+ - non_layout_height
1559
+ )
1560
+
1561
+ nested_face.layout.content_width = content_width
1562
+ nested_face.layout.content_height = max(layout_available_height, 0)
1563
+
1564
+ calculate_layout_items(
1565
+ nested_face.layout,
1566
+ content_width,
1567
+ max(layout_available_height, 0),
1568
+ card_gap,
1569
+ gap=child_gap,
1570
+ variable_defaults=variable_defaults,
1571
+ height_provider=height_provider,
1572
+ resolved_style=nested_face.resolved_style,
1573
+ )
1574
+
1575
+
1576
+ def _calculate_rows_dimensions(
1577
+ items: list[LayoutItem],
1578
+ available_width: float,
1579
+ available_height: float,
1580
+ card_gap: float,
1581
+ gap: float,
1582
+ variable_defaults: dict[str, Any] | None = None,
1583
+ height_provider: HeightProvider | None = None,
1584
+ *,
1585
+ resolved_style: MergedStyle,
1586
+ ) -> None:
1587
+ """Calculate dimensions for items in a rows layout.
1588
+
1589
+ In a rows layout:
1590
+ - Items stack vertically
1591
+ - All items get full width
1592
+ - Height respects user-specified values (e.g., "200px", "50%")
1593
+ - Remaining height is distributed among auto items based on content type
1594
+ - If auto items exceed remaining space, they scale proportionally
1595
+ - Specified items are never scaled; if they exceed available space the layout overflows
1596
+
1597
+ Args:
1598
+ items: List of layout items
1599
+ available_width: Available width in pixels
1600
+ available_height: Available height in pixels
1601
+ card_gap: Gap between cards (inter-item spacing)
1602
+ gap: Gap between items in pixels
1603
+ variable_defaults: Optional variable defaults for Jinja resolution
1604
+ """
1605
+ n = len(items)
1606
+ if n == 0:
1607
+ return
1608
+
1609
+ # Calculate total gap space
1610
+ total_gap = (gap + card_gap) * (n - 1)
1611
+ available_content_height = available_height - total_gap
1612
+
1613
+ # First pass: parse user-specified heights and get content heights for auto items
1614
+ specified_heights: list[float | None] = []
1615
+ content_heights: list[float] = []
1616
+ total_specified = 0.0
1617
+
1618
+ for item in items:
1619
+ parsed_height = _resolve_layout_height(item, available_content_height)
1620
+ specified_heights.append(parsed_height)
1621
+ if parsed_height is not None:
1622
+ total_specified += parsed_height
1623
+ content_heights.append(parsed_height)
1624
+ else:
1625
+ height = _resolve_height(
1626
+ item,
1627
+ card_gap,
1628
+ gap,
1629
+ available_width,
1630
+ variable_defaults,
1631
+ height_provider,
1632
+ resolved_style=resolved_style,
1633
+ )
1634
+ content_heights.append(height)
1635
+
1636
+ # Distribute height: specified items keep their height, auto items share the rest
1637
+ remaining = max(available_content_height - total_specified, 0.0)
1638
+ auto_total = sum(
1639
+ h for h, s in zip(content_heights, specified_heights, strict=True) if s is None
1640
+ )
1641
+
1642
+ item_heights: list[float] = []
1643
+ for i, _item in enumerate(items):
1644
+ spec_h = specified_heights[i]
1645
+ if spec_h is not None:
1646
+ item_heights.append(spec_h)
1647
+ elif auto_total > 0 and remaining < auto_total:
1648
+ # Auto items need scaling to fit
1649
+ item_heights.append(content_heights[i] * (remaining / auto_total))
1650
+ else:
1651
+ item_heights.append(content_heights[i])
1652
+
1653
+ # Second pass: assign dimensions
1654
+ current_y = 0.0
1655
+ for i, item in enumerate(items):
1656
+ item.width_fraction = 1.0
1657
+ item.width = available_width
1658
+ item.height = item_heights[i]
1659
+ item.x = 0.0
1660
+ item.y = current_y
1661
+ current_y += item.height + gap + card_gap
1662
+ # Set render-ready sizing fields
1663
+ _set_render_ready_sizing(item)
1664
+
1665
+
1666
+ def parse_dimension(value: str | None, total: float) -> float | None:
1667
+ """Parse a dimension string to pixels.
1668
+
1669
+ Supports:
1670
+ - Percentages: "30%", "70%" -> fraction of total
1671
+ - Pixels: "200px", "200" -> exact pixels
1672
+ - None -> returns None (auto-distribute)
1673
+
1674
+ Args:
1675
+ value: Dimension string or None
1676
+ total: Total available size for percentage calculations
1677
+
1678
+ Returns:
1679
+ Pixel value or None if not specified
1680
+ """
1681
+ if value is None:
1682
+ return None
1683
+
1684
+ value = str(value).strip()
1685
+
1686
+ if value.endswith("%"):
1687
+ try:
1688
+ percent = float(value[:-1])
1689
+ return (percent / 100.0) * total
1690
+ except ValueError:
1691
+ return None
1692
+
1693
+ if value.endswith("px"):
1694
+ try:
1695
+ return float(value[:-2])
1696
+ except ValueError:
1697
+ return None
1698
+
1699
+ # Try as plain number (pixels)
1700
+ try:
1701
+ return float(value)
1702
+ except ValueError:
1703
+ return None
1704
+
1705
+
1706
+ def _resolve_layout_height(item: LayoutItem, available: float) -> float | None:
1707
+ """Return resolved layout-wrapper height in px, or None for auto.
1708
+
1709
+ Percentages resolve against ``available``; measurement functions pass
1710
+ ``available=0.0`` so percentages fall through to content-aware sizing
1711
+ (``parse_dimension("50%", 0.0)`` returns 0.0, filtered by ``h > 0``).
1712
+
1713
+ Explicit ``height: 0`` is rejected at compile time in
1714
+ ``normalize_layout._validate_dimension``.
1715
+ """
1716
+ h = parse_dimension(item.layout_height, available)
1717
+ return h if h is not None and h > 0 else None
1718
+
1719
+
1720
+ def _resolve_cols_widths(
1721
+ specified_widths: list[float | None], content_width: float, item_count: int
1722
+ ) -> list[float]:
1723
+ """Resolve column widths, avoiding zero-width auto items in mixed layouts.
1724
+
1725
+ Normal case:
1726
+ - explicit widths keep their authored size
1727
+ - auto items split the leftover width equally
1728
+
1729
+ Defensive overflow case:
1730
+ - when explicit widths consume the full row and auto siblings exist,
1731
+ reserve one equal-share slot per auto item
1732
+ - scale explicit widths proportionally into the remaining space
1733
+
1734
+ This preserves authored ratios for explicit siblings while avoiding
1735
+ unreadable 0px auto columns in over-constrained mixed layouts.
1736
+ """
1737
+ if item_count == 0:
1738
+ return []
1739
+
1740
+ total_specified = sum(width for width in specified_widths if width is not None)
1741
+ auto_count = sum(1 for width in specified_widths if width is None)
1742
+
1743
+ if auto_count == 0:
1744
+ return [width if width is not None else 0.0 for width in specified_widths]
1745
+
1746
+ remaining_width = content_width - total_specified
1747
+ if remaining_width > 0:
1748
+ auto_width = remaining_width / auto_count
1749
+ return [auto_width if width is None else width for width in specified_widths]
1750
+
1751
+ equal_share = content_width / item_count if item_count > 0 else 0.0
1752
+ auto_width = equal_share
1753
+ specified_budget = max(content_width - (auto_width * auto_count), 0.0)
1754
+ scale = specified_budget / total_specified if total_specified > 0 else 0.0
1755
+
1756
+ return [
1757
+ auto_width if width is None else width * scale for width in specified_widths
1758
+ ]
1759
+
1760
+
1761
+ def _calculate_cols_dimensions(
1762
+ items: list[LayoutItem],
1763
+ available_width: float,
1764
+ available_height: float,
1765
+ card_gap: float,
1766
+ gap: float,
1767
+ variable_defaults: dict[str, Any] | None = None,
1768
+ height_provider: HeightProvider | None = None,
1769
+ *,
1770
+ resolved_style: MergedStyle,
1771
+ ) -> None:
1772
+ """Calculate dimensions for items in a cols layout.
1773
+
1774
+ In a cols layout:
1775
+ - Items arrange horizontally
1776
+ - Width respects user-specified values (e.g., "30%", "200px")
1777
+ - Remaining width is distributed equally among items without specified widths
1778
+ - ALL items get the SAME height (max of content heights, capped at available)
1779
+
1780
+ This ensures alignment: all items in a row have the same height.
1781
+ The height is the max of what each item needs, but won't exceed available.
1782
+
1783
+ Args:
1784
+ items: List of layout items
1785
+ available_width: Available width in pixels
1786
+ available_height: Available height in pixels (upper bound)
1787
+ card_gap: Gap between cards (inter-item spacing)
1788
+ gap: Gap between items in pixels
1789
+ variable_defaults: Optional variable defaults for Jinja resolution
1790
+ """
1791
+ n = len(items)
1792
+ if n == 0:
1793
+ return
1794
+
1795
+ # Calculate total gap space
1796
+ effective_gap = gap + card_gap
1797
+ total_gap = effective_gap * (n - 1)
1798
+ content_width = available_width - total_gap
1799
+
1800
+ # First pass: parse user-specified widths and calculate remaining space
1801
+ specified_widths: list[float | None] = []
1802
+
1803
+ for item in items:
1804
+ parsed_width = parse_dimension(item.user_width, content_width)
1805
+ specified_widths.append(parsed_width)
1806
+
1807
+ resolved_widths = _resolve_cols_widths(
1808
+ specified_widths, content_width=content_width, item_count=n
1809
+ )
1810
+
1811
+ # Find max content height (all items in cols share this height).
1812
+ # Track specified and content maxima separately so overflow semantics
1813
+ # are order-independent: if max_specified >= max_content, the user's
1814
+ # explicit height wins and the row overflows rather than being capped.
1815
+ max_specified = 0.0
1816
+ max_auto = MIN_CONTENT_HEIGHT
1817
+ for i, item in enumerate(items):
1818
+ # Percentages resolve against available_height (no vertical gap in cols)
1819
+ resolved = _resolve_layout_height(item, available_height)
1820
+ if resolved is not None:
1821
+ max_specified = max(max_specified, resolved)
1822
+ else:
1823
+ item_w = resolved_widths[i]
1824
+ ch = _resolve_height(
1825
+ item,
1826
+ card_gap,
1827
+ gap,
1828
+ item_w,
1829
+ variable_defaults,
1830
+ height_provider,
1831
+ resolved_style=resolved_style,
1832
+ )
1833
+ max_auto = max(max_auto, ch)
1834
+
1835
+ max_content_height = max(max_specified, max_auto)
1836
+ # Cap to available height unless a user-specified height drove the max.
1837
+ # >= so that a specified height equal to the content max still wins
1838
+ # (the user explicitly asked for this height, so don't clamp it).
1839
+ row_height = (
1840
+ max_content_height
1841
+ if max_specified >= max_auto
1842
+ else min(max_content_height, available_height)
1843
+ )
1844
+
1845
+ # Second pass: assign dimensions
1846
+ current_x = 0.0
1847
+ for i, item in enumerate(items):
1848
+ item_width = resolved_widths[i]
1849
+ item.width_fraction = (
1850
+ item_width / content_width if content_width > 0 else 1.0 / n
1851
+ )
1852
+ item.width = item_width
1853
+ item.height = row_height # All items get same height
1854
+ item.x = current_x
1855
+ item.y = 0.0
1856
+ # Add gap between items, but not after the last item
1857
+ if i < n - 1:
1858
+ current_x += item_width + effective_gap
1859
+ else:
1860
+ current_x += item_width
1861
+ # Set render-ready sizing fields
1862
+ _set_render_ready_sizing(item)
1863
+
1864
+
1865
+ def _calculate_grid_dimensions(
1866
+ items: list[LayoutItem],
1867
+ available_width: float,
1868
+ available_height: float,
1869
+ columns: int,
1870
+ card_gap: float,
1871
+ gap: float,
1872
+ variable_defaults: dict[str, Any] | None = None,
1873
+ height_provider: HeightProvider | None = None,
1874
+ *,
1875
+ resolved_style: MergedStyle,
1876
+ ) -> None:
1877
+ """Calculate dimensions for items in a grid layout.
1878
+
1879
+ In a grid layout:
1880
+ - Items can have explicit row, col positions and row_span, col_span
1881
+ - Items WITHOUT explicit positions are auto-flowed like CSS grid
1882
+ - Width is based on column count
1883
+ - Row height is calculated based on content types
1884
+
1885
+ Args:
1886
+ items: List of layout items with grid positions
1887
+ available_width: Available width in pixels
1888
+ available_height: Available height in pixels
1889
+ columns: Number of grid columns
1890
+ card_gap: Gap between cards (inter-item spacing)
1891
+ gap: Gap between items in pixels
1892
+ variable_defaults: Optional variable defaults for Jinja resolution
1893
+ """
1894
+ if not items:
1895
+ return
1896
+
1897
+ # Calculate column width
1898
+ effective_gap = gap + card_gap
1899
+ total_gap_x = effective_gap * (columns - 1)
1900
+ col_width = (available_width - total_gap_x) / columns
1901
+
1902
+ # Auto-flow items without explicit positions
1903
+ # Track occupied cells as a set of (col, row) tuples
1904
+ occupied: set = set()
1905
+
1906
+ # First pass: mark cells occupied by items with explicit positions
1907
+ for item in items:
1908
+ if item.col is not None and item.row is not None:
1909
+ item_col_span = item.col_span or 1
1910
+ item_row_span = item.row_span or 1
1911
+ for c in range(item.col, item.col + item_col_span):
1912
+ for r in range(item.row, item.row + item_row_span):
1913
+ occupied.add((c, r))
1914
+
1915
+ # Second pass: auto-place items without explicit positions
1916
+ current_col = 0
1917
+ current_row = 0
1918
+
1919
+ for item in items:
1920
+ if item.col is None or item.row is None:
1921
+ item_col_span = item.col_span or 1
1922
+ item_row_span = item.row_span or 1
1923
+
1924
+ # Find next available position that fits the item
1925
+ placed = False
1926
+ while not placed:
1927
+ # Check if item fits at current position
1928
+ fits = True
1929
+ if current_col + item_col_span > columns:
1930
+ # Doesn't fit, move to next row
1931
+ current_col = 0
1932
+ current_row += 1
1933
+ continue
1934
+
1935
+ # Check if all cells are available
1936
+ for c in range(current_col, current_col + item_col_span):
1937
+ for r in range(current_row, current_row + item_row_span):
1938
+ if (c, r) in occupied:
1939
+ fits = False
1940
+ break
1941
+ if not fits:
1942
+ break
1943
+
1944
+ if fits:
1945
+ # Place the item
1946
+ item.col = current_col
1947
+ item.row = current_row
1948
+ # Mark cells as occupied
1949
+ for c in range(current_col, current_col + item_col_span):
1950
+ for r in range(current_row, current_row + item_row_span):
1951
+ occupied.add((c, r))
1952
+ placed = True
1953
+ # Move to next column for next item
1954
+ current_col += item_col_span
1955
+ else:
1956
+ # Try next column
1957
+ current_col += 1
1958
+
1959
+ # Calculate number of rows
1960
+ max_row_end = 1
1961
+ for item in items:
1962
+ item_row = item.row or 0
1963
+ item_rows = item.row_span or 1
1964
+ row_end = item_row + item_rows
1965
+ max_row_end = max(max_row_end, row_end)
1966
+
1967
+ # Calculate content-aware row height
1968
+ # Find max content height per row, then average
1969
+ row_content_heights: dict = {} # row_index -> max_height
1970
+
1971
+ # Track which rows have heights driven by user-specified values.
1972
+ # A row with any specified-height item is exempt from scaling,
1973
+ # even if the row's max came from an auto item. This is intentional:
1974
+ # the user pinned at least one item in that row, so we respect the
1975
+ # resulting row height rather than squashing it.
1976
+ specified_rows: set[int] = set()
1977
+
1978
+ for item in items:
1979
+ item_row = item.row or 0
1980
+ item_row_span = item.row_span or 1
1981
+
1982
+ # User-specified height takes precedence
1983
+ resolved = _resolve_layout_height(item, available_height)
1984
+ if resolved is not None:
1985
+ content_height = resolved
1986
+ for r in range(item_row, item_row + item_row_span):
1987
+ specified_rows.add(r)
1988
+ else:
1989
+ content_height = _resolve_height(
1990
+ item,
1991
+ card_gap,
1992
+ gap,
1993
+ col_width,
1994
+ variable_defaults,
1995
+ height_provider,
1996
+ resolved_style=resolved_style,
1997
+ )
1998
+ # Distribute height across spanned rows
1999
+ height_per_row = content_height / item_row_span
2000
+
2001
+ for r in range(item_row, item_row + item_row_span):
2002
+ current_max = row_content_heights.get(r, MIN_CONTENT_HEIGHT)
2003
+ row_content_heights[r] = max(current_max, height_per_row)
2004
+
2005
+ # Calculate total content height
2006
+ total_content_height = sum(row_content_heights.values())
2007
+ total_gap_y = effective_gap * (max_row_end - 1)
2008
+
2009
+ # Use content height if it fits, otherwise scale auto rows to fit.
2010
+ # Rows with user-specified heights are exempt from scaling (overflow).
2011
+ if total_content_height + total_gap_y <= available_height:
2012
+ row_heights = row_content_heights
2013
+ else:
2014
+ specified_total = sum(
2015
+ row_content_heights[r] for r in specified_rows if r in row_content_heights
2016
+ )
2017
+ auto_total = total_content_height - specified_total
2018
+ auto_budget = max(available_height - total_gap_y - specified_total, 0.0)
2019
+ auto_scale = auto_budget / auto_total if auto_total > 0 else 1.0
2020
+ row_heights = {
2021
+ r: h if r in specified_rows else h * auto_scale
2022
+ for r, h in row_content_heights.items()
2023
+ }
2024
+
2025
+ # Calculate row Y positions
2026
+ row_y_positions: dict = {}
2027
+ current_y = 0.0
2028
+ for r in range(max_row_end):
2029
+ row_y_positions[r] = current_y
2030
+ row_h = row_heights.get(r, DEFAULT_CHART_HEIGHT)
2031
+ current_y += row_h + effective_gap
2032
+
2033
+ # Position each item
2034
+ for item in items:
2035
+ item_col = item.col or 0
2036
+ item_row = item.row or 0
2037
+ item_col_span = item.col_span or 1
2038
+ item_row_span = item.row_span or 1
2039
+
2040
+ # Calculate pixel position
2041
+ item.x = item_col * (col_width + effective_gap)
2042
+ item.y = row_y_positions.get(item_row, 0.0)
2043
+
2044
+ # Calculate pixel dimensions
2045
+ item.width = item_col_span * col_width + (item_col_span - 1) * effective_gap
2046
+
2047
+ # Height spans multiple rows
2048
+ item_height = 0.0
2049
+ for r in range(item_row, item_row + item_row_span):
2050
+ item_height += row_heights.get(r, DEFAULT_CHART_HEIGHT)
2051
+ item_height += effective_gap * (item_row_span - 1) # Internal gaps
2052
+ item.height = item_height
2053
+
2054
+ # Calculate width fraction
2055
+ item.width_fraction = item_col_span / columns
2056
+
2057
+ # Set render-ready sizing fields
2058
+ _set_render_ready_sizing(item)
2059
+
2060
+
2061
+ def _calculate_tabs_dimensions(
2062
+ items: list[LayoutItem],
2063
+ available_width: float,
2064
+ available_height: float,
2065
+ card_gap: float,
2066
+ gap: float,
2067
+ variable_defaults: dict[str, Any] | None = None,
2068
+ height_provider: HeightProvider | None = None,
2069
+ *,
2070
+ resolved_style: MergedStyle,
2071
+ ) -> None:
2072
+ """Calculate dimensions for items in a tabs layout.
2073
+
2074
+ In a tabs layout:
2075
+ - Each tab gets the full container size (minus tab bar height)
2076
+ - Only one tab is visible at a time
2077
+ - Height is based on max content height of all tabs
2078
+
2079
+ Args:
2080
+ items: List of layout items (one per tab)
2081
+ available_width: Available width in pixels
2082
+ available_height: Available height in pixels
2083
+ card_gap: Gap between cards (inter-item spacing)
2084
+ gap: Gap (unused for tabs)
2085
+ variable_defaults: Optional variable defaults for Jinja resolution
2086
+ """
2087
+ # Reserve space for tab bar
2088
+ tab_bar_height = float(get_config().style.layout.tabs.bar_height)
2089
+
2090
+ # Find max content height across all tabs.
2091
+ # Track specified and content maxima separately (order-independent).
2092
+ tab_available = available_height - tab_bar_height
2093
+ max_specified = 0.0
2094
+ max_auto = MIN_CONTENT_HEIGHT
2095
+ for item in items:
2096
+ resolved = _resolve_layout_height(item, tab_available)
2097
+ if resolved is not None:
2098
+ max_specified = max(max_specified, resolved)
2099
+ else:
2100
+ ch = _resolve_height(
2101
+ item,
2102
+ card_gap,
2103
+ gap,
2104
+ available_width,
2105
+ variable_defaults,
2106
+ height_provider,
2107
+ resolved_style=resolved_style,
2108
+ )
2109
+ max_auto = max(max_auto, ch)
2110
+
2111
+ max_content_height = max(max_specified, max_auto)
2112
+ # Cap to available height unless the max was driven by a user-specified height
2113
+ content_height = (
2114
+ max_content_height
2115
+ if max_specified >= max_auto
2116
+ else min(max_content_height, tab_available)
2117
+ )
2118
+
2119
+ for item in items:
2120
+ item.width_fraction = 1.0
2121
+ item.width = available_width
2122
+ item.height = content_height
2123
+ item.x = 0.0
2124
+ item.y = tab_bar_height
2125
+ # Set render-ready sizing fields
2126
+ _set_render_ready_sizing(item)