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,1194 @@
1
+ """Face rendering functions.
2
+
3
+ Stage: RENDER
4
+ Purpose: Render face structures (root and nested) to SVG.
5
+
6
+ This module handles:
7
+ - Root face SVG rendering (render_face_svg)
8
+ - Nested face SVG rendering (render_nested_face)
9
+
10
+ Dependencies:
11
+ - .layouts (for layout rendering)
12
+ - .variable_controls (for variable rendering)
13
+ - .svg_utils (for SVG utilities)
14
+ - .themes (for theme colors)
15
+ """
16
+
17
+ import hashlib
18
+ import html
19
+ import re
20
+ from datetime import datetime, timezone
21
+ from typing import TYPE_CHECKING, Literal
22
+
23
+ from dataface.core.errors import StructuredError
24
+
25
+ if TYPE_CHECKING:
26
+ from dataface.core.compile.models.primitives import FontStyle
27
+ from dataface.core.compile.models.style.compiled import TextStyle
28
+ from dataface.core.compile.models.style.merged import MergedStyle
29
+
30
+ from dataface.core.compile.models.face.compiled import (
31
+ MergedFace,
32
+ VariableValues,
33
+ )
34
+ from dataface.core.compile.models.style.compiled import compute_text_column_count
35
+ from dataface.core.compile.models.style.merged import (
36
+ effective_padding as _effective_padding,
37
+ )
38
+ from dataface.core.execute.executor import Executor
39
+ from dataface.core.render.chart_interactivity import (
40
+ generate_svg_chart_interactivity_script,
41
+ )
42
+ from dataface.core.render.svg_utils import _px, extract_svg_inner_content
43
+
44
+ # Named constant for SVG ID hash length
45
+ SVG_ID_HASH_LENGTH = 8
46
+
47
+ # Vertical gap between the page footer text and the hairline rule above it.
48
+ # Used by both the height-reservation math and the rule-position math so the
49
+ # two never drift.
50
+ FOOTER_RULE_GAP_PX = 2
51
+
52
+ __all__ = [
53
+ "render_face_svg",
54
+ "render_nested_face",
55
+ "SVG_ID_HASH_LENGTH",
56
+ ]
57
+
58
+ # -----------------------------------------------------------------------------
59
+ # Shared rendering helpers (DRY - single source of truth)
60
+ # -----------------------------------------------------------------------------
61
+
62
+
63
+ def _format_svg_numeric(value: float) -> str:
64
+ """Render integer-valued floats without a trailing .0."""
65
+ numeric = float(value)
66
+ return str(int(numeric)) if numeric.is_integer() else str(numeric)
67
+
68
+
69
+ def _resolve_jinja(template: str, variables: VariableValues) -> str:
70
+ """Resolve Jinja templates in a string (lenient mode).
71
+
72
+ Returns the original string if no Jinja syntax is present.
73
+ """
74
+ if "{{" not in template and "{%" not in template:
75
+ return template
76
+ from dataface.core.compile.jinja import resolve_jinja_template
77
+
78
+ return resolve_jinja_template(template, variables, strict=False)
79
+
80
+
81
+ def _render_title_svg(
82
+ title: str,
83
+ variables: VariableValues,
84
+ width: float,
85
+ text_align: Literal["left", "center", "right"] = "left",
86
+ level: int = 1,
87
+ prose: bool = False,
88
+ resolved_style: "MergedStyle | None" = None,
89
+ ) -> str:
90
+ """Render a title with Jinja resolution and case transform."""
91
+ from dataface.core.render.svg_utils import render_title
92
+ from dataface.core.render.text.case import apply_case
93
+
94
+ resolved = _resolve_jinja(title, variables)
95
+ # Apply the case transform from style.title.font.case (cascaded from root).
96
+ # Face body markdown (TextStyle.font.case) is explicitly excluded — case
97
+ # transforms on prose blocks would corrupt code spans, links, and emphasis.
98
+ _title_case = resolved_style.title.font.case if resolved_style is not None else None
99
+ if _title_case is not None and _title_case != "none":
100
+ resolved = apply_case(resolved, _title_case)
101
+ return render_title(
102
+ resolved,
103
+ width,
104
+ text_align=text_align,
105
+ level=level,
106
+ prose=prose,
107
+ resolved_style=resolved_style,
108
+ )
109
+
110
+
111
+ def _face_uses_title_inline_band(face: MergedFace) -> bool:
112
+ return (
113
+ face.style.variables.position == "title-inline"
114
+ and bool(face.title)
115
+ and bool(face.visible_variables)
116
+ )
117
+
118
+
119
+ def _paint_title_svg_fill(title_svg: str, color: str) -> str:
120
+ r"""Apply a solid fill to every ``<text>`` element in the title SVG.
121
+
122
+ Uses inline ``style="fill: ..."`` rather than the ``fill="..."`` presentation
123
+ attribute. The title text carries ``class="md-heading"`` (mdsvg), and SVG/CSS
124
+ specificity rules let class-rule properties beat presentation attributes —
125
+ so a bare ``fill="…"`` override is silently swallowed by the class's fill.
126
+ An inline style attribute trumps the class rule and the override sticks.
127
+
128
+ Two independent passes (both run unconditionally):
129
+
130
+ 1. **Inject** ``style="fill: …"`` on every ``<text>`` element that does
131
+ not already carry a style attribute. mdsvg emits plain-heading text
132
+ as ``<text class="md-heading">…</text>``; that text gets the new
133
+ inline style here.
134
+ 2. **Rewrite** any existing ``fill:`` declaration inside any ``style="…"``
135
+ attribute. mdsvg wraps code-styled or link-styled runs inside a
136
+ title in ``<tspan style="…; fill: …">…</tspan>``; those tspans get
137
+ their existing fill rewritten in place so a mixed-content title
138
+ like ``Sales \`Q3\``` paints uniformly in the override color.
139
+
140
+ The rewrite is scoped to the attribute value (not the whole document)
141
+ so CSS rules inside any ``<style>`` block — ``.md-text { fill: ... }``
142
+ etc. — are untouched. Other declarations in the same ``style="..."``
143
+ attribute (e.g. ``font-weight: 500``) are preserved.
144
+ """
145
+ color_escaped = html.escape(str(color))
146
+
147
+ # Pass 1 — inject inline style on every <text> that doesn't already carry
148
+ # one. Inserting before any attribute means subsequent attributes survive.
149
+ no_style_re = re.compile(r"<text(?![^>]*\bstyle=)")
150
+ title_svg = no_style_re.sub(f'<text style="fill: {color_escaped}"', title_svg)
151
+
152
+ # Pass 2 — rewrite any existing fill: declaration inside a style attribute.
153
+ attr_with_fill_re = re.compile(r'(style=")([^"]*\bfill\s*:\s*)[^;"]*([^"]*)(")')
154
+ title_svg = attr_with_fill_re.sub(
155
+ rf"\g<1>\g<2>{color_escaped}\g<3>\g<4>", title_svg
156
+ )
157
+
158
+ return title_svg
159
+
160
+
161
+ def _render_title_variables_inline_band(
162
+ face: MergedFace,
163
+ variables: VariableValues,
164
+ layout_content_width: float,
165
+ card_pad: float,
166
+ interactive: bool,
167
+ executor: Executor,
168
+ text_align: Literal["left", "center", "right"],
169
+ prose: bool,
170
+ ) -> tuple[str, float]:
171
+ """One horizontal band: face title (left column) + variable controls (right)."""
172
+ from dataface.core.compile.sizing import (
173
+ compute_title_variables_inline_baseline_layout,
174
+ get_title_height,
175
+ resolve_title_variables_inline_widths,
176
+ )
177
+ from dataface.core.render.variable_controls import (
178
+ render_interactive_variables_svg,
179
+ render_variables_svg,
180
+ )
181
+
182
+ vs = face.style.variables
183
+ inner = max(layout_content_width - 2 * card_pad, 1.0)
184
+ title_w, vars_w = resolve_title_variables_inline_widths(
185
+ inner,
186
+ vs,
187
+ face.visible_variables,
188
+ title=face.title,
189
+ variable_defaults=face.variable_defaults,
190
+ )
191
+ col_gap = float(vs.gap)
192
+
193
+ # Size the title against the board's inner width, not the title column.
194
+ # face_title_spec selects a width tier (narrow/medium/wide) that drives the
195
+ # heading-level offset. Passing the cramped title-inline column (~262px on
196
+ # the default board with a daterange filter) lands in the narrow tier and
197
+ # bumps the title from H1 (24px) to H2 (18px) the moment a filter is added.
198
+ # The title still wraps/measures inside title_w at the layout step — only
199
+ # tier selection sees the board inner width.
200
+ title_svg_raw = _render_title_svg(
201
+ face.title,
202
+ variables,
203
+ inner,
204
+ text_align,
205
+ prose=prose,
206
+ resolved_style=face.style,
207
+ )
208
+ # Paint the title fill from cascade-resolved face style so user overrides
209
+ # win over mdsvg's CSS-baked class fill. Same precedence as the non-inline
210
+ # path further down (style.title.font.color → style.color); both render
211
+ # paths produce identical title colors for the same face.
212
+ # TODO(audit-variable-controls-css-for-off-stack-font-sizes follow-up):
213
+ # this is a post-render painter sitting on top of mdsvg's heading-color
214
+ # path; the proper cascade fix in get_compact_style (style.title.font.color
215
+ # vs markdown_colors layer) is deferred to that task to bound blast radius.
216
+ title_color = face.style.title.font.color or face.style.color
217
+ if title_color:
218
+ title_svg_raw = _paint_title_svg_fill(title_svg_raw, str(title_color))
219
+ title_inner = extract_svg_inner_content(title_svg_raw)
220
+ title_h = max(
221
+ get_title_height(face.title, title_w, face.variable_defaults),
222
+ float(face.style.title.min_height),
223
+ )
224
+
225
+ if interactive:
226
+ vars_svg, vars_h = render_interactive_variables_svg(
227
+ face.visible_variables,
228
+ variables,
229
+ vars_w,
230
+ executor,
231
+ resolved_style=face.style,
232
+ )
233
+ else:
234
+ vars_svg, vars_h = render_variables_svg(
235
+ face.visible_variables,
236
+ variables,
237
+ vars_w,
238
+ resolved_style=face.style,
239
+ )
240
+ assert vs.font.size is not None, "style.variables.font.size must be configured"
241
+ title_dy, vars_dy, band_h = compute_title_variables_inline_baseline_layout(
242
+ title_h, vars_h, float(vs.font.size)
243
+ )
244
+ band = (
245
+ "<g>"
246
+ f'<g transform="translate({_px(card_pad)}, {_px(title_dy)})">{title_inner}</g>'
247
+ f'<g transform="translate({_px(card_pad + title_w + col_gap)}, {_px(vars_dy)})">{vars_svg}</g>'
248
+ "</g>"
249
+ )
250
+ return band, band_h
251
+
252
+
253
+ def _render_text_svg(
254
+ text: str,
255
+ variables: VariableValues,
256
+ width: float,
257
+ text_align: Literal["left", "center", "right"] = "left",
258
+ prose: bool = False,
259
+ resolved_style: "MergedStyle | None" = None,
260
+ text_style: "TextStyle | None" = None,
261
+ ) -> tuple[str, float]:
262
+ """Render markdown text with Jinja resolution and board-link rewriting.
263
+
264
+ Returns (svg_string, height).
265
+ """
266
+ from dataface.core.compile.config import get_config
267
+ from dataface.core.compile.models.primitives import FontStyle
268
+ from dataface.core.compile.models.style.merged import resolve_style
269
+ from dataface.core.compile.sizing import get_compact_style
270
+ from dataface.core.fonts import get_inter_font_path, get_mono_font_path
271
+ from dataface.core.render.board_links import get_link_context, rewrite_board_links
272
+ from mdsvg import parse as parse_markdown
273
+ from mdsvg.renderer import SVGRenderer
274
+
275
+ resolved = _resolve_jinja(text, variables)
276
+ resolved = rewrite_board_links(resolved, get_link_context())
277
+ if prose:
278
+ assert resolved_style is not None
279
+ prose_font: FontStyle | None = resolved_style.title.font
280
+ else:
281
+ prose_font = None
282
+
283
+ if text_style is not None and text_style.column.has_overrides:
284
+ return _render_columned_text_svg(
285
+ resolved, width, text_style, font=prose_font, resolved_style=resolved_style
286
+ )
287
+
288
+ font_family = prose_font.family if prose_font is not None else None
289
+ _ms = (
290
+ resolved_style
291
+ if resolved_style is not None
292
+ else resolve_style(get_config().style)
293
+ )
294
+ style = get_compact_style(_ms, text_align=text_align, font_family=font_family)
295
+ font_path = str(get_inter_font_path())
296
+ mono_font_path = str(get_mono_font_path())
297
+ # fetch_image_sizes=False: http:// image URLs in text blocks must not make
298
+ # synchronous network calls (10s urlopen timeout × N images blocks the
299
+ # asyncio event loop in dft serve). Pages that need accurate per-image
300
+ # sizing must embed dimensions in the markdown source via mdsvg's explicit
301
+ # ``![alt](url){width=X height=Y}`` syntax — pre-compute and inject at
302
+ # YAML-staging time, not at render time.
303
+ renderer = SVGRenderer(
304
+ style=style,
305
+ font_path=font_path,
306
+ mono_font_path=mono_font_path,
307
+ fetch_image_sizes=False,
308
+ )
309
+ blocks = list(parse_markdown(resolved))
310
+ size = renderer.measure(blocks, width=width, padding=0.0)
311
+ svg = renderer.render(blocks, width=width, padding=0.0)
312
+ return svg, size.height
313
+
314
+
315
+ def _render_columned_text_svg(
316
+ markdown_text: str,
317
+ width: float,
318
+ text_style: "TextStyle",
319
+ font: "FontStyle | None" = None,
320
+ resolved_style: "MergedStyle | None" = None,
321
+ ) -> tuple[str, float]:
322
+ """Render body-text markdown as pure SVG with column/align overrides.
323
+
324
+ Handles align and multi-column flow entirely in SVG —
325
+ no foreignObject, no browser HTML layout dependency.
326
+
327
+ Returns (svg_string, height).
328
+ """
329
+ from dataface.core.compile.config import get_config
330
+ from dataface.core.compile.models.style.merged import (
331
+ apply_emoji_to_family,
332
+ resolve_style,
333
+ )
334
+ from dataface.core.compile.sizing import get_compact_style, greedy_column_fill
335
+ from dataface.core.fonts import get_inter_font_path, get_mono_font_path
336
+ from mdsvg import parse as parse_markdown
337
+ from mdsvg.renderer import SVGRenderer
338
+
339
+ config = get_config()
340
+ col = text_style.column
341
+ column_gap = col.gap if col.gap is not None else float(config.style.layout.rows.gap)
342
+
343
+ _align: Literal["left", "center", "right"] = text_style.align
344
+ _root_family = config.style.font.family
345
+ assert _root_family is not None, "style.font.family must be configured"
346
+ _base_family = apply_emoji_to_family(_root_family, config.style.font.emoji)
347
+ effective_family = (font.family if font is not None else None) or _base_family
348
+
349
+ _ms = (
350
+ resolved_style
351
+ if resolved_style is not None
352
+ else resolve_style(get_config().style)
353
+ )
354
+ style = get_compact_style(_ms, text_align=_align, font_family=effective_family)
355
+ font_path = str(get_inter_font_path())
356
+ mono_font_path = str(get_mono_font_path())
357
+
358
+ n_cols = compute_text_column_count(col, width, column_gap)
359
+
360
+ # fetch_image_sizes=False: see comment in _render_text_svg.
361
+ renderer = SVGRenderer(
362
+ style=style,
363
+ font_path=font_path,
364
+ mono_font_path=mono_font_path,
365
+ fetch_image_sizes=False,
366
+ )
367
+
368
+ blocks = list(parse_markdown(markdown_text))
369
+
370
+ if n_cols <= 1:
371
+ result = renderer.render_content(blocks, width=width, padding=0.0)
372
+ w = _format_svg_numeric(width)
373
+ h = _format_svg_numeric(result.height)
374
+ svg = (
375
+ f'<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}">'
376
+ f"{result.content}</svg>"
377
+ )
378
+ return svg, result.height
379
+
380
+ per_col_width = max((width - (n_cols - 1) * column_gap) / n_cols, 1.0)
381
+
382
+ block_results = [
383
+ renderer.render_content([block], width=per_col_width, padding=0.0)
384
+ for block in blocks
385
+ ]
386
+
387
+ assignments, actual_col_height = greedy_column_fill(
388
+ [r.height for r in block_results], n_cols, style.paragraph_spacing
389
+ )
390
+
391
+ style_block = block_results[0].style_block if block_results else ""
392
+ svg_parts = [style_block]
393
+ for ci in range(n_cols):
394
+ col_x = ci * (per_col_width + column_gap)
395
+ col_elements = [
396
+ f'<g transform="translate(0, {_px(y)})">{r.elements}</g>'
397
+ for (c, y), r in zip(assignments, block_results, strict=True)
398
+ if c == ci
399
+ ]
400
+ if col_elements:
401
+ svg_parts.append(
402
+ f'<g transform="translate({_px(col_x)}, 0)">'
403
+ f'{"".join(col_elements)}</g>'
404
+ )
405
+ if col.rule and ci < n_cols - 1:
406
+ svg_parts.append(
407
+ _column_rule_line(
408
+ col_x + per_col_width + column_gap / 2, actual_col_height, col.rule
409
+ )
410
+ )
411
+
412
+ total_width = n_cols * per_col_width + (n_cols - 1) * column_gap
413
+ w = _format_svg_numeric(total_width)
414
+ h = _format_svg_numeric(actual_col_height)
415
+ svg = f'<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}">{"".join(svg_parts)}</svg>'
416
+ return svg, actual_col_height
417
+
418
+
419
+ _COLUMN_RULE_DASHARRAY = {"dotted": "2,2", "dashed": "6,3"}
420
+
421
+
422
+ def _column_rule_line(x: float, height: float, rule: str) -> str:
423
+ """Generate a vertical SVG line from a validated column-rule string (e.g. '1px solid #e5e7eb').
424
+
425
+ Input must have been validated by TextColumnStyle._validate_rule_parseable:
426
+ exactly one px width, exactly one hex color, at most one of solid/dashed/dotted.
427
+ """
428
+ parts = rule.strip().split()
429
+ stroke_width = next(float(p[:-2]) for p in parts if p.endswith("px"))
430
+ stroke_color = next(p for p in parts if p.startswith("#"))
431
+ stroke_style = next((p for p in parts if p in _COLUMN_RULE_DASHARRAY), "solid")
432
+ # Snap x to integer: a 1px structural mark at fractional x blurs horizontally.
433
+ x_snapped = _px(x)
434
+ h_s = _format_svg_numeric(height)
435
+ sw_s = _format_svg_numeric(stroke_width)
436
+ extra = (
437
+ f' stroke-dasharray="{_COLUMN_RULE_DASHARRAY[stroke_style]}"'
438
+ if stroke_style in _COLUMN_RULE_DASHARRAY
439
+ else ""
440
+ )
441
+ return (
442
+ f'<line x1="{x_snapped}" y1="0" x2="{x_snapped}" y2="{h_s}"'
443
+ f' stroke="{html.escape(stroke_color)}" stroke-width="{sw_s}"{extra}/>'
444
+ )
445
+
446
+
447
+ def _build_face_content_items(
448
+ x_offset: float,
449
+ y_offset: float,
450
+ gap: float,
451
+ title_svg: str,
452
+ title_height: float,
453
+ text_svg: str,
454
+ text_height: float,
455
+ variables_svg: str,
456
+ variables_height: float,
457
+ layout_content: str,
458
+ layout_content_height: float,
459
+ card_padding: float,
460
+ inline_header_svg: str = "",
461
+ inline_header_height: float = 0.0,
462
+ ) -> tuple[list[str], float]:
463
+ """Build inner content item list; return (items, items_height).
464
+
465
+ Applies gap between adjacent elements only — no trailing gap after the
466
+ last non-layout element when no layout items follow. This matches the
467
+ sizing pass formula: gap * (n - 1).
468
+
469
+ card_padding: when nonzero, title, text, and variables are inset by this
470
+ amount on the x-axis, matching the card_padding inset applied to chart
471
+ leaf items. Layout content keeps the base x_offset.
472
+ """
473
+ items: list[str] = []
474
+ items_height = y_offset
475
+ has_prev = False
476
+ content_x = x_offset + card_padding # title and text align with chart content
477
+
478
+ if inline_header_svg:
479
+ items.append(
480
+ f'<g transform="translate({_px(x_offset)}, {_px(items_height)})">{inline_header_svg}</g>'
481
+ )
482
+ items_height += inline_header_height
483
+ has_prev = True
484
+ elif title_svg:
485
+ items.append(
486
+ f'<g transform="translate({_px(content_x)}, {_px(items_height)})">{title_svg}</g>'
487
+ )
488
+ items_height += title_height
489
+ has_prev = True
490
+
491
+ if text_svg:
492
+ if has_prev:
493
+ items_height += gap
494
+ md = extract_svg_inner_content(text_svg)
495
+ if md:
496
+ items.append(
497
+ f'<g transform="translate({_px(content_x)}, {_px(items_height)})">{md}</g>'
498
+ )
499
+ items_height += text_height
500
+ has_prev = True
501
+
502
+ if variables_svg and not inline_header_svg:
503
+ if has_prev:
504
+ items_height += gap
505
+ items.append(
506
+ f'<g transform="translate({_px(content_x)}, {_px(items_height)})">{variables_svg}</g>'
507
+ )
508
+ items_height += variables_height
509
+ has_prev = True
510
+
511
+ if layout_content:
512
+ if has_prev:
513
+ items_height += gap
514
+ items.append(
515
+ f'<g transform="translate({_px(x_offset)}, {_px(items_height)})">{layout_content}</g>'
516
+ )
517
+
518
+ # Advance by the remaining layout space so the face fills its allocated slot.
519
+ # For nested faces: sizing deducted all non-layout heights to compute this value,
520
+ # so adding it back fills the slot exactly. For the root face: if non-layout
521
+ # content overflows (available goes negative), clamp to 0 — the SVG will grow
522
+ # via max(height, ...) in total_height.
523
+ items_height += max(layout_content_height, 0)
524
+
525
+ return items, items_height - y_offset
526
+
527
+
528
+ def _render_layout(
529
+ face: MergedFace,
530
+ executor: Executor,
531
+ variables: VariableValues,
532
+ layout_content_width: float,
533
+ layout_content_height: float,
534
+ card_gap: float,
535
+ gap: float,
536
+ background: str | None,
537
+ interactive: bool = True,
538
+ render_cache: dict[tuple[str, float, float], tuple[str, float]] | None = None,
539
+ error_collector: list[StructuredError] | None = None,
540
+ ) -> tuple[str, float]:
541
+ """Render layout based on type (single dispatch point).
542
+
543
+ This is the single source of truth for layout type dispatch.
544
+
545
+ Returns:
546
+ (svg_elements_string, actual_layout_height)
547
+ """
548
+ from dataface.core.render.layouts import (
549
+ render_cols_layout,
550
+ render_grid_layout,
551
+ render_rows_layout,
552
+ render_tabs_layout,
553
+ )
554
+
555
+ if not face.layout.items:
556
+ return "", 0.0
557
+
558
+ layout = face.layout
559
+ items = layout.items
560
+ # vega_config is baked by resolve_face/_build_resolved_nested_face — always non-empty.
561
+ _vega_config = face.vega_config
562
+ resolved_style = face.style # MergedFace.style is the MergedStyle
563
+
564
+ if layout.type == "cols":
565
+ result = render_cols_layout(
566
+ items,
567
+ executor,
568
+ variables,
569
+ layout_content_width,
570
+ layout_content_height,
571
+ card_gap,
572
+ gap,
573
+ background,
574
+ resolved_style,
575
+ interactive,
576
+ render_cache,
577
+ error_collector=error_collector,
578
+ face_level=face.level,
579
+ vega_config=_vega_config,
580
+ )
581
+ elif layout.type == "grid":
582
+ columns = layout.columns or resolved_style.layout.grid.columns
583
+ result = render_grid_layout(
584
+ items,
585
+ executor,
586
+ variables,
587
+ layout_content_width,
588
+ layout_content_height,
589
+ columns,
590
+ card_gap,
591
+ gap,
592
+ background,
593
+ resolved_style,
594
+ interactive,
595
+ render_cache,
596
+ error_collector=error_collector,
597
+ face_level=face.level,
598
+ vega_config=_vega_config,
599
+ )
600
+ elif layout.type == "tabs":
601
+ result = render_tabs_layout(
602
+ items,
603
+ executor,
604
+ variables,
605
+ layout_content_width,
606
+ layout_content_height,
607
+ card_gap,
608
+ list(layout.tab_titles),
609
+ list(layout.tab_slugs),
610
+ layout.tab_variable,
611
+ layout.default_tab or 0,
612
+ layout.tab_position or "top",
613
+ background,
614
+ resolved_style=resolved_style,
615
+ interactive=interactive,
616
+ render_cache=render_cache,
617
+ error_collector=error_collector,
618
+ face_level=face.level,
619
+ vega_config=_vega_config,
620
+ )
621
+ else:
622
+ # Default to rows (handles "rows" and any unknown type)
623
+ result = render_rows_layout(
624
+ items,
625
+ executor,
626
+ variables,
627
+ layout_content_width,
628
+ layout_content_height,
629
+ card_gap,
630
+ gap,
631
+ background,
632
+ resolved_style,
633
+ interactive,
634
+ render_cache,
635
+ error_collector=error_collector,
636
+ face_level=face.level,
637
+ vega_config=_vega_config,
638
+ )
639
+
640
+ return result
641
+
642
+
643
+ def render_face_svg(
644
+ face: MergedFace,
645
+ executor: Executor,
646
+ variables: VariableValues,
647
+ background: str | None,
648
+ grid: bool = False,
649
+ interactive: bool = True,
650
+ render_cache: dict[tuple[str, float, float], tuple[str, float]] | None = None,
651
+ margins: bool = False,
652
+ error_collector: list[StructuredError] | None = None,
653
+ ) -> str:
654
+ """Render face to SVG.
655
+
656
+ Walks the layout structure and renders each item based on layout type.
657
+
658
+ Args:
659
+ face: MergedFace with baked board config — no get_config() needed.
660
+ executor: Executor for query execution
661
+ variables: Variable values for queries
662
+ background: Background color or pattern URL
663
+ grid: Whether to show grid overlay pattern
664
+ interactive: Whether to render interactive variable controls using foreignObject.
665
+ Set to False for PNG/PDF export (svglib doesn't support foreignObject).
666
+ margins: Whether to show vertical margin guide lines
667
+
668
+ Returns:
669
+ Complete SVG string for the face
670
+ """
671
+ from dataface.core.compile.sizing import get_title_height
672
+ from dataface.core.render.svg_utils import create_grid_pattern, generate_svg_styles
673
+ from dataface.core.render.variable_controls import (
674
+ generate_svg_variable_script,
675
+ render_interactive_variables_svg,
676
+ render_variables_svg,
677
+ )
678
+
679
+ resolved_style = face.style # MergedFace.style is the MergedStyle
680
+ # Board config baked into MergedFace — no get_config() needed.
681
+ page_padding = face.page_padding or 0.0
682
+ card_pad = face.card_padding or 0.0
683
+ card_gap = face.card_gap or 0.0
684
+ # gap between layout items: MergedFace.layout.gap (set during resolve_face).
685
+ gap = face.layout.gap or 0.0
686
+ effective_gap = gap + card_gap
687
+
688
+ width = face.width
689
+ height = face.height
690
+
691
+ # Root content width should match the sizing pass exactly.
692
+ layout_content_width = face.layout.content_width
693
+ # Title and text render within a narrower width (inset by card_padding on both sides).
694
+ title_text_width = max((layout_content_width or 0.0) - 2 * card_pad, 0.0)
695
+ # Width is container-driven and precomputed during sizing; height remains
696
+ # content-driven and is finalized here after title/content/controls render.
697
+ layout_content_height = height - (2 * page_padding)
698
+ # Track whether any element has been placed so we only insert a gap *between*
699
+ # adjacent elements — matching the sizing pass's gap*(n-1) formula exactly.
700
+ # Note: the root face renders title/content/variable heights dynamically here
701
+ # (rather than reading pre-computed values from sizing) because the root face
702
+ # is the source of truth for its own height — it has no pre-allocated slot.
703
+ # Nested faces follow the opposite contract (see render_nested_face).
704
+ has_prev = False
705
+
706
+ text_align = resolved_style.text.align
707
+
708
+ from dataface.core.compile.typography import is_prose
709
+
710
+ prose = is_prose(face.text) if face.text else False
711
+
712
+ inline_header_svg = ""
713
+ inline_header_height = 0.0
714
+ title_svg = ""
715
+ title_height = 0.0
716
+
717
+ if _face_uses_title_inline_band(face):
718
+ inline_header_svg, inline_header_height = _render_title_variables_inline_band(
719
+ face,
720
+ variables,
721
+ layout_content_width,
722
+ card_pad,
723
+ interactive,
724
+ executor,
725
+ text_align=text_align,
726
+ prose=prose,
727
+ )
728
+ layout_content_height -= inline_header_height
729
+ has_prev = True
730
+ elif face.title:
731
+ title_svg = _render_title_svg(
732
+ face.title,
733
+ variables,
734
+ title_text_width or layout_content_width,
735
+ text_align=text_align,
736
+ level=face.level,
737
+ prose=prose,
738
+ resolved_style=resolved_style,
739
+ )
740
+ # Same paint precedence as the inline-band path (style.title.font.color
741
+ # → style.color); both render paths produce identical title colors for
742
+ # the same face style, regardless of variables.position.
743
+ title_color = resolved_style.title.font.color or resolved_style.color
744
+ if title_color:
745
+ title_svg = _paint_title_svg_fill(title_svg, str(title_color))
746
+ title_height = get_title_height(
747
+ face.title,
748
+ title_text_width or layout_content_width,
749
+ face.variable_defaults,
750
+ level=face.level,
751
+ )
752
+ title_height = max(title_height, float(face.style.title.min_height))
753
+ layout_content_height -= title_height
754
+ has_prev = True
755
+
756
+ # Render text (markdown) if present (using shared helper)
757
+ text_svg = ""
758
+ text_height = 0.0
759
+ if face.text:
760
+ text_svg, text_height = _render_text_svg(
761
+ face.text,
762
+ variables,
763
+ title_text_width or layout_content_width,
764
+ text_align=text_align,
765
+ prose=prose,
766
+ resolved_style=resolved_style,
767
+ text_style=resolved_style.text,
768
+ )
769
+ if has_prev:
770
+ layout_content_height -= effective_gap
771
+ layout_content_height -= text_height
772
+ has_prev = True
773
+
774
+ # Render variable controls for root-level variables only (skip when merged into title band).
775
+ variables_svg = ""
776
+ variables_height = 0.0
777
+ variables_script = ""
778
+ chart_interactivity_script = ""
779
+ # Variable controls are inset by card_pad on the left (aligned with chart
780
+ # content); reduce width by the same amount so the right edge is unchanged.
781
+ variables_width = max(layout_content_width - card_pad, 0.0)
782
+ if face.visible_variables and variables and not inline_header_svg:
783
+ if interactive:
784
+ variables_svg, variables_height = render_interactive_variables_svg(
785
+ face.visible_variables,
786
+ variables,
787
+ variables_width,
788
+ executor,
789
+ resolved_style=resolved_style,
790
+ )
791
+ else:
792
+ variables_svg, variables_height = render_variables_svg(
793
+ face.visible_variables,
794
+ variables,
795
+ variables_width,
796
+ resolved_style=resolved_style,
797
+ )
798
+ if variables_height > 0:
799
+ if has_prev:
800
+ layout_content_height -= effective_gap
801
+ layout_content_height -= variables_height
802
+ has_prev = True
803
+
804
+ # Gap before layout items (only when something precedes them)
805
+ if face.layout.items and has_prev:
806
+ layout_content_height -= effective_gap
807
+
808
+ if interactive:
809
+ # Always embed variables.js for interactive SVG: it handles both
810
+ # variable control updates and SVG <a href="?..."> tab navigation.
811
+ # The tab click interception is required even when no variable controls
812
+ # are declared (blob-URL iframes can't navigate to query-string URLs).
813
+ variables_script = generate_svg_variable_script()
814
+ chart_interactivity_script = generate_svg_chart_interactivity_script(
815
+ resolved_style=resolved_style
816
+ )
817
+
818
+ # Render layout (using shared helper)
819
+ layout_content, actual_layout_height = _render_layout(
820
+ face,
821
+ executor,
822
+ variables,
823
+ layout_content_width,
824
+ layout_content_height,
825
+ card_gap,
826
+ gap,
827
+ resolved_style.background,
828
+ interactive,
829
+ render_cache,
830
+ error_collector=error_collector,
831
+ )
832
+
833
+ # Combine title, content, variables, and layout into positioned groups.
834
+ # Gap is added only between adjacent elements (matches sizing's gap*(n-1) formula).
835
+ # Use actual_layout_height (from rendered content) rather than the pre-computed
836
+ # layout_content_height so the root face SVG grows to fit Vega charts.
837
+ content_items, content_items_height = _build_face_content_items(
838
+ x_offset=page_padding,
839
+ y_offset=page_padding,
840
+ gap=effective_gap,
841
+ title_svg=title_svg,
842
+ title_height=title_height,
843
+ text_svg=text_svg,
844
+ text_height=text_height,
845
+ variables_svg=variables_svg,
846
+ variables_height=variables_height,
847
+ layout_content=layout_content,
848
+ layout_content_height=actual_layout_height,
849
+ card_padding=card_pad,
850
+ inline_header_svg=inline_header_svg,
851
+ inline_header_height=inline_header_height,
852
+ )
853
+
854
+ from dataface.core.compile.config import get_rendering_config
855
+
856
+ rendering_cfg = get_rendering_config()
857
+
858
+ # Calculate final dimensions
859
+ total_width = max(width, layout_content_width + (2 * page_padding))
860
+ total_height = max(height, content_items_height + (2 * page_padding))
861
+
862
+ # Reserve vertical space for the page footer (right-aligned text, optional
863
+ # hairline rule above) so it doesn't overlap the bottom card.
864
+ if rendering_cfg.footer_text:
865
+ # FooterConfig._require_font_size_and_color guarantees non-None.
866
+ assert rendering_cfg.footer.font.size is not None
867
+ footer_font_size = float(rendering_cfg.footer.font.size)
868
+ rule_gap = FOOTER_RULE_GAP_PX if rendering_cfg.footer.rule is not None else 0
869
+ total_height += rendering_cfg.footer.y_offset + footer_font_size + rule_gap
870
+
871
+ # Grid pattern if enabled
872
+ grid_defs = ""
873
+ if grid:
874
+ grid_defs = create_grid_pattern()
875
+ background = "url(#grid-pattern)"
876
+
877
+ # Background rect
878
+ bg_rect = ""
879
+ if background:
880
+ bg_rect = f'<rect x="0" y="0" width="{total_width}" height="{total_height}" fill="{html.escape(background)}"/>'
881
+
882
+ # Margin guide lines (print-media style alignment guides)
883
+ margin_lines = ""
884
+ if margins:
885
+ margin_color = "rgba(219, 112, 147, 0.45)"
886
+ inset = 16
887
+ left = page_padding
888
+ right = total_width - page_padding
889
+ lines = [
890
+ f'<line x1="{_format_svg_numeric(left)}" y1="0" x2="{_format_svg_numeric(left)}" y2="{total_height}" stroke="{margin_color}" stroke-width="1"/>',
891
+ f'<line x1="{_format_svg_numeric(left + inset)}" y1="0" x2="{_format_svg_numeric(left + inset)}" y2="{total_height}" stroke="{margin_color}" stroke-width="1"/>',
892
+ f'<line x1="{_format_svg_numeric(right)}" y1="0" x2="{_format_svg_numeric(right)}" y2="{total_height}" stroke="{margin_color}" stroke-width="1"/>',
893
+ f'<line x1="{_format_svg_numeric(right - inset)}" y1="0" x2="{_format_svg_numeric(right - inset)}" y2="{total_height}" stroke="{margin_color}" stroke-width="1"/>',
894
+ ]
895
+ margin_lines = "\n".join(lines)
896
+
897
+ from dataface.core.compile.config import get_config
898
+
899
+ svg_styles = generate_svg_styles(emoji_mode=get_config().style.font.emoji)
900
+
901
+ svg_id_hash = hashlib.md5(f"{total_width}x{total_height}".encode()).hexdigest()[
902
+ :SVG_ID_HASH_LENGTH
903
+ ]
904
+ svg_id = f"dataface-svg-{svg_id_hash}"
905
+
906
+ render_time = datetime.now(timezone.utc)
907
+ render_timestamp_iso = render_time.strftime("%Y-%m-%dT%H:%M:%SZ")
908
+
909
+ timestamp_element = ""
910
+ if rendering_cfg.timestamp_visible:
911
+ timestamp_format = rendering_cfg.timestamp_format
912
+ timestamp_config = rendering_cfg.timestamp
913
+ # TimestampConfig's _require_font_size_and_color model validator
914
+ # guarantees both are non-None at config load; narrow for mypy.
915
+ assert timestamp_config.font.size is not None
916
+ assert timestamp_config.font.color is not None
917
+ display_timestamp = render_time.strftime(timestamp_format)
918
+ timestamp_x = total_width - page_padding
919
+ timestamp_y = timestamp_config.y
920
+ timestamp_element = (
921
+ f'<text data-role="render-timestamp" x="{_format_svg_numeric(timestamp_x)}" y="{_format_svg_numeric(timestamp_y)}" text-anchor="end" '
922
+ f'font-size="{_format_svg_numeric(float(timestamp_config.font.size))}" fill="{timestamp_config.font.color}" font-family="{face.style.font.family}" '
923
+ f'style="font-variant-numeric: tabular-nums lining-nums;">'
924
+ f"{html.escape(display_timestamp)}</text>"
925
+ )
926
+
927
+ footer_element = ""
928
+ if rendering_cfg.footer_text:
929
+ footer_cfg = rendering_cfg.footer
930
+ # FooterConfig._require_font_size_and_color guarantees both non-None.
931
+ assert footer_cfg.font.size is not None
932
+ assert footer_cfg.font.color is not None
933
+ footer_x = total_width - page_padding
934
+ footer_y = total_height - footer_cfg.y_offset
935
+ footer_parts = []
936
+ if footer_cfg.rule is not None:
937
+ rule_y = footer_y - float(footer_cfg.font.size) - FOOTER_RULE_GAP_PX
938
+ footer_parts.append(
939
+ f'<line x1="{_format_svg_numeric(page_padding)}" y1="{_format_svg_numeric(rule_y)}" '
940
+ f'x2="{_format_svg_numeric(footer_x)}" y2="{_format_svg_numeric(rule_y)}" '
941
+ f'stroke="{footer_cfg.rule.color}" stroke-width="{_format_svg_numeric(footer_cfg.rule.stroke_width)}"/>'
942
+ )
943
+ footer_parts.append(
944
+ f'<text x="{_format_svg_numeric(footer_x)}" y="{_format_svg_numeric(footer_y)}" text-anchor="end" '
945
+ f'font-size="{_format_svg_numeric(float(footer_cfg.font.size))}" fill="{footer_cfg.font.color}" font-family="{face.style.font.family}">'
946
+ f"{html.escape(rendering_cfg.footer_text)}</text>"
947
+ )
948
+ footer_element = "\n".join(footer_parts)
949
+
950
+ return f"""<svg id="{svg_id}" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {total_width} {total_height}" width="100%" preserveAspectRatio="xMinYMin meet" style="display: block;" data-rendered-at="{render_timestamp_iso}">
951
+ <defs>
952
+ {grid_defs}
953
+ {svg_styles}
954
+ </defs>
955
+ {bg_rect}
956
+ {"".join(content_items)}
957
+ {margin_lines}
958
+ {timestamp_element}
959
+ {footer_element}
960
+ {variables_script}
961
+ {chart_interactivity_script}
962
+ </svg>"""
963
+
964
+
965
+ def render_nested_face(
966
+ face: MergedFace,
967
+ executor: Executor,
968
+ variables: VariableValues,
969
+ available_width: float,
970
+ available_height: float,
971
+ card_gap: float,
972
+ interactive: bool = True,
973
+ render_cache: dict[tuple[str, float, float], tuple[str, float]] | None = None,
974
+ error_collector: list[StructuredError] | None = None,
975
+ ) -> tuple[str, float]:
976
+ """Render a nested face.
977
+
978
+ Args:
979
+ face: MergedFace for the nested face (board config fields are None).
980
+ executor: Executor for query execution
981
+ variables: Variable values for queries
982
+ available_width: Width in pixels for the face
983
+ card_gap: Gap between cards (inter-item spacing)
984
+ interactive: Whether to render interactive variable controls (foreignObject).
985
+ Set to False for PNG/PDF export.
986
+ available_height: Pre-allocated slot height from parent layout (e.g. cols max
987
+ height). When provided, the face renders at least this tall so
988
+ all siblings in a cols row share the same height.
989
+
990
+ Returns:
991
+ (svg_string, actual_height) — actual_height is derived from rendered content.
992
+ """
993
+
994
+ resolved_style = face.style # MergedFace.style is the MergedStyle
995
+
996
+ gap = resolved_style.gap if resolved_style.gap is not None else 0.0
997
+ ep = _effective_padding(resolved_style)
998
+
999
+ # Trust the normalizer - content dimensions are always set by sizing.py
1000
+ layout_content_width = face.layout.content_width
1001
+ layout_content_height = face.layout.content_height
1002
+
1003
+ card_pad = float(resolved_style.board.card_padding)
1004
+ title_text_width = max(layout_content_width - 2 * card_pad, 0.0)
1005
+
1006
+ face_width = available_width - (
1007
+ resolved_style.margin.horizontal if resolved_style.margin else 0.0
1008
+ )
1009
+ text_align = resolved_style.text.align
1010
+
1011
+ from dataface.core.compile.typography import is_prose
1012
+
1013
+ prose = is_prose(face.text) if face.text else False
1014
+
1015
+ inline_header_svg = ""
1016
+ inline_header_height = 0.0
1017
+ title_svg = ""
1018
+ title_height = 0.0
1019
+ if _face_uses_title_inline_band(face):
1020
+ inline_header_svg, inline_header_height = _render_title_variables_inline_band(
1021
+ face,
1022
+ variables,
1023
+ layout_content_width,
1024
+ card_pad,
1025
+ interactive,
1026
+ executor,
1027
+ text_align=text_align,
1028
+ prose=prose,
1029
+ )
1030
+ elif face.title:
1031
+ from dataface.core.compile.sizing import get_title_height
1032
+
1033
+ title_svg = _render_title_svg(
1034
+ face.title,
1035
+ variables,
1036
+ title_text_width or layout_content_width,
1037
+ text_align,
1038
+ level=face.level,
1039
+ prose=prose,
1040
+ resolved_style=resolved_style,
1041
+ )
1042
+ title_height = max(
1043
+ get_title_height(
1044
+ face.title,
1045
+ title_text_width or layout_content_width,
1046
+ face.variable_defaults,
1047
+ level=face.level,
1048
+ ),
1049
+ float(face.style.title.min_height),
1050
+ )
1051
+
1052
+ # Render text (markdown) if present (using shared helper)
1053
+ text_svg = ""
1054
+ text_height = 0.0
1055
+ if face.text:
1056
+ text_svg, text_height = _render_text_svg(
1057
+ face.text,
1058
+ variables,
1059
+ title_text_width or layout_content_width,
1060
+ text_align=text_align,
1061
+ prose=prose,
1062
+ resolved_style=resolved_style,
1063
+ text_style=resolved_style.text,
1064
+ )
1065
+
1066
+ # Render variable controls for this nested face's local variables.
1067
+ # Interactive: foreignObject HTML controls (variables.js in the root SVG exposes
1068
+ # window.updateVariable globally, accessible from all foreignObject contexts).
1069
+ # Non-interactive (PNG/PDF): plain SVG text labels showing current values.
1070
+ nested_variables_svg = ""
1071
+ nested_variables_height = 0.0
1072
+ # Variable controls are inset by card_pad on the left (aligned with chart
1073
+ # content); reduce width by the same amount so the right edge is unchanged.
1074
+ nested_variables_width = max(layout_content_width - card_pad, 0.0)
1075
+ if face.visible_variables and variables and not inline_header_svg:
1076
+ from dataface.core.render.variable_controls import (
1077
+ render_interactive_variables_svg,
1078
+ render_variables_svg,
1079
+ )
1080
+
1081
+ if interactive:
1082
+ nested_variables_svg, nested_variables_height = (
1083
+ render_interactive_variables_svg(
1084
+ face.visible_variables,
1085
+ variables,
1086
+ nested_variables_width,
1087
+ executor,
1088
+ resolved_style=resolved_style,
1089
+ )
1090
+ )
1091
+ else:
1092
+ nested_variables_svg, nested_variables_height = render_variables_svg(
1093
+ face.visible_variables,
1094
+ variables,
1095
+ nested_variables_width,
1096
+ resolved_style=resolved_style,
1097
+ )
1098
+
1099
+ # _calculate_nested_face_layout deducted variable controls height from
1100
+ # face.layout.content_height. Pass that as the layout slot hint. The actual
1101
+ # rendered height is read back from the layout renderer below.
1102
+ authored_bg = resolved_style.background
1103
+ layout_content, actual_layout_height = _render_layout(
1104
+ face,
1105
+ executor,
1106
+ variables,
1107
+ layout_content_width,
1108
+ layout_content_height,
1109
+ card_gap,
1110
+ gap,
1111
+ authored_bg,
1112
+ interactive,
1113
+ render_cache,
1114
+ error_collector=error_collector,
1115
+ )
1116
+
1117
+ # Apply face-level title color overrides before building items. Same
1118
+ # precedence as the inline-band path: `style.title.font.color` first
1119
+ # (the canonical "face title color" knob), `style.color` as a face-wide
1120
+ # ink fallback. Painting happens AFTER mdsvg renders so the override
1121
+ # wins over mdsvg's CSS-baked class fill regardless of upstream cascade
1122
+ # quirks in get_compact_style.
1123
+ title_color = resolved_style.title.font.color or resolved_style.color
1124
+ if title_color and title_svg:
1125
+ title_svg = _paint_title_svg_fill(title_svg, str(title_color))
1126
+
1127
+ # Combine title, text, variables, and layout into positioned groups.
1128
+ # Gap is added only between adjacent elements (matches sizing's gap*(n-1) formula).
1129
+ # Title positioning: mdsvg handles x position and text-anchor from text_align.
1130
+ # Use actual_layout_height (from rendered content) so Vega charts that render
1131
+ # taller than their pre-computed slot expand the face rather than being clipped.
1132
+ inner_items, inner_items_height = _build_face_content_items(
1133
+ x_offset=ep.left,
1134
+ y_offset=ep.top,
1135
+ gap=gap,
1136
+ title_svg=title_svg,
1137
+ title_height=title_height,
1138
+ text_svg=text_svg,
1139
+ text_height=text_height,
1140
+ variables_svg=nested_variables_svg,
1141
+ variables_height=nested_variables_height,
1142
+ layout_content=layout_content,
1143
+ layout_content_height=actual_layout_height,
1144
+ card_padding=card_pad,
1145
+ inline_header_svg=inline_header_svg,
1146
+ inline_header_height=inline_header_height,
1147
+ )
1148
+
1149
+ # Compute face dimensions from actual rendered content, floored at the pre-allocated
1150
+ # slot height from the parent layout (e.g. cols max height) so all siblings share
1151
+ # the same height. Content overflow still expands beyond the slot.
1152
+ natural_face_height = ep.top + inner_items_height + ep.bottom
1153
+ face_margin_vertical = (
1154
+ resolved_style.margin.vertical if resolved_style.margin else 0.0
1155
+ )
1156
+ face_margin_left = resolved_style.margin.left if resolved_style.margin else 0.0
1157
+ face_margin_top = resolved_style.margin.top if resolved_style.margin else 0.0
1158
+ allocated_face_height = max(available_height - face_margin_vertical, 0.0)
1159
+ face_height = max(natural_face_height, allocated_face_height)
1160
+ total_svg_width = available_width
1161
+ total_svg_height = face_height + face_margin_vertical
1162
+
1163
+ bg_rect = ""
1164
+ border_rect = ""
1165
+ border_radius = resolved_style.border.radius
1166
+
1167
+ if authored_bg:
1168
+ bg = html.escape(str(authored_bg))
1169
+ bg_rect = f'<rect x="0" y="0" width="{face_width}" height="{face_height}" fill="{bg}" rx="{border_radius}"/>'
1170
+
1171
+ border_width = resolved_style.border.width
1172
+ border_color = resolved_style.border.color
1173
+ if border_width > 0 and border_color:
1174
+ stroke_width = border_width
1175
+ stroke_color = html.escape(border_color)
1176
+ stroke_inset = stroke_width / 2.0
1177
+ border_rect = (
1178
+ f'<rect x="{stroke_inset}" y="{stroke_inset}" '
1179
+ f'width="{max(face_width - stroke_width, 0)}" '
1180
+ f'height="{max(face_height - stroke_width, 0)}" '
1181
+ f'fill="none" stroke="{stroke_color}" stroke-width="{stroke_width}" '
1182
+ f'rx="{max(border_radius - stroke_inset, 0)}"/>'
1183
+ )
1184
+
1185
+ face_group = f"""<g transform="translate({_px(face_margin_left)}, {_px(face_margin_top)})">
1186
+ {bg_rect}
1187
+ {border_rect}
1188
+ {"".join(inner_items)}
1189
+ </g>"""
1190
+
1191
+ svg = f"""<svg width="{total_svg_width}" height="{total_svg_height}" viewBox="0 0 {total_svg_width} {total_svg_height}">
1192
+ {face_group}
1193
+ </svg>"""
1194
+ return svg, total_svg_height