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
mdsvg/fonts.py
ADDED
|
@@ -0,0 +1,656 @@
|
|
|
1
|
+
"""Font-based text measurement for precise width calculations.
|
|
2
|
+
|
|
3
|
+
This module provides accurate text measurement using fonttools to read
|
|
4
|
+
actual glyph metrics from font files.
|
|
5
|
+
|
|
6
|
+
## Basic Usage
|
|
7
|
+
|
|
8
|
+
from mdsvg.fonts import FontMeasurer, get_system_font
|
|
9
|
+
|
|
10
|
+
# Use system font (auto-detected)
|
|
11
|
+
measurer = FontMeasurer.system_default()
|
|
12
|
+
width = measurer.measure("Hello World", font_size=14)
|
|
13
|
+
|
|
14
|
+
## Custom Fonts
|
|
15
|
+
|
|
16
|
+
You can use any TTF/OTF font file:
|
|
17
|
+
|
|
18
|
+
measurer = FontMeasurer("/path/to/your/font.ttf")
|
|
19
|
+
width = measurer.measure("Hello", 14)
|
|
20
|
+
|
|
21
|
+
### Where to put custom font files
|
|
22
|
+
|
|
23
|
+
Recommended locations:
|
|
24
|
+
- Project directory: `./fonts/MyFont.ttf`
|
|
25
|
+
- User fonts (macOS): `~/Library/Fonts/MyFont.ttf`
|
|
26
|
+
- User fonts (Linux): `~/.local/share/fonts/MyFont.ttf`
|
|
27
|
+
- User fonts (Windows): `C:\\Users\\<user>\\AppData\\Local\\Microsoft\\Windows\\Fonts\\`
|
|
28
|
+
|
|
29
|
+
### Google Fonts
|
|
30
|
+
|
|
31
|
+
Download fonts from Google Fonts automatically:
|
|
32
|
+
|
|
33
|
+
from mdsvg.fonts import download_google_font, FontMeasurer
|
|
34
|
+
|
|
35
|
+
font_path = download_google_font("Inter")
|
|
36
|
+
measurer = FontMeasurer(font_path)
|
|
37
|
+
|
|
38
|
+
Or download manually from https://fonts.google.com and place in your project.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
import os
|
|
44
|
+
import platform
|
|
45
|
+
import re
|
|
46
|
+
from dataclasses import dataclass, field
|
|
47
|
+
from functools import lru_cache
|
|
48
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class FontMeasurer:
|
|
53
|
+
"""
|
|
54
|
+
Measure text width using actual font metrics via fonttools.
|
|
55
|
+
|
|
56
|
+
Example:
|
|
57
|
+
>>> measurer = FontMeasurer("/System/Library/Fonts/Helvetica.ttc")
|
|
58
|
+
>>> measurer.measure("Hello World", 14)
|
|
59
|
+
72.4
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
font_path: str
|
|
63
|
+
font_number: int = 0 # For .ttc files with multiple fonts
|
|
64
|
+
_cmap: Optional[Dict[int, str]] = field(default=None, init=False, repr=False)
|
|
65
|
+
_hmtx: Optional[Any] = field(default=None, init=False, repr=False)
|
|
66
|
+
_units_per_em: int = field(default=1000, init=False, repr=False)
|
|
67
|
+
_available: bool = field(default=False, init=False, repr=False)
|
|
68
|
+
|
|
69
|
+
def __post_init__(self) -> None:
|
|
70
|
+
self._init_font()
|
|
71
|
+
|
|
72
|
+
def _init_font(self) -> None:
|
|
73
|
+
"""Load font metrics from the font file."""
|
|
74
|
+
try:
|
|
75
|
+
from fontTools.ttLib import TTFont, TTLibError
|
|
76
|
+
except ImportError:
|
|
77
|
+
# fontTools not installed — measurement unavailable.
|
|
78
|
+
self._available = False
|
|
79
|
+
return
|
|
80
|
+
try:
|
|
81
|
+
font = TTFont(self.font_path, fontNumber=self.font_number)
|
|
82
|
+
self._cmap = font.getBestCmap()
|
|
83
|
+
self._hmtx = font["hmtx"]
|
|
84
|
+
self._units_per_em = font["head"].unitsPerEm
|
|
85
|
+
self._available = True
|
|
86
|
+
except (FileNotFoundError, OSError, TTLibError):
|
|
87
|
+
# Font file not found, unreadable, or invalid.
|
|
88
|
+
self._available = False
|
|
89
|
+
|
|
90
|
+
def measure(self, text: str, font_size: float) -> float:
|
|
91
|
+
"""
|
|
92
|
+
Measure the width of text in pixels.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
text: The text to measure.
|
|
96
|
+
font_size: Font size in pixels.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
Width in pixels.
|
|
100
|
+
|
|
101
|
+
Raises:
|
|
102
|
+
RuntimeError: If fonttools is not available.
|
|
103
|
+
"""
|
|
104
|
+
if not text:
|
|
105
|
+
return 0.0
|
|
106
|
+
|
|
107
|
+
if not self._available:
|
|
108
|
+
raise RuntimeError(
|
|
109
|
+
"FontMeasurer not available. Install fonttools: pip install fonttools"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
total_width: float = 0
|
|
113
|
+
for char in text:
|
|
114
|
+
glyph_id = self._cmap.get(ord(char)) if self._cmap else None
|
|
115
|
+
if glyph_id and self._hmtx and glyph_id in self._hmtx.metrics:
|
|
116
|
+
advance_width, _ = self._hmtx.metrics[glyph_id]
|
|
117
|
+
total_width += advance_width
|
|
118
|
+
else:
|
|
119
|
+
# Fallback for unknown glyphs (space-like width)
|
|
120
|
+
total_width += self._units_per_em * 0.25
|
|
121
|
+
|
|
122
|
+
return (total_width / self._units_per_em) * font_size
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def is_available(self) -> bool:
|
|
126
|
+
"""Check if font measurement is available."""
|
|
127
|
+
return self._available
|
|
128
|
+
|
|
129
|
+
@classmethod
|
|
130
|
+
def system_default(cls) -> Optional[FontMeasurer]:
|
|
131
|
+
"""
|
|
132
|
+
Create a FontMeasurer using the system default font.
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
FontMeasurer if a system font is found and fonttools is available,
|
|
136
|
+
None otherwise.
|
|
137
|
+
"""
|
|
138
|
+
font_path = get_system_font()
|
|
139
|
+
if font_path:
|
|
140
|
+
measurer = cls(font_path)
|
|
141
|
+
if measurer.is_available:
|
|
142
|
+
return measurer
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@dataclass(frozen=True)
|
|
147
|
+
class WrapPiece:
|
|
148
|
+
"""A measured word/token plus the separator that may precede it."""
|
|
149
|
+
|
|
150
|
+
text: str
|
|
151
|
+
width: float
|
|
152
|
+
separator: str = ""
|
|
153
|
+
separator_width: float = 0.0
|
|
154
|
+
meta: Any = None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def get_system_font() -> Optional[str]:
|
|
158
|
+
"""
|
|
159
|
+
Find a system font that can be used for measurement.
|
|
160
|
+
|
|
161
|
+
Looks for common sans-serif fonts that match typical "system-ui" rendering.
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
Path to a font file, or None if not found.
|
|
165
|
+
"""
|
|
166
|
+
system = platform.system()
|
|
167
|
+
|
|
168
|
+
if system == "Darwin": # macOS
|
|
169
|
+
candidates = [
|
|
170
|
+
"/System/Library/Fonts/SFNS.ttf",
|
|
171
|
+
"/System/Library/Fonts/SFNSText.ttf",
|
|
172
|
+
"/System/Library/Fonts/Helvetica.ttc",
|
|
173
|
+
"/Library/Fonts/Arial.ttf",
|
|
174
|
+
"/System/Library/Fonts/Supplemental/Arial.ttf",
|
|
175
|
+
]
|
|
176
|
+
elif system == "Windows":
|
|
177
|
+
windir = os.environ.get("WINDIR", "C:\\Windows")
|
|
178
|
+
candidates = [
|
|
179
|
+
os.path.join(windir, "Fonts", "segoeui.ttf"),
|
|
180
|
+
os.path.join(windir, "Fonts", "arial.ttf"),
|
|
181
|
+
os.path.join(windir, "Fonts", "calibri.ttf"),
|
|
182
|
+
]
|
|
183
|
+
else: # Linux
|
|
184
|
+
candidates = [
|
|
185
|
+
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
|
186
|
+
"/usr/share/fonts/TTF/DejaVuSans.ttf",
|
|
187
|
+
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
|
|
188
|
+
"/usr/share/fonts/truetype/ubuntu/Ubuntu-R.ttf",
|
|
189
|
+
]
|
|
190
|
+
|
|
191
|
+
for path in candidates:
|
|
192
|
+
if os.path.exists(path):
|
|
193
|
+
return path
|
|
194
|
+
|
|
195
|
+
return None
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def get_system_mono_font() -> Optional[str]:
|
|
199
|
+
"""Find a system monospace font for inline-code measurement."""
|
|
200
|
+
system = platform.system()
|
|
201
|
+
|
|
202
|
+
if system == "Darwin": # macOS
|
|
203
|
+
candidates = [
|
|
204
|
+
"/System/Library/Fonts/SFNSMono.ttf",
|
|
205
|
+
"/System/Library/Fonts/SFMono-Regular.otf",
|
|
206
|
+
"/System/Library/Fonts/Menlo.ttc",
|
|
207
|
+
"/System/Library/Fonts/Supplemental/Menlo.ttc",
|
|
208
|
+
"/Library/Fonts/Courier New.ttf",
|
|
209
|
+
]
|
|
210
|
+
elif system == "Windows":
|
|
211
|
+
windir = os.environ.get("WINDIR", "C:\\Windows")
|
|
212
|
+
candidates = [
|
|
213
|
+
os.path.join(windir, "Fonts", "consola.ttf"),
|
|
214
|
+
os.path.join(windir, "Fonts", "cour.ttf"),
|
|
215
|
+
]
|
|
216
|
+
else: # Linux
|
|
217
|
+
candidates = [
|
|
218
|
+
"/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
|
|
219
|
+
"/usr/share/fonts/TTF/DejaVuSansMono.ttf",
|
|
220
|
+
"/usr/share/fonts/truetype/liberation2/LiberationMono-Regular.ttf",
|
|
221
|
+
"/usr/share/fonts/truetype/liberation/LiberationMono-Regular.ttf",
|
|
222
|
+
]
|
|
223
|
+
|
|
224
|
+
for path in candidates:
|
|
225
|
+
if os.path.exists(path):
|
|
226
|
+
return path
|
|
227
|
+
|
|
228
|
+
return None
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@lru_cache(maxsize=32)
|
|
232
|
+
def _cached_measurer(font_path: str, font_number: int = 0) -> FontMeasurer:
|
|
233
|
+
"""Return a process-global cached FontMeasurer for *font_path*.
|
|
234
|
+
|
|
235
|
+
FontMeasurer is read-only after __post_init__, so sharing a single
|
|
236
|
+
instance across many SVGRenderer calls (the common Dataface pattern)
|
|
237
|
+
is safe and avoids repeated TTF CMAP decompile overhead.
|
|
238
|
+
"""
|
|
239
|
+
return FontMeasurer(font_path, font_number)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@lru_cache(maxsize=1)
|
|
243
|
+
def get_default_measurer() -> Optional[FontMeasurer]:
|
|
244
|
+
"""Get a cached FontMeasurer using the system default font."""
|
|
245
|
+
font_path = get_system_font()
|
|
246
|
+
if font_path is None:
|
|
247
|
+
return None
|
|
248
|
+
measurer = _cached_measurer(font_path)
|
|
249
|
+
return measurer if measurer.is_available else None
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def measure_text_precise(
|
|
253
|
+
text: str,
|
|
254
|
+
font_size: float,
|
|
255
|
+
measurer: FontMeasurer,
|
|
256
|
+
) -> float:
|
|
257
|
+
"""Measure text with a real FontMeasurer."""
|
|
258
|
+
if not measurer.is_available:
|
|
259
|
+
raise RuntimeError(
|
|
260
|
+
"Precise font measurer is required for strict wrapping/truncation"
|
|
261
|
+
)
|
|
262
|
+
return float(measurer.measure(text, font_size))
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
_SEAM_CHARS = frozenset("_/.-?&=:")
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _find_seam_break(
|
|
269
|
+
token: str, max_width: float, measure: Callable[[str], float]
|
|
270
|
+
) -> int | None:
|
|
271
|
+
"""Return the index after the longest seam-char-terminated prefix that fits max_width.
|
|
272
|
+
|
|
273
|
+
Scans left-to-right collecting the last seam position whose prefix fits, so
|
|
274
|
+
the first acceptable position is always the longest one. Returns None when
|
|
275
|
+
no seam-terminated prefix fits within max_width.
|
|
276
|
+
"""
|
|
277
|
+
best: int | None = None
|
|
278
|
+
for i, ch in enumerate(token):
|
|
279
|
+
if ch in _SEAM_CHARS and i > 0 and measure(token[: i + 1]) <= max_width:
|
|
280
|
+
best = i + 1
|
|
281
|
+
return best
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def split_token_precise(
|
|
285
|
+
token: str,
|
|
286
|
+
max_width: float,
|
|
287
|
+
measure: Callable[[str], float],
|
|
288
|
+
) -> list[str]:
|
|
289
|
+
"""Split a long token into pieces that fit within max_width.
|
|
290
|
+
|
|
291
|
+
Tries seam characters first (``_/.-?&=:``) then falls back to a
|
|
292
|
+
binary-search character break for tokens with no seam that fits.
|
|
293
|
+
|
|
294
|
+
Args:
|
|
295
|
+
token: The token to split.
|
|
296
|
+
max_width: Maximum width of each piece.
|
|
297
|
+
measure: Width measurement function.
|
|
298
|
+
"""
|
|
299
|
+
if not token:
|
|
300
|
+
return [""]
|
|
301
|
+
if measure(token) <= max_width:
|
|
302
|
+
return [token]
|
|
303
|
+
|
|
304
|
+
pieces: list[str] = []
|
|
305
|
+
remaining = token
|
|
306
|
+
while remaining:
|
|
307
|
+
if measure(remaining) <= max_width:
|
|
308
|
+
pieces.append(remaining)
|
|
309
|
+
break
|
|
310
|
+
seam_pos = _find_seam_break(remaining, max_width, measure)
|
|
311
|
+
if seam_pos is not None:
|
|
312
|
+
pieces.append(remaining[:seam_pos])
|
|
313
|
+
remaining = remaining[seam_pos:]
|
|
314
|
+
else:
|
|
315
|
+
# No seam fits — fall back to binary-search character break
|
|
316
|
+
lo, hi = 1, len(remaining)
|
|
317
|
+
best = 1
|
|
318
|
+
while lo <= hi:
|
|
319
|
+
mid = (lo + hi) // 2
|
|
320
|
+
candidate = remaining[:mid]
|
|
321
|
+
if measure(candidate) <= max_width:
|
|
322
|
+
best = mid
|
|
323
|
+
lo = mid + 1
|
|
324
|
+
else:
|
|
325
|
+
hi = mid - 1
|
|
326
|
+
pieces.append(remaining[:best])
|
|
327
|
+
remaining = remaining[best:]
|
|
328
|
+
return pieces
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def wrap_measured_pieces(
|
|
332
|
+
pieces: list[WrapPiece],
|
|
333
|
+
max_width: float,
|
|
334
|
+
) -> list[list[WrapPiece]]:
|
|
335
|
+
"""Wrap already-measured pieces into lines."""
|
|
336
|
+
if not pieces:
|
|
337
|
+
return []
|
|
338
|
+
|
|
339
|
+
lines: list[list[WrapPiece]] = [[]]
|
|
340
|
+
current_width = 0.0
|
|
341
|
+
for piece in pieces:
|
|
342
|
+
candidate_width = piece.width
|
|
343
|
+
if lines[-1]:
|
|
344
|
+
candidate_width += piece.separator_width
|
|
345
|
+
if lines[-1] and current_width + candidate_width > max_width:
|
|
346
|
+
lines.append([piece])
|
|
347
|
+
current_width = piece.width
|
|
348
|
+
else:
|
|
349
|
+
lines[-1].append(piece)
|
|
350
|
+
current_width += candidate_width
|
|
351
|
+
return [line for line in lines if line]
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def pieces_to_text(pieces: list[WrapPiece]) -> str:
|
|
355
|
+
"""Join measured pieces back into text."""
|
|
356
|
+
rendered: list[str] = []
|
|
357
|
+
for index, piece in enumerate(pieces):
|
|
358
|
+
if index > 0 and piece.separator:
|
|
359
|
+
rendered.append(piece.separator)
|
|
360
|
+
rendered.append(piece.text)
|
|
361
|
+
return "".join(rendered)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def truncate_text_precise(
|
|
365
|
+
text: str,
|
|
366
|
+
max_width: float,
|
|
367
|
+
font_size: float,
|
|
368
|
+
measurer: FontMeasurer,
|
|
369
|
+
*,
|
|
370
|
+
ellipsis: bool,
|
|
371
|
+
normalize_whitespace: bool = True,
|
|
372
|
+
) -> str:
|
|
373
|
+
"""Clip or ellipsize text to fit within max_width using precise measurement."""
|
|
374
|
+
if normalize_whitespace:
|
|
375
|
+
text = re.sub(r"\s+", " ", text.strip())
|
|
376
|
+
if not text:
|
|
377
|
+
return ""
|
|
378
|
+
|
|
379
|
+
def measure(value: str) -> float:
|
|
380
|
+
return measure_text_precise(value, font_size, measurer)
|
|
381
|
+
|
|
382
|
+
if measure(text) <= max_width:
|
|
383
|
+
return text
|
|
384
|
+
|
|
385
|
+
suffix = "…" if ellipsis else ""
|
|
386
|
+
lo, hi = 0, len(text)
|
|
387
|
+
best = ""
|
|
388
|
+
while lo <= hi:
|
|
389
|
+
mid = (lo + hi) // 2
|
|
390
|
+
candidate = text[:mid].rstrip() + suffix
|
|
391
|
+
if measure(candidate) <= max_width:
|
|
392
|
+
best = candidate
|
|
393
|
+
lo = mid + 1
|
|
394
|
+
else:
|
|
395
|
+
hi = mid - 1
|
|
396
|
+
if best:
|
|
397
|
+
return best
|
|
398
|
+
return text[:1]
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def wrap_text_precise(
|
|
402
|
+
text: str,
|
|
403
|
+
max_width: float,
|
|
404
|
+
font_size: float,
|
|
405
|
+
measurer: FontMeasurer,
|
|
406
|
+
*,
|
|
407
|
+
max_lines: int | None = None,
|
|
408
|
+
ellipsis: bool = False,
|
|
409
|
+
normalize_whitespace: bool = True,
|
|
410
|
+
) -> list[str]:
|
|
411
|
+
"""Wrap plain text using precise measurement only."""
|
|
412
|
+
if normalize_whitespace:
|
|
413
|
+
text = re.sub(r"\s+", " ", text.strip())
|
|
414
|
+
if not text:
|
|
415
|
+
return []
|
|
416
|
+
|
|
417
|
+
def measure(value: str) -> float:
|
|
418
|
+
return measure_text_precise(value, font_size, measurer)
|
|
419
|
+
|
|
420
|
+
space_width = measure(" ")
|
|
421
|
+
pieces: list[WrapPiece] = []
|
|
422
|
+
words = text.split(" ")
|
|
423
|
+
for word_index, word in enumerate(words):
|
|
424
|
+
for chunk_index, chunk in enumerate(
|
|
425
|
+
split_token_precise(word, max_width, measure)
|
|
426
|
+
):
|
|
427
|
+
separator = " " if word_index > 0 and chunk_index == 0 else ""
|
|
428
|
+
separator_width = space_width if separator else 0.0
|
|
429
|
+
pieces.append(
|
|
430
|
+
WrapPiece(
|
|
431
|
+
text=chunk,
|
|
432
|
+
width=measure(chunk),
|
|
433
|
+
separator=separator,
|
|
434
|
+
separator_width=separator_width,
|
|
435
|
+
)
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
lines = wrap_measured_pieces(pieces, max_width)
|
|
439
|
+
if max_lines is not None and len(lines) > max_lines:
|
|
440
|
+
tail: list[WrapPiece] = []
|
|
441
|
+
for line in lines[max_lines - 1 :]:
|
|
442
|
+
tail.extend(line)
|
|
443
|
+
rendered = [pieces_to_text(line) for line in lines[: max_lines - 1]]
|
|
444
|
+
rendered.append(
|
|
445
|
+
truncate_text_precise(
|
|
446
|
+
pieces_to_text(tail),
|
|
447
|
+
max_width,
|
|
448
|
+
font_size,
|
|
449
|
+
measurer,
|
|
450
|
+
ellipsis=ellipsis,
|
|
451
|
+
normalize_whitespace=False,
|
|
452
|
+
)
|
|
453
|
+
)
|
|
454
|
+
return rendered
|
|
455
|
+
return [pieces_to_text(line) for line in lines]
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def create_precise_wrapper(
|
|
459
|
+
max_width: float,
|
|
460
|
+
font_size: float,
|
|
461
|
+
measurer: FontMeasurer | None = None,
|
|
462
|
+
) -> Callable[[str], list[str]]:
|
|
463
|
+
"""
|
|
464
|
+
Create a text wrapper function that uses precise font measurement.
|
|
465
|
+
|
|
466
|
+
Args:
|
|
467
|
+
max_width: Maximum line width in pixels.
|
|
468
|
+
font_size: Font size in pixels.
|
|
469
|
+
measurer: FontMeasurer to use (auto-detects if None).
|
|
470
|
+
|
|
471
|
+
Returns:
|
|
472
|
+
A function that takes text and returns list of wrapped lines.
|
|
473
|
+
"""
|
|
474
|
+
if measurer is None:
|
|
475
|
+
measurer = get_default_measurer()
|
|
476
|
+
|
|
477
|
+
if measurer is None or not measurer.is_available:
|
|
478
|
+
raise RuntimeError(
|
|
479
|
+
"FontMeasurer is required for precise wrapping; no heuristic fallback is allowed"
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
def wrap_precise(text: str) -> list[str]:
|
|
483
|
+
lines = wrap_text_precise(text, max_width, font_size, measurer)
|
|
484
|
+
return lines if lines else [""]
|
|
485
|
+
|
|
486
|
+
return wrap_precise
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def calibrate_heuristic(
|
|
490
|
+
measurer: Optional[FontMeasurer] = None,
|
|
491
|
+
font_size: float = 14.0,
|
|
492
|
+
) -> Tuple[float, float]:
|
|
493
|
+
"""
|
|
494
|
+
Calibrate heuristic character width ratios based on actual font measurement.
|
|
495
|
+
|
|
496
|
+
Returns adjusted ratios for use with the heuristic estimator.
|
|
497
|
+
|
|
498
|
+
Args:
|
|
499
|
+
measurer: FontMeasurer to use for calibration.
|
|
500
|
+
font_size: Font size for calibration.
|
|
501
|
+
|
|
502
|
+
Returns:
|
|
503
|
+
Tuple of (char_width_ratio, bold_char_width_ratio).
|
|
504
|
+
"""
|
|
505
|
+
if measurer is None:
|
|
506
|
+
measurer = get_default_measurer()
|
|
507
|
+
|
|
508
|
+
if measurer is None or not measurer.is_available:
|
|
509
|
+
return (0.48, 0.52)
|
|
510
|
+
|
|
511
|
+
sample = "The quick brown fox jumps over the lazy dog. 0123456789"
|
|
512
|
+
actual_width = measurer.measure(sample, font_size)
|
|
513
|
+
ratio = actual_width / (font_size * len(sample))
|
|
514
|
+
|
|
515
|
+
return (ratio, ratio * 1.08)
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def get_font_cache_dir() -> str:
|
|
519
|
+
"""
|
|
520
|
+
Get the directory for caching downloaded fonts.
|
|
521
|
+
|
|
522
|
+
Creates the directory if it doesn't exist.
|
|
523
|
+
|
|
524
|
+
Returns:
|
|
525
|
+
Path to the font cache directory.
|
|
526
|
+
"""
|
|
527
|
+
# Use platform-appropriate cache directory
|
|
528
|
+
system = platform.system()
|
|
529
|
+
|
|
530
|
+
if system == "Darwin":
|
|
531
|
+
cache_base = os.path.expanduser("~/Library/Caches")
|
|
532
|
+
elif system == "Windows":
|
|
533
|
+
cache_base = os.environ.get("LOCALAPPDATA", os.path.expanduser("~"))
|
|
534
|
+
else:
|
|
535
|
+
cache_base = os.environ.get("XDG_CACHE_HOME", os.path.expanduser("~/.cache"))
|
|
536
|
+
|
|
537
|
+
cache_dir = os.path.join(cache_base, "mdsvg", "fonts")
|
|
538
|
+
os.makedirs(cache_dir, exist_ok=True)
|
|
539
|
+
return cache_dir
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def download_google_font(
|
|
543
|
+
font_name: str,
|
|
544
|
+
weight: int = 400,
|
|
545
|
+
cache_dir: Optional[str] = None,
|
|
546
|
+
) -> str:
|
|
547
|
+
"""
|
|
548
|
+
Download a font from Google Fonts.
|
|
549
|
+
|
|
550
|
+
Downloads the font file and caches it locally. Subsequent calls
|
|
551
|
+
return the cached file.
|
|
552
|
+
|
|
553
|
+
Args:
|
|
554
|
+
font_name: Name of the font (e.g., "Inter", "Roboto", "Open Sans").
|
|
555
|
+
weight: Font weight (100-900). Default 400 (regular).
|
|
556
|
+
cache_dir: Directory to cache fonts. Uses system cache if None.
|
|
557
|
+
|
|
558
|
+
Returns:
|
|
559
|
+
Path to the downloaded font file.
|
|
560
|
+
|
|
561
|
+
Raises:
|
|
562
|
+
RuntimeError: If download fails or font not found.
|
|
563
|
+
|
|
564
|
+
Example:
|
|
565
|
+
>>> font_path = download_google_font("Inter")
|
|
566
|
+
>>> measurer = FontMeasurer(font_path)
|
|
567
|
+
>>> measurer.measure("Hello", 14)
|
|
568
|
+
|
|
569
|
+
>>> # With specific weight
|
|
570
|
+
>>> bold_path = download_google_font("Inter", weight=700)
|
|
571
|
+
"""
|
|
572
|
+
import re
|
|
573
|
+
import urllib.error
|
|
574
|
+
import urllib.request
|
|
575
|
+
|
|
576
|
+
if cache_dir is None:
|
|
577
|
+
cache_dir = get_font_cache_dir()
|
|
578
|
+
|
|
579
|
+
# Normalize font name for filename
|
|
580
|
+
safe_name = re.sub(r"[^a-zA-Z0-9]", "", font_name)
|
|
581
|
+
font_filename = f"{safe_name}-{weight}.ttf"
|
|
582
|
+
font_path = os.path.join(cache_dir, font_filename)
|
|
583
|
+
|
|
584
|
+
# Return cached font if exists
|
|
585
|
+
if os.path.exists(font_path):
|
|
586
|
+
return font_path
|
|
587
|
+
|
|
588
|
+
# Google Fonts CSS API URL
|
|
589
|
+
css_url = f"https://fonts.googleapis.com/css2?family={font_name.replace(' ', '+')}:wght@{weight}"
|
|
590
|
+
|
|
591
|
+
try:
|
|
592
|
+
# Fetch CSS to get the actual font URL
|
|
593
|
+
# Use a browser-like User-Agent to get TTF instead of WOFF2
|
|
594
|
+
request = urllib.request.Request(
|
|
595
|
+
css_url, headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"}
|
|
596
|
+
)
|
|
597
|
+
with urllib.request.urlopen(request, timeout=30) as response:
|
|
598
|
+
css = response.read().decode("utf-8")
|
|
599
|
+
|
|
600
|
+
# Extract font URL from CSS
|
|
601
|
+
# Looking for: src: url(https://fonts.gstatic.com/...) format('truetype')
|
|
602
|
+
match = re.search(r"src:\s*url\(([^)]+\.ttf)\)", css)
|
|
603
|
+
if not match:
|
|
604
|
+
# Try woff2 and convert note
|
|
605
|
+
match = re.search(r"src:\s*url\(([^)]+)\)", css)
|
|
606
|
+
if match:
|
|
607
|
+
raise RuntimeError(
|
|
608
|
+
f"Font '{font_name}' only available as WOFF2. "
|
|
609
|
+
f"Download TTF manually from https://fonts.google.com/specimen/{font_name.replace(' ', '+')}"
|
|
610
|
+
)
|
|
611
|
+
raise RuntimeError(f"Could not find font URL for '{font_name}'")
|
|
612
|
+
|
|
613
|
+
font_url = match.group(1)
|
|
614
|
+
|
|
615
|
+
# Download the font file
|
|
616
|
+
with urllib.request.urlopen(font_url, timeout=60) as response:
|
|
617
|
+
font_data = response.read()
|
|
618
|
+
|
|
619
|
+
# Save to cache
|
|
620
|
+
with open(font_path, "wb") as f:
|
|
621
|
+
f.write(font_data)
|
|
622
|
+
|
|
623
|
+
return font_path
|
|
624
|
+
|
|
625
|
+
except urllib.error.HTTPError as e:
|
|
626
|
+
if e.code == 400:
|
|
627
|
+
raise RuntimeError(
|
|
628
|
+
f"Font '{font_name}' not found on Google Fonts. "
|
|
629
|
+
f"Check spelling at https://fonts.google.com"
|
|
630
|
+
) from e
|
|
631
|
+
raise RuntimeError(f"Failed to download font: {e}") from e
|
|
632
|
+
except urllib.error.URLError as e:
|
|
633
|
+
raise RuntimeError(f"Network error downloading font: {e}") from e
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def list_cached_fonts(cache_dir: Optional[str] = None) -> List[str]:
|
|
637
|
+
"""
|
|
638
|
+
List all fonts in the cache directory.
|
|
639
|
+
|
|
640
|
+
Args:
|
|
641
|
+
cache_dir: Cache directory to list. Uses system cache if None.
|
|
642
|
+
|
|
643
|
+
Returns:
|
|
644
|
+
List of cached font file paths.
|
|
645
|
+
"""
|
|
646
|
+
if cache_dir is None:
|
|
647
|
+
cache_dir = get_font_cache_dir()
|
|
648
|
+
|
|
649
|
+
if not os.path.exists(cache_dir):
|
|
650
|
+
return []
|
|
651
|
+
|
|
652
|
+
return [
|
|
653
|
+
os.path.join(cache_dir, f)
|
|
654
|
+
for f in os.listdir(cache_dir)
|
|
655
|
+
if f.endswith((".ttf", ".otf", ".ttc"))
|
|
656
|
+
]
|