dataface 0.3.1.dev22__py3-none-any.whl → 0.3.1.dev42__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.
dataface/ai/llm.py CHANGED
@@ -326,7 +326,11 @@ class OpenAIClient:
326
326
  "stream": True,
327
327
  }
328
328
  if _supports_reasoning_summaries(self.model):
329
- kwargs["reasoning"] = {"summary": "auto"}
329
+ # Effort must be explicit. Left to its default, gpt-5.x emits no
330
+ # reasoning items at all, and "summary" then has nothing to
331
+ # summarize — the UI is stuck on a generic "Thinking...". Medium is
332
+ # the lowest level that actually produces summaries.
333
+ kwargs["reasoning"] = {"effort": "medium", "summary": "auto"}
330
334
 
331
335
  logger.info("llm_stream_begin model=%s", self.model)
332
336
  started = time.monotonic()
@@ -35,6 +35,13 @@ whenever one fits — and say which you reused.{{/if_tool}}
35
35
  2. **Clarify only what blocks you.** If the metric, grain, time window, or filters
36
36
  are ambiguous *and* the choice changes the answer, ask one focused question.
37
37
  Otherwise state the assumption and proceed.
38
+ 3. **Open with the plan.** One or two sentences naming the deliverable and the
39
+ data you are about to go look at ("I'll pull ticket volume and resolution
40
+ time by priority and put them side by side"), written before you start the
41
+ work. Hosts collapse tool activity behind a single row, so without this the
42
+ reader watches a spinner with nothing to read. Write it once, then work —
43
+ this is not a play-by-play of each tool call, and it does not replace the
44
+ read in step 4.
38
45
 
39
46
  ## 2. Do the analysis
40
47
 
@@ -60,16 +60,18 @@ style:
60
60
  card_gap: 8.0
61
61
 
62
62
  font:
63
- # Body floor 11px. Anchored — referenced by style.text.font.size,
64
- # style.title.subtitle.font.size, style.variables.font.size below.
63
+ # Chrome floor 11px. Anchored — referenced by style.title.subtitle.font.size
64
+ # and style.variables.font.size below. Body prose is NOT one of them; it has
65
+ # its own size at style.text.font.size.
65
66
  size: &size_body 11
66
67
  weight: '400'
67
68
  style: normal
68
69
  decoration: none
69
70
  case: none
70
71
  emoji: monochrome
71
- # Body leading. Cascades to all text roles via Style.font InheritSlot.
72
- # Title overrides tighter (1.1) in its font patch below.
72
+ # Chrome leading. Cascades to text roles that do not declare their own.
73
+ # Body prose (style.text.font) and title both declare one - prose looser at
74
+ # 1.4 for reading, title tighter at 1.1 for display.
73
75
  line_height: 1.25
74
76
 
75
77
  border:
@@ -855,7 +857,16 @@ style:
855
857
 
856
858
  text:
857
859
  font:
858
- size: *size_body
860
+ # Body prose is sized and led independently of `style.font.*` (the 11-unit
861
+ # dashboard-chrome floor that subtitles and variable controls share).
862
+ # Sustained reading wants more than a label size; 14 matches the existing
863
+ # series-label and H3/H4 rungs rather than introducing a new value.
864
+ size: 14
865
+ # Root 1.25 is label leading — right for axis ticks and legend entries,
866
+ # too tight for prose, and tighter still while the measure is uncapped and
867
+ # lines run the full board. Title keeps its own 1.1 for the same reason in
868
+ # the other direction.
869
+ line_height: 1.4
859
870
  align: left
860
871
  # Block margin values (in line-height units).
861
872
  # paragraph_spacing = font_size × line_height × paragraph.margin_bottom
@@ -491,6 +491,7 @@ def get_compact_style(
491
491
  heading_font_weight: int | str | None = None,
492
492
  font_family: str | None = None,
493
493
  h1_size: float | None = None,
494
+ heading_margin_scale: Literal["prose", "chrome"] = "prose",
494
495
  ) -> Any:
495
496
  """Get a compact mdsvg Style with reduced heading margins (theme-derived colors).
496
497
 
@@ -501,7 +502,12 @@ def get_compact_style(
501
502
 
502
503
  return Style(
503
504
  **compact_style_kwargs(
504
- resolved_style, text_align, heading_font_weight, font_family, h1_size
505
+ resolved_style,
506
+ text_align,
507
+ heading_font_weight,
508
+ font_family,
509
+ h1_size,
510
+ heading_margin_scale,
505
511
  )
506
512
  )
507
513
 
@@ -512,6 +518,7 @@ def compact_style_kwargs(
512
518
  heading_font_weight: int | str | None = None,
513
519
  font_family: str | None = None,
514
520
  h1_size: float | None = None,
521
+ heading_margin_scale: Literal["prose", "chrome"] = "prose",
515
522
  ) -> dict[str, Any]:
516
523
  """Build the ``mdsvg.Style`` kwargs from the resolved theme — the dft→mdsvg mapper.
517
524
 
@@ -550,12 +557,31 @@ def compact_style_kwargs(
550
557
 
551
558
  _text_font_size = _rs.text.font.size
552
559
  assert _text_font_size is not None, "style.text.font.size must be configured"
560
+ # Heading margins are in line-height units, so they need a font size to resolve
561
+ # against. "prose" is right for a markdown heading inside a text block — the gap
562
+ # under it should equal one line of the prose it introduces. "chrome" is right for
563
+ # a face title, which labels a card that may hold a chart, a table, or nothing
564
+ # textual at all: its spacing must not move when prose is resized. Chart card
565
+ # titles already work this way (size varies by width tier, gap does not).
566
+ _chrome_font_size = _rs.font.size
567
+ assert _chrome_font_size is not None, "style.font.size must be configured"
568
+ _chrome_line_height = _rs.font.line_height
569
+ assert _chrome_line_height is not None, "style.font.line_height must be configured"
553
570
  _text_font_weight = _rs.text.font.weight
554
571
  assert _text_font_weight is not None, "style.text.font.weight must be configured"
555
572
  _text_line_height = _rs.text.font.line_height
556
573
  assert (
557
574
  _text_line_height is not None
558
575
  ), "style.text.font.line_height must be configured"
576
+ # One line of the chosen scale, in px. A heading margin of 1.5 means "one and a
577
+ # half lines of the text this heading belongs to", so size and leading must come
578
+ # from the same scale — mixing prose size with chrome leading (or vice versa)
579
+ # silently produces a unit that matches neither.
580
+ _heading_margin_unit = (
581
+ float(_text_font_size) * float(_text_line_height)
582
+ if heading_margin_scale == "prose"
583
+ else float(_chrome_font_size) * float(_chrome_line_height)
584
+ )
559
585
  _title_line_height = _rs.title.font.line_height
560
586
  assert (
561
587
  _title_line_height is not None
@@ -599,11 +625,9 @@ def compact_style_kwargs(
599
625
  # mdsvg ≥ 1.0 skips paragraph_spacing on heading-adjacent transitions and
600
626
  # collapses H↔H margins to max — so heading_margin_*_px are the literal
601
627
  # visible space above / below a heading.
602
- "heading_margin_top_px": float(_text_font_size)
603
- * float(_text_line_height)
628
+ "heading_margin_top_px": _heading_margin_unit
604
629
  * float(_rs.text.heading.margin_top),
605
- "heading_margin_bottom_px": float(_text_font_size)
606
- * float(_text_line_height)
630
+ "heading_margin_bottom_px": _heading_margin_unit
607
631
  * float(_rs.text.heading.margin_bottom),
608
632
  "paragraph_spacing": float(_text_font_size)
609
633
  * float(_text_line_height)
@@ -938,6 +962,9 @@ def get_title_height(
938
962
  h1_size=h1_size,
939
963
  heading_font_weight=heading_weight,
940
964
  font_family=font_family if prose else None,
965
+ # Must match render_title exactly — this is the measure half of the same
966
+ # title block; a mismatch desyncs reserved height from drawn height.
967
+ heading_margin_scale="chrome",
941
968
  ),
942
969
  font_path=font_path,
943
970
  mono_font_path=str(get_mono_font_path()),
@@ -224,6 +224,8 @@ def render_title(
224
224
  h1_size=h1_size,
225
225
  heading_font_weight=heading_weight,
226
226
  font_family=font_family,
227
+ # Title spacing is chrome: it must not grow when body prose is resized.
228
+ heading_margin_scale="chrome",
227
229
  )
228
230
  font_path = get_font_path(font_path_family)
229
231
  return render_markdown(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dataface
3
- Version: 0.3.1.dev22
3
+ Version: 0.3.1.dev42
4
4
  Summary: Dataface - dbt-native dashboard and visualization layer
5
5
  Author: Fivetran, Inc.
6
6
  License-Expression: Apache-2.0
@@ -35,7 +35,7 @@ dataface/ai/agent.py,sha256=6dmJg0rhoDQJx8jga26gdHTsr-ilQTKe2bMk_w1YFHY,13306
35
35
  dataface/ai/context.py,sha256=iTHYvPUm5HLR4vj7bBzXw5O6kU4yklPkp1fnqb_Qouc,1680
36
36
  dataface/ai/events.py,sha256=A76iTl8oj-iwNgDNystNyAJ6iP3NmWoDMYS0xC34c9M,1661
37
37
  dataface/ai/generate_sql.py,sha256=YLPaiZM41yva13SDBTHEuXyz9Ybh4ZeKBW74vdKMf0g,2291
38
- dataface/ai/llm.py,sha256=U0XXGhVHV_7w9s6o094P6ivPEqXuQjJQjM7WUyeSusU,15794
38
+ dataface/ai/llm.py,sha256=dXuAkoaH7AU94k7_-yQqw2GjyQZ-DgUOTD0Ce-2u498,16110
39
39
  dataface/ai/messages.py,sha256=o0OTnxC_rcILCcyNsWr_oGvZIPWQyzuwXVHP6WzAAF0,3040
40
40
  dataface/ai/prompts.py,sha256=-ppxAR-GMqePMtSREUBuRfoGVzpTN0DFPB3Wsoflzpg,15067
41
41
  dataface/ai/tool_schemas.py,sha256=rHfbl42qaP4O5NWzlJmYKwk4JrtxjEO3Tm6ddOr6b9g,6030
@@ -318,7 +318,7 @@ dataface/core/defaults/palettes/tone/info.yml,sha256=pdy148P5_dCs3Bl9oyYETmEL2z-
318
318
  dataface/core/defaults/palettes/tone/negative.yml,sha256=dPls8ppZRRVFg7oTjnILaskjSM3vEbP_6_RI5_vp3HI,1375
319
319
  dataface/core/defaults/palettes/tone/positive.yml,sha256=jpT23QtxzH2KtjgR8rDQgSArPM4KMfFMzGOsratlhR0,877
320
320
  dataface/core/defaults/palettes/tone/warning.yml,sha256=Cmv_c1i0Xqzqk_QWuLu9yQmtoOySazqwTxtKVX9-QdM,890
321
- dataface/core/defaults/themes/_base.yaml,sha256=OhsGjOAnByiH_6ImCH2YnztNfW026MnGFVqNpBVvDXA,32978
321
+ dataface/core/defaults/themes/_base.yaml,sha256=wFcKxYonsTzPf8XPnSDyf-gVC16yi-_cIqSDk7Tcvsk,33717
322
322
  dataface/core/defaults/themes/cream.yaml,sha256=3w1b9iGMVIZ2i0uzvNobnrjA1w5Uc-WQcxhuti2WXeY,5086
323
323
  dataface/core/defaults/themes/diagnostics-title-angle-extreme.yaml,sha256=VZxgj_MVYHlB_SJeUJKPV6MQe2e1MDj6nTKpNY0-3Lc,132
324
324
  dataface/core/defaults/themes/diagnostics-title-baseline-extreme.yaml,sha256=kt2TfoVrdKbzkYqzBzxWdOJ2albSjwemxTfehqhnyaY,141
@@ -470,9 +470,9 @@ dataface/core/render/render_result.py,sha256=0q2SQCsIGqk0FtrXBOm8GoZe-VpRQxq4Ker
470
470
  dataface/core/render/renderer.py,sha256=f-8-Ak8H-DYMjOGV0n9BLJUM9Wyhy8J4Aa-k0NLSEUI,25672
471
471
  dataface/core/render/script_embedding.py,sha256=sCx7aV4QUSczJj6mVZTZvmzX0CNwlo7s3w-cSWJk_Dw,716
472
472
  dataface/core/render/shared_y_datasets.py,sha256=BH_TgzCWdLMEVPWObG03KnH_DZSZZNCrX9izbEi97cI,1175
473
- dataface/core/render/sizing.py,sha256=Xaq-iFrgJUbodvp33THQf81c5RrCWeYR-rOYtaMZoUI,90881
473
+ dataface/core/render/sizing.py,sha256=LgSRBIWE_KfVz8Lalb4crfzM8CDeFKxuDFatWIN2pgI,92491
474
474
  dataface/core/render/stable_domain.py,sha256=eaeqXmIuKJ8nymG5tVmcIuQpsM3CJbugH0qfvwR0Xzw,17767
475
- dataface/core/render/svg_utils.py,sha256=oyFrJ9ghNfBeNDg0JnpeBgnh2HsRQqIxVcHNKLF2EtM,7906
475
+ dataface/core/render/svg_utils.py,sha256=JZuJjdt062reQSpKck3Jz3l55GueA-_UAyyW1ztp51g,8025
476
476
  dataface/core/render/template_loader.py,sha256=f9BgEDKLCkyhOn3cMeIBAIxt9xawWXN6CQ7HdsmOCQ4,2424
477
477
  dataface/core/render/terminal.py,sha256=6pzBo33QtskxRMo2MAhutnYnHy0kXvBwrYxrD3OAKcU,10432
478
478
  dataface/core/render/terminal_charts.py,sha256=s7zHhn7jDLgqtklFply9y-Nec33ZskW2HC0CmtfDWCc,20092
@@ -631,7 +631,7 @@ dataface/agent_api/_init_templates/meta.yaml,sha256=9I8jwiVKGCu-g3vmMtwNo6ZKBJLt
631
631
  dataface/ai/prompts/sql-guidance.md,sha256=2hwS-vR6qtdohBmf2IQlGduLwRqjkHZG7A6-TJ9bLsQ,1637
632
632
  dataface/ai/prompts/sql-system.md,sha256=L1F_Mh2BewpZU9x3UryUVHdWg1kZDC80Su_iD0x2xXk,299
633
633
  dataface/ai/prompts/system.md,sha256=ruqScPJ2UgYtnAviM6FFbeqaIQmogCbGCjl7gW48Wok,294
634
- dataface/ai/skills/analyst-runbook/SKILL.md,sha256=fvjr5b3ptRKOXo2mzucmcxeG6d_prEIhwsW8W7TCx9A,5089
634
+ dataface/ai/skills/analyst-runbook/SKILL.md,sha256=p_Ob8LRF28g-dWx-vZfkJPAy05_yfd0HBuogRDa83zk,5577
635
635
  dataface/ai/skills/before-after-comparison/SKILL.md,sha256=sVUPTTlU1WdDStBELhing_XQcQpzvKFco29DceFvmPU,3276
636
636
  dataface/ai/skills/before-after-comparison/examples/before-after-comparison.yml,sha256=dybhZtXI6S8d3wRIYTyQ3sNBX7GcworjRqqL2t3QsqQ,726
637
637
  dataface/ai/skills/dashboard-build/SKILL.md,sha256=roii3eKWloflz0uiIJYrNnpbX0zVRW7xQ5dxDSgZOV4,19105
@@ -673,7 +673,7 @@ d3_format/spec.py,sha256=3We0MbSav4Mm-7pvfcZ8S7hfn7hT1vacFzBti8Y3He0,4448
673
673
  mdsvg/__init__.py,sha256=AKn9ZNNSS2w-1ROtaW3f-R6zDob4jq08HdIsKd_J5ng,3597
674
674
  mdsvg/fonts.py,sha256=jOpchxyZTl8l5qsCmGz35xBGUkInrtKBSWDpMjR5XZs,20525
675
675
  mdsvg/highlight.py,sha256=QpGEJ1nbnh7dqesOyLXCIRIaF9V19WEAPz_XdZGoEoU,3858
676
- mdsvg/images.py,sha256=SEANlTUSXzitU0CArb7UPa8PvH2tOmoaXK4V_V2zeH0,8908
676
+ mdsvg/images.py,sha256=lEsNBNxIEp89ZTTb612_YbJ5i12GAAAoThNDZL-Z6Zw,13647
677
677
  mdsvg/parser.py,sha256=baXDGY1R2bOEiOOSN7kcdoUfk-aYRHFUzd0y3ljRiBw,20722
678
678
  mdsvg/playground.py,sha256=5_VtVDAvCAoHvOTUAxeEkJTZXlQG600V5SShXGzXEM0,9719
679
679
  mdsvg/py.typed,sha256=JylgQapwARdMCQ670FrdEAaUuRXogmCrwnREf9g2qyQ,64
@@ -681,8 +681,8 @@ mdsvg/renderer.py,sha256=sGALb7S5tTNfQOQ1lfnSYCo3yWi-3d2HkG9BsP4ll7k,73919
681
681
  mdsvg/style.py,sha256=A3M1Nz4gaI-UJGOJvmiRdUn4s7VhFe60MQKHcqpnyl0,18837
682
682
  mdsvg/types.py,sha256=MQlqsUP5y78D8kX0brD-82DkBvaUYdbmkq1U95TnyWA,5093
683
683
  mdsvg/utils.py,sha256=CIqFACFaJiM0A_dl-hJXYhRqH-T7ayazP62wgfWGMjw,1866
684
- dataface-0.3.1.dev22.dist-info/METADATA,sha256=nD77aZrCMn4zZHF6N7xGA3_CpKIH1Cf_0Yv1eEe52cY,11417
685
- dataface-0.3.1.dev22.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
686
- dataface-0.3.1.dev22.dist-info/entry_points.txt,sha256=imfZRsAzmIbDRHW7jqhn0nT6EbYWAyOiuHisxmfVgXs,46
687
- dataface-0.3.1.dev22.dist-info/licenses/LICENSE,sha256=Iy9gBB2gC8WGQEwHxJd4-huwUvxB6OMOoT3no6emeaQ,11345
688
- dataface-0.3.1.dev22.dist-info/RECORD,,
684
+ dataface-0.3.1.dev42.dist-info/METADATA,sha256=2tPMv98KBZKw9tzxY9cGt_k-YG1O36Fp_8tYpKwMzao,11417
685
+ dataface-0.3.1.dev42.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
686
+ dataface-0.3.1.dev42.dist-info/entry_points.txt,sha256=imfZRsAzmIbDRHW7jqhn0nT6EbYWAyOiuHisxmfVgXs,46
687
+ dataface-0.3.1.dev42.dist-info/licenses/LICENSE,sha256=Iy9gBB2gC8WGQEwHxJd4-huwUvxB6OMOoT3no6emeaQ,11345
688
+ dataface-0.3.1.dev42.dist-info/RECORD,,
mdsvg/images.py CHANGED
@@ -2,11 +2,15 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import base64
6
+ import binascii
7
+ import re
5
8
  import struct
6
9
  from dataclasses import dataclass
7
10
  from pathlib import Path
8
11
  from typing import Callable, Dict, Optional
9
- from urllib.parse import urlparse
12
+ from urllib.parse import unquote_to_bytes, urlparse
13
+ from xml.etree import ElementTree
10
14
 
11
15
  # Type alias for URL mapping functions
12
16
  ImageUrlMapper = Callable[[str], str]
@@ -47,6 +51,12 @@ def get_image_size(
47
51
  Returns:
48
52
  ImageSize with width and height, or None if dimensions couldn't be determined.
49
53
 
54
+ Raises:
55
+ ValueError: For a structurally corrupt `data:` URI (no ',' separator,
56
+ invalid base64, or an `image/svg+xml` payload that is not XML).
57
+ A payload that is merely unmeasurable returns None like any other
58
+ source.
59
+
50
60
  Example:
51
61
  >>> size = get_image_size("/path/to/image.png")
52
62
  >>> if size:
@@ -56,6 +66,11 @@ def get_image_size(
56
66
  """
57
67
  parsed = urlparse(url)
58
68
 
69
+ # Inline payload — must not reach the filesystem, where a long base64 body
70
+ # trips ENAMETOOLONG instead of reporting a missing file.
71
+ if parsed.scheme == "data":
72
+ return _get_data_uri_image_size(url)
73
+
59
74
  # Check if it's a remote URL
60
75
  if parsed.scheme in ("http", "https"):
61
76
  return _get_remote_image_size(url, timeout)
@@ -64,6 +79,111 @@ def get_image_size(
64
79
  return _get_local_image_size(url, base_path)
65
80
 
66
81
 
82
+ def _get_data_uri_image_size(url: str) -> Optional[ImageSize]:
83
+ """
84
+ Get dimensions from a `data:` URI by decoding its own payload.
85
+
86
+ Corrupt input raises: the author wrote bytes that cannot be an image, and
87
+ guessing would hide it. But a payload that is intact and simply not
88
+ measurable — a format the header parser doesn't know, or an SVG with no
89
+ intrinsic size — returns None, exactly as the local and remote branches do
90
+ for the same bytes. Measuring is best-effort here; the renderer owns the
91
+ fallback via `image_fallback_aspect_ratio`.
92
+ """
93
+ header, separator, payload = url[len("data:") :].partition(",")
94
+ if not separator:
95
+ raise ValueError("Malformed data: URI — no ',' separating header from payload")
96
+
97
+ # Media types and the base64 token are case-insensitive (RFC 2045), and a
98
+ # space after the ';' is common enough that browsers accept it.
99
+ parameters = [parameter.strip().lower() for parameter in header.split(";")]
100
+ media_type = parameters[0]
101
+
102
+ if "base64" in parameters[1:]:
103
+ # The URL spec's data: processor uses forgiving-base64, so browsers
104
+ # render an unpadded payload; b64decode would reject it as corrupt.
105
+ try:
106
+ data = base64.b64decode(payload + "=" * (-len(payload) % 4), validate=True)
107
+ except binascii.Error as e:
108
+ raise ValueError(
109
+ f"Malformed base64 payload in data:{media_type} URI: {e}"
110
+ ) from e
111
+ else:
112
+ data = unquote_to_bytes(payload)
113
+
114
+ if media_type == "image/svg+xml":
115
+ return _parse_svg_dimensions(data)
116
+ return _parse_image_dimensions(data)
117
+
118
+
119
+ # SVG lengths only have a pixel size when unitless or in px; %, em, etc. do not.
120
+ _SVG_PIXEL_LENGTH = re.compile(r"\s*([0-9]*\.?[0-9]+)(px)?\s*\Z")
121
+
122
+
123
+ def _parse_svg_dimensions(data: bytes) -> Optional[ImageSize]:
124
+ """
125
+ Parse the intrinsic size of an SVG document: width/height, else viewBox.
126
+
127
+ None when the document genuinely has no intrinsic size — `width="100%"`
128
+ with no viewBox is the common case, and the consumer is meant to supply
129
+ one. Raises only when the payload is not XML at all.
130
+ """
131
+ # Decline to measure entity-declaring payloads rather than hand them to
132
+ # expat, which only caps entity amplification from 2.6 on and this package
133
+ # supports Pythons that may link an older one. Unmeasured, not corrupt:
134
+ # such a document is often perfectly valid, so it takes the fallback.
135
+ if b"<!ENTITY" in data:
136
+ return None
137
+
138
+ try:
139
+ root = ElementTree.fromstring(data)
140
+ except ElementTree.ParseError as e:
141
+ raise ValueError(f"Malformed XML in data:image/svg+xml URI: {e}") from e
142
+
143
+ size = _svg_image_size(
144
+ _parse_svg_length(root.get("width")), _parse_svg_length(root.get("height"))
145
+ )
146
+ return size if size is not None else _parse_svg_view_box(root.get("viewBox"))
147
+
148
+
149
+ def _svg_image_size(
150
+ width: Optional[float], height: Optional[float]
151
+ ) -> Optional[ImageSize]:
152
+ """
153
+ Build an ImageSize from SVG lengths, or None if either has no pixel size.
154
+
155
+ The "no size" test has to run on the rounded value, not the parsed float:
156
+ a legal `width="0.4"` is positive but rounds to a zero-width ImageSize,
157
+ whose aspect_ratio is 0.0 and divides by zero in the renderer.
158
+ """
159
+ if width is None or height is None:
160
+ return None
161
+ size = ImageSize(width=round(width), height=round(height))
162
+ if size.width < 1 or size.height < 1:
163
+ return None
164
+ return size
165
+
166
+
167
+ def _parse_svg_length(value: Optional[str]) -> Optional[float]:
168
+ """Parse an SVG length in pixels, or None if it has no pixel size."""
169
+ if value is None:
170
+ return None
171
+ match = _SVG_PIXEL_LENGTH.match(value)
172
+ return float(match.group(1)) if match else None
173
+
174
+
175
+ def _parse_svg_view_box(value: Optional[str]) -> Optional[ImageSize]:
176
+ """Parse the width and height out of a `viewBox="min-x min-y width height"`."""
177
+ if value is None:
178
+ return None
179
+
180
+ parts = value.replace(",", " ").split()
181
+ if len(parts) != 4:
182
+ return None
183
+
184
+ return _svg_image_size(_parse_svg_length(parts[2]), _parse_svg_length(parts[3]))
185
+
186
+
67
187
  def _get_local_image_size(
68
188
  path: str,
69
189
  base_path: Optional[str] = None,