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