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