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.
- d3_format/__init__.py +14 -0
- d3_format/errors.py +19 -0
- d3_format/format.py +551 -0
- d3_format/spec.py +159 -0
- dataface/DATAFACE_SYNTAX.md +1135 -0
- dataface/__init__.py +93 -0
- dataface/_docs_site.py +20 -0
- dataface/_install_hint.py +26 -0
- dataface/agent_api/__init__.py +79 -0
- dataface/agent_api/_init_templates/__init__.py +0 -0
- dataface/agent_api/_init_templates/agents_dft_snippet.md +26 -0
- dataface/agent_api/_init_templates/dataface.yml +15 -0
- dataface/agent_api/_init_templates/faces-dataface.yml +144 -0
- dataface/agent_api/_init_templates/index.md +24 -0
- dataface/agent_api/_paths.py +118 -0
- dataface/agent_api/_project_agents_md.py +43 -0
- dataface/agent_api/_session_store.py +486 -0
- dataface/agent_api/_state.py +28 -0
- dataface/agent_api/chat.py +221 -0
- dataface/agent_api/dashboards.py +257 -0
- dataface/agent_api/describe.py +366 -0
- dataface/agent_api/describe_query.py +120 -0
- dataface/agent_api/docs/__init__.py +25 -0
- dataface/agent_api/docs/_loader.py +292 -0
- dataface/agent_api/docs/yaml-reference.md +2757 -0
- dataface/agent_api/file_refs.py +118 -0
- dataface/agent_api/init.py +126 -0
- dataface/agent_api/inspect.py +128 -0
- dataface/agent_api/mcp_install.py +170 -0
- dataface/agent_api/query.py +274 -0
- dataface/agent_api/schema.py +658 -0
- dataface/agent_api/schema_search.py +284 -0
- dataface/agent_api/search.py +270 -0
- dataface/agent_api/skill_install.py +141 -0
- dataface/agent_api/skill_render.py +90 -0
- dataface/agent_api/skills.py +293 -0
- dataface/agent_api/surface_aliases.yaml +128 -0
- dataface/agent_api/validate.py +175 -0
- dataface/agent_api/validate_query.py +84 -0
- dataface/ai/__init__.py +39 -0
- dataface/ai/agent.py +139 -0
- dataface/ai/context.py +45 -0
- dataface/ai/events.py +62 -0
- dataface/ai/external_mcp.py +610 -0
- dataface/ai/generate_sql.py +96 -0
- dataface/ai/llm.py +403 -0
- dataface/ai/mcp/__init__.py +51 -0
- dataface/ai/mcp/server.py +289 -0
- dataface/ai/memories.py +85 -0
- dataface/ai/prompts.py +177 -0
- dataface/ai/schema_context.py +138 -0
- dataface/ai/skills/before-after-comparison/SKILL.md +102 -0
- dataface/ai/skills/before-after-comparison/examples/before-after-comparison.yml +24 -0
- dataface/ai/skills/dashboard-build/SKILL.md +212 -0
- dataface/ai/skills/dashboard-build/examples/_smoke.yml +15 -0
- dataface/ai/skills/dashboard-design/SKILL.md +182 -0
- dataface/ai/skills/dashboard-review/SKILL.md +113 -0
- dataface/ai/skills/dashboard-structural-review/SKILL.md +173 -0
- dataface/ai/skills/dashboard-visual-review/SKILL.md +139 -0
- dataface/ai/skills/dataface-mcp-setup/SKILL.md +177 -0
- dataface/ai/skills/dataface-troubleshooting/SKILL.md +225 -0
- dataface/ai/skills/drill-down-link/SKILL.md +112 -0
- dataface/ai/skills/drill-down-link/examples/drill-down-link.yml +27 -0
- dataface/ai/skills/faceted-small-multiples/SKILL.md +116 -0
- dataface/ai/skills/faceted-small-multiples/examples/faceted-small-multiples.yml +33 -0
- dataface/ai/skills/filter-bar-with-variables/SKILL.md +105 -0
- dataface/ai/skills/filter-bar-with-variables/examples/filter-bar-with-variables.yml +49 -0
- dataface/ai/skills/kpi-row/SKILL.md +101 -0
- dataface/ai/skills/kpi-row/examples/kpi-row.yml +55 -0
- dataface/ai/skills/report-design/SKILL.md +184 -0
- dataface/ai/skills/single-metric-bignum/SKILL.md +90 -0
- dataface/ai/skills/single-metric-bignum/examples/single-metric-bignum.yml +27 -0
- dataface/ai/skills/table-heavy-ops-dashboard/SKILL.md +114 -0
- dataface/ai/skills/table-heavy-ops-dashboard/examples/table-heavy-ops-dashboard.yml +48 -0
- dataface/ai/skills/time-series-trend/SKILL.md +93 -0
- dataface/ai/skills/time-series-trend/examples/time-series-trend.yml +26 -0
- dataface/ai/skills/top-n-with-detail/SKILL.md +98 -0
- dataface/ai/skills/top-n-with-detail/examples/top-n-with-detail.yml +45 -0
- dataface/ai/skills/two-by-two-grid-overview/SKILL.md +78 -0
- dataface/ai/skills/two-by-two-grid-overview/examples/two-by-two-grid-overview.yml +64 -0
- dataface/ai/tool_schemas.py +132 -0
- dataface/ai/tools/__init__.py +312 -0
- dataface/ai/yaml_utils.py +57 -0
- dataface/cli/__init__.py +3 -0
- dataface/cli/_console.py +48 -0
- dataface/cli/_error_format.py +83 -0
- dataface/cli/_extras.py +190 -0
- dataface/cli/_json_output.py +8 -0
- dataface/cli/_parsing.py +17 -0
- dataface/cli/_version_info.py +56 -0
- dataface/cli/commands/__init__.py +3 -0
- dataface/cli/commands/_agent_input.py +205 -0
- dataface/cli/commands/_agent_server.py +115 -0
- dataface/cli/commands/chat.py +645 -0
- dataface/cli/commands/describe.py +107 -0
- dataface/cli/commands/docs.py +131 -0
- dataface/cli/commands/extension.py +179 -0
- dataface/cli/commands/init.py +240 -0
- dataface/cli/commands/inspect.py +94 -0
- dataface/cli/commands/mcp_init.py +167 -0
- dataface/cli/commands/query.py +386 -0
- dataface/cli/commands/render.py +291 -0
- dataface/cli/commands/schema.py +411 -0
- dataface/cli/commands/search.py +49 -0
- dataface/cli/commands/serve.py +114 -0
- dataface/cli/commands/skills.py +133 -0
- dataface/cli/commands/skills_init.py +161 -0
- dataface/cli/commands/validate.py +63 -0
- dataface/cli/main.py +1501 -0
- dataface/core/__init__.py +75 -0
- dataface/core/compile/__init__.py +244 -0
- dataface/core/compile/_jinja_helpers.py +78 -0
- dataface/core/compile/channel.py +222 -0
- dataface/core/compile/chart_focus.py +101 -0
- dataface/core/compile/chart_resolved.py +169 -0
- dataface/core/compile/chart_type_detection.py +489 -0
- dataface/core/compile/chart_update.py +261 -0
- dataface/core/compile/colors.py +64 -0
- dataface/core/compile/compiler.py +904 -0
- dataface/core/compile/config.py +823 -0
- dataface/core/compile/custom_chart_types.py +208 -0
- dataface/core/compile/data_table_attachment.py +1287 -0
- dataface/core/compile/detect.py +110 -0
- dataface/core/compile/errors.py +302 -0
- dataface/core/compile/filter_injection.py +319 -0
- dataface/core/compile/introspection.py +527 -0
- dataface/core/compile/jinja.py +511 -0
- dataface/core/compile/labels_env.py +52 -0
- dataface/core/compile/markdown.py +154 -0
- dataface/core/compile/meta.py +388 -0
- dataface/core/compile/models/__init__.py +0 -0
- dataface/core/compile/models/chart/__init__.py +0 -0
- dataface/core/compile/models/chart/authored.py +2137 -0
- dataface/core/compile/models/chart/compiled.py +398 -0
- dataface/core/compile/models/config.py +347 -0
- dataface/core/compile/models/face/__init__.py +0 -0
- dataface/core/compile/models/face/authored.py +659 -0
- dataface/core/compile/models/face/compiled.py +522 -0
- dataface/core/compile/models/factories.py +201 -0
- dataface/core/compile/models/markers.py +40 -0
- dataface/core/compile/models/palette.py +36 -0
- dataface/core/compile/models/primitives.py +415 -0
- dataface/core/compile/models/query/__init__.py +0 -0
- dataface/core/compile/models/query/authored.py +246 -0
- dataface/core/compile/models/query/compiled.py +710 -0
- dataface/core/compile/models/refs.py +137 -0
- dataface/core/compile/models/source.py +611 -0
- dataface/core/compile/models/style/__init__.py +0 -0
- dataface/core/compile/models/style/authored.py +481 -0
- dataface/core/compile/models/style/compiled.py +3399 -0
- dataface/core/compile/models/style/merged.py +1682 -0
- dataface/core/compile/models/theme.py +362 -0
- dataface/core/compile/models/variable/__init__.py +0 -0
- dataface/core/compile/models/variable/authored.py +254 -0
- dataface/core/compile/models/vega_lite/__init__.py +0 -0
- dataface/core/compile/models/vega_lite/config.py +510 -0
- dataface/core/compile/models/vega_lite/contracts.py +171 -0
- dataface/core/compile/normalize_charts.py +494 -0
- dataface/core/compile/normalize_layout.py +1000 -0
- dataface/core/compile/normalize_queries.py +297 -0
- dataface/core/compile/normalize_variables.py +489 -0
- dataface/core/compile/normalizer.py +543 -0
- dataface/core/compile/palette.py +1100 -0
- dataface/core/compile/parameterized.py +658 -0
- dataface/core/compile/parser.py +228 -0
- dataface/core/compile/schema.py +20 -0
- dataface/core/compile/schema_renderers/__init__.py +0 -0
- dataface/core/compile/schema_renderers/json_schema.py +163 -0
- dataface/core/compile/schema_renderers/prompt.py +152 -0
- dataface/core/compile/schema_renderers/vscode_schema.py +301 -0
- dataface/core/compile/sizing.py +2126 -0
- dataface/core/compile/sources.py +518 -0
- dataface/core/compile/sql_authoring_lint.py +56 -0
- dataface/core/compile/style_cascade.py +471 -0
- dataface/core/compile/typography.py +299 -0
- dataface/core/compile/validator.py +301 -0
- dataface/core/compile/variables.py +53 -0
- dataface/core/compile/vega_config.py +98 -0
- dataface/core/compile/vega_lite/__init__.py +6 -0
- dataface/core/compile/vega_lite/validation.py +95 -0
- dataface/core/compile/yaml_error_formatter.py +838 -0
- dataface/core/connections.py +38 -0
- dataface/core/dashboard.py +358 -0
- dataface/core/defaults/default_config.yml +101 -0
- dataface/core/defaults/palettes/categorical/category-10-dark.yml +32 -0
- dataface/core/defaults/palettes/categorical/category-10-light.yml +43 -0
- dataface/core/defaults/palettes/categorical/category-10.yml +31 -0
- dataface/core/defaults/palettes/categorical/category-6-tonal-blue.yml +22 -0
- dataface/core/defaults/palettes/categorical/category-6-tonal-brown.yml +29 -0
- dataface/core/defaults/palettes/categorical/category-6-tonal-green.yml +20 -0
- dataface/core/defaults/palettes/categorical/category-6-tonal-orange.yml +21 -0
- dataface/core/defaults/palettes/categorical/category-6-tonal-purple.yml +20 -0
- dataface/core/defaults/palettes/categorical/editorial-10-dark.yml +32 -0
- dataface/core/defaults/palettes/categorical/editorial-10.yml +40 -0
- dataface/core/defaults/palettes/categorical/hero-6.yml +17 -0
- dataface/core/defaults/palettes/categorical/single-blue.yml +11 -0
- dataface/core/defaults/palettes/categorical/tableau.yml +20 -0
- dataface/core/defaults/palettes/data/xkcd_colors.json +3803 -0
- dataface/core/defaults/palettes/diverging/blue-red.yml +25 -0
- dataface/core/defaults/palettes/diverging/coolwarm.yml +24 -0
- dataface/core/defaults/palettes/diverging/crimson-green.yml +23 -0
- dataface/core/defaults/palettes/diverging/orange-teal.yml +23 -0
- dataface/core/defaults/palettes/diverging/sunset.yml +24 -0
- dataface/core/defaults/palettes/scaffold/dft-creams.yml +38 -0
- dataface/core/defaults/palettes/scaffold/dft-grays.yml +53 -0
- dataface/core/defaults/palettes/sequential/amber.yml +22 -0
- dataface/core/defaults/palettes/sequential/blue.yml +22 -0
- dataface/core/defaults/palettes/sequential/brown.yml +22 -0
- dataface/core/defaults/palettes/sequential/gray.yml +22 -0
- dataface/core/defaults/palettes/sequential/green.yml +22 -0
- dataface/core/defaults/palettes/sequential/purple.yml +22 -0
- dataface/core/defaults/palettes/sequential/rust.yml +22 -0
- dataface/core/defaults/palettes/sequential/teal.yml +22 -0
- dataface/core/defaults/palettes/tone/negative.yml +32 -0
- dataface/core/defaults/palettes/tone/positive.yml +22 -0
- dataface/core/defaults/palettes/tone/warning.yml +22 -0
- dataface/core/defaults/themes/_base.yaml +786 -0
- dataface/core/defaults/themes/bi.yaml +16 -0
- dataface/core/defaults/themes/carbong100.yaml +41 -0
- dataface/core/defaults/themes/cream.yaml +122 -0
- dataface/core/defaults/themes/dark.yaml +40 -0
- dataface/core/defaults/themes/diagnostics-title-angle-extreme.yaml +9 -0
- dataface/core/defaults/themes/diagnostics-title-baseline-extreme.yaml +9 -0
- dataface/core/defaults/themes/diagnostics-title-baseline.yaml +24 -0
- dataface/core/defaults/themes/diagnostics-title-center.yaml +8 -0
- dataface/core/defaults/themes/diagnostics-title-color-extreme.yaml +24 -0
- dataface/core/defaults/themes/diagnostics-title-font-extreme.yaml +25 -0
- dataface/core/defaults/themes/diagnostics-title-left.yaml +8 -0
- dataface/core/defaults/themes/diagnostics-title-offset-extreme.yaml +9 -0
- dataface/core/defaults/themes/diagnostics-title-size-extreme.yaml +24 -0
- dataface/core/defaults/themes/diagnostics-title-weight-extreme.yaml +24 -0
- dataface/core/defaults/themes/editorial.yaml +147 -0
- dataface/core/defaults/themes/light.yaml +30 -0
- dataface/core/defaults/themes/looker.yaml +17 -0
- dataface/core/defaults/themes/stark.yaml +134 -0
- dataface/core/errors/__init__.py +67 -0
- dataface/core/errors/codes_compile.py +56 -0
- dataface/core/errors/codes_execute.py +177 -0
- dataface/core/errors/codes_render.py +106 -0
- dataface/core/errors/codes_unknown.py +15 -0
- dataface/core/errors/hints.py +74 -0
- dataface/core/errors/registry.py +42 -0
- dataface/core/errors/structured.py +92 -0
- dataface/core/execute/__init__.py +91 -0
- dataface/core/execute/adapters/__init__.py +49 -0
- dataface/core/execute/adapters/adapter_registry.py +400 -0
- dataface/core/execute/adapters/base.py +245 -0
- dataface/core/execute/adapters/csv_adapter.py +239 -0
- dataface/core/execute/adapters/dbt_adapter.py +283 -0
- dataface/core/execute/adapters/dbt_adapter_factory.py +212 -0
- dataface/core/execute/adapters/dbt_macro_loader.py +95 -0
- dataface/core/execute/adapters/dbt_utils.py +150 -0
- dataface/core/execute/adapters/http_adapter.py +224 -0
- dataface/core/execute/adapters/metricflow_adapter.py +94 -0
- dataface/core/execute/adapters/schema_resolver_adapter.py +144 -0
- dataface/core/execute/adapters/sql_adapter.py +710 -0
- dataface/core/execute/adapters/values_adapter.py +58 -0
- dataface/core/execute/batch.py +744 -0
- dataface/core/execute/cache_backend.py +135 -0
- dataface/core/execute/cache_keys.py +66 -0
- dataface/core/execute/dbt_jinja.py +21 -0
- dataface/core/execute/dialects/__init__.py +121 -0
- dataface/core/execute/dialects/athena.py +75 -0
- dataface/core/execute/dialects/base.py +302 -0
- dataface/core/execute/dialects/bigquery.py +38 -0
- dataface/core/execute/dialects/databricks.py +68 -0
- dataface/core/execute/dialects/duckdb.py +35 -0
- dataface/core/execute/dialects/mysql.py +68 -0
- dataface/core/execute/dialects/postgres.py +39 -0
- dataface/core/execute/dialects/redshift.py +12 -0
- dataface/core/execute/dialects/snowflake.py +51 -0
- dataface/core/execute/dialects/sqlserver.py +92 -0
- dataface/core/execute/duckdb_cache.py +712 -0
- dataface/core/execute/duckdb_config.py +26 -0
- dataface/core/execute/errors.py +213 -0
- dataface/core/execute/executor.py +1249 -0
- dataface/core/execute/parallel.py +162 -0
- dataface/core/execute/setup_sql.py +58 -0
- dataface/core/execute/source_registry.py +72 -0
- dataface/core/execute/source_resolver.py +255 -0
- dataface/core/execute/sql_guard.py +387 -0
- dataface/core/execute/sql_literals.py +199 -0
- dataface/core/fonts.py +52 -0
- dataface/core/inspect/__init__.py +32 -0
- dataface/core/inspect/cache_factory.py +98 -0
- dataface/core/inspect/db_types.py +162 -0
- dataface/core/inspect/dbt_schema.py +96 -0
- dataface/core/inspect/defaults.yml +37 -0
- dataface/core/inspect/fanout_risk.py +109 -0
- dataface/core/inspect/manifest_utils.py +77 -0
- dataface/core/inspect/partials/categorical.yml +40 -0
- dataface/core/inspect/partials/date.yml +40 -0
- dataface/core/inspect/partials/numeric.yml +55 -0
- dataface/core/inspect/partition_types.py +38 -0
- dataface/core/inspect/query_validator.py +975 -0
- dataface/core/inspect/renderer.py +354 -0
- dataface/core/inspect/resolver.py +808 -0
- dataface/core/inspect/search.py +461 -0
- dataface/core/inspect/sources/__init__.py +32 -0
- dataface/core/inspect/sources/dbt.py +738 -0
- dataface/core/inspect/sources/duckdb_utils.py +66 -0
- dataface/core/inspect/templates/__init__.py +1 -0
- dataface/core/inspect/templates/categorical_column.yml +196 -0
- dataface/core/inspect/templates/charts.yml +109 -0
- dataface/core/inspect/templates/date_column.yml +248 -0
- dataface/core/inspect/templates/model.yml +138 -0
- dataface/core/inspect/templates/numeric_column.yml +261 -0
- dataface/core/inspect/templates/quality.yml +80 -0
- dataface/core/inspect/templates/string_column.yml +263 -0
- dataface/core/project_roots.py +165 -0
- dataface/core/render/__init__.py +87 -0
- dataface/core/render/board_links.py +176 -0
- dataface/core/render/chart/__init__.py +27 -0
- dataface/core/render/chart/arc_attached_table.py +251 -0
- dataface/core/render/chart/artifacts.py +16 -0
- dataface/core/render/chart/callout.py +225 -0
- dataface/core/render/chart/decisions.py +358 -0
- dataface/core/render/chart/geo.py +700 -0
- dataface/core/render/chart/kpi.py +916 -0
- dataface/core/render/chart/labels.py +76 -0
- dataface/core/render/chart/pipeline.py +818 -0
- dataface/core/render/chart/presentation.py +36 -0
- dataface/core/render/chart/profile.py +3438 -0
- dataface/core/render/chart/render_single.py +347 -0
- dataface/core/render/chart/renderers.py +193 -0
- dataface/core/render/chart/rendering.py +565 -0
- dataface/core/render/chart/serialization.py +90 -0
- dataface/core/render/chart/spark.py +496 -0
- dataface/core/render/chart/spark_bar.py +370 -0
- dataface/core/render/chart/spec_builders.py +154 -0
- dataface/core/render/chart/standard_renderer.py +2645 -0
- dataface/core/render/chart/table.py +2957 -0
- dataface/core/render/chart/table_support.py +1452 -0
- dataface/core/render/chart/tick_values.py +66 -0
- dataface/core/render/chart/time_unit_detect.py +809 -0
- dataface/core/render/chart/title_overflow.py +157 -0
- dataface/core/render/chart/type_inference.py +122 -0
- dataface/core/render/chart/validation.py +99 -0
- dataface/core/render/chart/vega_lite.py +125 -0
- dataface/core/render/chart/vega_lite_types.py +268 -0
- dataface/core/render/chart/vl_field_maps.py +346 -0
- dataface/core/render/chart_interactivity.py +24 -0
- dataface/core/render/control_registry.py +287 -0
- dataface/core/render/converters/__init__.py +24 -0
- dataface/core/render/converters/chart.py +276 -0
- dataface/core/render/converters/html.py +98 -0
- dataface/core/render/converters/pdf.py +40 -0
- dataface/core/render/converters/png.py +41 -0
- dataface/core/render/errors.py +144 -0
- dataface/core/render/face_api.py +160 -0
- dataface/core/render/faces.py +1194 -0
- dataface/core/render/font_measurement.py +48 -0
- dataface/core/render/font_support.py +197 -0
- dataface/core/render/fonts/DFTSansTabular-Regular.ttf +0 -0
- dataface/core/render/fonts/DFTSansTabular-Regular.woff2 +0 -0
- dataface/core/render/fonts/DFTSerifOldstyleProportional-Regular.ttf +0 -0
- dataface/core/render/fonts/DFTSerifOldstyleTabular-Regular.ttf +0 -0
- dataface/core/render/fonts/InterVariable.ttf +0 -0
- dataface/core/render/fonts/InterVariable.woff2 +0 -0
- dataface/core/render/fonts/NOTO_COLOR_EMOJI_LICENSE.txt +93 -0
- dataface/core/render/fonts/NOTO_EMOJI_LICENSE.txt +93 -0
- dataface/core/render/fonts/NotoColorEmoji-Regular.ttf +0 -0
- dataface/core/render/fonts/NotoColorEmoji-Regular.woff2 +0 -0
- dataface/core/render/fonts/NotoEmoji-Regular.ttf +0 -0
- dataface/core/render/fonts/NotoEmoji-Regular.woff2 +0 -0
- dataface/core/render/fonts/SOURCE_CODE_PRO_LICENSE.txt +93 -0
- dataface/core/render/fonts/SOURCE_SERIF_4_LICENSE.txt +98 -0
- dataface/core/render/fonts/SourceCodePro-Regular.ttf +0 -0
- dataface/core/render/fonts/SourceSerif4-Regular.ttf +0 -0
- dataface/core/render/fonts/_emoji_font_face.css +43 -0
- dataface/core/render/fonts/source-serif-4-variable-latin.woff2 +0 -0
- dataface/core/render/format_utils.py +329 -0
- dataface/core/render/geo_defaults.yml +28 -0
- dataface/core/render/json_format.py +146 -0
- dataface/core/render/layout_sizing.py +865 -0
- dataface/core/render/layouts.py +541 -0
- dataface/core/render/markdown_defaults.yml +16 -0
- dataface/core/render/missing_vars_prompt.py +79 -0
- dataface/core/render/placeholder.py +389 -0
- dataface/core/render/render_result.py +14 -0
- dataface/core/render/renderer.py +467 -0
- dataface/core/render/script_embedding.py +16 -0
- dataface/core/render/svg_utils.py +212 -0
- dataface/core/render/template_loader.py +69 -0
- dataface/core/render/templates/controls/_styles.css +606 -0
- dataface/core/render/templates/controls/checkbox.html +16 -0
- dataface/core/render/templates/controls/date.html +16 -0
- dataface/core/render/templates/controls/number.html +19 -0
- dataface/core/render/templates/controls/readonly.html +9 -0
- dataface/core/render/templates/controls/select.html +21 -0
- dataface/core/render/templates/controls/slider.html +22 -0
- dataface/core/render/templates/controls/text.html +16 -0
- dataface/core/render/templates/scripts/chart_interactivity.js +191 -0
- dataface/core/render/templates/scripts/variables.js +976 -0
- dataface/core/render/templates/svg/grid_pattern.svg +3 -0
- dataface/core/render/templates/svg/styles.css +51 -0
- dataface/core/render/terminal.py +311 -0
- dataface/core/render/terminal_charts.py +563 -0
- dataface/core/render/terminal_defaults.yml +2 -0
- dataface/core/render/terminal_layouts.py +299 -0
- dataface/core/render/terminal_text.py +31 -0
- dataface/core/render/text/__init__.py +1 -0
- dataface/core/render/text/case.py +113 -0
- dataface/core/render/text_format.py +129 -0
- dataface/core/render/utils.py +106 -0
- dataface/core/render/variable_controls.py +946 -0
- dataface/core/render/variable_input_refinement.py +140 -0
- dataface/core/render/warnings/__init__.py +15 -0
- dataface/core/render/warnings/bar_color_1_to_1_with_x.py +80 -0
- dataface/core/render/warnings/base.py +44 -0
- dataface/core/render/warnings/fanout_risk.py +15 -0
- dataface/core/render/warnings/from_query_diagnostic.py +56 -0
- dataface/core/render/warnings/missing_join_predicate.py +13 -0
- dataface/core/render/warnings/query_parse_error.py +14 -0
- dataface/core/render/warnings/query_returned_zero_rows.py +42 -0
- dataface/core/render/warnings/reaggregation.py +14 -0
- dataface/core/render/warnings/registry.py +45 -0
- dataface/core/render/warnings/suppression.py +46 -0
- dataface/core/render/warnings/temporal_single_point.py +63 -0
- dataface/core/render/warnings/unreferenced_chart.py +15 -0
- dataface/core/render/warnings/y_encoding_mostly_null.py +76 -0
- dataface/core/render/yaml_format.py +167 -0
- dataface/core/resolve_face.py +195 -0
- dataface/core/schema/__init__.py +0 -0
- dataface/core/schema/guidance.py +151 -0
- dataface/core/scoped_paths.py +59 -0
- dataface/core/serve/__init__.py +14 -0
- dataface/core/serve/bootstrap.py +39 -0
- dataface/core/serve/embedded.py +57 -0
- dataface/core/serve/port.py +129 -0
- dataface/core/serve/server.py +938 -0
- dataface/core/serve/templates/__init__.py +0 -0
- dataface/core/serve/templates/directory.yml +6 -0
- dataface/core/serve/templates/error.html.j2 +217 -0
- dataface/core/utils.py +121 -0
- dataface/core/validate.py +64 -0
- dataface/integrations/__init__.py +0 -0
- dataface/integrations/highlighting.py +351 -0
- dataface/integrations/markdown.py +537 -0
- dataface/py.typed +0 -0
- dataface-0.1.2.dist-info/METADATA +375 -0
- dataface-0.1.2.dist-info/RECORD +455 -0
- dataface-0.1.2.dist-info/WHEEL +4 -0
- dataface-0.1.2.dist-info/entry_points.txt +2 -0
- dataface-0.1.2.dist-info/licenses/LICENSE +202 -0
- mdsvg/__init__.py +168 -0
- mdsvg/fonts.py +656 -0
- mdsvg/images.py +299 -0
- mdsvg/parser.py +629 -0
- mdsvg/playground.py +284 -0
- mdsvg/py.typed +2 -0
- mdsvg/renderer.py +1623 -0
- mdsvg/style.py +355 -0
- mdsvg/types.py +200 -0
- 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())
|