glyphsynth 0.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.
glyphsynth/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ from pyrollup import rollup
2
+
3
+ from . import core
4
+ from . import lib
5
+
6
+ from .core import *
7
+ from .lib import *
8
+
9
+ __all__ = rollup(core, lib)
@@ -0,0 +1,53 @@
1
+ from types import ModuleType
2
+ from typing import Any, Literal
3
+
4
+ from ..core.glyph import BaseGlyph
5
+
6
+
7
+ def invoke_export(
8
+ path_cls: str, path_output: str, format: Literal["svg", "png"] | None
9
+ ):
10
+ print(
11
+ f"Exporting: modpath={path_cls}, path_output={path_output}, format={format}"
12
+ )
13
+
14
+ # get class to be imported
15
+ glyph_cls: type[BaseGlyph] = _get_glyph_cls(path_cls)
16
+
17
+ print(f"--- got glyph_cls: {glyph_cls}")
18
+
19
+ # instantiate glyph
20
+ glyph: BaseGlyph = glyph_cls()
21
+
22
+ # export glyph
23
+ glyph.export(path_output, format)
24
+
25
+
26
+ def _get_glyph_cls(path_cls: str) -> type[BaseGlyph]:
27
+ modpath: str
28
+ cls_name: str
29
+
30
+ modpath, cls_name = _parse_path_cls(path_cls)
31
+
32
+ print(f"--- importing {cls_name} from {modpath}")
33
+
34
+ mod: ModuleType = __import__(modpath, fromlist=[cls_name])
35
+ glyph_cls: Any = getattr(mod, cls_name)
36
+
37
+ assert issubclass(glyph_cls, BaseGlyph)
38
+ return glyph_cls
39
+
40
+
41
+ def _parse_path_cls(path_cls: str) -> tuple[str, str]:
42
+ """
43
+ Return modpath and class name.
44
+ """
45
+ modpath: str
46
+ cls_name: str
47
+
48
+ path_split: list[str] = path_cls.rsplit(".", maxsplit=1)
49
+ assert len(path_split) == 2, f"Invalid path_cls: {path_cls}"
50
+
51
+ modpath, cls_name = path_split
52
+
53
+ return (modpath, cls_name)
glyphsynth/cli/main.py ADDED
@@ -0,0 +1,51 @@
1
+ from typing_extensions import Annotated
2
+ from typing import Union, Literal
3
+
4
+ import typer
5
+
6
+ from .export import invoke_export
7
+
8
+
9
+ app = typer.Typer(
10
+ add_completion=False, invoke_without_command=False, no_args_is_help=True
11
+ )
12
+
13
+
14
+ # Dummy callback required in order to allow having a single subcommand
15
+ @app.callback()
16
+ def main():
17
+ pass
18
+
19
+
20
+ @app.command(no_args_is_help=True)
21
+ def export(
22
+ path_cls: Annotated[
23
+ str,
24
+ typer.Argument(help="Path to Python class, e.g. my_pkg.my_mod.MyGlyph"),
25
+ ],
26
+ path_output: Annotated[
27
+ str,
28
+ typer.Argument(
29
+ help="Path to output file or folder; if folder, filename generated by class name"
30
+ ),
31
+ ],
32
+ format: Annotated[
33
+ str,
34
+ typer.Argument(
35
+ help='"svg" or "png", or leave empty to determine from filename if possible, defaulting to "svg"'
36
+ ),
37
+ ] = None,
38
+ ):
39
+ assert format in {"svg", "png", None}, f"Invalid format: {format}"
40
+ invoke_export(path_cls, path_output, format)
41
+
42
+
43
+ def run():
44
+ """
45
+ Entry point of `glyphsynth` command.
46
+ """
47
+ app()
48
+
49
+
50
+ if __name__ == "__main__":
51
+ run()
@@ -0,0 +1,9 @@
1
+ from pyrollup import rollup
2
+
3
+ from . import glyph
4
+ from . import graphics
5
+
6
+ from .glyph import *
7
+ from .graphics import *
8
+
9
+ __all__ = rollup(glyph, graphics)
@@ -0,0 +1,242 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import Self, TypeVar, get_args, cast
5
+
6
+ from pydantic import BaseModel, ConfigDict
7
+
8
+ from .graphics.graphics import (
9
+ GraphicsContainer,
10
+ BaseContainer,
11
+ )
12
+ from .graphics.properties import Properties
13
+
14
+ __all__ = [
15
+ "BaseParams",
16
+ "BaseGlyph",
17
+ "EmptyParams",
18
+ "EmptyGlyph",
19
+ ]
20
+
21
+
22
+ class IdFactory:
23
+ _cls_map: dict[type[BaseGlyph], int]
24
+
25
+ def __init__(self):
26
+ self.reset()
27
+
28
+ def get_id(self, glyph_cls: type[BaseGlyph]) -> int:
29
+ if glyph_cls not in self._cls_map:
30
+ self._cls_map[glyph_cls] = 0
31
+
32
+ self._cls_map[glyph_cls] += 1
33
+
34
+ return self._cls_map[glyph_cls]
35
+
36
+ def reset(self):
37
+ self._cls_map = dict()
38
+
39
+
40
+ id_factory = IdFactory()
41
+
42
+
43
+ class BaseParams(BaseModel):
44
+ """
45
+ Subclass this class to create parameters for a Glyph.
46
+ """
47
+
48
+ model_config = ConfigDict(arbitrary_types_allowed=True)
49
+
50
+
51
+ # def __init_subclass__(cls):
52
+ # dataclass(frozen=True, kw_only=True)(cls)
53
+
54
+
55
+ class EmptyParams(BaseParams):
56
+ pass
57
+
58
+
59
+ class BaseGlyph[ParamsT: BaseParams](ABC, GraphicsContainer, BaseContainer):
60
+ """
61
+ Base class for a standalone or reusable glyph, sized in abstract
62
+ (user) units.
63
+ """
64
+
65
+ params: ParamsT
66
+ """
67
+ Instance of params with type as specified by typevar.
68
+ """
69
+
70
+ DefaultParams: type[ParamsT] | None = None
71
+ """
72
+ Subclass of ParamsT to use as defaults for this particular Glyph,
73
+ if no parameters provided during instantiation.
74
+ """
75
+
76
+ _params_cls: type[ParamsT]
77
+ """
78
+ Params class: the type parameter provided as ParamsT, or EmptyParams
79
+ if no type parameter provided.
80
+ """
81
+
82
+ def __init__(
83
+ self,
84
+ *,
85
+ glyph_id: str | None = None,
86
+ params: ParamsT | None = None,
87
+ properties: Properties | None = None,
88
+ size: tuple[float, float] | None = None,
89
+ parent: BaseGlyph | None = None,
90
+ insert: tuple[float, float] | None = None,
91
+ ):
92
+ """
93
+ :param parent: Parent glyph, or `None`{l=python} to create top-level glyph
94
+ :param glyph_id: Unique identifier, or `None`{l=python} to generate one
95
+ """
96
+
97
+ # if parent not provided, ensure insert/size_inst not provided
98
+ if parent is None:
99
+ assert insert is None
100
+
101
+ glyph_id = glyph_id or self._get_id()
102
+
103
+ super().__init__(glyph_id, properties, size)
104
+
105
+ # set params
106
+ self.params = params or type(self).params_cls()
107
+
108
+ # invoke subclass's init (e.g. set properties based on params)
109
+ self.init()
110
+
111
+ # invoke post-init since size_canon may be set in init()
112
+ self._init_post()
113
+
114
+ # invoke subclass's drawing logic
115
+ self.draw()
116
+
117
+ if parent is not None:
118
+ parent.insert_glyph(self, insert)
119
+
120
+ def __init_subclass__(cls: type[BaseGlyph]):
121
+ """
122
+ Populate _params_cls with the class representing the parameters for
123
+ this glyph. If not subscripted with a type arg by the subclass,
124
+ _params_cls is set to EmptyParams.
125
+ """
126
+
127
+ # note: pyright is not yet aware that the new style
128
+ #
129
+ # class BaseGlyph[ParamsT: BaseParams](...): ...
130
+ #
131
+ # is equivalent to
132
+ #
133
+ # ParamsT = TypeVar("ParamsT", bound=BaseParams)
134
+ # class BaseGlyph(Generic[ParamsT], ...): ...
135
+ #
136
+ # since with the new style, Generic is not a base. Nonetheless
137
+ # __orig_bases__ is indeed defined here.
138
+ orig_bases = (
139
+ cls.__orig_bases__ # pyright: ignore[reportGeneralTypeIssues]
140
+ )
141
+
142
+ # get_args() returns tuple[Any, ...], but we know the type param
143
+ # should always be ParamsT
144
+ args: tuple[type[ParamsT]] = cast(
145
+ tuple[type[ParamsT]], get_args(orig_bases[0])
146
+ )
147
+
148
+ if len(args) > 0:
149
+ assert len(args) == 1
150
+
151
+ params_cls: type[ParamsT]
152
+
153
+ if isinstance(args[0], TypeVar):
154
+ # have a TypeVar, look up its bound
155
+ type_var = cast(TypeVar, args[0])
156
+ assert type_var.__bound__ is not None
157
+ params_cls = type_var.__bound__
158
+ else:
159
+ # already have a class
160
+ params_cls = args[0]
161
+
162
+ cls._params_cls = params_cls
163
+ else:
164
+ # set _params_cls with EmptyParams
165
+ cls._params_cls = EmptyParams
166
+
167
+ assert issubclass(cls._params_cls, BaseParams)
168
+
169
+ @property
170
+ def glyph_id(self) -> str:
171
+ """
172
+ A meaningful identifier to associate with this glyph. Also used as
173
+ base name (without extension) of file to write when no filename is
174
+ provided.
175
+ """
176
+ return self._id
177
+
178
+ @classmethod
179
+ @property
180
+ def params_cls(cls) -> type[ParamsT]:
181
+ """
182
+ Returns the {obj}`BaseParams` subclass with which this class is
183
+ parameterized, accounting for any default values provided by
184
+ the subclass.
185
+ """
186
+ return cls.DefaultParams or cls._params_cls
187
+
188
+ def insert_glyph(
189
+ self,
190
+ glyph: BaseGlyph,
191
+ insert: tuple[float, float] | None = None,
192
+ ) -> Self:
193
+ glyph._bind(self, insert)
194
+ return self
195
+
196
+ def init(self):
197
+ ...
198
+
199
+ @abstractmethod
200
+ def draw(self):
201
+ ...
202
+
203
+ # TODO_NEXT 2
204
+ # - move to subclass of BaseClass
205
+ # - rename BaseClass -> _BaseClass?
206
+ # try:
207
+ # def draw_glyph[GlyphT: BaseGlyph, GlyphParamsT: BaseParams](self,
208
+ # glyph_cls: type[GlyphT[GlyphParamsT]], params: GlyphParamsT) -> GlyphT[GlyphParamsT]:
209
+ # return glyph_cls(parent=self, params=params)
210
+
211
+ def _get_id(self) -> str:
212
+ global id_factory
213
+ id_num: int = id_factory.get_id(type(self))
214
+ return f"{type(self).__name__}-{id_num}"
215
+
216
+
217
+ class EmptyGlyph(BaseGlyph[EmptyParams]):
218
+ """
219
+ Glyph to use as an on-the-fly alternative to subclassing
220
+ {obj}`BaseGlyph`. It has an empty {obj}`BaseGlyph.draw`
221
+ implementation; the user can then add graphics objects
222
+ and other glyphs after creation.
223
+
224
+ Example:
225
+
226
+ ```python
227
+ glyph1 = MyGlyph1()
228
+ glyph2 = MyGlyph2()
229
+
230
+ # create an empty glyph with unspecified size
231
+ glyph = EmptyGlyph()
232
+
233
+ # insert a glyph
234
+ glyph.insert_glyph(glyph1)
235
+
236
+ # insert another glyph in a different position
237
+ glyph.insert_glyph(glyph2, (100, 100))
238
+ ```
239
+ """
240
+
241
+ def draw(self):
242
+ pass
@@ -0,0 +1,15 @@
1
+ from pyrollup import rollup
2
+
3
+ from . import graphics, draw, export, container, properties
4
+
5
+ from .graphics import *
6
+ from .draw import *
7
+ from .export import *
8
+ from .container import *
9
+ from .properties import *
10
+
11
+ __all__ = rollup(graphics, draw, export, container, properties)
12
+
13
+ # TODO: add types.py?
14
+ # - Coordinate
15
+ # - Box/Area
@@ -0,0 +1,239 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+
5
+ from svgwrite.drawing import Drawing
6
+ from svgwrite.container import SVG, Group
7
+
8
+ from .properties import Properties
9
+
10
+
11
+ __all__ = []
12
+
13
+
14
+ class BaseContainer:
15
+ """
16
+ Container for drawing and manipulation of low-level graphics objects.
17
+
18
+ There are 2 SVG trees maintained:
19
+ 1. Standalone glyph
20
+ drawing (root <svg>)
21
+ wrapper svg (if size provided)
22
+ canonical svg
23
+ 2. Placement in parent glyph
24
+ svg-parent
25
+ svg-wrapper-placement (insert only)
26
+ group (w/transforms, e.g. rotation)
27
+ svg-wrapper-scaling (width/height/viewbox only)
28
+ svg-canonical
29
+ """
30
+
31
+ DefaultProperties: type[Properties] = Properties
32
+ """
33
+ Subclass of Properties with properties set as class attributes.
34
+
35
+ May be subclassed in-place or assigned to an existing subclass.
36
+ For example, subclassing in-place:
37
+
38
+ ```python
39
+ class MyGlyph(BaseGlyph):
40
+
41
+ class DefaultProperties(Properties):
42
+ color: PropertyValue = "black"
43
+ stroke_width: PropertyValue = "10"
44
+
45
+ ...
46
+ ```
47
+ """
48
+
49
+ properties: Properties
50
+ """
51
+ Properties automatically propagated to graphics objects in draw_*() APIs.
52
+ """
53
+
54
+ size_canon: tuple[float, float] | None = None
55
+ """
56
+ Canonical size in user units, as provided by concrete class either as class
57
+ attribute or upon creation in {obj}`BaseGlyph.init`.
58
+
59
+ Used to set a consistent size for drawing, invariant of the size
60
+ passed upon instantiation.
61
+
62
+ May be `None`, in which case {obj}`BaseContainer.size`,
63
+ {obj}`BaseContainer.width`, and {obj}`BaseContainer.height`
64
+ will return `None`.
65
+
66
+ Note that some {obj}`BaseGlyph` subclasses may require that
67
+ this field is not `None`.
68
+ """
69
+
70
+ viewbox_canon: tuple[tuple[float, float], tuple[float, float]] | None = None
71
+
72
+ _id: str
73
+ """
74
+ Unique id for this container.
75
+ """
76
+
77
+ _size: tuple[float, float] | None = None
78
+ """
79
+ Instantiated size, set upon glyph creation. If `None`, not rescaled;
80
+ uses canonical size.
81
+ """
82
+
83
+ _drawing: Drawing
84
+ """
85
+ Drawing object, unique to each Glyph.
86
+ """
87
+
88
+ _svg: SVG
89
+ """
90
+ Canonical SVG container for all elements generated by this glyph.
91
+ """
92
+
93
+ _group: Group
94
+ """
95
+ Group to hold wrapper SVG container when added to a parent glyph.
96
+ Transformations are performed on this group rather than the <svg> itself
97
+ as <svg> does not support transformations in SVG 1.1.
98
+ """
99
+
100
+ _parent: BaseContainer | None = None
101
+ """
102
+ Parent container, or None if not inserted in another Glyph via
103
+ {obj}`Glyph.insert_glyph` or passing `parent` upon instantiation.
104
+ In this case it has its own standalone Drawing, but may still be
105
+ added as a child of another Glyph.
106
+
107
+ Only set when inserted as a child of another Glyph.
108
+ """
109
+
110
+ _insert: tuple[float, float] | None = None
111
+ """
112
+ Point at which this glyph is inserted in parent.
113
+
114
+ Only set when inserted as a child of another Glyph.
115
+ """
116
+
117
+ def __init__(
118
+ self,
119
+ id_: str,
120
+ properties: Properties | None,
121
+ size: tuple[float, float] | None,
122
+ ):
123
+ self._id = id_
124
+ self._size = size
125
+ self._drawing = Drawing()
126
+ self._group = self._drawing.g(id_=f"{self._id}-group")
127
+
128
+ self.properties = (
129
+ self.DefaultProperties() if properties is None else properties
130
+ )
131
+
132
+ @property
133
+ def has_size(self) -> bool:
134
+ try:
135
+ _ = self.size
136
+ except AssertionError:
137
+ return False
138
+
139
+ return True
140
+
141
+ # TODO: document: raises exception + add custom exception
142
+ @property
143
+ def size(self) -> tuple[float, float]:
144
+ s = self._size or self.size_canon
145
+ assert s is not None
146
+ return s
147
+
148
+ @property
149
+ def width(self) -> float:
150
+ return self.size[0]
151
+
152
+ @property
153
+ def height(self) -> float:
154
+ return self.size[1]
155
+
156
+ def _init_post(self):
157
+ # create canonical svg
158
+ self._svg: SVG = self._drawing.svg(
159
+ id_=self._id,
160
+ size=self.size_canon,
161
+ class_=type(self).__name__,
162
+ )
163
+
164
+ # set viewbox, if configured
165
+ if self.viewbox_canon is not None:
166
+ x, y = self.viewbox_canon[0]
167
+ w, h = self.viewbox_canon[1]
168
+ self._svg.viewbox(x, y, w, h)
169
+ self._svg.fit()
170
+
171
+ # add to drawing for standalone glyph
172
+ if self._size is None:
173
+ # no scaling needed, add svg directly
174
+ self._drawing.add(self._svg)
175
+ self._group.add(self._svg)
176
+ else:
177
+ # create wrapper svg and rescale
178
+ svg_wrapper: SVG = self._create_wrapper_scale()
179
+ self._drawing.add(svg_wrapper)
180
+ self._group.add(svg_wrapper)
181
+
182
+ # set top-level dimensions explicitly as larger SVGs
183
+ # are unexpectedly truncated when sized to 100% (default)
184
+ if self.has_size:
185
+ self._drawing["width"] = str(self.size[0])
186
+ self._drawing["height"] = str(self.size[1])
187
+
188
+ def _bind(
189
+ self,
190
+ parent: BaseContainer,
191
+ insert: tuple[float, float] | None,
192
+ ):
193
+ self._parent = parent
194
+ self._insert = insert
195
+
196
+ # add group to parent, using wrapper svg for placement
197
+ wrapper_insert: SVG = self._drawing.svg(
198
+ insert=self._insert,
199
+ id_=f"{self._id}-wrapper-insert",
200
+ )
201
+
202
+ wrapper_insert.add(self._group)
203
+ parent._svg.add(wrapper_insert)
204
+
205
+ def _create_wrapper_scale(self) -> SVG:
206
+ """
207
+ Create SVG wrapper for canonical SVG object to handle
208
+ placement/scaling.
209
+ """
210
+
211
+ wrapper_scale: SVG = self._drawing.svg(
212
+ size=self._size,
213
+ id_=f"{self._id}-wrapper-scale",
214
+ )
215
+ self._rescale_svg(wrapper_scale, self._size)
216
+ wrapper_scale.add(self._svg)
217
+
218
+ return wrapper_scale
219
+
220
+ def _rescale_svg(
221
+ self,
222
+ svg: SVG,
223
+ size: tuple[float, float] | tuple[str, str] | None,
224
+ set_size: bool = False,
225
+ ):
226
+ if size is not None:
227
+ if set_size:
228
+ svg["width"] = str(size[0])
229
+ svg["height"] = str(size[1])
230
+
231
+ if self.size_canon is None:
232
+ logging.warning(
233
+ f"Rescaling glyph {self} which has no canonical size; viewbox will not be set, so results may be unexpected"
234
+ )
235
+ else:
236
+ svg.viewbox(
237
+ 0, 0, round(self.size_canon[0]), round(self.size_canon[1])
238
+ )
239
+ svg.fit()