diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0092180..98f83c0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,7 +28,7 @@ jobs: shell: bash -l {0} run: | pip install -e . - python selftest.py + pytest -v build: name: "Build wheels on ${{ matrix.os }} ${{ matrix.cibw_archs }}" @@ -63,17 +63,8 @@ jobs: - name: Build wheels uses: pypa/cibuildwheel@v3.3.1 env: - CIBW_TEST_COMMAND: python {project}/selftest.py - CIBW_BEFORE_BUILD_LINUX: yum install -y freetype-devel - CIBW_SKIP: "cp39-* cp310-* *-win32 *i686 *-musllinux*" - CIBW_TEST_REQUIRES: numpy pillow pytest - CIBW_TEST_SKIP: "*-win_arm64" + # see pyproject.toml for other options CIBW_ARCHS: "${{ matrix.cibw_archs }}" - # disable finding unintended freetype installations - CIBW_ENVIRONMENT_WINDOWS: "AGGDRAW_FREETYPE_ROOT=''" - # we use libpng/libfreetype from homebrew which has a current limit of - # macos 14 - CIBW_ENVIRONMENT_MACOS: MACOSX_DEPLOYMENT_TARGET=14 - name: upload uses: actions/upload-artifact@v6 with: diff --git a/MANIFEST.in b/MANIFEST.in index 17b2c5c..3a91bd7 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,3 @@ -include aggdraw.cxx include LICENSE.txt recursive-include agg2 *.cpp recursive-include agg2 *.h diff --git a/PKG-INFO b/PKG-INFO deleted file mode 100644 index bfe5db3..0000000 --- a/PKG-INFO +++ /dev/null @@ -1,16 +0,0 @@ -Metadata-Version: 1.0 -Name: aggdraw -Version: 1.2a3-20060212 -Summary: High quality drawing interface for PIL. -Home-page: http://www.effbot.org/zone/aggdraw.htm -Author: Fredrik Lundh -Author-email: fredrik@pythonware.com -License: Python (MIT style) -Download-URL: http://www.effbot.org/downloads#aggdraw -Description: The aggdraw module implements the basic WCK 2D Drawing Interface on - top of the AGG library. This library provides high-quality drawing, - with anti-aliasing and alpha compositing, while being fully compatible - with the WCK renderer. -Platform: Python 2.1 and later. -Classifier: Development Status :: 4 - Beta -Classifier: Topic :: Multimedia :: Graphics diff --git a/README.rst b/README.rst index d60f3ff..8f3497f 100644 --- a/README.rst +++ b/README.rst @@ -14,7 +14,8 @@ with the WCK renderer. The necessary AGG sources are included in the aggdraw source kit. -For posterity, reference `the old documentation `_. +For posterity, reference +`the old documentation `_. Build instructions (all platforms) ---------------------------------- @@ -57,7 +58,7 @@ Build instructions (all platforms) 4. Once aggdraw is installed run the tests:: - $ python selftest.py + $ pytest -v 5. Enjoy! diff --git a/aggdraw/__init__.py b/aggdraw/__init__.py new file mode 100644 index 0000000..897e01b --- /dev/null +++ b/aggdraw/__init__.py @@ -0,0 +1,7 @@ +from .core import Draw, Pen, Brush, Path, Symbol, Font +from .dib import Dib + +__all__ = ["Pen", "Brush", "Font", "Path", "Symbol", "Draw", "Dib"] + +VERSION = "1.4.1" +__version__ = VERSION diff --git a/aggdraw.cxx b/aggdraw/_aggdraw.cxx similarity index 99% rename from aggdraw.cxx rename to aggdraw/_aggdraw.cxx index 743787a..c04c678 100644 --- a/aggdraw.cxx +++ b/aggdraw/_aggdraw.cxx @@ -2634,7 +2634,7 @@ const char *mod_doc = "Python interface to the Anti-Grain Graphics Drawing libra #ifdef IS_PY3K static struct PyModuleDef moduledef = { PyModuleDef_HEAD_INIT, - "aggdraw", + "_aggdraw", mod_doc, -1, aggdraw_functions, @@ -2704,7 +2704,7 @@ aggdraw_init(void) #ifdef IS_PY3K PyMODINIT_FUNC -PyInit_aggdraw(void) +PyInit__aggdraw(void) { return aggdraw_init(); } diff --git a/aggdraw/core.py b/aggdraw/core.py new file mode 100644 index 0000000..07e1d44 --- /dev/null +++ b/aggdraw/core.py @@ -0,0 +1,472 @@ +import aggdraw._aggdraw as _aggdraw + + +class Brush(): + """Creates a brush object. + + The brush color can be an RGB tuple (e.g. `(255, 255, 255)`), a CSS-style color + name, or a color integer (0xAARRGGBB). + + Args: + color: The brush color. + opacity (int, optional): The opacity of the brush (from 0 to 255). Defaults to + a solid brush. + + """ + def __init__(self, color, opacity=255): + self._brush = _aggdraw.Brush(color, opacity) + + +class Pen(): + """Creates a pen object. + + The pen color can be a color tuple (e.g. `(255, 255, 255)`), a CSS-style color + name, or a color integer (0xAARRGGBB). + + Args: + color: The pen color. + width (int, optional): The width of the pen. + opacity (int, optional): The opacity of the pen (from 0 to 255). Defaults to + a solid pen. + + """ + def __init__(self, color, width=1, opacity=255): + self._pen = _aggdraw.Pen(color, width, opacity) + + +class Font(): + """Creates a font object. + + This creates a font object for use with :meth:`aggdraw.Draw.text` and + :meth:`aggdraw.Draw.textsize` from a TrueType font file. + + The font color can be a color tuple (e.g. `(255, 255, 255)`), a CSS-style color + name, or a color integer (0xAARRGGBB). + + Args: + color: The font color. + file: Path to a valid TrueType font file. + size (optional): The font size (in pixels). Defaults to 12. + opacity (int, optional): The opacity of the font (from 0 to 255). Defaults + to solid. + + """ + def __init__(self, color, file, size=12, opacity=255): + # NOTE: Only available if compiled with FreeType support + self._font = _aggdraw.Font(color, file, size, opacity) + + +class Symbol(): + """Symbol factory. + + This creates a symbol object from an SVG-style path descriptor for use with + :meth:`aggdraw.Draw.symbol`. + + The following operators are supported: + * M (move) + * L (line) + * H (horizontal line) + * V (vertical line) + * C (cubic bezier) + * S (smooth cubic bezier) + * Q (quadratic bezier) + * T (smooth quadratic bezier) + * Z (close path) + + Use lower-case operators for relative coordinates, upper-case for absolute + coordinates. + + Args: + path (str): An SVG-style path descriptor. + + """ + def __init__(self, path, scale=1.0): + # NOTE: 'scale' param is undocumented + self._path = _aggdraw.Symbol(path, scale) + + +class Path(): + """Path factory. + + This creates a path object for use with :meth:`aggdraw.Draw.path`. + + """ + def __init__(self, path=None): + # NOTE: 'path' param is undocumented but defines a initial set + # of points to connect with lines + if path: + self._path = _aggdraw.Path(path) + else: + self._path = _aggdraw.Path() + + def close(self): + """Closes the current path.""" + self._path.close() + + def coords(self): + """Returns the coordinates for the path. + + Curves are flattened before being returned. + + Returns: + list: A sequence in (x, y, x, y, ...) format. + + """ + return self._path.coords() + + def curveto(self, x1, y1, x2, y2, x, y): + """Adds a bezier curve segment to the path.""" + self._path.curveto(x1, y1, x2, y2, x, y) + + def lineto(self, x, y): + """Adds a line segment to the path.""" + self._path.lineto(x, y) + + def moveto(self, x, y): + """Moves the path pointer to the given location.""" + self._path.moveto(x, y) + + def rcurveto(self, x1, y1, x2, y2, x, y): + """Adds a bezier curve segment to the path using relative coordinates. + + Same as :meth:`~curveto`, but the coordinates are relative to the current + position. + + """ + self._path.rcurveto(x1, y1, x2, y2, x, y) + + def rlineto(self, x, y): + """Adds a line segment to the path using relative coordinates. + + Same as :meth:`~lineto`, but the coordinates are relative to the current + position. + + """ + self._path.rlineto(x, y) + + def rmoveto(self, x, y): + """Moves the path pointer relative to the current position.""" + self._path.rmoveto(x, y) + + +class Draw(): + """Creates a drawing interface object. + + The constructor can either take a PIL Image object, or mode and size specifiers. + + Examples:: + d = aggdraw.Draw(im) + d = aggdraw.Draw("RGB", (800, 600), "white") + + Args: + image_or_mode: A PIL image or a mode string. The following modes are + supported: “L”, “RGB”, “RGBA”, “BGR”, “BGRA”. + size (tuple, optional): The size of the image (width, height). + color (optional): An optional background color. If omitted, defaults + to white with full alpha. + + """ + def __init__(self, image_or_mode, size=None, color="white"): + if size: + self._draw = _aggdraw.Draw(image_or_mode, size, color) + else: + self._draw = _aggdraw.Draw(image_or_mode) + + @property + def size(self): + """tuple: The size in pixels of the draw surface (width, height).""" + return self._draw.size + + @property + def mode(self): + """str: The mode of the draw surface (e.g. "RGBA").""" + return self._draw.mode + + def _parse_args(self, brush=None, pen=None): + # Allow order of brush and pen to be reversed, matching C++ API + # NOTE: This segfaults for some reason if pen and brush are swapped here + # instead of leaving it to the C++ extension to handle + if brush: + brush = brush._pen if isinstance(brush, Pen) else brush._brush + if pen: + pen = pen._brush if isinstance(pen, Brush) else pen._pen + return (brush, pen) + + def arc(self, xy, start, end, pen=None): + """Draws an arc. + + Args: + xy: A 4-element Python sequence (x, y, x, y) with the upper-left corner + given first. + start (float): The start angle of the arc. + end (float): The end angle of the arc. + pen (:obj:`aggdraw.Pen`, optional): A pen to use for drawing the arc. + + """ + # NOTE: Why is pen optional? + if pen: + pen = pen._pen + self._draw.arc(xy, start, end, pen) + + def chord(self, xy, start, end, pen=None, brush=None): + """Draws a chord. + + If a brush is given, it is used to fill the chord. If a pen is given, + it is used to draw an outline around the chord. Either one (or both) + can be left out. + + Args: + xy: A 4-element Python sequence (x, y, x, y) with the upper-left corner + given first. + start (float): The start angle of the chord. + end (float): The end angle of the chord. + pen (:obj:`aggdraw.Pen`, optional): A pen to use for drawing an outline + around the chord. + brush (:obj:`aggdraw.Brush`, optional): A brush to use for filling + the chord. + + """ + brush, pen = self._parse_args(brush, pen) + self._draw.chord(xy, start, end, pen, brush) + + def ellipse(self, xy, pen=None, brush=None): + """Draws an ellipse. + + If a brush is given, it is used to fill the ellipse. If a pen is given, + it is used to draw an outline around the ellipse. Either one (or both) + can be left out. + + To draw a circle, make sure the coordinates form a square. + + Args: + xy: A bounding rectangle as a 4-element Python sequence (x, y, x, y), + with the upper-left corner given first. + pen (:obj:`aggdraw.Pen`, optional): A pen to use for drawing an outline + around the ellipse. + brush (:obj:`aggdraw.Brush`, optional): A brush to use for filling + the ellipse. + + """ + brush, pen = self._parse_args(brush, pen) + self._draw.ellipse(xy, brush, pen) + + def flush(self): + """Updates the associated image. + + If the drawing area is attached to a PIL Image object, this method must + be called to make sure that the image updated. + + """ + return self._draw.flush() + + def frombytes(self, data): + """Copies data from a bytes object to the drawing area. + + Args: + data (bytes): Packed image data compatible with PIL's tobytes method. + + """ + self._draw.frombytes(data) + + def line(self, xy, pen=None): + """Draws a line. + + If the sequence contains multiple x/y pairs, multiple connected lines + will be drawn. + + Args: + xy: A Python sequence in the format (x, y, x, y, ...) + pen (:obj:`aggdraw.Pen`, optional): A pen to use for drawing the line. + + """ + if isinstance(xy, Path): + xy = xy._path + if pen: + pen = pen._pen + self._draw.line(xy, pen) + + def path(self, xy, path, pen=None, brush=None): + """Draws a path at the given positions. + + If a brush is given, it is used to fill the path. If a pen is given, + it is used to draw an outline around the path. Either one (or both) + can be left out. + + Args: + xy: A Python sequence in the format (x, y, x, y, ...) + path (:obj:`aggdraw.Path`): The Path object to draw. + pen (:obj:`aggdraw.Pen`, optional): A pen to use for drawing an outline + around the path. + brush (:obj:`aggdraw.Brush`, optional): A brush to use for filling + the path. + + """ + brush, pen = self._parse_args(brush, pen) + self._draw.path(xy, path._path, brush, pen) + + def pieslice(self, xy, start, end, pen=None, brush=None): + """Draws a pie slice. + + If a brush is given, it is used to fill the pie slice. If a pen is given, + it is used to draw an outline around the pie slice. Either one (or both) + can be left out. + + Args: + xy: A 4-element Python sequence (x, y, x, y) with the upper-left corner + given first. + start (float): The start angle of the pie slice. + end (float): The end angle of the pie slice. + pen (:obj:`aggdraw.Pen`, optional): A pen to use for drawing an outline + around the pie slice. + brush (:obj:`aggdraw.Brush`, optional): A brush to use for filling + the pie slice. + + """ + brush, pen = self._parse_args(brush, pen) + self._draw.pieslice(xy, start, end, pen, brush) + + def polygon(self, xy, pen=None, brush=None): + """Draws a polygon. + + If a brush is given, it is used to fill the polygon. If a pen is given, + it is used to draw an outline around the polygon. Either one (or both) + can be left out. + + Args: + xy: A Python sequence (x, y, x, y, ...). + pen (:obj:`aggdraw.Pen`, optional): A pen to use for drawing an outline + around the polygon. + brush (:obj:`aggdraw.Brush`, optional): A brush to use for filling + the polygon. + + """ + if isinstance(xy, Path): + xy = xy._path + brush, pen = self._parse_args(brush, pen) + self._draw.polygon(xy, brush, pen) + + def rectangle(self, xy, pen=None, brush=None): + """Draws a rectangle. + + If a brush is given, it is used to fill the rectangle. If a pen is given, + it is used to draw an outline around the rectangle. Either one (or both) + can be left out. + + Args: + xy: A 4-element Python sequence (x, y, x, y), with the upper left corner + given first. + pen (:obj:`aggdraw.Pen`, optional): A pen to use for drawing an outline + around the rectangle. + brush (:obj:`aggdraw.Brush`, optional): A brush to use for filling + the rectangle. + + """ + brush, pen = self._parse_args(brush, pen) + self._draw.rectangle(xy, brush, pen) + + def rounded_rectangle(self, xy, radius, pen=None, brush=None): + """Draws a rounded rectangle. + + If a brush is given, it is used to fill the rectangle. If a pen is given, + it is used to draw an outline around the rectangle. Either one (or both) + can be left out. + + Args: + xy: A 4-element Python sequence (x, y, x, y), with the upper left corner + given first. + radius (float): The corner radius. + pen (:obj:`aggdraw.Pen`, optional): A pen to use for drawing an outline + around the rectangle. + brush (:obj:`aggdraw.Brush`, optional): A brush to use for filling + the rectangle. + + """ + brush, pen = self._parse_args(brush, pen) + self._draw.rounded_rectangle(xy, radius, brush, pen) + + def setantialias(self, flag): + """Controls anti-aliasing. + + Args: + flag (bool): True to enable anti-aliasing, False to disable it. + + """ + self._draw.setantialias(flag) + + def settransform(self, transform=None): + """Replaces the current drawing transform. + + The transform must either be a (dx, dy) translation tuple, or a PIL-style + (a, b, c, d, e, f) affine transform tuple. If the transform is omitted, + it is reset. + + Example:: + draw.settransform((dx, dy)) + + Args: + transform (tuple, optional): The new transform, or None to reset. + + """ + if transform: + self._draw.settransform(transform) + else: + self._draw.settransform() + + def symbol(self, xy, symbol, pen=None, brush=None): + """Draws a symbol at the given positions. + + If a brush is given, it is used to fill the symbol. If a pen is given, + it is used to draw an outline around the symbol. Either one (or both) + can be left out. + + Args: + xy: A Python sequence in the format (x, y, x, y, ...) + symbol (:obj:`aggdraw.Symbol`): The Symbol object to draw. + pen (:obj:`aggdraw.Pen`, optional): A pen to use for drawing an outline + around the symbol. + brush (:obj:`aggdraw.Brush`, optional): A brush to use for filling + the symbol. + + """ + brush, pen = self._parse_args(brush, pen) + self._draw.symbol(xy, symbol._path, brush, pen) + + def text(self, xy, text, font): + """Draws a text string at a given position using a given font. + + Example:: + font = aggdraw.Font(black, times) + draw.text((100, 100), "hello, world", font) + + Args: + xy: A 2-element Python sequence (x, y). + text (str): A string of text to render. + font (:obj:`aggdraw.Font`): The font object to render with. + + Returns: + tuple: A (width, height) tuple. + + """ + self._draw.text(xy, text, font._font) + + def textsize(self, text, font): + """Determines the size of a text string. + + Args: + text (str): A string of text to measure. + font (:obj:`aggdraw.Font`): The font object to render with. + + Returns: + tuple: A (width, height) tuple. + + """ + return self._draw.textsize(text, font._font) + + def tobytes(self): + """Copies data from the drawing area to a bytes object. + + Returns: + bytes: Packed image data compatible with PIL's frombytes method. + + """ + return self._draw.tobytes() diff --git a/aggdraw/dib.py b/aggdraw/dib.py new file mode 100644 index 0000000..a420896 --- /dev/null +++ b/aggdraw/dib.py @@ -0,0 +1,39 @@ +import platform + +import aggdraw._aggdraw as _aggdraw +from .core import Draw + + +class Dib(Draw): + """Creates a drawing interface object that can be copied to a window. + + Windows-only. + + This object has the same methods as Draw, plus an expose method that copies + the contents to a given window or device context. + + Args: + mode (str): The pixel mode of the draw surface. Currently only supports + "RGB". + size (tuple): The size (width, height) of the drawing surface in pixels. + color (tuple, optional): A default background fill color for the surface. + + """ + def __init__(self, mode, size, color=None): + if platform.system() != "Windows": + e = "Dib class only available on Windows." + raise RuntimeError(e) + self._draw = _aggdraw.Dib(mode, size, color) + + def expose(self, hwnd=0, hwc=0): + """Copies the contents of the drawing object to the given window or context. + + You must provide either a hwnd or a hdc keyword argument. + + Args: + hwnd (int): An HWND handle cast to an integer. + hdc (int): An HDC handle cast to an integer. + + """ + self._draw.expose(hwnd=hwnd, hdc=hdc) + diff --git a/aggdraw/tests/__init__.py b/aggdraw/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/selftest.py b/aggdraw/tests/test_aggdraw.py similarity index 96% rename from selftest.py rename to aggdraw/tests/test_aggdraw.py index 47643e1..a580c3c 100644 --- a/selftest.py +++ b/aggdraw/tests/test_aggdraw.py @@ -1,7 +1,3 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -# sanity check - import pytest @@ -158,8 +154,3 @@ def test_transform(): draw.settransform((1, 0, 250, 0, 1, 250)) draw.settransform((2.0, 0.5, 250, 0.5, 2.0, 250)) draw.settransform() - - -if __name__ == "__main__": - import sys - sys.exit(pytest.main(sys.argv)) diff --git a/doc/source/index.rst b/doc/source/index.rst index e28ef57..0cc699f 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -1,23 +1,23 @@ AggDraw ======= -The AggDraw library provides a python interface on top of -`the AGG library `_. The library was originally -developed by Fredrik Lundh (effbot), but has since been picked up by various -developers. It is currently maintained by the PyTroll developmer group. The -official repository can be found on GitHub: +The AggDraw library provides a Python interface on top of +`the AGG library `_. +The library was originally developed by Fredrik Lundh (effbot), but has since +been picked up by various developers. It is currently maintained by the PyTroll +developer group. The official repository can be found on GitHub: https://github.com/pytroll/aggdraw The original documentation by effbot is -`still available `_ but may be out -of date with the current version of the library. Original examples will be -migrated as time is available (pull requests welcome). +`still available `_ +but is out of date with the current version of the library. Original examples +will be migrated as time is available (pull requests welcome). Installation ------------ -Aggdraw is available on Linux, OSX, and Windows. It can be installed from PyPI +Aggdraw is available on Linux, macOS, and Windows. It can be installed from PyPI with pip: .. code-block:: bash diff --git a/pyproject.toml b/pyproject.toml index 4b51ebc..13914f7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,20 @@ [build-system] requires = ["packaging", "setuptools"] build-backend = "setuptools.build_meta" + +[tool.cibuildwheel] +test-command = "pytest --pyargs aggdraw.tests" +skip = "cp39-* cp310-* *-win32 *i686 *-musllinux*" +test-requires = ["numpy", "pillow", "pytest"] +test-skip = "*-win_arm64" + +[tool.cibuildwheel.linux] +before-build = "yum install -y freetype-devel" + +[tool.cibuildwheel.windows] +# disable finding unintended freetype installations +environment = "AGGDRAW_FREETYPE_ROOT=''" + +[tool.cibuildwheel.macos] +# we use libpng/libfreetype from homebrew which has a current limit of macos 14 +environment = "MACOSX_DEPLOYMENT_TARGET=14" diff --git a/setup.py b/setup.py index 5863557..6da6b4e 100644 --- a/setup.py +++ b/setup.py @@ -13,19 +13,25 @@ # from __future__ import print_function import os +import re import sys import subprocess import platform from sysconfig import get_config_var from packaging.version import Version -from setuptools import setup, Extension - -VERSION = "1.4.1" +from setuptools import setup, Extension, find_packages SUMMARY = "High quality drawing interface for PIL." README = open("README.rst", "r").read() +def get_version(path): + version_regex = re.compile(r'\nVERSION = "([\w\.]+)"') + with open(path, "r") as f: + return version_regex.findall(f.read())[0] + +VERSION = get_version(os.path.join("aggdraw", "__init__.py")) + def is_platform_mac(): return sys.platform == 'darwin' @@ -153,11 +159,11 @@ def _get_freetype_with_pkgconfig(): description=SUMMARY, long_description=README, long_description_content_type="text/x-rst", - download_url="http://www.effbot.org/downloads#aggdraw", license="Python (MIT style)", url="https://github.com/pytroll/aggdraw", + packages=find_packages(), ext_modules=[ - Extension("aggdraw", ["aggdraw.cxx"] + sources, + Extension("aggdraw._aggdraw", ["aggdraw/_aggdraw.cxx"] + sources, define_macros=defines, include_dirs=include_dirs, library_dirs=library_dirs, libraries=libraries,