dataface 0.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (455) hide show
  1. d3_format/__init__.py +14 -0
  2. d3_format/errors.py +19 -0
  3. d3_format/format.py +551 -0
  4. d3_format/spec.py +159 -0
  5. dataface/DATAFACE_SYNTAX.md +1135 -0
  6. dataface/__init__.py +93 -0
  7. dataface/_docs_site.py +20 -0
  8. dataface/_install_hint.py +26 -0
  9. dataface/agent_api/__init__.py +79 -0
  10. dataface/agent_api/_init_templates/__init__.py +0 -0
  11. dataface/agent_api/_init_templates/agents_dft_snippet.md +26 -0
  12. dataface/agent_api/_init_templates/dataface.yml +15 -0
  13. dataface/agent_api/_init_templates/faces-dataface.yml +144 -0
  14. dataface/agent_api/_init_templates/index.md +24 -0
  15. dataface/agent_api/_paths.py +118 -0
  16. dataface/agent_api/_project_agents_md.py +43 -0
  17. dataface/agent_api/_session_store.py +486 -0
  18. dataface/agent_api/_state.py +28 -0
  19. dataface/agent_api/chat.py +221 -0
  20. dataface/agent_api/dashboards.py +257 -0
  21. dataface/agent_api/describe.py +366 -0
  22. dataface/agent_api/describe_query.py +120 -0
  23. dataface/agent_api/docs/__init__.py +25 -0
  24. dataface/agent_api/docs/_loader.py +292 -0
  25. dataface/agent_api/docs/yaml-reference.md +2757 -0
  26. dataface/agent_api/file_refs.py +118 -0
  27. dataface/agent_api/init.py +126 -0
  28. dataface/agent_api/inspect.py +128 -0
  29. dataface/agent_api/mcp_install.py +170 -0
  30. dataface/agent_api/query.py +274 -0
  31. dataface/agent_api/schema.py +658 -0
  32. dataface/agent_api/schema_search.py +284 -0
  33. dataface/agent_api/search.py +270 -0
  34. dataface/agent_api/skill_install.py +141 -0
  35. dataface/agent_api/skill_render.py +90 -0
  36. dataface/agent_api/skills.py +293 -0
  37. dataface/agent_api/surface_aliases.yaml +128 -0
  38. dataface/agent_api/validate.py +175 -0
  39. dataface/agent_api/validate_query.py +84 -0
  40. dataface/ai/__init__.py +39 -0
  41. dataface/ai/agent.py +139 -0
  42. dataface/ai/context.py +45 -0
  43. dataface/ai/events.py +62 -0
  44. dataface/ai/external_mcp.py +610 -0
  45. dataface/ai/generate_sql.py +96 -0
  46. dataface/ai/llm.py +403 -0
  47. dataface/ai/mcp/__init__.py +51 -0
  48. dataface/ai/mcp/server.py +289 -0
  49. dataface/ai/memories.py +85 -0
  50. dataface/ai/prompts.py +177 -0
  51. dataface/ai/schema_context.py +138 -0
  52. dataface/ai/skills/before-after-comparison/SKILL.md +102 -0
  53. dataface/ai/skills/before-after-comparison/examples/before-after-comparison.yml +24 -0
  54. dataface/ai/skills/dashboard-build/SKILL.md +212 -0
  55. dataface/ai/skills/dashboard-build/examples/_smoke.yml +15 -0
  56. dataface/ai/skills/dashboard-design/SKILL.md +182 -0
  57. dataface/ai/skills/dashboard-review/SKILL.md +113 -0
  58. dataface/ai/skills/dashboard-structural-review/SKILL.md +173 -0
  59. dataface/ai/skills/dashboard-visual-review/SKILL.md +139 -0
  60. dataface/ai/skills/dataface-mcp-setup/SKILL.md +177 -0
  61. dataface/ai/skills/dataface-troubleshooting/SKILL.md +225 -0
  62. dataface/ai/skills/drill-down-link/SKILL.md +112 -0
  63. dataface/ai/skills/drill-down-link/examples/drill-down-link.yml +27 -0
  64. dataface/ai/skills/faceted-small-multiples/SKILL.md +116 -0
  65. dataface/ai/skills/faceted-small-multiples/examples/faceted-small-multiples.yml +33 -0
  66. dataface/ai/skills/filter-bar-with-variables/SKILL.md +105 -0
  67. dataface/ai/skills/filter-bar-with-variables/examples/filter-bar-with-variables.yml +49 -0
  68. dataface/ai/skills/kpi-row/SKILL.md +101 -0
  69. dataface/ai/skills/kpi-row/examples/kpi-row.yml +55 -0
  70. dataface/ai/skills/report-design/SKILL.md +184 -0
  71. dataface/ai/skills/single-metric-bignum/SKILL.md +90 -0
  72. dataface/ai/skills/single-metric-bignum/examples/single-metric-bignum.yml +27 -0
  73. dataface/ai/skills/table-heavy-ops-dashboard/SKILL.md +114 -0
  74. dataface/ai/skills/table-heavy-ops-dashboard/examples/table-heavy-ops-dashboard.yml +48 -0
  75. dataface/ai/skills/time-series-trend/SKILL.md +93 -0
  76. dataface/ai/skills/time-series-trend/examples/time-series-trend.yml +26 -0
  77. dataface/ai/skills/top-n-with-detail/SKILL.md +98 -0
  78. dataface/ai/skills/top-n-with-detail/examples/top-n-with-detail.yml +45 -0
  79. dataface/ai/skills/two-by-two-grid-overview/SKILL.md +78 -0
  80. dataface/ai/skills/two-by-two-grid-overview/examples/two-by-two-grid-overview.yml +64 -0
  81. dataface/ai/tool_schemas.py +132 -0
  82. dataface/ai/tools/__init__.py +312 -0
  83. dataface/ai/yaml_utils.py +57 -0
  84. dataface/cli/__init__.py +3 -0
  85. dataface/cli/_console.py +48 -0
  86. dataface/cli/_error_format.py +83 -0
  87. dataface/cli/_extras.py +190 -0
  88. dataface/cli/_json_output.py +8 -0
  89. dataface/cli/_parsing.py +17 -0
  90. dataface/cli/_version_info.py +56 -0
  91. dataface/cli/commands/__init__.py +3 -0
  92. dataface/cli/commands/_agent_input.py +205 -0
  93. dataface/cli/commands/_agent_server.py +115 -0
  94. dataface/cli/commands/chat.py +645 -0
  95. dataface/cli/commands/describe.py +107 -0
  96. dataface/cli/commands/docs.py +131 -0
  97. dataface/cli/commands/extension.py +179 -0
  98. dataface/cli/commands/init.py +240 -0
  99. dataface/cli/commands/inspect.py +94 -0
  100. dataface/cli/commands/mcp_init.py +167 -0
  101. dataface/cli/commands/query.py +386 -0
  102. dataface/cli/commands/render.py +291 -0
  103. dataface/cli/commands/schema.py +411 -0
  104. dataface/cli/commands/search.py +49 -0
  105. dataface/cli/commands/serve.py +114 -0
  106. dataface/cli/commands/skills.py +133 -0
  107. dataface/cli/commands/skills_init.py +161 -0
  108. dataface/cli/commands/validate.py +63 -0
  109. dataface/cli/main.py +1501 -0
  110. dataface/core/__init__.py +75 -0
  111. dataface/core/compile/__init__.py +244 -0
  112. dataface/core/compile/_jinja_helpers.py +78 -0
  113. dataface/core/compile/channel.py +222 -0
  114. dataface/core/compile/chart_focus.py +101 -0
  115. dataface/core/compile/chart_resolved.py +169 -0
  116. dataface/core/compile/chart_type_detection.py +489 -0
  117. dataface/core/compile/chart_update.py +261 -0
  118. dataface/core/compile/colors.py +64 -0
  119. dataface/core/compile/compiler.py +904 -0
  120. dataface/core/compile/config.py +823 -0
  121. dataface/core/compile/custom_chart_types.py +208 -0
  122. dataface/core/compile/data_table_attachment.py +1287 -0
  123. dataface/core/compile/detect.py +110 -0
  124. dataface/core/compile/errors.py +302 -0
  125. dataface/core/compile/filter_injection.py +319 -0
  126. dataface/core/compile/introspection.py +527 -0
  127. dataface/core/compile/jinja.py +511 -0
  128. dataface/core/compile/labels_env.py +52 -0
  129. dataface/core/compile/markdown.py +154 -0
  130. dataface/core/compile/meta.py +388 -0
  131. dataface/core/compile/models/__init__.py +0 -0
  132. dataface/core/compile/models/chart/__init__.py +0 -0
  133. dataface/core/compile/models/chart/authored.py +2137 -0
  134. dataface/core/compile/models/chart/compiled.py +398 -0
  135. dataface/core/compile/models/config.py +347 -0
  136. dataface/core/compile/models/face/__init__.py +0 -0
  137. dataface/core/compile/models/face/authored.py +659 -0
  138. dataface/core/compile/models/face/compiled.py +522 -0
  139. dataface/core/compile/models/factories.py +201 -0
  140. dataface/core/compile/models/markers.py +40 -0
  141. dataface/core/compile/models/palette.py +36 -0
  142. dataface/core/compile/models/primitives.py +415 -0
  143. dataface/core/compile/models/query/__init__.py +0 -0
  144. dataface/core/compile/models/query/authored.py +246 -0
  145. dataface/core/compile/models/query/compiled.py +710 -0
  146. dataface/core/compile/models/refs.py +137 -0
  147. dataface/core/compile/models/source.py +611 -0
  148. dataface/core/compile/models/style/__init__.py +0 -0
  149. dataface/core/compile/models/style/authored.py +481 -0
  150. dataface/core/compile/models/style/compiled.py +3399 -0
  151. dataface/core/compile/models/style/merged.py +1682 -0
  152. dataface/core/compile/models/theme.py +362 -0
  153. dataface/core/compile/models/variable/__init__.py +0 -0
  154. dataface/core/compile/models/variable/authored.py +254 -0
  155. dataface/core/compile/models/vega_lite/__init__.py +0 -0
  156. dataface/core/compile/models/vega_lite/config.py +510 -0
  157. dataface/core/compile/models/vega_lite/contracts.py +171 -0
  158. dataface/core/compile/normalize_charts.py +494 -0
  159. dataface/core/compile/normalize_layout.py +1000 -0
  160. dataface/core/compile/normalize_queries.py +297 -0
  161. dataface/core/compile/normalize_variables.py +489 -0
  162. dataface/core/compile/normalizer.py +543 -0
  163. dataface/core/compile/palette.py +1100 -0
  164. dataface/core/compile/parameterized.py +658 -0
  165. dataface/core/compile/parser.py +228 -0
  166. dataface/core/compile/schema.py +20 -0
  167. dataface/core/compile/schema_renderers/__init__.py +0 -0
  168. dataface/core/compile/schema_renderers/json_schema.py +163 -0
  169. dataface/core/compile/schema_renderers/prompt.py +152 -0
  170. dataface/core/compile/schema_renderers/vscode_schema.py +301 -0
  171. dataface/core/compile/sizing.py +2126 -0
  172. dataface/core/compile/sources.py +518 -0
  173. dataface/core/compile/sql_authoring_lint.py +56 -0
  174. dataface/core/compile/style_cascade.py +471 -0
  175. dataface/core/compile/typography.py +299 -0
  176. dataface/core/compile/validator.py +301 -0
  177. dataface/core/compile/variables.py +53 -0
  178. dataface/core/compile/vega_config.py +98 -0
  179. dataface/core/compile/vega_lite/__init__.py +6 -0
  180. dataface/core/compile/vega_lite/validation.py +95 -0
  181. dataface/core/compile/yaml_error_formatter.py +838 -0
  182. dataface/core/connections.py +38 -0
  183. dataface/core/dashboard.py +358 -0
  184. dataface/core/defaults/default_config.yml +101 -0
  185. dataface/core/defaults/palettes/categorical/category-10-dark.yml +32 -0
  186. dataface/core/defaults/palettes/categorical/category-10-light.yml +43 -0
  187. dataface/core/defaults/palettes/categorical/category-10.yml +31 -0
  188. dataface/core/defaults/palettes/categorical/category-6-tonal-blue.yml +22 -0
  189. dataface/core/defaults/palettes/categorical/category-6-tonal-brown.yml +29 -0
  190. dataface/core/defaults/palettes/categorical/category-6-tonal-green.yml +20 -0
  191. dataface/core/defaults/palettes/categorical/category-6-tonal-orange.yml +21 -0
  192. dataface/core/defaults/palettes/categorical/category-6-tonal-purple.yml +20 -0
  193. dataface/core/defaults/palettes/categorical/editorial-10-dark.yml +32 -0
  194. dataface/core/defaults/palettes/categorical/editorial-10.yml +40 -0
  195. dataface/core/defaults/palettes/categorical/hero-6.yml +17 -0
  196. dataface/core/defaults/palettes/categorical/single-blue.yml +11 -0
  197. dataface/core/defaults/palettes/categorical/tableau.yml +20 -0
  198. dataface/core/defaults/palettes/data/xkcd_colors.json +3803 -0
  199. dataface/core/defaults/palettes/diverging/blue-red.yml +25 -0
  200. dataface/core/defaults/palettes/diverging/coolwarm.yml +24 -0
  201. dataface/core/defaults/palettes/diverging/crimson-green.yml +23 -0
  202. dataface/core/defaults/palettes/diverging/orange-teal.yml +23 -0
  203. dataface/core/defaults/palettes/diverging/sunset.yml +24 -0
  204. dataface/core/defaults/palettes/scaffold/dft-creams.yml +38 -0
  205. dataface/core/defaults/palettes/scaffold/dft-grays.yml +53 -0
  206. dataface/core/defaults/palettes/sequential/amber.yml +22 -0
  207. dataface/core/defaults/palettes/sequential/blue.yml +22 -0
  208. dataface/core/defaults/palettes/sequential/brown.yml +22 -0
  209. dataface/core/defaults/palettes/sequential/gray.yml +22 -0
  210. dataface/core/defaults/palettes/sequential/green.yml +22 -0
  211. dataface/core/defaults/palettes/sequential/purple.yml +22 -0
  212. dataface/core/defaults/palettes/sequential/rust.yml +22 -0
  213. dataface/core/defaults/palettes/sequential/teal.yml +22 -0
  214. dataface/core/defaults/palettes/tone/negative.yml +32 -0
  215. dataface/core/defaults/palettes/tone/positive.yml +22 -0
  216. dataface/core/defaults/palettes/tone/warning.yml +22 -0
  217. dataface/core/defaults/themes/_base.yaml +786 -0
  218. dataface/core/defaults/themes/bi.yaml +16 -0
  219. dataface/core/defaults/themes/carbong100.yaml +41 -0
  220. dataface/core/defaults/themes/cream.yaml +122 -0
  221. dataface/core/defaults/themes/dark.yaml +40 -0
  222. dataface/core/defaults/themes/diagnostics-title-angle-extreme.yaml +9 -0
  223. dataface/core/defaults/themes/diagnostics-title-baseline-extreme.yaml +9 -0
  224. dataface/core/defaults/themes/diagnostics-title-baseline.yaml +24 -0
  225. dataface/core/defaults/themes/diagnostics-title-center.yaml +8 -0
  226. dataface/core/defaults/themes/diagnostics-title-color-extreme.yaml +24 -0
  227. dataface/core/defaults/themes/diagnostics-title-font-extreme.yaml +25 -0
  228. dataface/core/defaults/themes/diagnostics-title-left.yaml +8 -0
  229. dataface/core/defaults/themes/diagnostics-title-offset-extreme.yaml +9 -0
  230. dataface/core/defaults/themes/diagnostics-title-size-extreme.yaml +24 -0
  231. dataface/core/defaults/themes/diagnostics-title-weight-extreme.yaml +24 -0
  232. dataface/core/defaults/themes/editorial.yaml +147 -0
  233. dataface/core/defaults/themes/light.yaml +30 -0
  234. dataface/core/defaults/themes/looker.yaml +17 -0
  235. dataface/core/defaults/themes/stark.yaml +134 -0
  236. dataface/core/errors/__init__.py +67 -0
  237. dataface/core/errors/codes_compile.py +56 -0
  238. dataface/core/errors/codes_execute.py +177 -0
  239. dataface/core/errors/codes_render.py +106 -0
  240. dataface/core/errors/codes_unknown.py +15 -0
  241. dataface/core/errors/hints.py +74 -0
  242. dataface/core/errors/registry.py +42 -0
  243. dataface/core/errors/structured.py +92 -0
  244. dataface/core/execute/__init__.py +91 -0
  245. dataface/core/execute/adapters/__init__.py +49 -0
  246. dataface/core/execute/adapters/adapter_registry.py +400 -0
  247. dataface/core/execute/adapters/base.py +245 -0
  248. dataface/core/execute/adapters/csv_adapter.py +239 -0
  249. dataface/core/execute/adapters/dbt_adapter.py +283 -0
  250. dataface/core/execute/adapters/dbt_adapter_factory.py +212 -0
  251. dataface/core/execute/adapters/dbt_macro_loader.py +95 -0
  252. dataface/core/execute/adapters/dbt_utils.py +150 -0
  253. dataface/core/execute/adapters/http_adapter.py +224 -0
  254. dataface/core/execute/adapters/metricflow_adapter.py +94 -0
  255. dataface/core/execute/adapters/schema_resolver_adapter.py +144 -0
  256. dataface/core/execute/adapters/sql_adapter.py +710 -0
  257. dataface/core/execute/adapters/values_adapter.py +58 -0
  258. dataface/core/execute/batch.py +744 -0
  259. dataface/core/execute/cache_backend.py +135 -0
  260. dataface/core/execute/cache_keys.py +66 -0
  261. dataface/core/execute/dbt_jinja.py +21 -0
  262. dataface/core/execute/dialects/__init__.py +121 -0
  263. dataface/core/execute/dialects/athena.py +75 -0
  264. dataface/core/execute/dialects/base.py +302 -0
  265. dataface/core/execute/dialects/bigquery.py +38 -0
  266. dataface/core/execute/dialects/databricks.py +68 -0
  267. dataface/core/execute/dialects/duckdb.py +35 -0
  268. dataface/core/execute/dialects/mysql.py +68 -0
  269. dataface/core/execute/dialects/postgres.py +39 -0
  270. dataface/core/execute/dialects/redshift.py +12 -0
  271. dataface/core/execute/dialects/snowflake.py +51 -0
  272. dataface/core/execute/dialects/sqlserver.py +92 -0
  273. dataface/core/execute/duckdb_cache.py +712 -0
  274. dataface/core/execute/duckdb_config.py +26 -0
  275. dataface/core/execute/errors.py +213 -0
  276. dataface/core/execute/executor.py +1249 -0
  277. dataface/core/execute/parallel.py +162 -0
  278. dataface/core/execute/setup_sql.py +58 -0
  279. dataface/core/execute/source_registry.py +72 -0
  280. dataface/core/execute/source_resolver.py +255 -0
  281. dataface/core/execute/sql_guard.py +387 -0
  282. dataface/core/execute/sql_literals.py +199 -0
  283. dataface/core/fonts.py +52 -0
  284. dataface/core/inspect/__init__.py +32 -0
  285. dataface/core/inspect/cache_factory.py +98 -0
  286. dataface/core/inspect/db_types.py +162 -0
  287. dataface/core/inspect/dbt_schema.py +96 -0
  288. dataface/core/inspect/defaults.yml +37 -0
  289. dataface/core/inspect/fanout_risk.py +109 -0
  290. dataface/core/inspect/manifest_utils.py +77 -0
  291. dataface/core/inspect/partials/categorical.yml +40 -0
  292. dataface/core/inspect/partials/date.yml +40 -0
  293. dataface/core/inspect/partials/numeric.yml +55 -0
  294. dataface/core/inspect/partition_types.py +38 -0
  295. dataface/core/inspect/query_validator.py +975 -0
  296. dataface/core/inspect/renderer.py +354 -0
  297. dataface/core/inspect/resolver.py +808 -0
  298. dataface/core/inspect/search.py +461 -0
  299. dataface/core/inspect/sources/__init__.py +32 -0
  300. dataface/core/inspect/sources/dbt.py +738 -0
  301. dataface/core/inspect/sources/duckdb_utils.py +66 -0
  302. dataface/core/inspect/templates/__init__.py +1 -0
  303. dataface/core/inspect/templates/categorical_column.yml +196 -0
  304. dataface/core/inspect/templates/charts.yml +109 -0
  305. dataface/core/inspect/templates/date_column.yml +248 -0
  306. dataface/core/inspect/templates/model.yml +138 -0
  307. dataface/core/inspect/templates/numeric_column.yml +261 -0
  308. dataface/core/inspect/templates/quality.yml +80 -0
  309. dataface/core/inspect/templates/string_column.yml +263 -0
  310. dataface/core/project_roots.py +165 -0
  311. dataface/core/render/__init__.py +87 -0
  312. dataface/core/render/board_links.py +176 -0
  313. dataface/core/render/chart/__init__.py +27 -0
  314. dataface/core/render/chart/arc_attached_table.py +251 -0
  315. dataface/core/render/chart/artifacts.py +16 -0
  316. dataface/core/render/chart/callout.py +225 -0
  317. dataface/core/render/chart/decisions.py +358 -0
  318. dataface/core/render/chart/geo.py +700 -0
  319. dataface/core/render/chart/kpi.py +916 -0
  320. dataface/core/render/chart/labels.py +76 -0
  321. dataface/core/render/chart/pipeline.py +818 -0
  322. dataface/core/render/chart/presentation.py +36 -0
  323. dataface/core/render/chart/profile.py +3438 -0
  324. dataface/core/render/chart/render_single.py +347 -0
  325. dataface/core/render/chart/renderers.py +193 -0
  326. dataface/core/render/chart/rendering.py +565 -0
  327. dataface/core/render/chart/serialization.py +90 -0
  328. dataface/core/render/chart/spark.py +496 -0
  329. dataface/core/render/chart/spark_bar.py +370 -0
  330. dataface/core/render/chart/spec_builders.py +154 -0
  331. dataface/core/render/chart/standard_renderer.py +2645 -0
  332. dataface/core/render/chart/table.py +2957 -0
  333. dataface/core/render/chart/table_support.py +1452 -0
  334. dataface/core/render/chart/tick_values.py +66 -0
  335. dataface/core/render/chart/time_unit_detect.py +809 -0
  336. dataface/core/render/chart/title_overflow.py +157 -0
  337. dataface/core/render/chart/type_inference.py +122 -0
  338. dataface/core/render/chart/validation.py +99 -0
  339. dataface/core/render/chart/vega_lite.py +125 -0
  340. dataface/core/render/chart/vega_lite_types.py +268 -0
  341. dataface/core/render/chart/vl_field_maps.py +346 -0
  342. dataface/core/render/chart_interactivity.py +24 -0
  343. dataface/core/render/control_registry.py +287 -0
  344. dataface/core/render/converters/__init__.py +24 -0
  345. dataface/core/render/converters/chart.py +276 -0
  346. dataface/core/render/converters/html.py +98 -0
  347. dataface/core/render/converters/pdf.py +40 -0
  348. dataface/core/render/converters/png.py +41 -0
  349. dataface/core/render/errors.py +144 -0
  350. dataface/core/render/face_api.py +160 -0
  351. dataface/core/render/faces.py +1194 -0
  352. dataface/core/render/font_measurement.py +48 -0
  353. dataface/core/render/font_support.py +197 -0
  354. dataface/core/render/fonts/DFTSansTabular-Regular.ttf +0 -0
  355. dataface/core/render/fonts/DFTSansTabular-Regular.woff2 +0 -0
  356. dataface/core/render/fonts/DFTSerifOldstyleProportional-Regular.ttf +0 -0
  357. dataface/core/render/fonts/DFTSerifOldstyleTabular-Regular.ttf +0 -0
  358. dataface/core/render/fonts/InterVariable.ttf +0 -0
  359. dataface/core/render/fonts/InterVariable.woff2 +0 -0
  360. dataface/core/render/fonts/NOTO_COLOR_EMOJI_LICENSE.txt +93 -0
  361. dataface/core/render/fonts/NOTO_EMOJI_LICENSE.txt +93 -0
  362. dataface/core/render/fonts/NotoColorEmoji-Regular.ttf +0 -0
  363. dataface/core/render/fonts/NotoColorEmoji-Regular.woff2 +0 -0
  364. dataface/core/render/fonts/NotoEmoji-Regular.ttf +0 -0
  365. dataface/core/render/fonts/NotoEmoji-Regular.woff2 +0 -0
  366. dataface/core/render/fonts/SOURCE_CODE_PRO_LICENSE.txt +93 -0
  367. dataface/core/render/fonts/SOURCE_SERIF_4_LICENSE.txt +98 -0
  368. dataface/core/render/fonts/SourceCodePro-Regular.ttf +0 -0
  369. dataface/core/render/fonts/SourceSerif4-Regular.ttf +0 -0
  370. dataface/core/render/fonts/_emoji_font_face.css +43 -0
  371. dataface/core/render/fonts/source-serif-4-variable-latin.woff2 +0 -0
  372. dataface/core/render/format_utils.py +329 -0
  373. dataface/core/render/geo_defaults.yml +28 -0
  374. dataface/core/render/json_format.py +146 -0
  375. dataface/core/render/layout_sizing.py +865 -0
  376. dataface/core/render/layouts.py +541 -0
  377. dataface/core/render/markdown_defaults.yml +16 -0
  378. dataface/core/render/missing_vars_prompt.py +79 -0
  379. dataface/core/render/placeholder.py +389 -0
  380. dataface/core/render/render_result.py +14 -0
  381. dataface/core/render/renderer.py +467 -0
  382. dataface/core/render/script_embedding.py +16 -0
  383. dataface/core/render/svg_utils.py +212 -0
  384. dataface/core/render/template_loader.py +69 -0
  385. dataface/core/render/templates/controls/_styles.css +606 -0
  386. dataface/core/render/templates/controls/checkbox.html +16 -0
  387. dataface/core/render/templates/controls/date.html +16 -0
  388. dataface/core/render/templates/controls/number.html +19 -0
  389. dataface/core/render/templates/controls/readonly.html +9 -0
  390. dataface/core/render/templates/controls/select.html +21 -0
  391. dataface/core/render/templates/controls/slider.html +22 -0
  392. dataface/core/render/templates/controls/text.html +16 -0
  393. dataface/core/render/templates/scripts/chart_interactivity.js +191 -0
  394. dataface/core/render/templates/scripts/variables.js +976 -0
  395. dataface/core/render/templates/svg/grid_pattern.svg +3 -0
  396. dataface/core/render/templates/svg/styles.css +51 -0
  397. dataface/core/render/terminal.py +311 -0
  398. dataface/core/render/terminal_charts.py +563 -0
  399. dataface/core/render/terminal_defaults.yml +2 -0
  400. dataface/core/render/terminal_layouts.py +299 -0
  401. dataface/core/render/terminal_text.py +31 -0
  402. dataface/core/render/text/__init__.py +1 -0
  403. dataface/core/render/text/case.py +113 -0
  404. dataface/core/render/text_format.py +129 -0
  405. dataface/core/render/utils.py +106 -0
  406. dataface/core/render/variable_controls.py +946 -0
  407. dataface/core/render/variable_input_refinement.py +140 -0
  408. dataface/core/render/warnings/__init__.py +15 -0
  409. dataface/core/render/warnings/bar_color_1_to_1_with_x.py +80 -0
  410. dataface/core/render/warnings/base.py +44 -0
  411. dataface/core/render/warnings/fanout_risk.py +15 -0
  412. dataface/core/render/warnings/from_query_diagnostic.py +56 -0
  413. dataface/core/render/warnings/missing_join_predicate.py +13 -0
  414. dataface/core/render/warnings/query_parse_error.py +14 -0
  415. dataface/core/render/warnings/query_returned_zero_rows.py +42 -0
  416. dataface/core/render/warnings/reaggregation.py +14 -0
  417. dataface/core/render/warnings/registry.py +45 -0
  418. dataface/core/render/warnings/suppression.py +46 -0
  419. dataface/core/render/warnings/temporal_single_point.py +63 -0
  420. dataface/core/render/warnings/unreferenced_chart.py +15 -0
  421. dataface/core/render/warnings/y_encoding_mostly_null.py +76 -0
  422. dataface/core/render/yaml_format.py +167 -0
  423. dataface/core/resolve_face.py +195 -0
  424. dataface/core/schema/__init__.py +0 -0
  425. dataface/core/schema/guidance.py +151 -0
  426. dataface/core/scoped_paths.py +59 -0
  427. dataface/core/serve/__init__.py +14 -0
  428. dataface/core/serve/bootstrap.py +39 -0
  429. dataface/core/serve/embedded.py +57 -0
  430. dataface/core/serve/port.py +129 -0
  431. dataface/core/serve/server.py +938 -0
  432. dataface/core/serve/templates/__init__.py +0 -0
  433. dataface/core/serve/templates/directory.yml +6 -0
  434. dataface/core/serve/templates/error.html.j2 +217 -0
  435. dataface/core/utils.py +121 -0
  436. dataface/core/validate.py +64 -0
  437. dataface/integrations/__init__.py +0 -0
  438. dataface/integrations/highlighting.py +351 -0
  439. dataface/integrations/markdown.py +537 -0
  440. dataface/py.typed +0 -0
  441. dataface-0.1.2.dist-info/METADATA +375 -0
  442. dataface-0.1.2.dist-info/RECORD +455 -0
  443. dataface-0.1.2.dist-info/WHEEL +4 -0
  444. dataface-0.1.2.dist-info/entry_points.txt +2 -0
  445. dataface-0.1.2.dist-info/licenses/LICENSE +202 -0
  446. mdsvg/__init__.py +168 -0
  447. mdsvg/fonts.py +656 -0
  448. mdsvg/images.py +299 -0
  449. mdsvg/parser.py +629 -0
  450. mdsvg/playground.py +284 -0
  451. mdsvg/py.typed +2 -0
  452. mdsvg/renderer.py +1623 -0
  453. mdsvg/style.py +355 -0
  454. mdsvg/types.py +200 -0
  455. mdsvg/utils.py +86 -0
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: ![alt](url){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)