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/renderer.py
ADDED
|
@@ -0,0 +1,1623 @@
|
|
|
1
|
+
"""SVG renderer for parsed Markdown AST."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Dict, List, Optional, Tuple
|
|
9
|
+
|
|
10
|
+
# Precise text measurement
|
|
11
|
+
from .fonts import (
|
|
12
|
+
FontMeasurer,
|
|
13
|
+
WrapPiece,
|
|
14
|
+
_cached_measurer,
|
|
15
|
+
get_default_measurer,
|
|
16
|
+
get_system_mono_font,
|
|
17
|
+
split_token_precise,
|
|
18
|
+
wrap_measured_pieces,
|
|
19
|
+
)
|
|
20
|
+
from .images import ImageSize, ImageUrlMapper, get_image_size
|
|
21
|
+
from .style import Style
|
|
22
|
+
from .types import (
|
|
23
|
+
Block,
|
|
24
|
+
Blockquote,
|
|
25
|
+
CodeBlock,
|
|
26
|
+
Document,
|
|
27
|
+
Heading,
|
|
28
|
+
HorizontalRule,
|
|
29
|
+
ImageBlock,
|
|
30
|
+
OrderedList,
|
|
31
|
+
Paragraph,
|
|
32
|
+
RawHtmlBlock,
|
|
33
|
+
Span,
|
|
34
|
+
SpanType,
|
|
35
|
+
Table,
|
|
36
|
+
TableCell,
|
|
37
|
+
TableRow,
|
|
38
|
+
UnorderedList,
|
|
39
|
+
)
|
|
40
|
+
from .utils import escape_svg_text, escape_xml, format_number
|
|
41
|
+
|
|
42
|
+
# --- Raw HTML sanitization ---
|
|
43
|
+
_UNSAFE_TAG_RE = re.compile(
|
|
44
|
+
r"<\s*/?\s*(script|iframe|object|embed|applet|form|input|textarea|button)\b[^>]*>",
|
|
45
|
+
re.IGNORECASE,
|
|
46
|
+
)
|
|
47
|
+
_EVENT_ATTR_RE = re.compile(r"\s+on\w+\s*=\s*[\"'][^\"']*[\"']", re.IGNORECASE)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _sanitize_html(html: str) -> str:
|
|
51
|
+
"""Remove dangerous tags and event-handler attributes from HTML."""
|
|
52
|
+
html = _UNSAFE_TAG_RE.sub("", html)
|
|
53
|
+
html = _EVENT_ATTR_RE.sub("", html)
|
|
54
|
+
return html
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class RenderResult:
|
|
59
|
+
"""Result of rendering markdown content.
|
|
60
|
+
|
|
61
|
+
Contains the rendered SVG content (without wrapper), along with dimensions.
|
|
62
|
+
This allows callers to compose multiple mdsvg outputs into a larger SVG
|
|
63
|
+
without needing to regex-strip wrappers.
|
|
64
|
+
|
|
65
|
+
Attributes:
|
|
66
|
+
elements: SVG elements without wrapper or style block.
|
|
67
|
+
style_block: The <style> block with CSS classes for the rendered content.
|
|
68
|
+
width: Width of the rendered content in pixels.
|
|
69
|
+
height: Height of the rendered content in pixels.
|
|
70
|
+
|
|
71
|
+
Example:
|
|
72
|
+
>>> from mdsvg import render_content
|
|
73
|
+
>>> result = render_content("# Hello", width=400)
|
|
74
|
+
>>> result.elements # Just SVG elements (rects, text, etc.)
|
|
75
|
+
>>> result.style_block # The <style>...</style> CSS block
|
|
76
|
+
>>> result.content # Combined style_block + elements (backwards compatible)
|
|
77
|
+
>>> result.width # 400.0
|
|
78
|
+
>>> result.height # Actual rendered height
|
|
79
|
+
>>> result.to_svg() # Full SVG with wrapper
|
|
80
|
+
|
|
81
|
+
# Compose multiple sections with single style block:
|
|
82
|
+
>>> left = render_content("# Left", width=350)
|
|
83
|
+
>>> right = render_content("# Right", width=350)
|
|
84
|
+
>>> combined = f'''
|
|
85
|
+
... <svg xmlns="http://www.w3.org/2000/svg" width="750" height="{max(left.height, right.height)}">
|
|
86
|
+
... {left.style_block}
|
|
87
|
+
... <g transform="translate(0, 0)">{left.elements}</g>
|
|
88
|
+
... <g transform="translate(400, 0)">{right.elements}</g>
|
|
89
|
+
... </svg>
|
|
90
|
+
... '''
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
elements: str
|
|
94
|
+
style_block: str
|
|
95
|
+
width: float
|
|
96
|
+
height: float
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def content(self) -> str:
|
|
100
|
+
"""Combined style block and elements for backwards compatibility.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
String containing the style block followed by SVG elements.
|
|
104
|
+
"""
|
|
105
|
+
return self.style_block + "\n" + self.elements
|
|
106
|
+
|
|
107
|
+
def to_svg(self) -> str:
|
|
108
|
+
"""Wrap content in a complete SVG element.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
Complete SVG string with xmlns, width, height, and viewBox attributes.
|
|
112
|
+
|
|
113
|
+
Example:
|
|
114
|
+
>>> result = render_content("# Hello", width=400)
|
|
115
|
+
>>> svg = result.to_svg()
|
|
116
|
+
>>> with open("output.svg", "w") as f:
|
|
117
|
+
... f.write(svg)
|
|
118
|
+
"""
|
|
119
|
+
svg_parts = [
|
|
120
|
+
f'<svg xmlns="http://www.w3.org/2000/svg" '
|
|
121
|
+
f'width="{format_number(self.width)}" height="{format_number(self.height)}" '
|
|
122
|
+
f'viewBox="0 0 {format_number(self.width)} {format_number(self.height)}">',
|
|
123
|
+
self.content,
|
|
124
|
+
"</svg>",
|
|
125
|
+
]
|
|
126
|
+
return "\n".join(svg_parts)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass
|
|
130
|
+
class RenderContext:
|
|
131
|
+
"""Context passed through rendering for tracking state."""
|
|
132
|
+
|
|
133
|
+
x: float
|
|
134
|
+
y: float
|
|
135
|
+
width: float
|
|
136
|
+
style: Style
|
|
137
|
+
indent: float = 0.0
|
|
138
|
+
|
|
139
|
+
def with_offset(self, dx: float = 0, dy: float = 0) -> RenderContext:
|
|
140
|
+
"""Create a new context with offset position."""
|
|
141
|
+
return RenderContext(
|
|
142
|
+
x=self.x + dx,
|
|
143
|
+
y=self.y + dy,
|
|
144
|
+
width=self.width,
|
|
145
|
+
style=self.style,
|
|
146
|
+
indent=self.indent,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
def with_indent(self, additional_indent: float) -> RenderContext:
|
|
150
|
+
"""Create a new context with additional indentation."""
|
|
151
|
+
return RenderContext(
|
|
152
|
+
x=self.x + additional_indent,
|
|
153
|
+
y=self.y,
|
|
154
|
+
width=self.width - additional_indent,
|
|
155
|
+
style=self.style,
|
|
156
|
+
indent=self.indent + additional_indent,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@dataclass(frozen=True)
|
|
161
|
+
class TableRowLayout:
|
|
162
|
+
"""Measured layout for one rendered table row."""
|
|
163
|
+
|
|
164
|
+
cell_lines: tuple[tuple[tuple["TextRun", ...], ...], ...]
|
|
165
|
+
row_height: float
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@dataclass(frozen=True)
|
|
169
|
+
class Size:
|
|
170
|
+
"""Rendered width and height."""
|
|
171
|
+
|
|
172
|
+
width: float
|
|
173
|
+
height: float
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class SVGRenderer:
|
|
177
|
+
"""
|
|
178
|
+
Renderer that converts Markdown AST to SVG.
|
|
179
|
+
|
|
180
|
+
Uses fonttools-backed precise text measurement.
|
|
181
|
+
|
|
182
|
+
Example:
|
|
183
|
+
>>> renderer = SVGRenderer(style=Style())
|
|
184
|
+
>>> blocks = parse("# Hello\\n\\nWorld")
|
|
185
|
+
>>> svg = renderer.render(blocks, width=400)
|
|
186
|
+
"""
|
|
187
|
+
|
|
188
|
+
def __init__(
|
|
189
|
+
self,
|
|
190
|
+
style: Optional[Style] = None,
|
|
191
|
+
font_path: Optional[str] = None,
|
|
192
|
+
mono_font_path: Optional[str] = None,
|
|
193
|
+
# Image options
|
|
194
|
+
fetch_image_sizes: bool = True,
|
|
195
|
+
image_base_path: Optional[str] = None,
|
|
196
|
+
image_url_mapper: Optional[ImageUrlMapper] = None,
|
|
197
|
+
image_timeout: float = 10.0,
|
|
198
|
+
# Raw HTML passthrough
|
|
199
|
+
allow_raw_html: bool = False,
|
|
200
|
+
) -> None:
|
|
201
|
+
"""
|
|
202
|
+
Initialize the renderer.
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
style: Style configuration. Uses default style if None.
|
|
206
|
+
font_path: Path to a TTF/OTF font file for precise measurement.
|
|
207
|
+
If None, uses system default font.
|
|
208
|
+
mono_font_path: Path to a monospace TTF/OTF font file for measuring
|
|
209
|
+
inline code. If None, uses the detected system mono font.
|
|
210
|
+
fetch_image_sizes: If True (default), fetch image dimensions from
|
|
211
|
+
local files or remote URLs. Required for accurate layout.
|
|
212
|
+
image_base_path: Base directory for resolving relative image paths.
|
|
213
|
+
Used when fetching local image dimensions.
|
|
214
|
+
image_url_mapper: Optional function to transform image URLs before
|
|
215
|
+
embedding in SVG. Useful for mapping local paths to CDN URLs.
|
|
216
|
+
Example: create_prefix_mapper({"/assets/": "https://cdn.example.com/"})
|
|
217
|
+
image_timeout: Timeout in seconds for fetching remote images (default 10).
|
|
218
|
+
"""
|
|
219
|
+
self.style = style or Style()
|
|
220
|
+
self._allow_raw_html = allow_raw_html
|
|
221
|
+
self._measurer: FontMeasurer
|
|
222
|
+
self._mono_char_width: Optional[float] = None
|
|
223
|
+
self._mono_font_path = mono_font_path
|
|
224
|
+
|
|
225
|
+
# Image handling
|
|
226
|
+
self._fetch_image_sizes = fetch_image_sizes
|
|
227
|
+
self._image_base_path = image_base_path
|
|
228
|
+
self._image_url_mapper = image_url_mapper
|
|
229
|
+
self._image_timeout = image_timeout
|
|
230
|
+
self._image_size_cache: Dict[str, Optional[ImageSize]] = {}
|
|
231
|
+
|
|
232
|
+
if font_path:
|
|
233
|
+
self._measurer = _cached_measurer(font_path)
|
|
234
|
+
if not self._measurer.is_available:
|
|
235
|
+
raise RuntimeError(
|
|
236
|
+
"Precise text measurement is required, but no FontMeasurer is available"
|
|
237
|
+
)
|
|
238
|
+
else:
|
|
239
|
+
default = get_default_measurer()
|
|
240
|
+
if default is None or not default.is_available:
|
|
241
|
+
raise RuntimeError(
|
|
242
|
+
"Precise text measurement is required, but no FontMeasurer is available"
|
|
243
|
+
)
|
|
244
|
+
self._measurer = default
|
|
245
|
+
|
|
246
|
+
def _ensure_mono_char_width(self) -> float:
|
|
247
|
+
"""Load precise monospace measurement on first actual code use."""
|
|
248
|
+
if self._mono_char_width is not None:
|
|
249
|
+
return self._mono_char_width
|
|
250
|
+
|
|
251
|
+
mono_font_path = self._mono_font_path or get_system_mono_font()
|
|
252
|
+
if not mono_font_path:
|
|
253
|
+
raise RuntimeError(
|
|
254
|
+
"Precise monospace measurement is required, but no monospace FontMeasurer is available"
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
mono_measurer = _cached_measurer(mono_font_path)
|
|
258
|
+
if not mono_measurer.is_available:
|
|
259
|
+
raise RuntimeError(
|
|
260
|
+
"Precise monospace measurement is required, but no monospace FontMeasurer is available"
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
self._mono_char_width = mono_measurer.measure("M", 1.0)
|
|
264
|
+
return self._mono_char_width
|
|
265
|
+
|
|
266
|
+
def _measure_text(
|
|
267
|
+
self,
|
|
268
|
+
text: str,
|
|
269
|
+
font_size: float,
|
|
270
|
+
is_bold: bool = False,
|
|
271
|
+
is_italic: bool = False,
|
|
272
|
+
is_mono: bool = False,
|
|
273
|
+
) -> float:
|
|
274
|
+
"""Measure text width using best available method."""
|
|
275
|
+
width: float
|
|
276
|
+
|
|
277
|
+
# Monospace: all characters have identical width, so just multiply
|
|
278
|
+
if is_mono:
|
|
279
|
+
width = len(text) * self._ensure_mono_char_width() * font_size
|
|
280
|
+
else:
|
|
281
|
+
width = self._measurer.measure(text, font_size)
|
|
282
|
+
# Apply scaling for bold/italic since FontMeasurer only has regular font
|
|
283
|
+
# Bold text is typically 10-15% wider, italic ~4% wider
|
|
284
|
+
if is_bold and is_italic:
|
|
285
|
+
bold_ratio = (
|
|
286
|
+
self.style.bold_char_width_ratio / self.style.char_width_ratio
|
|
287
|
+
)
|
|
288
|
+
italic_ratio = (
|
|
289
|
+
self.style.italic_char_width_ratio / self.style.char_width_ratio
|
|
290
|
+
)
|
|
291
|
+
width *= bold_ratio * italic_ratio
|
|
292
|
+
elif is_bold:
|
|
293
|
+
width *= self.style.bold_char_width_ratio / self.style.char_width_ratio
|
|
294
|
+
elif is_italic:
|
|
295
|
+
width *= (
|
|
296
|
+
self.style.italic_char_width_ratio / self.style.char_width_ratio
|
|
297
|
+
)
|
|
298
|
+
return width
|
|
299
|
+
|
|
300
|
+
def _render_blocks_to_elements(
|
|
301
|
+
self,
|
|
302
|
+
blocks: Document,
|
|
303
|
+
width: float,
|
|
304
|
+
padding: float,
|
|
305
|
+
) -> Tuple[List[str], float]:
|
|
306
|
+
"""
|
|
307
|
+
Render blocks to SVG elements and return total height.
|
|
308
|
+
|
|
309
|
+
This is the core rendering logic shared by render() and render_content().
|
|
310
|
+
|
|
311
|
+
Args:
|
|
312
|
+
blocks: Document AST to render.
|
|
313
|
+
width: Width of the SVG in pixels.
|
|
314
|
+
padding: Padding inside the SVG.
|
|
315
|
+
|
|
316
|
+
Returns:
|
|
317
|
+
Tuple of (svg_elements, total_height).
|
|
318
|
+
"""
|
|
319
|
+
content_width = width - (padding * 2)
|
|
320
|
+
|
|
321
|
+
ctx = RenderContext(
|
|
322
|
+
x=padding,
|
|
323
|
+
y=padding,
|
|
324
|
+
width=content_width,
|
|
325
|
+
style=self.style,
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
svg_elements: List[str] = []
|
|
329
|
+
current_y = padding
|
|
330
|
+
|
|
331
|
+
for block in blocks:
|
|
332
|
+
elements, height = self._render_block(
|
|
333
|
+
block, ctx.with_offset(dy=current_y - ctx.y)
|
|
334
|
+
)
|
|
335
|
+
svg_elements.extend(elements)
|
|
336
|
+
current_y += height + self.style.paragraph_spacing
|
|
337
|
+
|
|
338
|
+
# Remove trailing spacing
|
|
339
|
+
if blocks:
|
|
340
|
+
current_y -= self.style.paragraph_spacing
|
|
341
|
+
|
|
342
|
+
total_height = current_y + padding
|
|
343
|
+
return svg_elements, total_height
|
|
344
|
+
|
|
345
|
+
def render(
|
|
346
|
+
self,
|
|
347
|
+
blocks: Document,
|
|
348
|
+
width: float = 400,
|
|
349
|
+
padding: float = 0,
|
|
350
|
+
) -> str:
|
|
351
|
+
"""
|
|
352
|
+
Render blocks to an SVG string.
|
|
353
|
+
|
|
354
|
+
Args:
|
|
355
|
+
blocks: Document AST to render.
|
|
356
|
+
width: Width of the SVG in pixels.
|
|
357
|
+
padding: Padding inside the SVG.
|
|
358
|
+
|
|
359
|
+
Returns:
|
|
360
|
+
SVG string.
|
|
361
|
+
"""
|
|
362
|
+
svg_elements, total_height = self._render_blocks_to_elements(
|
|
363
|
+
blocks, width, padding
|
|
364
|
+
)
|
|
365
|
+
svg = self._build_svg(svg_elements, width, total_height)
|
|
366
|
+
return svg
|
|
367
|
+
|
|
368
|
+
def render_content(
|
|
369
|
+
self,
|
|
370
|
+
blocks: Document,
|
|
371
|
+
width: float = 400,
|
|
372
|
+
padding: float = 0,
|
|
373
|
+
) -> RenderResult:
|
|
374
|
+
"""
|
|
375
|
+
Render blocks and return structured result with content and dimensions.
|
|
376
|
+
|
|
377
|
+
Unlike render(), this returns the SVG content without the <svg> wrapper,
|
|
378
|
+
along with the actual dimensions. This is useful when composing multiple
|
|
379
|
+
mdsvg outputs into a larger SVG.
|
|
380
|
+
|
|
381
|
+
Args:
|
|
382
|
+
blocks: Document AST to render.
|
|
383
|
+
width: Width of the SVG in pixels.
|
|
384
|
+
padding: Padding inside the SVG.
|
|
385
|
+
|
|
386
|
+
Returns:
|
|
387
|
+
RenderResult with content (SVG elements without wrapper),
|
|
388
|
+
width, and height.
|
|
389
|
+
|
|
390
|
+
Example:
|
|
391
|
+
>>> renderer = SVGRenderer()
|
|
392
|
+
>>> blocks = parse("# Hello")
|
|
393
|
+
>>> result = renderer.render_content(blocks, width=400)
|
|
394
|
+
>>> result.content # SVG elements without wrapper
|
|
395
|
+
>>> result.width # 400.0
|
|
396
|
+
>>> result.height # Actual rendered height
|
|
397
|
+
>>> result.to_svg() # Full SVG with wrapper
|
|
398
|
+
"""
|
|
399
|
+
svg_elements, total_height = self._render_blocks_to_elements(
|
|
400
|
+
blocks, width, padding
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
return RenderResult(
|
|
404
|
+
elements="\n".join(svg_elements),
|
|
405
|
+
style_block=self._get_style_block(),
|
|
406
|
+
width=width,
|
|
407
|
+
height=total_height,
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
def measure(
|
|
411
|
+
self,
|
|
412
|
+
blocks: Document,
|
|
413
|
+
width: float = 400,
|
|
414
|
+
padding: float = 0,
|
|
415
|
+
) -> Size:
|
|
416
|
+
"""
|
|
417
|
+
Measure the size needed to render blocks.
|
|
418
|
+
|
|
419
|
+
Args:
|
|
420
|
+
blocks: Document AST to measure.
|
|
421
|
+
width: Width constraint.
|
|
422
|
+
padding: Padding inside the SVG.
|
|
423
|
+
|
|
424
|
+
Returns:
|
|
425
|
+
Size with width and height.
|
|
426
|
+
"""
|
|
427
|
+
content_width = width - (padding * 2)
|
|
428
|
+
|
|
429
|
+
ctx = RenderContext(
|
|
430
|
+
x=padding,
|
|
431
|
+
y=padding,
|
|
432
|
+
width=content_width,
|
|
433
|
+
style=self.style,
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
current_y = padding
|
|
437
|
+
|
|
438
|
+
for block in blocks:
|
|
439
|
+
_, height = self._render_block(block, ctx.with_offset(dy=current_y - ctx.y))
|
|
440
|
+
current_y += height + self.style.paragraph_spacing
|
|
441
|
+
|
|
442
|
+
if blocks:
|
|
443
|
+
current_y -= self.style.paragraph_spacing
|
|
444
|
+
|
|
445
|
+
return Size(width=width, height=current_y + padding)
|
|
446
|
+
|
|
447
|
+
def _get_style_block(self) -> str:
|
|
448
|
+
"""Generate the CSS style block for SVG rendering.
|
|
449
|
+
|
|
450
|
+
Returns:
|
|
451
|
+
A <style> block string with CSS classes for text, headings, code, etc.
|
|
452
|
+
"""
|
|
453
|
+
return f""" <style>
|
|
454
|
+
.md-text {{ font-family: {self.style.font_family}; fill: {self.style.text_color}; }}
|
|
455
|
+
.md-mono {{ font-family: {self.style.mono_font_family}; }}
|
|
456
|
+
.md-heading {{ font-family: {self.style.font_family}; fill: {self.style.get_heading_color()}; font-weight: {self.style.heading_font_weight}; }}
|
|
457
|
+
.md-code {{ font-family: {self.style.mono_font_family}; fill: {self.style.code_color}; }}
|
|
458
|
+
.md-link {{ fill: {self.style.link_color}; }}
|
|
459
|
+
.md-blockquote {{ fill: {self.style.blockquote_color}; }}
|
|
460
|
+
</style>"""
|
|
461
|
+
|
|
462
|
+
def _build_svg(
|
|
463
|
+
self,
|
|
464
|
+
elements: List[str],
|
|
465
|
+
width: float,
|
|
466
|
+
height: float,
|
|
467
|
+
) -> str:
|
|
468
|
+
"""Build the complete SVG document."""
|
|
469
|
+
svg_parts = [
|
|
470
|
+
f'<svg xmlns="http://www.w3.org/2000/svg" '
|
|
471
|
+
f'width="{format_number(width)}" height="{format_number(height)}" '
|
|
472
|
+
f'viewBox="0 0 {format_number(width)} {format_number(height)}">',
|
|
473
|
+
]
|
|
474
|
+
|
|
475
|
+
# Add a style block for fonts
|
|
476
|
+
svg_parts.append(self._get_style_block())
|
|
477
|
+
|
|
478
|
+
svg_parts.extend(elements)
|
|
479
|
+
svg_parts.append("</svg>")
|
|
480
|
+
|
|
481
|
+
return "\n".join(svg_parts)
|
|
482
|
+
|
|
483
|
+
def _render_block(
|
|
484
|
+
self,
|
|
485
|
+
block: Block,
|
|
486
|
+
ctx: RenderContext,
|
|
487
|
+
) -> Tuple[List[str], float]:
|
|
488
|
+
"""Render a block and return SVG elements and height used."""
|
|
489
|
+
if isinstance(block, Paragraph):
|
|
490
|
+
return self._render_paragraph(block, ctx)
|
|
491
|
+
elif isinstance(block, Heading):
|
|
492
|
+
return self._render_heading(block, ctx)
|
|
493
|
+
elif isinstance(block, CodeBlock):
|
|
494
|
+
return self._render_code_block(block, ctx)
|
|
495
|
+
elif isinstance(block, Blockquote):
|
|
496
|
+
return self._render_blockquote(block, ctx)
|
|
497
|
+
elif isinstance(block, UnorderedList):
|
|
498
|
+
return self._render_unordered_list(block, ctx)
|
|
499
|
+
elif isinstance(block, OrderedList):
|
|
500
|
+
return self._render_ordered_list(block, ctx)
|
|
501
|
+
elif isinstance(block, HorizontalRule):
|
|
502
|
+
return self._render_horizontal_rule(ctx)
|
|
503
|
+
elif isinstance(block, Table):
|
|
504
|
+
return self._render_table(block, ctx)
|
|
505
|
+
elif isinstance(block, ImageBlock):
|
|
506
|
+
return self._render_image_block(block, ctx)
|
|
507
|
+
elif isinstance(block, RawHtmlBlock):
|
|
508
|
+
return self._render_raw_html_block(block, ctx)
|
|
509
|
+
else:
|
|
510
|
+
# Unknown block type
|
|
511
|
+
return [], 0
|
|
512
|
+
|
|
513
|
+
def _render_paragraph(
|
|
514
|
+
self,
|
|
515
|
+
para: Paragraph,
|
|
516
|
+
ctx: RenderContext,
|
|
517
|
+
) -> Tuple[List[str], float]:
|
|
518
|
+
"""Render a paragraph."""
|
|
519
|
+
return self._render_text_block(
|
|
520
|
+
para.spans,
|
|
521
|
+
ctx,
|
|
522
|
+
font_size=self.style.base_font_size,
|
|
523
|
+
css_class="md-text",
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
def _render_raw_html_block(
|
|
527
|
+
self,
|
|
528
|
+
block: RawHtmlBlock,
|
|
529
|
+
ctx: RenderContext,
|
|
530
|
+
) -> Tuple[List[str], float]:
|
|
531
|
+
"""Render a raw HTML block.
|
|
532
|
+
|
|
533
|
+
When allow_raw_html is True, wraps in <foreignObject> with XHTML namespace.
|
|
534
|
+
When False, escapes HTML and renders as a text paragraph.
|
|
535
|
+
"""
|
|
536
|
+
if not self._allow_raw_html:
|
|
537
|
+
# Escape and render as plain text paragraph
|
|
538
|
+
from .types import Paragraph, Span
|
|
539
|
+
|
|
540
|
+
escaped = Paragraph(spans=(Span(text=block.html),))
|
|
541
|
+
return self._render_paragraph(escaped, ctx)
|
|
542
|
+
|
|
543
|
+
sanitized = _sanitize_html(block.html)
|
|
544
|
+
# Estimate height: count lines, use line_height heuristic
|
|
545
|
+
line_count = max(1, sanitized.count("\n") + 1)
|
|
546
|
+
line_h = self.style.base_font_size * self.style.line_height
|
|
547
|
+
fo_height = line_count * line_h + 16 # 16px padding
|
|
548
|
+
fo_width = ctx.width
|
|
549
|
+
|
|
550
|
+
fo = (
|
|
551
|
+
f'<foreignObject x="{format_number(ctx.x)}" y="{format_number(ctx.y)}" '
|
|
552
|
+
f'width="{format_number(fo_width)}" height="{format_number(fo_height)}">'
|
|
553
|
+
f'<div xmlns="http://www.w3.org/1999/xhtml" '
|
|
554
|
+
f'style="font-family: {self.style.font_family}; '
|
|
555
|
+
f"font-size: {format_number(self.style.base_font_size)}px; "
|
|
556
|
+
f'color: {self.style.text_color};">'
|
|
557
|
+
f"{sanitized}"
|
|
558
|
+
f"</div>"
|
|
559
|
+
f"</foreignObject>"
|
|
560
|
+
)
|
|
561
|
+
return [fo], fo_height
|
|
562
|
+
|
|
563
|
+
def _render_heading(
|
|
564
|
+
self,
|
|
565
|
+
heading: Heading,
|
|
566
|
+
ctx: RenderContext,
|
|
567
|
+
) -> Tuple[List[str], float]:
|
|
568
|
+
"""Render a heading."""
|
|
569
|
+
font_size = self.style.get_heading_size(heading.level)
|
|
570
|
+
|
|
571
|
+
# Add top margin
|
|
572
|
+
margin_top = font_size * self.style.heading_margin_top
|
|
573
|
+
margin_bottom = font_size * self.style.heading_margin_bottom
|
|
574
|
+
|
|
575
|
+
elements, text_height = self._render_text_block(
|
|
576
|
+
heading.spans,
|
|
577
|
+
ctx.with_offset(dy=margin_top),
|
|
578
|
+
font_size=font_size,
|
|
579
|
+
css_class="md-heading",
|
|
580
|
+
font_weight=self.style.heading_font_weight,
|
|
581
|
+
line_height_multiplier=self.style.heading_line_height,
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
return elements, margin_top + text_height + margin_bottom
|
|
585
|
+
|
|
586
|
+
def _render_code_block(
|
|
587
|
+
self,
|
|
588
|
+
code: CodeBlock,
|
|
589
|
+
ctx: RenderContext,
|
|
590
|
+
) -> Tuple[List[str], float]:
|
|
591
|
+
"""Render a code block with background."""
|
|
592
|
+
overflow = self.style.code_block_overflow
|
|
593
|
+
|
|
594
|
+
if overflow == "wrap":
|
|
595
|
+
return self._render_code_block_wrapped(code, ctx)
|
|
596
|
+
elif overflow in {"show", "hide", "ellipsis"}:
|
|
597
|
+
return self._render_code_block_simple(code, ctx, overflow)
|
|
598
|
+
else:
|
|
599
|
+
# Defensive fallback (should be unreachable due to typing)
|
|
600
|
+
return self._render_code_block_wrapped(code, ctx)
|
|
601
|
+
|
|
602
|
+
def _render_code_block_simple(
|
|
603
|
+
self,
|
|
604
|
+
code: CodeBlock,
|
|
605
|
+
ctx: RenderContext,
|
|
606
|
+
overflow: str,
|
|
607
|
+
) -> Tuple[List[str], float]:
|
|
608
|
+
"""Render code block with show/hide/ellipsis overflow."""
|
|
609
|
+
elements: List[str] = []
|
|
610
|
+
|
|
611
|
+
padding = self.style.code_block_padding
|
|
612
|
+
font_size = self.style.base_font_size * 0.9
|
|
613
|
+
line_height = font_size * 1.4
|
|
614
|
+
char_width = font_size * self._ensure_mono_char_width()
|
|
615
|
+
max_chars = int((ctx.width - padding * 2) / char_width)
|
|
616
|
+
|
|
617
|
+
lines = code.code.split("\n")
|
|
618
|
+
|
|
619
|
+
# Process lines for ellipsis mode
|
|
620
|
+
if overflow == "ellipsis":
|
|
621
|
+
processed_lines = []
|
|
622
|
+
for line in lines:
|
|
623
|
+
if len(line) > max_chars and max_chars > 3:
|
|
624
|
+
processed_lines.append(line[: max_chars - 3] + "...")
|
|
625
|
+
else:
|
|
626
|
+
processed_lines.append(line)
|
|
627
|
+
lines = processed_lines
|
|
628
|
+
|
|
629
|
+
text_height = len(lines) * line_height
|
|
630
|
+
total_height = text_height + (padding * 2)
|
|
631
|
+
|
|
632
|
+
# For hide mode, add a clipPath
|
|
633
|
+
clip_id = None
|
|
634
|
+
if overflow == "hide":
|
|
635
|
+
clip_id = f"code-clip-{id(code)}"
|
|
636
|
+
elements.append(
|
|
637
|
+
f' <defs><clipPath id="{clip_id}">'
|
|
638
|
+
f'<rect x="{format_number(ctx.x)}" y="{format_number(ctx.y)}" '
|
|
639
|
+
f'width="{format_number(ctx.width)}" height="{format_number(total_height)}"/>'
|
|
640
|
+
f"</clipPath></defs>"
|
|
641
|
+
)
|
|
642
|
+
|
|
643
|
+
# Background rectangle
|
|
644
|
+
elements.append(
|
|
645
|
+
f' <rect x="{format_number(ctx.x)}" y="{format_number(ctx.y)}" '
|
|
646
|
+
f'width="{format_number(ctx.width)}" height="{format_number(total_height)}" '
|
|
647
|
+
f'fill="{self.style.code_background}" '
|
|
648
|
+
f'rx="{format_number(self.style.code_block_border_radius)}"/>'
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
# Code lines (optionally clipped)
|
|
652
|
+
clip_attr = f' clip-path="url(#{clip_id})"' if clip_id else ""
|
|
653
|
+
y_offset = ctx.y + padding + font_size
|
|
654
|
+
for line in lines:
|
|
655
|
+
if line: # Don't render empty lines as text elements
|
|
656
|
+
escaped = escape_xml(line)
|
|
657
|
+
elements.append(
|
|
658
|
+
f' <text x="{format_number(ctx.x + padding)}" '
|
|
659
|
+
f'y="{format_number(y_offset)}" '
|
|
660
|
+
f'class="md-mono" font-size="{format_number(font_size)}" '
|
|
661
|
+
f'font-weight="400" xml:space="preserve" '
|
|
662
|
+
f'fill="{self.style.text_color}"{clip_attr}>{escaped}</text>'
|
|
663
|
+
)
|
|
664
|
+
y_offset += line_height
|
|
665
|
+
|
|
666
|
+
return elements, total_height
|
|
667
|
+
|
|
668
|
+
def _render_code_block_wrapped(
|
|
669
|
+
self,
|
|
670
|
+
code: CodeBlock,
|
|
671
|
+
ctx: RenderContext,
|
|
672
|
+
) -> Tuple[List[str], float]:
|
|
673
|
+
"""Render code block with wrapped lines."""
|
|
674
|
+
elements: List[str] = []
|
|
675
|
+
|
|
676
|
+
padding = self.style.code_block_padding
|
|
677
|
+
font_size = self.style.base_font_size * 0.9
|
|
678
|
+
line_height = font_size * 1.4
|
|
679
|
+
char_width = font_size * self._ensure_mono_char_width()
|
|
680
|
+
max_chars = max(10, int((ctx.width - padding * 2) / char_width))
|
|
681
|
+
|
|
682
|
+
# Wrap lines
|
|
683
|
+
wrapped_lines: List[str] = []
|
|
684
|
+
for line in code.code.split("\n"):
|
|
685
|
+
if not line:
|
|
686
|
+
wrapped_lines.append("")
|
|
687
|
+
elif len(line) <= max_chars:
|
|
688
|
+
wrapped_lines.append(line)
|
|
689
|
+
else:
|
|
690
|
+
# Wrap long lines
|
|
691
|
+
while line:
|
|
692
|
+
wrapped_lines.append(line[:max_chars])
|
|
693
|
+
line = line[max_chars:]
|
|
694
|
+
|
|
695
|
+
text_height = len(wrapped_lines) * line_height
|
|
696
|
+
total_height = text_height + (padding * 2)
|
|
697
|
+
|
|
698
|
+
# Background rectangle
|
|
699
|
+
elements.append(
|
|
700
|
+
f' <rect x="{format_number(ctx.x)}" y="{format_number(ctx.y)}" '
|
|
701
|
+
f'width="{format_number(ctx.width)}" height="{format_number(total_height)}" '
|
|
702
|
+
f'fill="{self.style.code_background}" '
|
|
703
|
+
f'rx="{format_number(self.style.code_block_border_radius)}"/>'
|
|
704
|
+
)
|
|
705
|
+
|
|
706
|
+
# Code lines
|
|
707
|
+
y_offset = ctx.y + padding + font_size
|
|
708
|
+
for line in wrapped_lines:
|
|
709
|
+
if line:
|
|
710
|
+
escaped = escape_xml(line)
|
|
711
|
+
elements.append(
|
|
712
|
+
f' <text x="{format_number(ctx.x + padding)}" '
|
|
713
|
+
f'y="{format_number(y_offset)}" '
|
|
714
|
+
f'class="md-mono" font-size="{format_number(font_size)}" '
|
|
715
|
+
f'font-weight="400" xml:space="preserve" '
|
|
716
|
+
f'fill="{self.style.text_color}">{escaped}</text>'
|
|
717
|
+
)
|
|
718
|
+
y_offset += line_height
|
|
719
|
+
|
|
720
|
+
return elements, total_height
|
|
721
|
+
|
|
722
|
+
def _render_blockquote(
|
|
723
|
+
self,
|
|
724
|
+
bq: Blockquote,
|
|
725
|
+
ctx: RenderContext,
|
|
726
|
+
) -> Tuple[List[str], float]:
|
|
727
|
+
"""Render a blockquote with left border."""
|
|
728
|
+
elements: List[str] = []
|
|
729
|
+
|
|
730
|
+
# Create indented context for content
|
|
731
|
+
indent = self.style.blockquote_padding
|
|
732
|
+
inner_ctx = ctx.with_indent(indent)
|
|
733
|
+
|
|
734
|
+
# Render inner blocks
|
|
735
|
+
current_y = 0.0
|
|
736
|
+
inner_elements: List[str] = []
|
|
737
|
+
|
|
738
|
+
for block in bq.blocks:
|
|
739
|
+
block_elements, height = self._render_block(
|
|
740
|
+
block,
|
|
741
|
+
inner_ctx.with_offset(dy=current_y),
|
|
742
|
+
)
|
|
743
|
+
inner_elements.extend(block_elements)
|
|
744
|
+
current_y += height + self.style.paragraph_spacing
|
|
745
|
+
|
|
746
|
+
if bq.blocks:
|
|
747
|
+
current_y -= self.style.paragraph_spacing
|
|
748
|
+
|
|
749
|
+
total_height = current_y
|
|
750
|
+
|
|
751
|
+
# Left border
|
|
752
|
+
elements.append(
|
|
753
|
+
f' <rect x="{format_number(ctx.x)}" y="{format_number(ctx.y)}" '
|
|
754
|
+
f'width="{format_number(self.style.blockquote_border_width)}" '
|
|
755
|
+
f'height="{format_number(total_height)}" '
|
|
756
|
+
f'fill="{self.style.blockquote_border_color}"/>'
|
|
757
|
+
)
|
|
758
|
+
|
|
759
|
+
elements.extend(inner_elements)
|
|
760
|
+
|
|
761
|
+
return elements, total_height
|
|
762
|
+
|
|
763
|
+
def _render_unordered_list(
|
|
764
|
+
self,
|
|
765
|
+
ul: UnorderedList,
|
|
766
|
+
ctx: RenderContext,
|
|
767
|
+
) -> Tuple[List[str], float]:
|
|
768
|
+
"""Render an unordered list."""
|
|
769
|
+
elements: List[str] = []
|
|
770
|
+
current_y = 0.0
|
|
771
|
+
|
|
772
|
+
bullet_indent = self.style.list_indent
|
|
773
|
+
|
|
774
|
+
for item in ul.items:
|
|
775
|
+
# Render bullet
|
|
776
|
+
bullet_x = ctx.x + (bullet_indent / 2) - 4
|
|
777
|
+
bullet_y = (
|
|
778
|
+
ctx.y
|
|
779
|
+
+ current_y
|
|
780
|
+
+ (self.style.base_font_size * self.style.line_height / 2)
|
|
781
|
+
)
|
|
782
|
+
|
|
783
|
+
elements.append(
|
|
784
|
+
f' <circle cx="{format_number(bullet_x)}" '
|
|
785
|
+
f'cy="{format_number(bullet_y)}" r="3" '
|
|
786
|
+
f'fill="{self.style.text_color}"/>'
|
|
787
|
+
)
|
|
788
|
+
|
|
789
|
+
# Render item text
|
|
790
|
+
item_ctx = ctx.with_indent(bullet_indent).with_offset(dy=current_y)
|
|
791
|
+
item_elements, item_height = self._render_text_block(
|
|
792
|
+
item.spans,
|
|
793
|
+
item_ctx,
|
|
794
|
+
font_size=self.style.base_font_size,
|
|
795
|
+
css_class="md-text",
|
|
796
|
+
)
|
|
797
|
+
elements.extend(item_elements)
|
|
798
|
+
|
|
799
|
+
current_y += item_height + self.style.list_item_spacing
|
|
800
|
+
|
|
801
|
+
if ul.items:
|
|
802
|
+
current_y -= self.style.list_item_spacing
|
|
803
|
+
|
|
804
|
+
return elements, current_y
|
|
805
|
+
|
|
806
|
+
def _render_ordered_list(
|
|
807
|
+
self,
|
|
808
|
+
ol: OrderedList,
|
|
809
|
+
ctx: RenderContext,
|
|
810
|
+
) -> Tuple[List[str], float]:
|
|
811
|
+
"""Render an ordered list."""
|
|
812
|
+
elements: List[str] = []
|
|
813
|
+
current_y = 0.0
|
|
814
|
+
|
|
815
|
+
bullet_indent = self.style.list_indent
|
|
816
|
+
|
|
817
|
+
for idx, item in enumerate(ol.items):
|
|
818
|
+
number = ol.start + idx
|
|
819
|
+
|
|
820
|
+
# Render number
|
|
821
|
+
number_text = f"{number}."
|
|
822
|
+
number_x = ctx.x + bullet_indent - 8
|
|
823
|
+
number_y = ctx.y + current_y + self.style.base_font_size
|
|
824
|
+
|
|
825
|
+
elements.append(
|
|
826
|
+
f' <text x="{format_number(number_x)}" '
|
|
827
|
+
f'y="{format_number(number_y)}" '
|
|
828
|
+
f'class="md-text" font-size="{format_number(self.style.base_font_size)}" '
|
|
829
|
+
f'text-anchor="end">{number_text}</text>'
|
|
830
|
+
)
|
|
831
|
+
|
|
832
|
+
# Render item text
|
|
833
|
+
item_ctx = ctx.with_indent(bullet_indent).with_offset(dy=current_y)
|
|
834
|
+
item_elements, item_height = self._render_text_block(
|
|
835
|
+
item.spans,
|
|
836
|
+
item_ctx,
|
|
837
|
+
font_size=self.style.base_font_size,
|
|
838
|
+
css_class="md-text",
|
|
839
|
+
)
|
|
840
|
+
elements.extend(item_elements)
|
|
841
|
+
|
|
842
|
+
current_y += item_height + self.style.list_item_spacing
|
|
843
|
+
|
|
844
|
+
if ol.items:
|
|
845
|
+
current_y -= self.style.list_item_spacing
|
|
846
|
+
|
|
847
|
+
return elements, current_y
|
|
848
|
+
|
|
849
|
+
def _render_horizontal_rule(
|
|
850
|
+
self,
|
|
851
|
+
ctx: RenderContext,
|
|
852
|
+
) -> Tuple[List[str], float]:
|
|
853
|
+
"""Render a horizontal rule."""
|
|
854
|
+
height = self.style.hr_height
|
|
855
|
+
margin = self.style.paragraph_spacing
|
|
856
|
+
|
|
857
|
+
y_pos = ctx.y + margin
|
|
858
|
+
|
|
859
|
+
element = (
|
|
860
|
+
f' <rect x="{format_number(ctx.x)}" '
|
|
861
|
+
f'y="{format_number(y_pos)}" '
|
|
862
|
+
f'width="{format_number(ctx.width)}" '
|
|
863
|
+
f'height="{format_number(height)}" '
|
|
864
|
+
f'fill="{self.style.hr_color}"/>'
|
|
865
|
+
)
|
|
866
|
+
|
|
867
|
+
return [element], margin + height + margin
|
|
868
|
+
|
|
869
|
+
def _render_table(
|
|
870
|
+
self,
|
|
871
|
+
table: Table,
|
|
872
|
+
ctx: RenderContext,
|
|
873
|
+
) -> Tuple[List[str], float]:
|
|
874
|
+
"""Render a table."""
|
|
875
|
+
elements: List[str] = []
|
|
876
|
+
|
|
877
|
+
padding = self.style.table_cell_padding
|
|
878
|
+
font_size = self.style.base_font_size
|
|
879
|
+
|
|
880
|
+
# Calculate column widths (equal distribution for now)
|
|
881
|
+
num_cols = len(table.header.cells)
|
|
882
|
+
col_width = ctx.width / num_cols if num_cols > 0 else ctx.width
|
|
883
|
+
|
|
884
|
+
header_layout = self._measure_table_row_layout(
|
|
885
|
+
table.header,
|
|
886
|
+
col_width=col_width,
|
|
887
|
+
padding=padding,
|
|
888
|
+
font_size=font_size,
|
|
889
|
+
is_header=True,
|
|
890
|
+
)
|
|
891
|
+
body_layouts = [
|
|
892
|
+
self._measure_table_row_layout(
|
|
893
|
+
row,
|
|
894
|
+
col_width=col_width,
|
|
895
|
+
padding=padding,
|
|
896
|
+
font_size=font_size,
|
|
897
|
+
is_header=False,
|
|
898
|
+
)
|
|
899
|
+
for row in table.rows
|
|
900
|
+
]
|
|
901
|
+
|
|
902
|
+
current_y = ctx.y
|
|
903
|
+
|
|
904
|
+
# Render header background
|
|
905
|
+
elements.append(
|
|
906
|
+
f' <rect x="{format_number(ctx.x)}" y="{format_number(current_y)}" '
|
|
907
|
+
f'width="{format_number(ctx.width)}" height="{format_number(header_layout.row_height)}" '
|
|
908
|
+
f'fill="{self.style.table_header_background}"/>'
|
|
909
|
+
)
|
|
910
|
+
|
|
911
|
+
# Render header row
|
|
912
|
+
self._render_table_row(
|
|
913
|
+
elements,
|
|
914
|
+
table.header,
|
|
915
|
+
header_layout.cell_lines,
|
|
916
|
+
ctx.x,
|
|
917
|
+
current_y,
|
|
918
|
+
col_width,
|
|
919
|
+
padding,
|
|
920
|
+
font_size,
|
|
921
|
+
is_header=True,
|
|
922
|
+
)
|
|
923
|
+
current_y += header_layout.row_height
|
|
924
|
+
|
|
925
|
+
# Render body rows
|
|
926
|
+
for row, row_layout in zip(table.rows, body_layouts):
|
|
927
|
+
self._render_table_row(
|
|
928
|
+
elements,
|
|
929
|
+
row,
|
|
930
|
+
row_layout.cell_lines,
|
|
931
|
+
ctx.x,
|
|
932
|
+
current_y,
|
|
933
|
+
col_width,
|
|
934
|
+
padding,
|
|
935
|
+
font_size,
|
|
936
|
+
is_header=False,
|
|
937
|
+
)
|
|
938
|
+
current_y += row_layout.row_height
|
|
939
|
+
|
|
940
|
+
# Table border
|
|
941
|
+
total_height = current_y - ctx.y
|
|
942
|
+
elements.append(
|
|
943
|
+
f' <rect x="{format_number(ctx.x)}" y="{format_number(ctx.y)}" '
|
|
944
|
+
f'width="{format_number(ctx.width)}" height="{format_number(total_height)}" '
|
|
945
|
+
f'fill="none" stroke="{self.style.table_border_color}"/>'
|
|
946
|
+
)
|
|
947
|
+
|
|
948
|
+
# Column separators
|
|
949
|
+
col_x = ctx.x
|
|
950
|
+
for _ in range(num_cols - 1):
|
|
951
|
+
col_x += col_width
|
|
952
|
+
elements.append(
|
|
953
|
+
f' <line x1="{format_number(col_x)}" y1="{format_number(ctx.y)}" '
|
|
954
|
+
f'x2="{format_number(col_x)}" y2="{format_number(current_y)}" '
|
|
955
|
+
f'stroke="{self.style.table_border_color}"/>'
|
|
956
|
+
)
|
|
957
|
+
|
|
958
|
+
# Row separators
|
|
959
|
+
row_y = ctx.y
|
|
960
|
+
row_heights = [
|
|
961
|
+
header_layout.row_height,
|
|
962
|
+
*[layout.row_height for layout in body_layouts],
|
|
963
|
+
]
|
|
964
|
+
for row_height in row_heights:
|
|
965
|
+
row_y += row_height
|
|
966
|
+
if row_y < current_y:
|
|
967
|
+
elements.append(
|
|
968
|
+
f' <line x1="{format_number(ctx.x)}" y1="{format_number(row_y)}" '
|
|
969
|
+
f'x2="{format_number(ctx.x + ctx.width)}" y2="{format_number(row_y)}" '
|
|
970
|
+
f'stroke="{self.style.table_border_color}"/>'
|
|
971
|
+
)
|
|
972
|
+
|
|
973
|
+
return elements, total_height
|
|
974
|
+
|
|
975
|
+
def _table_cell_runs(
|
|
976
|
+
self,
|
|
977
|
+
cell: TableCell,
|
|
978
|
+
is_header: bool,
|
|
979
|
+
) -> List[TextRun]:
|
|
980
|
+
"""Build text runs for a table cell, forcing bold widths for headers."""
|
|
981
|
+
runs = self._build_text_runs(cell.spans, self.style.base_font_size)
|
|
982
|
+
if not is_header:
|
|
983
|
+
return runs
|
|
984
|
+
return [
|
|
985
|
+
TextRun(
|
|
986
|
+
text=run.text,
|
|
987
|
+
is_bold=True,
|
|
988
|
+
is_italic=run.is_italic,
|
|
989
|
+
is_code=run.is_code,
|
|
990
|
+
is_link=run.is_link,
|
|
991
|
+
is_image=run.is_image,
|
|
992
|
+
url=run.url,
|
|
993
|
+
)
|
|
994
|
+
for run in runs
|
|
995
|
+
]
|
|
996
|
+
|
|
997
|
+
def _table_cell_lines_to_inner_svg(
|
|
998
|
+
self,
|
|
999
|
+
line_runs: Sequence[TextRun],
|
|
1000
|
+
is_header: bool,
|
|
1001
|
+
) -> str:
|
|
1002
|
+
"""Inline spans for a wrapped table-cell line."""
|
|
1003
|
+
parts: list[str] = []
|
|
1004
|
+
for run in line_runs:
|
|
1005
|
+
if not run.text:
|
|
1006
|
+
continue
|
|
1007
|
+
escaped = escape_svg_text(run.text)
|
|
1008
|
+
style_parts: list[str] = []
|
|
1009
|
+
|
|
1010
|
+
if run.is_bold or is_header:
|
|
1011
|
+
style_parts.append("font-weight: bold")
|
|
1012
|
+
|
|
1013
|
+
if run.is_italic:
|
|
1014
|
+
style_parts.append("font-style: italic")
|
|
1015
|
+
|
|
1016
|
+
if run.is_code:
|
|
1017
|
+
style_parts.append(f"font-family: {self.style.mono_font_family}")
|
|
1018
|
+
style_parts.append(f"fill: {self.style.code_color}")
|
|
1019
|
+
|
|
1020
|
+
if run.is_link:
|
|
1021
|
+
style_parts.append(f"fill: {self.style.link_color}")
|
|
1022
|
+
if self.style.link_underline:
|
|
1023
|
+
style_parts.append("text-decoration: underline")
|
|
1024
|
+
|
|
1025
|
+
if run.is_image:
|
|
1026
|
+
style_parts.append("font-style: italic")
|
|
1027
|
+
style_parts.append(f"fill: {self.style.code_color}")
|
|
1028
|
+
|
|
1029
|
+
style_attr = f' style="{"; ".join(style_parts)}"' if style_parts else ""
|
|
1030
|
+
if run.is_link and run.url:
|
|
1031
|
+
parts.append(
|
|
1032
|
+
f'<a href="{escape_svg_text(run.url)}">'
|
|
1033
|
+
f"<tspan{style_attr}>{escaped}</tspan></a>"
|
|
1034
|
+
)
|
|
1035
|
+
elif style_parts:
|
|
1036
|
+
parts.append(f"<tspan{style_attr}>{escaped}</tspan>")
|
|
1037
|
+
else:
|
|
1038
|
+
parts.append(escaped)
|
|
1039
|
+
|
|
1040
|
+
return "".join(parts)
|
|
1041
|
+
|
|
1042
|
+
def _measure_table_row_layout(
|
|
1043
|
+
self,
|
|
1044
|
+
row: TableRow,
|
|
1045
|
+
col_width: float,
|
|
1046
|
+
padding: float,
|
|
1047
|
+
font_size: float,
|
|
1048
|
+
is_header: bool,
|
|
1049
|
+
) -> TableRowLayout:
|
|
1050
|
+
"""Wrap each cell in a row and return its rendered line layout."""
|
|
1051
|
+
max_width = max(col_width - (padding * 2), 1.0)
|
|
1052
|
+
line_height = font_size * self.style.line_height
|
|
1053
|
+
cell_lines: list[tuple[tuple[TextRun, ...], ...]] = []
|
|
1054
|
+
max_lines = 1
|
|
1055
|
+
|
|
1056
|
+
for cell in row.cells:
|
|
1057
|
+
runs = self._table_cell_runs(cell, is_header=is_header)
|
|
1058
|
+
wrapped = self._wrap_runs(runs, max_width, font_size)
|
|
1059
|
+
if not wrapped:
|
|
1060
|
+
wrapped = [[]]
|
|
1061
|
+
cell_lines.append(
|
|
1062
|
+
tuple(tuple(line_run for line_run in line) for line in wrapped)
|
|
1063
|
+
)
|
|
1064
|
+
max_lines = max(max_lines, len(wrapped))
|
|
1065
|
+
|
|
1066
|
+
row_height = (max_lines * line_height) + (padding * 2)
|
|
1067
|
+
return TableRowLayout(cell_lines=tuple(cell_lines), row_height=row_height)
|
|
1068
|
+
|
|
1069
|
+
def _render_table_row(
|
|
1070
|
+
self,
|
|
1071
|
+
elements: List[str],
|
|
1072
|
+
row: TableRow,
|
|
1073
|
+
cell_lines: Sequence[Sequence[Sequence[TextRun]]],
|
|
1074
|
+
start_x: float,
|
|
1075
|
+
y: float,
|
|
1076
|
+
col_width: float,
|
|
1077
|
+
padding: float,
|
|
1078
|
+
font_size: float,
|
|
1079
|
+
is_header: bool,
|
|
1080
|
+
) -> None:
|
|
1081
|
+
"""Render a single table row."""
|
|
1082
|
+
x = start_x
|
|
1083
|
+
line_height = font_size * self.style.line_height
|
|
1084
|
+
|
|
1085
|
+
for idx, cell in enumerate(row.cells):
|
|
1086
|
+
line_runs = cell_lines[idx]
|
|
1087
|
+
|
|
1088
|
+
# Get alignment
|
|
1089
|
+
align = cell.align
|
|
1090
|
+
|
|
1091
|
+
if align == "center":
|
|
1092
|
+
text_x = x + col_width / 2
|
|
1093
|
+
anchor = "middle"
|
|
1094
|
+
elif align == "right":
|
|
1095
|
+
text_x = x + col_width - padding
|
|
1096
|
+
anchor = "end"
|
|
1097
|
+
else: # left or default
|
|
1098
|
+
text_x = x + padding
|
|
1099
|
+
anchor = "start"
|
|
1100
|
+
|
|
1101
|
+
css_class = "md-text"
|
|
1102
|
+
weight = "bold" if is_header else "normal"
|
|
1103
|
+
|
|
1104
|
+
text_y = y + padding + font_size
|
|
1105
|
+
for runs in line_runs:
|
|
1106
|
+
inner = self._table_cell_lines_to_inner_svg(runs, is_header=is_header)
|
|
1107
|
+
elements.append(
|
|
1108
|
+
f' <text x="{format_number(text_x)}" y="{format_number(text_y)}" '
|
|
1109
|
+
f'class="{css_class}" font-size="{format_number(font_size)}" '
|
|
1110
|
+
f'font-weight="{weight}" text-anchor="{anchor}">{inner}</text>'
|
|
1111
|
+
)
|
|
1112
|
+
text_y += line_height
|
|
1113
|
+
|
|
1114
|
+
x += col_width
|
|
1115
|
+
|
|
1116
|
+
def _get_image_size(self, url: str) -> Optional[ImageSize]:
|
|
1117
|
+
"""Get image dimensions, using cache to avoid re-fetching."""
|
|
1118
|
+
if url in self._image_size_cache:
|
|
1119
|
+
return self._image_size_cache[url]
|
|
1120
|
+
|
|
1121
|
+
# Skip fetching if enforce_aspect_ratio is set (speed optimization)
|
|
1122
|
+
if self.style.image_enforce_aspect_ratio:
|
|
1123
|
+
return None
|
|
1124
|
+
|
|
1125
|
+
if not self._fetch_image_sizes:
|
|
1126
|
+
return None
|
|
1127
|
+
|
|
1128
|
+
size = get_image_size(
|
|
1129
|
+
url,
|
|
1130
|
+
base_path=self._image_base_path,
|
|
1131
|
+
timeout=self._image_timeout,
|
|
1132
|
+
)
|
|
1133
|
+
self._image_size_cache[url] = size
|
|
1134
|
+
return size
|
|
1135
|
+
|
|
1136
|
+
def _map_image_url(self, url: str) -> str:
|
|
1137
|
+
"""Apply URL mapper if configured."""
|
|
1138
|
+
if self._image_url_mapper:
|
|
1139
|
+
return self._image_url_mapper(url)
|
|
1140
|
+
return url
|
|
1141
|
+
|
|
1142
|
+
def _render_image_block(
|
|
1143
|
+
self,
|
|
1144
|
+
img: ImageBlock,
|
|
1145
|
+
ctx: RenderContext,
|
|
1146
|
+
) -> Tuple[List[str], float]:
|
|
1147
|
+
"""Render an image block.
|
|
1148
|
+
|
|
1149
|
+
Image sizing priority:
|
|
1150
|
+
1. Explicit dimensions from markdown: {width=X height=Y}
|
|
1151
|
+
2. Fetched dimensions from the actual image (if fetch_image_sizes=True)
|
|
1152
|
+
3. Style defaults (image_width, image_height)
|
|
1153
|
+
4. Fallback: full width with image_fallback_aspect_ratio
|
|
1154
|
+
|
|
1155
|
+
The preserveAspectRatio attribute ensures the actual image
|
|
1156
|
+
scales proportionally within the allocated space.
|
|
1157
|
+
"""
|
|
1158
|
+
# Try to get actual image dimensions
|
|
1159
|
+
actual_size = self._get_image_size(img.url)
|
|
1160
|
+
|
|
1161
|
+
# Determine dimensions using priority order
|
|
1162
|
+
explicit_width = img.width
|
|
1163
|
+
explicit_height = img.height
|
|
1164
|
+
|
|
1165
|
+
# Calculate final width
|
|
1166
|
+
if explicit_width is not None:
|
|
1167
|
+
# Explicit width from markdown
|
|
1168
|
+
img_width = min(ctx.width, explicit_width)
|
|
1169
|
+
elif self.style.image_width is not None:
|
|
1170
|
+
# Style default width
|
|
1171
|
+
img_width = min(ctx.width, self.style.image_width)
|
|
1172
|
+
else:
|
|
1173
|
+
# Full container width
|
|
1174
|
+
img_width = ctx.width
|
|
1175
|
+
|
|
1176
|
+
# Calculate final height
|
|
1177
|
+
if explicit_height is not None:
|
|
1178
|
+
# Explicit height from markdown
|
|
1179
|
+
img_height = explicit_height
|
|
1180
|
+
elif explicit_width is not None and actual_size is not None:
|
|
1181
|
+
# Scale height based on actual aspect ratio
|
|
1182
|
+
img_height = img_width / actual_size.aspect_ratio
|
|
1183
|
+
elif self.style.image_height is not None:
|
|
1184
|
+
# Style default height
|
|
1185
|
+
img_height = self.style.image_height
|
|
1186
|
+
elif actual_size is not None:
|
|
1187
|
+
# Use actual image aspect ratio
|
|
1188
|
+
img_height = img_width / actual_size.aspect_ratio
|
|
1189
|
+
else:
|
|
1190
|
+
# Fallback to configured aspect ratio
|
|
1191
|
+
img_height = img_width / self.style.image_fallback_aspect_ratio
|
|
1192
|
+
|
|
1193
|
+
# Map URL for embedding (e.g., local path -> CDN URL)
|
|
1194
|
+
embed_url = self._map_image_url(img.url)
|
|
1195
|
+
|
|
1196
|
+
element = (
|
|
1197
|
+
f' <image x="{format_number(ctx.x)}" y="{format_number(ctx.y)}" '
|
|
1198
|
+
f'width="{format_number(img_width)}" height="{format_number(img_height)}" '
|
|
1199
|
+
f'href="{escape_svg_text(embed_url)}" '
|
|
1200
|
+
f'preserveAspectRatio="{self.style.image_preserve_aspect_ratio}"/>'
|
|
1201
|
+
)
|
|
1202
|
+
|
|
1203
|
+
elements = [element]
|
|
1204
|
+
|
|
1205
|
+
# Add alt text as title for accessibility
|
|
1206
|
+
if img.alt:
|
|
1207
|
+
elements.append(f" <title>{escape_svg_text(img.alt)}</title>")
|
|
1208
|
+
|
|
1209
|
+
return elements, img_height
|
|
1210
|
+
|
|
1211
|
+
def _render_text_block(
|
|
1212
|
+
self,
|
|
1213
|
+
spans: Sequence[Span],
|
|
1214
|
+
ctx: RenderContext,
|
|
1215
|
+
font_size: float,
|
|
1216
|
+
css_class: str,
|
|
1217
|
+
font_weight: str | int = "normal",
|
|
1218
|
+
line_height_multiplier: Optional[float] = None,
|
|
1219
|
+
) -> Tuple[List[str], float]:
|
|
1220
|
+
"""Render a sequence of spans as wrapped text using tspan for proper spacing."""
|
|
1221
|
+
if not spans:
|
|
1222
|
+
return [], 0
|
|
1223
|
+
|
|
1224
|
+
elements: List[str] = []
|
|
1225
|
+
multiplier = (
|
|
1226
|
+
line_height_multiplier
|
|
1227
|
+
if line_height_multiplier is not None
|
|
1228
|
+
else self.style.line_height
|
|
1229
|
+
)
|
|
1230
|
+
line_height = font_size * multiplier
|
|
1231
|
+
|
|
1232
|
+
# Build runs of text with their styles
|
|
1233
|
+
runs = self._build_text_runs(spans, font_size)
|
|
1234
|
+
|
|
1235
|
+
# Wrap and layout text
|
|
1236
|
+
lines = self._wrap_runs(runs, ctx.width, font_size)
|
|
1237
|
+
|
|
1238
|
+
current_y = ctx.y + font_size # Baseline
|
|
1239
|
+
|
|
1240
|
+
# Calculate x position based on text alignment
|
|
1241
|
+
text_anchor = self.style.get_text_anchor()
|
|
1242
|
+
if self.style.text_align == "center":
|
|
1243
|
+
text_x = ctx.x + ctx.width / 2
|
|
1244
|
+
elif self.style.text_align == "right":
|
|
1245
|
+
text_x = ctx.x + ctx.width
|
|
1246
|
+
else: # left (default)
|
|
1247
|
+
text_x = ctx.x
|
|
1248
|
+
|
|
1249
|
+
for line_runs in lines:
|
|
1250
|
+
if not line_runs:
|
|
1251
|
+
current_y += line_height
|
|
1252
|
+
continue
|
|
1253
|
+
|
|
1254
|
+
# Build a single <text> element with <tspan> children for proper spacing
|
|
1255
|
+
# This lets the browser handle text positioning correctly
|
|
1256
|
+
tspan_parts: List[str] = []
|
|
1257
|
+
|
|
1258
|
+
for run in line_runs:
|
|
1259
|
+
if not run.text:
|
|
1260
|
+
continue
|
|
1261
|
+
|
|
1262
|
+
escaped = escape_svg_text(run.text)
|
|
1263
|
+
|
|
1264
|
+
# Build tspan styling
|
|
1265
|
+
style_parts: List[str] = []
|
|
1266
|
+
|
|
1267
|
+
if run.is_bold:
|
|
1268
|
+
style_parts.append("font-weight: bold")
|
|
1269
|
+
elif font_weight != "normal":
|
|
1270
|
+
style_parts.append(f"font-weight: {font_weight}")
|
|
1271
|
+
|
|
1272
|
+
if run.is_italic:
|
|
1273
|
+
style_parts.append("font-style: italic")
|
|
1274
|
+
|
|
1275
|
+
if run.is_code:
|
|
1276
|
+
style_parts.append(f"font-family: {self.style.mono_font_family}")
|
|
1277
|
+
style_parts.append(f"fill: {self.style.code_color}")
|
|
1278
|
+
|
|
1279
|
+
if run.is_link:
|
|
1280
|
+
style_parts.append(f"fill: {self.style.link_color}")
|
|
1281
|
+
if self.style.link_underline:
|
|
1282
|
+
style_parts.append("text-decoration: underline")
|
|
1283
|
+
|
|
1284
|
+
# Create tspan element
|
|
1285
|
+
style_attr = f' style="{"; ".join(style_parts)}"' if style_parts else ""
|
|
1286
|
+
|
|
1287
|
+
if run.is_link and run.url:
|
|
1288
|
+
# Wrap link text in an anchor
|
|
1289
|
+
tspan_parts.append(
|
|
1290
|
+
f'<a href="{escape_svg_text(run.url)}">'
|
|
1291
|
+
f"<tspan{style_attr}>{escaped}</tspan></a>"
|
|
1292
|
+
)
|
|
1293
|
+
elif style_parts:
|
|
1294
|
+
tspan_parts.append(f"<tspan{style_attr}>{escaped}</tspan>")
|
|
1295
|
+
else:
|
|
1296
|
+
# Plain text without tspan wrapper
|
|
1297
|
+
tspan_parts.append(escaped)
|
|
1298
|
+
|
|
1299
|
+
# Build the complete text element
|
|
1300
|
+
text_content = "".join(tspan_parts)
|
|
1301
|
+
text_element = (
|
|
1302
|
+
f' <text x="{format_number(text_x)}" y="{format_number(current_y)}" '
|
|
1303
|
+
f'font-size="{format_number(font_size)}" class="{css_class}" '
|
|
1304
|
+
f'text-anchor="{text_anchor}">'
|
|
1305
|
+
f"{text_content}</text>"
|
|
1306
|
+
)
|
|
1307
|
+
elements.append(text_element)
|
|
1308
|
+
|
|
1309
|
+
current_y += line_height
|
|
1310
|
+
|
|
1311
|
+
total_height = len(lines) * line_height
|
|
1312
|
+
return elements, total_height
|
|
1313
|
+
|
|
1314
|
+
def _build_text_runs(
|
|
1315
|
+
self,
|
|
1316
|
+
spans: Sequence[Span],
|
|
1317
|
+
font_size: float,
|
|
1318
|
+
) -> List[TextRun]:
|
|
1319
|
+
"""Convert spans to text runs with computed styles."""
|
|
1320
|
+
runs: List[TextRun] = []
|
|
1321
|
+
|
|
1322
|
+
for span in spans:
|
|
1323
|
+
is_bold = span.span_type in (SpanType.BOLD, SpanType.BOLD_ITALIC)
|
|
1324
|
+
is_italic = span.span_type in (SpanType.ITALIC, SpanType.BOLD_ITALIC)
|
|
1325
|
+
is_code = span.span_type == SpanType.CODE
|
|
1326
|
+
is_link = span.span_type == SpanType.LINK
|
|
1327
|
+
is_image = span.span_type == SpanType.IMAGE
|
|
1328
|
+
|
|
1329
|
+
runs.append(
|
|
1330
|
+
TextRun(
|
|
1331
|
+
text=span.text,
|
|
1332
|
+
is_bold=is_bold,
|
|
1333
|
+
is_italic=is_italic,
|
|
1334
|
+
is_code=is_code,
|
|
1335
|
+
is_link=is_link,
|
|
1336
|
+
is_image=is_image,
|
|
1337
|
+
url=span.url,
|
|
1338
|
+
)
|
|
1339
|
+
)
|
|
1340
|
+
|
|
1341
|
+
return runs
|
|
1342
|
+
|
|
1343
|
+
def _wrap_runs(
|
|
1344
|
+
self,
|
|
1345
|
+
runs: List[TextRun],
|
|
1346
|
+
max_width: float,
|
|
1347
|
+
font_size: float,
|
|
1348
|
+
) -> List[List[TextRun]]:
|
|
1349
|
+
"""Wrap text runs to fit within max_width."""
|
|
1350
|
+
if not runs:
|
|
1351
|
+
return []
|
|
1352
|
+
|
|
1353
|
+
pieces: List[WrapPiece] = []
|
|
1354
|
+
separator = ""
|
|
1355
|
+
for run in runs:
|
|
1356
|
+
is_bold = run.is_bold
|
|
1357
|
+
is_italic = run.is_italic
|
|
1358
|
+
is_mono = run.is_code
|
|
1359
|
+
|
|
1360
|
+
def measure(
|
|
1361
|
+
text: str,
|
|
1362
|
+
*,
|
|
1363
|
+
_is_bold: bool = is_bold,
|
|
1364
|
+
_is_italic: bool = is_italic,
|
|
1365
|
+
_is_mono: bool = is_mono,
|
|
1366
|
+
) -> float:
|
|
1367
|
+
return self._measure_text(
|
|
1368
|
+
text,
|
|
1369
|
+
font_size,
|
|
1370
|
+
is_bold=_is_bold,
|
|
1371
|
+
is_italic=_is_italic,
|
|
1372
|
+
is_mono=_is_mono,
|
|
1373
|
+
)
|
|
1374
|
+
|
|
1375
|
+
for token in re.findall(r"\S+|\s+", run.text):
|
|
1376
|
+
if token.isspace():
|
|
1377
|
+
separator = " "
|
|
1378
|
+
continue
|
|
1379
|
+
separator_width = measure(separator) if separator else 0.0
|
|
1380
|
+
for chunk_index, chunk in enumerate(
|
|
1381
|
+
split_token_precise(token, max_width, measure)
|
|
1382
|
+
):
|
|
1383
|
+
pieces.append(
|
|
1384
|
+
WrapPiece(
|
|
1385
|
+
text=chunk,
|
|
1386
|
+
width=measure(chunk),
|
|
1387
|
+
separator=separator if chunk_index == 0 else "",
|
|
1388
|
+
separator_width=(
|
|
1389
|
+
separator_width if chunk_index == 0 else 0.0
|
|
1390
|
+
),
|
|
1391
|
+
meta=run,
|
|
1392
|
+
)
|
|
1393
|
+
)
|
|
1394
|
+
separator = ""
|
|
1395
|
+
|
|
1396
|
+
wrapped = wrap_measured_pieces(pieces, max_width)
|
|
1397
|
+
lines: List[List[TextRun]] = []
|
|
1398
|
+
for line in wrapped:
|
|
1399
|
+
line_runs: List[TextRun] = []
|
|
1400
|
+
for index, piece in enumerate(line):
|
|
1401
|
+
run = piece.meta
|
|
1402
|
+
assert isinstance(run, TextRun)
|
|
1403
|
+
prefix = piece.separator if index > 0 else ""
|
|
1404
|
+
text = f"{prefix}{piece.text}"
|
|
1405
|
+
if line_runs and line_runs[-1].same_style(run):
|
|
1406
|
+
line_runs[-1] = line_runs[-1].append(text)
|
|
1407
|
+
else:
|
|
1408
|
+
line_runs.append(run.with_text(text))
|
|
1409
|
+
if line_runs:
|
|
1410
|
+
lines.append(line_runs)
|
|
1411
|
+
return lines
|
|
1412
|
+
|
|
1413
|
+
|
|
1414
|
+
@dataclass
|
|
1415
|
+
class TextRun:
|
|
1416
|
+
"""A run of text with consistent styling."""
|
|
1417
|
+
|
|
1418
|
+
text: str
|
|
1419
|
+
is_bold: bool = False
|
|
1420
|
+
is_italic: bool = False
|
|
1421
|
+
is_code: bool = False
|
|
1422
|
+
is_link: bool = False
|
|
1423
|
+
is_image: bool = False
|
|
1424
|
+
url: Optional[str] = None
|
|
1425
|
+
|
|
1426
|
+
def same_style(self, other: TextRun) -> bool:
|
|
1427
|
+
"""Check if another run has the same styling."""
|
|
1428
|
+
return (
|
|
1429
|
+
self.is_bold == other.is_bold
|
|
1430
|
+
and self.is_italic == other.is_italic
|
|
1431
|
+
and self.is_code == other.is_code
|
|
1432
|
+
and self.is_link == other.is_link
|
|
1433
|
+
and self.url == other.url
|
|
1434
|
+
)
|
|
1435
|
+
|
|
1436
|
+
def with_text(self, text: str) -> TextRun:
|
|
1437
|
+
"""Create a copy with different text."""
|
|
1438
|
+
return TextRun(
|
|
1439
|
+
text=text,
|
|
1440
|
+
is_bold=self.is_bold,
|
|
1441
|
+
is_italic=self.is_italic,
|
|
1442
|
+
is_code=self.is_code,
|
|
1443
|
+
is_link=self.is_link,
|
|
1444
|
+
is_image=self.is_image,
|
|
1445
|
+
url=self.url,
|
|
1446
|
+
)
|
|
1447
|
+
|
|
1448
|
+
def append(self, text: str) -> TextRun:
|
|
1449
|
+
"""Create a copy with appended text."""
|
|
1450
|
+
return self.with_text(self.text + text)
|
|
1451
|
+
|
|
1452
|
+
|
|
1453
|
+
# Convenience functions
|
|
1454
|
+
|
|
1455
|
+
|
|
1456
|
+
def render(
|
|
1457
|
+
markdown: str,
|
|
1458
|
+
width: float = 400,
|
|
1459
|
+
padding: float = 20,
|
|
1460
|
+
style: Optional[Style] = None,
|
|
1461
|
+
allow_raw_html: bool = False,
|
|
1462
|
+
font_path: str | None = None,
|
|
1463
|
+
mono_font_path: str | None = None,
|
|
1464
|
+
) -> str:
|
|
1465
|
+
"""
|
|
1466
|
+
Render Markdown text to SVG.
|
|
1467
|
+
|
|
1468
|
+
This is the main entry point for the library.
|
|
1469
|
+
|
|
1470
|
+
Args:
|
|
1471
|
+
markdown: Markdown text to render.
|
|
1472
|
+
width: Width of the SVG in pixels.
|
|
1473
|
+
padding: Padding inside the SVG.
|
|
1474
|
+
style: Style configuration. Uses default if None.
|
|
1475
|
+
allow_raw_html: If True, render raw HTML blocks via foreignObject.
|
|
1476
|
+
font_path: Path to TTF/OTF font for precise text measurement.
|
|
1477
|
+
mono_font_path: Path to monospace TTF/OTF font for code measurement.
|
|
1478
|
+
|
|
1479
|
+
Returns:
|
|
1480
|
+
SVG string.
|
|
1481
|
+
|
|
1482
|
+
Example:
|
|
1483
|
+
>>> svg = render("# Hello World\\n\\nThis is **bold** text.")
|
|
1484
|
+
>>> with open("output.svg", "w") as f:
|
|
1485
|
+
... f.write(svg)
|
|
1486
|
+
"""
|
|
1487
|
+
from .parser import parse
|
|
1488
|
+
|
|
1489
|
+
blocks = parse(markdown)
|
|
1490
|
+
renderer = SVGRenderer(
|
|
1491
|
+
style=style,
|
|
1492
|
+
allow_raw_html=allow_raw_html,
|
|
1493
|
+
font_path=font_path,
|
|
1494
|
+
mono_font_path=mono_font_path,
|
|
1495
|
+
)
|
|
1496
|
+
return renderer.render(blocks, width=width, padding=padding)
|
|
1497
|
+
|
|
1498
|
+
|
|
1499
|
+
def render_blocks(
|
|
1500
|
+
blocks: Document,
|
|
1501
|
+
width: float = 400,
|
|
1502
|
+
padding: float = 20,
|
|
1503
|
+
style: Optional[Style] = None,
|
|
1504
|
+
allow_raw_html: bool = False,
|
|
1505
|
+
font_path: str | None = None,
|
|
1506
|
+
mono_font_path: str | None = None,
|
|
1507
|
+
) -> str:
|
|
1508
|
+
"""
|
|
1509
|
+
Render pre-parsed blocks to SVG.
|
|
1510
|
+
|
|
1511
|
+
Args:
|
|
1512
|
+
blocks: Document AST to render.
|
|
1513
|
+
width: Width of the SVG in pixels.
|
|
1514
|
+
padding: Padding inside the SVG.
|
|
1515
|
+
style: Style configuration.
|
|
1516
|
+
allow_raw_html: If True, render raw HTML blocks via foreignObject.
|
|
1517
|
+
font_path: Path to TTF/OTF font for precise text measurement.
|
|
1518
|
+
mono_font_path: Path to monospace TTF/OTF font for code measurement.
|
|
1519
|
+
|
|
1520
|
+
Returns:
|
|
1521
|
+
SVG string.
|
|
1522
|
+
"""
|
|
1523
|
+
renderer = SVGRenderer(
|
|
1524
|
+
style=style,
|
|
1525
|
+
allow_raw_html=allow_raw_html,
|
|
1526
|
+
font_path=font_path,
|
|
1527
|
+
mono_font_path=mono_font_path,
|
|
1528
|
+
)
|
|
1529
|
+
return renderer.render(blocks, width=width, padding=padding)
|
|
1530
|
+
|
|
1531
|
+
|
|
1532
|
+
def measure(
|
|
1533
|
+
markdown: str,
|
|
1534
|
+
width: float = 400,
|
|
1535
|
+
padding: float = 20,
|
|
1536
|
+
style: Optional[Style] = None,
|
|
1537
|
+
font_path: str | None = None,
|
|
1538
|
+
mono_font_path: str | None = None,
|
|
1539
|
+
) -> Size:
|
|
1540
|
+
"""
|
|
1541
|
+
Measure the dimensions needed to render Markdown.
|
|
1542
|
+
|
|
1543
|
+
Args:
|
|
1544
|
+
markdown: Markdown text to measure.
|
|
1545
|
+
width: Width constraint.
|
|
1546
|
+
padding: Padding inside the SVG.
|
|
1547
|
+
style: Style configuration.
|
|
1548
|
+
font_path: Path to TTF/OTF font for precise text measurement.
|
|
1549
|
+
mono_font_path: Path to monospace TTF/OTF font for code measurement.
|
|
1550
|
+
|
|
1551
|
+
Returns:
|
|
1552
|
+
Size with width and height.
|
|
1553
|
+
|
|
1554
|
+
Example:
|
|
1555
|
+
>>> size = measure("# Hello\\n\\nLong paragraph...")
|
|
1556
|
+
>>> print(f"Height needed: {size.height}px")
|
|
1557
|
+
"""
|
|
1558
|
+
from .parser import parse
|
|
1559
|
+
|
|
1560
|
+
blocks = parse(markdown)
|
|
1561
|
+
renderer = SVGRenderer(
|
|
1562
|
+
style=style,
|
|
1563
|
+
font_path=font_path,
|
|
1564
|
+
mono_font_path=mono_font_path,
|
|
1565
|
+
)
|
|
1566
|
+
return renderer.measure(blocks, width=width, padding=padding)
|
|
1567
|
+
|
|
1568
|
+
|
|
1569
|
+
def render_content(
|
|
1570
|
+
markdown: str,
|
|
1571
|
+
width: float = 400,
|
|
1572
|
+
padding: float = 20,
|
|
1573
|
+
style: Optional[Style] = None,
|
|
1574
|
+
allow_raw_html: bool = False,
|
|
1575
|
+
font_path: str | None = None,
|
|
1576
|
+
mono_font_path: str | None = None,
|
|
1577
|
+
) -> RenderResult:
|
|
1578
|
+
"""
|
|
1579
|
+
Render Markdown and return structured result with content and dimensions.
|
|
1580
|
+
|
|
1581
|
+
Unlike render(), this returns the SVG content without the <svg> wrapper,
|
|
1582
|
+
along with the actual dimensions. This is useful when composing multiple
|
|
1583
|
+
mdsvg outputs into a larger SVG without needing regex-based extraction.
|
|
1584
|
+
|
|
1585
|
+
Args:
|
|
1586
|
+
markdown: Markdown text to render.
|
|
1587
|
+
width: Width of the SVG in pixels.
|
|
1588
|
+
padding: Padding inside the SVG.
|
|
1589
|
+
style: Style configuration. Uses default if None.
|
|
1590
|
+
font_path: Path to TTF/OTF font for precise text measurement.
|
|
1591
|
+
mono_font_path: Path to monospace TTF/OTF font for code measurement.
|
|
1592
|
+
|
|
1593
|
+
Returns:
|
|
1594
|
+
RenderResult with content (SVG elements without wrapper),
|
|
1595
|
+
width, and height.
|
|
1596
|
+
|
|
1597
|
+
Example:
|
|
1598
|
+
>>> from mdsvg import render_content
|
|
1599
|
+
>>> result = render_content("# Hello World", width=400)
|
|
1600
|
+
>>> result.content # SVG elements without <svg> wrapper
|
|
1601
|
+
>>> result.width # 400.0
|
|
1602
|
+
>>> result.height # Actual rendered height
|
|
1603
|
+
>>> result.to_svg() # Full SVG with wrapper (convenience method)
|
|
1604
|
+
|
|
1605
|
+
# Embed in a larger SVG:
|
|
1606
|
+
>>> large_svg = f'''
|
|
1607
|
+
... <svg xmlns="http://www.w3.org/2000/svg" width="800" height="600">
|
|
1608
|
+
... <g transform="translate(50, 100)">
|
|
1609
|
+
... {result.content}
|
|
1610
|
+
... </g>
|
|
1611
|
+
... </svg>
|
|
1612
|
+
... '''
|
|
1613
|
+
"""
|
|
1614
|
+
from .parser import parse
|
|
1615
|
+
|
|
1616
|
+
blocks = parse(markdown)
|
|
1617
|
+
renderer = SVGRenderer(
|
|
1618
|
+
style=style,
|
|
1619
|
+
allow_raw_html=allow_raw_html,
|
|
1620
|
+
font_path=font_path,
|
|
1621
|
+
mono_font_path=mono_font_path,
|
|
1622
|
+
)
|
|
1623
|
+
return renderer.render_content(blocks, width=width, padding=padding)
|