From 2415eca6fa51409813bd5a4264dc259ef2ef906d Mon Sep 17 00:00:00 2001 From: "A.Shpak" Date: Sat, 22 Aug 2026 10:30:19 +0300 Subject: [PATCH 1/8] fix: pattern rendering, input validation and svg output - derive cell boundaries once and account for the inclusive bounds of ImageDraw.rectangle, so cells tile without seams or overlap - restore pixel-level mirror symmetry of the rendered pattern - reject degenerate size/image_size/padding instead of hanging, dividing by zero or drawing garbage - escape colors in SVG attributes and render RGB/RGBA tuples as valid paint values - build SVG from a viewBox and merged cell runs - replace the private PIL.Image._check_size import with own validation - drop the unused _make_svg_rectangle and the duplicated make_userpic - align the padding default to (20, 20) across all generators - fix the imports and stale defaults in the readme - add regression tests for symmetry, seams, svg/image parity, escaping and validation - lint and type-check the tests, enable mypy strict - pin the interpreter per matrix entry, run tests on main, cancel superseded runs - anchor the release version substitution and replace set-output - fix the .gitignore patterns and align pre-commit ruff with the lockfile - seed the example images so they are reproducible --- .github/workflows/pypi.yml | 23 ++- .github/workflows/tests.yml | 28 ++- .gitignore | 4 +- .pre-commit-config.yaml | 2 +- README.md | 35 ++-- examples/basic.png | Bin 1005 -> 989 bytes examples/colored.png | Bin 1043 -> 1006 bytes examples/large.png | Bin 1153 -> 1124 bytes examples/random.png | Bin 1023 -> 0 bytes examples/seeded.png | Bin 1023 -> 1009 bytes examples/small.png | Bin 980 -> 989 bytes examples/transparent.png | Bin 1194 -> 1157 bytes justfile | 44 ++--- pyproject.toml | 13 ++ test_userpic.py | 223 +++++++++++++++++++++++ userpic.py | 353 +++++++++++++++++++++++------------- 16 files changed, 545 insertions(+), 180 deletions(-) delete mode 100644 examples/random.png diff --git a/.github/workflows/pypi.yml b/.github/workflows/pypi.yml index 73e74fb..17c4d10 100644 --- a/.github/workflows/pypi.yml +++ b/.github/workflows/pypi.yml @@ -5,19 +5,30 @@ on: release: types: [ created ] +permissions: + contents: read + jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Extract branch name + - name: Extract version from tag + id: version + shell: bash + run: | + version="${GITHUB_REF#refs/tags/}" + test -n "${version}" + echo "value=${version}" >> "${GITHUB_OUTPUT}" + - name: Set package version shell: bash + env: + VERSION: ${{ steps.version.outputs.value }} run: | - echo "##[set-output name=ver;]$(echo ${GITHUB_REF#refs/*/})" - id: extract_name_and_version - - run: sed -i 's/0.0.0/'"${{ steps.extract_name_and_version.outputs.ver }}"'/' pyproject.toml - - run: head -n 10 pyproject.toml - - run: sed -i 's/0.0.0/'"${{ steps.extract_name_and_version.outputs.ver }}"'/' userpic.py + sed -i "s/^version = \"0.0.0\"$/version = \"${VERSION}\"/" pyproject.toml + sed -i "s/^__version__ = '0.0.0'$/__version__ = '${VERSION}'/" userpic.py + grep -qx "version = \"${VERSION}\"" pyproject.toml + grep -qx "__version__ = '${VERSION}'" userpic.py - uses: astral-sh/setup-uv@v4 - run: uv python install 3.12 - run: uv sync --all-extras --dev diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0698e4e..db68b5c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -2,26 +2,44 @@ name: 🧐 test on: + push: + branches: [ main ] pull_request: branches: [ main ] +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: + lint: + runs-on: ubuntu-latest + env: + UV_PYTHON: "3.13" + steps: + - uses: actions/checkout@v4 + - uses: extractions/setup-just@v2 + - uses: astral-sh/setup-uv@v4 + - run: uv python install "${UV_PYTHON}" + - run: uv sync --all-extras --dev + - run: just lint + tests: runs-on: ubuntu-latest strategy: + fail-fast: false matrix: - just-trigger: - - "lint" - - "tests" python-version: - "3.10" - "3.11" - "3.12" - "3.13" + env: + UV_PYTHON: ${{ matrix.python-version }} steps: - uses: actions/checkout@v4 - uses: extractions/setup-just@v2 - uses: astral-sh/setup-uv@v4 - - run: uv python install ${{ matrix.python-version }} + - run: uv python install "${UV_PYTHON}" - run: uv sync --all-extras --dev - - run: just ${{ matrix.just-trigger }} + - run: just tests diff --git a/.gitignore b/.gitignore index 5a1bb6a..402885d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ - .png -.svg +/*.png +/*.svg .idea/ # Byte-compiled / optimized / DLL files diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 184b7be..6731c65 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,7 +11,7 @@ repos: language: system 'types_or': [ python, pyi ] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: "v0.8.1" + rev: "v0.8.2" hooks: - id: ruff args: [ --fix, --unsafe-fixes ] diff --git a/README.md b/README.md index ac077d0..3715f5e 100644 --- a/README.md +++ b/README.md @@ -15,10 +15,14 @@ Oversimplified Github-like userpic (avatar) generator. ## Installation +Requires Python 3.10 or newer. + ```bash pip install tiny-userpic ``` +The package installs a single top-level module named `userpic`. + ## Usage The library provides several ways to generate avatars: @@ -27,7 +31,7 @@ The library provides several ways to generate avatars: Generate a unique random avatar each time. ```python -from tiny_userpic import make_userpic_image +from userpic import make_userpic_image # Generate random avatar random_image = make_userpic_image( @@ -43,7 +47,7 @@ random_image.save("random_avatar.png") Generate an avatar with a specific seed for reproducible results. ```python -from tiny_userpic import make_userpic_image +from userpic import make_userpic_image, make_userpic_svg # Generate avatar with specific seed seeded_image = make_userpic_image( @@ -54,13 +58,16 @@ seeded_image = make_userpic_image( seed=42 # Any integer value will work as seed ) seeded_image.save("seeded_avatar.png") + +# The same seed produces the same pattern in SVG +seeded_svg = make_userpic_svg(size=(7, 5), image_size=(300, 300), seed=42) ``` ### 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 +from userpic import make_userpic_image_from_string, make_userpic_svg_from_string # Generate avatar from email email = "user@example.com" @@ -68,8 +75,8 @@ email = "user@example.com" # As PNG image image = make_userpic_image_from_string( text=email, # Input text to generate avatar from - size=(7, 5), # Pattern size (width, height) - image_size=(300, 300), # Output image size in pixels + 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) ) @@ -89,12 +96,15 @@ with open("avatar.svg", "w") as f: ### 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) +- `size`: Tuple of (width, height) for the pattern size in cells (default: `(5, 5)`, width must be at least 2) +- `image_size`: Tuple of (width, height) for the output image size in pixels (default: `(300, 300)`) +- `background`: Background color (color name, hex string, RGB or RGBA tuple; `None` for no background in SVG) +- `foreground`: Foreground color (color name, hex string, RGB or RGBA tuple) +- `padding`: Padding around the pattern in pixels (default: `(20, 20)`) +- `mode`: Image mode for PNG output (default: `'RGB'`, can be `'RGBA'` for transparency) + +The pattern must fit into the image: `image_size` minus twice the `padding` has to leave at least one pixel per +cell, otherwise a `ValueError` is raised. ## Examples @@ -113,8 +123,5 @@ All generation methods share these parameters: ### 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 104ae48bfd0a6c3eeeb9a9f17b21f9ec4324a0ce..2bb5a07f04b884b7c8baa1f65fabbb817f19ad4c 100644 GIT binary patch delta 206 zcmV;<05SjV2i*sdBrT*#L_t(|obB8}YQsPf06|Ug|GzwWfia1U+Q?ORq1i>v+%&s; z0G*$D0Qh#3fdMfO_NWK=Tr*RTa4Y_1^o&iB@hy?CUKo4S1N_0vT=}})z4CPzpYFK( z0WUt?88(rjeUY$kk+3fiY<9Pv;X}<#J;JT{(w?yilc54xlW+kt7_+6R;c>n+a07*qo IM6N<$g40`NasU7T delta 240 zcmcc1{+4}$itZLq7srr_IdAVc<~2JAFgW@j`M=+$(oyMVVMb2)-HUt+?ma#7<{j^g znybtQmQP;DXehXGvD^bb`qzAI}K8H%|Xn;B~zC`H%ein?~1E?-p*xmL(>DzZR zPoF(A^YmT6{f>2yU}gwNmtA4Llb-fU@Lqd+rJ%mp!|L02FJB9KQ2Ilrb@F~DEuj7c njv~WrFy#stSmy=NgGZyv(9$gz|OHOs300000NkvXXu0mjfvwvyY delta 287 zcmaFIKAB^Jiuo;17srr_Id88W^gUuA;^63@sam>ZtH#=;eIgTub`-F%=)B(EIP>?Z z`=9pRIRCuTj&bhvDuy5LCkrweO6BZ&di?apzt?2qx14{yVe6ZB<$vGawY|H+(R{KX zbGLlXD)t}qp5AyR_(L)M`^0WniyL<9zjAyxs>tU0)AC$nav)>Xe8Kt1Y++ur~ XvKVx4R2?g@WB>wBS3j3^P6)xK{Pv7=TI{Ncm z{b!%P-{{+FNwx>e)@+^k@3FAY)ZBfazv}OAJ!`K*ro(n`v!8!nW^O`~hIxJ4?0Pmo z@gXtH}?WNRnKRvD$Jk{FI5T{h%|CW`^v~XkVzJ`fEeJ*pV zwLSY3eWNDtv?rAi$$ib=bjfrNFdYNaX7!!?!}IMAUc2!@anE7l z8T*g>U#~k!X26?CRn0oB-uBGe`={;`o5PA^CIeu0?b{kw%e%pp90T6)t3R$ScJT<8 Q29_QSp00i_>zopr00SC#kpKVy delta 508 zcmaFD(a1SL#mvLg#WAE}&f7Z&0}nfhusU`taZFNVS)|G`NwNE>1KYn7 zKfIDZXg}GIMRxKBMo9r-#UpOn+2R-U=Ds~3d*Y}1WCbQ;nNESEa``H$Uw?nC(NE;Q zwn)uQaM#y253bI4J{~yPkV$Fc0b#|mHCtDGw@Y1>WAQ0jFIRZ-`@(ki%tKES*UNH$ zHl9)}(>hs^$ylOo-kHgB4jnsVYV+4ts_IqwKD`6a?zTMEm>kHcG%;UT)cDJl-9OI= z7Ph>-B|rOweDk;R3xy(+8<=D!^D|0HA9eP(=IMQ^?fmA0M2nAH-x=n64}X3(zET7> z-AMT6jGIYMmpgTe~DWM4fg_`ID diff --git a/examples/random.png b/examples/random.png deleted file mode 100644 index 79e1bb76b4d237d1067dc6113fc023191a8d3d8d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 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 diff --git a/examples/seeded.png b/examples/seeded.png index 0aa2a5256238285b3c40595322536e9027e4da77..7fa0d7dcf81b18d38bac3452fcafbb03be90959a 100644 GIT binary patch delta 241 zcmVY^0Np^nYP$3(K^i0|O{ z?r3I(zGfO@+>@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 diff --git a/examples/small.png b/examples/small.png index 3961db3e62bd43a6d4065290bc4881b2fd453a2c..655fc93cc12f8d444a6442816352bfcb4df87916 100644 GIT binary patch literal 989 zcmeAS@N?(olHy`uVBq!ia0y~yVAKI&4kn<;H+e}V1_tIOo-U3d6?5KRJ-D*jLBj2# zL`?VJi2Z*%c+ad>nC-IjgH5{q2g8D&%MFilzu5Dd@k5DH%M1=hHK&eFfh6LXX4jeD z6}`*0ZS2;)9q7NK)tkdm(8;GoKv?mJ%Rn<{*H_lOd+lnxZhMZq?L+1N-{$`Rm9IAl z90m)V9I50h(s_l0HTYgL`fmY++~VT+*PQ*2g$7H!!h&y*Br~9MmKnP3Wt2OsN^0%_ c=Cr?z*P~>EpXlbw0JANFr>mdKI;Vst0Qu<^P5=M^ delta 239 zcmcc1euaI4iqULO7srr_Id89C%xrZKalQEbaQ;U5*W2YcHhMBEHo9!)T=&(!K;p)o z%$|=-R`07AZ01g0$gDQ8jzd_@spIVa%9?ktU5y{ykKXn;oj-PBq3FZ{F+xa^FKzopr0CNgt ALjV8( diff --git a/examples/transparent.png b/examples/transparent.png index f7dda333c0530c62bccf17908990a5411a903ec5..a7ac8c45b35455e8e33a167736777e3cdb50c2e2 100644 GIT binary patch delta 256 zcmZ3**~&RV#n8vo#WAE}&f7bVUPl}R90H?@-cOi3UE5sAB4f#O?5-+@CTd?-D|08*W;&peeKUClQeEiPedAlbkvKUWZ&!jlHpUDx3 z1$ZsW^gpb+wkz#BU%=x-lN*=?!SqF&FsYh81E^P|b!lP54a zK$R$q_dI^rX#9Lv^|Sc3rDxyCKG<*jcze0I_~ebu3nw37WD!1om-+gxXYXbnI6v>> z?b(*s=YEcve2{q}P)>wn&CHv7nGZO}Oy0<7D^|mBSV8sf&+c*=1|aZs^>bP0l+XkK DKH+)= delta 290 zcmV+-0p0$E391Q@Bs6hJL_t(|obB8}s@pIWK+&pr-zF=6e^h7(r74&%U2@I>#6xnf z dict[str, Any]: } +def _svg_cells(svg: str) -> set[tuple[int, int]]: + body = svg.split('', body): + cells.update((int(x) + offset, int(y)) for offset in range(int(span))) + return cells + + +def _image_cells( + image: Image, + size: tuple[int, int], + image_size: tuple[int, int], + padding: tuple[int, int], +) -> set[tuple[int, int]]: + cell_width = (image_size[0] - 2 * padding[0]) / size[0] + cell_height = (image_size[1] - 2 * padding[1]) / size[1] + return { + (x, y) + for y in range(size[1]) + for x in range(size[0]) + if image.getpixel( + (int(padding[0] + (x + 0.5) * cell_width), int(padding[1] + (y + 0.5) * cell_height)), + ) + == (0, 0, 0) + } + + def test_make_userpic_image(default_params: dict[str, Any]) -> None: image = make_userpic_image(**default_params) assert isinstance(image, Image) @@ -109,3 +143,192 @@ def test_svg_structure(default_params: dict[str, Any]) -> None: 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) + + +@pytest.mark.parametrize( + 'size', + [ + (7, 5), + (8, 6), + (5, 5), + (2, 1), + ], +) +@pytest.mark.parametrize('image_size', [(300, 300), (280, 300), (301, 299), (256, 256)]) +def test_image_is_mirror_symmetric(size: tuple[int, int], image_size: tuple[int, int]) -> None: + for seed in range(10): + image = make_userpic_image(size=size, image_size=image_size, padding=(0, 0), seed=seed) + assert image.tobytes() == ImageOps.mirror(image).tobytes() + + +@pytest.mark.parametrize('size', [(7, 5), (8, 6), (12, 12)]) +def test_svg_matches_image(size: tuple[int, int]) -> None: + image_size, padding = (300, 300), (20, 20) + for seed in range(10): + svg = make_userpic_svg(size=size, image_size=image_size, padding=padding, seed=seed) + image = make_userpic_image(size=size, image_size=image_size, padding=padding, seed=seed) + assert _svg_cells(svg) == _image_cells(image, size, image_size, padding) + + +@pytest.mark.parametrize( + 'color', + [ + 'red"/>