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 +5 -1
- dataface/ai/skills/analyst-runbook/SKILL.md +7 -0
- dataface/core/defaults/themes/_base.yaml +16 -5
- dataface/core/render/sizing.py +32 -5
- dataface/core/render/svg_utils.py +2 -0
- {dataface-0.3.1.dev22.dist-info → dataface-0.3.1.dev42.dist-info}/METADATA +1 -1
- {dataface-0.3.1.dev22.dist-info → dataface-0.3.1.dev42.dist-info}/RECORD +11 -11
- mdsvg/images.py +121 -1
- {dataface-0.3.1.dev22.dist-info → dataface-0.3.1.dev42.dist-info}/WHEEL +0 -0
- {dataface-0.3.1.dev22.dist-info → dataface-0.3.1.dev42.dist-info}/entry_points.txt +0 -0
- {dataface-0.3.1.dev22.dist-info → dataface-0.3.1.dev42.dist-info}/licenses/LICENSE +0 -0
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
|
-
|
|
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
|
-
#
|
|
64
|
-
#
|
|
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
|
-
#
|
|
72
|
-
#
|
|
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
|
-
|
|
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
|
dataface/core/render/sizing.py
CHANGED
|
@@ -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,
|
|
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":
|
|
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":
|
|
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(
|
|
@@ -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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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.
|
|
685
|
-
dataface-0.3.1.
|
|
686
|
-
dataface-0.3.1.
|
|
687
|
-
dataface-0.3.1.
|
|
688
|
-
dataface-0.3.1.
|
|
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,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|