From 81140bc917945992a558d6949347991776f45ef0 Mon Sep 17 00:00:00 2001 From: "A.Shpak" Date: Mon, 24 Mar 2025 23:04:37 +0300 Subject: [PATCH 1/4] feat: add examples and tests, update configuration --- .github/workflows/tests.yml | 2 +- README.md | 78 ++++++------- example.png | Bin 944 -> 0 bytes examples/basic.png | Bin 0 -> 1035 bytes examples/colored.png | Bin 0 -> 1041 bytes examples/large.png | Bin 0 -> 1180 bytes examples/small.png | Bin 0 -> 1016 bytes examples/transparent.png | Bin 0 -> 1217 bytes justfile | 63 ++++++++++- pyproject.toml | 1 + test_userpic.py | 111 +++++++++++++++++++ userpic.py | 213 ++++++++++++++++++++++++++++++------ uv.lock | 1 + 13 files changed, 399 insertions(+), 70 deletions(-) delete mode 100644 example.png create mode 100644 examples/basic.png create mode 100644 examples/colored.png create mode 100644 examples/large.png create mode 100644 examples/small.png create mode 100644 examples/transparent.png create mode 100644 test_userpic.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 145acd0..0698e4e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -12,7 +12,7 @@ jobs: matrix: just-trigger: - "lint" -# - "tests" + - "tests" python-version: - "3.10" - "3.11" diff --git a/README.md b/README.md index 920a087..a7f92bd 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Github-like Userpic (Avatar) Generator +# tiny-userpic Oversimplified Github-like userpic (avatar) generator. @@ -6,58 +6,62 @@ Oversimplified Github-like userpic (avatar) generator. [![PyPI](https://img.shields.io/pypi/v/tiny-userpic.svg)](https://pypi.python.org/pypi/tiny-userpic) [![PyPI](https://img.shields.io/pypi/dm/tiny-userpic.svg)](https://pypi.python.org/pypi/tiny-userpic) -## Installation +## Features + +- Generate unique avatars from text input (email, username, etc.) +- Create both PIL Image and SVG outputs +- Customizable size, colors, and padding +- Deterministic output (same input always produces the same avatar) -Get started by installing the library via pip: +## Installation ```bash pip install tiny-userpic ``` -## Create a PIL Image +## Usage ```python -from PIL.Image import Image - -from userpic import make_userpic_image +from tiny_userpic import make_userpic_image_from_string, make_userpic_svg_from_string + +# Generate avatar from email +email = "user@example.com" + +# As PNG image +image = make_userpic_image_from_string( + text=email, + size=(7, 5), # Pattern size (width, height) + image_size=(300, 300), # Output image size + background="white", # Background color + foreground="black" # Foreground color +) +image.save("avatar.png") -# Generate a PIL Image object -image: Image = make_userpic_image( +# As SVG +svg = make_userpic_svg_from_string( + text=email, size=(7, 5), - padding=(20, 10), - mode='RGB', image_size=(300, 300), - background='white', - foreground='black', + background="white", + foreground="black" ) - -# save as JPEG file -with open('output.jpeg', 'wb') as fp: - image.save(fp) +with open("avatar.svg", "w") as f: + f.write(svg) ``` -## Create SVG Data +## Examples -```python +### Basic +![Basic example](examples/basic.png) -from userpic import make_userpic_svg - -# Generate SVG string data -image: str = make_userpic_svg( - size=(7, 5), - padding=(20, 10), - image_size=(300, 300), - background='white', - foreground='black', -) - -# save as SVG file -with open('output.svg', 'w') as fp: - fp.write(image) -``` +### Colored +![Colored example](examples/colored.png) -## Example Output +### Transparent +![Transparent example](examples/transparent.png) -Check out the awesome userpic you can generate: +### Small +![Small example](examples/small.png) -![Awesome generated userpic!](example.png) +### Large +![Large example](examples/large.png) diff --git a/example.png b/example.png deleted file mode 100644 index 1671a526b25310e979dc7cefa9fe93915169cc78..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 944 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K5893O0R7}x|GzJFda!(h>kcv5PZ#i~JGx9JT z)Hw5h|C&3+w+=Vlc)5!GgjBM*QAJt#e+JL`_2LZgI2c+uu_4>*+{yTTKyZ<|JX3icx-|+dLx9=PT0S1XQaIlqquWFq# wb7$N`;TQ%1wLyc-cJIFK&0vnpP2c#{+ExEw2wA!fm=hU1UHx3vIVCg!0Md-kiU0rr diff --git a/examples/basic.png b/examples/basic.png new file mode 100644 index 0000000000000000000000000000000000000000..12011b69eb6df8a5e4d98e4158cfd2c258ec4e6d GIT binary patch literal 1035 zcmeAS@N?(olHy`uVBq!ia0y~yVAKI&4kn<;H+e}V1_tI!o-U3d6?5L+G3>kTAi&_* zfAD{OrHqx;0r!hJf#FkyXSHsdE?rpmUHrn&Sbhfkxg3gWP92>BNlGmu( zZ2tezT=@qZcV*kYc|LPq=xvs?9vL zKkl^O@2=MxgT;*?SJj&uf90?_)@=4IkpDn@`8#R#TfYlrTZxPXQeuo8wPkxgeK-?i z${zFj-`SnJpUc%~9{N2u?m1`QjO?H9_dT-W8Vqp_bVE+r_dkb?#rv50%dD0@zGZ25 zMqB+#`Q7?OwclRJ9VrzdK3$U&N~B~Rv)$3>8SH17lbTUB+Itq5M!mZ7GX|K+89ZJ6 KT-G@yGywq1l0*Ff literal 0 HcmV?d00001 diff --git a/examples/colored.png b/examples/colored.png new file mode 100644 index 0000000000000000000000000000000000000000..88f64a89119bee9d31809df97788b8f7aa93dbec GIT binary patch literal 1041 zcmeAS@N?(olHy`uVBq!ia0y~yVAKI&4kn<;H+e}V1_tIEo-U3d6?5KRJDA(-z~FjN zAdsu{g480@>yCoaPJKJwrX4n}c-HqL`?usW!(-wXey(Fbuv}R2hzp0Ipp#FF0CCKX zJLNv?yOwPKv#&qZ`fc2IySrN}goxKpidAH(eOAS=|61~Tb%75mEi*V2)tovy1(K8o zn(3R|Q*}6g?eRGO2S#$I!~B1&1_tA`bAOc1|L@-U{iWWK*BT_o16hGXL{x*~_Zlbw z|17Sa|ITTdq1)bvzH|Re*XrEP{J!h^%XLQwk5e;yK7Kj+dgJ~)&Y0WDH|OmxsC(Nt zyYg3Z?V+FlGk__xwL-RaaHRp}8WVq#Q>6V4R%823bK?I4vpR#PtDnm{r-UW|?X*oi literal 0 HcmV?d00001 diff --git a/examples/large.png b/examples/large.png new file mode 100644 index 0000000000000000000000000000000000000000..884a100e888069611dae82e0460d85649019cf0f GIT binary patch literal 1180 zcmeAS@N?(olHy`uVBq!ia0y~yVAKI&4kn<;H+e}V1_qX7PZ!6KiaBrZ94x%;Aj0Z+ zX{o}T9_NBBTnA5Dbbmc4`C)NZS>wCiDidx97F508dX4+VpB4HH^ZZ%_gcXmta2N_Y z`4Gq4_lmpY@87@KKcwsG{%7hZ}gwEDMYLRD3)!!cp-S-B$nwJ+gI6eDylhk zbP6OXwanlkiD?!UJ$v`TxZA4Ev1>2eA9{IR-hGC&*&V;9v5(l~AC|_Zp6K`0&Z*i-C)vrx*OHFJ8Zx)btDv$u~b1F2C$v z7x(qrW!`-UE>9Ia$3LI>NWFXiwMyAm5>q2EEPKt;eD-YUl(~ ze7BZ(r(^pk`->G9@!1V2qFp#PR^*=b5}$7IOM%Sn2DJCh-fOGYGs>NvOln5EVZX_u W*);#eRBd3X!{F)a=d#Wzp$Py@m!9(g literal 0 HcmV?d00001 diff --git a/examples/small.png b/examples/small.png new file mode 100644 index 0000000000000000000000000000000000000000..d70697e9cbb848256939d03898738bf3dbf72db3 GIT binary patch literal 1016 zcmeAS@N?(olHy`uVBq!ia0y~yVAKI&4kn<;H+e}V1_tK+o-U3d6?5KR3(RVEU~s({ z^+)Xc<6oQQ3z;wTP7zyk(sx$zKXy6h>xELuH4JmlS266d7IgAy5fD~9;=*A_9CP;n zq8~ffil1$mzCGvn&gM9$j!uCjrIr~SifV&^X?C6YUB=CCRfoR!-H!7A(OF$>pihG5 zNC^mX)aI;X-?KRN_G?ZX!9nA!jXUKE+>ht3RX^~4nW5X>hk7DE^mT8?`R|x_KX9t64d4C>1y(f3(y$10Kn4EYcC6DAtC0~(# hu(pzu)C^I}_^MJZ__5fIOkj>?@O1TaS?83{1ORapILiP4 literal 0 HcmV?d00001 diff --git a/examples/transparent.png b/examples/transparent.png new file mode 100644 index 0000000000000000000000000000000000000000..be48615fe15997af2cdc6682c3c7d4bd8b2fa386 GIT binary patch literal 1217 zcmeAS@N?(olHy`uVBq!ia0y~yVAKI&4mO}jWo=(60|QHkr;B4q#hkZy4D*;3MI0Q3 zf8M(`Z_bvmSvieN`tvLqejWO-#YC?79J|B8>+cvD-U;a_IB#g+Iml$h!hJ%36i(0c zcZ~lINnD>;A7lSK`^~(Eb8B}Uw|o1Tn?!TSv72H&J+)Y)8J2geKDGayW#N3k-m6*jS{&LE7$uunQ#iyXC`JrM&iT8{f2JC)+gaV#WEr3Q zo#*|Zygku!YcBKsdHnFgXGy<3)vGIomFVdQ&MBb@06)rm A{r~^~ literal 0 HcmV?d00001 diff --git a/justfile b/justfile index 27124c4..9032de5 100644 --- a/justfile +++ b/justfile @@ -1,4 +1,5 @@ SOURCE_PATH := "userpic.py" +TEST_PATH := "test_userpic.py" upgrade: uv lock --upgrade @@ -8,5 +9,65 @@ lint: uv run python -m mypy --pretty {{ SOURCE_PATH }} fix: - uv run ruff format {{ SOURCE_PATH }} + uv run ruff format {{ SOURCE_PATH }} {{ TEST_PATH }} uv run ruff check --fix --unsafe-fixes {{ SOURCE_PATH }} + +tests: + uv run pytest test_userpic.py + + +# Генерируем изображения с разными параметрами +examples: + #!/usr/bin/env bash + .venv/bin/python -c ' + from userpic import make_userpic_image + # Базовое черно-белое изображение + make_userpic_image( + size=(7, 5), + mode="RGB", + image_size=(300, 300), + padding=(20, 20), + background="white", + foreground="black" + ).save("examples/basic.png") + + # Цветное изображение + make_userpic_image( + size=(7, 5), + mode="RGB", + image_size=(300, 300), + padding=(20, 20), + background="#f0f0f0", + foreground="#2ecc71" + ).save("examples/colored.png") + + # Изображение с прозрачным фоном + make_userpic_image( + size=(7, 5), + mode="RGBA", + image_size=(300, 300), + padding=(20, 20), + background=(255, 255, 255, 0), + foreground=(0, 0, 128, 255) + ).save("examples/transparent.png") + + # Изображение с большим размером паттерна + make_userpic_image( + size=(12, 12), + mode="RGB", + image_size=(300, 300), + padding=(20, 20), + background="white", + foreground="#e74c3c" + ).save("examples/large.png") + + # Изображение с маленьким размером паттерна + make_userpic_image( + size=(5, 5), + mode="RGB", + image_size=(300, 300), + padding=(20, 20), + background="white", + foreground="#f1c40f" + ).save("examples/small.png") + ' diff --git a/pyproject.toml b/pyproject.toml index 0ac8213..ae9e5e9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,7 @@ exclude = [ ] lint.ignore = [ "PLR0913", # Too many arguments to function call + "S311", # Standard pseudo-random generators are not suitable for cryptographic purposes ] lint.flake8-tidy-imports.ban-relative-imports = "all" lint.mccabe.max-complexity = 20 diff --git a/test_userpic.py b/test_userpic.py new file mode 100644 index 0000000..a629e64 --- /dev/null +++ b/test_userpic.py @@ -0,0 +1,111 @@ +from typing import Any + +import pytest +from PIL.Image import Image + +from userpic import ( + make_userpic_image, + make_userpic_image_from_string, + make_userpic_svg, + make_userpic_svg_from_string, +) + + +@pytest.fixture +def default_params() -> dict[str, Any]: + return { + 'size': (7, 5), + 'image_size': (300, 300), + 'padding': (20, 20), + 'mode': 'RGB', + 'background': 'white', + 'foreground': 'black', + } + + +def test_make_userpic_image(default_params: dict[str, Any]) -> None: + image = make_userpic_image(**default_params) + assert isinstance(image, Image) + assert image.size == default_params['image_size'] + assert image.mode == default_params['mode'] + + +def test_make_userpic_svg(default_params: dict[str, Any]) -> None: + params = {k: v for k, v in default_params.items() if k != 'mode'} + svg = make_userpic_svg(**params) + assert isinstance(svg, str) + assert ' None: + seed = 42 + image1 = make_userpic_image(**default_params, seed=seed) + image2 = make_userpic_image(**default_params, seed=seed) + assert image1.tobytes() == image2.tobytes() + + +def test_different_seeds(default_params: dict[str, Any]) -> None: + image1 = make_userpic_image(**default_params, seed=1) + image2 = make_userpic_image(**default_params, seed=2) + assert image1.tobytes() != image2.tobytes() + + +def test_string_based_image_consistency(default_params: dict[str, Any]) -> None: + text = 'test@example.com' + params = {k: v for k, v in default_params.items() if k not in ['seed']} + image1 = make_userpic_image_from_string(text=text, **params) + image2 = make_userpic_image_from_string(text=text, **params) + assert image1.tobytes() == image2.tobytes() + + +def test_string_based_svg_consistency(default_params: dict[str, Any]) -> None: + text = 'test@example.com' + params = {k: v for k, v in default_params.items() if k not in ['seed', 'mode']} + svg1 = make_userpic_svg_from_string(text=text, **params) + svg2 = make_userpic_svg_from_string(text=text, **params) + assert svg1 == svg2 + + +def test_different_strings_different_results(default_params: dict[str, Any]) -> None: + params = {k: v for k, v in default_params.items() if k not in ['seed']} + image1 = make_userpic_image_from_string(text='user1@example.com', **params) + image2 = make_userpic_image_from_string(text='user2@example.com', **params) + assert image1.tobytes() != image2.tobytes() + + +@pytest.mark.parametrize( + 'size', + [ + (5, 5), + (7, 7), + (9, 9), + ], +) +def test_different_sizes(default_params: dict[str, Any], size: tuple[int, int]) -> None: + params = default_params.copy() + params['size'] = size + image = make_userpic_image(**params) + assert isinstance(image, Image) + + +@pytest.mark.parametrize('mode', ['RGB', 'RGBA', 'L']) +def test_different_modes(default_params: dict[str, Any], mode: str) -> None: + params = default_params.copy() + params['mode'] = mode + image = make_userpic_image(**params) + assert image.mode == mode + + +def test_svg_structure(default_params: dict[str, Any]) -> None: + params = {k: v for k, v in default_params.items() if k != 'mode'} + svg = make_userpic_svg(**params) + assert '' in svg + assert 'rect' in svg + + +def test_empty_string() -> None: + image = make_userpic_image_from_string(text='', size=(7, 5), mode='RGB', image_size=(300, 300)) + assert isinstance(image, Image) diff --git a/userpic.py b/userpic.py index d472f88..2e0cb19 100644 --- a/userpic.py +++ b/userpic.py @@ -1,11 +1,24 @@ +import hashlib from collections.abc import Generator -from random import getrandbits +from random import Random from PIL import ImageDraw -from PIL.Image import Image, _check_size +from PIL.Image import Image as PILImage +from PIL.Image import _check_size from PIL.Image import new as make_image -__all__ = ['__version__', 'make_userpic_image', 'make_userpic_svg'] +__all__ = [ + '__author__', + '__author_email__', + '__license__', + '__title__', + '__url__', + '__version__', + 'make_userpic_image', + 'make_userpic_image_from_string', + 'make_userpic_svg', + 'make_userpic_svg_from_string', +] __title__ = 'tiny-userpic' __version__ = '0.0.0' __url__ = 'https://github.com/shpaker/tiny-userpic' @@ -48,25 +61,27 @@ def _invert_bits(n: int, bits_num: int) -> int: return result << shift -def _iter_bit_lines(size: tuple[int, int] | list[int]) -> Generator[int, None, None]: +def _iter_bit_lines(size: tuple[int, int] | list[int], seed: int | None = None) -> Generator[int, None, None]: """ Generate lines of bits for the userpic. Args: size (tuple[int, int] | list[int]): The size of the userpic. + seed (int | None, optional): Seed for random number generation. Defaults to None. Yields: int: The next line of bits. """ + rng = Random(seed) if seed is not None else Random() bits_count = size[0] // 2 with_spacer = size[0] % 2 == 1 spacer = 0 while not spacer: - spacer = getrandbits(size[1]) + spacer = rng.getrandbits(size[1]) for i in range(size[1]): data = 0 while not data: - data = getrandbits(bits_count) + data = rng.getrandbits(bits_count) inverted = _invert_bits(data, bits_count) if with_spacer: data = data << 1 | (spacer >> i & 1) @@ -77,6 +92,7 @@ def _make_rectangles_xy( size: tuple[int, int] | list[int], image_size: tuple[int, int] | list[int], padding: tuple[int, int] | list[int], + seed: int | None = None, ) -> Generator[tuple[float, float, float, float], None, None]: """ Generate the coordinates and sizes of rectangles for the userpic. @@ -85,15 +101,20 @@ def _make_rectangles_xy( size (tuple[int, int] | list[int]): The size of the userpic. image_size (tuple[int, int] | list[int]): The size of the image. padding (tuple[int, int] | list[int]): The padding around the userpic. + seed (int | None, optional): Seed for random number generation. Defaults to None. Yields: tuple[float, float, float, float]: The coordinates and size of the next rectangle. + + Raises: + ValueError: If any of the size parameters are invalid. """ _check_size(size) _check_size(padding) _check_size(image_size) + rect_size = ((image_size[0] - 2 * padding[0]) / size[0], (image_size[1] - 2 * padding[1]) / size[1]) - for n, line in enumerate(_iter_bit_lines(size)): + for n, line in enumerate(_iter_bit_lines(size, seed=seed)): pos = size[0] for bit in _iter_bits(line): pos -= 1 @@ -105,30 +126,32 @@ def _make_rectangles_xy( def make_userpic_image( - size: tuple[int, int] | list[int], - mode: str, - image_size: tuple[int, int] | list[int], - padding: tuple[int, int] | list[int] = (0, 0), - background: float | tuple[int, ...] | str = 'white', - foreground: float | tuple[int, ...] | str = 'black', -) -> Image: + size: tuple[int, int], + mode: str = 'RGB', + image_size: tuple[int, int] = (300, 300), + padding: tuple[int, int] = (20, 20), + background: str | tuple[int, ...] = 'white', + foreground: str | tuple[int, ...] = 'black', + seed: int | None = None, +) -> PILImage: """ Generate a PIL Image object for the userpic. Args: - size (tuple[int, int] | list[int]): The size of the userpic. + size (tuple[int, int]): The size of the userpic. mode (str): The mode of the image (e.g., 'RGB'). - image_size (tuple[int, int] | list[int]): The size of the image. - padding (tuple[int, int] | list[int], optional): The padding around the userpic. Defaults to (0, 0). - background (float | tuple[int, ...] | str, optional): The background color. Defaults to 'white'. - foreground (float | tuple[int, ...] | str, optional): The foreground color. Defaults to 'black'. + image_size (tuple[int, int]): The size of the image. + padding (tuple[int, int]): The padding around the userpic. + background (str | tuple[int, ...]): The background color. + foreground (str | tuple[int, ...]): The foreground color. + seed (int | None, optional): Seed for random number generation. Defaults to None. Returns: Image: The generated PIL Image object. """ image = make_image(mode=mode, size=image_size, color=background) draw = ImageDraw.Draw(image) - for xy in _make_rectangles_xy(size=size, image_size=image_size, padding=padding): + for xy in _make_rectangles_xy(size=size, image_size=image_size, padding=padding, seed=seed): draw.rectangle((xy[0], xy[1], xy[0] + xy[2], xy[1] + xy[3]), width=0, fill=foreground) return image @@ -138,8 +161,8 @@ def _make_svg_rectangle(xy: tuple[float, float, float, float], fill: float | tup Generate an SVG rectangle element. Args: - xy (tuple[float, float, float, float]): The coordinates and size of the rectangle. - fill (float | tuple[float, ...] | str | None): The fill color of the rectangle. + xy (Tuple[float, float, float, float]): The coordinates and size of the rectangle. + fill (Union[float, Tuple[float, ...], str, None]): The fill color of the rectangle. Returns: str: The SVG rectangle element as a string. @@ -148,11 +171,12 @@ def _make_svg_rectangle(xy: tuple[float, float, float, float], fill: float | tup def make_userpic_svg( - size: tuple[int, int] | list[int], - image_size: tuple[int, int] | list[int], + size: tuple[int, int] | list[int] = (5, 5), + image_size: tuple[int, int] | list[int] = (300, 300), padding: tuple[int, int] | list[int] = (0, 0), background: float | tuple[float, ...] | str | None = 'white', foreground: float | tuple[float, ...] | str | None = 'black', + seed: int | None = None, ) -> str: """ Generate an SVG string for the userpic. @@ -163,15 +187,142 @@ def make_userpic_svg( padding (tuple[int, int] | list[int], optional): The padding around the userpic. Defaults to (0, 0). background (float | tuple[float, ...] | str | None, optional): The background color. Defaults to 'white'. foreground (float | tuple[float, ...] | str | None, optional): The foreground color. Defaults to 'black'. + seed (int | None, optional): Seed for random number generation. Defaults to None. + + Returns: + str: The generated SVG string. + """ + # Calculate pattern size and position + pattern_width = image_size[0] - 2 * padding[0] + pattern_height = image_size[1] - 2 * padding[1] + cell_width = pattern_width / size[0] + cell_height = pattern_height / size[1] + + # Generate SVG + svg = f'\n' + svg += f'\n' + + # Draw pattern + for y, bits in enumerate(_iter_bit_lines(size, seed)): + for x in range(size[0]): + if bits & (1 << (size[0] - 1 - x)): + svg += f'\n' + + svg += '' + return svg + + +def _string_to_seed(text: str) -> int: + """ + Convert a string to a stable seed value. + + Args: + text (str): The input string (e.g., email or username). + + Returns: + int: A stable seed value derived from the input string. + """ + # Используем первые 8 байт SHA-256 хэша для получения 64-битного целого числа + hash_bytes = hashlib.sha256(text.encode('utf-8')).digest()[:8] + return int.from_bytes(hash_bytes, byteorder='big') + + +def make_userpic_image_from_string( + text: str, + size: tuple[int, int] = (5, 5), + mode: str = 'RGB', + image_size: tuple[int, int] = (300, 300), + padding: tuple[int, int] = (0, 0), + background: str | tuple[int, ...] = 'white', + foreground: str | tuple[int, ...] = 'black', +) -> PILImage: + """ + Generate a PIL Image object for the userpic based on a string input. + + Args: + text (str): The input string to generate the avatar from (e.g., email or username). + size (tuple[int, int] | list[int]): The size of the userpic. + mode (str): The mode of the image (e.g., 'RGB'). + image_size (tuple[int, int] | list[int], optional): The size of the image. Defaults to (300, 300). + padding (tuple[int, int] | list[int], optional): The padding around the userpic. Defaults to (0, 0). + background (float | tuple[int, ...] | str, optional): The background color. Defaults to 'white'. + foreground (float | tuple[int, ...] | str, optional): The foreground color. Defaults to 'black'. + + Returns: + Image: The generated PIL Image object. + """ + seed = _string_to_seed(text) + return make_userpic_image( + size=size, + mode=mode, + image_size=image_size, + padding=padding, + background=background, + foreground=foreground, + seed=seed, + ) + + +def make_userpic_svg_from_string( + text: str, + size: tuple[int, int] | list[int] = (5, 5), + image_size: tuple[int, int] | list[int] = (300, 300), + padding: tuple[int, int] | list[int] = (0, 0), + background: float | tuple[float, ...] | str | None = 'white', + foreground: float | tuple[float, ...] | str | None = 'black', +) -> str: + """ + Generate an SVG string for the userpic based on a string input. + + Args: + text (str): The input string to generate the avatar from (e.g., email or username). + size (tuple[int, int] | list[int]): The size of the userpic. + image_size (tuple[int, int] | list[int]): The size of the image. + padding (tuple[int, int] | list[int], optional): The padding around the userpic. Defaults to (0, 0). + background (float | tuple[float, ...] | str | None, optional): The background color. Defaults to 'white'. + foreground (float | tuple[float, ...] | str | None, optional): The foreground color. Defaults to 'black'. Returns: str: The generated SVG string. """ - return ( - f'' - f'' - f'' - f'{_make_svg_rectangle((0, 0, image_size[0], image_size[1]), fill=background) if background else ""}' - f'{"".join([_make_svg_rectangle(xy, fill=foreground) for xy in _make_rectangles_xy(size=size, image_size=image_size, padding=padding)])}' - f'' + seed = _string_to_seed(text) + return make_userpic_svg( + size=size, + image_size=image_size, + padding=padding, + background=background, + foreground=foreground, + seed=seed, + ) + + +def make_userpic( + size: tuple[int, int], + mode: str = 'RGB', + image_size: tuple[int, int] = (300, 300), + padding: tuple[int, int] = (20, 20), + background: str | tuple[int, ...] = 'white', + foreground: str | tuple[int, ...] = 'black', +) -> PILImage: + """ + Generate a PIL Image object for the userpic. + + Args: + size (tuple[int, int]): The size of the userpic. + mode (str): The mode of the image (e.g., 'RGB'). + image_size (tuple[int, int]): The size of the image. + padding (tuple[int, int]): The padding around the userpic. + background (str | tuple[int, ...]): The background color. + foreground (str | tuple[int, ...]): The foreground color. + + Returns: + Image: The generated PIL Image object. + """ + return make_userpic_image( + size=size, + mode=mode, + image_size=image_size, + padding=padding, + background=background, + foreground=foreground, ) diff --git a/uv.lock b/uv.lock index dff7c38..a17f9ed 100644 --- a/uv.lock +++ b/uv.lock @@ -1,4 +1,5 @@ version = 1 +revision = 1 requires-python = ">=3.10" [[package]] From cdfa445b577c1e41e880d7f4dc07c4915645b713 Mon Sep 17 00:00:00 2001 From: "A.Shpak" Date: Mon, 24 Mar 2025 23:06:18 +0300 Subject: [PATCH 2/4] feat: add examples and tests, update configuration --- pyproject.toml | 1 + uv.lock | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index ae9e5e9..543e7c7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,7 @@ build-backend = "hatchling.build" [dependency-groups] dev = [ "mypy>=1.13.0", + "pytest>=8.3.5", "ruff>=0.8.2", ] diff --git a/uv.lock b/uv.lock index a17f9ed..ef5da98 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,33 @@ version = 1 revision = 1 requires-python = ">=3.10" +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "exceptiongroup" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/09/35/2495c4ac46b980e4ca1f6ad6db102322ef3ad2410b79fdde159a4b0f3b92/exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc", size = 28883 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453 }, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050 }, +] + [[package]] name = "mypy" version = "1.13.0" @@ -45,6 +72,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/e2/5d3f6ada4297caebe1a2add3b126fe800c96f56dbe5d1988a2cbe0b267aa/mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", size = 4695 }, ] +[[package]] +name = "packaging" +version = "24.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 }, +] + [[package]] name = "pillow" version = "11.0.0" @@ -112,6 +148,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/3d/c32a51d848401bd94cabb8767a39621496491ee7cd5199856b77da9b18ad/pillow-11.0.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:224aaa38177597bb179f3ec87eeefcce8e4f85e608025e9cfac60de237ba6316", size = 2567508 }, ] +[[package]] +name = "pluggy" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, +] + +[[package]] +name = "pytest" +version = "8.3.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634 }, +] + [[package]] name = "ruff" version = "0.8.2" @@ -148,6 +210,7 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "mypy" }, + { name = "pytest" }, { name = "ruff" }, ] @@ -157,6 +220,7 @@ requires-dist = [{ name = "pillow", specifier = ">=11.0" }] [package.metadata.requires-dev] dev = [ { name = "mypy", specifier = ">=1.13.0" }, + { name = "pytest", specifier = ">=8.3.5" }, { name = "ruff", specifier = ">=0.8.2" }, ] From 282b9919d89da92e0c4a765fa77c8868ef6b4fb8 Mon Sep 17 00:00:00 2001 From: "A.Shpak" Date: Mon, 24 Mar 2025 23:09:30 +0300 Subject: [PATCH 3/4] feat: add examples and tests, update configuration --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a7f92bd..c4102c0 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# tiny-userpic +# Github-like Userpic (Avatar) Generator Oversimplified Github-like userpic (avatar) generator. From de9a586f177fa7b7b3248836352eef331396017e Mon Sep 17 00:00:00 2001 From: "A.Shpak" Date: Mon, 24 Mar 2025 23:22:27 +0300 Subject: [PATCH 4/4] feat: add examples and tests, update configuration --- README.md | 65 +++++++++++++++++++++++++++++++++++---- examples/basic.png | Bin 1035 -> 1005 bytes examples/colored.png | Bin 1041 -> 1043 bytes examples/large.png | Bin 1180 -> 1153 bytes examples/random.png | Bin 0 -> 1023 bytes examples/seeded.png | Bin 0 -> 1023 bytes examples/small.png | Bin 1016 -> 980 bytes examples/transparent.png | Bin 1217 -> 1194 bytes justfile | 33 ++++++++++++++++---- 9 files changed, 86 insertions(+), 12 deletions(-) create mode 100644 examples/random.png create mode 100644 examples/seeded.png diff --git a/README.md b/README.md index c4102c0..ac077d0 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,44 @@ pip install tiny-userpic ## Usage +The library provides several ways to generate avatars: + +### 1. Random Generation (Non-deterministic) +Generate a unique random avatar each time. + +```python +from tiny_userpic import make_userpic_image + +# Generate random avatar +random_image = make_userpic_image( + size=(7, 5), + image_size=(300, 300), + background="white", + foreground="black" +) +random_image.save("random_avatar.png") +``` + +### 2. With Custom Seed (Deterministic) +Generate an avatar with a specific seed for reproducible results. + +```python +from tiny_userpic import make_userpic_image + +# Generate avatar with specific seed +seeded_image = make_userpic_image( + size=(7, 5), + image_size=(300, 300), + background="white", + foreground="black", + seed=42 # Any integer value will work as seed +) +seeded_image.save("seeded_avatar.png") +``` + +### 3. From Text Input (Deterministic) +Generate an avatar from any text input (email, username, etc.). The same input will always produce the same avatar. + ```python from tiny_userpic import make_userpic_image_from_string, make_userpic_svg_from_string @@ -29,11 +67,11 @@ email = "user@example.com" # As PNG image image = make_userpic_image_from_string( - text=email, - size=(7, 5), # Pattern size (width, height) - image_size=(300, 300), # Output image size - background="white", # Background color - foreground="black" # Foreground color + text=email, # Input text to generate avatar from + size=(7, 5), # Pattern size (width, height) + image_size=(300, 300), # Output image size in pixels + background="white", # Background color (can be color name, hex or RGB tuple) + foreground="black" # Foreground color (can be color name, hex or RGB tuple) ) image.save("avatar.png") @@ -49,9 +87,18 @@ with open("avatar.svg", "w") as f: f.write(svg) ``` +### Common Parameters +All generation methods share these parameters: +- `size`: Tuple of (width, height) for the pattern size +- `image_size`: Tuple of (width, height) for the output image size in pixels +- `background`: Background color (can be color name, hex or RGB tuple) +- `foreground`: Foreground color (can be color name, hex or RGB tuple) +- `padding`: Optional padding around the pattern (default: (20, 20)) +- `mode`: Image mode for PNG output (default: 'RGB', can be 'RGBA' for transparency) + ## Examples -### Basic +### Basic (from string) ![Basic example](examples/basic.png) ### Colored @@ -65,3 +112,9 @@ with open("avatar.svg", "w") as f: ### Large ![Large example](examples/large.png) + +### Random (non-deterministic) +![Random example](examples/random.png) + +### Seeded (deterministic) +![Seeded example](examples/seeded.png) diff --git a/examples/basic.png b/examples/basic.png index 12011b69eb6df8a5e4d98e4158cfd2c258ec4e6d..104ae48bfd0a6c3eeeb9a9f17b21f9ec4324a0ce 100644 GIT binary patch delta 241 zcmeC?c*{OPMR$v*i(^Q|oVRxz^O_w57##hN{NHa=>8NzGFe4}Y?nS-@_nsbk^N#mL z%~j?D%O`F$6x_I2?g5{1@u#l31D|#7-PNBnc_UNO#1EQ0Ac;$9%j6orKUbNY$Q(1d zfLUMu>^r-fFO@H$@n{6~KMO`~_I-IaRp->KWVPyWbQ1ym;}>~4FY^zFNu zr_Y|5dHSy3e#g2;Ff(+d%dRlrNl$wvc(1*^Qcz#)VfF31m#+mqDE%SRI@yuA2B@Ep o6GdKU@ogd(_x@$e?<4N9=0RbJ6uyPr#%}hPQfAra_ zdyv`PdWJV;k6z18`-F4p2t3AvHug~uz+ZRn8z>7=Fai@HcD(ZN%v?1GTjGNlaT^xlW+kc2sXQ0&j3G~H<6(*7O_7` Wa5Y-$)cITh0000ZtH#=;eIgTub`-F%=)B(EIP>?Z z`=9pRIRCuTj&bhvDuy5LCtfs^%Gvew`00&*ugS!3IsbaY);I6U|GvFzdv}AQ`D8&x ziHRGE<#JZB|Csmm#w)=eis|1ccDq{Kuv`C?bP0l+XkKICg|E delta 283 zcmbQtF_B|}ipdR67srr_Id88W%x!jHa6Kpx$W?klYLV%6N5N>PzMXE<4jWfI>-&-Y zTXLDiRY zTJm~zfe$K^7cwhNPGC+3Vm4*pQr}Yv=wbo&Vpx^ZQG^ zBd;|k7c!xLVbXnLQuB z9DTiUe;#Md?c|&Db{EvW?VDZsE4lX2&;J=fr?pndwoYzjb^1KYnTZ-`6{Vje}ApfPvpL~NX<=f*Vi`> zuFiKp9ypniQGVioF~zbqTUULzOI?*?@hMp^S9tRK!glt|Lr)Ud%W{7DB+I`N~v zjG9wNZ`-^xljj^dcE;4^udP(otMYw%2cF$+d8{${Bcm?RdJa+JFIRT|JR?}x^7fYe z>=W|M-^wo(icAh<5}z!{rschh$PRs9d+MbE-3yk)mqo;|y+|5yfbsoG>{_PTB0WzoZ^q`y)Jg dBBO!WZ|2zmvv4FO#qOY?N|T+ delta 536 zcmZqVoWnUm#Uk0$#WAE}&f7Z&3vWA!usU8^sxYU=xnK*|!IKuefT4w!96q#HCz+Jkjr~om0oRw-4E- zi4Ry5q>Zh0MCR_(KXixh`3F;rF8}!G+0y^SZ#-nU`#@Rd?CZ&i%*H^ci`>gz+h1Q{ zw>fpg1lbrPiSHj1zo|?<$gDc~0<)l0{M-5GmoL(b{E?VfB=5h!b1?_&f!vnE^$-Us zf4p#c&ca#M6`yKr)^B~4{o{^mxn9hV%D06-ZskANdhkWuX|Od)J}m;;*RPe`uy`cb zH?Pa8?$d`CGuK}KdA+{#;q-!E^~LKKPfXOB*uW$6W8w14?sairuU+Qdci{3=!E^lc znUB=F_g|}&ZJqehNU_%}&1cVsPT9Uk$F0qeuU~uC*Q}{*$9HRqcRIFzvcFhyO;%*q znpnWgv#}!gte5z7i(d*Dalpa(X79CC>lx+FPM++@XdrgOev?JBY5s|++6+M8>FVdQ I&MBb@0D2bq)c^nh diff --git a/examples/random.png b/examples/random.png new file mode 100644 index 0000000000000000000000000000000000000000..79e1bb76b4d237d1067dc6113fc023191a8d3d8d GIT binary patch literal 1023 zcmeAS@N?(olHy`uVBq!ia0y~yVAKI&4kn<;H+e}V1_tJ1o-U3d6?5KRJDA&IAmVUP zAh&qSfy&d5e7L7P51F8$*rLA4xcs#IO!aE1gW_mB98fO z1^bW2(wqACKJGj#{{Bb23rlv!}4)7 zvVM=M<}Dsz#|?%6!sdo$hQ}TreZ6VE1=pQh#k2c$Yd|q;{b%l;Db=z3gDKR2u^V`v hLI12TscG#E^XWt*_e6fnZ@`Sr;OXk;vd$@?2>=L)Gx7ic literal 0 HcmV?d00001 diff --git a/examples/seeded.png b/examples/seeded.png new file mode 100644 index 0000000000000000000000000000000000000000..0aa2a5256238285b3c40595322536e9027e4da77 GIT binary patch literal 1023 zcmeAS@N?(olHy`uVBq!ia0y~yVAKI&4kn<;H+e}V1_tJ1o-U3d6?5KRJDA(-z~FjN z;3DhOfUehzL{2U2J9}|?gkR1_o$6>@k#E{U+_7WzrkPKsiRXMNvUN9hoTyB z%+JdF`oF)ekuA5nb~-b?a`*G9_uGG0Jz%}tA|R}I#D&99(8*_@nHzV?eK0@0@2c<* z_51J4FH0=GSFrx`{zn_*{Omr){wy>9A=^s4he-(>lGU1BXO6qIDgP_Wz56D|R+#V6 zV70rxG42(o{;BsGgCsD45t_4(y`Xf>^vma>zP;VDdB<7bK@rPPhi!cmZQIx|o3vC+ tazqiCJ7DJeCihe^*O-`-nj<#K|GuZAzSRBmNnpli@O1TaS?83{1OVnYJ^cUx literal 0 HcmV?d00001 diff --git a/examples/small.png b/examples/small.png index d70697e9cbb848256939d03898738bf3dbf72db3..3961db3e62bd43a6d4065290bc4881b2fd453a2c 100644 GIT binary patch delta 198 zcmeyteuaI4iqULO7srr_Id89C%xrZKalQEbaQ;U5*W2YcHhMBEHo9!)T=&(!K;p)o z%$|=-R`07AZ01fpXee-Ye`U?P*RIA7?niHXoX#IR`5=?c|zw_eG^(v;C^Qx0KGWh^iI|&!tK5#o-c175({9U%~L+yWW x`*%(@WUiQOz$DDEF|#KyzrnqD@xELuH4JmlS266d7IgAy5fD~9;=*A_9CP;n zq8~ffil1$mzCGvn&gM9$j!uCjrIr~SifV&^X?C6YUB=CCRfoR!-H!7A(OF$>pihG5 zNC^mX)aI;X-?KRN_G?ZX!9nA!jXUKE+>ht3RX^~4nW5X>hk7DE^mT8?`R|x_KX9t64d4C>1y(f3(y$10Kn4EYcC6DAtC0~(# hu(pzu)C^I}_^MJZ__5fIOkj>?@O1TaS?83{1ORapILiP4 diff --git a/examples/transparent.png b/examples/transparent.png index be48615fe15997af2cdc6682c3c7d4bd8b2fa386..f7dda333c0530c62bccf17908990a5411a903ec5 100644 GIT binary patch delta 292 zcmV+<0o(q;391Q@Bs6hJL_t(|obB8}s@pIWK+&pr-zF=6e^h7(r74&%U2@I>#6xnf zi2ly^C_u4(f>Fm*aPTZ%pUbB0K@8E`$K?7Zr qPyIKKR=IS+p-<^8*=6sD+Yb_7fsa9^RO;n4FLPP}`oc@hGV@m!OT0YoJ{S1&lmHCL|* z{O;7dH|J}tT5EZ*xNqeT`Cpy5N2iA26L=B=k?~xUkO4#|Ja-T9$;P