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.
- dicebear_core-10.1.0/.gitignore +8 -0
- dicebear_core-10.1.0/LICENSE +21 -0
- dicebear_core-10.1.0/PKG-INFO +100 -0
- dicebear_core-10.1.0/README.md +72 -0
- dicebear_core-10.1.0/pyproject.toml +65 -0
- dicebear_core-10.1.0/src/dicebear/__init__.py +33 -0
- dicebear_core-10.1.0/src/dicebear/avatar.py +52 -0
- dicebear_core-10.1.0/src/dicebear/errors.py +66 -0
- dicebear_core-10.1.0/src/dicebear/options.py +138 -0
- dicebear_core-10.1.0/src/dicebear/options_descriptor.py +90 -0
- dicebear_core-10.1.0/src/dicebear/prng/__init__.py +186 -0
- dicebear_core-10.1.0/src/dicebear/prng/fnv1a.py +47 -0
- dicebear_core-10.1.0/src/dicebear/prng/mulberry32.py +57 -0
- dicebear_core-10.1.0/src/dicebear/py.typed +0 -0
- dicebear_core-10.1.0/src/dicebear/renderer.py +487 -0
- dicebear_core-10.1.0/src/dicebear/resolver.py +259 -0
- dicebear_core-10.1.0/src/dicebear/style.py +139 -0
- dicebear_core-10.1.0/src/dicebear/style_def/__init__.py +27 -0
- dicebear_core-10.1.0/src/dicebear/style_def/canvas.py +33 -0
- dicebear_core-10.1.0/src/dicebear/style_def/color.py +24 -0
- dicebear_core-10.1.0/src/dicebear/style_def/component.py +107 -0
- dicebear_core-10.1.0/src/dicebear/style_def/component_translate.py +20 -0
- dicebear_core-10.1.0/src/dicebear/style_def/component_variant.py +26 -0
- dicebear_core-10.1.0/src/dicebear/style_def/element.py +42 -0
- dicebear_core-10.1.0/src/dicebear/style_def/meta.py +43 -0
- dicebear_core-10.1.0/src/dicebear/style_def/meta_creator.py +20 -0
- dicebear_core-10.1.0/src/dicebear/style_def/meta_license.py +24 -0
- dicebear_core-10.1.0/src/dicebear/style_def/meta_source.py +20 -0
- dicebear_core-10.1.0/src/dicebear/utils/__init__.py +11 -0
- dicebear_core-10.1.0/src/dicebear/utils/color.py +90 -0
- dicebear_core-10.1.0/src/dicebear/utils/initials.py +101 -0
- dicebear_core-10.1.0/src/dicebear/utils/license.py +112 -0
- dicebear_core-10.1.0/src/dicebear/utils/number.py +52 -0
- dicebear_core-10.1.0/src/dicebear/utils/xml.py +22 -0
- dicebear_core-10.1.0/src/dicebear/validator.py +85 -0
- dicebear_core-10.1.0/tests/test_avatar.py +118 -0
- dicebear_core-10.1.0/tests/test_options.py +197 -0
- dicebear_core-10.1.0/tests/test_options_descriptor.py +165 -0
- dicebear_core-10.1.0/tests/test_parity.py +174 -0
- dicebear_core-10.1.0/tests/test_prng.py +326 -0
- dicebear_core-10.1.0/tests/test_renderer.py +1048 -0
- dicebear_core-10.1.0/tests/test_resolver.py +713 -0
- dicebear_core-10.1.0/tests/test_style.py +398 -0
- dicebear_core-10.1.0/tests/utils/test_color.py +139 -0
|
@@ -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
|