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,1452 @@
1
+ """Shared table layout and style helpers for SVG rendering."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime
6
+ import re
7
+ from collections.abc import Iterator, Sequence
8
+ from typing import TYPE_CHECKING, Any, Literal, cast
9
+
10
+ from dataface.core.compile.models.chart.authored import (
11
+ coerce_numeric,
12
+ match_predicate,
13
+ )
14
+ from dataface.core.compile.models.primitives import FontStyle
15
+ from dataface.core.compile.models.style.compiled import (
16
+ TableChartStylePatch,
17
+ font_weight_as_css,
18
+ )
19
+ from dataface.core.compile.palette import palette as _resolve_named_palette
20
+ from dataface.core.render.board_links import get_link_context, resolve_href
21
+ from dataface.core.render.chart.title_overflow import (
22
+ TitleOverflowMode,
23
+ prepare_title_text,
24
+ resolve_title_overflow,
25
+ )
26
+ from dataface.core.render.format_utils import format_value, resolve_format
27
+ from dataface.core.render.text.case import CaseValue, apply_case
28
+ from dataface.core.render.utils import slug_to_text
29
+
30
+ # Three-lane numeric rendering constants used by table.py:_compute_lane_positions.
31
+ _WORD_SUFFIXES: frozenset[str] = frozenset({"K", "M", "B", "T", "k", "mn", "bn", "tr"})
32
+
33
+ # Swatch column geometry. The rect is 14px square (table.py _SWATCH_SIZE);
34
+ # _SWATCH_CELL_DEMAND adds 4px breathing room on each side so a swatch column's
35
+ # demand/floor reflects the painted rect, not the hex-color string the cell
36
+ # carries. Lives here (not in table.py) so the measurement helpers below can
37
+ # read it without a circular import.
38
+ _SWATCH_CELL_DEMAND = 18 # _SWATCH_SIZE (14) + 4px breathing room
39
+
40
+
41
+ if TYPE_CHECKING:
42
+ from dataface.core.compile.models.chart.authored import (
43
+ ConditionalRule,
44
+ ScaleTargetConfig,
45
+ TableColumnConfig,
46
+ )
47
+ from dataface.core.compile.models.primitives import FormatConfig
48
+ from dataface.core.compile.models.style.compiled import TableColumnDefaultsConfig
49
+
50
+ _TEMPLATE_RE = re.compile(r"\{\{\s*(\w+)\s*\}\}")
51
+
52
+ # Date pattern used by is_date_like (cell scope, re-exported into table.py).
53
+ _DATE_RE = re.compile(
54
+ r"^\d{4}-\d{2}(-\d{2})?$" # ISO: 2024-03 or 2024-03-15
55
+ r"|^\d{2}/\d{2}/\d{4}$" # US: 03/15/2024
56
+ r"|^Q[1-4]\s+\d{4}$" # Quarter: Q1 2024
57
+ r"|^(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{1,2}$"
58
+ r"|^(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{1,2},?\s+\d{4}$"
59
+ r"|^\d{1,2}\s+(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{4}$"
60
+ r"|^\d{4}$" # Year: 2024
61
+ )
62
+
63
+
64
+ def is_date_like(value: Any) -> bool:
65
+ """Return True for date-like values: Python date/datetime objects or
66
+ string values matching date patterns.
67
+
68
+ Recognized string patterns: ISO (2024-03, 2024-03-15), US (03/15/2024),
69
+ short month name (Mar 15), long form (Mar 15, 2024), euro
70
+ (15 Mar 2024), quarter (Q1 2024), year (2024).
71
+
72
+ Accepts datetime.date / datetime.datetime directly so that layout
73
+ decisions (right-align, tabular font, no-wrap) apply to native Python
74
+ temporal objects returned by DuckDB fetchall(), not just pre-formatted
75
+ strings.
76
+
77
+ Cell-scope date detector used by the table renderer.
78
+ """
79
+ if isinstance(value, bool):
80
+ return False
81
+ if isinstance(value, (datetime.date, datetime.datetime)):
82
+ return True
83
+ if not isinstance(value, str) or not value:
84
+ return False
85
+ return bool(_DATE_RE.match(value.strip()))
86
+
87
+
88
+ # Matches ISO date "YYYY-MM-DD", ISO datetime "YYYY-MM-DDTHH:MM:SS[...]",
89
+ # and Postgres-style space-separated timestamps "YYYY-MM-DD HH:MM:SS[...]".
90
+ # Does NOT match bare year strings like "2024" (those are numeric).
91
+ _ISO_TEMPORAL_RE = re.compile(
92
+ r"^\d{4}-\d{2}-\d{2}" # date part: YYYY-MM-DD
93
+ r"([T ]\d{2}:\d{2}:\d{2}(\.\d+)?" # optional time (T or space separator)
94
+ r"(Z|[+-]\d{2}:?\d{2})?)?$" # optional TZ suffix
95
+ )
96
+
97
+ # Valid strftime/d3-time-format directive letters. Padding modifiers (-, _, 0)
98
+ # between % and the letter are allowed (e.g. %-d, %_d, %0H).
99
+ # %% is a literal-percent escape and is validated separately.
100
+ _VALID_STRFTIME_DIRECTIVES: frozenset[str] = frozenset(
101
+ "aAbBcCdDeEfFgGhHIjklmMnOpPqrRsSTuUVwWxXyYzZ"
102
+ )
103
+
104
+ # Matches a % directive sequence: optional padding modifier + letter.
105
+ _STRFTIME_DIRECTIVE_RE = re.compile(r"%[-_0]?(.)")
106
+
107
+
108
+ def is_temporal_value(value: Any) -> bool:
109
+ """Return True for Python date/datetime objects and ISO temporal strings.
110
+
111
+ Covers:
112
+ - datetime.date and datetime.datetime (including timezone-aware)
113
+ - ISO strings "YYYY-MM-DD" and "YYYY-MM-DDTHH:MM:SS[...]"
114
+ - Postgres-style space-separated timestamps "YYYY-MM-DD HH:MM:SS[...]"
115
+
116
+ Does NOT match bare year strings like "2024" (those are numeric).
117
+ TIME/INTERVAL values from DuckDB pass through str() — they are not
118
+ date-like in the calendar sense and strftime would crash on them.
119
+
120
+ Applies post-regex calendar validation so "2024-13-99" (regex-matching
121
+ but logically invalid) returns False rather than causing a ValueError
122
+ at format time.
123
+ """
124
+ if isinstance(value, bool):
125
+ return False
126
+ if isinstance(value, (datetime.date, datetime.datetime)):
127
+ return True
128
+ if not isinstance(value, str) or len(value) < 10:
129
+ return False
130
+ stripped = value.strip()
131
+ if not _ISO_TEMPORAL_RE.match(stripped):
132
+ return False
133
+ # Post-regex: validate the date part is a real calendar date.
134
+ date_part = stripped[:10]
135
+ try:
136
+ datetime.date.fromisoformat(date_part)
137
+ except ValueError:
138
+ return False
139
+ return True
140
+
141
+
142
+ def _validate_strftime_spec(format_spec: str) -> None:
143
+ """Raise ValueError for format specs containing unknown % directives.
144
+
145
+ Python's strftime silently passes unknown directives through (e.g. %Q → Q).
146
+ We reject them explicitly so authors get a clear error rather than garbled output.
147
+
148
+ ``%%`` (literal percent escape) is accepted and stripped before validation.
149
+ """
150
+ stripped = format_spec.replace("%%", "")
151
+ for m in _STRFTIME_DIRECTIVE_RE.finditer(stripped):
152
+ letter = m.group(1)
153
+ if letter not in _VALID_STRFTIME_DIRECTIVES:
154
+ raise ValueError(
155
+ f"Invalid temporal format spec {format_spec!r}: "
156
+ f"unknown directive %{letter!r}"
157
+ )
158
+
159
+
160
+ def _format_temporal_value(
161
+ value: datetime.date | datetime.datetime | str,
162
+ format_spec: str,
163
+ ) -> str:
164
+ """Apply a strftime format spec to a temporal value.
165
+
166
+ Args:
167
+ value: Python date/datetime or ISO temporal string.
168
+ format_spec: strftime-style format string (e.g. "%-d %b %Y").
169
+
170
+ Returns:
171
+ Formatted date string.
172
+
173
+ Raises:
174
+ ValueError: If the format spec contains an invalid/unknown directive.
175
+ """
176
+ _validate_strftime_spec(format_spec)
177
+
178
+ if isinstance(value, str):
179
+ # Parse ISO string to a date object so strftime works uniformly.
180
+ # fromisoformat() on 3.10 doesn't handle the "Z" suffix — normalise it first.
181
+ # Space-separated Postgres timestamps ("YYYY-MM-DD HH:MM:SS") are normalised
182
+ # to ISO T-separator before parsing.
183
+ stripped = value.strip().replace("Z", "+00:00")
184
+ # Normalise space-separated timestamp to T-separator for fromisoformat().
185
+ if len(stripped) > 10 and stripped[10] == " ":
186
+ stripped = stripped[:10] + "T" + stripped[11:]
187
+ if "T" in stripped:
188
+ dt = datetime.datetime.fromisoformat(stripped)
189
+ if dt.tzinfo is not None:
190
+ d: datetime.date = dt.astimezone(datetime.timezone.utc).date()
191
+ else:
192
+ d = dt.date()
193
+ else:
194
+ d = datetime.date.fromisoformat(stripped[:10])
195
+ elif isinstance(value, datetime.datetime):
196
+ # Aware datetime: convert to UTC first to match ISO string behavior.
197
+ if value.tzinfo is not None:
198
+ d = value.astimezone(datetime.timezone.utc).date()
199
+ else:
200
+ d = value.date()
201
+ else:
202
+ d = value
203
+
204
+ try:
205
+ return d.strftime(format_spec)
206
+ except ValueError as e:
207
+ raise ValueError(f"Invalid temporal format spec {format_spec!r}: {e}") from e
208
+
209
+
210
+ def _apply_column_defaults(
211
+ cfg: TableColumnConfig,
212
+ defaults: TableColumnDefaultsConfig,
213
+ ) -> TableColumnConfig:
214
+ updates = {
215
+ name: getattr(defaults, name)
216
+ for name in type(defaults).model_fields
217
+ if getattr(cfg, name) is None and getattr(defaults, name) is not None
218
+ }
219
+ return cfg.model_copy(update=updates) if updates else cfg
220
+
221
+
222
+ def parse_table_column_configs(
223
+ style: TableChartStylePatch | None,
224
+ query_columns: list[str] | None = None,
225
+ ) -> dict[str, TableColumnConfig]:
226
+ """Parse column configurations from chart style into typed models.
227
+
228
+ When column_defaults is set and columns is unset, query_columns is used to
229
+ materialize a default config for every query-inferred column — enabling the
230
+ "no columns list" authoring shape where uniform presentation is set once at
231
+ the table style level.
232
+ """
233
+ from dataface.core.compile.models.chart.authored import (
234
+ SparkConfig,
235
+ SparkTypeLiteral,
236
+ TableColumnConfig,
237
+ )
238
+
239
+ if not style:
240
+ return {}
241
+
242
+ columns_config = style.columns
243
+ defaults = style.column_defaults
244
+
245
+ # No explicit columns: if column_defaults is set, materialize defaults for
246
+ # every query-inferred column so authors need not list columns at all.
247
+ if not columns_config:
248
+ if defaults and query_columns:
249
+ return {
250
+ col: _apply_column_defaults(TableColumnConfig(), defaults)
251
+ for col in query_columns
252
+ }
253
+ return {}
254
+
255
+ result: dict[str, TableColumnConfig] = {}
256
+ for field_name, col_cfg in columns_config.items():
257
+ if isinstance(col_cfg, TableColumnConfig):
258
+ cfg = col_cfg
259
+ elif isinstance(col_cfg, dict):
260
+ cfg = TableColumnConfig.model_validate(col_cfg)
261
+ else:
262
+ continue
263
+ # Normalize spark string shorthand to SparkConfig so render code can
264
+ # use isinstance(cfg.spark, SparkConfig) uniformly.
265
+ if isinstance(cfg.spark, str):
266
+ cfg = cfg.model_copy(
267
+ update={"spark": SparkConfig(type=cast("SparkTypeLiteral", cfg.spark))}
268
+ )
269
+ result[field_name] = _apply_column_defaults(cfg, defaults) if defaults else cfg
270
+
271
+ return result
272
+
273
+
274
+ def parse_column_width(
275
+ width_hint: int | str | None, available_width: float
276
+ ) -> float | None:
277
+ """Resolve a column width hint into pixels."""
278
+ if width_hint is None:
279
+ return None
280
+
281
+ if isinstance(width_hint, int):
282
+ return float(width_hint) if width_hint > 0 else None
283
+
284
+ width_str = str(width_hint).strip()
285
+ if not width_str:
286
+ return None
287
+
288
+ if width_str.endswith("%"):
289
+ try:
290
+ pct = float(width_str[:-1])
291
+ except ValueError:
292
+ return None
293
+ return available_width * (pct / 100.0) if pct > 0 else None
294
+
295
+ try:
296
+ px = float(width_str)
297
+ except ValueError:
298
+ return None
299
+ return px if px > 0 else None
300
+
301
+
302
+ def format_table_cell_value(
303
+ value: Any,
304
+ format_config: str | FormatConfig | None,
305
+ formats: dict[str, str] | None = None,
306
+ ) -> str:
307
+ """Format a cell value for display.
308
+
309
+ Temporal values (Python date/datetime objects and ISO timestamp strings)
310
+ are formatted before numeric values so DuckDB-typed columns show
311
+ human-readable dates without SQL-side strftime. The default format comes
312
+ from ``formats["date_short"]``; if that key is absent the call raises
313
+ ``KeyError`` — a missing ``date_short`` is a theme cascade wiring bug,
314
+ not a "use built-in default" situation.
315
+
316
+ Explicit column ``format:`` strings containing ``%`` are treated as
317
+ strftime specs and applied to temporal values directly. Non-temporal
318
+ ``format:`` strings (numeric d3 specs) raise ValueError.
319
+ """
320
+ if value is None or (isinstance(value, str) and not value.strip()):
321
+ return "—"
322
+
323
+ if isinstance(value, (list, tuple)):
324
+ return f"[{len(value)} items]"
325
+
326
+ if isinstance(value, bool):
327
+ return str(value)
328
+
329
+ # Temporal path: date/datetime objects and ISO strings — must come before
330
+ # the numeric check because datetime.date is not int/float but authors
331
+ # may still set a format: spec that should be treated as a time format.
332
+ if is_temporal_value(value):
333
+ if format_config is not None:
334
+ resolved = resolve_format(format_config, formats)
335
+ # A strftime spec starts with (or solely contains) %-style directives.
336
+ # Distinguish from d3/numeric specs by requiring a leading % or %- modifier.
337
+ # d3 percent specs like ".0%" end with %, so a leading check is accurate.
338
+ if resolved.startswith("%"):
339
+ # Author-provided strftime spec — raises ValueError for bad directives.
340
+ return _format_temporal_value(value, resolved)
341
+ raise ValueError(
342
+ f"non-temporal format spec {resolved!r} applied to a temporal column; "
343
+ f"use a strftime spec (e.g. '%-d %b %Y') or remove format: to use date_short"
344
+ )
345
+ # The theme cascade must supply date_short. Missing formats or absent
346
+ # date_short is a wiring bug — raise so it's caught at dev time, not
347
+ # silently swallowed.
348
+ if formats is None:
349
+ raise ValueError(
350
+ "formats dict is None — theme cascade failed to deliver date_short; "
351
+ "check that the default theme is loaded before rendering"
352
+ )
353
+ date_format = formats["date_short"]
354
+ return _format_temporal_value(value, date_format)
355
+
356
+ if isinstance(value, (int, float)):
357
+ if format_config is not None:
358
+ return format_value(value, format_config, formats)
359
+ if isinstance(value, float):
360
+ if value == int(value):
361
+ return str(int(value))
362
+ return f"{value:,.2f}"
363
+ return f"{value:,}"
364
+
365
+ if isinstance(value, dict):
366
+ return str(value)
367
+ if format_config is not None:
368
+ return format_value(value, format_config, formats)
369
+ return str(value)
370
+
371
+
372
+ def resolve_cell_link(
373
+ link: str,
374
+ row: dict[str, Any],
375
+ columns: list[str],
376
+ ) -> str | None:
377
+ """Resolve a cell link value for a single row."""
378
+ if link in columns:
379
+ val = row.get(link)
380
+ return str(val) if val is not None else None
381
+
382
+ if _TEMPLATE_RE.search(link):
383
+ has_none = False
384
+
385
+ def _sub(m: re.Match[str]) -> str:
386
+ nonlocal has_none
387
+ col_name = m.group(1)
388
+ val = row.get(col_name)
389
+ if val is None:
390
+ has_none = True
391
+ return ""
392
+ return str(val)
393
+
394
+ resolved = _TEMPLATE_RE.sub(_sub, link)
395
+ if has_none:
396
+ return None
397
+ return resolved or None
398
+
399
+ return link
400
+
401
+
402
+ def resolve_cell_link_with_board(
403
+ link: str,
404
+ row: dict[str, Any],
405
+ columns: list[str],
406
+ ) -> str | None:
407
+ """Resolve a cell link and apply board-path rewriting if link context is set."""
408
+ resolved = resolve_cell_link(link, row, columns)
409
+ if resolved is None:
410
+ return None
411
+ ctx = get_link_context()
412
+ if ctx is None:
413
+ return resolved
414
+ return resolve_href(resolved, ctx)
415
+
416
+
417
+ def resolve_table_style_value(
418
+ spec: str | None,
419
+ row: dict[str, Any],
420
+ ) -> str | None:
421
+ """Resolve a table style override from a literal or per-row field ref."""
422
+ if spec is None:
423
+ return None
424
+ if spec in row:
425
+ value = row.get(spec)
426
+ return None if value in (None, "") else str(value)
427
+ return spec
428
+
429
+
430
+ # ---------------------------------------------------------------------------
431
+ # Row typing — per-row semantic roles (value / summary / total)
432
+ # ---------------------------------------------------------------------------
433
+
434
+ _VALID_ROW_ROLES = frozenset({"value", "summary", "total"})
435
+
436
+
437
+ def resolve_row_role(row_role_spec: str | None, row: dict[str, Any]) -> str:
438
+ """Resolve the row's semantic role using column-ID-first resolution.
439
+
440
+ Returns one of: "value" (default), "summary", "total".
441
+ Unknown values fall back to "value".
442
+
443
+ The row-role signal comes from the query layer (ADR-010: data and its
444
+ meaning belong to the query). DFT does not auto-detect summary rows
445
+ from content; the query must emit a column whose per-row value is
446
+ the role.
447
+ """
448
+ if row_role_spec is None:
449
+ return "value"
450
+ raw = resolve_table_style_value(row_role_spec, row)
451
+ if raw is None or raw == "":
452
+ return "value"
453
+ normalized = raw.strip().lower()
454
+ if normalized in _VALID_ROW_ROLES:
455
+ return normalized
456
+ return "value"
457
+
458
+
459
+ def is_summary_role(role: str) -> bool:
460
+ """Return True if the role is summary or total (all summary rows)."""
461
+ return role in ("summary", "total")
462
+
463
+
464
+ def is_total_role(role: str) -> bool:
465
+ """Return True only for total rows (which get double-rule treatment)."""
466
+ return role == "total"
467
+
468
+
469
+ def _apply_rule_outputs(rule: ConditionalRule, result: dict[str, Any]) -> None:
470
+ """Merge a rule's style outputs into ``result`` (last-match-wins)."""
471
+ if rule.background is not None:
472
+ result["background"] = rule.background
473
+ if rule.font is not None:
474
+ f = rule.font
475
+ if f.color is not None:
476
+ result["color"] = f.color
477
+ if f.weight is not None:
478
+ result["weight"] = font_weight_as_css(f.weight)
479
+ if f.style is not None:
480
+ result["style"] = f.style
481
+ if f.decoration is not None:
482
+ result["decoration"] = f.decoration
483
+ if rule.glyph is not None:
484
+ result["glyph"] = rule.glyph
485
+ # A later rule that swaps the glyph without specifying its color
486
+ # must not inherit the previous rule's color — clear it so the
487
+ # cell falls back to default ink.
488
+ result.pop("glyph_color", None)
489
+ if rule.glyph_color is not None:
490
+ result["glyph_color"] = rule.glyph_color
491
+
492
+
493
+ def resolve_conditional_styles(
494
+ when_rules: Sequence[ConditionalRule] | None,
495
+ value: Any,
496
+ ) -> dict[str, Any]:
497
+ """Evaluate a list of ``when`` rules and return merged style overrides.
498
+
499
+ Non-default rules are evaluated in list order; all matching rules
500
+ contribute their style keys with last-match-wins semantics.
501
+
502
+ A trailing ``default: true`` rule is a catch-all — it only applies when
503
+ no non-default rule matched. This lets authors write
504
+ ``[{lt:0, ...}, {gt:100, ...}, {default: true, ...}]`` and have the
505
+ default fire only for rows in (0, 100]. Position of the default rule is
506
+ validated on FieldConditionalFormatting; evaluation here is tolerant of
507
+ ordering.
508
+
509
+ Keys: ``background`` (str), ``color`` (str), ``weight`` (str),
510
+ ``style`` (str), ``decoration`` (str).
511
+ """
512
+ if not when_rules:
513
+ return {}
514
+
515
+ result: dict[str, Any] = {}
516
+ matched_any = False
517
+ default_rule: ConditionalRule | None = None
518
+ for rule in when_rules:
519
+ if rule.default is True:
520
+ default_rule = rule
521
+ continue
522
+ if match_predicate(rule, value):
523
+ matched_any = True
524
+ _apply_rule_outputs(rule, result)
525
+ if not matched_any and default_rule is not None:
526
+ _apply_rule_outputs(default_rule, result)
527
+ return result
528
+
529
+
530
+ def resolve_header_overflow(
531
+ style: TableChartStylePatch | None,
532
+ *,
533
+ inherited_title_style: Any | None,
534
+ table_config: Any,
535
+ promoted: str | None = None,
536
+ ) -> TitleOverflowMode:
537
+ """Resolve the effective table-header overflow mode.
538
+
539
+ Precedence (highest first):
540
+ 1. ``promoted`` — ResolvedChart.header_overflow (the authored Patch
541
+ value lifted to a first-class field by resolve_chart).
542
+ 2. ``style.header_overflow`` — fallback for Chart/MockChart
543
+ entry-boundary inputs that haven't been resolved yet.
544
+ 3. ``table_config.header_overflow`` — face-level TableChartStyle override.
545
+ 4. inherited title overflow.
546
+ """
547
+ if promoted in {"clip", "truncate", "wrap-two", "wrap"}:
548
+ return cast(Literal["clip", "truncate", "wrap-two", "wrap"], promoted)
549
+ if style is not None:
550
+ raw = style.header_overflow
551
+ if raw in {"clip", "truncate", "wrap-two", "wrap"}:
552
+ return raw
553
+ raw_table = getattr(table_config, "header_overflow", None)
554
+ if raw_table in {"clip", "truncate", "wrap-two", "wrap"}:
555
+ return raw_table
556
+ return resolve_title_overflow(inherited_title_style)
557
+
558
+
559
+ # Compact-column classifier threshold. Any auto-column whose measured demand
560
+ # falls at or below this value is pinned to that demand rather than joining the
561
+ # proportional text pool. ~220px corresponds to roughly 30 characters at the
562
+ # default 13px body font — enough for short enums, numbers, and dates.
563
+ _COMPACT_DEMAND_CEILING: float = 220.0
564
+
565
+ # URL prefix filter for min-word floor measurement.
566
+ _URL_PREFIX_RE = re.compile(r"^https?://")
567
+
568
+ # Max token length included in min-word floor (tokens longer than this are
569
+ # opaque IDs / base64 chunks and would force an unreachable floor).
570
+ _MAX_FLOOR_TOKEN_LEN = 40
571
+
572
+
573
+ def _measure_min_word_width(
574
+ values: list[Any],
575
+ header_label: str,
576
+ measurer: Any,
577
+ font_size: float,
578
+ ) -> float:
579
+ """Return the pixel width of the widest eligible whitespace-delimited token.
580
+
581
+ Searches ``values`` (sampled cell strings) and the ``header_label`` for the
582
+ widest token, applying two filters:
583
+ - Skip tokens that start with ``http://`` or ``https://`` (URLs).
584
+ - Skip tokens longer than ``_MAX_FLOOR_TOKEN_LEN`` characters.
585
+
586
+ This gives the minimum column width that avoids mid-word line breaks.
587
+ """
588
+ max_w = 0.0
589
+
590
+ def _candidate_tokens(text: str) -> Iterator[str]:
591
+ for token in text.split():
592
+ if _URL_PREFIX_RE.match(token):
593
+ continue
594
+ if len(token) > _MAX_FLOOR_TOKEN_LEN:
595
+ continue
596
+ yield token
597
+
598
+ for token in _candidate_tokens(header_label):
599
+ w = measurer.measure(token, font_size)
600
+ if w > max_w:
601
+ max_w = w
602
+
603
+ for val in values:
604
+ for token in _candidate_tokens(str(val)):
605
+ w = measurer.measure(token, font_size)
606
+ if w > max_w:
607
+ max_w = w
608
+
609
+ return max_w
610
+
611
+
612
+ def _classify_columns(
613
+ demands: dict[str, float],
614
+ column_configs: dict[str, TableColumnConfig],
615
+ ) -> tuple[set[str], set[str]]:
616
+ """Classify auto-columns as compact or text.
617
+
618
+ Compact columns are pinned to their measured demand; text columns compete
619
+ proportionally for the remaining budget.
620
+
621
+ A column is compact when:
622
+ - Its demand <= ``_COMPACT_DEMAND_CEILING``, OR
623
+ - It has a spark config (spark columns own their own width).
624
+
625
+ Returns ``(compact_keys, text_keys)``.
626
+ """
627
+ from dataface.core.compile.models.chart.authored import SparkConfig
628
+
629
+ compact: set[str] = set()
630
+ text: set[str] = set()
631
+ for col, demand in demands.items():
632
+ cfg = column_configs.get(col)
633
+ has_spark = cfg is not None and isinstance(cfg.spark, SparkConfig)
634
+ if has_spark or demand <= _COMPACT_DEMAND_CEILING:
635
+ compact.add(col)
636
+ else:
637
+ text.add(col)
638
+ return compact, text
639
+
640
+
641
+ # Anti-pathological cap: no single auto-column may claim more than this
642
+ # fraction of the auto-column budget in proportional allocation.
643
+ _PROPORTIONAL_CAP = 0.6
644
+
645
+
646
+ def _proportional_allocate(
647
+ demands: dict[str, float], budget: float
648
+ ) -> dict[str, float]:
649
+ """Allocate budget to columns proportionally to their demands.
650
+
651
+ Single-column: always gets the full budget.
652
+ Multi-column: iteratively caps any column at ``_PROPORTIONAL_CAP * budget``
653
+ and redistributes the excess among the uncapped columns until stable.
654
+ Budget is always fully consumed.
655
+ """
656
+ if len(demands) <= 1:
657
+ col = next(iter(demands)) if demands else None
658
+ return {col: budget} if col is not None else {}
659
+
660
+ cap = budget * _PROPORTIONAL_CAP
661
+ active = dict(demands)
662
+ fixed: dict[str, float] = {}
663
+ remaining = budget
664
+
665
+ while active:
666
+ total = sum(active.values())
667
+ if total <= 0:
668
+ per_col = remaining / len(active)
669
+ fixed.update(dict.fromkeys(active, per_col))
670
+ break
671
+
672
+ to_fix = {
673
+ col: cap for col, d in active.items() if (d / total) * remaining > cap
674
+ }
675
+ if not to_fix:
676
+ for col, d in active.items():
677
+ fixed[col] = (d / total) * remaining
678
+ break
679
+
680
+ fixed.update(to_fix)
681
+ remaining -= sum(to_fix.values())
682
+ for col in to_fix:
683
+ del active[col]
684
+
685
+ return fixed
686
+
687
+
688
+ def _cluster_and_equalize_widths(
689
+ widths: dict[str, float], budget: float, threshold: float
690
+ ) -> dict[str, float]:
691
+ """Snap near-equal widths to a shared value, then rescale to budget.
692
+
693
+ Sorted walk: columns sorted ascending, clusters grow while
694
+ cluster_min / new_width >= threshold. This is the cluster extremes check —
695
+ not adjacent-pair — so 100/110/120/130/140/150 does NOT collapse into one
696
+ cluster even though each adjacent ratio is >= 0.8. Each cluster snaps to
697
+ its max. A final uniform rescale restores the budget, preserving
698
+ within-cluster equality.
699
+
700
+ threshold=1.0 means only exactly-equal widths cluster (effectively disabled).
701
+ threshold=0.0 means all widths cluster (force-equal).
702
+ """
703
+ if len(widths) <= 1:
704
+ return dict(widths)
705
+
706
+ sorted_items = sorted(widths.items(), key=lambda kv: kv[1])
707
+
708
+ clusters: list[list[str]] = []
709
+ cluster_cols: list[str] = []
710
+ cluster_min = sorted_items[0][1]
711
+
712
+ for col, w in sorted_items:
713
+ if cluster_cols and cluster_min / (w + 1e-9) < threshold:
714
+ clusters.append(cluster_cols)
715
+ cluster_cols = [col]
716
+ cluster_min = w
717
+ else:
718
+ cluster_cols.append(col)
719
+ clusters.append(cluster_cols)
720
+
721
+ snapped: dict[str, float] = {}
722
+ col_to_w = dict(widths)
723
+ for cluster in clusters:
724
+ cluster_max = max(col_to_w[c] for c in cluster)
725
+ for c in cluster:
726
+ snapped[c] = cluster_max
727
+
728
+ total = sum(snapped.values())
729
+ if total > 0:
730
+ scale = budget / total
731
+ return {c: v * scale for c, v in snapped.items()}
732
+ return snapped
733
+
734
+
735
+ def measure_column_demands(
736
+ columns: list[str],
737
+ column_configs: dict[str, TableColumnConfig],
738
+ data: list[dict[str, Any]],
739
+ measurer: Any,
740
+ *,
741
+ font_size: float,
742
+ header_font_size: float,
743
+ header_case: CaseValue = "none",
744
+ cell_pad: int,
745
+ max_sample_rows: int = 50,
746
+ formats: dict[str, str] | None = None,
747
+ header_visible: bool = True,
748
+ ) -> dict[str, float]:
749
+ """Measure total-width demand per column (header + p95 cell content + padding).
750
+
751
+ Returns a dict mapping column name → demanded pixel width including both
752
+ sides of cell padding. Callers pass this to ``calculate_column_layout``
753
+ as the ``demands`` kwarg to enable proportional budget allocation.
754
+
755
+ When ``header_visible`` is False, the header label is excluded from the
756
+ demand so a hidden header can't inflate column width above the cell content.
757
+
758
+ Swatch columns are pinned to ``_SWATCH_CELL_DEMAND`` (rect width plus cell
759
+ padding); their cell value is a color string, not rendered as text, so
760
+ measuring it would massively over-allocate.
761
+ """
762
+ demands: dict[str, float] = {}
763
+ sample = data[:max_sample_rows]
764
+
765
+ for col in columns:
766
+ col_config = column_configs.get(col)
767
+ if col_config and col_config.swatch:
768
+ demands[col] = _SWATCH_CELL_DEMAND + cell_pad * 2
769
+ continue
770
+ if not header_visible:
771
+ header_w = 0.0
772
+ else:
773
+ display_name = (col_config.label if col_config else None) or slug_to_text(
774
+ col
775
+ )
776
+ display_name = apply_case(display_name, header_case)
777
+ header_w = measurer.measure(display_name, header_font_size)
778
+
779
+ cell_widths: list[float] = []
780
+ for row in sample:
781
+ val = row.get(col, "")
782
+ fmt = col_config.format if col_config else None
783
+ rendered = format_table_cell_value(val, fmt, formats)
784
+ cell_widths.append(measurer.measure(rendered, font_size))
785
+
786
+ if cell_widths:
787
+ cell_widths.sort()
788
+ p95_idx = min(int(len(cell_widths) * 0.95), len(cell_widths) - 1)
789
+ p95_w = cell_widths[p95_idx]
790
+ else:
791
+ p95_w = 0.0
792
+
793
+ demands[col] = max(header_w, p95_w) + cell_pad * 2
794
+
795
+ return demands
796
+
797
+
798
+ def measure_column_word_floors(
799
+ columns: list[str],
800
+ column_configs: dict[str, TableColumnConfig],
801
+ data: list[dict[str, Any]],
802
+ measurer: Any,
803
+ *,
804
+ font_size: float,
805
+ header_case: CaseValue = "none",
806
+ cell_pad: int = 0,
807
+ max_sample_rows: int = 50,
808
+ header_visible: bool = True,
809
+ ) -> dict[str, float]:
810
+ """Return the min-word floor (px) for each column including cell padding.
811
+
812
+ The floor is the pixel width of the widest whitespace-delimited token in
813
+ any sampled cell value or the column header label, plus ``cell_pad * 2``
814
+ (both sides of cell padding). URL tokens and tokens longer than
815
+ ``_MAX_FLOOR_TOKEN_LEN`` are excluded (see ``_measure_min_word_width``).
816
+
817
+ The padding offset ensures that when the layout assigns a column a width
818
+ equal to its floor, the content area (``width - 2 * cell_pad``) is still
819
+ wide enough to display the longest token without mid-word wrapping.
820
+
821
+ Callers pass this to ``calculate_column_layout`` as the ``word_floors``
822
+ kwarg so that text-column allocations are never narrower than their longest
823
+ non-URL word.
824
+
825
+ When ``header_visible`` is False, the header label is excluded — a hidden
826
+ label can't drive a min-width floor.
827
+
828
+ Swatch columns pin to the rect width (no text), bypassing token measurement.
829
+ """
830
+ floors: dict[str, float] = {}
831
+ sample = data[:max_sample_rows]
832
+
833
+ for col in columns:
834
+ col_config = column_configs.get(col)
835
+ if col_config and col_config.swatch:
836
+ floors[col] = _SWATCH_CELL_DEMAND + cell_pad * 2
837
+ continue
838
+ if not header_visible:
839
+ display_name = ""
840
+ else:
841
+ display_name = (col_config.label if col_config else None) or slug_to_text(
842
+ col
843
+ )
844
+ display_name = apply_case(display_name, header_case)
845
+
846
+ cell_values = [row.get(col, "") for row in sample]
847
+ token_w = _measure_min_word_width(
848
+ cell_values, display_name, measurer, font_size
849
+ )
850
+ floors[col] = token_w + cell_pad * 2
851
+
852
+ return floors
853
+
854
+
855
+ def _allocate_text_columns(
856
+ text_keys: set[str],
857
+ text_demands: dict[str, float],
858
+ text_budget: float,
859
+ max_widths: dict[str, float | None],
860
+ word_floors: dict[str, float],
861
+ width_similarity_threshold: float,
862
+ ) -> dict[str, float]:
863
+ """Proportionally allocate ``text_budget`` among text columns.
864
+
865
+ Enforces per-column ``max_width`` caps through iterative redistribution.
866
+ Applies ``word_floors`` as a minimum (clamped at ``max_width`` so a floor
867
+ never silently exceeds a declared cap). Cluster-equalizes uncapped columns.
868
+ """
869
+ if len(text_keys) == 1:
870
+ (only_col,) = text_keys
871
+ mw = max_widths[only_col]
872
+ floor = word_floors.get(only_col, 0.0)
873
+ # Floor is clamped at max_width — max_width is the hard upper bound.
874
+ effective_floor = floor if mw is None else min(floor, mw)
875
+ allocated_w = text_budget if mw is None else min(text_budget, mw)
876
+ return {only_col: max(allocated_w, effective_floor)}
877
+
878
+ text_allocated = _proportional_allocate(text_demands, text_budget)
879
+
880
+ # Iteratively cap columns at max_width and redistribute leftover to uncapped
881
+ # columns. Each iteration resolves at least one more column; the loop is
882
+ # guaranteed to terminate in at most len(text_keys) passes.
883
+ final_text: dict[str, float] = {}
884
+ to_allocate = dict(text_allocated)
885
+
886
+ while True:
887
+ capped_now: dict[str, float] = {}
888
+ free: dict[str, float] = {}
889
+ leftover = 0.0
890
+ for col, w in to_allocate.items():
891
+ mw = max_widths[col]
892
+ if mw is not None and w > mw:
893
+ capped_now[col] = mw
894
+ leftover += w - mw
895
+ else:
896
+ free[col] = w
897
+ final_text.update(capped_now)
898
+ if not leftover or not free:
899
+ final_text.update(free)
900
+ break
901
+ # Redistribute leftover to uncapped columns proportionally.
902
+ total_free = sum(free.values())
903
+ if total_free > 0:
904
+ new_total = total_free + leftover
905
+ for col in free:
906
+ free[col] = free[col] / total_free * new_total
907
+ to_allocate = free
908
+
909
+ # Cluster-equalize only non-capped columns; capped columns are excluded
910
+ # so the snap-to-cluster-max cannot push them above their declared cap.
911
+ non_capped = {c: v for c, v in final_text.items() if max_widths[c] is None}
912
+ capped_only = {c: v for c, v in final_text.items() if max_widths[c] is not None}
913
+ if len(non_capped) > 1:
914
+ non_capped = _cluster_and_equalize_widths(
915
+ non_capped,
916
+ sum(non_capped.values()),
917
+ threshold=width_similarity_threshold,
918
+ )
919
+ final_text = {**capped_only, **non_capped}
920
+
921
+ # Apply word_floors after all allocation. Floor is clamped at max_width
922
+ # so a word-floor wider than a declared cap cannot silently override it.
923
+ for col in text_keys:
924
+ floor = word_floors.get(col, 0.0)
925
+ mw = max_widths[col]
926
+ effective_floor = floor if mw is None else min(floor, mw)
927
+ if effective_floor > 0 and final_text[col] < effective_floor:
928
+ final_text[col] = effective_floor
929
+
930
+ return final_text
931
+
932
+
933
+ def calculate_column_layout(
934
+ columns: list[str],
935
+ column_configs: dict[str, TableColumnConfig],
936
+ available_width: float,
937
+ *,
938
+ demands: dict[str, float] | None = None,
939
+ width_similarity_threshold: float = 0.8,
940
+ word_floors: dict[str, float] | None = None,
941
+ ) -> tuple[dict[str, float], list[float], float]:
942
+ """Compute per-column widths, x offsets, and actual content width.
943
+
944
+ Returns ``(col_widths, col_x_offsets, actual_content_width)``.
945
+
946
+ When ``demands`` is provided and there are multiple auto-columns:
947
+
948
+ 1. Columns with ``width:`` set are pinned and removed from the auto pool.
949
+ 2. Auto-columns are classified as *compact* or *text* (``_classify_columns``).
950
+ Compact columns are pinned to their measured demand; text columns share
951
+ the remaining budget via ``_allocate_text_columns``.
952
+ 3. Any leftover budget (e.g. from ``max_width:`` caps) goes to compact
953
+ columns proportionally. When no compact columns exist, the gap is left
954
+ unconsumed — clipping is honest, mid-word wrapping is not.
955
+ 4. All compact, no text: compact widths scale up to fill the budget.
956
+
957
+ Without ``demands`` or for a single auto-column: equal distribution.
958
+ """
959
+ col_widths: dict[str, float] = {}
960
+ if not columns:
961
+ col_x_offsets: list[float] = []
962
+ return col_widths, col_x_offsets, available_width
963
+
964
+ # --- Phase 1: resolve explicit width: pins ---
965
+ explicit_widths: dict[str, float] = {}
966
+ for col in columns:
967
+ col_cfg = column_configs.get(col)
968
+ width_hint = parse_column_width(
969
+ col_cfg.width if col_cfg else None, available_width
970
+ )
971
+ if width_hint is not None:
972
+ explicit_widths[col] = width_hint
973
+
974
+ auto_columns = [col for col in columns if col not in explicit_widths]
975
+ explicit_total = sum(explicit_widths.values())
976
+ available_for_auto = max(available_width - explicit_total, 0.0)
977
+
978
+ if not auto_columns:
979
+ col_widths.update(explicit_widths)
980
+ actual_content_width = sum(col_widths.get(col, 0) for col in columns)
981
+ col_x_offsets = []
982
+ current_x = 0.0
983
+ for col in columns:
984
+ col_x_offsets.append(current_x)
985
+ current_x += col_widths.get(col, 100)
986
+ return col_widths, col_x_offsets, actual_content_width
987
+
988
+ # --- Phase 2: smart allocation when demands are provided ---
989
+ if demands and len(auto_columns) > 1:
990
+ auto_demands = {col: max(demands.get(col, 1.0), 1.0) for col in auto_columns}
991
+ compact_keys, text_keys = _classify_columns(auto_demands, column_configs)
992
+
993
+ compact_pinned: dict[str, float] = {c: auto_demands[c] for c in compact_keys}
994
+ compact_total = sum(compact_pinned.values())
995
+ text_budget = max(available_for_auto - compact_total, 0.0)
996
+
997
+ if not text_keys:
998
+ # All compact: scale proportionally to fill (or fit) the budget.
999
+ total_compact_demand = sum(compact_pinned.values())
1000
+ if total_compact_demand > 0:
1001
+ scale = available_for_auto / total_compact_demand
1002
+ col_widths.update({c: w * scale for c, w in compact_pinned.items()})
1003
+ else:
1004
+ per_col = available_for_auto / len(auto_columns)
1005
+ col_widths.update(dict.fromkeys(auto_columns, per_col))
1006
+ else:
1007
+ # Resolve per-column max_width caps.
1008
+ max_widths: dict[str, float | None] = {}
1009
+ for col in text_keys:
1010
+ cfg = column_configs.get(col)
1011
+ if cfg is not None and cfg.max_width is not None:
1012
+ max_widths[col] = parse_column_width(cfg.max_width, available_width)
1013
+ else:
1014
+ max_widths[col] = None
1015
+
1016
+ text_result = _allocate_text_columns(
1017
+ text_keys,
1018
+ {c: auto_demands[c] for c in text_keys},
1019
+ text_budget,
1020
+ max_widths,
1021
+ word_floors or {},
1022
+ width_similarity_threshold,
1023
+ )
1024
+ col_widths.update(text_result)
1025
+
1026
+ # Any leftover after text allocation (e.g. from max_width caps) goes
1027
+ # to compact columns proportionally. No compact → gap is left
1028
+ # unconsumed (accepting underflow rather than violating a cap).
1029
+ text_used = sum(col_widths.get(c, 0.0) for c in text_keys)
1030
+ remainder = available_for_auto - compact_total - text_used
1031
+ if remainder > 0.1 and compact_pinned:
1032
+ total_compact = sum(compact_pinned.values())
1033
+ if total_compact > 0:
1034
+ for col in compact_pinned:
1035
+ compact_pinned[col] += remainder * (
1036
+ compact_pinned[col] / total_compact
1037
+ )
1038
+ else:
1039
+ per_compact = remainder / len(compact_pinned)
1040
+ for col in compact_pinned:
1041
+ compact_pinned[col] += per_compact
1042
+
1043
+ col_widths.update(compact_pinned)
1044
+ else:
1045
+ # Fallback: equal distribution (no demands, or single auto-column)
1046
+ per_col = available_for_auto / len(auto_columns)
1047
+ for col in auto_columns:
1048
+ col_widths[col] = per_col
1049
+
1050
+ col_widths.update(explicit_widths)
1051
+ actual_content_width = sum(col_widths.get(col, 0) for col in columns)
1052
+
1053
+ col_x_offsets = []
1054
+ current_x = 0.0
1055
+ for col in columns:
1056
+ col_x_offsets.append(current_x)
1057
+ current_x += col_widths.get(col, 100)
1058
+ return col_widths, col_x_offsets, actual_content_width
1059
+
1060
+
1061
+ def resolve_wrapped_headers(
1062
+ columns: list[str],
1063
+ column_configs: dict[str, TableColumnConfig],
1064
+ col_widths: dict[str, float],
1065
+ *,
1066
+ header_overflow: TitleOverflowMode,
1067
+ header_height: float,
1068
+ header_font: FontStyle,
1069
+ padding: int,
1070
+ table_config: Any,
1071
+ cell_pad: int | None = None,
1072
+ measurer: Any,
1073
+ ) -> tuple[dict[str, list[str]], float]:
1074
+ """Return wrapped header lines and the effective header height.
1075
+
1076
+ Wrapping uses the full cell content area (``cw - 2 * pad``) for every
1077
+ column — both text and numeric headers get the same available width.
1078
+ Wide headers wrap into the next line when they'd overflow.
1079
+
1080
+ Wrapping decisions always use real glyph metrics via ``measurer`` to
1081
+ avoid false-positive wraps (e.g. "Poverty" wrapping at widths where it
1082
+ actually fits).
1083
+ """
1084
+ if not columns:
1085
+ return {}, header_height
1086
+
1087
+ pad = cell_pad if cell_pad is not None else table_config.columns.cell_padding
1088
+ header_font_size = int(header_font.size) if header_font.size is not None else 12
1089
+ header_line_height = header_font_size + table_config.text_baseline_offset
1090
+ wrapped_headers: dict[str, list[str]] = {}
1091
+
1092
+ for col in columns:
1093
+ cw = col_widths.get(col, 100)
1094
+ col_config = column_configs.get(col)
1095
+ name = (col_config.label if col_config else None) or slug_to_text(col)
1096
+ # Apply header case transform so wrapped lines match what render_table_headers emits.
1097
+ _header_case = header_font.case
1098
+ if _header_case is not None and _header_case != "none":
1099
+ from dataface.core.render.text.case import apply_case
1100
+
1101
+ name = apply_case(name, _header_case)
1102
+ column_overflow: TitleOverflowMode = (
1103
+ col_config.header_overflow
1104
+ if col_config and col_config.header_overflow is not None
1105
+ else header_overflow
1106
+ )
1107
+ content_area = max(int(cw - pad * 2), 1)
1108
+ # Short-circuit: if the full header fits on one line under real font
1109
+ # metrics, skip the char-count heuristic entirely.
1110
+ if measurer.measure(name, header_font_size) <= content_area:
1111
+ wrapped_headers[col] = [name]
1112
+ continue
1113
+ rendered = prepare_title_text(
1114
+ name,
1115
+ overflow=column_overflow,
1116
+ limit=content_area,
1117
+ font_size=header_font_size,
1118
+ font_family=header_font.family,
1119
+ )
1120
+ wrapped_headers[col] = rendered.splitlines() or [name]
1121
+ max_lines = max(len(lines) for lines in wrapped_headers.values())
1122
+ effective_height = max(header_height, padding * 2 + max_lines * header_line_height)
1123
+ return wrapped_headers, effective_height
1124
+
1125
+
1126
+ # ---------------------------------------------------------------------------
1127
+ # Conditional formatting helpers (when rules + scale color mapping)
1128
+ # ---------------------------------------------------------------------------
1129
+
1130
+
1131
+ def _expand_hex(c: str) -> str:
1132
+ """Expand 3-char hex (#RGB) to 6-char (#RRGGBB). Passes 6-char through."""
1133
+ h = c.lstrip("#")
1134
+ if len(h) == 3:
1135
+ h = "".join(ch * 2 for ch in h)
1136
+ return f"#{h}"
1137
+
1138
+
1139
+ def _parse_hex(c: str) -> tuple[int, int, int]:
1140
+ h = _expand_hex(c).lstrip("#")
1141
+ return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
1142
+
1143
+
1144
+ def _lerp_color(c1: str, c2: str, t: float) -> str:
1145
+ """Linearly interpolate between two hex colors."""
1146
+ r1, g1, b1 = _parse_hex(c1)
1147
+ r2, g2, b2 = _parse_hex(c2)
1148
+ r = int(r1 + (r2 - r1) * t + 0.5)
1149
+ g = int(g1 + (g2 - g1) * t + 0.5)
1150
+ b = int(b1 + (b2 - b1) * t + 0.5)
1151
+ return f"#{r:02x}{g:02x}{b:02x}"
1152
+
1153
+
1154
+ def resolve_palette_stops(
1155
+ ref: str | list[str] | list[float],
1156
+ *,
1157
+ surface: Literal["default", "table"] | None = None,
1158
+ ) -> list[str]:
1159
+ """Resolve a palette reference to a list of hex stops.
1160
+
1161
+ Named string → resolved via M2 palette resolver. Pass ``surface="table"``
1162
+ to get a WCAG-safe sub-palette suitable for table cell backgrounds (sequential
1163
+ and diverging only; inline lists are returned unchanged regardless of surface).
1164
+
1165
+ Inline list passes through unchanged (new list object returned).
1166
+
1167
+ ``list[float]`` is accepted to match the schema union but is not
1168
+ meaningful for render; callers that pass numeric lists will still see
1169
+ garbage colors from ``_expand_hex``.
1170
+ """
1171
+ if isinstance(ref, list):
1172
+ if not ref:
1173
+ raise ValueError("palette must not be empty")
1174
+ return [str(c) for c in ref]
1175
+ return _resolve_named_palette(ref, surface=surface)
1176
+
1177
+
1178
+ def resolve_hinge(
1179
+ config: ScaleTargetConfig,
1180
+ lo: float,
1181
+ hi: float,
1182
+ col_format: str | None,
1183
+ ) -> float | None:
1184
+ """Decide the diverging pivot from config + domain context.
1185
+
1186
+ Returns ``None`` for sequential (``config.hinge is None``).
1187
+ Explicit float in ``config.hinge`` short-circuits the decision tree.
1188
+ ``"auto"`` runs the tree: zero-crossing → 0; percent-format crosses 1.0
1189
+ → 1.0; else midpoint.
1190
+ """
1191
+ if config.hinge is None:
1192
+ return None
1193
+ # Degenerate domain: diverging is meaningless; fall back to sequential.
1194
+ if lo == hi:
1195
+ return None
1196
+ if isinstance(config.hinge, (int, float)):
1197
+ return float(config.hinge)
1198
+ # "auto" decision tree.
1199
+ if lo < 0.0 < hi or (lo < 0.0 and hi == 0.0):
1200
+ return 0.0
1201
+ is_percent = col_format is not None and "%" in col_format
1202
+ if is_percent and lo < 1.0 < hi:
1203
+ return 1.0
1204
+ return (lo + hi) / 2.0
1205
+
1206
+
1207
+ def _interpolate_arm(t: float, stops: list[str]) -> str:
1208
+ """Interpolate at normalized position t∈[0,1] along a palette arm.
1209
+
1210
+ ``stops`` must already be expanded to 6-char hex. ``t`` is clamped to
1211
+ [0, 1] before indexing.
1212
+ """
1213
+ t = max(0.0, min(1.0, t))
1214
+ n_segments = len(stops) - 1
1215
+ segment = t * n_segments
1216
+ i = min(int(segment), n_segments - 1)
1217
+ return _lerp_color(stops[i], stops[i + 1], segment - i)
1218
+
1219
+
1220
+ def interpolate_scale_color(
1221
+ value: float,
1222
+ min_val: float,
1223
+ max_val: float,
1224
+ palette: list[str],
1225
+ *,
1226
+ hinge: float | None = None,
1227
+ arm_mode: str = "asymmetric",
1228
+ ) -> str:
1229
+ """Map a numeric value to a color via linear interpolation across palette stops.
1230
+
1231
+ Sequential mode (``hinge is None``):
1232
+ Values below min clamp to the first stop; above max clamp to the last.
1233
+ When min == max, returns the middle stop.
1234
+
1235
+ Diverging mode (``hinge is not None``):
1236
+ Palette is split at the midpoint. Left half maps to [min_val, hinge];
1237
+ right half maps to [hinge, max_val].
1238
+
1239
+ ``arm_mode="asymmetric"`` (default): each arm's t is computed relative
1240
+ to the actual arm width — per-unit intensity is consistent across the
1241
+ pivot regardless of arm length.
1242
+
1243
+ ``arm_mode="symmetric"``: each arm stretches fully from neutral to
1244
+ its extreme regardless of absolute width — useful when visual parity
1245
+ between arms matters more than per-unit consistency.
1246
+ """
1247
+ palette = [_expand_hex(c) for c in palette]
1248
+
1249
+ if hinge is None:
1250
+ # Sequential path — unchanged behavior.
1251
+ if min_val == max_val:
1252
+ return palette[len(palette) // 2]
1253
+ t = (value - min_val) / (max_val - min_val)
1254
+ t = max(0.0, min(1.0, t))
1255
+ n_segments = len(palette) - 1
1256
+ segment = t * n_segments
1257
+ i = min(int(segment), n_segments - 1)
1258
+ return _lerp_color(palette[i], palette[i + 1], segment - i)
1259
+
1260
+ # Diverging path.
1261
+ if len(palette) < 3 or len(palette) % 2 == 0:
1262
+ raise ValueError(
1263
+ f"diverging palette must have an odd number of stops >= 3, got {len(palette)}"
1264
+ )
1265
+
1266
+ # Clamp hinge to [min_val, max_val] so out-of-domain author values don't
1267
+ # produce negative arm widths or meaningless interpolation.
1268
+ hinge = max(min_val, min(max_val, hinge))
1269
+
1270
+ mid = len(palette) // 2
1271
+ neg_stops = palette[: mid + 1][::-1] # neutral → neg extreme
1272
+ pos_stops = palette[mid:] # neutral → pos extreme
1273
+
1274
+ # Clamp value to domain.
1275
+ clamped = max(min_val, min(max_val, value))
1276
+
1277
+ neg_width = hinge - min_val
1278
+ pos_width = max_val - hinge
1279
+ # Asymmetric uses the longer arm as the common intensity unit so that
1280
+ # equal absolute distance from the hinge produces equal palette depth
1281
+ # on both sides — the shorter arm will never reach full palette saturation.
1282
+ # Symmetric uses per-arm width so both arms always fill their full palette
1283
+ # range regardless of absolute length.
1284
+ if clamped <= hinge:
1285
+ distance = hinge - clamped
1286
+ if arm_mode == "asymmetric":
1287
+ denom = max(neg_width, pos_width, 1e-12)
1288
+ else:
1289
+ denom = max(neg_width, 1e-12)
1290
+ return _interpolate_arm(distance / denom, neg_stops)
1291
+ else:
1292
+ distance = clamped - hinge
1293
+ if arm_mode == "asymmetric":
1294
+ denom = max(neg_width, pos_width, 1e-12)
1295
+ else:
1296
+ denom = max(pos_width, 1e-12)
1297
+ return _interpolate_arm(distance / denom, pos_stops)
1298
+
1299
+
1300
+ def compute_scale_domain(
1301
+ data: list[dict[str, Any]], field: str, cfg: ScaleTargetConfig
1302
+ ) -> tuple[float, float]:
1303
+ """Compute the effective [min, max] domain for a scale target.
1304
+
1305
+ Uses explicit overrides when set, otherwise infers from data.
1306
+ Falls back to (0, 1) when no numeric values are present.
1307
+ """
1308
+
1309
+ has_min = cfg.min is not None
1310
+ has_max = cfg.max is not None
1311
+
1312
+ if has_min and has_max:
1313
+ return (float(cfg.min), float(cfg.max)) # type: ignore[arg-type]
1314
+
1315
+ values: list[float] = []
1316
+ for row in data:
1317
+ coerced = coerce_numeric(row.get(field))
1318
+ if coerced is not None:
1319
+ values.append(coerced)
1320
+
1321
+ if not values:
1322
+ return (
1323
+ float(cfg.min) if has_min else 0.0, # type: ignore[arg-type]
1324
+ float(cfg.max) if has_max else 1.0, # type: ignore[arg-type]
1325
+ )
1326
+
1327
+ lo = float(cfg.min) if has_min else min(values) # type: ignore[arg-type]
1328
+ hi = float(cfg.max) if has_max else max(values) # type: ignore[arg-type]
1329
+ return (lo, hi)
1330
+
1331
+
1332
+ def resolve_cell_conditional_styles(
1333
+ col_config: TableColumnConfig,
1334
+ value: Any,
1335
+ data: list[dict[str, Any]],
1336
+ when_rules: Sequence[ConditionalRule] | None = None,
1337
+ col_format: str | None = None,
1338
+ row_role: str = "value",
1339
+ col_name: str = "",
1340
+ ) -> tuple[str | None, str | None, str | float | None, str | None, str | None]:
1341
+ """Resolve effective cell styling.
1342
+
1343
+ Precedence (each layer only overrides keys it sets):
1344
+ 1. base column style
1345
+ 2. scale — continuous numeric mapping (skipped for summary/total rows)
1346
+ 3. when — discrete predicate rules from the chart-level
1347
+ ``conditional_formatting`` block (``when_rules``)
1348
+
1349
+ Args:
1350
+ col_config: Column configuration carrying base style and scale config.
1351
+ value: Raw cell value (may be a numeric string from CSV loads).
1352
+ data: Rows used to compute the scale domain. Callers must exclude
1353
+ summary/total rows so the domain is not skewed by aggregate values.
1354
+ when_rules: Author-specified threshold rules. Applied to all row roles.
1355
+ col_format: D3 format string for the column, passed to ``resolve_hinge``
1356
+ so the "auto" branch can detect percent-format domains.
1357
+ row_role: Semantic role of this row — ``"value"``, ``"summary"``, or
1358
+ ``"total"``. Scale (heatmap) fills are skipped for non-value rows
1359
+ because coloring aggregate rows by their own value relative to the
1360
+ column domain is visually misleading. ``when`` rules still apply.
1361
+
1362
+ Returns ``(background, color, weight, style, decoration)``.
1363
+ ``weight`` is the raw ``FontStyle.weight`` value — ``str | float | None``
1364
+ — left for callers to normalize via ``font_weight_as_css`` before CSS
1365
+ emission.
1366
+ """
1367
+ bg = col_config.background
1368
+ col_font = col_config.font
1369
+ color = col_font.color if col_font is not None else None
1370
+ fw = col_font.weight if col_font is not None else None
1371
+ fstyle: str | None = None
1372
+ fdecoration: str | None = None
1373
+
1374
+ # Layer 2: scale — skipped for summary/total rows.
1375
+ if col_config.scale and not is_summary_role(row_role):
1376
+ scale = col_config.scale
1377
+ for attr, target in [("background", scale.background), ("color", scale.color)]:
1378
+ if target is None:
1379
+ continue
1380
+ if value is None:
1381
+ if target.null_color is not None:
1382
+ if attr == "background":
1383
+ bg = target.null_color
1384
+ else:
1385
+ color = target.null_color
1386
+ elif (numeric_value := coerce_numeric(value)) is not None:
1387
+ lo, hi = compute_scale_domain(data, col_name, target)
1388
+ palette = resolve_palette_stops(target.palette, surface="table")
1389
+ hinge = resolve_hinge(target, lo, hi, col_format)
1390
+ scaled = interpolate_scale_color(
1391
+ numeric_value,
1392
+ lo,
1393
+ hi,
1394
+ palette,
1395
+ hinge=hinge,
1396
+ arm_mode=target.arm_mode,
1397
+ )
1398
+ if attr == "background":
1399
+ bg = scaled
1400
+ else:
1401
+ color = scaled
1402
+
1403
+ # Layer 3: when rules from the chart-level conditional_formatting block.
1404
+ if when_rules:
1405
+ overrides = resolve_conditional_styles(when_rules, value)
1406
+ if "background" in overrides:
1407
+ bg = overrides["background"]
1408
+ if "color" in overrides:
1409
+ color = overrides["color"]
1410
+ if "weight" in overrides:
1411
+ fw = overrides["weight"]
1412
+ if "style" in overrides:
1413
+ fstyle = overrides["style"]
1414
+ if "decoration" in overrides:
1415
+ fdecoration = overrides["decoration"]
1416
+
1417
+ return bg, color, fw, fstyle, fdecoration
1418
+
1419
+
1420
+ def resolve_cell_glyph(
1421
+ col_config: TableColumnConfig | None,
1422
+ value: Any,
1423
+ when_rules: Sequence[ConditionalRule] | None,
1424
+ ) -> tuple[str | None, str | None]:
1425
+ """Resolve the effective glyph + glyph_color for a single cell.
1426
+
1427
+ A column's static ``glyph`` is the base; a matching ``when`` rule's
1428
+ ``glyph`` overrides it. When a rule sets ``glyph`` but not
1429
+ ``glyph_color``, the cell falls back to default ink rather than
1430
+ inheriting the static or earlier-rule color (see
1431
+ ``_apply_rule_outputs``).
1432
+ """
1433
+ return resolve_cell_glyph_from_overrides(
1434
+ col_config,
1435
+ resolve_conditional_styles(when_rules, value) if when_rules else {},
1436
+ )
1437
+
1438
+
1439
+ def resolve_cell_glyph_from_overrides(
1440
+ col_config: TableColumnConfig | None,
1441
+ overrides: dict[str, Any],
1442
+ ) -> tuple[str | None, str | None]:
1443
+ """Variant that reads from an already-merged ``overrides`` dict.
1444
+
1445
+ Lets callers that already evaluated ``resolve_conditional_styles``
1446
+ (e.g. the table renderer's per-cell loop) avoid a second pass.
1447
+ """
1448
+ if "glyph" in overrides:
1449
+ return overrides["glyph"], overrides.get("glyph_color")
1450
+ if col_config is not None and col_config.glyph is not None:
1451
+ return col_config.glyph, col_config.glyph_color
1452
+ return None, None