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,2957 @@
1
+ """Table SVG rendering for Dataface dashboards.
2
+
3
+ Chart-level color and background channels are lowered to style.columns by
4
+ pipeline._lower_channels_to_table before this renderer runs. Opacity and
5
+ stroke_* are not meaningful on table charts and are rejected at normalize time.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import html as html_module
11
+ import re
12
+ from dataclasses import dataclass
13
+ from typing import TYPE_CHECKING, Any
14
+
15
+ from dataface.core.compile.models.primitives import FontStyle
16
+
17
+ if TYPE_CHECKING:
18
+ from dataface.core.compile.models.chart.authored import (
19
+ SparkConfig,
20
+ TableColumnConfig,
21
+ )
22
+ from dataface.core.compile.models.style.compiled import (
23
+ PaginationConfig,
24
+ TableRowRoleStyle,
25
+ )
26
+ from dataface.core.compile.models.style.merged import MergedChartsStyle, MergedStyle
27
+ from dataface.core.compile.colors import is_dark_color, sanitize_color
28
+ from dataface.core.compile.config import get_markdown_config
29
+ from dataface.core.compile.models.chart.authored import coerce_numeric
30
+ from dataface.core.compile.models.style.compiled import (
31
+ VALID_FONT_WEIGHTS,
32
+ PaginatorStyle,
33
+ TableChartStyle,
34
+ TableChartStylePatch,
35
+ TableColumnDefaultsConfig,
36
+ TitleStyle,
37
+ font_weight_as_css,
38
+ )
39
+ from dataface.core.compile.typography import chart_title_spec
40
+ from dataface.core.render.chart.table_support import (
41
+ _WORD_SUFFIXES,
42
+ calculate_column_layout,
43
+ format_table_cell_value,
44
+ is_date_like,
45
+ is_summary_role,
46
+ is_total_role,
47
+ measure_column_demands,
48
+ measure_column_word_floors,
49
+ parse_table_column_configs,
50
+ resolve_cell_conditional_styles,
51
+ resolve_cell_glyph,
52
+ resolve_cell_glyph_from_overrides,
53
+ resolve_cell_link_with_board,
54
+ resolve_conditional_styles,
55
+ resolve_header_overflow,
56
+ resolve_row_role,
57
+ resolve_table_style_value,
58
+ resolve_wrapped_headers,
59
+ )
60
+ from dataface.core.render.chart.title_overflow import (
61
+ compute_title_limit,
62
+ prepare_title_text,
63
+ resolve_title_overflow,
64
+ )
65
+ from dataface.core.render.errors import ChartDataError
66
+ from dataface.core.render.font_measurement import get_font_measurer
67
+ from dataface.core.render.font_support import DFT_SANS_TABULAR_FONT_FAMILY
68
+ from dataface.core.render.format_utils import format_kpi_parts, resolve_format
69
+ from dataface.core.render.svg_utils import _px
70
+ from dataface.core.render.text.case import apply_case
71
+ from dataface.core.render.utils import (
72
+ normalize_data_types,
73
+ slug_to_text,
74
+ )
75
+ from mdsvg.fonts import truncate_text_precise, wrap_text_precise
76
+
77
+ # Height of the pagination control bar (chevrons + page numbers).
78
+ _PAGINATION_CONTROL_HEIGHT = 28
79
+
80
+ # Anti-dangle: collapse a small trailing page by squeezing row_height.
81
+ # Only fire when the overflow is 1-2 rows (not a "real" second page).
82
+ _ANTI_DANGLE_MAX_OVERFLOW = 2
83
+ # Maximum row_height reduction ratio (15%): 32px → 27px still reads fine.
84
+ _ANTI_DANGLE_MAX_SQUEEZE = 0.15
85
+ # Hard pixel floor: below 20px text clips descenders at our smallest body size.
86
+ _ANTI_DANGLE_MIN_ROW_H = 20
87
+
88
+ # Sentinel column key for the synthetic row-number column. The key uses a
89
+ # private-use character that YAML-authored column names cannot reach, so
90
+ # style.columns lookups and conditional_formatting dicts keyed by column name
91
+ # never match it. It is transparent to column_configs and when_rules.
92
+ _ROW_NUMBER_COL = "\u0001__row_number__"
93
+
94
+
95
+ # Fallback chart width when neither layout nor sizer supplies one. Used for
96
+ # title-block sizing math; both `_get_table_height_from_data` and the renderer
97
+ # read this so the two stay in lockstep.
98
+ TABLE_DEFAULT_WIDTH: float = 800.0
99
+
100
+
101
+ def _subtitle_baseline_below_title(
102
+ title_bottom: float,
103
+ title_subtitle_gap: float,
104
+ subtitle_font_size: float,
105
+ ) -> float:
106
+ """Place the subtitle baseline safely below the title block."""
107
+ subtitle_top = title_bottom + title_subtitle_gap
108
+ return subtitle_top + (subtitle_font_size * 0.8)
109
+
110
+
111
+ @dataclass(frozen=True)
112
+ class TableTitleBlockLayout:
113
+ """Resolved title-block geometry shared between sizer and renderer.
114
+
115
+ The renderer consumes every field; the sizer only reads ``height``. Both
116
+ paths run through ``compute_table_title_block_layout`` so the height the
117
+ sizer reserves matches what the renderer actually paints.
118
+ """
119
+
120
+ height: int
121
+ rendered_title: str = ""
122
+ title_lines: tuple[str, ...] = ()
123
+ subtitle_font_size: float = 0.0
124
+
125
+
126
+ def compute_table_title_block_layout(
127
+ *,
128
+ chart_title: str,
129
+ chart_subtitle: str,
130
+ table_width: float,
131
+ tc: TableChartStyle,
132
+ padding: int,
133
+ title_style: TitleStyle,
134
+ resolved_chart_style: MergedChartsStyle,
135
+ face_level: int = 1,
136
+ ) -> TableTitleBlockLayout:
137
+ """Resolve the title-block layout used by both renderer and sizer.
138
+
139
+ Single source of truth — runs ``prepare_title_text`` once, computes
140
+ subtitle font size once. The renderer reads every field for SVG
141
+ emission; the sizer reads only ``height``.
142
+
143
+ Args:
144
+ resolved_chart_style: Active ``MergedChartsStyle`` for the face. Threaded
145
+ into ``chart_title_spec`` so the title font family reflects the
146
+ active theme, not the global default config.
147
+ face_level: Heading level of the parent face (root=1, nested=2, …).
148
+ Chart title uses face_level + 1 so titles are one level below their
149
+ containing face header.
150
+
151
+ Returns a zero-height layout when ``chart_title`` is empty.
152
+ """
153
+ if not chart_title:
154
+ return TableTitleBlockLayout(height=0)
155
+ title_font_size, _, title_font_family = chart_title_spec(
156
+ table_width, level=face_level + 1, resolved_chart_style=resolved_chart_style
157
+ )
158
+ title_line_height = title_font_size + 2
159
+ rendered_title = prepare_title_text(
160
+ chart_title,
161
+ overflow=resolve_title_overflow(title_style),
162
+ limit=compute_title_limit(table_width, {"left": padding, "right": padding}),
163
+ font_size=title_font_size,
164
+ font_family=title_font_family,
165
+ )
166
+ title_lines = tuple(rendered_title.splitlines() or [chart_title])
167
+ title_baseline = padding + tc.title_baseline_offset
168
+ last_title_baseline = title_baseline + ((len(title_lines) - 1) * title_line_height)
169
+ title_bottom = last_title_baseline + (title_font_size * 0.5)
170
+ height = max(int(tc.title_row.height), int(title_bottom - padding + 8))
171
+ subtitle_font_size = 0.0
172
+ if chart_subtitle:
173
+ assert (
174
+ tc.subtitle.font.size is not None
175
+ ), "theme must supply table.subtitle.font.size"
176
+ subtitle_font_size = float(tc.subtitle.font.size)
177
+ subtitle_baseline = _subtitle_baseline_below_title(
178
+ title_bottom=title_bottom,
179
+ title_subtitle_gap=tc.title_subtitle_gap,
180
+ subtitle_font_size=subtitle_font_size,
181
+ )
182
+ subtitle_bottom = subtitle_baseline + (subtitle_font_size * 0.5)
183
+ height = max(height, int(subtitle_bottom - padding + 8))
184
+ return TableTitleBlockLayout(
185
+ height=height,
186
+ rendered_title=rendered_title,
187
+ title_lines=title_lines,
188
+ subtitle_font_size=subtitle_font_size,
189
+ )
190
+
191
+
192
+ def _row_number_column_width(
193
+ row_numbers: Any,
194
+ total_row_count: int,
195
+ table_config: Any,
196
+ font: FontStyle,
197
+ measurer: Any,
198
+ ) -> float:
199
+ """Compute the synthetic row-number column width.
200
+
201
+ Sized from ``total_row_count`` digit count — not per-page count — so the
202
+ column width is the same on every page of a paginated table.
203
+ """
204
+ font_size = float(font.size) if font.size is not None else 13.0
205
+ digits = max(len(str(max(total_row_count, 1))), len(row_numbers.header))
206
+ text_w = max(
207
+ measurer.measure("9" * digits, font_size),
208
+ measurer.measure(row_numbers.header, font_size),
209
+ )
210
+ cell_pad = int(table_config.column_layout.cell_padding)
211
+ return float(text_w + cell_pad * 2)
212
+
213
+
214
+ # CSS font stack for numeric table cells — matches Vega axisQuantitative config
215
+ _SANS_NUMERIC_FONT_STACK = (
216
+ f"'{DFT_SANS_TABULAR_FONT_FAMILY}', Inter, system-ui, sans-serif"
217
+ )
218
+
219
+
220
+ def _is_numeric_cell(value: Any, col_config: Any) -> bool:
221
+ """Determine if a cell should receive numeric styling (tabular font, three-lane layout).
222
+
223
+ Checks the Python type first, then falls back to probing string values.
224
+ A column with an explicit numeric format config (currency, percent, etc.)
225
+ is always treated as numeric regardless of the runtime value type — CSV
226
+ adapters deliver everything as strings.
227
+ """
228
+ if isinstance(value, bool):
229
+ return False
230
+ if isinstance(value, (int, float)):
231
+ return True
232
+
233
+ # If the column has a format config, treat it as numeric — the author
234
+ # declared intent by attaching a number format.
235
+ if col_config and col_config.format is not None:
236
+ return True
237
+
238
+ # Probe string values that look like numbers (from CSV adapters)
239
+ if isinstance(value, str) and value:
240
+ stripped = value.strip()
241
+ if stripped:
242
+ try:
243
+ float(stripped)
244
+ return True
245
+ except ValueError:
246
+ pass
247
+ return False
248
+
249
+
250
+ def _wants_tabular_font(value: Any, col_config: Any) -> bool:
251
+ """Return True if the cell should use tabular (monospaced-digit) font.
252
+
253
+ Covers numbers AND date-like strings — anything where digits need to
254
+ align vertically across rows.
255
+ """
256
+ return _is_numeric_cell(value, col_config) or is_date_like(value)
257
+
258
+
259
+ # Swatch column constants. Swatch cells render a small rounded color square
260
+ # instead of text — used for series-keyed tables (e.g. donut-attached tables
261
+ # where each row carries the parent chart's palette color). The square sits
262
+ # inside the cell with the standard cell-x padding (4) and is vertically
263
+ # centered against the row's effective band by the caller.
264
+ _SWATCH_SIZE = 14
265
+ _SWATCH_CORNER_RADIUS = 3
266
+
267
+
268
+ def _render_swatch_cell(value: Any, col: str, row_idx: int, chart_id: str) -> str:
269
+ """Render a small rounded color square for a swatch-typed cell.
270
+
271
+ Cell value must be a hex color string (e.g. ``#3164a3``) or one of the
272
+ sanitizer-accepted keywords (``transparent`` / ``none``). Raises
273
+ ``ChartDataError`` on any non-color value — a column declared
274
+ ``swatch: true`` whose cell content is empty or non-color is a misconfig,
275
+ not paint. Failing fast points the author at the wrong column / wrong
276
+ data rather than silently shipping a half-broken table.
277
+ """
278
+ from dataface.core.render.errors import ChartDataError
279
+
280
+ color = sanitize_color(value, None) if isinstance(value, str) else None
281
+ if not color:
282
+ raise ChartDataError(
283
+ f"Column {col!r} declares swatch: true but row {row_idx} value "
284
+ f"{value!r} is not a CSS color. Swatch cells must be hex "
285
+ f"(e.g. '#3164a3') or 'transparent'. Either point swatch: true "
286
+ f"at a different column, or fix the data.",
287
+ chart_id=chart_id,
288
+ )
289
+ return (
290
+ f'<rect width="{_SWATCH_SIZE}" height="{_SWATCH_SIZE}" '
291
+ f'rx="{_SWATCH_CORNER_RADIUS}" fill="{color}"/>'
292
+ )
293
+
294
+
295
+ def _render_spark_cell(
296
+ value: Any,
297
+ spark_config: SparkConfig,
298
+ cell_width: float,
299
+ row_height: float,
300
+ cell_font: FontStyle | None = None,
301
+ resolved_style: MergedChartsStyle | None = None,
302
+ column_max: float | None = None,
303
+ ) -> tuple[str, int]:
304
+ """Render a spark chart for a table cell.
305
+
306
+ Args:
307
+ value: Cell value (array for line/area/columns; scalar for bar/bar-normalize).
308
+ spark_config: SparkConfig instance with typed configuration
309
+ cell_width: Available cell width
310
+ row_height: Nominal row height used to size the spark mark. The mark's
311
+ visual size stays pinned to the nominal row height so wrapped
312
+ rows don't grow their spark vertically — the caller centers the
313
+ mark within the row's effective (possibly grown) height instead.
314
+ cell_font: Table FontStyle to inherit for bar/bar-normalize value labels.
315
+ column_max: Pre-computed column max for `bar` auto-max (None = use default).
316
+
317
+ Returns:
318
+ ``(svg_content, spark_height)``. ``svg_content`` is the inner SVG
319
+ (sans wrapper) for embedding under a translate-wrapped `<g>`; the
320
+ height is reported so the caller can vertical-center the mark
321
+ against the row's effective height. Empty string + 0 when the
322
+ value can't be rendered (e.g. non-numeric for bar variants).
323
+ """
324
+ from dataface.core.render.chart.spark import render_spark
325
+
326
+ if resolved_style is None:
327
+ raise ValueError(
328
+ "_render_spark_cell requires a resolved charts style (resolved_style)"
329
+ )
330
+ spark_type = spark_config.type
331
+
332
+ # Calculate spark dimensions to fit in cell
333
+ padding = 8
334
+ spark_width = spark_config.width or int(cell_width - (padding * 2))
335
+ spark_height = spark_config.height or int(row_height - 8)
336
+
337
+ # Cap dimensions
338
+ spark_width = min(spark_width, int(cell_width - padding))
339
+ spark_height = min(spark_height, int(row_height - 4))
340
+
341
+ # Build options dict from SparkConfig (exclude type/width/height, drop None values)
342
+ options = spark_config.model_dump(
343
+ exclude={"type", "width", "height"},
344
+ exclude_none=True,
345
+ )
346
+
347
+ # Auto-max: `bar` (no explicit ceiling) auto-scales width to the column
348
+ # data max. `bar-normalize` always uses an explicit `max:` (or the theme
349
+ # `default_max`) — the whole point of the variant is "% of ceiling," so
350
+ # silently rescaling to the data max would defeat the contract.
351
+ if spark_type == "bar" and spark_config.max is None and column_max is not None:
352
+ options["max"] = column_max
353
+
354
+ # For single-bar sparklines, only render when value is numeric.
355
+ # This lets mixed tables (text + numeric rows) use one spark config without
356
+ # replacing text cells with zero-width bars.
357
+ if spark_type in ("bar", "bar-normalize"):
358
+ if isinstance(value, bool):
359
+ return "", 0
360
+ if not isinstance(value, (int, float)):
361
+ try:
362
+ float(str(value))
363
+ except (TypeError, ValueError):
364
+ return "", 0
365
+
366
+ # Render the spark SVG
367
+ spark_svg = render_spark(
368
+ value,
369
+ spark_type,
370
+ width=spark_width,
371
+ height=spark_height,
372
+ font=cell_font,
373
+ resolved_style=resolved_style,
374
+ **options,
375
+ )
376
+
377
+ # Extract inner content from SVG (remove wrapper)
378
+ inner_match = re.search(r"<svg[^>]*>(.*?)</svg>", spark_svg, re.DOTALL)
379
+ if inner_match is None:
380
+ raise ValueError(f"render_spark returned invalid SVG: {spark_svg[:100]}")
381
+ return inner_match.group(1), spark_height
382
+
383
+
384
+ def _count_rows_fitting_height(row_heights: list[int], available: float) -> int:
385
+ """Count leading rows whose cumulative height fits within ``available``.
386
+
387
+ Returns at least 1 (so the table renders at least one row even when the
388
+ first row is taller than the budget).
389
+ """
390
+ cumulative = 0.0
391
+ count = 0
392
+ for h in row_heights:
393
+ if cumulative + h > available and count >= 1:
394
+ break
395
+ cumulative += h
396
+ count += 1
397
+ return max(1, count)
398
+
399
+
400
+ def _largest_safe_page_size(
401
+ row_heights: list[int], available: float, max_page_size: int
402
+ ) -> int:
403
+ """Largest ``size <= max_page_size`` such that every contiguous
404
+ ``size``-row window sums to ≤ ``available``.
405
+
406
+ Safe under mixed row heights: page 1 fitting doesn't imply page 2 fits.
407
+ Returns 1 at minimum so the table always renders something.
408
+ """
409
+ n = len(row_heights)
410
+ if n == 0:
411
+ return 1
412
+ for size in range(min(max_page_size, n), 0, -1):
413
+ if all(sum(row_heights[s : s + size]) <= available for s in range(0, n, size)):
414
+ return size
415
+ return 1
416
+
417
+
418
+ def _max_page_sum(row_heights: list[int], page_size: int) -> int:
419
+ """Max over every contiguous page-sized window of ``row_heights``.
420
+
421
+ Shared between ``_resolve_visible_rows`` (table_height for paginated
422
+ auto-height tables) and the more-rows indicator placement so both sit
423
+ at the same y-coordinate across pages.
424
+ """
425
+ if not row_heights or page_size <= 0:
426
+ return 0
427
+ return max(
428
+ sum(row_heights[s : s + page_size])
429
+ for s in range(0, len(row_heights), page_size)
430
+ )
431
+
432
+
433
+ def _resolve_visible_rows(
434
+ data: list[dict[str, Any]],
435
+ height: float | None,
436
+ title_height: float,
437
+ header_height: float,
438
+ padding: int,
439
+ row_height: int,
440
+ bottom_padding: int,
441
+ pagination: PaginationConfig | None = None,
442
+ page: int = 1,
443
+ row_heights: list[int] | None = None,
444
+ header_visible: bool = True,
445
+ ) -> tuple[float, list[dict[str, Any]], int, int, list[int] | None, int, int]:
446
+ """Resolve table height, visible rows, total page count, page offset, and rows-height.
447
+
448
+ Returns ``(table_height, visible_data, total_pages, page_offset,
449
+ visible_row_heights, rows_height, effective_row_height)``.
450
+ ``rows_height`` is the vertical extent used for the data-row section
451
+ (tallest page when paginated + multi-page, else this page's own rows) —
452
+ callers place pagination indicators off this value so controls sit at a
453
+ consistent y across pages.
454
+
455
+ ``effective_row_height`` equals ``row_height`` unless the anti-dangle
456
+ heuristic fired and squeezed it to collapse a 1-2 row trailing page.
457
+ The caller must use this value for all per-row drawing so the squeezed
458
+ geometry is consistent end-to-end.
459
+
460
+ When ``row_heights`` is provided, pagination splitting and fixed-height
461
+ row-count calculations operate on real cumulative heights; otherwise
462
+ the uniform ``row_height`` is used.
463
+ """
464
+ # Chart-local pagination is pre-merged into ``pagination`` by the cascade —
465
+ # read the resolved page_size off the merged value. When pagination is
466
+ # enabled but no page_size is set, default to ``len(data)`` so the bounded
467
+ # auto-shrink branch still fires: a small cell will then split into pages
468
+ # rather than silently dropping rows into the "+ N more rows" footer, which
469
+ # is reserved for the explicit pagination-disabled case.
470
+ if pagination is not None and pagination.enabled:
471
+ page_size = (
472
+ pagination.page_size
473
+ if pagination.page_size is not None
474
+ else (len(data) if data else 1)
475
+ )
476
+ else:
477
+ page_size = None
478
+ page = max(1, page)
479
+
480
+ # Header-body gap is the visual buffer between the header rule and the first
481
+ # data row. When the header is hidden, this gap must collapse to zero so the
482
+ # sizer (layout_sizing._get_table_height_from_data) and the renderer's final
483
+ # placement (current_y bump after the header section, ~line 2762) agree on
484
+ # total height. The squeezed-row path below mirrors the same conditional.
485
+ header_body_gap = int(row_height * 0.25) if header_visible else 0
486
+
487
+ def _slice_heights(start: int, count: int) -> list[int] | None:
488
+ if row_heights is None:
489
+ return None
490
+ return row_heights[start : start + count]
491
+
492
+ def _rows_height(visible_heights: list[int] | None, visible_n: int) -> int:
493
+ if visible_heights is not None:
494
+ return sum(visible_heights)
495
+ return visible_n * row_height
496
+
497
+ if height and height > 0:
498
+ table_height = height
499
+ available_height = (
500
+ table_height
501
+ - title_height
502
+ - header_height
503
+ - header_body_gap
504
+ - padding
505
+ - bottom_padding
506
+ )
507
+ if page_size is not None:
508
+ # Reserve pagination control height whenever pagination will
509
+ # fire — either because page_size splits the data, or because
510
+ # the allotted height fits fewer rows than the data.
511
+ #
512
+ # With variable row heights, the probe must check every page's
513
+ # window, not just the leading rows — otherwise a short page 1
514
+ # leads to a picked `effective` that overflows page 2.
515
+ def _safe_page_size(avail: float) -> int:
516
+ if row_heights is not None:
517
+ return _largest_safe_page_size(row_heights, avail, page_size)
518
+ return min(max(1, int(avail / row_height)), page_size)
519
+
520
+ probe_effective = _safe_page_size(available_height)
521
+ if len(data) > probe_effective:
522
+ overflow = len(data) - probe_effective
523
+ # Anti-dangle: if the overflow is ≤ 2 rows, try squeezing
524
+ # row_height to fit all rows in available_height. Only fire
525
+ # when row_heights is None (uniform rows); variable-height
526
+ # tables have row-specific measurements that can't be rescaled.
527
+ #
528
+ # Known pre-existing limitation: when row_role_spec inserts
529
+ # summary gaps (~0.4 * row_height per summary transition),
530
+ # available_height here does not subtract that reservation,
531
+ # so the squeezed layout can exceed the bounded card height
532
+ # by ~13px per gap. The non-squeeze bounded path below has
533
+ # the same blind spot. Fixing requires plumbing
534
+ # row_role_spec into pick_table_layout so we can count gaps
535
+ # over the visible slice; deferred pre-launch.
536
+ if row_heights is None and 0 < overflow <= _ANTI_DANGLE_MAX_OVERFLOW:
537
+ squeezed = max(
538
+ _ANTI_DANGLE_MIN_ROW_H, int(available_height // len(data))
539
+ )
540
+ squeeze_ratio = squeezed / row_height
541
+ if (
542
+ squeeze_ratio >= (1.0 - _ANTI_DANGLE_MAX_SQUEEZE)
543
+ and squeezed * len(data) <= available_height
544
+ ):
545
+ squeezed_rh = len(data) * squeezed
546
+ return (
547
+ table_height,
548
+ data,
549
+ 1,
550
+ 0,
551
+ None,
552
+ squeezed_rh,
553
+ squeezed,
554
+ )
555
+ # Pagination controls will render — re-pick effective with
556
+ # the reduced budget so rows leave room for the controls.
557
+ effective = _safe_page_size(
558
+ available_height - _PAGINATION_CONTROL_HEIGHT
559
+ )
560
+ else:
561
+ effective = probe_effective
562
+ total_pages = max(1, -(-len(data) // effective)) if data else 1
563
+ page = min(page, total_pages)
564
+ start = (page - 1) * effective
565
+ visible_heights = _slice_heights(start, effective)
566
+ # Use the tallest page's rows_height so pagination controls sit
567
+ # at the same y across pages (otherwise they wobble as the user
568
+ # clicks prev/next on mixed-height data).
569
+ if row_heights is not None and total_pages > 1:
570
+ rows_height_out = _max_page_sum(row_heights, effective)
571
+ else:
572
+ rows_height_out = _rows_height(visible_heights, effective)
573
+ return (
574
+ table_height,
575
+ data[start : start + effective],
576
+ total_pages,
577
+ start,
578
+ visible_heights,
579
+ rows_height_out,
580
+ row_height,
581
+ )
582
+ if row_heights is not None:
583
+ max_rows = _count_rows_fitting_height(row_heights, available_height)
584
+ else:
585
+ max_rows = max(1, int(available_height / row_height))
586
+ visible_heights = _slice_heights(0, max_rows)
587
+ return (
588
+ table_height,
589
+ data[:max_rows],
590
+ 1,
591
+ 0,
592
+ visible_heights,
593
+ _rows_height(visible_heights, max_rows),
594
+ row_height,
595
+ )
596
+
597
+ if page_size is not None:
598
+ n_data = len(data)
599
+ overflow = n_data - page_size
600
+ # Anti-dangle (unbounded path): if a 1-2 row tail would create a
601
+ # second page, squeeze row_height to fit all rows on one page.
602
+ # Only fire for uniform-height rows (row_heights is None).
603
+ if (
604
+ row_heights is None
605
+ and n_data > 0
606
+ and 0 < overflow <= _ANTI_DANGLE_MAX_OVERFLOW
607
+ ):
608
+ squeeze_ratio = page_size / n_data
609
+ if squeeze_ratio >= (1.0 - _ANTI_DANGLE_MAX_SQUEEZE):
610
+ squeezed = max(
611
+ _ANTI_DANGLE_MIN_ROW_H, int(round(row_height * squeeze_ratio))
612
+ )
613
+ squeezed_rows_height = squeezed * n_data
614
+ squeezed_header_body_gap = int(squeezed * 0.25) if header_visible else 0
615
+ squeezed_table_height = (
616
+ title_height
617
+ + header_height
618
+ + squeezed_header_body_gap
619
+ + squeezed_rows_height
620
+ + padding
621
+ + bottom_padding
622
+ )
623
+ return (
624
+ squeezed_table_height,
625
+ data,
626
+ 1,
627
+ 0,
628
+ None,
629
+ squeezed_rows_height,
630
+ squeezed,
631
+ )
632
+ total_pages = max(1, -(-n_data // page_size)) if data else 1
633
+ page = min(page, total_pages)
634
+ start = (page - 1) * page_size
635
+ visible_data = data[start : start + page_size]
636
+ visible_heights = _slice_heights(start, page_size)
637
+ # Size to the tallest page so every page renders at the same height.
638
+ if row_heights is not None and total_pages > 1:
639
+ rows_height = _max_page_sum(row_heights, page_size)
640
+ else:
641
+ # Use page_size (not len(visible_data)) for uniform height across
642
+ # pages when paginating uniform-height rows; the last page may
643
+ # have fewer rows but keeps the same table height.
644
+ row_slots = page_size if total_pages > 1 else len(visible_data)
645
+ rows_height = _rows_height(visible_heights, row_slots)
646
+ table_height = (
647
+ title_height
648
+ + header_height
649
+ + header_body_gap
650
+ + rows_height
651
+ + padding
652
+ + bottom_padding
653
+ )
654
+ return (
655
+ table_height,
656
+ visible_data,
657
+ total_pages,
658
+ start,
659
+ visible_heights,
660
+ rows_height,
661
+ row_height,
662
+ )
663
+
664
+ num_rows = len(data)
665
+ rows_height = sum(row_heights) if row_heights is not None else num_rows * row_height
666
+ table_height = (
667
+ title_height
668
+ + header_height
669
+ + header_body_gap
670
+ + rows_height
671
+ + padding
672
+ + bottom_padding
673
+ )
674
+ return table_height, data, 1, 0, row_heights, rows_height, row_height
675
+
676
+
677
+ def _compute_content_span(
678
+ columns: list[str],
679
+ col_widths: dict[str, float],
680
+ col_x_offsets: list[float],
681
+ padding_x: int,
682
+ cell_pad: int,
683
+ table_width: float,
684
+ ) -> tuple[float, float]:
685
+ """Compute (x1, x2) for horizontal elements spanning all columns.
686
+
687
+ Returns the cell-content edge: from the first column's content start
688
+ to the last column's content end. Used for header background, header
689
+ rules (continuous), row stripes, row rules, and summary/total rules
690
+ — all horizontal spans use this one function so they share a visible
691
+ right edge.
692
+
693
+ The cell-content edge sits cell_pad inside the outer cell bounds,
694
+ matching where text is already laid out. That gives text and rects
695
+ a shared visible edge.
696
+ """
697
+ if not columns or not col_x_offsets:
698
+ return 0, table_width
699
+ first_cell_x = padding_x + col_x_offsets[0]
700
+ last_col = columns[-1]
701
+ last_cell_x = padding_x + col_x_offsets[-1]
702
+ last_cw = col_widths.get(last_col, 100)
703
+ x1 = first_cell_x + cell_pad
704
+ x2 = last_cell_x + last_cw - cell_pad
705
+ return x1, x2
706
+
707
+
708
+ def _render_header_section(
709
+ svg_parts: list[str],
710
+ markdown_config: Any,
711
+ columns: list[str],
712
+ column_configs: dict[str, TableColumnConfig],
713
+ colors: dict[str, str],
714
+ table_config: Any,
715
+ table_width: float,
716
+ header_height: float,
717
+ current_y: float,
718
+ padding_x: int,
719
+ col_x_offsets: list[float],
720
+ col_widths: dict[str, float],
721
+ col_lane_positions: dict[str, tuple[float, float, float, float, float]],
722
+ header_font: FontStyle,
723
+ wrapped_headers: dict[str, list[str]] | None = None,
724
+ cell_pad: int | None = None,
725
+ header_rule_width: float = 1.0,
726
+ rule_color: str | None = None,
727
+ header_rule_continuous: bool = False,
728
+ row_numbers: Any = None,
729
+ ) -> None:
730
+ """Render header background, border, and labels."""
731
+ header_font_size = int(header_font.size) if header_font.size is not None else 12
732
+ header_font_weight = (
733
+ str(header_font.weight) if header_font.weight is not None else "600"
734
+ )
735
+ effective_font_family = header_font.family
736
+ # Header background paints edge-to-edge across the full column span.
737
+ bg_x1, bg_x2 = _compute_content_span(
738
+ columns=columns,
739
+ col_widths=col_widths,
740
+ col_x_offsets=col_x_offsets,
741
+ padding_x=padding_x,
742
+ cell_pad=0,
743
+ table_width=table_width,
744
+ )
745
+ # Skip the header background rect when transparent — no point emitting
746
+ # invisible paint.
747
+ _hdr_bg = colors["header_background"]
748
+ if _hdr_bg and _hdr_bg.lower() != "transparent":
749
+ svg_parts.append(
750
+ f'<rect x="{bg_x1}" y="{current_y}" width="{bg_x2 - bg_x1}" '
751
+ f'height="{header_height}" '
752
+ f'fill="{_hdr_bg}"/>',
753
+ )
754
+ effective_rule_color = rule_color or colors["color"]
755
+ if header_rule_width > 0:
756
+ # Rules rendered as <rect> for consistent thickness at any browser
757
+ # scale. Integer y + integer height = exact pixel coverage.
758
+ rule_y = int(current_y + header_height - header_rule_width)
759
+ effective_pad = (
760
+ cell_pad
761
+ if cell_pad is not None
762
+ else table_config.column_layout.cell_padding
763
+ )
764
+ if header_rule_continuous and columns and col_x_offsets:
765
+ # Single unbroken rule spanning the cell-content edge — same
766
+ # extent as stripes and row rules so all right edges align.
767
+ x1, x2 = _compute_content_span(
768
+ columns=columns,
769
+ col_widths=col_widths,
770
+ col_x_offsets=col_x_offsets,
771
+ padding_x=padding_x,
772
+ cell_pad=effective_pad,
773
+ table_width=table_width,
774
+ )
775
+ svg_parts.append(
776
+ f'<rect x="{x1}" y="{rule_y}" width="{x2 - x1}" '
777
+ f'height="{header_rule_width}" fill="{effective_rule_color}" '
778
+ f'shape-rendering="crispEdges"/>',
779
+ )
780
+ else:
781
+ # Per-column rules: EVERY rule centers on the cell midpoint,
782
+ # matching where the header text and number tspan center.
783
+ # Rule width = max(widest wrapped header line, value lane
784
+ # extent) so the rule always covers whatever is visually
785
+ # anchored above it. Text columns (no lane) use the widest
786
+ # header line plus minimal breathing.
787
+ _RULE_GAP = 4 # min px gap between adjacent per-column rules
788
+ measurer = get_font_measurer(effective_font_family)
789
+ resolved_wrapped = wrapped_headers or {}
790
+ for i, col in enumerate(columns):
791
+ cw = col_widths.get(col, 100)
792
+ cell_x = padding_x + col_x_offsets[i]
793
+ cell_left_bound = cell_x + _RULE_GAP
794
+ cell_right_bound = cell_x + cw - _RULE_GAP
795
+
796
+ col_config = column_configs.get(col)
797
+ display_name = (
798
+ col_config.label if col_config and col_config.label else col
799
+ )
800
+ if display_name == col:
801
+ display_name = slug_to_text(display_name)
802
+ # Use the WIDEST WRAPPED LINE (what's actually rendered),
803
+ # not the full unwrapped name.
804
+ lines = resolved_wrapped.get(col) or [display_name]
805
+ widest_line = max(lines, key=len)
806
+ label_w = measurer.measure(widest_line, header_font_size)
807
+
808
+ if col in col_lane_positions:
809
+ # Numeric column: center rule on cell midpoint.
810
+ # Rule width covers the widest content element — the
811
+ # header line or the value lane, whichever is wider.
812
+ _, _, _, content_left, content_right = col_lane_positions[col]
813
+ lane_w = content_right - content_left
814
+ rule_w = max(label_w, lane_w)
815
+ center = cell_x + cw / 2
816
+ x1 = center - rule_w / 2
817
+ x2 = center + rule_w / 2
818
+ else:
819
+ # Text column: header is left-aligned, so the rule is
820
+ # too. Width at least spans the label.
821
+ x1 = cell_x + effective_pad
822
+ x2 = x1 + max(label_w, cw - 2 * effective_pad)
823
+
824
+ # Clamp to cell + gap so adjacent column rules don't touch.
825
+ x1 = max(x1, cell_left_bound)
826
+ x2 = min(x2, cell_right_bound)
827
+ if x2 > x1:
828
+ svg_parts.append(
829
+ f'<rect x="{x1}" y="{rule_y}" width="{x2 - x1}" '
830
+ f'height="{header_rule_width}" fill="{effective_rule_color}" '
831
+ f'shape-rendering="crispEdges"/>',
832
+ )
833
+
834
+ markdown_colors = (
835
+ markdown_config.dark
836
+ if is_dark_color(colors["background"])
837
+ else markdown_config.light
838
+ )
839
+ link_color = markdown_colors.link_color
840
+ header_line_height = header_font_size + table_config.text_baseline_offset
841
+ resolved_wrapped_headers = wrapped_headers or {}
842
+
843
+ # Bottom-align all headers: position every header so its last line
844
+ # sits at the same baseline, creating a firm line before values start.
845
+ bottom_baseline = current_y + header_height - table_config.text_baseline_offset
846
+
847
+ for i, col in enumerate(columns):
848
+ cell_x = padding_x + col_x_offsets[i]
849
+
850
+ effective_pad = (
851
+ cell_pad
852
+ if cell_pad is not None
853
+ else table_config.column_layout.cell_padding
854
+ )
855
+ cw = col_widths.get(col, 100)
856
+
857
+ # Synthetic row-number column: render row_numbers.header with the
858
+ # author-selected alignment. Skip all column-config lookups.
859
+ if col == _ROW_NUMBER_COL and row_numbers is not None:
860
+ display_name = row_numbers.header
861
+ text_fill = colors["label_color"]
862
+ if row_numbers.align == "right":
863
+ x = cell_x + cw - effective_pad
864
+ anchor = "end"
865
+ else:
866
+ x = cell_x + effective_pad
867
+ anchor = "start"
868
+ y = bottom_baseline
869
+ escaped_name = html_module.escape(display_name)
870
+ svg_parts.append(
871
+ f'<text x="{x}" y="{y}" '
872
+ f'font-size="{header_font_size}" font-weight="{header_font_weight}" fill="{text_fill}" '
873
+ f'text-anchor="{anchor}" '
874
+ f'font-family="{effective_font_family}">'
875
+ f"{escaped_name}</text>",
876
+ )
877
+ continue
878
+
879
+ col_config = column_configs.get(col)
880
+ display_name = (col_config.label if col_config else None) or slug_to_text(col)
881
+ _header_case = header_font.case or "none"
882
+ display_name = apply_case(display_name, _header_case)
883
+ display_lines = resolved_wrapped_headers.get(col) or [display_name]
884
+ header_link = col_config.header_link if col_config else None
885
+ text_fill = link_color if header_link else colors["label_color"]
886
+
887
+ # STRONG CENTER-ON-MIDPOINT INVARIANT:
888
+ # - Numeric columns: header centers on cell midpoint (same point
889
+ # where the number tspan centers and the per-column rule
890
+ # centers).
891
+ # - Text columns: header left-aligns at cell_x + pad.
892
+ # No fallbacks, no conditional right-alignment, no centered_fit
893
+ # calculations. If the header would overflow, the wrap path shrinks
894
+ # or token-splits it in ``resolve_wrapped_headers``.
895
+ if col in col_lane_positions:
896
+ x = cell_x + cw / 2
897
+ anchor = "middle"
898
+ else:
899
+ x = cell_x + effective_pad
900
+ anchor = "start"
901
+
902
+ if header_link:
903
+ escaped_href = html_module.escape(header_link, quote=True)
904
+ svg_parts.append(f'<a href="{escaped_href}">')
905
+
906
+ if len(display_lines) > 1:
907
+ lines = display_lines
908
+ start_y = bottom_baseline - (len(lines) - 1) * header_line_height
909
+ # Emit <title> only when wrap-two/truncate adds ellipsis — the
910
+ # visible lines don't fully represent the original display_name.
911
+ is_truncated = any("…" in ln for ln in lines)
912
+ multi_title = (
913
+ f"<title>{html_module.escape(display_name)}</title>"
914
+ if is_truncated
915
+ else ""
916
+ )
917
+ svg_parts.append(
918
+ f'<text x="{x}" '
919
+ f'font-size="{header_font_size}" font-weight="{header_font_weight}" fill="{text_fill}" '
920
+ f'text-anchor="{anchor}" '
921
+ f'font-family="{effective_font_family}">'
922
+ f"{multi_title}",
923
+ )
924
+ for li, line in enumerate(lines):
925
+ ly = start_y + li * header_line_height
926
+ escaped_line = html_module.escape(line)
927
+ svg_parts.append(f'<tspan x="{x}" y="{ly}">{escaped_line}</tspan>')
928
+ svg_parts.append("</text>")
929
+ else:
930
+ y = bottom_baseline
931
+ visible = display_lines[0]
932
+ escaped_name = html_module.escape(visible)
933
+ # Emit <title> only when clip/truncate shortened the label — the
934
+ # title carries the full text so AT is accessible while the visual
935
+ # text is abbreviated. Skip when visible == full name (no tooltip needed).
936
+ title_attr = (
937
+ f"<title>{html_module.escape(display_name)}</title>"
938
+ if visible != display_name
939
+ else ""
940
+ )
941
+ svg_parts.append(
942
+ f'<text x="{x}" y="{y}" '
943
+ f'font-size="{header_font_size}" font-weight="{header_font_weight}" fill="{text_fill}" '
944
+ f'text-anchor="{anchor}" '
945
+ f'font-family="{effective_font_family}">'
946
+ f"{title_attr}{escaped_name}</text>",
947
+ )
948
+
949
+ if header_link:
950
+ svg_parts.append("</a>")
951
+
952
+
953
+ _FIT_BREATH = 4 # px breathing room — content shouldn't kiss cell edges
954
+
955
+
956
+ def _has_overflow(
957
+ columns: list[str],
958
+ data: list[dict[str, Any]],
959
+ column_configs: dict[str, TableColumnConfig],
960
+ col_widths: dict[str, float],
961
+ cell_pad: int,
962
+ cell_font: FontStyle,
963
+ *,
964
+ header_font: FontStyle | None = None,
965
+ wrap: bool,
966
+ formats: dict[str, str] | None = None,
967
+ header_visible: bool = True,
968
+ ) -> bool:
969
+ """Return True if any column's widest value OR header exceeds its content area.
970
+
971
+ When *header_font* is given, also checks whether the column label
972
+ (display name) fits in a single line. Headers that would need to wrap
973
+ count as overflow so the fit cascade can shrink fonts to avoid awkward
974
+ line breaks.
975
+
976
+ When *wrap* is True, text cells are skipped — they'll wrap rather than
977
+ overflow, so only numeric/date columns drive the cascade.
978
+
979
+ When *header_visible* is False, the header label is excluded from the
980
+ overflow check — a hidden header can't drive the fit cascade.
981
+ """
982
+ font_size = float(cell_font.size) if cell_font.size is not None else 12.0
983
+ measurer = get_font_measurer(cell_font.family)
984
+ for col in columns:
985
+ col_config = column_configs.get(col)
986
+ # Swatch columns render a fixed 14×14 <rect>, not the cell text. A 7-char
987
+ # hex string in a 24px-wide swatch column would otherwise trip the
988
+ # text-overflow check and kick off the table-wide fit cascade.
989
+ if col_config and col_config.swatch:
990
+ continue
991
+ fmt = col_config.format if col_config else None
992
+ cw = col_widths.get(col, 100)
993
+ content_area = cw - cell_pad * 2 - _FIT_BREATH
994
+
995
+ # Check header label overflow (single-line fit) — only for short
996
+ # labels in multi-column layouts where wrapping looks awkward.
997
+ # Long headers in single-column tables should wrap gracefully.
998
+ # Skipped when header.visible: false — a hidden label can't overflow.
999
+ if header_font is not None and header_visible and len(columns) > 1:
1000
+ _hfs = (
1001
+ float(header_font.size) if header_font.size is not None else font_size
1002
+ )
1003
+ display_name = col_config.label if col_config and col_config.label else col
1004
+ display_name = (
1005
+ slug_to_text(display_name) if display_name == col else display_name
1006
+ )
1007
+ _header_case = header_font.case or "none"
1008
+ display_name = apply_case(display_name, _header_case)
1009
+ # Only flag short labels (≤12 chars) as overflow candidates —
1010
+ # "Growth", "Margin" shouldn't wrap, but "Opportunities
1011
+ # Subscription Start Date" is fine to wrap.
1012
+ if len(display_name) <= 12:
1013
+ header_w = measurer.measure(display_name, _hfs)
1014
+ if header_w > content_area:
1015
+ return True
1016
+
1017
+ for row in data[:50]:
1018
+ val = row.get(col, "")
1019
+ # In wrap mode, text cells wrap instead of overflow — skip them.
1020
+ if wrap and not (_is_numeric_cell(val, col_config) or is_date_like(val)):
1021
+ continue
1022
+ rendered = format_table_cell_value(val, fmt, formats)
1023
+ text_w = measurer.measure(rendered, font_size)
1024
+ if text_w > content_area:
1025
+ return True
1026
+ return False
1027
+
1028
+
1029
+ def _compute_lane_positions(
1030
+ rows: list[dict[str, Any]],
1031
+ columns: list[str],
1032
+ column_configs: dict[str, TableColumnConfig],
1033
+ col_widths: dict[str, float],
1034
+ col_x_offsets: list[float],
1035
+ padding_x: int,
1036
+ cell_pad: int,
1037
+ cell_font: FontStyle,
1038
+ column_when_rules: dict[str, tuple[Any, ...]] | None = None,
1039
+ formats: dict[str, str] | None = None,
1040
+ ) -> dict[str, tuple[float, float, float, float, float]]:
1041
+ """Compute fixed (prefix_x, number_x, suffix_x, content_left, content_right) per numeric column.
1042
+
1043
+ Positions are constant across all rows so currency symbols, magnitude
1044
+ letters, and percent signs form clean vertical columns.
1045
+
1046
+ **Center-on-midpoint invariant** (see ``DESIGN.md``): the number tspan
1047
+ is anchored so its horizontal center sits at the cell's content
1048
+ midpoint. Because the tspan renders with ``text-anchor="end"``, that
1049
+ means its right edge (``number_x``) sits at
1050
+ ``cell_midpoint + max_number_w / 2``. Header and per-column rule both
1051
+ center on the same midpoint — everything reads as one column.
1052
+
1053
+ ``content_left`` and ``content_right`` mark the extent of the full
1054
+ content including prefix and suffix; they're what the header rule
1055
+ uses to size itself.
1056
+ """
1057
+ # numeric=True critical here: numeric cells render with the DFT Sans
1058
+ # Tabular stack (tabular digit widths), which are noticeably wider
1059
+ # than Inter's proportional digits. Measuring with Inter would
1060
+ # under-estimate content width by ~4px per number at 11px, pushing
1061
+ # the prefix right on top of the value.
1062
+ font_size = float(cell_font.size) if cell_font.size is not None else 12.0
1063
+ measurer = get_font_measurer(cell_font.family, numeric=True)
1064
+ positions: dict[str, tuple[float, float, float, float, float]] = {}
1065
+ for i, col in enumerate(columns):
1066
+ col_config = column_configs.get(col)
1067
+ fmt = col_config.format if col_config else None
1068
+ cw = col_widths.get(col, 100)
1069
+ cell_x = padding_x + col_x_offsets[i]
1070
+ # Content midpoint respects horizontal padding on both sides so
1071
+ # that if a caller configures asymmetric padding, the midpoint
1072
+ # still sits in the middle of the *visible content area*.
1073
+ content_area_left = cell_x + cell_pad
1074
+ content_area_right = cell_x + cw - cell_pad
1075
+ cell_midpoint = (content_area_left + content_area_right) / 2
1076
+
1077
+ max_content_w = 0.0
1078
+ max_prefix_w = 0.0
1079
+ max_suffix_w = 0.0
1080
+ col_suffix = ""
1081
+ is_date_col = False
1082
+ when_rules = (
1083
+ column_when_rules.get(col) if column_when_rules is not None else None
1084
+ )
1085
+ glyph_possible = bool(when_rules) or (
1086
+ col_config is not None and col_config.glyph is not None
1087
+ )
1088
+ for row in rows:
1089
+ raw = row.get(col, "")
1090
+ if _is_numeric_cell(raw, col_config):
1091
+ try:
1092
+ num_val = float(raw) if isinstance(raw, str) else raw
1093
+ except (ValueError, TypeError):
1094
+ continue
1095
+ if not isinstance(num_val, (int, float)) or isinstance(num_val, bool):
1096
+ continue
1097
+ _p, num_str, _s = format_kpi_parts(num_val, fmt, formats)
1098
+ if not col_suffix and _s:
1099
+ col_suffix = _s
1100
+ num_str = num_str.removeprefix("-")
1101
+ w = measurer.measure(num_str, font_size)
1102
+ # Glyph replaces the format prefix in the prefix lane,
1103
+ # so measure whichever the cell will render.
1104
+ cell_glyph: str | None = None
1105
+ if glyph_possible:
1106
+ cell_glyph, _ = resolve_cell_glyph(col_config, raw, when_rules)
1107
+ effective_prefix = cell_glyph or _p
1108
+ if effective_prefix:
1109
+ max_prefix_w = max(
1110
+ max_prefix_w,
1111
+ measurer.measure(effective_prefix, font_size),
1112
+ )
1113
+ if _s:
1114
+ max_suffix_w = max(
1115
+ max_suffix_w,
1116
+ measurer.measure(_s, font_size),
1117
+ )
1118
+ max_content_w = max(max_content_w, w)
1119
+ elif is_date_like(raw):
1120
+ is_date_col = True
1121
+ w = measurer.measure(str(raw).strip(), font_size)
1122
+ max_content_w = max(max_content_w, w)
1123
+
1124
+ if max_content_w > 0:
1125
+ # CORE INVARIANT: number tspan center == cell midpoint.
1126
+ # tspan is right-anchored at number_x, so its left edge is at
1127
+ # (number_x - max_content_w). Setting the center to midpoint
1128
+ # gives number_x = cell_midpoint + max_content_w / 2.
1129
+ number_x = cell_midpoint + max_content_w / 2
1130
+ if is_date_col:
1131
+ suffix_gap = 0
1132
+ else:
1133
+ suffix_gap = 3 if col_suffix.strip() in _WORD_SUFFIXES else 0
1134
+ suffix_x = number_x + suffix_gap
1135
+ # Prefix sits 3px left of the widest number so the two never
1136
+ # visually kiss. Matches suffix_gap above. Rows narrower
1137
+ # than the widest get a larger gap naturally. Restores the
1138
+ # 2px gap from the original three-lane rendering (PR #1186)
1139
+ # that #1244 removed on the premise that measured tabular
1140
+ # widths were accurate enough for zero-gap to read as
1141
+ # "touching, not overlapping" — in practice it reads as a
1142
+ # kiss, not a separator.
1143
+ prefix_gap = 3 if max_prefix_w > 0 else 0
1144
+ prefix_x = number_x - max_content_w - prefix_gap
1145
+ # Content extent: from prefix's left edge to suffix's right
1146
+ # edge — what per-column rules and header rules reference.
1147
+ content_left = prefix_x - max_prefix_w
1148
+ content_right = suffix_x + max_suffix_w
1149
+ # Defensive clamp: if prefix or suffix would sit outside the
1150
+ # cell's content area (asymmetric value — e.g. "$" prefix but
1151
+ # no suffix), shift the whole band toward the side that has
1152
+ # headroom. The shift amount is capped by the available
1153
+ # headroom so we never trade one overflow for another. The
1154
+ # fit cascade should have already shrunk font size to prevent
1155
+ # real overflow; this clamp handles the subtler asymmetric
1156
+ # padding case.
1157
+ overflow_left = content_area_left - content_left
1158
+ overflow_right = content_right - content_area_right
1159
+ if overflow_left > 0 and overflow_right < 0:
1160
+ # Content is too far left; shift right up to the right
1161
+ # headroom (-overflow_right).
1162
+ shift = min(overflow_left, -overflow_right)
1163
+ prefix_x += shift
1164
+ number_x += shift
1165
+ suffix_x += shift
1166
+ content_left += shift
1167
+ content_right += shift
1168
+ elif overflow_right > 0 and overflow_left < 0:
1169
+ # Content is too far right; shift left up to the left
1170
+ # headroom (-overflow_left).
1171
+ shift = min(overflow_right, -overflow_left)
1172
+ prefix_x -= shift
1173
+ number_x -= shift
1174
+ suffix_x -= shift
1175
+ content_left -= shift
1176
+ content_right -= shift
1177
+ positions[col] = (
1178
+ prefix_x,
1179
+ number_x,
1180
+ suffix_x,
1181
+ content_left,
1182
+ content_right,
1183
+ )
1184
+ return positions
1185
+
1186
+
1187
+ def _compute_wrap_layout(
1188
+ rows: list[dict[str, Any]],
1189
+ text_columns: list[str],
1190
+ column_configs: dict[str, Any],
1191
+ col_widths: dict[str, float],
1192
+ cell_pad: int,
1193
+ font_size: int,
1194
+ row_height: int,
1195
+ text_baseline_offset: float,
1196
+ measurer: Any,
1197
+ formats: dict[str, str] | None = None,
1198
+ ) -> tuple[list[int], list[dict[str, list[str]]]]:
1199
+ """Pre-compute per-row heights and cached wrapped lines for text cells.
1200
+
1201
+ Returns ``(heights, wrapped_lines_per_row)``. ``wrapped_lines_per_row[i]``
1202
+ maps a text column to its wrapped line list for row ``i``. Cells that
1203
+ need no wrapping (single-line fit, empty value, or the column has no
1204
+ measurable width) are absent from the map — render path falls back to
1205
+ the single-line path for those.
1206
+
1207
+ Rows whose cells all fit on one line keep the configured ``row_height``
1208
+ verbatim; wrapped rows grow by
1209
+ ``max_lines * line_height - text_baseline_offset + 2*cell_pad`` (the
1210
+ offset subtraction keeps top/bottom margins symmetric).
1211
+ We never shrink below ``row_height`` (``max(row_height, grown)``) so a
1212
+ user who set a generous ``row.height`` keeps their spacing.
1213
+ ``text_columns`` must already exclude the row-number synthetic column.
1214
+ """
1215
+ line_height = font_size + text_baseline_offset
1216
+ heights: list[int] = []
1217
+ wrapped_by_row: list[dict[str, list[str]]] = []
1218
+ for row in rows:
1219
+ max_lines = 1
1220
+ row_wraps: dict[str, list[str]] = {}
1221
+ for col in text_columns:
1222
+ col_config = column_configs.get(col)
1223
+ value = row.get(col, "")
1224
+ # Swatch columns render a fixed-size <rect>, not text — the cell's
1225
+ # value (a hex color) is paint, not content. Excluding them keeps
1226
+ # narrow swatch columns from inflating row height by "wrapping"
1227
+ # the hex string into one-character-per-line.
1228
+ if col_config and col_config.swatch:
1229
+ continue
1230
+ if _is_numeric_cell(value, col_config) or is_date_like(value):
1231
+ continue
1232
+ fmt = col_config.format if col_config else None
1233
+ display = format_table_cell_value(value, fmt, formats)
1234
+ if not display:
1235
+ continue
1236
+ content_w = col_widths.get(col, 100) - cell_pad * 2
1237
+ if content_w <= 0:
1238
+ continue
1239
+ # Fast path: if the cell already fits on one line, skip the
1240
+ # (much more expensive) word-boundary wrap search. Saves the
1241
+ # bulk of wrap_text_precise calls on wide columns / short text.
1242
+ if measurer.measure(display, float(font_size)) <= content_w:
1243
+ continue
1244
+ lines = wrap_text_precise(display, content_w, float(font_size), measurer)
1245
+ if lines:
1246
+ row_wraps[col] = lines
1247
+ max_lines = max(max_lines, len(lines))
1248
+ wrapped_by_row.append(row_wraps)
1249
+ if max_lines <= 1:
1250
+ heights.append(row_height)
1251
+ else:
1252
+ # Subtract one text_baseline_offset so the bottom margin equals
1253
+ # the top margin (cell_pad on each side). Without this the last
1254
+ # baseline sits too close to the bottom edge and wrapped cells
1255
+ # read as top-weighted.
1256
+ grown = int(
1257
+ round(max_lines * line_height - text_baseline_offset + 2 * cell_pad)
1258
+ )
1259
+ heights.append(max(row_height, grown))
1260
+ return heights, wrapped_by_row
1261
+
1262
+
1263
+ def _render_data_rows(
1264
+ svg_parts: list[str],
1265
+ *,
1266
+ markdown_config: Any,
1267
+ table_config: Any,
1268
+ rows: list[dict[str, Any]],
1269
+ columns: list[str],
1270
+ column_configs: dict[str, TableColumnConfig],
1271
+ column_when_rules: dict[str, tuple[Any, ...]],
1272
+ colors: dict[str, str],
1273
+ col_widths: dict[str, float],
1274
+ col_x_offsets: list[float],
1275
+ col_lane_positions: dict[str, tuple[float, float, float, float, float]],
1276
+ padding_x: int,
1277
+ current_y: float,
1278
+ row_height: int,
1279
+ cell_font: FontStyle,
1280
+ table_width: float,
1281
+ cell_pad: int | None = None,
1282
+ symbol_mode: str = "all",
1283
+ row_rule_width: float = 0.0,
1284
+ summary_rule_width: float = 0.0,
1285
+ rule_color: str | None = None,
1286
+ row_role_spec: str | None = None,
1287
+ summary_font_weight: str | None = None,
1288
+ role_summary: TableRowRoleStyle | None = None,
1289
+ role_total: TableRowRoleStyle | None = None,
1290
+ resolved_style: MergedChartsStyle | None = None,
1291
+ row_numbers: Any = None,
1292
+ page_offset: int = 0,
1293
+ row_heights: list[int] | None = None,
1294
+ wrapped_lines_by_row: list[dict[str, list[str]]] | None = None,
1295
+ wrap: bool,
1296
+ chart_root_link: str | None = None,
1297
+ chart_id: str = "",
1298
+ ) -> None:
1299
+ """Render all visible table rows.
1300
+
1301
+ symbol_mode controls prefix/suffix rendering:
1302
+ "all" — every row shows full formatted value (default)
1303
+ "anchors" — first data row and summary/total rows show full value;
1304
+ plain middle data rows strip currency prefix and
1305
+ magnitude/unit suffix. The "anchor" rows structurally
1306
+ guide the reader at the top and bottom of the value field.
1307
+ """
1308
+ from dataface.core.compile.models.chart.authored import SparkConfig
1309
+
1310
+ effective_font_family: str = cell_font.family # type: ignore[assignment]
1311
+ assert (
1312
+ effective_font_family is not None
1313
+ ), "_render_data_rows requires cell_font.family"
1314
+ font_size = int(cell_font.size) if cell_font.size is not None else 12
1315
+ if cell_pad is None:
1316
+ cell_pad = int(table_config.column_layout.cell_padding)
1317
+ text_offset = table_config.text_baseline_offset
1318
+ truncation_measurer = get_font_measurer(cell_font.family)
1319
+
1320
+ # Pre-compute column max for `bar` sparks (absolute magnitude, no
1321
+ # explicit ceiling). `bar-normalize` deliberately does NOT auto-max —
1322
+ # see _render_spark_cell for the rationale.
1323
+ bar_auto_max: dict[str, float | None] = {}
1324
+ for col in columns:
1325
+ col_cfg = column_configs.get(col)
1326
+ if (
1327
+ col_cfg
1328
+ and isinstance(col_cfg.spark, SparkConfig)
1329
+ and col_cfg.spark.type == "bar"
1330
+ and col_cfg.spark.max is None
1331
+ ):
1332
+ non_null = [
1333
+ n for row in rows if (n := coerce_numeric(row.get(col))) is not None
1334
+ ]
1335
+ bar_auto_max[col] = max(non_null) if non_null else None
1336
+
1337
+ # Column names for link resolution (stable across rows)
1338
+ all_data_columns = list(rows[0].keys()) if rows else []
1339
+
1340
+ # Extra breathing room before summary/total rows so the double rule
1341
+ # doesn't crowd the last data row. Roughly half a row_height.
1342
+ _SUMMARY_GAP = int(row_height * 0.4)
1343
+ summary_gap_accum = 0
1344
+ # Running y-offset from variable per-row heights (used when row_heights provided)
1345
+ cumulative_row_height = 0.0
1346
+
1347
+ # Row rules are deferred here and flushed AFTER the loop so they paint on
1348
+ # top of all fill rects. SVG document order = z-order: later = higher.
1349
+ row_rule_parts: list[str] = []
1350
+
1351
+ # Scale domain is computed from detail rows only. Including summary/total rows
1352
+ # would skew min/max — a grand-total cell is often 10-100x any single detail
1353
+ # value, which crushes the detail-row gradient toward the low end of the palette.
1354
+ scale_rows = [
1355
+ r for r in rows if not is_summary_role(resolve_row_role(row_role_spec, r))
1356
+ ]
1357
+
1358
+ for row_idx, row in enumerate(rows):
1359
+ # Resolve per-row semantic role (value/summary/total)
1360
+ role = resolve_row_role(row_role_spec, row)
1361
+ row_is_summary = is_summary_role(role)
1362
+ row_is_total = is_total_role(role)
1363
+
1364
+ # Add breathing room before the first summary/total row.
1365
+ if row_is_summary and row_idx > 0:
1366
+ prev_role = resolve_row_role(row_role_spec, rows[row_idx - 1])
1367
+ if not is_summary_role(prev_role):
1368
+ summary_gap_accum += _SUMMARY_GAP
1369
+
1370
+ per_row_height = row_heights[row_idx] if row_heights is not None else row_height
1371
+ # Snap row_y to integer: cumulative float additions (row_height, padding)
1372
+ # can drift sub-pixel and push 1px rules onto fractional rows, causing them to
1373
+ # rasterize across two rows at reduced opacity. Round at the emit boundary so
1374
+ # the sizing pipeline retains float precision while SVG coordinates stay crisp.
1375
+ if row_heights is not None:
1376
+ row_y = int(round(current_y + cumulative_row_height + summary_gap_accum))
1377
+ else:
1378
+ row_y = int(round(current_y + (row_idx * row_height) + summary_gap_accum))
1379
+
1380
+ # Compute row-rule draw condition and reserve at the TOP of the loop —
1381
+ # before any fill rects are emitted. Every fill in this iteration uses
1382
+ # fill_height (= per_row_height - rule_reserve_px) so the rule band
1383
+ # at the bottom of the row stays clear and fills can't bleed past it.
1384
+ is_last_row = row_idx == len(rows) - 1
1385
+ next_is_summary = False
1386
+ if not is_last_row:
1387
+ next_role = resolve_row_role(row_role_spec, rows[row_idx + 1])
1388
+ next_is_summary = is_summary_role(next_role)
1389
+ will_draw_row_rule = (
1390
+ row_rule_width > 0
1391
+ and not is_last_row
1392
+ and not next_is_summary
1393
+ and not row_is_summary
1394
+ )
1395
+ # Promote sub-pixel rule widths to 1px: a 0.5px rule is invisible on
1396
+ # every display we ship to. Zero means "no rule" and is never rounded up.
1397
+ rule_reserve_px = (
1398
+ max(1, int(round(row_rule_width))) if will_draw_row_rule else 0
1399
+ )
1400
+ fill_height = per_row_height - rule_reserve_px
1401
+
1402
+ # Stripe: suppress on summary rows (they stand out against clean bg).
1403
+ # Also skip when the stripe color is transparent — no point emitting
1404
+ # invisible rects, and some SVG renderers still treat "transparent"
1405
+ # as a paint operation that interacts with other elements.
1406
+ stripe_fill = colors["row_stripe"]
1407
+ stripes_enabled = stripe_fill and stripe_fill.lower() != "transparent"
1408
+ if row_idx % 2 == 1 and not row_is_summary and stripes_enabled:
1409
+ stripe_x1, stripe_x2 = _compute_content_span(
1410
+ columns=columns,
1411
+ col_widths=col_widths,
1412
+ col_x_offsets=col_x_offsets,
1413
+ padding_x=padding_x,
1414
+ cell_pad=0,
1415
+ table_width=table_width,
1416
+ )
1417
+ svg_parts.append(
1418
+ f'<rect x="{stripe_x1}" y="{row_y}" '
1419
+ f'width="{stripe_x2 - stripe_x1}" height="{fill_height}" '
1420
+ f'fill="{stripe_fill}"/>',
1421
+ )
1422
+
1423
+ # Per-role background fill from row.roles.summary / row.roles.total.
1424
+ role_bg_raw = None
1425
+ if row_is_total and role_total and role_total.background:
1426
+ role_bg_raw = role_total.background
1427
+ elif row_is_summary and role_summary and role_summary.background:
1428
+ role_bg_raw = role_summary.background
1429
+ role_bg = sanitize_color(role_bg_raw, None) if role_bg_raw else None
1430
+ if role_bg:
1431
+ bg_x1, bg_x2 = _compute_content_span(
1432
+ columns=columns,
1433
+ col_widths=col_widths,
1434
+ col_x_offsets=col_x_offsets,
1435
+ padding_x=padding_x,
1436
+ cell_pad=0,
1437
+ table_width=table_width,
1438
+ )
1439
+ svg_parts.append(
1440
+ f'<rect x="{bg_x1}" y="{row_y}" '
1441
+ f'width="{bg_x2 - bg_x1}" height="{fill_height}" '
1442
+ f'fill="{role_bg}"/>',
1443
+ )
1444
+
1445
+ # Summary rule ABOVE the summary row. Decoupled from row_rule_width:
1446
+ # summary_rule_width defaults to row_rule_width (backward compat) but
1447
+ # can be set independently so BI/Classic variants get summary rules
1448
+ # without body row rules.
1449
+ # Single rule for "summary"; double rule (two rects + 1px gap) for "total".
1450
+ if row_is_summary and summary_rule_width > 0:
1451
+ effective_rule_color = rule_color or colors["color"]
1452
+ rule_x1, rule_x2 = _compute_content_span(
1453
+ columns=columns,
1454
+ col_widths=col_widths,
1455
+ col_x_offsets=col_x_offsets,
1456
+ padding_x=padding_x,
1457
+ cell_pad=cell_pad,
1458
+ table_width=table_width,
1459
+ )
1460
+ line_h = max(1, int(summary_rule_width))
1461
+ if row_is_total:
1462
+ gap = 1
1463
+ y_upper = row_y - line_h - gap - line_h
1464
+ y_lower = row_y - line_h
1465
+ svg_parts.append(
1466
+ f'<rect x="{rule_x1}" y="{y_upper}" '
1467
+ f'width="{rule_x2 - rule_x1}" height="{line_h}" '
1468
+ f'fill="{effective_rule_color}" '
1469
+ f'shape-rendering="crispEdges"/>',
1470
+ )
1471
+ svg_parts.append(
1472
+ f'<rect x="{rule_x1}" y="{y_lower}" '
1473
+ f'width="{rule_x2 - rule_x1}" height="{line_h}" '
1474
+ f'fill="{effective_rule_color}" '
1475
+ f'shape-rendering="crispEdges"/>',
1476
+ )
1477
+ else:
1478
+ y_single = row_y - line_h
1479
+ svg_parts.append(
1480
+ f'<rect x="{rule_x1}" y="{y_single}" '
1481
+ f'width="{rule_x2 - rule_x1}" height="{line_h}" '
1482
+ f'fill="{effective_rule_color}" '
1483
+ f'shape-rendering="crispEdges"/>',
1484
+ )
1485
+
1486
+ # Row rule BELOW each row — deferred into row_rule_parts so it paints
1487
+ # on top of all fill rects. Draw condition and reserve were computed at
1488
+ # the top of this iteration; will_draw_row_rule / rule_reserve_px drive
1489
+ # both the fill geometry and the rule geometry from the same values.
1490
+ if will_draw_row_rule:
1491
+ effective_rule_color = rule_color or colors["color"]
1492
+ # rule_y sits at the bottom of the fill band. row_y is integer
1493
+ # (pixel-snapped); per_row_height may be float (anti-dangle
1494
+ # squeeze), so rule_y may be fractional — SVG renders it fine.
1495
+ rule_y = row_y + per_row_height - rule_reserve_px
1496
+ rule_x1, rule_x2 = _compute_content_span(
1497
+ columns=columns,
1498
+ col_widths=col_widths,
1499
+ col_x_offsets=col_x_offsets,
1500
+ padding_x=padding_x,
1501
+ cell_pad=cell_pad,
1502
+ table_width=table_width,
1503
+ )
1504
+ row_rule_parts.append(
1505
+ f'<rect x="{rule_x1}" y="{rule_y}" width="{rule_x2 - rule_x1}" '
1506
+ f'height="{rule_reserve_px}" fill="{effective_rule_color}" '
1507
+ f'shape-rendering="crispEdges"/>',
1508
+ )
1509
+
1510
+ cumulative_row_height += per_row_height
1511
+
1512
+ for i, col in enumerate(columns):
1513
+ cw = col_widths.get(col, 100)
1514
+ value = row.get(col, "")
1515
+ cell_x = padding_x + col_x_offsets[i]
1516
+ y = row_y + (per_row_height / 2) + text_offset
1517
+
1518
+ # Synthetic row-number cell — absolute 1-based index across pages.
1519
+ # Summary/total rows render blank (the sequence is for data rows).
1520
+ if col == _ROW_NUMBER_COL and row_numbers is not None:
1521
+ if not (row_is_summary or row_is_total):
1522
+ absolute_index = page_offset + row_idx + 1
1523
+ if row_numbers.align == "right":
1524
+ rn_x = cell_x + cw - cell_pad
1525
+ rn_anchor = "end"
1526
+ else:
1527
+ rn_x = cell_x + cell_pad
1528
+ rn_anchor = "start"
1529
+ svg_parts.append(
1530
+ f'<text x="{rn_x}" y="{y}" '
1531
+ f'font-size="{font_size}" fill="{colors["muted"]}" '
1532
+ f'text-anchor="{rn_anchor}" '
1533
+ f'font-family="{_SANS_NUMERIC_FONT_STACK}">'
1534
+ f"{absolute_index}</text>",
1535
+ )
1536
+ continue
1537
+
1538
+ col_config = column_configs.get(col)
1539
+ spark_config = col_config.spark if col_config else None
1540
+
1541
+ if col_config and col_config.swatch:
1542
+ # _render_swatch_cell raises ChartDataError on a non-color
1543
+ # value — a swatch column with a bad cell is a misconfig,
1544
+ # not a rendering choice. The error names column + row index
1545
+ # so the author can find the cell that broke.
1546
+ swatch_content = _render_swatch_cell(
1547
+ value, col=col, row_idx=page_offset + row_idx, chart_id=chart_id
1548
+ )
1549
+ swatch_x = _px(cell_x + 4)
1550
+ swatch_y = row_y + (per_row_height - _SWATCH_SIZE) / 2
1551
+ svg_parts.append(
1552
+ f'<g transform="translate({swatch_x}, {swatch_y})">{swatch_content}</g>',
1553
+ )
1554
+ continue
1555
+
1556
+ if isinstance(spark_config, SparkConfig) and value is not None:
1557
+ spark_content, spark_height = _render_spark_cell(
1558
+ value,
1559
+ spark_config,
1560
+ cw,
1561
+ row_height,
1562
+ cell_font=cell_font,
1563
+ resolved_style=resolved_style,
1564
+ column_max=bar_auto_max.get(col),
1565
+ )
1566
+ if spark_content:
1567
+ spark_x = _px(cell_x + 4)
1568
+ # Center the spark mark within the row's effective band.
1569
+ # per_row_height >= nominal row_height; wrapped rows grow
1570
+ # to fit their tallest text column, so a fixed top-of-row
1571
+ # offset would leave the spark hugging the top.
1572
+ spark_y = row_y + (per_row_height - spark_height) / 2
1573
+ svg_parts.append(
1574
+ f'<g transform="translate({spark_x}, {spark_y})">{spark_content}</g>',
1575
+ )
1576
+ continue
1577
+
1578
+ # Resolve base styles (may be field refs resolved per-row)
1579
+ cell_background = None
1580
+ if col_config and col_config.background:
1581
+ cell_background = sanitize_color(
1582
+ resolve_table_style_value(col_config.background, row),
1583
+ None,
1584
+ )
1585
+
1586
+ # Overlay scale + when conditional formatting
1587
+ has_scale = col_config and col_config.scale
1588
+ when_rules = column_when_rules.get(col)
1589
+ overrides: dict[str, Any] = (
1590
+ resolve_conditional_styles(when_rules, value) if when_rules else {}
1591
+ )
1592
+ cond_color: str | None = None
1593
+ cond_fw: str | float | None = None
1594
+ cond_style: str | None = None
1595
+ cond_decoration: str | None = None
1596
+
1597
+ if has_scale:
1598
+ assert col_config is not None
1599
+ col_format = (
1600
+ resolve_format(
1601
+ col_config.format,
1602
+ resolved_style.formats if resolved_style else None,
1603
+ )
1604
+ or None
1605
+ )
1606
+ cond_bg, cond_clr, cond_fw, cond_style, cond_decoration = (
1607
+ resolve_cell_conditional_styles(
1608
+ col_config,
1609
+ value,
1610
+ scale_rows,
1611
+ when_rules=when_rules,
1612
+ col_format=col_format,
1613
+ row_role=role,
1614
+ col_name=col,
1615
+ )
1616
+ )
1617
+ if cond_bg is not None:
1618
+ cell_background = sanitize_color(cond_bg, None)
1619
+ if cond_clr is not None:
1620
+ cond_color = cond_clr
1621
+ elif when_rules:
1622
+ if "background" in overrides:
1623
+ cell_background = sanitize_color(overrides["background"], None)
1624
+ cond_color = overrides.get("color")
1625
+ cond_fw = overrides.get("weight")
1626
+ cond_style = overrides.get("style")
1627
+ cond_decoration = overrides.get("decoration")
1628
+
1629
+ if cell_background:
1630
+ svg_parts.append(
1631
+ f'<rect x="{cell_x}" y="{row_y}" width="{cw}" height="{fill_height}" '
1632
+ f'fill="{cell_background}"/>',
1633
+ )
1634
+
1635
+ cell_glyph, cell_glyph_color = resolve_cell_glyph_from_overrides(
1636
+ col_config, overrides
1637
+ )
1638
+
1639
+ is_numeric = _is_numeric_cell(value, col_config)
1640
+ fmt = col_config.format if col_config else None
1641
+
1642
+ # Resolve cell link: column link takes priority; chart-root link is the fallback.
1643
+ link_raw = col_config.link if col_config else None
1644
+ if link_raw is None:
1645
+ link_raw = chart_root_link
1646
+ cell_link: str | None = None
1647
+ if link_raw:
1648
+ cell_link = resolve_cell_link_with_board(
1649
+ link_raw, row, all_data_columns
1650
+ )
1651
+
1652
+ fill_color = colors["color"]
1653
+ if cell_link:
1654
+ _md_colors = (
1655
+ markdown_config.dark
1656
+ if is_dark_color(colors["background"])
1657
+ else markdown_config.light
1658
+ )
1659
+ fill_color = _md_colors.link_color
1660
+ elif cond_color:
1661
+ fill_color = sanitize_color(cond_color, fill_color)
1662
+ elif col_config and col_config.font and col_config.font.color:
1663
+ fill_color = sanitize_color(
1664
+ resolve_table_style_value(col_config.font.color, row),
1665
+ fill_color,
1666
+ )
1667
+
1668
+ font_weight_attr = ""
1669
+ if cond_fw and cond_fw in VALID_FONT_WEIGHTS:
1670
+ font_weight_attr = f' font-weight="{cond_fw}"'
1671
+ elif col_config and col_config.font and col_config.font.weight:
1672
+ resolved_weight = resolve_table_style_value(
1673
+ font_weight_as_css(col_config.font.weight), row
1674
+ )
1675
+ if resolved_weight in VALID_FONT_WEIGHTS:
1676
+ font_weight_attr = f' font-weight="{resolved_weight}"'
1677
+ # Summary/total rows: per-role font.weight from row.roles
1678
+ # takes precedence, then flat summary_font_weight, then
1679
+ # default medium (500). All values validated against
1680
+ # VALID_FONT_WEIGHTS before interpolation into SVG.
1681
+ if not font_weight_attr and row_is_summary:
1682
+ _rw = None
1683
+ _role_total_weight = (
1684
+ role_total.font.weight if role_total and role_total.font else None
1685
+ )
1686
+ _role_summary_weight = (
1687
+ role_summary.font.weight
1688
+ if role_summary and role_summary.font
1689
+ else None
1690
+ )
1691
+ if row_is_total and _role_total_weight:
1692
+ _candidate = font_weight_as_css(_role_total_weight)
1693
+ if _candidate in VALID_FONT_WEIGHTS:
1694
+ _rw = _candidate
1695
+ if not _rw and _role_summary_weight:
1696
+ _candidate = font_weight_as_css(_role_summary_weight)
1697
+ if _candidate in VALID_FONT_WEIGHTS:
1698
+ _rw = _candidate
1699
+ if not _rw:
1700
+ _rw = summary_font_weight or "500"
1701
+ font_weight_attr = f' font-weight="{_rw}"'
1702
+
1703
+ font_style_attr = ""
1704
+ if cond_style is not None:
1705
+ font_style_attr = f' font-style="{cond_style}"'
1706
+
1707
+ font_decoration_attr = ""
1708
+ if cond_decoration is not None:
1709
+ font_decoration_attr = f' text-decoration="{cond_decoration}"'
1710
+
1711
+ use_tabular = _wants_tabular_font(value, col_config)
1712
+ if use_tabular:
1713
+ # Source Serif tables use the serif font for numeric cells
1714
+ # (CSS tabular-nums handles digit alignment); all other fonts
1715
+ # use the DFT Sans Tabular stack for tabular digit widths.
1716
+ if effective_font_family and "Source Serif" in effective_font_family:
1717
+ cell_font_family = effective_font_family
1718
+ else:
1719
+ cell_font_family = _SANS_NUMERIC_FONT_STACK
1720
+ else:
1721
+ cell_font_family = effective_font_family or ""
1722
+ # tabular-nums + lining-nums ensures consistent column alignment.
1723
+ # font-feature-settings is the OpenType belt-and-suspenders for
1724
+ # renderers that don't support font-variant-numeric.
1725
+ numeric_style = (
1726
+ ' style="font-variant-numeric: tabular-nums lining-nums;'
1727
+ " font-feature-settings: 'tnum' 1, 'lnum' 1;\""
1728
+ if use_tabular
1729
+ else ""
1730
+ )
1731
+
1732
+ if cell_link:
1733
+ escaped_href = html_module.escape(cell_link, quote=True)
1734
+ svg_parts.append(f'<a href="{escaped_href}">')
1735
+
1736
+ # Three-lane rendering for numeric cells (not date-like strings
1737
+ # which happen to parse as numbers, e.g. "2024")
1738
+ if is_numeric and col in col_lane_positions and not is_date_like(value):
1739
+ # Coerce string values from CSV adapter
1740
+ try:
1741
+ num_value = float(value) if isinstance(value, str) else value
1742
+ except (ValueError, TypeError):
1743
+ num_value = value
1744
+
1745
+ if isinstance(num_value, (int, float)) and not isinstance(
1746
+ num_value,
1747
+ bool,
1748
+ ):
1749
+ prefix, number_str, suffix = format_kpi_parts(
1750
+ num_value,
1751
+ fmt,
1752
+ resolved_style.formats if resolved_style else None,
1753
+ )
1754
+ else:
1755
+ prefix, number_str, suffix = (
1756
+ "",
1757
+ format_table_cell_value(
1758
+ value,
1759
+ fmt,
1760
+ resolved_style.formats if resolved_style else None,
1761
+ ),
1762
+ "",
1763
+ )
1764
+
1765
+ # Anchors: only the first row + summary rows show prefix/suffix
1766
+ if symbol_mode == "anchors" and row_idx != 0 and not row_is_summary:
1767
+ prefix = ""
1768
+ suffix = ""
1769
+
1770
+ # Replace ASCII hyphen-minus with proper minus sign
1771
+ if number_str.startswith("-"):
1772
+ number_str = "\u2212" + number_str[1:]
1773
+ # When a glyph is active for this cell, it replaces the
1774
+ # format prefix (currency etc.) so the prefix lane carries
1775
+ # one colored indicator instead of two stacked symbols.
1776
+ full_prefix = cell_glyph or prefix
1777
+
1778
+ # Use pre-computed fixed lane positions for this column
1779
+ prefix_x, number_x, suffix_x, *_ = col_lane_positions[col]
1780
+ escaped_number = html_module.escape(number_str)
1781
+
1782
+ svg_parts.append(
1783
+ f'<text y="{y}" font-size="{font_size}" '
1784
+ f'fill="{fill_color}" '
1785
+ f'font-family="{cell_font_family}"{numeric_style}{font_weight_attr}{font_style_attr}{font_decoration_attr}>',
1786
+ )
1787
+
1788
+ # Prefix tspan: end-anchored at prefix_x (left of number).
1789
+ # A glyph carries its own fill so it can stand out against
1790
+ # the cell's default text color.
1791
+ if full_prefix:
1792
+ escaped_prefix = html_module.escape(full_prefix)
1793
+ glyph_fill_attr = (
1794
+ f' fill="{sanitize_color(cell_glyph_color, fill_color)}"'
1795
+ if cell_glyph and cell_glyph_color
1796
+ else ""
1797
+ )
1798
+ svg_parts.append(
1799
+ f'<tspan x="{prefix_x}" text-anchor="end"{glyph_fill_attr}>'
1800
+ f"{escaped_prefix}</tspan>",
1801
+ )
1802
+
1803
+ # Number tspan: end-anchored so its center sits at cell
1804
+ # midpoint (number_x = midpoint + max_number_w / 2).
1805
+ svg_parts.append(
1806
+ f'<tspan x="{number_x}" text-anchor="end">{escaped_number}</tspan>',
1807
+ )
1808
+
1809
+ # Suffix tspan: left-aligned at fixed position
1810
+ if suffix:
1811
+ escaped_suffix = html_module.escape(suffix)
1812
+ svg_parts.append(
1813
+ f'<tspan x="{suffix_x}" text-anchor="start">'
1814
+ f"{escaped_suffix}</tspan>",
1815
+ )
1816
+
1817
+ svg_parts.append("</text>")
1818
+ else:
1819
+ display_value = format_table_cell_value(
1820
+ value, fmt, resolved_style.formats if resolved_style else None
1821
+ )
1822
+ content_area = cw - cell_pad * 2
1823
+
1824
+ align = col_config.align if col_config and col_config.align else None
1825
+ if align == "center":
1826
+ x = cell_x + (cw / 2)
1827
+ anchor = "middle"
1828
+ elif (
1829
+ align == "right" or is_date_like(value)
1830
+ ) and col in col_lane_positions:
1831
+ # Route through the column's number_x so date cells
1832
+ # center under the (centered) header, matching the
1833
+ # numeric three-lane invariant. See _compute_lane_positions
1834
+ # docstring.
1835
+ _prefix_x, number_x, *_ = col_lane_positions[col]
1836
+ x = number_x
1837
+ anchor = "end"
1838
+ elif align == "right" or is_date_like(value):
1839
+ x = cell_x + cw - cell_pad
1840
+ anchor = "end"
1841
+ else: # "left" or unset
1842
+ x = cell_x + cell_pad
1843
+ anchor = "start"
1844
+
1845
+ if wrap and not is_date_like(value) and not is_numeric:
1846
+ # _compute_wrap_layout ran upstream; missing-cache means
1847
+ # the cell was skipped (empty display or content_w <= 0).
1848
+ # Render those as a single line — no need to re-measure.
1849
+ # Numeric cells that fall through here (no lane position)
1850
+ # take the else-branch so they keep tabular-nums CSS and
1851
+ # a hard ellipsis rather than word-wrap.
1852
+ cached = (
1853
+ wrapped_lines_by_row[row_idx].get(col)
1854
+ if wrapped_lines_by_row is not None
1855
+ else None
1856
+ )
1857
+ lines = cached if cached is not None else [display_value]
1858
+ else:
1859
+ display_value = truncate_text_precise(
1860
+ display_value,
1861
+ content_area,
1862
+ font_size,
1863
+ truncation_measurer,
1864
+ ellipsis=True,
1865
+ )
1866
+ lines = [display_value]
1867
+
1868
+ # Glyph for non-three-lane cells (text columns, dates, nulls).
1869
+ # Inlined as a leading colored tspan; multi-line cells carry
1870
+ # the glyph on the first line only.
1871
+ glyph_prefix_inline = ""
1872
+ if cell_glyph:
1873
+ escaped_glyph = html_module.escape(cell_glyph)
1874
+ glyph_fill_attr = (
1875
+ f' fill="{sanitize_color(cell_glyph_color, fill_color)}"'
1876
+ if cell_glyph_color
1877
+ else ""
1878
+ )
1879
+ glyph_prefix_inline = (
1880
+ f"<tspan{glyph_fill_attr}>{escaped_glyph} </tspan>"
1881
+ )
1882
+
1883
+ if len(lines) > 1:
1884
+ line_height = font_size + text_offset
1885
+ total_text_h = len(lines) * line_height
1886
+ first_y = row_y + (per_row_height - total_text_h) / 2 + font_size
1887
+ svg_parts.append(
1888
+ f'<text x="{x}" '
1889
+ f'font-size="{font_size}" fill="{fill_color}" '
1890
+ f'text-anchor="{anchor}" '
1891
+ f'font-family="{cell_font_family}"{font_weight_attr}{font_style_attr}{font_decoration_attr}>'
1892
+ )
1893
+ for li, line in enumerate(lines):
1894
+ ly = first_y + li * line_height
1895
+ prefix = glyph_prefix_inline if li == 0 else ""
1896
+ svg_parts.append(
1897
+ f'<tspan x="{x}" y="{ly}">{prefix}{html_module.escape(line)}</tspan>'
1898
+ )
1899
+ svg_parts.append("</text>")
1900
+ else:
1901
+ svg_parts.append(
1902
+ f'<text x="{x}" y="{y}" '
1903
+ f'font-size="{font_size}" fill="{fill_color}" '
1904
+ f'text-anchor="{anchor}" '
1905
+ f'font-family="{cell_font_family}"{numeric_style}{font_weight_attr}{font_style_attr}{font_decoration_attr}>'
1906
+ f"{glyph_prefix_inline}{html_module.escape(lines[0])}</text>",
1907
+ )
1908
+
1909
+ if cell_link:
1910
+ svg_parts.append("</a>")
1911
+
1912
+ # Flush deferred row rules last so they paint above all fill rects.
1913
+ # SVG z-order is document order: later = higher.
1914
+ svg_parts.extend(row_rule_parts)
1915
+
1916
+
1917
+ def _extract_page_from_variables(
1918
+ chart_id: str,
1919
+ variables: dict[str, Any] | None,
1920
+ ) -> int:
1921
+ """Extract the current page number from the variables dict.
1922
+
1923
+ The page variable is named ``{chart_id}_page`` and is 1-based.
1924
+ Returns 1 if no page variable is present or the value is invalid.
1925
+ """
1926
+ if not variables:
1927
+ return 1
1928
+ raw = variables.get(f"{chart_id}_page")
1929
+ if raw is None:
1930
+ return 1
1931
+ try:
1932
+ return max(1, int(raw))
1933
+ except (ValueError, TypeError):
1934
+ return 1
1935
+
1936
+
1937
+ _PAGINATOR_PREV_CHEVRON = "\u2039" # \u2039
1938
+ _PAGINATOR_NEXT_CHEVRON = "\u203a" # \u203a
1939
+ _PAGINATOR_ELLIPSIS = "\u2026" # \u2026
1940
+ # siblingCount=1 shows ±1 around the current page (e.g. `4 5 6` mid-window)
1941
+ # so the paginator carries some context, not just the single current digit.
1942
+ # The aux-slot squeeze keeps total width modest; the MUI small-gap
1943
+ # expansion fills in single hidden pages instead of ellipsizing them.
1944
+ _PAGINATOR_SIBLING_COUNT = 1
1945
+ _PAGINATOR_BOUNDARY_COUNT = 1
1946
+
1947
+ # Chevrons and ellipses sit in narrower slots than digits so they read as
1948
+ # pairs with their adjacent page numbers rather than floating a full slot
1949
+ # away. 0.66 keeps the pairing tight without overlapping glyph bounds.
1950
+ _PAGINATOR_AUX_SLOT_RATIO = 0.66
1951
+
1952
+
1953
+ def _paginator_window(
1954
+ page: int,
1955
+ total: int,
1956
+ sibling: int = _PAGINATOR_SIBLING_COUNT,
1957
+ boundary: int = _PAGINATOR_BOUNDARY_COUNT,
1958
+ ) -> list[int | str]:
1959
+ """Build the windowed page sequence: numeric pages with ``"\u2026"`` for gaps.
1960
+
1961
+ Always-show set:
1962
+ * ``[1 .. boundary]`` and ``[total-boundary+1 .. total]`` (the
1963
+ boundary pages)
1964
+ * ``[page-sibling .. page+sibling]`` (siblings around current)
1965
+
1966
+ Gaps between consecutive must-show pages become a single ``"\u2026"``
1967
+ sentinel, regardless of gap size. We deliberately do NOT expand
1968
+ single-page gaps into the hidden page \u2014 that produces "5-in-a-row"
1969
+ runs near the edges (e.g. page 1 of 8 \u2192 ``1 2 3 4 5 \u2026 8``) which
1970
+ overweight the start/end states. Siblings carry the local context;
1971
+ ellipsis carries the "more here" signal.
1972
+ """
1973
+ if total <= 0:
1974
+ return []
1975
+ if total == 1:
1976
+ return [1]
1977
+
1978
+ must_show: set[int] = set()
1979
+ must_show.update(range(1, min(boundary, total) + 1))
1980
+ must_show.update(range(max(total - boundary + 1, 1), total + 1))
1981
+ must_show.update(range(max(page - sibling, 1), min(page + sibling, total) + 1))
1982
+
1983
+ sorted_pages = sorted(must_show)
1984
+ items: list[int | str] = []
1985
+ prev = 0
1986
+ for p in sorted_pages:
1987
+ if p > prev + 1:
1988
+ items.append(_PAGINATOR_ELLIPSIS)
1989
+ items.append(p)
1990
+ prev = p
1991
+ return items
1992
+
1993
+
1994
+ def _render_pagination_controls(
1995
+ page: int,
1996
+ total_pages: int,
1997
+ page_var_name: str,
1998
+ table_width: float,
1999
+ y: float,
2000
+ font_family: str,
2001
+ paginator: PaginatorStyle,
2002
+ ) -> str:
2003
+ """Render a right-aligned paginator: ``\u2039 1 \u2026 4 5 6 \u2026 12 \u203a``.
2004
+
2005
+ Layout: each item occupies a fixed ``item_width`` slot; the rightmost
2006
+ slot's right edge sits at ``table_width``. Clickable items (live
2007
+ chevrons and inactive page numbers) get an invisible ``<rect>`` with an
2008
+ ``onclick=updateVariable(...)`` handler \u2014 this is the hit target.
2009
+ Disabled chevrons, the active page, and the ellipsis are non-interactive.
2010
+ Disabled state is signalled by colour (``color_disabled``), not opacity.
2011
+ """
2012
+ window = _paginator_window(page=page, total=total_pages)
2013
+
2014
+ # Build the full visual sequence: leading chevron, page items, trailing
2015
+ # chevron. Each entry is (role, glyph, target_page).
2016
+ sequence: list[tuple[str, str, int]] = []
2017
+ prev_target = page - 1 if page > 1 else 0
2018
+ next_target = page + 1 if page < total_pages else 0
2019
+ sequence.append(("prev", _PAGINATOR_PREV_CHEVRON, prev_target))
2020
+ for item in window:
2021
+ if isinstance(item, str):
2022
+ sequence.append(("ellipsis", item, 0))
2023
+ else:
2024
+ sequence.append(("page", str(item), int(item)))
2025
+ sequence.append(("next", _PAGINATOR_NEXT_CHEVRON, next_target))
2026
+
2027
+ font_size = int(paginator.font.size) if paginator.font.size is not None else 11
2028
+ item_width = paginator.item_width
2029
+ text_y = y + 18 # baseline within the reserved control band
2030
+ # Hit rect hugs the glyph tightly so cursor:pointer matches the visible
2031
+ # character. We don't want adjacent rects to butt up against each other —
2032
+ # that makes the gap between items feel clickable when it isn't.
2033
+ rect_w = float(font_size) + 6.0
2034
+ rect_h = max(font_size * 1.8, 18.0)
2035
+ rect_y = y + 4
2036
+
2037
+ safe_var = html_module.escape(page_var_name, quote=True)
2038
+ safe_font = html_module.escape(font_family or "", quote=True)
2039
+ safe_active = html_module.escape(paginator.color_active, quote=True)
2040
+ safe_inactive = html_module.escape(paginator.color_inactive, quote=True)
2041
+ safe_disabled = html_module.escape(paginator.color_disabled, quote=True)
2042
+
2043
+ # Right-anchored layout: walk slot widths so chevrons and ellipses
2044
+ # get narrower slots than digits, sitting closer to their boundary
2045
+ # neighbours. Total width is the sum of slot widths; the rightmost
2046
+ # slot's right edge sits at ``table_width``.
2047
+ aux_slot_w = item_width * _PAGINATOR_AUX_SLOT_RATIO
2048
+ slot_widths = [
2049
+ aux_slot_w if role in ("prev", "next", "ellipsis") else item_width
2050
+ for role, _, _ in sequence
2051
+ ]
2052
+ total_width = sum(slot_widths)
2053
+ cursor_left = table_width - total_width
2054
+
2055
+ parts: list[str] = [f'<g class="dft-paginator" data-paginator="{safe_var}">']
2056
+ for i, (role, glyph, target) in enumerate(sequence):
2057
+ slot_w = slot_widths[i]
2058
+ slot_left = cursor_left
2059
+ center_x = slot_left + slot_w / 2
2060
+ cursor_left += slot_w
2061
+
2062
+ is_active_page = role == "page" and target == page
2063
+ is_disabled_chevron = role in ("prev", "next") and target == 0
2064
+ is_ellipsis = role == "ellipsis"
2065
+
2066
+ if is_active_page:
2067
+ color = safe_active
2068
+ weight = paginator.weight_active
2069
+ clickable = False
2070
+ elif is_disabled_chevron:
2071
+ color = safe_disabled
2072
+ weight = (
2073
+ paginator.weight_chevron
2074
+ ) # silhouette stays heavy; tone signals disabled
2075
+ clickable = False
2076
+ elif role in ("prev", "next"):
2077
+ color = safe_active
2078
+ weight = paginator.weight_chevron
2079
+ clickable = True
2080
+ elif is_ellipsis:
2081
+ color = safe_inactive
2082
+ weight = paginator.weight_inactive
2083
+ clickable = False
2084
+ else: # inactive page number
2085
+ color = safe_inactive
2086
+ weight = paginator.weight_inactive
2087
+ clickable = True
2088
+
2089
+ if clickable:
2090
+ # Rect is centred on the glyph and narrower than the slot so
2091
+ # cursor:pointer doesn't extend into the gap between items.
2092
+ rect_x = center_x - rect_w / 2
2093
+ parts.append(
2094
+ f'<rect x="{rect_x:.1f}" y="{rect_y:.1f}" '
2095
+ f'width="{rect_w:.1f}" height="{rect_h:.1f}" '
2096
+ f'fill="transparent" pointer-events="all" '
2097
+ f'style="cursor: pointer;" '
2098
+ f"onclick=\"updateVariable('{safe_var}', '{target}')\"/>"
2099
+ )
2100
+
2101
+ text_style = "font-variant-numeric: tabular-nums; user-select: none;"
2102
+ data_attrs = f' data-paginator-role="{role}"'
2103
+ if is_active_page:
2104
+ data_attrs += f' data-pagination-current="{safe_var}"'
2105
+ # pointer-events="none" so the underlying <rect> catches hover/click
2106
+ # over the painted glyph — without this, SVG's default visiblePainted
2107
+ # behaviour makes the text capture events but no cursor:pointer or
2108
+ # onclick lives there, so the hit feels broken.
2109
+ parts.append(
2110
+ f'<text x="{center_x:.1f}" y="{text_y:.1f}" '
2111
+ f'font-size="{font_size}" fill="{color}" text-anchor="middle" '
2112
+ f'font-family="{safe_font}" font-weight="{weight}" '
2113
+ f'pointer-events="none" '
2114
+ f'style="{text_style}"{data_attrs}>{glyph}</text>'
2115
+ )
2116
+
2117
+ parts.append("</g>")
2118
+ return "\n".join(parts)
2119
+
2120
+
2121
+ def render_table_svg(
2122
+ chart: Any,
2123
+ data: list[dict[str, Any]],
2124
+ width: float | None = None,
2125
+ height: float | None = None,
2126
+ is_placeholder: bool = False,
2127
+ resolved_style: MergedChartsStyle | None = None,
2128
+ variables: dict[str, Any] | None = None,
2129
+ face_level: int = 1,
2130
+ *,
2131
+ board_style: MergedStyle,
2132
+ ) -> str:
2133
+ """Render a data table as SVG.
2134
+
2135
+ Creates a clean, modern table visualization with:
2136
+ - Header row with column names
2137
+ - Alternating row backgrounds
2138
+ - Auto-calculated column widths
2139
+ - Proper text truncation
2140
+ - Spark charts (sparklines) in columns with spark config
2141
+ - Interactive pagination controls when data exceeds page_size
2142
+
2143
+ Args:
2144
+ chart: Chart definition with title and optional columns config
2145
+ data: List of dicts containing table data
2146
+ width: Optional explicit width in pixels
2147
+ height: Optional explicit height in pixels
2148
+ is_placeholder: If True, render with placeholder styling (reduced opacity,
2149
+ "add data" overlay). Used when table has no query/data.
2150
+ variables: Current variable values (used to read pagination page state).
2151
+ face_level: Heading level of the parent face (root=1, nested=2, …).
2152
+ Chart title uses face_level + 1.
2153
+
2154
+ Returns:
2155
+ SVG string representing the table
2156
+
2157
+ """
2158
+ data = normalize_data_types(data)
2159
+
2160
+ # Apply cross-tab pivot transformation when the chart's query has pivot: set.
2161
+ # ResolvedChart.query delegates to source_chart.query via property; Chart
2162
+ # has .query directly. Both paths return None for blank/placeholder charts.
2163
+ # The Pivot model guarantees column/value are non-empty strings, so the isinstance
2164
+ # check distinguishes a real Pivot from an unset query attribute.
2165
+ chart_pivot = getattr(getattr(chart, "query", None), "pivot", None)
2166
+ if chart_pivot is not None and isinstance(chart_pivot.column, str) and data:
2167
+ data = pivot_long_to_wide(
2168
+ data, column=chart_pivot.column, value=chart_pivot.value
2169
+ )
2170
+
2171
+ # --- Resolve chart-level style (presentation fields) ------------------
2172
+ # Body operates on MergedChartsStyle (chart-level merged). When the
2173
+ # caller didn't pass one, build it now from the chart's authored style
2174
+ # so chart-local pagination/wrap/columns flow without each caller having
2175
+ # to plumb a MergedChartsStyle through.
2176
+ from dataface.core.compile.style_cascade import build_resolved_style
2177
+
2178
+ _ms = board_style
2179
+
2180
+ if resolved_style is None:
2181
+ resolved_style = build_resolved_style(
2182
+ _ms,
2183
+ getattr(chart, "style", None),
2184
+ )
2185
+ tc: TableChartStyle = resolved_style.table
2186
+
2187
+ # markdown colors — narrow engine-config getter
2188
+ _markdown = get_markdown_config()
2189
+
2190
+ # Build colors dict from board-level MergedStyle, then apply chart-level
2191
+ # TableChartStyle overrides. Keeps all downstream helper-function call sites
2192
+ # unchanged (they accept colors: dict[str, str]).
2193
+ colors: dict[str, str] = {
2194
+ "background": _ms.background,
2195
+ "header_background": _ms.charts.table.header.background or "",
2196
+ "label_color": _ms.variables.label.font.color or "",
2197
+ "border": _ms.border.color,
2198
+ "row_stripe": (
2199
+ _ms.charts.table.row.stripe.color if _ms.charts.table.row.stripe else None
2200
+ )
2201
+ or "",
2202
+ "color": _ms.font.color,
2203
+ "title_color": _ms.title.font.color or "",
2204
+ "muted": _ms.variables.font.color or "",
2205
+ }
2206
+ colors["background"] = sanitize_color(tc.background, colors["background"])
2207
+ colors["header_background"] = sanitize_color(
2208
+ tc.header.background,
2209
+ colors["header_background"],
2210
+ )
2211
+ colors["label_color"] = sanitize_color(
2212
+ tc.header.font.color,
2213
+ colors["label_color"],
2214
+ )
2215
+ colors["border"] = sanitize_color(tc.border.color, colors["border"])
2216
+ colors["row_stripe"] = sanitize_color(
2217
+ tc.row.stripe.color if tc.row.stripe else None, colors["row_stripe"]
2218
+ )
2219
+ colors["color"] = sanitize_color(tc.color, colors["color"]) # type: ignore[arg-type] # StyleColorConfig resolved to str by cascade before render
2220
+
2221
+ # tc IS both the style and the layout constants (TableChartStyle has all fields)
2222
+ table_config = tc
2223
+
2224
+ row_height = int(tc.row.height)
2225
+ padding = int(tc.outer_padding)
2226
+ assert (
2227
+ tc.font.size is not None
2228
+ ), "TableChartStyle.font.size must be set after cascade"
2229
+ font_size = int(tc.font.size)
2230
+ # Header font size: when not explicitly set, MATCH the body font and
2231
+ # track it through the fit cascade. BI's apparatus tier (11px) is
2232
+ # an explicit override that does NOT track body — header stays at 11
2233
+ # even when body shrinks to 8. Minimal and Classic leave it unset
2234
+ # so headers travel with body 14 → 11 → 8.
2235
+ _header_inherits_body = tc.header.font.size is None
2236
+ header_font_size = (
2237
+ int(tc.header.font.size) if tc.header.font.size is not None else font_size
2238
+ )
2239
+ # Header weight: compact tier (body ≤11px) may use font_compact.weight to
2240
+ # apply a lighter weight when body text shrinks — e.g. BI theme uses 500 at
2241
+ # 11px vs. 600 at the default 14px body size.
2242
+ _default_header_weight = (
2243
+ str(tc.header.font.weight) if tc.header.font.weight else "600"
2244
+ )
2245
+ if (
2246
+ font_size <= 11
2247
+ and tc.header.font_compact is not None
2248
+ and tc.header.font_compact.weight is not None
2249
+ ):
2250
+ header_font_weight = str(tc.header.font_compact.weight)
2251
+ else:
2252
+ header_font_weight = _default_header_weight
2253
+ symbol_mode = tc.symbol_mode or "all"
2254
+ wrap_cells = tc.wrap
2255
+ # Row role: TableRowStyle.role is never None after cascade (has default)
2256
+ row_role_spec = tc.row.role
2257
+ header_rule_width = float(tc.header.rule.width)
2258
+ # Header height: when not explicitly set, scale with header font size
2259
+ # plus padding for breathing room and the rule (if any). Roughly
2260
+ # 2 × font_size gives a comfortable text band; add the rule height.
2261
+ # When the header is disabled, collapse to 0 so the layout assigns
2262
+ # all vertical room to data rows.
2263
+ if not tc.header.visible:
2264
+ header_height = 0
2265
+ elif tc.header.height is not None:
2266
+ header_height = int(tc.header.height)
2267
+ else:
2268
+ header_height = int(header_font_size * 2 + header_rule_width + 4)
2269
+ row_rule_width = float(tc.row.rule.width)
2270
+ # Summary rule: falls back to row_rule_width when not explicitly set.
2271
+ summary_rule_width = float(
2272
+ tc.row.roles.summary.rule_width or row_rule_width,
2273
+ )
2274
+ _summary_role_font = tc.row.roles.summary.font
2275
+ summary_font_weight = (
2276
+ font_weight_as_css(_summary_role_font.weight)
2277
+ if _summary_role_font is not None and _summary_role_font.weight is not None
2278
+ else None
2279
+ )
2280
+ # Per-role presentation from row.roles (summary / total).
2281
+ _role_summary = tc.row.roles.summary
2282
+ _role_total = tc.row.roles.total
2283
+ # Table font family can be overridden at the table level (e.g. Classic
2284
+ # variant uses Source Serif), falling back to the global body font.
2285
+ # Strip CSS-style quotes from font names for SVG compatibility — SVG
2286
+ # font-family attributes treat single quotes as literal characters, not
2287
+ # CSS string delimiters. 'Source Serif 4' → Source Serif 4.
2288
+ # Also prepend "Source Serif 4 Web" alias so the browser prefers our
2289
+ # bundled woff2 over a locally installed system font.
2290
+ # tc.font.family is always filled by _apply_cascade in production paths
2291
+ table_font_family = tc.font.family
2292
+ assert table_font_family is not None, "style.font.family must be configured"
2293
+ # Remove CSS quoting: 'Font Name' → Font Name (SVG presentation attrs
2294
+ # use space-separated identifiers for multi-word family names, not quotes)
2295
+ table_font_family = table_font_family.replace("'", "").replace('"', "")
2296
+ if (
2297
+ "Source Serif 4" in table_font_family
2298
+ and "Source Serif 4 Web" not in table_font_family
2299
+ ):
2300
+ table_font_family = table_font_family.replace(
2301
+ "Source Serif 4",
2302
+ "Source Serif 4 Web, Source Serif 4",
2303
+ )
2304
+ header_rule_continuous = bool(tc.header.rule.continuous or False)
2305
+ # Rules default to the body text color for strong visibility.
2306
+ # Row-level rule color takes precedence over table-level rule color.
2307
+ # Sanitize all user-provided colors; fall back to theme text color.
2308
+ _raw_rule_color = tc.row.rule.color or (tc.rule.color if tc.rule else None)
2309
+ rule_color = sanitize_color(_raw_rule_color, colors["color"])
2310
+ bottom_padding = int(tc.bottom_padding)
2311
+ title_text = chart.title
2312
+ subtitle_text = chart.subtitle
2313
+
2314
+ # Use width/height if provided; TableChartStyle has no default_width.
2315
+ table_width: float = width or TABLE_DEFAULT_WIDTH
2316
+ title_font_size, title_font_weight, title_font_family_str = chart_title_spec(
2317
+ table_width, level=face_level + 1, resolved_chart_style=resolved_style
2318
+ )
2319
+ title_style = resolved_style.title
2320
+ title_line_height = title_font_size + 2
2321
+ title_block = compute_table_title_block_layout(
2322
+ chart_title=str(title_text or ""),
2323
+ chart_subtitle=str(subtitle_text or ""),
2324
+ table_width=table_width,
2325
+ tc=tc,
2326
+ padding=padding,
2327
+ title_style=title_style,
2328
+ resolved_chart_style=resolved_style,
2329
+ face_level=face_level,
2330
+ )
2331
+ title_height = title_block.height
2332
+ rendered_title = title_block.rendered_title
2333
+ title_lines = list(title_block.title_lines)
2334
+ subtitle_font_size = title_block.subtitle_font_size
2335
+
2336
+ # Authored chart-local style entry boundary. ``chart`` is Any here
2337
+ # (ResolvedChart, Chart, or MockChart in tests). For ResolvedChart
2338
+ # the chart-local Patch is consumed by the cascade and not stored on the
2339
+ # ResolvedChart itself — column configs live on resolved.columns and the
2340
+ # rest is in resolved_style. For Chart/MockChart we still
2341
+ # read the authored Patch directly so column configs and table.wrap flow.
2342
+ # ``wrap_cells`` already has the merged value: tc = resolved_style.table,
2343
+ # and build_resolved_style merges any chart-local style.table onto
2344
+ # base.table — so tc.wrap reflects both layers without further
2345
+ # discrimination.
2346
+ # Get the table family sub-patch for column configs and header overflow.
2347
+ # ResolvedChart: column configs and defaults are promoted to resolved fields;
2348
+ # the ChartStylePatch is consumed by the cascade and not stored on ResolvedChart.
2349
+ # Chart/MockChart: read the authored table sub-patch directly.
2350
+ raw_style = getattr(chart, "style", None)
2351
+ if isinstance(raw_style, TableChartStylePatch):
2352
+ table_patch: TableChartStylePatch = raw_style
2353
+ elif raw_style is not None and hasattr(raw_style, "table"):
2354
+ table_patch = raw_style.table or TableChartStylePatch() # type: ignore[call-arg]
2355
+ elif isinstance(raw_style, dict):
2356
+ # Plain dict style — coerce to TableChartStylePatch (handles both
2357
+ # flat {"columns": {...}} and nested {"table": {"columns": {...}}} shapes).
2358
+ _style_dict = (
2359
+ raw_style.get("table", raw_style) if "table" in raw_style else raw_style
2360
+ )
2361
+ table_patch = TableChartStylePatch.model_validate(_style_dict)
2362
+ else:
2363
+ table_patch = TableChartStylePatch() # type: ignore[call-arg]
2364
+ # Column configs: prefer ResolvedChart.columns and ResolvedChart.column_defaults
2365
+ # (set by the table-channel lowering in pipeline.resolve_chart), fall back
2366
+ # to authored style for direct Chart/MockChart calls.
2367
+ resolved_columns = getattr(chart, "columns", None)
2368
+ resolved_column_defaults = getattr(chart, "column_defaults", None)
2369
+ patch_updates: dict[str, object] = {}
2370
+ if resolved_columns:
2371
+ patch_updates["columns"] = dict(resolved_columns)
2372
+ if isinstance(resolved_column_defaults, TableColumnDefaultsConfig):
2373
+ patch_updates["column_defaults"] = resolved_column_defaults
2374
+ if patch_updates:
2375
+ table_patch = table_patch.model_copy(update=patch_updates)
2376
+ # Pass query column names so column_defaults can apply to all inferred columns
2377
+ # when no explicit style.columns is provided (the "no columns" authoring shape).
2378
+ query_columns = list(data[0].keys()) if data else None
2379
+ column_configs = parse_table_column_configs(
2380
+ table_patch, query_columns=query_columns
2381
+ )
2382
+
2383
+ # Chart-level conditional_formatting block, indexed by column. Tables read
2384
+ # rules directly at render time — no internal lowering into column configs.
2385
+ # ResolvedChart stashes the block on ``source_chart``; Chart /
2386
+ # MockChart expose it directly.
2387
+ cf_block = getattr(chart, "conditional_formatting", None)
2388
+ if cf_block is None:
2389
+ source = getattr(chart, "source_chart", None)
2390
+ if source is not None:
2391
+ cf_block = getattr(source, "conditional_formatting", None)
2392
+ column_when_rules: dict[str, tuple[Any, ...]] = {
2393
+ col: tuple(entry.when) for col, entry in (cf_block or {}).items()
2394
+ }
2395
+
2396
+ # ResolvedChart.header_overflow is the promoted authored value (see
2397
+ # resolve_chart); for Chart/MockChart inputs we fall
2398
+ # back to the authored Patch.
2399
+ promoted_header_overflow = getattr(chart, "header_overflow", None)
2400
+ header_overflow = resolve_header_overflow(
2401
+ table_patch,
2402
+ inherited_title_style=resolved_style.title,
2403
+ table_config=table_config,
2404
+ promoted=promoted_header_overflow,
2405
+ )
2406
+
2407
+ # Prefer explicit configured columns so helper/style columns can stay hidden.
2408
+ # Dict keys preserve insertion order, so authored column order is honored.
2409
+ if table_patch.columns:
2410
+ columns = list(table_patch.columns.keys())
2411
+ else:
2412
+ columns = list(data[0].keys()) if data else []
2413
+
2414
+ available_width = table_width - (padding * 2)
2415
+ cell_pad = int(table_config.column_layout.cell_padding)
2416
+
2417
+ # Fit cascade: if content overflows, progressively reduce padding then
2418
+ # font sizes. 11px is the floor — below that, truncation handles what
2419
+ # can't fit. 8px is unreadable and visually worse than ellipsis.
2420
+ _COMPACT_PAD = max(cell_pad // 2, 6) # reduced padding floor
2421
+ _COMPACT_FONT = 11 # stepped-down body size (also the floor)
2422
+ _COMPACT_HEADER_FONT = 11 # header floor matches body
2423
+
2424
+ _cell_font = FontStyle(size=float(font_size), family=table_font_family)
2425
+ _header_font = FontStyle(
2426
+ size=float(header_font_size),
2427
+ family=table_font_family,
2428
+ weight=header_font_weight,
2429
+ case=tc.header.font.case,
2430
+ )
2431
+
2432
+ # Row-number column: compute its width NOW, before calculate_column_layout,
2433
+ # so the data-column layout pass gets a budget already reduced by the
2434
+ # synthetic column. This prevents the SVG widening when row_numbers is
2435
+ # toggled on for a table whose real columns already fill available_width.
2436
+ # Width is stable across pages (uses total row count, not per-page count).
2437
+ row_numbers = tc.row_numbers
2438
+ row_number_width = 0.0
2439
+ if row_numbers.visible:
2440
+ row_number_width = _row_number_column_width(
2441
+ row_numbers=row_numbers,
2442
+ total_row_count=len(data),
2443
+ table_config=table_config,
2444
+ font=_cell_font,
2445
+ measurer=get_font_measurer(table_font_family),
2446
+ )
2447
+ data_column_budget = available_width - row_number_width
2448
+
2449
+ col_demands = measure_column_demands(
2450
+ columns,
2451
+ column_configs,
2452
+ data,
2453
+ get_font_measurer(table_font_family),
2454
+ font_size=float(font_size),
2455
+ header_font_size=float(header_font_size),
2456
+ header_case=_header_font.case or "none",
2457
+ cell_pad=cell_pad,
2458
+ formats=resolved_style.formats,
2459
+ header_visible=tc.header.visible,
2460
+ )
2461
+ col_word_floors = measure_column_word_floors(
2462
+ columns,
2463
+ column_configs,
2464
+ data,
2465
+ get_font_measurer(table_font_family),
2466
+ font_size=float(font_size),
2467
+ header_case=_header_font.case or "none",
2468
+ cell_pad=cell_pad,
2469
+ header_visible=tc.header.visible,
2470
+ )
2471
+ col_widths, col_x_offsets, actual_content_width = calculate_column_layout(
2472
+ columns,
2473
+ column_configs,
2474
+ data_column_budget,
2475
+ demands=col_demands,
2476
+ width_similarity_threshold=table_config.column_layout.width_similarity_threshold,
2477
+ word_floors=col_word_floors,
2478
+ )
2479
+
2480
+ _formats = resolved_style.formats
2481
+ if _has_overflow(
2482
+ columns,
2483
+ data,
2484
+ column_configs,
2485
+ col_widths,
2486
+ cell_pad,
2487
+ _cell_font,
2488
+ header_font=_header_font,
2489
+ wrap=wrap_cells,
2490
+ formats=_formats,
2491
+ header_visible=tc.header.visible,
2492
+ ):
2493
+ # Step 1: reduce cell padding (table-wide)
2494
+ cell_pad = _COMPACT_PAD
2495
+ if _has_overflow(
2496
+ columns,
2497
+ data,
2498
+ column_configs,
2499
+ col_widths,
2500
+ cell_pad,
2501
+ _cell_font,
2502
+ header_font=_header_font,
2503
+ wrap=wrap_cells,
2504
+ formats=_formats,
2505
+ header_visible=tc.header.visible,
2506
+ ):
2507
+ # Step 2: step down font size to 11px (table-wide, cascade stops here)
2508
+ font_size = _COMPACT_FONT
2509
+ row_height = max(row_height - 4, 16)
2510
+ if _header_inherits_body:
2511
+ header_font_size = font_size # Minimal/Classic: track body
2512
+ else:
2513
+ header_font_size = _COMPACT_HEADER_FONT
2514
+ # Rebuild FontStyle after cascade changes font sizes.
2515
+ _cell_font = FontStyle(size=float(font_size), family=table_font_family)
2516
+ _header_font = FontStyle(
2517
+ size=float(header_font_size),
2518
+ family=table_font_family,
2519
+ weight=header_font_weight,
2520
+ case=tc.header.font.case,
2521
+ )
2522
+ # Remaining overflow is handled by per-cell truncation (ellipsis).
2523
+
2524
+ # After the cascade, body font size may have shrunk. When row numbers are
2525
+ # shown, row_number_width is font-dependent and must be recomputed so the
2526
+ # data-column budget reflects the final synthetic column width.
2527
+ _initial_font_size = float(int(tc.font.size)) # asserted non-None above
2528
+ if font_size < _initial_font_size and row_numbers.visible:
2529
+ row_number_width = _row_number_column_width(
2530
+ row_numbers=row_numbers,
2531
+ total_row_count=len(data),
2532
+ table_config=table_config,
2533
+ font=_cell_font,
2534
+ measurer=get_font_measurer(table_font_family),
2535
+ )
2536
+ data_column_budget = available_width - row_number_width
2537
+ col_demands = measure_column_demands(
2538
+ columns,
2539
+ column_configs,
2540
+ data,
2541
+ get_font_measurer(table_font_family),
2542
+ font_size=float(font_size),
2543
+ header_font_size=float(header_font_size),
2544
+ header_case=_header_font.case or "none",
2545
+ cell_pad=cell_pad,
2546
+ formats=resolved_style.formats,
2547
+ header_visible=tc.header.visible,
2548
+ )
2549
+ col_word_floors = measure_column_word_floors(
2550
+ columns,
2551
+ column_configs,
2552
+ data,
2553
+ get_font_measurer(table_font_family),
2554
+ font_size=float(font_size),
2555
+ header_case=_header_font.case or "none",
2556
+ cell_pad=cell_pad,
2557
+ header_visible=tc.header.visible,
2558
+ )
2559
+ col_widths, col_x_offsets, actual_content_width = calculate_column_layout(
2560
+ columns,
2561
+ column_configs,
2562
+ data_column_budget,
2563
+ demands=col_demands,
2564
+ width_similarity_threshold=table_config.column_layout.width_similarity_threshold,
2565
+ word_floors=col_word_floors,
2566
+ )
2567
+
2568
+ # Now stitch the synthetic column into the layout: prepend at x=0 and
2569
+ # shift all data-column x_offsets right by row_number_width.
2570
+ if row_numbers.visible:
2571
+ columns = [_ROW_NUMBER_COL, *columns]
2572
+ col_widths = {_ROW_NUMBER_COL: row_number_width, **col_widths}
2573
+ col_x_offsets = [0.0, *[off + row_number_width for off in col_x_offsets]]
2574
+ actual_content_width += row_number_width
2575
+
2576
+ # When explicit column widths sum past available_width, the table widens.
2577
+ if actual_content_width > available_width:
2578
+ table_width = actual_content_width + padding * 2
2579
+
2580
+ # Extract the data-only column/offset views once; reused by lane_positions
2581
+ # and resolve_wrapped_headers (both must skip the synthetic column).
2582
+ real_columns = [c for c in columns if c != _ROW_NUMBER_COL]
2583
+ real_col_x_offsets = [
2584
+ col_x_offsets[i] for i, c in enumerate(columns) if c != _ROW_NUMBER_COL
2585
+ ]
2586
+
2587
+ # Compute lane positions FIRST (before wrap decisions) so the wrap
2588
+ # logic knows where the value lane sits in each column. Use full
2589
+ # `data` here (not visible_data which depends on header_height that
2590
+ # we haven't determined yet — chicken-and-egg). Lane positions are
2591
+ # max widths across rows; using all data is a slight over-estimate
2592
+ # but avoids the dependency cycle.
2593
+ lane_positions = _compute_lane_positions(
2594
+ rows=data,
2595
+ columns=real_columns,
2596
+ column_configs=column_configs,
2597
+ col_widths=col_widths,
2598
+ col_x_offsets=real_col_x_offsets,
2599
+ padding_x=padding,
2600
+ cell_pad=cell_pad,
2601
+ cell_font=_cell_font,
2602
+ column_when_rules=column_when_rules,
2603
+ formats=resolved_style.formats,
2604
+ )
2605
+
2606
+ # When the header row is hidden, skip header wrapping/sizing entirely —
2607
+ # the resolver would otherwise re-compute a non-zero header_height even
2608
+ # though no header text will render. Keep header_height at 0 so layout
2609
+ # gives every pixel to data rows.
2610
+ wrapped_headers: dict[str, list[str]]
2611
+ if not tc.header.visible:
2612
+ wrapped_headers = {}
2613
+ else:
2614
+ wrapped_headers, header_height_resolved = resolve_wrapped_headers(
2615
+ real_columns,
2616
+ column_configs,
2617
+ col_widths,
2618
+ header_overflow=header_overflow,
2619
+ header_height=header_height,
2620
+ header_font=_header_font,
2621
+ padding=padding,
2622
+ table_config=table_config,
2623
+ cell_pad=cell_pad,
2624
+ measurer=get_font_measurer(table_font_family),
2625
+ )
2626
+ header_height = int(header_height_resolved)
2627
+
2628
+ # Pagination: extract current page from variables using chart ID
2629
+ chart_id = getattr(chart, "id", "") or ""
2630
+ current_page = _extract_page_from_variables(chart_id, variables) if chart_id else 1
2631
+
2632
+ # Pre-compute per-row heights AND cache wrapped lines for the full
2633
+ # dataset. Heights flow into pagination/height-constrained slicing;
2634
+ # cached lines skip the re-wrap work in _render_data_rows.
2635
+ all_row_heights: list[int] | None = None
2636
+ all_wrapped_lines: list[dict[str, list[str]]] | None = None
2637
+ if wrap_cells and data:
2638
+ all_row_heights, all_wrapped_lines = _compute_wrap_layout(
2639
+ data,
2640
+ real_columns,
2641
+ column_configs,
2642
+ col_widths,
2643
+ cell_pad,
2644
+ font_size,
2645
+ row_height,
2646
+ table_config.text_baseline_offset,
2647
+ get_font_measurer(table_font_family),
2648
+ formats=_formats,
2649
+ )
2650
+
2651
+ (
2652
+ table_height,
2653
+ visible_data,
2654
+ total_pages,
2655
+ page_offset,
2656
+ per_row_heights,
2657
+ rows_height,
2658
+ row_height,
2659
+ ) = _resolve_visible_rows(
2660
+ data,
2661
+ height=height,
2662
+ title_height=title_height,
2663
+ header_height=header_height,
2664
+ padding=padding,
2665
+ row_height=row_height,
2666
+ bottom_padding=bottom_padding,
2667
+ pagination=resolved_style.pagination,
2668
+ page=current_page,
2669
+ row_heights=all_row_heights,
2670
+ header_visible=tc.header.visible,
2671
+ )
2672
+ # Slice cached wrapped-lines to align with visible_data.
2673
+ visible_wrapped_lines: list[dict[str, list[str]]] | None = None
2674
+ if all_wrapped_lines is not None:
2675
+ visible_wrapped_lines = all_wrapped_lines[
2676
+ page_offset : page_offset + len(visible_data)
2677
+ ]
2678
+
2679
+ # Add breathing room before summary/total rows so double rules don't
2680
+ # crowd the last data row. Compute here to adjust total SVG height.
2681
+ _SUMMARY_GAP = int(row_height * 0.4)
2682
+ summary_gap_total = 0
2683
+ if row_role_spec and visible_data:
2684
+ for i, row in enumerate(visible_data):
2685
+ if i == 0:
2686
+ continue
2687
+ role = resolve_row_role(row_role_spec, row)
2688
+ prev_role = resolve_row_role(row_role_spec, visible_data[i - 1])
2689
+ if is_summary_role(role) and not is_summary_role(prev_role):
2690
+ summary_gap_total += _SUMMARY_GAP
2691
+ if summary_gap_total and not (height and height > 0):
2692
+ table_height += summary_gap_total
2693
+
2694
+ # Reserve space for pagination controls when needed.
2695
+ # When height is explicit (from sizing), it already includes the control
2696
+ # height — only add it for auto-sized tables.
2697
+ pagination_active = total_pages > 1
2698
+ pagination_control_height = _PAGINATION_CONTROL_HEIGHT if pagination_active else 0
2699
+ if pagination_active and not (height and height > 0):
2700
+ table_height += pagination_control_height
2701
+
2702
+ # Start building SVG
2703
+ svg_parts: list[str] = []
2704
+
2705
+ # Background
2706
+ svg_parts.append(
2707
+ f'<rect x="0" y="0" width="{table_width}" height="{table_height}" '
2708
+ f'fill="{colors["background"]}" rx="4"/>',
2709
+ )
2710
+
2711
+ current_y = padding
2712
+
2713
+ # Title
2714
+ if title_text:
2715
+ title_baseline = current_y + table_config.title_baseline_offset
2716
+ # Emit inner <title> when the rendered text differs from the original —
2717
+ # catches all overflow modes (clip, truncate, wrap-two). The old "…" sniff
2718
+ # missed clip mode which shortens without an ellipsis.
2719
+ is_title_truncated = rendered_title != str(title_text)
2720
+ inner_title = (
2721
+ f"<title>{html_module.escape(str(title_text))}</title>"
2722
+ if is_title_truncated
2723
+ else ""
2724
+ )
2725
+ svg_parts.append(
2726
+ f'<text x="{padding}" y="{title_baseline}" '
2727
+ f'font-size="{title_font_size}" font-weight="{title_font_weight}" fill="{colors["title_color"]}" '
2728
+ f'font-family="{title_font_family_str}">{inner_title}',
2729
+ )
2730
+ for line_index, line in enumerate(title_lines):
2731
+ line_y = title_baseline + (line_index * title_line_height)
2732
+ svg_parts.append(
2733
+ f'<tspan x="{padding}" y="{line_y}">{html_module.escape(line)}</tspan>',
2734
+ )
2735
+ svg_parts.append("</text>")
2736
+ if subtitle_text:
2737
+ last_title_baseline = title_baseline + (
2738
+ (len(title_lines) - 1) * title_line_height
2739
+ )
2740
+ subtitle_y = _subtitle_baseline_below_title(
2741
+ title_bottom=last_title_baseline + (title_font_size * 0.5),
2742
+ title_subtitle_gap=table_config.title_subtitle_gap,
2743
+ subtitle_font_size=subtitle_font_size,
2744
+ )
2745
+ escaped_subtitle = html_module.escape(subtitle_text)
2746
+ svg_parts.append(
2747
+ f'<text x="{padding}" y="{subtitle_y}" '
2748
+ f'font-size="{subtitle_font_size}" fill="{colors["muted"]}" '
2749
+ f'font-family="{table_font_family}">'
2750
+ f"{escaped_subtitle}</text>",
2751
+ )
2752
+ current_y += title_height
2753
+
2754
+ # lane_positions already computed above (before resolve_wrapped_headers)
2755
+
2756
+ # Skip header rendering when style.header.visible is False. Header
2757
+ # contributes 0 to layout (header_height has been zeroed above) so
2758
+ # data rows start immediately after the title (or at the table top
2759
+ # when no title). The header-body gap is also skipped.
2760
+ if tc.header.visible:
2761
+ _render_header_section(
2762
+ svg_parts,
2763
+ markdown_config=_markdown,
2764
+ columns=columns,
2765
+ column_configs=column_configs,
2766
+ colors=colors,
2767
+ table_config=table_config,
2768
+ table_width=table_width,
2769
+ header_height=header_height,
2770
+ current_y=current_y,
2771
+ padding_x=padding,
2772
+ col_x_offsets=col_x_offsets,
2773
+ col_widths=col_widths,
2774
+ col_lane_positions=lane_positions,
2775
+ header_font=_header_font,
2776
+ wrapped_headers=wrapped_headers,
2777
+ cell_pad=cell_pad,
2778
+ header_rule_width=header_rule_width,
2779
+ rule_color=rule_color,
2780
+ header_rule_continuous=header_rule_continuous,
2781
+ row_numbers=row_numbers if row_numbers.visible else None,
2782
+ )
2783
+
2784
+ current_y += header_height
2785
+ # Small whitespace gap between header and first data row for visual
2786
+ # hierarchy. Scales with row height. Skipped when header is disabled —
2787
+ # data rows start at the table top (after the title, if any).
2788
+ header_body_gap = int(row_height * 0.25) if tc.header.visible else 0
2789
+ current_y += header_body_gap
2790
+
2791
+ _render_data_rows(
2792
+ svg_parts,
2793
+ markdown_config=_markdown,
2794
+ table_config=table_config,
2795
+ rows=visible_data,
2796
+ columns=columns,
2797
+ column_configs=column_configs,
2798
+ column_when_rules=column_when_rules,
2799
+ colors=colors,
2800
+ col_widths=col_widths,
2801
+ col_x_offsets=col_x_offsets,
2802
+ col_lane_positions=lane_positions,
2803
+ padding_x=padding,
2804
+ current_y=current_y,
2805
+ row_height=row_height,
2806
+ cell_font=_cell_font,
2807
+ table_width=table_width,
2808
+ cell_pad=cell_pad,
2809
+ symbol_mode=symbol_mode,
2810
+ row_rule_width=row_rule_width,
2811
+ summary_rule_width=summary_rule_width,
2812
+ rule_color=rule_color,
2813
+ row_role_spec=row_role_spec,
2814
+ summary_font_weight=summary_font_weight,
2815
+ role_summary=_role_summary,
2816
+ role_total=_role_total,
2817
+ resolved_style=resolved_style,
2818
+ row_numbers=row_numbers if row_numbers.visible else None,
2819
+ page_offset=page_offset,
2820
+ row_heights=per_row_heights,
2821
+ wrapped_lines_by_row=visible_wrapped_lines,
2822
+ wrap=wrap_cells,
2823
+ chart_root_link=chart.link,
2824
+ chart_id=chart_id,
2825
+ )
2826
+
2827
+ # Show pagination controls or "more rows" indicator if data was truncated.
2828
+ # rows_height comes from _resolve_visible_rows so indicator_y can't desync
2829
+ # from table_height (both use the tallest-page sum when paginated).
2830
+ if len(data) > len(visible_data):
2831
+ indicator_y = current_y + rows_height + bottom_padding
2832
+
2833
+ if pagination_active and chart_id:
2834
+ # Interactive pagination controls
2835
+ page_var_name = f"{chart_id}_page"
2836
+ controls_svg = _render_pagination_controls(
2837
+ page=current_page,
2838
+ total_pages=total_pages,
2839
+ page_var_name=page_var_name,
2840
+ table_width=table_width,
2841
+ y=indicator_y,
2842
+ font_family=table_font_family,
2843
+ paginator=table_config.paginator,
2844
+ )
2845
+ svg_parts.append(controls_svg)
2846
+ else:
2847
+ more_count = len(data) - len(visible_data)
2848
+ svg_parts.append(
2849
+ f'<text x="{table_width / 2}" y="{indicator_y}" '
2850
+ f'font-size="{table_config.more_rows.font.size}" fill="{colors["muted"]}" text-anchor="middle" font-style="italic" '
2851
+ f'font-family="{table_font_family}">'
2852
+ f"+ {more_count} more rows</text>",
2853
+ )
2854
+
2855
+ # Empty state (only show if not placeholder - placeholder has data)
2856
+ if not data and not is_placeholder:
2857
+ svg_parts.append(
2858
+ f'<text x="{table_width / 2}" y="{table_height / 2}" '
2859
+ f'font-size="{table_config.empty_state.font.size}" fill="{colors["muted"]}" text-anchor="middle" '
2860
+ f'font-family="{table_font_family}">'
2861
+ f"No data</text>",
2862
+ )
2863
+
2864
+ # Wrap in SVG
2865
+ svg_result = f"""<svg xmlns="http://www.w3.org/2000/svg" width="{table_width}" height="{table_height}" viewBox="0 0 {table_width} {table_height}">
2866
+ {"".join(svg_parts)}
2867
+ </svg>"""
2868
+
2869
+ # Apply placeholder styling if needed
2870
+ if is_placeholder:
2871
+ from dataface.core.render.placeholder import (
2872
+ add_placeholder_overlay,
2873
+ apply_placeholder_opacity,
2874
+ )
2875
+
2876
+ svg_result = apply_placeholder_opacity(svg_result)
2877
+ svg_result = add_placeholder_overlay(
2878
+ svg_result,
2879
+ table_width,
2880
+ table_height,
2881
+ font=FontStyle(family=table_font_family),
2882
+ )
2883
+
2884
+ return svg_result
2885
+
2886
+
2887
+ # ---------------------------------------------------------------------------
2888
+ # Pivot helper — long-form → wide-form (cross-tab) transformation
2889
+ # ---------------------------------------------------------------------------
2890
+
2891
+
2892
+ def pivot_long_to_wide(
2893
+ data: list[dict[str, Any]],
2894
+ *,
2895
+ column: str,
2896
+ value: str,
2897
+ ) -> list[dict[str, Any]]:
2898
+ """Transform long-form rows into a wide (cross-tab) matrix.
2899
+
2900
+ Given long-form rows where `column` field values become column headers
2901
+ and `value` field fills each cell, returns wide-form rows keyed by the
2902
+ remaining row-dim fields.
2903
+
2904
+ Example:
2905
+ data = [{"region":"US","month":"Jan","amount":100}, ...]
2906
+ pivot_long_to_wide(data, column="month", value="amount")
2907
+ → [{"region":"US","Jan":100,"Feb":200}, {"region":"EU","Jan":150,"Feb":250}]
2908
+
2909
+ Missing (row, col) combinations produce None values in the output.
2910
+ """
2911
+ # Validate that both pivot fields are present before processing.
2912
+ if data:
2913
+ first_row = data[0]
2914
+ observed = sorted(first_row.keys())
2915
+ if column not in first_row:
2916
+ raise ChartDataError(
2917
+ f"pivot.column {column!r} not in data rows; observed keys: {observed}"
2918
+ )
2919
+ if value not in first_row:
2920
+ raise ChartDataError(
2921
+ f"pivot.value {value!r} not in data rows; observed keys: {observed}"
2922
+ )
2923
+ else:
2924
+ return []
2925
+
2926
+ # Collect ordered column header values (preserve first-seen order)
2927
+ col_headers: list[str] = list(dict.fromkeys(str(r[column]) for r in data))
2928
+
2929
+ # Identify row-dim fields (everything except column and value)
2930
+ row_dim_keys = [k for k in data[0] if k != column and k != value]
2931
+
2932
+ # Build composite row key → dict of col_header → cell value.
2933
+ # Track filled slots separately so None measure values don't bypass duplicate detection.
2934
+ row_map: dict[tuple, dict[str, Any]] = {}
2935
+ filled: dict[tuple, set[str]] = {}
2936
+ for row in data:
2937
+ key = tuple(row.get(k) for k in row_dim_keys)
2938
+ if key not in row_map:
2939
+ row_map[key] = {k: row.get(k) for k in row_dim_keys}
2940
+ for h in col_headers:
2941
+ row_map[key][h] = None
2942
+ filled[key] = set()
2943
+ col_val = str(row[column]) if column in row else None
2944
+ if col_val is not None:
2945
+ if col_val in filled[key]:
2946
+ row_key_repr = ", ".join(
2947
+ f"{k}={row_map[key][k]!r}" for k in row_dim_keys
2948
+ )
2949
+ raise ChartDataError(
2950
+ f"pivot_long_to_wide: duplicate value for row ({row_key_repr})"
2951
+ f" and column {col_val!r} — data is not uniquely keyed by"
2952
+ f" ({', '.join(row_dim_keys)}, {column!r})"
2953
+ )
2954
+ row_map[key][col_val] = row.get(value)
2955
+ filled[key].add(col_val)
2956
+
2957
+ return list(row_map.values())