dicebear-core 10.1.0__tar.gz

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 (44) hide show
  1. dicebear_core-10.1.0/.gitignore +8 -0
  2. dicebear_core-10.1.0/LICENSE +21 -0
  3. dicebear_core-10.1.0/PKG-INFO +100 -0
  4. dicebear_core-10.1.0/README.md +72 -0
  5. dicebear_core-10.1.0/pyproject.toml +65 -0
  6. dicebear_core-10.1.0/src/dicebear/__init__.py +33 -0
  7. dicebear_core-10.1.0/src/dicebear/avatar.py +52 -0
  8. dicebear_core-10.1.0/src/dicebear/errors.py +66 -0
  9. dicebear_core-10.1.0/src/dicebear/options.py +138 -0
  10. dicebear_core-10.1.0/src/dicebear/options_descriptor.py +90 -0
  11. dicebear_core-10.1.0/src/dicebear/prng/__init__.py +186 -0
  12. dicebear_core-10.1.0/src/dicebear/prng/fnv1a.py +47 -0
  13. dicebear_core-10.1.0/src/dicebear/prng/mulberry32.py +57 -0
  14. dicebear_core-10.1.0/src/dicebear/py.typed +0 -0
  15. dicebear_core-10.1.0/src/dicebear/renderer.py +487 -0
  16. dicebear_core-10.1.0/src/dicebear/resolver.py +259 -0
  17. dicebear_core-10.1.0/src/dicebear/style.py +139 -0
  18. dicebear_core-10.1.0/src/dicebear/style_def/__init__.py +27 -0
  19. dicebear_core-10.1.0/src/dicebear/style_def/canvas.py +33 -0
  20. dicebear_core-10.1.0/src/dicebear/style_def/color.py +24 -0
  21. dicebear_core-10.1.0/src/dicebear/style_def/component.py +107 -0
  22. dicebear_core-10.1.0/src/dicebear/style_def/component_translate.py +20 -0
  23. dicebear_core-10.1.0/src/dicebear/style_def/component_variant.py +26 -0
  24. dicebear_core-10.1.0/src/dicebear/style_def/element.py +42 -0
  25. dicebear_core-10.1.0/src/dicebear/style_def/meta.py +43 -0
  26. dicebear_core-10.1.0/src/dicebear/style_def/meta_creator.py +20 -0
  27. dicebear_core-10.1.0/src/dicebear/style_def/meta_license.py +24 -0
  28. dicebear_core-10.1.0/src/dicebear/style_def/meta_source.py +20 -0
  29. dicebear_core-10.1.0/src/dicebear/utils/__init__.py +11 -0
  30. dicebear_core-10.1.0/src/dicebear/utils/color.py +90 -0
  31. dicebear_core-10.1.0/src/dicebear/utils/initials.py +101 -0
  32. dicebear_core-10.1.0/src/dicebear/utils/license.py +112 -0
  33. dicebear_core-10.1.0/src/dicebear/utils/number.py +52 -0
  34. dicebear_core-10.1.0/src/dicebear/utils/xml.py +22 -0
  35. dicebear_core-10.1.0/src/dicebear/validator.py +85 -0
  36. dicebear_core-10.1.0/tests/test_avatar.py +118 -0
  37. dicebear_core-10.1.0/tests/test_options.py +197 -0
  38. dicebear_core-10.1.0/tests/test_options_descriptor.py +165 -0
  39. dicebear_core-10.1.0/tests/test_parity.py +174 -0
  40. dicebear_core-10.1.0/tests/test_prng.py +326 -0
  41. dicebear_core-10.1.0/tests/test_renderer.py +1048 -0
  42. dicebear_core-10.1.0/tests/test_resolver.py +713 -0
  43. dicebear_core-10.1.0/tests/test_style.py +398 -0
  44. dicebear_core-10.1.0/tests/utils/test_color.py +139 -0
@@ -0,0 +1,8 @@
1
+ .venv
2
+ dist
3
+ dist-build
4
+ *.egg-info
5
+ __pycache__
6
+ .pytest_cache
7
+ .mypy_cache
8
+ .ruff_cache
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Florian Körner
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,100 @@
1
+ Metadata-Version: 2.4
2
+ Name: dicebear-core
3
+ Version: 10.1.0
4
+ Summary: Unique avatars from dozens of styles — deterministic, customizable, vector-based.
5
+ Project-URL: Homepage, https://www.dicebear.com
6
+ Project-URL: Repository, https://github.com/dicebear/dicebear
7
+ Project-URL: Issues, https://github.com/dicebear/dicebear/issues
8
+ Author-email: Florian Körner <contact@florian-koerner.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: avatar,dicebear,svg
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Topic :: Multimedia :: Graphics
20
+ Requires-Python: >=3.10
21
+ Requires-Dist: dicebear-schema>=1.1.0
22
+ Requires-Dist: jsonschema>=4.0
23
+ Provides-Extra: dev
24
+ Requires-Dist: mypy>=1.8; extra == 'dev'
25
+ Requires-Dist: pytest>=8.0; extra == 'dev'
26
+ Requires-Dist: ruff>=0.6; extra == 'dev'
27
+ Description-Content-Type: text/markdown
28
+
29
+ # DiceBear Core (Python)
30
+
31
+ Deterministic, customizable, vector-based avatars — the Python port of the
32
+ DiceBear core engine. It produces **byte-identical** SVG output to the
33
+ JavaScript (`@dicebear/core`) and PHP (`dicebear/core`) implementations for the
34
+ same style definition and options.
35
+
36
+ This package contains only the rendering engine. Avatar **style definitions**
37
+ ship separately as language-agnostic JSON via
38
+ [`dicebear-styles`](https://pypi.org/project/dicebear-styles/) (or any other
39
+ source of a DiceBear style definition).
40
+
41
+ ## Installation
42
+
43
+ ```bash
44
+ pip install dicebear-core
45
+ ```
46
+
47
+ Requires Python 3.10 or newer.
48
+
49
+ ## Usage
50
+
51
+ ```python
52
+ import json
53
+ from importlib.resources import files
54
+
55
+ from dicebear import Avatar
56
+
57
+ # Load a style definition (here from the dicebear-styles package).
58
+ style = json.loads(
59
+ files("dicebear_styles").joinpath("adventurer.json").read_text("utf-8")
60
+ )
61
+
62
+ avatar = Avatar(style, {"seed": "John"})
63
+
64
+ avatar.to_string() # the SVG markup
65
+ avatar.to_data_uri() # data:image/svg+xml;charset=utf-8,...
66
+ avatar.to_json() # {"svg": ..., "options": {...resolved options...}}
67
+ ```
68
+
69
+ `Avatar` accepts either a raw style-definition dict or a `Style` instance, plus
70
+ an optional options dict:
71
+
72
+ ```python
73
+ from dicebear import Avatar, Style
74
+
75
+ style = Style(style_data)
76
+ Avatar(style, {"seed": "John", "size": 128, "backgroundColor": ["b6e3f4"]})
77
+ ```
78
+
79
+ ## Development
80
+
81
+ This package lives in the
82
+ [DiceBear monorepo](https://github.com/dicebear/dicebear) under
83
+ `src/python/core`. See `CONTRIBUTING.md` in the repository root for the full
84
+ workflow.
85
+
86
+ ```bash
87
+ cd src/python/core
88
+ pip install -e ".[dev]"
89
+ ruff check .
90
+ mypy src
91
+ pytest
92
+ ```
93
+
94
+ The decisive check is `tests/test_parity.py`, which asserts byte-identical
95
+ output against the shared fixtures in `tests/fixtures/parity/` (generated from
96
+ the JavaScript reference).
97
+
98
+ ## License
99
+
100
+ [MIT](https://github.com/dicebear/dicebear/blob/10.x/src/python/core/LICENSE)
@@ -0,0 +1,72 @@
1
+ # DiceBear Core (Python)
2
+
3
+ Deterministic, customizable, vector-based avatars — the Python port of the
4
+ DiceBear core engine. It produces **byte-identical** SVG output to the
5
+ JavaScript (`@dicebear/core`) and PHP (`dicebear/core`) implementations for the
6
+ same style definition and options.
7
+
8
+ This package contains only the rendering engine. Avatar **style definitions**
9
+ ship separately as language-agnostic JSON via
10
+ [`dicebear-styles`](https://pypi.org/project/dicebear-styles/) (or any other
11
+ source of a DiceBear style definition).
12
+
13
+ ## Installation
14
+
15
+ ```bash
16
+ pip install dicebear-core
17
+ ```
18
+
19
+ Requires Python 3.10 or newer.
20
+
21
+ ## Usage
22
+
23
+ ```python
24
+ import json
25
+ from importlib.resources import files
26
+
27
+ from dicebear import Avatar
28
+
29
+ # Load a style definition (here from the dicebear-styles package).
30
+ style = json.loads(
31
+ files("dicebear_styles").joinpath("adventurer.json").read_text("utf-8")
32
+ )
33
+
34
+ avatar = Avatar(style, {"seed": "John"})
35
+
36
+ avatar.to_string() # the SVG markup
37
+ avatar.to_data_uri() # data:image/svg+xml;charset=utf-8,...
38
+ avatar.to_json() # {"svg": ..., "options": {...resolved options...}}
39
+ ```
40
+
41
+ `Avatar` accepts either a raw style-definition dict or a `Style` instance, plus
42
+ an optional options dict:
43
+
44
+ ```python
45
+ from dicebear import Avatar, Style
46
+
47
+ style = Style(style_data)
48
+ Avatar(style, {"seed": "John", "size": 128, "backgroundColor": ["b6e3f4"]})
49
+ ```
50
+
51
+ ## Development
52
+
53
+ This package lives in the
54
+ [DiceBear monorepo](https://github.com/dicebear/dicebear) under
55
+ `src/python/core`. See `CONTRIBUTING.md` in the repository root for the full
56
+ workflow.
57
+
58
+ ```bash
59
+ cd src/python/core
60
+ pip install -e ".[dev]"
61
+ ruff check .
62
+ mypy src
63
+ pytest
64
+ ```
65
+
66
+ The decisive check is `tests/test_parity.py`, which asserts byte-identical
67
+ output against the shared fixtures in `tests/fixtures/parity/` (generated from
68
+ the JavaScript reference).
69
+
70
+ ## License
71
+
72
+ [MIT](https://github.com/dicebear/dicebear/blob/10.x/src/python/core/LICENSE)
@@ -0,0 +1,65 @@
1
+ [build-system]
2
+ # hatchling >= 1.27 is required for the PEP 639 `license = "MIT"` SPDX string.
3
+ requires = ["hatchling>=1.27"]
4
+ build-backend = "hatchling.build"
5
+
6
+ [project]
7
+ name = "dicebear-core"
8
+ version = "10.1.0"
9
+ description = "Unique avatars from dozens of styles — deterministic, customizable, vector-based."
10
+ readme = "README.md"
11
+ requires-python = ">=3.10"
12
+ license = "MIT"
13
+ license-files = ["LICENSE"]
14
+ authors = [{ name = "Florian Körner", email = "contact@florian-koerner.com" }]
15
+ keywords = ["dicebear", "avatar", "svg"]
16
+ classifiers = [
17
+ "Intended Audience :: Developers",
18
+ "Programming Language :: Python :: 3 :: Only",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Programming Language :: Python :: 3.14",
24
+ "Topic :: Multimedia :: Graphics",
25
+ ]
26
+ dependencies = ["jsonschema>=4.0", "dicebear-schema>=1.1.0"]
27
+
28
+ [project.optional-dependencies]
29
+ dev = ["pytest>=8.0", "mypy>=1.8", "ruff>=0.6"]
30
+
31
+ [project.urls]
32
+ Homepage = "https://www.dicebear.com"
33
+ Repository = "https://github.com/dicebear/dicebear"
34
+ Issues = "https://github.com/dicebear/dicebear/issues"
35
+
36
+ # The package lives under src/dicebear. The JSON Schemas are not vendored: they
37
+ # come from the `dicebear-schema` dependency and are read at runtime via
38
+ # importlib.resources (files("dicebear_schema")), mirroring how the JS/PHP ports
39
+ # pin @dicebear/schema / dicebear/schema.
40
+ [tool.hatch.build.targets.wheel]
41
+ packages = ["src/dicebear"]
42
+
43
+ [tool.hatch.build.targets.sdist]
44
+ include = ["src/dicebear", "tests", "README.md", "LICENSE", "pyproject.toml"]
45
+
46
+ [tool.ruff]
47
+ line-length = 88
48
+ target-version = "py310"
49
+ src = ["src", "tests"]
50
+
51
+ [tool.ruff.lint]
52
+ select = ["E", "F", "I", "UP", "B", "SIM"]
53
+
54
+ [tool.mypy]
55
+ python_version = "3.10"
56
+ strict = true
57
+ files = ["src"]
58
+
59
+ # jsonschema ships no type stubs; the validator wraps it behind a typed boundary.
60
+ [[tool.mypy.overrides]]
61
+ module = ["jsonschema", "jsonschema.*"]
62
+ ignore_missing_imports = true
63
+
64
+ [tool.pytest.ini_options]
65
+ testpaths = ["tests"]
@@ -0,0 +1,33 @@
1
+ """DiceBear core — deterministic, customizable, vector-based avatars.
2
+
3
+ A faithful port of ``@dicebear/core`` (JS) and ``dicebear/core`` (PHP) that
4
+ produces byte-identical SVG output for the same style and options.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from .avatar import Avatar
10
+ from .errors import (
11
+ CircularColorReferenceError,
12
+ OptionsValidationError,
13
+ StyleValidationError,
14
+ ValidationError,
15
+ )
16
+ from .options_descriptor import OptionsDescriptor
17
+ from .style import Style
18
+ from .utils.color import Color
19
+
20
+ # Public surface mirrors the JS index (Avatar, Style, Color, OptionsDescriptor)
21
+ # plus the exception types Python consumers catch. Internals — Options, Prng,
22
+ # Resolver, Renderer — remain importable from their submodules but are not
23
+ # re-exported here.
24
+ __all__ = [
25
+ "Avatar",
26
+ "CircularColorReferenceError",
27
+ "Color",
28
+ "OptionsDescriptor",
29
+ "OptionsValidationError",
30
+ "Style",
31
+ "StyleValidationError",
32
+ "ValidationError",
33
+ ]
@@ -0,0 +1,52 @@
1
+ """Top-level entry point for rendering an avatar from a style and options."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ from typing import Any
7
+ from urllib.parse import quote
8
+
9
+ from .options import Options
10
+ from .renderer import Renderer
11
+ from .resolver import Resolver
12
+ from .style import Style
13
+
14
+ # encodeURIComponent leaves A-Za-z0-9 and -_.!~*'() unescaped. Python's quote
15
+ # always keeps letters, digits, and _.-~; adding !*'() to ``safe`` reproduces
16
+ # the JS set exactly (and drops the default '/' so it is escaped like JS does).
17
+ _DATA_URI_SAFE = "!*'()"
18
+
19
+
20
+ class Avatar:
21
+ """Top-level entry point for rendering an avatar from a style and options.
22
+
23
+ Construction immediately resolves and renders the SVG; the various accessor
24
+ methods return different serializations of that result.
25
+ """
26
+
27
+ def __init__(
28
+ self, style_input: Any, options_input: dict[str, Any] | None = None
29
+ ) -> None:
30
+ style = style_input if isinstance(style_input, Style) else Style(style_input)
31
+ options = Options(options_input)
32
+ resolver = Resolver(style, options)
33
+
34
+ self._svg = Renderer(style, resolver).render()
35
+ self._resolved_options = resolver.resolved()
36
+
37
+ def __str__(self) -> str:
38
+ return self._svg
39
+
40
+ def to_string(self) -> str:
41
+ """Return the rendered SVG markup."""
42
+ return self._svg
43
+
44
+ def to_json(self) -> dict[str, Any]:
45
+ """Return ``{"svg", "options"}`` — the SVG and the resolved options."""
46
+ return {"svg": self._svg, "options": copy.deepcopy(self._resolved_options)}
47
+
48
+ def to_data_uri(self) -> str:
49
+ """Return the SVG encoded as a ``data:image/svg+xml`` URI."""
50
+ return "data:image/svg+xml;charset=utf-8," + quote(
51
+ self._svg, safe=_DATA_URI_SAFE
52
+ )
@@ -0,0 +1,66 @@
1
+ """Domain error types raised by the core."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TypedDict
6
+
7
+
8
+ class ErrorDetail(TypedDict, total=False):
9
+ """A single schema-validation failure."""
10
+
11
+ message: str
12
+ instancePath: str
13
+
14
+
15
+ class ValidationError(RuntimeError):
16
+ """Base class for schema validation errors.
17
+
18
+ Carries the prefix in the exception message and the per-field failures in
19
+ :attr:`details`.
20
+ """
21
+
22
+ def __init__(self, prefix: str, details: list[ErrorDetail]) -> None:
23
+ parts: list[str] = []
24
+
25
+ for detail in details:
26
+ segments: list[str] = []
27
+
28
+ instance_path = detail.get("instancePath", "")
29
+ if instance_path != "":
30
+ segments.append(instance_path)
31
+
32
+ message = detail.get("message", "")
33
+ if message != "":
34
+ segments.append(message)
35
+
36
+ parts.append(" ".join(segments))
37
+
38
+ super().__init__(prefix + ": " + ", ".join(parts))
39
+ self.details = details
40
+
41
+
42
+ class OptionsValidationError(ValidationError):
43
+ """Raised when avatar options fail schema validation."""
44
+
45
+ def __init__(self, details: list[ErrorDetail]) -> None:
46
+ super().__init__("Invalid options", details)
47
+
48
+
49
+ class StyleValidationError(ValidationError):
50
+ """Raised when a style definition fails schema validation."""
51
+
52
+ def __init__(self, details: list[ErrorDetail]) -> None:
53
+ super().__init__("Invalid style definition", details)
54
+
55
+
56
+ class CircularColorReferenceError(RuntimeError):
57
+ """Raised when a color references itself, directly or indirectly.
58
+
59
+ The :attr:`chain` field reproduces the resolution path.
60
+ """
61
+
62
+ def __init__(self, chain: list[str]) -> None:
63
+ path = " → ".join(chain)
64
+
65
+ super().__init__(f"Circular color reference: {path}")
66
+ self.chain = chain
@@ -0,0 +1,138 @@
1
+ """User-supplied option parsing and normalization."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ from typing import Any, cast
7
+
8
+ from .validator import OptionsValidator
9
+
10
+ Numeric = int | float
11
+ Range = dict[str, Numeric]
12
+
13
+
14
+ class Options:
15
+ """Validates raw user options and exposes them through typed accessors.
16
+
17
+ Each accessor returns the user's input in a normalized form (always a list
18
+ for options that accept either a scalar or a list, or ``None`` when the
19
+ option is not set), so consumers — chiefly :class:`Resolver` — never have to
20
+ do their own normalization.
21
+
22
+ Resolution against the style definition and the PRNG happens in
23
+ :class:`Resolver`; this class is purely about reading user input.
24
+ """
25
+
26
+ def __init__(self, data: dict[str, Any] | None = None) -> None:
27
+ data = data if data is not None else {}
28
+ OptionsValidator.validate(data)
29
+
30
+ # Deep-copy so later caller mutations cannot leak into resolution,
31
+ # mirroring the JS core's structuredClone and Style's deep copy.
32
+ self._data = copy.deepcopy(data)
33
+
34
+ def seed(self) -> str | None:
35
+ return cast("str | None", self._data.get("seed"))
36
+
37
+ def size(self) -> int | None:
38
+ return cast("int | None", self._data.get("size"))
39
+
40
+ def id_randomization(self) -> bool | None:
41
+ return cast("bool | None", self._data.get("idRandomization"))
42
+
43
+ def title(self) -> str | None:
44
+ return cast("str | None", self._data.get("title"))
45
+
46
+ def flip(self) -> list[str]:
47
+ return self._as_array(self._data.get("flip"))
48
+
49
+ def font_family(self) -> list[str]:
50
+ return self._as_array(self._data.get("fontFamily"))
51
+
52
+ def font_weight(self) -> list[Numeric]:
53
+ return self._as_array(self._data.get("fontWeight"))
54
+
55
+ def scale(self) -> Range | None:
56
+ return self._to_range(self._data.get("scale"))
57
+
58
+ def border_radius(self) -> Range | None:
59
+ return self._to_range(self._data.get("borderRadius"))
60
+
61
+ def rotate(self) -> Range | None:
62
+ return self._to_range(self._data.get("rotate"))
63
+
64
+ def translate_x(self) -> Range | None:
65
+ return self._to_range(self._data.get("translateX"))
66
+
67
+ def translate_y(self) -> Range | None:
68
+ return self._to_range(self._data.get("translateY"))
69
+
70
+ def component_variant(self, name: str) -> dict[str, Numeric] | None:
71
+ """Return the variant constraint for ``name`` as a weighted map, or
72
+ ``None`` when ``{name}Variant`` is unset.
73
+
74
+ A bare string or string list is normalized to a map weighted ``1`` each.
75
+ """
76
+ raw = self._data.get(name + "Variant")
77
+
78
+ if raw is None:
79
+ return None
80
+
81
+ if isinstance(raw, str):
82
+ return {raw: 1}
83
+
84
+ if isinstance(raw, list):
85
+ return dict.fromkeys(raw, 1)
86
+
87
+ return cast("dict[str, Numeric]", raw)
88
+
89
+ def component_probability(self, name: str) -> Numeric | None:
90
+ return cast("Numeric | None", self._data.get(name + "Probability"))
91
+
92
+ def color(self, name: str) -> list[str] | None:
93
+ """Return ``None`` (not ``[]``) when ``{name}Color`` is unset so the
94
+ resolver can fall back to the style definition's color values.
95
+ """
96
+ raw = self._data.get(name + "Color")
97
+
98
+ return None if raw is None else self._as_array(raw)
99
+
100
+ def color_fill(self, name: str) -> list[str]:
101
+ return self._as_array(self._data.get(name + "ColorFill"))
102
+
103
+ def color_angle(self, name: str) -> Range | None:
104
+ return self._to_range(self._data.get(name + "ColorAngle"))
105
+
106
+ def color_fill_stops(self, name: str) -> Range | None:
107
+ return self._to_range(self._data.get(name + "ColorFillStops"))
108
+
109
+ @staticmethod
110
+ def _as_array(value: Any) -> list[Any]:
111
+ if value is None:
112
+ return []
113
+
114
+ return value if isinstance(value, list) else [value]
115
+
116
+ @staticmethod
117
+ def _to_range(value: Any) -> Range | None:
118
+ """Normalize a range option (bare number, ``[n]``, ``[min, max]``, or
119
+ ``None``).
120
+
121
+ A bare number ``n`` — or a single-element array ``[n]`` — becomes
122
+ ``{'min': n, 'max': n}`` (a fixed value). An array's smaller/larger
123
+ element is taken as min/max. An empty array is treated as unset
124
+ (``None``), so the resolver applies the option's default.
125
+ """
126
+ if value is None:
127
+ return None
128
+
129
+ if isinstance(value, bool):
130
+ return None
131
+
132
+ if isinstance(value, (int, float)):
133
+ return {"min": value, "max": value}
134
+
135
+ if isinstance(value, list) and len(value) > 0:
136
+ return {"min": min(value), "max": max(value)}
137
+
138
+ return None
@@ -0,0 +1,90 @@
1
+ """Builds a descriptor of every option a given style accepts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ from typing import Any
7
+
8
+ from .style import Style
9
+
10
+ _ROTATE_RANGE: dict[str, Any] = {"type": "range", "min": -360, "max": 360}
11
+ _TRANSLATE_RANGE: dict[str, Any] = {"type": "range", "min": -1000, "max": 1000}
12
+
13
+
14
+ class OptionsDescriptor:
15
+ """Builds a descriptor of every option a given style accepts.
16
+
17
+ Tooling such as the editor uses the result to render form controls and
18
+ validation hints without having to introspect the style itself.
19
+ """
20
+
21
+ def __init__(self, style: Style) -> None:
22
+ self._style = style
23
+ self._descriptor: dict[str, Any] | None = None
24
+
25
+ def to_json(self) -> dict[str, Any]:
26
+ """Return the descriptor, building it lazily on first call.
27
+
28
+ Each call returns an independent deep copy so callers can mutate the
29
+ result without affecting the cached descriptor.
30
+ """
31
+ if self._descriptor is None:
32
+ self._descriptor = self._build()
33
+
34
+ return copy.deepcopy(self._descriptor)
35
+
36
+ def _build(self) -> dict[str, Any]:
37
+ """Walk the style's components and colors and assemble the field map."""
38
+ result: dict[str, Any] = {
39
+ "seed": {"type": "string"},
40
+ "size": {"type": "number", "min": 1, "max": 4096},
41
+ "idRandomization": {"type": "boolean"},
42
+ "title": {"type": "string"},
43
+ "flip": {
44
+ "type": "enum",
45
+ "values": ["none", "horizontal", "vertical", "both"],
46
+ "list": True,
47
+ },
48
+ "fontFamily": {"type": "string", "list": True},
49
+ "fontWeight": {"type": "number", "min": 1, "max": 1000, "list": True},
50
+ "scale": {"type": "range", "min": 0, "max": 10},
51
+ "borderRadius": {"type": "range", "min": 0, "max": 50},
52
+ "rotate": dict(_ROTATE_RANGE),
53
+ "translateX": dict(_TRANSLATE_RANGE),
54
+ "translateY": dict(_TRANSLATE_RANGE),
55
+ }
56
+
57
+ for name, component in self._style.components().items():
58
+ if component.extends_name() is not None:
59
+ continue
60
+
61
+ variants = sorted(component.variants().keys())
62
+
63
+ result[f"{name}Variant"] = {
64
+ "type": "enum",
65
+ "values": variants,
66
+ "list": True,
67
+ "weighted": True,
68
+ }
69
+ result[f"{name}Probability"] = {"type": "number", "min": 0, "max": 100}
70
+
71
+ colors = self._style.colors()
72
+ color_names = [*colors.keys(), "background"]
73
+
74
+ for name in color_names:
75
+ color_field: dict[str, Any] = {"type": "color", "list": True}
76
+ contrast_to = colors[name].contrast_to() if name in colors else None
77
+
78
+ if contrast_to is not None:
79
+ color_field["contrastTo"] = contrast_to
80
+
81
+ result[f"{name}Color"] = color_field
82
+ result[f"{name}ColorFill"] = {
83
+ "type": "enum",
84
+ "values": ["solid", "linear", "radial"],
85
+ "list": True,
86
+ }
87
+ result[f"{name}ColorFillStops"] = {"type": "range", "min": 2}
88
+ result[f"{name}ColorAngle"] = dict(_ROTATE_RANGE)
89
+
90
+ return result