From aed9a0cb5403ffc784a451e361758504aa701240 Mon Sep 17 00:00:00 2001 From: Vaughn Kottler Date: Sun, 4 Aug 2024 16:17:02 -0700 Subject: [PATCH] Add text annotation to theme display --- .github/workflows/python-package.yml | 7 ++- .pylintrc | 3 ++ README.md | 5 +- config | 2 +- local/configs/package.yaml | 3 ++ local/configs/python.yaml | 2 +- local/variables/package.yaml | 4 +- pyproject.toml | 5 +- setup.py | 3 +- svgen/__init__.py | 4 +- svgen/app.py | 2 +- svgen/attribute/__init__.py | 12 ++--- svgen/cartesian/angle.py | 35 +++++++++++++ svgen/color/__init__.py | 4 ++ svgen/color/hsl.py | 48 ++++++++++++------ svgen/color/theme/visualize.py | 12 +++-- svgen/element/mixins/__init__.py | 74 ++++++++++++++++++++++++++++ svgen/element/rect.py | 33 ++++++------- svgen/element/text.py | 40 +++++++++++++++ tasks/svgen/default.py | 24 +++++++-- tasks/svgen/default.yaml | 7 +-- tests/color/test_hsl.py | 3 +- tests/test_entry.py | 6 +++ 23 files changed, 271 insertions(+), 67 deletions(-) create mode 100644 svgen/cartesian/angle.py create mode 100644 svgen/element/mixins/__init__.py create mode 100644 svgen/element/text.py diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index a6703cf..8b21f0a 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -19,7 +19,6 @@ jobs: strategy: matrix: python-version: - - "3.10" - "3.11" - "3.12" system: @@ -41,6 +40,10 @@ jobs: - run: pip${{matrix.python-version}} install vmklib>=2.0.3 + # Begin project-specific setup. + - run: mk python-editable + # End project-specific setup. + - run: mk python-sa-types - name: lint and build @@ -74,7 +77,7 @@ jobs: - run: | mk python-release owner=vkottler \ - repo=svgen version=0.6.8 + repo=svgen version=0.7.0 if: | matrix.python-version == '3.12' && matrix.system == 'ubuntu-latest' diff --git a/.pylintrc b/.pylintrc index 18a7959..5940cf3 100644 --- a/.pylintrc +++ b/.pylintrc @@ -1,5 +1,8 @@ [BASIC] good-names=p1,p2,x1,x2,y1,y2,rx,ry,cx,cy,dx,dy,x,y +[MESSAGES CONTROL] +disable=too-few-public-methods + [DESIGN] max-args=7 diff --git a/README.md b/README.md index 18fe87f..f9863d6 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,11 @@ ===================================== generator=datazen version=3.1.4 - hash=81a7bf82ba5e1d93e03c2f2677035fae + hash=9520e8fc36ee88b167c27f532fc3c1e4 ===================================== --> -# svgen ([0.6.8](https://pypi.org/project/svgen/)) +# svgen ([0.7.0](https://pypi.org/project/svgen/)) [![python](https://img.shields.io/pypi/pyversions/svgen.svg)](https://pypi.org/project/svgen/) ![Build Status](https://github.com/vkottler/svgen/workflows/Python%20Package/badge.svg) @@ -29,7 +29,6 @@ This package is tested with the following Python minor versions: -* [`python3.10`](https://docs.python.org/3.10/) * [`python3.11`](https://docs.python.org/3.11/) * [`python3.12`](https://docs.python.org/3.12/) diff --git a/config b/config index 18b0552..8e16a46 160000 --- a/config +++ b/config @@ -1 +1 @@ -Subproject commit 18b0552134deba04efce9db39eb4b92d3d8f4236 +Subproject commit 8e16a46cd7bf6fad5b3f8325566127f733ea7091 diff --git a/local/configs/package.yaml b/local/configs/package.yaml index 9109dcc..b3c6d45 100644 --- a/local/configs/package.yaml +++ b/local/configs/package.yaml @@ -12,3 +12,6 @@ requirements: dev_requirements: - setuptools-wrapper - pytest + +ci_local: + - "- run: mk python-editable" diff --git a/local/configs/python.yaml b/local/configs/python.yaml index e7f3979..5ee374a 100644 --- a/local/configs/python.yaml +++ b/local/configs/python.yaml @@ -4,7 +4,7 @@ author_info: email: vaughnkottler@gmail.com username: vkottler -versions: ["3.10", "3.11", "3.12"] +versions: ["3.11", "3.12"] systems: - macos-latest diff --git a/local/variables/package.yaml b/local/variables/package.yaml index 80b5dfe..fd6e81d 100644 --- a/local/variables/package.yaml +++ b/local/variables/package.yaml @@ -1,5 +1,5 @@ --- major: 0 -minor: 6 -patch: 8 +minor: 7 +patch: 0 entry: svgen diff --git a/pyproject.toml b/pyproject.toml index d9dd8d9..33dd6f2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,10 +4,10 @@ build-backend = "setuptools.build_meta:__legacy__" [project] name = "svgen" -version = "0.6.8" +version = "0.7.0" description = "A tool for working with scalable vector graphics." readme = "README.md" -requires-python = ">=3.10" +requires-python = ">=3.11" authors = [ {name = "Vaughn Kottler", email = "vaughnkottler@gmail.com"} ] @@ -15,7 +15,6 @@ maintainers = [ {name = "Vaughn Kottler", email = "vaughnkottler@gmail.com"} ] classifiers = [ - "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Operating System :: Microsoft :: Windows", diff --git a/setup.py b/setup.py index c4ac937..3286129 100644 --- a/setup.py +++ b/setup.py @@ -1,7 +1,7 @@ # ===================================== # generator=datazen # version=3.1.4 -# hash=fc5241a8b8252685d9df5e59db1c172d +# hash=6d153dc423cef1450e9b2cd28628b27c # ===================================== """ @@ -28,7 +28,6 @@ "version": VERSION, "description": DESCRIPTION, "versions": [ - "3.10", "3.11", "3.12", ], diff --git a/svgen/__init__.py b/svgen/__init__.py index fbe93b9..e5b2eba 100644 --- a/svgen/__init__.py +++ b/svgen/__init__.py @@ -1,7 +1,7 @@ # ===================================== # generator=datazen # version=3.1.4 -# hash=d5836c072a305ff3cc492ddda24e295b +# hash=9e67da4714539646e56663e5f514af0d # ===================================== """ @@ -10,4 +10,4 @@ DESCRIPTION = "A tool for working with scalable vector graphics." PKG_NAME = "svgen" -VERSION = "0.6.8" +VERSION = "0.7.0" diff --git a/svgen/app.py b/svgen/app.py index 9f6d90c..5d136f3 100644 --- a/svgen/app.py +++ b/svgen/app.py @@ -31,7 +31,7 @@ def generate( """Generate a single SVG document.""" # Set a theme for this variant. - THEMES.theme = config["theme"] + THEMES.theme = config.data["theme"] # Add the specified directory to the import path, so external scripts # can load their own dependencies. diff --git a/svgen/attribute/__init__.py b/svgen/attribute/__init__.py index 50acc2b..4efeafa 100644 --- a/svgen/attribute/__init__.py +++ b/svgen/attribute/__init__.py @@ -4,7 +4,7 @@ # built-in from abc import ABC, abstractmethod -from typing import Dict, List, Type, TypeVar, Union +from typing import TypeVar, Union T = TypeVar("T", bound="Attribute") @@ -45,7 +45,7 @@ def encode(self, quote: str = '"', force: bool = False) -> str: @classmethod @abstractmethod - def decode(cls: Type[T], key: str, value: str) -> T: + def decode(cls: type[T], key: str, value: str) -> T: """Create this attribute from a string.""" @@ -70,13 +70,13 @@ def value(self) -> str: @classmethod def decode( - cls: Type["SimpleAttribute"], key: str, value: str + cls: type["SimpleAttribute"], key: str, value: str ) -> "SimpleAttribute": """Create this attribute from a string.""" return cls(key, value) @staticmethod - def from_dict(data: Dict[str, Union[str, int, float]]) -> List[Attribute]: + def from_dict(data: dict[str, Union[str, int, float]]) -> list[Attribute]: """Get a list of attributes from dictionary data.""" return [ SimpleAttribute(key, str(value)) for key, value in data.items() @@ -84,11 +84,11 @@ def from_dict(data: Dict[str, Union[str, int, float]]) -> List[Attribute]: PossibleAttributes = Union[ - Dict[str, AttributeValue], List[Attribute], Attribute + dict[str, AttributeValue], list[Attribute], Attribute ] -def attributes(data: PossibleAttributes = None) -> List[Attribute]: +def attributes(data: PossibleAttributes = None) -> list[Attribute]: """ Get attributes from either an existing list of attributes, or dictionary data. diff --git a/svgen/cartesian/angle.py b/svgen/cartesian/angle.py new file mode 100644 index 0000000..e962863 --- /dev/null +++ b/svgen/cartesian/angle.py @@ -0,0 +1,35 @@ +""" +A module implementing an interface for angles. +""" + +# built-in +from abc import ABC, abstractmethod +from typing import TypeVar + +T = TypeVar("T", bound="Rotatable") + + +class Rotatable(ABC): + """A generic interface for rotatable instances.""" + + @abstractmethod + def arc(self: T, count: int = 1, divisor: int = 2) -> T: + """Rotate this angle around the circle.""" + + +class DegreePrimitive(int, Rotatable): + """ + A class to manage integer primitives for degrees within a 0 and 360 range. + """ + + def __new__(cls, val: int) -> "DegreePrimitive": + """Construct a new degree value.""" + return super().__new__(cls, val % 360) + + def rotate(self, val: int) -> "DegreePrimitive": + """Rotate this degree instance.""" + return DegreePrimitive(self + val) + + def arc(self, count: int = 1, divisor: int = 2) -> "DegreePrimitive": + """Rotate this angle around the circle.""" + return self.rotate(round(count * (360 / divisor))) diff --git a/svgen/color/__init__.py b/svgen/color/__init__.py index 0a13109..17a61c4 100644 --- a/svgen/color/__init__.py +++ b/svgen/color/__init__.py @@ -194,6 +194,10 @@ def from_hsl(cls, color: Hsl) -> "Color": """Create a color from an hsl object.""" return cls(hsl_to_rgb(color), color) + def hsl_arc(self, **kwargs) -> "Color": + """Get a new color based on some rotation in HSL space.""" + return self.from_hsl(self.hsl.arc(**kwargs)) + @classmethod def from_ctor(cls, value: str) -> "Color": """Create a color from an hsl or rgb constructor string.""" diff --git a/svgen/color/hsl.py b/svgen/color/hsl.py index b4fcc9e..2d6fac4 100644 --- a/svgen/color/hsl.py +++ b/svgen/color/hsl.py @@ -8,28 +8,17 @@ from typing import NamedTuple # internal +from svgen.cartesian.angle import DegreePrimitive, Rotatable from svgen.color.alpha import DEFAULT, Alpha from svgen.color.numbers import parse_ctor -class DegreePrimitive(int): - """ - A class to manage integer primitives for degrees within a 0 and 360 range. - """ - - def __new__(cls, val: int) -> "DegreePrimitive": - """Construct a new degree value.""" - return super().__new__(cls, val % 360) - - -class PercentPrimitive(int): +class PercentPrimitive(int, Rotatable): """A class for integer percentages.""" def __new__(cls, val: int) -> "PercentPrimitive": """Create a new percentage value.""" - val = max(val, 0) - val = min(val, 100) - return super().__new__(cls, val) + return super().__new__(cls, min(max(val, 0), 100)) def __str__(self) -> str: """Get this percentage as a string.""" @@ -40,6 +29,15 @@ def ratio(self) -> float: """Get this percentage as a ratio between 0 and 1.""" return float(self) / 100.0 + def arc(self, count: int = 1, divisor: int = 2) -> "PercentPrimitive": + """Rotate this angle around the circle.""" + + new_val = self + round(count * (100 / divisor)) + while new_val > 100: + new_val -= 100 + + return PercentPrimitive(new_val) + class Hsl(NamedTuple): """A definition of an hsl color.""" @@ -49,6 +47,28 @@ class Hsl(NamedTuple): lightness: PercentPrimitive alpha: Alpha = DEFAULT + def arc( + self, + hue_count: int = 1, + hue_divisor: int = 1, + saturation_count: int = 1, + saturation_divisor: int = 1, + lightness_count: int = 1, + lightness_divisor: int = 1, + ) -> "Hsl": + """Rotate this angle around the circle.""" + + return Hsl( + self.hue.arc(count=hue_count, divisor=hue_divisor), + self.saturation.arc( + count=saturation_count, divisor=saturation_divisor + ), + self.lightness.arc( + count=lightness_count, divisor=lightness_divisor + ), + self.alpha, + ) + def __str__(self) -> str: """Convert this hsl color to a string.""" diff --git a/svgen/color/theme/visualize.py b/svgen/color/theme/visualize.py index 7f48de3..db700d7 100644 --- a/svgen/color/theme/visualize.py +++ b/svgen/color/theme/visualize.py @@ -14,7 +14,7 @@ def visualize_theme( - theme: Union[ColorTheme, str], rect: Rectangle + theme: Union[ColorTheme, str], rect: Rectangle, columns: bool = True ) -> Iterator[Rect]: """ Create filled rectangle elements for this theme inside another @@ -27,16 +27,18 @@ def visualize_theme( assert isinstance(theme, ColorTheme) for box, color in zip( - RectangleGrid(rect, theme.size, 1).boxes, + RectangleGrid( + rect, theme.size if columns else 1, 1 if columns else theme.size + ).boxes, theme.data.values(), ): curr = Rect(box) - curr.style.add_color(color, "fill") + curr.assign_fill_color(color) yield curr def visualize( - rect: Rectangle, manager: ColorThemeManager = None + rect: Rectangle, manager: ColorThemeManager = None, columns: bool = True ) -> Iterator[Rect]: """Visualize all managed themes within a provided rectangle.""" @@ -47,4 +49,4 @@ def visualize( RectangleGrid(rect, 1, manager.size).boxes, manager.data.values() ): if theme is not None: - yield from visualize_theme(theme, box) + yield from visualize_theme(theme, box, columns=columns) diff --git a/svgen/element/mixins/__init__.py b/svgen/element/mixins/__init__.py new file mode 100644 index 0000000..747f3c2 --- /dev/null +++ b/svgen/element/mixins/__init__.py @@ -0,0 +1,74 @@ +""" +A module implementing svg element mixin classes. +""" + +# built-in +from typing import Iterator, Optional + +# internal +from svgen.attribute import Attribute, SimpleAttribute +from svgen.cartesian.rectangle import Rectangle +from svgen.color import Color, Colorlike +from svgen.element import Element + + +class RectangularMixin: + """A class mixin for rectangular entities.""" + + has_dimensions = True + + def __init__(self, rect: Rectangle) -> None: + """Initialize this instance.""" + + self.rect = rect + self.location = self.rect.location + self.dimensions = self.rect.dimensions + + @property + def rect_attributes(self) -> Iterator[Attribute]: + """Get attributes for this instance.""" + + yield from self.location.attrs + if self.has_dimensions: + yield from self.dimensions.attrs + + +class RadiusXyMixin: + """A class mixin for entities with 'rx' and 'ry' attributes.""" + + def __init__(self, rx: float = 0.0, ry: float = 0.0) -> None: + """Initialize this instance.""" + + self.rx = rx + self.ry = ry + + @property + def radius_xy_attributes(self) -> Iterator[Attribute]: + """Get attributes for this instance.""" + + yield SimpleAttribute("rx", str(self.rx)) + yield SimpleAttribute("ry", str(self.ry)) + + +class FillColorMixin(Element): + """A mixin class for elements with a 'fill' color attribute.""" + + _fill_color: Optional[Color] = None + + @property + def has_fill_color(self) -> bool: + """Determine if this instance has a fill color.""" + return self._fill_color is not None + + @property + def fill_color(self) -> Color: + """Get the fill color for this instance.""" + assert self._fill_color is not None, "No fill color for this element!" + return self._fill_color + + def assign_fill_color(self, data: Colorlike) -> None: + """Assign a fill color for this instance.""" + + assert not self.has_fill_color, (self._fill_color, data) + self._fill_color = Color.create(data) + self.style.add_color(self.fill_color, "fill") diff --git a/svgen/element/rect.py b/svgen/element/rect.py index 4aee222..0bb8d3f 100644 --- a/svgen/element/rect.py +++ b/svgen/element/rect.py @@ -7,7 +7,7 @@ from typing import Union # internal -from svgen.attribute import PossibleAttributes, SimpleAttribute, attributes +from svgen.attribute import PossibleAttributes, attributes from svgen.attribute.viewbox import ViewBox from svgen.cartesian import UNITY from svgen.cartesian.mutate import Translation @@ -16,10 +16,14 @@ from svgen.cartesian.rectangle.corner import RectangleCorner from svgen.cartesian.rectangle.grid import RectangleGrid from svgen.color import Colorlike -from svgen.element import Element +from svgen.element.mixins import ( + FillColorMixin, + RadiusXyMixin, + RectangularMixin, +) -class Rect(Element): +class Rect(FillColorMixin, RectangularMixin, RadiusXyMixin): """A class for rect elements.""" def __init__( @@ -32,22 +36,15 @@ def __init__( ) -> None: """Construct a new rect element.""" - self.rect = rect - self.location = self.rect.location - self.dimensions = self.rect.dimensions - self.rx = rx - self.ry = ry + RectangularMixin.__init__(self, rect) + RadiusXyMixin.__init__(self, rx=rx, ry=ry) - real_attrs = attributes(attrs) - real_attrs += [*self.location.attrs] - real_attrs += [*self.dimensions.attrs] - - if not isclose(self.rx, 0.0): - real_attrs.append(SimpleAttribute("rx", str(self.rx))) - if not isclose(self.ry, 0.0): - real_attrs.append(SimpleAttribute("ry", str(self.ry))) - - super().__init__(attrib=real_attrs, **extra) + super().__init__( + attrib=attributes(attrs) + + list(self.rect_attributes) + + list(self.radius_xy_attributes), + **extra, + ) def corner(self, corner: RectangleCorner) -> Point: """Get a specific corner of a rectangle.""" diff --git a/svgen/element/text.py b/svgen/element/text.py new file mode 100644 index 0000000..7a43fb0 --- /dev/null +++ b/svgen/element/text.py @@ -0,0 +1,40 @@ +""" +A module for the 'text' element. +""" + +# internal +from svgen.attribute import PossibleAttributes, SimpleAttribute, attributes +from svgen.element.mixins import FillColorMixin, RectangularMixin +from svgen.element.rect import Rect + + +class Text(FillColorMixin, RectangularMixin): + """A class for text elements""" + + has_dimensions = False + + def __init__( + self, + text: str, + rect: Rect, + attrs: PossibleAttributes = None, + **extra, + ) -> None: + """Initialize this instance.""" + + RectangularMixin.__init__(self, rect.rect) + + attrib = attributes(attrs) + list(self.rect_attributes) + attrib += [ + # When would height be used? + SimpleAttribute("textLength", str(self.dimensions.width / 2)), + SimpleAttribute("dy", str(self.dimensions.height)), + ] + + super().__init__(text=text, attrib=attrib, **extra) + + # Get a contrasting text color. + if rect.has_fill_color: + self.assign_fill_color( + rect.fill_color.hsl_arc(lightness_divisor=2) + ) diff --git a/tasks/svgen/default.py b/tasks/svgen/default.py index e2aa479..d718901 100644 --- a/tasks/svgen/default.py +++ b/tasks/svgen/default.py @@ -7,13 +7,31 @@ # third-party from svgen.attribute.viewbox import ViewBox +from svgen.color.theme.visualize import visualize, visualize_theme from svgen.element import Element +from svgen.element.text import Text def compose(viewbox: ViewBox, config: dict[str, Any]) -> list[Element]: """An example function for composing a document.""" - print(viewbox) - print(config) + theme = config.get("theme") + if theme: + result: list[Element] = [] + for rect in visualize_theme(theme, viewbox.box, columns=False): + text = Text("", rect) - return [] + # Set text. + text.text = f"{rect.fill_color} (text: {text.fill_color})" + + # Improve sizing. + text["font-size"] = "0.33em" + text["font-family"] = "monospace" + + # Add elements. + result.append(rect) + result.append(text) + + return result + + return list(visualize(viewbox.box)) diff --git a/tasks/svgen/default.yaml b/tasks/svgen/default.yaml index 4021d74..b2077a1 100644 --- a/tasks/svgen/default.yaml +++ b/tasks/svgen/default.yaml @@ -1,4 +1,5 @@ --- -a: 1 -b: 2 -c: 3 +theme: null +variants: + - {name: blue_gray, data: {theme: blue_gray}} + - {name: gray, data: {theme: gray}} diff --git a/tests/color/test_hsl.py b/tests/color/test_hsl.py index 2f51ca9..70939b8 100644 --- a/tests/color/test_hsl.py +++ b/tests/color/test_hsl.py @@ -3,7 +3,8 @@ """ # module under test -from svgen.color.hsl import DegreePrimitive, Hsl, hsl, hsla +from svgen.cartesian.angle import DegreePrimitive +from svgen.color.hsl import Hsl, hsl, hsla def test_hsl_basic(): diff --git a/tests/test_entry.py b/tests/test_entry.py index 6804500..754673c 100644 --- a/tests/test_entry.py +++ b/tests/test_entry.py @@ -10,6 +10,9 @@ from typing import Coroutine from unittest.mock import patch +# third-party +from vcorelib.platform import reconcile_platform + # module under test from svgen import PKG_NAME from svgen.entry import main as svgen_main @@ -50,3 +53,6 @@ def test_package_entry(): """Test the command-line entry through the 'python -m' invocation.""" check_output([executable, "-m", PKG_NAME, "-h"]) + + prog, args = reconcile_platform("mk", [PKG_NAME]) + check_output([prog, *args])