dicebear-core 10.1.0__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.
- dicebear/__init__.py +33 -0
- dicebear/avatar.py +52 -0
- dicebear/errors.py +66 -0
- dicebear/options.py +138 -0
- dicebear/options_descriptor.py +90 -0
- dicebear/prng/__init__.py +186 -0
- dicebear/prng/fnv1a.py +47 -0
- dicebear/prng/mulberry32.py +57 -0
- dicebear/py.typed +0 -0
- dicebear/renderer.py +487 -0
- dicebear/resolver.py +259 -0
- dicebear/style.py +139 -0
- dicebear/style_def/__init__.py +27 -0
- dicebear/style_def/canvas.py +33 -0
- dicebear/style_def/color.py +24 -0
- dicebear/style_def/component.py +107 -0
- dicebear/style_def/component_translate.py +20 -0
- dicebear/style_def/component_variant.py +26 -0
- dicebear/style_def/element.py +42 -0
- dicebear/style_def/meta.py +43 -0
- dicebear/style_def/meta_creator.py +20 -0
- dicebear/style_def/meta_license.py +24 -0
- dicebear/style_def/meta_source.py +20 -0
- dicebear/utils/__init__.py +11 -0
- dicebear/utils/color.py +90 -0
- dicebear/utils/initials.py +101 -0
- dicebear/utils/license.py +112 -0
- dicebear/utils/number.py +52 -0
- dicebear/utils/xml.py +22 -0
- dicebear/validator.py +85 -0
- dicebear_core-10.1.0.dist-info/METADATA +100 -0
- dicebear_core-10.1.0.dist-info/RECORD +34 -0
- dicebear_core-10.1.0.dist-info/WHEEL +4 -0
- dicebear_core-10.1.0.dist-info/licenses/LICENSE +21 -0
dicebear/__init__.py
ADDED
|
@@ -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
|
+
]
|
dicebear/avatar.py
ADDED
|
@@ -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
|
+
)
|
dicebear/errors.py
ADDED
|
@@ -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
|
dicebear/options.py
ADDED
|
@@ -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
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Key-based pseudorandom number generator and its primitives."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import builtins
|
|
6
|
+
import math
|
|
7
|
+
from collections.abc import Callable, Sequence
|
|
8
|
+
from typing import TypeVar
|
|
9
|
+
|
|
10
|
+
from ..utils.number import Number
|
|
11
|
+
from .fnv1a import Fnv1a
|
|
12
|
+
from .mulberry32 import Mulberry32
|
|
13
|
+
|
|
14
|
+
__all__ = ["Fnv1a", "Mulberry32", "Prng"]
|
|
15
|
+
|
|
16
|
+
T = TypeVar("T")
|
|
17
|
+
|
|
18
|
+
# The bool() / float() methods below shadow the builtins for the class scope, so
|
|
19
|
+
# signature annotations that need the builtin types reference them via builtins.
|
|
20
|
+
_Float = builtins.float
|
|
21
|
+
_Bool = builtins.bool
|
|
22
|
+
|
|
23
|
+
Numeric = int | float
|
|
24
|
+
Range = dict[str, Numeric]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Prng:
|
|
28
|
+
"""Key-based pseudorandom number generator.
|
|
29
|
+
|
|
30
|
+
Each method takes a key that, combined with the seed, produces a
|
|
31
|
+
deterministic value. The same seed + key always yields the same result,
|
|
32
|
+
regardless of call order.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, seed: str) -> None:
|
|
36
|
+
self._seed = seed
|
|
37
|
+
|
|
38
|
+
def pick(self, key: str, items: Sequence[T]) -> T | None:
|
|
39
|
+
"""Pick a single item from ``items`` deterministically.
|
|
40
|
+
|
|
41
|
+
Returns ``None`` for an empty list. Duplicate values (by string
|
|
42
|
+
representation) are collapsed before picking so that input order and
|
|
43
|
+
duplication do not affect the result.
|
|
44
|
+
"""
|
|
45
|
+
if len(items) == 0:
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
if len(items) == 1:
|
|
49
|
+
return items[0]
|
|
50
|
+
|
|
51
|
+
unique = _unique_by_code_point(items)
|
|
52
|
+
|
|
53
|
+
if len(unique) == 1:
|
|
54
|
+
return unique[0]
|
|
55
|
+
|
|
56
|
+
ordered = sorted(unique, key=_code_point_key)
|
|
57
|
+
index = math.floor(self.get_value(key) * len(ordered))
|
|
58
|
+
|
|
59
|
+
return ordered[index]
|
|
60
|
+
|
|
61
|
+
def weighted_pick(self, key: str, weights: dict[str, Numeric]) -> str | None:
|
|
62
|
+
"""Pick a key from ``weights`` proportional to its weight.
|
|
63
|
+
|
|
64
|
+
When all weights are zero, falls back to an unweighted :meth:`pick`.
|
|
65
|
+
Returns ``None`` for an empty map.
|
|
66
|
+
"""
|
|
67
|
+
if len(weights) == 0:
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
keys = list(weights.keys())
|
|
71
|
+
|
|
72
|
+
if len(keys) == 1:
|
|
73
|
+
return str(keys[0])
|
|
74
|
+
|
|
75
|
+
keys = sorted(keys, key=_code_point_key)
|
|
76
|
+
|
|
77
|
+
# Sum in sorted-key order to match JS reduce-over-sorted parity — float
|
|
78
|
+
# addition is non-associative, so iterating in insertion order would
|
|
79
|
+
# diverge from JS for fractional weights.
|
|
80
|
+
total_weight: float = 0.0
|
|
81
|
+
for k in keys:
|
|
82
|
+
total_weight += weights[k]
|
|
83
|
+
|
|
84
|
+
if total_weight == 0.0:
|
|
85
|
+
return self.pick(key, [str(k) for k in keys])
|
|
86
|
+
|
|
87
|
+
threshold = self.get_value(key) * total_weight
|
|
88
|
+
cumulative: float = 0
|
|
89
|
+
|
|
90
|
+
for k in keys:
|
|
91
|
+
cumulative += weights[k]
|
|
92
|
+
|
|
93
|
+
if threshold < cumulative:
|
|
94
|
+
return str(k)
|
|
95
|
+
|
|
96
|
+
return str(keys[-1])
|
|
97
|
+
|
|
98
|
+
def bool(self, key: str, likelihood: _Float = 50) -> _Bool:
|
|
99
|
+
"""Return ``True`` with the given probability (0–100, default 50)."""
|
|
100
|
+
return self.get_value(key) * 100 < likelihood
|
|
101
|
+
|
|
102
|
+
def float(self, key: str, range: Range) -> _Float:
|
|
103
|
+
"""Return a deterministic float in ``range``, rounded to four decimals.
|
|
104
|
+
|
|
105
|
+
With ``range['step'] > 0``, the result is drawn uniformly from
|
|
106
|
+
``{ min + i*step | 0 <= i <= floor((max - min) / step) }``, so both
|
|
107
|
+
endpoints of an evenly-divisible range are equally likely. Non-positive
|
|
108
|
+
or absent step means continuous. ``min``/``max`` are sorted internally,
|
|
109
|
+
so a reversed pair is tolerated.
|
|
110
|
+
"""
|
|
111
|
+
low = min(range["min"], range["max"])
|
|
112
|
+
high = max(range["min"], range["max"])
|
|
113
|
+
step = range.get("step", 0)
|
|
114
|
+
|
|
115
|
+
if step > 0:
|
|
116
|
+
buckets = math.floor((high - low) / step) + 1
|
|
117
|
+
i = math.floor(self.get_value(key) * buckets)
|
|
118
|
+
value = low + i * step
|
|
119
|
+
else:
|
|
120
|
+
value = low + self.get_value(key) * (high - low)
|
|
121
|
+
|
|
122
|
+
return Number.round_half_up(value * 10000) / 10000
|
|
123
|
+
|
|
124
|
+
def integer(self, key: str, range: Range) -> int:
|
|
125
|
+
"""Return a deterministic integer in ``range``.
|
|
126
|
+
|
|
127
|
+
``min``/``max`` are sorted internally, so a reversed pair is tolerated.
|
|
128
|
+
``range['step']`` is accepted for symmetry with :meth:`float` but ignored.
|
|
129
|
+
"""
|
|
130
|
+
low = int(min(range["min"], range["max"]))
|
|
131
|
+
high = int(max(range["min"], range["max"]))
|
|
132
|
+
|
|
133
|
+
return math.floor(self.get_value(key) * (high - low + 1)) + low
|
|
134
|
+
|
|
135
|
+
def shuffle(self, key: str, items: Sequence[T]) -> list[T]:
|
|
136
|
+
"""Fisher-Yates shuffle with chained Mulberry32 state.
|
|
137
|
+
|
|
138
|
+
Duplicate values (by string representation) are collapsed before
|
|
139
|
+
shuffling, so a caller's slice off the front cannot accidentally produce
|
|
140
|
+
a repeated value.
|
|
141
|
+
"""
|
|
142
|
+
if len(items) <= 1:
|
|
143
|
+
return list(items)
|
|
144
|
+
|
|
145
|
+
result = sorted(_unique_by_code_point(items), key=_code_point_key)
|
|
146
|
+
prng = Mulberry32(Fnv1a.hash(self._seed + ":" + key))
|
|
147
|
+
|
|
148
|
+
for i in range(len(result) - 1, 0, -1):
|
|
149
|
+
j = math.floor(prng.next_float() * (i + 1))
|
|
150
|
+
result[i], result[j] = result[j], result[i]
|
|
151
|
+
|
|
152
|
+
return result
|
|
153
|
+
|
|
154
|
+
def get_value(self, key: str) -> _Float:
|
|
155
|
+
"""Return a single float in ``[0, 1)`` derived from ``seed:key``.
|
|
156
|
+
|
|
157
|
+
The same seed/key pair always produces the same value.
|
|
158
|
+
"""
|
|
159
|
+
return Mulberry32(Fnv1a.hash(self._seed + ":" + key)).next_float()
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _code_point_key(value: object) -> str:
|
|
163
|
+
"""Cross-language deterministic sort key.
|
|
164
|
+
|
|
165
|
+
Compare by the string representation's code points, matching JS
|
|
166
|
+
``String(a) < String(b)`` and PHP ``strcmp`` for the ASCII values sorted in
|
|
167
|
+
practice (variant names, hex colors).
|
|
168
|
+
"""
|
|
169
|
+
return str(value)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _unique_by_code_point(
|
|
173
|
+
items: Sequence[T], key_fn: Callable[[T], str] = str
|
|
174
|
+
) -> list[T]:
|
|
175
|
+
"""Deduplicate by string representation, keeping the first occurrence."""
|
|
176
|
+
seen: set[str] = set()
|
|
177
|
+
result: list[T] = []
|
|
178
|
+
|
|
179
|
+
for item in items:
|
|
180
|
+
repr_ = key_fn(item)
|
|
181
|
+
|
|
182
|
+
if repr_ not in seen:
|
|
183
|
+
seen.add(repr_)
|
|
184
|
+
result.append(item)
|
|
185
|
+
|
|
186
|
+
return result
|
dicebear/prng/fnv1a.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""FNV-1a 32-bit hash."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import struct
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Fnv1a:
|
|
9
|
+
"""FNV-1a 32-bit hash.
|
|
10
|
+
|
|
11
|
+
Offset basis: ``0x811c9dc5``, prime: ``0x01000193``.
|
|
12
|
+
|
|
13
|
+
See https://en.wikipedia.org/wiki/Fowler%E2%80%93Noll%E2%80%93Vo_hash_function
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
@staticmethod
|
|
17
|
+
def hash(value: str) -> int:
|
|
18
|
+
"""Return the unsigned 32-bit FNV-1a hash of ``value``.
|
|
19
|
+
|
|
20
|
+
The input is decomposed into UTF-16 code units before hashing so the
|
|
21
|
+
result is identical across language ports.
|
|
22
|
+
"""
|
|
23
|
+
result = 0x811C9DC5
|
|
24
|
+
|
|
25
|
+
for code in _utf16_code_units(value):
|
|
26
|
+
result = ((result ^ code) * 0x01000193) & 0xFFFFFFFF
|
|
27
|
+
|
|
28
|
+
return result
|
|
29
|
+
|
|
30
|
+
@staticmethod
|
|
31
|
+
def hex(value: str) -> str:
|
|
32
|
+
"""Return the FNV-1a hash of ``value`` as an 8-char lowercase hex string."""
|
|
33
|
+
return format(Fnv1a.hash(value), "08x")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _utf16_code_units(value: str) -> list[int]:
|
|
37
|
+
"""Convert a string to its UTF-16 code units, matching JS ``charCodeAt``.
|
|
38
|
+
|
|
39
|
+
Iterating ``value`` directly would yield code points and break on non-BMP
|
|
40
|
+
input (e.g. emoji), so encode to little-endian UTF-16 and read uint16 units.
|
|
41
|
+
"""
|
|
42
|
+
if value == "":
|
|
43
|
+
return []
|
|
44
|
+
|
|
45
|
+
encoded = value.encode("utf-16-le")
|
|
46
|
+
|
|
47
|
+
return list(struct.unpack(f"<{len(encoded) // 2}H", encoded))
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Mulberry32 PRNG."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
_UINT32_MAX_PLUS_1 = 2**32
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Mulberry32:
|
|
9
|
+
"""Mulberry32 PRNG — stateful, matching the C reference by Tommy Ettinger.
|
|
10
|
+
|
|
11
|
+
C original::
|
|
12
|
+
|
|
13
|
+
uint32_t z = (x += 0x6D2B79F5UL);
|
|
14
|
+
z = (z ^ (z >> 15)) * (z | 1UL);
|
|
15
|
+
z ^= z + (z ^ (z >> 7)) * (z | 61UL);
|
|
16
|
+
return z ^ (z >> 14);
|
|
17
|
+
|
|
18
|
+
All arithmetic is unsigned 32-bit. Python ints are arbitrary precision, so
|
|
19
|
+
every operation is masked with ``& 0xFFFFFFFF`` to emulate JS ``Math.imul``
|
|
20
|
+
/ ``>>>`` and keep parity with the JS reference.
|
|
21
|
+
|
|
22
|
+
See https://gist.github.com/tommyettinger/46a874533244883189143505d203312c
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, seed: int = 0) -> None:
|
|
26
|
+
self._state = seed & 0xFFFFFFFF
|
|
27
|
+
|
|
28
|
+
def next(self) -> int:
|
|
29
|
+
"""Advance the state and return the next unsigned 32-bit value."""
|
|
30
|
+
z = (self._state + 0x6D2B79F5) & 0xFFFFFFFF
|
|
31
|
+
self._state = z
|
|
32
|
+
|
|
33
|
+
z = _mul(z ^ (z >> 15), z | 1)
|
|
34
|
+
z ^= _add(z, _mul(z ^ (z >> 7), z | 61))
|
|
35
|
+
|
|
36
|
+
return (z ^ (z >> 14)) & 0xFFFFFFFF
|
|
37
|
+
|
|
38
|
+
def next_float(self) -> float:
|
|
39
|
+
"""Advance the state and return the next value in ``[0, 1)``."""
|
|
40
|
+
return self.next() / _UINT32_MAX_PLUS_1
|
|
41
|
+
|
|
42
|
+
def state(self) -> int:
|
|
43
|
+
"""Return the current state as a signed 32-bit value (for JS compat)."""
|
|
44
|
+
if self._state >= 0x80000000:
|
|
45
|
+
return self._state - _UINT32_MAX_PLUS_1
|
|
46
|
+
|
|
47
|
+
return self._state
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _mul(a: int, b: int) -> int:
|
|
51
|
+
"""Unsigned 32-bit multiply (low 32 bits), matching JS ``Math.imul``."""
|
|
52
|
+
return (a * b) & 0xFFFFFFFF
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _add(a: int, b: int) -> int:
|
|
56
|
+
"""Unsigned 32-bit add."""
|
|
57
|
+
return (a + b) & 0xFFFFFFFF
|
dicebear/py.typed
ADDED
|
File without changes
|