Skip to content

geomotif.io

Getting designs out of Python, and back in again.

Five things can be written, and they answer five different questions:

========================== ==================================== =========== What Written by Reads back ========================== ==================================== =========== The coordinates :func:save_points -- The design, strokes and all :func:save_design JSON only The recipe that made it :func:save_spec yes A picture of it :func:save_svg, :func:save_dxf -- A still picture of it :func:save_png, :func:save_jpeg -- A moving picture of it :func:save_gif -- ========================== ==================================== ===========

A spec is the one worth reaching for by default: it is a few hundred bytes rather than a few hundred kilobytes, it survives a change of point count, and it is what the gallery manifest and the CLI's --spec flag are built on.

SVG is for anything that displays -- a browser, a vector editor, these docs -- and DXF for anything that cuts, mills or plots. The two disagree about which way y points, which each module handles rather than leaving to the caller. The two rasters are the still and the moving picture: :func:save_png and :func:save_jpeg write the finished design as one frame (a PNG lossless, a JPEG lossy and smaller), and :func:save_gif writes the animation from :mod:geomotif.animate, since a moving picture has no vector form that plays everywhere.

:mod:geomotif.io.plotter is the SVG writer again with a pen plotter in mind: real millimeters on a named sheet of paper, and a pass that joins strokes up and orders them so the pen wastes less time in the air.

Every writer here is pure standard library, so the zero-dependency core stays zero-dependency all the way out to the file -- LZW and zlib compression, color tables, chunk CRCs and all.

Modules:

Name Description
dxf

Write a design as DXF R12, in pure standard library.

gif

Write an animated GIF, in pure standard library.

jpeg

Write a JPEG still, in pure standard library.

plotter

Preparing a design for a pen plotter.

png

Write a PNG still, in pure standard library.

points

Write coordinates out, and read a structured design back in.

raster

Turn a design into pixels, in pure standard library.

spec

A design's recipe rather than its points: the motif and its parameters.

svg

Write a design as SVG, in pure standard library.

Classes:

Name Description
Raster

An in-memory picture, top-left origin.

Functions:

Name Description
save_dxf

Write a design to a DXF file and return the path written.

to_dxf

Render a design as a DXF R12 document.

save_gif

Write an animated GIF and return the path written.

to_gif

Render a sequence of designs as an animated GIF.

save_jpeg

Render a design -- or re-encode a raster -- as JPEG and write it.

to_jpeg

Render a design -- or re-encode a :class:~geomotif.io.Raster -- as JPEG.

save_plotter_svg

Write a plotter-ready SVG and return the path written.

to_plotter_svg

Render a design as an SVG measured in real millimeters.

to_vpype

Return this design as a vpype document, fitted to a page.

save_png

Render a design -- or re-encode a raster -- as a PNG and write it.

to_png

Render a design -- or re-encode a :class:~geomotif.io.Raster -- as PNG.

load_design

Read a design back from a JSON file written by :func:save_design.

save_design

Write a design, strokes kept apart, and return the path written.

save_points

Write points to a file and return the path written.

colors_in

Return the palette a set of designs needs, background first.

colours_in

Keep the British spelling working while it is phased out.

quantize

Shrink RGBA frames to one shared indexed palette of at most max_colors.

rasterize

Draw a design into an indexed bitmap.

rasterize_rgba

Draw a design into a full-color RGBA frame, supersampled and antialiased.

from_spec

Rebuild the motif a spec describes.

load_spec

Read a spec file and return the motif it describes.

save_spec

Write a motif's recipe to a JSON file and return the path written.

to_spec

Return the JSON-ready recipe for a motif, or for the design it built.

save_svg

Write a design to an SVG file and return the path written.

to_svg

Render a design as an SVG document.

Raster dataclass

Raster(width: int, height: int, pixels: bytes, palette: tuple[str, ...] = (), mode: str = 'indexed')

An in-memory picture, top-left origin.

A :class:Raster holds either an indexed bitmap -- one palette index per pixel, which is what GIF wants -- or a direct one -- RGB or RGBA bytes per pixel, which is what PNG and JPEG want. Everything downstream (antialiasing, styling, the encoders) feeds off one of the two, so the picture is drawn once and encoded many ways.

Parameters:

Name Type Description Default
width int

Size in pixels.

required
height int

Size in pixels.

required
pixels bytes

width * height indices (indexed), or width * height * 4 RGBA or width * height * 3 RGB bytes (direct), row-major.

required
palette tuple of str

The colors an indexed bitmap's indices name, as #rrggbb. Index 0 is the background. Unused by a direct bitmap.

()
mode str

"indexed", "rgb" or "rgba". Defaults to "indexed" so a bare four-argument :class:Raster is the picture it has always been.

'indexed'

save_dxf

save_dxf(design: Design, path: str | PathLike[str], *, layer: str = '0', precision: int = 4) -> Path

Write a design to a DXF file and return the path written.

See :func:to_dxf for what the options mean.

Source code in src/geomotif/io/dxf.py
def save_dxf(
    design: Design,
    path: str | PathLike[str],
    *,
    layer: str = "0",
    precision: int = 4,
) -> pathlib.Path:
    """Write a design to a DXF file and return the path written.

    See :func:`to_dxf` for what the options mean.
    """
    target = pathlib.Path(path)
    target.write_text(to_dxf(design, layer=layer, precision=precision))
    return target

to_dxf

to_dxf(design: Design, *, layer: str = '0', precision: int = 4) -> str

Render a design as a DXF R12 document.

Parameters:

Name Type Description Default
design Design

What to write. Strokes become POLYLINE entities -- closed ones carry the closed flag rather than a repeated final vertex -- and loose points become POINT entities.

required
layer str

Layer for geometry that does not name one of its own. "0" is the layer every DXF file already has; any other name is declared in the file's layer table, so the result is valid on its own rather than relying on the reader to invent the layer.

'0'
precision int

Decimal places for coordinates.

4

Returns:

Type Description
str

A complete DXF R12 document.

Raises:

Type Description
ValueError

If the design is empty, the precision is negative, or a layer name -- the argument's or a style's -- is not one R12 permits.

Source code in src/geomotif/io/dxf.py
def to_dxf(design: Design, *, layer: str = "0", precision: int = 4) -> str:
    """Render a design as a DXF R12 document.

    Parameters
    ----------
    design : Design
        What to write. Strokes become ``POLYLINE`` entities -- closed ones
        carry the closed flag rather than a repeated final vertex -- and loose
        points become ``POINT`` entities.
    layer : str, optional
        Layer for geometry that does not name one of its own. ``"0"`` is the
        layer every DXF file already has; any other name is declared in the
        file's layer table, so the result is valid on its own rather than
        relying on the reader to invent the layer.
    precision : int, optional
        Decimal places for coordinates.

    Returns
    -------
    str
        A complete DXF R12 document.

    Raises
    ------
    ValueError
        If the design is empty, the precision is negative, or a layer name --
        the argument's or a style's -- is not one R12 permits.
    """
    if not len(design):
        raise ValueError("cannot write an empty design to DXF: there is nothing to draw")
    if precision < 0:
        raise ValueError(f"precision must be >= 0, got {precision}")

    used = _layers_used(design, layer)
    bounds = design.bounds

    def num(value: float) -> str:
        return f"{value:.{precision}f}"

    parts = [
        *_section(
            "HEADER",
            _tag(9, "$ACADVER"),
            _tag(1, "AC1009"),
            # The drawing extents: what "zoom to fit" uses when the file opens.
            _tag(9, "$EXTMIN"),
            _point(bounds.min_x, bounds.min_y, num),
            _tag(9, "$EXTMAX"),
            _point(bounds.max_x, bounds.max_y, num),
        ),
        *_section("TABLES", *_layer_table(used)),
        *_section("ENTITIES", *_entities(design, layer, num)),
        _tag(0, "EOF"),
    ]
    return "".join(parts)

save_gif

save_gif(frames: Sequence[Design], path: str | PathLike[str], **kwargs: Any) -> Path

Write an animated GIF and return the path written.

Keyword arguments are passed straight through to :func:to_gif.

Source code in src/geomotif/io/gif.py
def save_gif(frames: Sequence[Design], path: str | PathLike[str], **kwargs: Any) -> pathlib.Path:
    """Write an animated GIF and return the path written.

    Keyword arguments are passed straight through to :func:`to_gif`.
    """
    target = pathlib.Path(path)
    target.write_bytes(to_gif(frames, **kwargs))
    return target

to_gif

to_gif(frames: Sequence[Design], *, width: int = 480, height: int = 480, padding: float = 8.0, fps: float = 20.0, loop: int = 0, ink: str = '#0b0b0b', background: str = '#ffffff', thickness: int = 1, dot_radius: int | None = None, antialias: bool = False, aa_level: int = 8, dither: bool = True, transparent: bool = False) -> bytes

Render a sequence of designs as an animated GIF.

Every frame is drawn against the same world rectangle -- the union of all of their bounds -- and the same color table, so a drawing that grows stays put instead of swimming about as its own extent changes.

Parameters:

Name Type Description Default
frames sequence of Design

What to draw, in order. At least one is needed.

required
width int

Canvas size in pixels.

480
height int

Canvas size in pixels.

480
padding float

Margin reserved on all four sides, in pixels.

8.0
fps float

Frames per second. GIF stores a delay in hundredths of a second, so the rate is rounded to what the format can actually say.

20.0
loop int

How many times to play; 0 means forever, which is what everyone expects of a GIF, and is the default. 1 plays it once and stops.

0
ink str

Default stroke color and the color behind everything. A stroke with a style of its own is drawn in that instead.

'#0b0b0b'
background str

Default stroke color and the color behind everything. A stroke with a style of its own is drawn in that instead.

'#0b0b0b'
thickness int

Stroke width in pixels.

1
dot_radius int

Radius for loose points. Defaults to thickness.

None
antialias bool

Supersample and blend edges. Off by default, so the output is exactly the hard-edged picture it has always been.

False
aa_level int

When antialiasing, how many shades an edge may blend into per color pair, which is what keeps the whole animation inside GIF's 256-color budget. Must be >= 1.

8
dither bool

Error-diffuse the round-off onto a gradient, which keeps an antialiased edge on a colored ground from banding inside the palette budget. On by default.

True
transparent bool

Leave the background empty. Index 0 is flagged transparent in the file, so the drawing sits over whatever the page shows instead of a painted ground. Off by default, so the picture stays what it has always been.

False

Returns:

Type Description
bytes

A complete GIF89a file.

Raises:

Type Description
ValueError

If there are no frames, the rate is not positive, the antialias level is zero, or the designs' own colors (the inks and background) exceed 256 between them -- which is GIF's limit, not this writer's.

Source code in src/geomotif/io/gif.py
def to_gif(
    frames: Sequence[Design],
    *,
    width: int = 480,
    height: int = 480,
    padding: float = 8.0,
    fps: float = 20.0,
    loop: int = 0,
    ink: str = "#0b0b0b",
    background: str = "#ffffff",
    thickness: int = 1,
    dot_radius: int | None = None,
    antialias: bool = False,
    aa_level: int = 8,
    dither: bool = True,
    transparent: bool = False,
) -> bytes:
    """Render a sequence of designs as an animated GIF.

    Every frame is drawn against the **same** world rectangle -- the union of
    all of their bounds -- and the same color table, so a drawing that grows
    stays put instead of swimming about as its own extent changes.

    Parameters
    ----------
    frames : sequence of Design
        What to draw, in order. At least one is needed.
    width, height : int
        Canvas size in pixels.
    padding : float
        Margin reserved on all four sides, in pixels.
    fps : float
        Frames per second. GIF stores a delay in hundredths of a second, so
        the rate is rounded to what the format can actually say.
    loop : int
        How many times to play; ``0`` means forever, which is what everyone
        expects of a GIF, and is the default. ``1`` plays it once and stops.
    ink, background : str
        Default stroke color and the color behind everything. A stroke with
        a style of its own is drawn in that instead.
    thickness : int
        Stroke width in pixels.
    dot_radius : int, optional
        Radius for loose points. Defaults to ``thickness``.
    antialias : bool
        Supersample and blend edges. Off by default, so the output is exactly
        the hard-edged picture it has always been.
    aa_level : int
        When antialiasing, how many shades an edge may blend into per color
        pair, which is what keeps the whole animation inside GIF's 256-color
        budget. Must be >= 1.
    dither : bool
        Error-diffuse the round-off onto a gradient, which keeps an
        antialiased edge on a colored ground from banding inside the palette
        budget. On by default.
    transparent : bool
        Leave the background empty. Index 0 is flagged transparent in the
        file, so the drawing sits over whatever the page shows instead of a
        painted ground. Off by default, so the picture stays what it has
        always been.

    Returns
    -------
    bytes
        A complete GIF89a file.

    Raises
    ------
    ValueError
        If there are no frames, the rate is not positive, the antialias level
        is zero, or the designs' own colors (the inks and background) exceed
        256 between them -- which is GIF's limit, not this writer's.
    """
    if not frames:
        raise ValueError("cannot write a GIF with no frames")
    if fps <= 0:
        raise ValueError(f"fps must be > 0, got {fps}")
    if loop < 0:
        raise ValueError(f"loop must be >= 0, got {loop}")
    if aa_level < 1:
        raise ValueError(f"aa_level must be >= 1, got {aa_level}")

    seeds = colors_in(frames, ink=ink, background=background)
    if len(seeds) > 256:
        raise ValueError(
            f"a GIF has at most 256 colors and these designs need {len(seeds)}; "
            f"restyle them onto fewer, or write them as SVG instead"
        )
    shared = _union(frames)
    delay = max(_MIN_DELAY, round(100.0 / fps))

    if antialias:
        frame_rgba = [
            rasterize_rgba(
                frame,
                width=width,
                height=height,
                padding=padding,
                bounds=shared,
                ink=ink,
                background=background,
                thickness=thickness,
                dot_radius=dot_radius,
                aa_level=aa_level,
                transparent=transparent,
            )
            for frame in frames
        ]
        rasters = quantize(
            frame_rgba, seeds=seeds, max_colors=256, dither=dither, transparent=transparent
        )
        palette = rasters[0].palette
    else:
        palette = seeds
        rasters = tuple(
            rasterize(
                frame,
                width=width,
                height=height,
                padding=padding,
                bounds=shared,
                palette=palette,
                thickness=thickness,
                dot_radius=dot_radius,
            )
            for frame in frames
        )

    depth = _depth(len(palette))
    parts = [_header(width, height, depth), _color_table(palette, depth)]
    # loop=1 is the absence of the extension, not a count of one: the block
    # says how many times to repeat *after* the first play, and there is no
    # value of it that means "stop after one".
    if len(rasters) > 1 and loop != 1:
        parts.append(_looping(loop))
    parts.extend(
        _frame(raster, delay=delay, animated=len(rasters) > 1, transparent=transparent)
        for raster in rasters
    )
    parts.append(b";")
    return b"".join(parts)

save_jpeg

save_jpeg(source: Design | Raster, path: str | PathLike[str], **kwargs: Any) -> Path

Render a design -- or re-encode a raster -- as JPEG and write it.

Keyword arguments are passed straight through to :func:to_jpeg.

Source code in src/geomotif/io/jpeg.py
def save_jpeg(source: Design | Raster, path: str | PathLike[str], **kwargs: Any) -> pathlib.Path:
    """Render a design -- or re-encode a raster -- as JPEG and write it.

    Keyword arguments are passed straight through to :func:`to_jpeg`.
    """
    target = pathlib.Path(path)
    target.write_bytes(to_jpeg(source, **kwargs))
    return target

to_jpeg

to_jpeg(source: Design | Raster, *, width: int = 480, height: int = 480, padding: float = 8.0, ink: str = '#0b0b0b', background: str = '#ffffff', thickness: int = 1, dot_radius: int | None = None, antialias: bool = False, aa_level: int = 8, quality: int = 85) -> bytes

Render a design -- or re-encode a :class:~geomotif.io.Raster -- as JPEG.

A still is drawn once, exactly the way :func:to_png draws a frame, and then encoded with baseline JPEG: 4:2:0 chroma subsampling, an 8x8 DCT on each block, quality-scaled quantization, and Huffman coding against the reference tables. The styling parameters are shared with the PNG and GIF writers so one summoning controls all three.

Parameters:

Name Type Description Default
source Design or Raster

What to write. A design is drawn; a raster is encoded as it is.

required
width int

Canvas size in pixels. Ignored when source is already a raster.

480
height int

Canvas size in pixels. Ignored when source is already a raster.

480
padding float

Margin reserved on all four sides, in pixels.

8.0
ink str

Default stroke color and the color behind everything.

'#0b0b0b'
background str

Default stroke color and the color behind everything.

'#0b0b0b'
thickness int

Stroke width in pixels.

1
dot_radius int

Radius for loose points. Defaults to thickness.

None
antialias bool

Supersample and blend edges. Off by default, so the edges are the same hard Bresenham edges they have always been.

False
aa_level int

How many shades an antialiased edge may blend into. A JPEG keeps full color anyway, so this only bounds the drawing, not the encode.

8
quality int

From 0 (smallest, most loss) to 100 (closest to the original). This is what the quantization tables are scaled by.

85

Returns:

Type Description
bytes

A complete baseline JPEG file.

Raises:

Type Description
ValueError

If quality is outside 0-100.

Source code in src/geomotif/io/jpeg.py
def to_jpeg(
    source: Design | Raster,
    *,
    width: int = 480,
    height: int = 480,
    padding: float = 8.0,
    ink: str = "#0b0b0b",
    background: str = "#ffffff",
    thickness: int = 1,
    dot_radius: int | None = None,
    antialias: bool = False,
    aa_level: int = 8,
    quality: int = 85,
) -> bytes:
    """Render a design -- or re-encode a :class:`~geomotif.io.Raster` -- as JPEG.

    A still is drawn once, exactly the way :func:`to_png` draws a frame, and
    then encoded with baseline JPEG: 4:2:0 chroma subsampling, an 8x8 DCT on
    each block, quality-scaled quantization, and Huffman coding against the
    reference tables. The styling parameters are shared with the PNG and GIF
    writers so one summoning controls all three.

    Parameters
    ----------
    source : Design or Raster
        What to write. A design is drawn; a raster is encoded as it is.
    width, height : int
        Canvas size in pixels. Ignored when ``source`` is already a raster.
    padding : float
        Margin reserved on all four sides, in pixels.
    ink, background : str
        Default stroke color and the color behind everything.
    thickness : int
        Stroke width in pixels.
    dot_radius : int, optional
        Radius for loose points. Defaults to ``thickness``.
    antialias : bool
        Supersample and blend edges. Off by default, so the edges are the
        same hard Bresenham edges they have always been.
    aa_level : int
        How many shades an antialiased edge may blend into. A JPEG keeps full
        color anyway, so this only bounds the drawing, not the encode.
    quality : int
        From 0 (smallest, most loss) to 100 (closest to the original). This is
        what the quantization tables are scaled by.

    Returns
    -------
    bytes
        A complete baseline JPEG file.

    Raises
    ------
    ValueError
        If ``quality`` is outside 0-100.
    """
    if not _MIN_QUALITY <= quality <= _MAX_QUALITY:
        raise ValueError(
            f"quality must be between {_MIN_QUALITY} and {_MAX_QUALITY}, got {quality}"
        )

    rgb_frame = _rgb_frame(
        source,
        width=width,
        height=height,
        padding=padding,
        ink=ink,
        background=background,
        thickness=thickness,
        dot_radius=dot_radius,
        antialias=antialias,
        aa_level=aa_level,
    )
    return _encode(rgb_frame, quality)

save_plotter_svg

save_plotter_svg(design: Design, path: str | PathLike[str], **kwargs: Any) -> Path

Write a plotter-ready SVG and return the path written.

Keyword arguments are passed straight through to :func:to_plotter_svg.

Source code in src/geomotif/io/plotter.py
def save_plotter_svg(design: Design, path: str | PathLike[str], **kwargs: Any) -> pathlib.Path:
    """Write a plotter-ready SVG and return the path written.

    Keyword arguments are passed straight through to :func:`to_plotter_svg`.
    """
    target = pathlib.Path(path)
    target.write_text(to_plotter_svg(design, **kwargs))
    return target

to_plotter_svg

to_plotter_svg(design: Design, *, paper: str = 'a4', margin: float = 10.0, landscape: bool = False, stroke_width: float = DEFAULT_PEN, **kwargs: Any) -> str

Render a design as an SVG measured in real millimeters.

Parameters:

Name Type Description Default
design Design

What to plot.

required
paper str

A name from :data:PAPER.

'a4'
margin float

Border to leave unplotted, in millimeters. Worth more than you would think: most plotters cannot reach the last few millimeters of a sheet.

10.0
landscape bool

Turn the paper on its side.

False
stroke_width float

Pen width in millimeters. Only affects how the file looks -- a plotter draws with the pen it has -- but getting it right makes the preview honest.

DEFAULT_PEN
**kwargs Any

Passed to :func:~geomotif.io.svg.to_svg.

{}

Returns:

Type Description
str

An SVG whose width and height carry mm, whose viewBox is the same numbers, and whose layers are the groups vpype and Inkscape read.

Source code in src/geomotif/io/plotter.py
def to_plotter_svg(
    design: Design,
    *,
    paper: str = "a4",
    margin: float = 10.0,
    landscape: bool = False,
    stroke_width: float = DEFAULT_PEN,
    **kwargs: Any,
) -> str:
    """Render a design as an SVG measured in real millimeters.

    Parameters
    ----------
    design : Design
        What to plot.
    paper : str
        A name from :data:`PAPER`.
    margin : float
        Border to leave unplotted, in millimeters. Worth more than you would
        think: most plotters cannot reach the last few millimeters of a sheet.
    landscape : bool
        Turn the paper on its side.
    stroke_width : float
        Pen width in millimeters. Only affects how the file *looks* -- a
        plotter draws with the pen it has -- but getting it right makes the
        preview honest.
    **kwargs
        Passed to :func:`~geomotif.io.svg.to_svg`.

    Returns
    -------
    str
        An SVG whose ``width`` and ``height`` carry ``mm``, whose ``viewBox``
        is the same numbers, and whose layers are the groups ``vpype`` and
        Inkscape read.
    """
    width, height = page_size(paper, landscape=landscape)
    # The writer does the placing. Fitting to the page here as well would only
    # be undone: to_svg fits whatever it is given into the canvas it is given,
    # so a margin applied first is scaled straight back out to the paper edge.
    return to_svg(
        design,
        width=width,
        height=height,
        padding=margin,
        stroke_width=stroke_width,
        units="mm",
        **kwargs,
    )

to_vpype

to_vpype(design: Design, *, paper: str = 'a4', margin: float = 10.0, landscape: bool = False) -> Any

Return this design as a vpype document, fitted to a page.

vpype is the pen-plotter toolchain -- occlusion, hatching, HPGL output, and a plotting pipeline this library is deliberately not trying to be. This is the door to it: one layer per :mod:geomotif.core.style layer, named the same, on a page of the size asked for.

::

import vpype_cli

document = to_vpype(design, paper="a3")
vpype_cli.execute("linemerge linesort write out.svg", document)

Returns:

Type Description
Document

Typed loosely because vpype is not a dependency and this module must stay importable without it.

Raises:

Type Description
ImportError

If vpype is not installed, with the command that installs it.

Source code in src/geomotif/io/plotter.py
def to_vpype(
    design: Design,
    *,
    paper: str = "a4",
    margin: float = 10.0,
    landscape: bool = False,
) -> Any:
    """Return this design as a ``vpype`` document, fitted to a page.

    ``vpype`` is the pen-plotter toolchain -- occlusion, hatching, HPGL output,
    and a plotting pipeline this library is deliberately not trying to be. This
    is the door to it: one layer per :mod:`geomotif.core.style` layer, named the
    same, on a page of the size asked for.

    ::

        import vpype_cli

        document = to_vpype(design, paper="a3")
        vpype_cli.execute("linemerge linesort write out.svg", document)

    Returns
    -------
    vpype.Document
        Typed loosely because ``vpype`` is not a dependency and this module
        must stay importable without it.

    Raises
    ------
    ImportError
        If ``vpype`` is not installed, with the command that installs it.
    """
    try:
        import vpype
    except ImportError:
        raise ImportError(
            "geomotif.io.plotter.to_vpype requires vpype. Install it with: pip install vpype"
        ) from None

    width, height = page_size(paper, landscape=landscape)
    placed = on_page(design, paper=paper, margin=margin, landscape=landscape)
    # vpype measures everything in CSS pixels, so millimeters have to be said
    # in its units rather than assumed to be its units.
    per_mm = vpype.convert_length("1mm")

    document = vpype.Document()
    document.page_size = (width * per_mm, height * per_mm)
    for index, (name, part) in enumerate(by_layer(placed).items(), start=1):
        lines = vpype.LineCollection(
            [
                [complex(x * per_mm, y * per_mm) for x, y in _closed_points(path)]
                for path in part.paths
                if len(path.points) > 1
            ]
        )
        document.add(lines, layer_id=index)
        if name is not None:
            document.layers[index].set_property(vpype.METADATA_FIELD_NAME, name)
    return document

save_png

save_png(source: Design | Raster, path: str | PathLike[str], **kwargs: Any) -> Path

Render a design -- or re-encode a raster -- as a PNG and write it.

Keyword arguments are passed straight through to :func:to_png.

Source code in src/geomotif/io/png.py
def save_png(source: Design | Raster, path: str | PathLike[str], **kwargs: Any) -> pathlib.Path:
    """Render a design -- or re-encode a raster -- as a PNG and write it.

    Keyword arguments are passed straight through to :func:`to_png`.
    """
    target = pathlib.Path(path)
    target.write_bytes(to_png(source, **kwargs))
    return target

to_png

to_png(source: Design | Raster, *, width: int = 480, height: int = 480, padding: float = 8.0, ink: str = '#0b0b0b', background: str = '#ffffff', thickness: int = 1, dot_radius: int | None = None, antialias: bool = False, aa_level: int = 8, color: str = 'rgb', compression: int = 6, transparent: bool = False) -> bytes

Render a design -- or re-encode a :class:~geomotif.io.Raster -- as PNG.

A still is drawn once, exactly the way :func:to_gif draws a frame, and then written as a PNG instead of being quantized toward a GIF's colors. The styling parameters are shared with the GIF writer so one summoning controls both.

Parameters:

Name Type Description Default
source Design or Raster

What to write. A design is drawn; a raster is encoded as it is.

required
width int

Canvas size in pixels. Ignored when source is already a raster.

480
height int

Canvas size in pixels. Ignored when source is already a raster.

480
padding float

Margin reserved on all four sides, in pixels.

8.0
ink str

Default stroke color and the color behind everything.

'#0b0b0b'
background str

Default stroke color and the color behind everything.

'#0b0b0b'
thickness int

Stroke width in pixels.

1
dot_radius int

Radius for loose points. Defaults to thickness.

None
antialias bool

Supersample and blend edges. Off by default, so the edges are the same hard Bresenham edges they have always been.

False
aa_level int

When antialiasing into an indexed frame, how many shades an edge may blend into per color pair. Ignored for the truecolor paths, which keep every level.

8
color str

"rgb", "rgba" or "indexed" -- how the PNG stores the picture. See the module docstring for what each means.

'rgb'
compression int

zlib level from 0 (fast, big) to 9 (slow, small). Must be an integer in that range.

6
transparent bool

Leave the background empty instead of painting background. A pixel with no ink is written with alpha 0 and an antialiased edge with the ink and its coverage as the alpha -- the straight alpha a PNG stores. This implies color="rgba" (a JPEG has no alpha, so this is where transparency lives in this library). Off by default.

False

Returns:

Type Description
bytes

A complete PNG file.

Raises:

Type Description
ValueError

If color is not one of the three, compression is out of range, or an indexed frame would need more than 256 palette colors.

Source code in src/geomotif/io/png.py
def to_png(
    source: Design | Raster,
    *,
    width: int = 480,
    height: int = 480,
    padding: float = 8.0,
    ink: str = "#0b0b0b",
    background: str = "#ffffff",
    thickness: int = 1,
    dot_radius: int | None = None,
    antialias: bool = False,
    aa_level: int = 8,
    color: str = "rgb",
    compression: int = 6,
    transparent: bool = False,
) -> bytes:
    """Render a design -- or re-encode a :class:`~geomotif.io.Raster` -- as PNG.

    A still is drawn once, exactly the way :func:`to_gif` draws a frame, and
    then written as a PNG instead of being quantized toward a GIF's colors.
    The styling parameters are shared with the GIF writer so one summoning
    controls both.

    Parameters
    ----------
    source : Design or Raster
        What to write. A design is drawn; a raster is encoded as it is.
    width, height : int
        Canvas size in pixels. Ignored when ``source`` is already a raster.
    padding : float
        Margin reserved on all four sides, in pixels.
    ink, background : str
        Default stroke color and the color behind everything.
    thickness : int
        Stroke width in pixels.
    dot_radius : int, optional
        Radius for loose points. Defaults to ``thickness``.
    antialias : bool
        Supersample and blend edges. Off by default, so the edges are the
        same hard Bresenham edges they have always been.
    aa_level : int
        When antialiasing into an indexed frame, how many shades an edge
        may blend into per color pair. Ignored for the truecolor paths,
        which keep every level.
    color : str
        ``"rgb"``, ``"rgba"`` or ``"indexed"`` -- how the PNG stores the
        picture. See the module docstring for what each means.
    compression : int
        zlib level from 0 (fast, big) to 9 (slow, small). Must be an
        integer in that range.
    transparent : bool
        Leave the background empty instead of painting ``background``. A
        pixel with no ink is written with alpha 0 and an antialiased edge
        with the ink and its coverage as the alpha -- the straight alpha a
        PNG stores. This implies ``color="rgba"`` (a JPEG has no alpha, so
        this is where transparency lives in this library). Off by default.

    Returns
    -------
    bytes
        A complete PNG file.

    Raises
    ------
    ValueError
        If ``color`` is not one of the three, ``compression`` is out of
        range, or an indexed frame would need more than 256 palette colors.
    """
    if color not in _COLOR_TYPE:
        raise ValueError(f"color must be one of {sorted(_COLOR_TYPE)}, got {color!r}")
    if not _MIN_COMPRESSION <= compression <= _MAX_COMPRESSION:
        raise ValueError(
            f"compression must be between {_MIN_COMPRESSION} and {_MAX_COMPRESSION}, "
            f"got {compression}"
        )
    if transparent:
        color = "rgba"

    raster = _frame(
        source,
        color=color,
        width=width,
        height=height,
        padding=padding,
        ink=ink,
        background=background,
        thickness=thickness,
        dot_radius=dot_radius,
        antialias=antialias,
        aa_level=aa_level,
        transparent=transparent,
    )
    return _encode(raster, _COLOR_TYPE[color], compression)

load_design

load_design(path: str | PathLike[str]) -> Design

Read a design back from a JSON file written by :func:save_design.

A plain JSON array of pairs -- what :func:save_points writes -- also loads, as a design of loose points with no strokes. The two shapes are an array and an object, so there is nothing to guess at.

Returns:

Type Description
Design

With meta restored where the file recorded it. The metadata is decoded but the motif is not rebuilt, so a design saved by a plugin still loads on a machine that does not have that plugin installed.

Raises:

Type Description
ValueError

If the file is not one of the two shapes above.

Source code in src/geomotif/io/points.py
def load_design(path: str | PathLike[str]) -> Design:
    """Read a design back from a JSON file written by :func:`save_design`.

    A plain JSON array of pairs -- what :func:`save_points` writes -- also
    loads, as a design of loose points with no strokes. The two shapes are an
    array and an object, so there is nothing to guess at.

    Returns
    -------
    Design
        With ``meta`` restored where the file recorded it. The metadata is
        decoded but the motif is *not* rebuilt, so a design saved by a plugin
        still loads on a machine that does not have that plugin installed.

    Raises
    ------
    ValueError
        If the file is not one of the two shapes above.
    """
    data = json.loads(pathlib.Path(path).read_text())
    match data:
        case list():
            return Design(points=_points(data, where="the file"))
        case {"paths": _} | {"points": _}:
            strokes = enumerate(data.get("paths", []))
            return Design(
                paths=tuple(_path(entry, index) for index, entry in strokes),
                points=_points(data.get("points", []), where="points"),
                meta=(
                    _meta_from_spec(data["meta"])
                    if isinstance(data.get("meta"), dict)
                    else EMPTY_META
                ),
            )
        case _:
            raise ValueError(
                f"{path} is not a design file: expected a JSON array of [x, y] pairs, or "
                f"an object with 'paths' and 'points' keys, got {type(data).__name__}"
            )

save_design

save_design(design: Design, path: str | PathLike[str], *, fmt: PointFormat | None = None, precision: int | None = None, meta: bool = True) -> Path

Write a design, strokes kept apart, and return the path written.

Parameters:

Name Type Description Default
design Design

What to write.

required
path str or path - like

Destination file.

required
fmt ('csv', 'txt', 'json')

Output format, inferred from the suffix when omitted.

  • csv -- a path,x,y header, then one row per point carrying the index of the stroke it belongs to. A design's loose points belong to no stroke, so their path cell is left empty.
  • txt -- one tab-separated x<TAB>y line per point, with a blank line between strokes: the convention gnuplot and most plotter toolchains already understand as "lift the pen here".
  • json -- the structured form, and the only one :func:load_design reads back.
"csv"
precision int

Round coordinates to this many decimal places, as for :func:save_points.

None
meta bool

Record the design's recipe alongside its points, for the JSON format only. Turn it off for a design whose motif takes a parameter that cannot be written as data.

True

Returns:

Type Description
Path

The file that was written.

Raises:

Type Description
TypeError

If meta is requested and a parameter is not JSON data. The message names the parameter; passing meta=False writes the points anyway.

Source code in src/geomotif/io/points.py
def save_design(
    design: Design,
    path: str | PathLike[str],
    *,
    fmt: PointFormat | None = None,
    precision: int | None = None,
    meta: bool = True,
) -> pathlib.Path:
    """Write a design, strokes kept apart, and return the path written.

    Parameters
    ----------
    design : Design
        What to write.
    path : str or path-like
        Destination file.
    fmt : {"csv", "txt", "json"}, optional
        Output format, inferred from the suffix when omitted.

        * ``csv``  -- a ``path,x,y`` header, then one row per point carrying
          the index of the stroke it belongs to. A design's loose points
          belong to no stroke, so their ``path`` cell is left empty.
        * ``txt``  -- one tab-separated ``x<TAB>y`` line per point, with a
          blank line between strokes: the convention gnuplot and most plotter
          toolchains already understand as "lift the pen here".
        * ``json`` -- the structured form, and the only one
          :func:`load_design` reads back.
    precision : int, optional
        Round coordinates to this many decimal places, as for
        :func:`save_points`.
    meta : bool, optional
        Record the design's recipe alongside its points, for the JSON format
        only. Turn it off for a design whose motif takes a parameter that
        cannot be written as data.

    Returns
    -------
    pathlib.Path
        The file that was written.

    Raises
    ------
    TypeError
        If ``meta`` is requested and a parameter is not JSON data. The message
        names the parameter; passing ``meta=False`` writes the points anyway.
    """
    target = pathlib.Path(path)
    chosen = _format_for(target, fmt)
    round_to = _rounder(precision)

    def pairs(points: Iterable[Point]) -> list[list[float | int]]:
        return [[round_to(x), round_to(y)] for x, y in points]

    match chosen:
        case "csv":
            with target.open("w", newline="") as f:
                writer = csv.writer(f)
                writer.writerow(("path", "x", "y"))
                for index, stroke in enumerate(design.paths):
                    writer.writerows((index, x, y) for x, y in pairs(stroke.points))
                writer.writerows(("", x, y) for x, y in pairs(design.points))
        case "txt":
            blocks = [pairs(stroke.points) for stroke in design.paths]
            if design.points:
                blocks.append(pairs(design.points))
            target.write_text(
                "\n".join("".join(f"{x}\t{y}\n" for x, y in block) for block in blocks)
            )
        case "json":
            # Imported at call time, not module scope: this module is part of
            # the package whose version it stamps, so the two would cycle.
            from .. import __version__

            blob: dict[str, object] = {
                VERSION_KEY: __version__,
                "paths": [
                    {"points": pairs(stroke.points), "closed": stroke.closed}
                    for stroke in design.paths
                ],
                "points": pairs(design.points),
            }
            if meta and design.meta:
                # The file already stamps its own version; a second copy inside
                # the recipe would only give the two a chance to disagree.
                blob["meta"] = {k: v for k, v in to_spec(design).items() if k != VERSION_KEY}
            target.write_text(json.dumps(blob) + "\n")
    return target

save_points

save_points(points: Iterable[Point], path: str | PathLike[str], *, fmt: PointFormat | None = None, precision: int | None = None) -> Path

Write points to a file and return the path written.

Parameters:

Name Type Description Default
points iterable of (float, float)

The points to export. A :class:~geomotif.Design is itself an iterable of points, so it can be passed directly.

required
path str or path - like

Destination file.

required
fmt ('csv', 'txt', 'json')

Output format. Inferred from the file suffix when omitted (.csv, .txt/.tsv, .json).

  • csv -- an x,y header followed by one x,y row per point
  • txt -- one tab-separated x<TAB>y line per point, no header
  • json -- a JSON array of [x, y] pairs
"csv"
precision int

Round coordinates to this many decimal places. 0 and below write whole integers, each further step back rounding to tens, hundreds and so on. Default keeps full float precision.

This rounds the file rather than the design, so it says nothing about what the other writers do with the same points. :meth:~geomotif.Design.snapped rounds the geometry itself -- onto any grid, not only powers of ten -- and every writer then agrees.

None

Returns:

Type Description
Path

The file that was written.

Source code in src/geomotif/io/points.py
def save_points(
    points: Iterable[Point],
    path: str | PathLike[str],
    *,
    fmt: PointFormat | None = None,
    precision: int | None = None,
) -> pathlib.Path:
    """Write points to a file and return the path written.

    Parameters
    ----------
    points : iterable of (float, float)
        The points to export. A :class:`~geomotif.Design` is itself an
        iterable of points, so it can be passed directly.
    path : str or path-like
        Destination file.
    fmt : {"csv", "txt", "json"}, optional
        Output format. Inferred from the file suffix when omitted
        (``.csv``, ``.txt``/``.tsv``, ``.json``).

        * ``csv``  -- an ``x,y`` header followed by one ``x,y`` row per point
        * ``txt``  -- one tab-separated ``x<TAB>y`` line per point, no header
        * ``json`` -- a JSON array of ``[x, y]`` pairs
    precision : int, optional
        Round coordinates to this many decimal places. ``0`` and below write
        whole integers, each further step back rounding to tens, hundreds and
        so on. Default keeps full float precision.

        This rounds the *file* rather than the design, so it says nothing about
        what the other writers do with the same points.
        :meth:`~geomotif.Design.snapped` rounds the geometry itself -- onto any
        grid, not only powers of ten -- and every writer then agrees.

    Returns
    -------
    pathlib.Path
        The file that was written.
    """
    target = pathlib.Path(path)
    chosen = _format_for(target, fmt)
    round_to = _rounder(precision)
    rows = [(round_to(x), round_to(y)) for x, y in points]

    match chosen:
        case "csv":
            with target.open("w", newline="") as f:
                writer = csv.writer(f)
                writer.writerow(("x", "y"))
                writer.writerows(rows)
        case "txt":
            target.write_text("".join(f"{x}\t{y}\n" for x, y in rows))
        case "json":
            target.write_text(json.dumps([[x, y] for x, y in rows]) + "\n")
    return target

colors_in

colors_in(designs: Iterable[Design], *, ink: str, background: str) -> tuple[str, ...]

Return the palette a set of designs needs, background first.

Shared across every frame of an animation rather than worked out per frame: a GIF has one global color table, and an index that meant crimson in one frame and black in the next would make the whole thing flicker.

Source code in src/geomotif/io/raster.py
def colors_in(designs: Iterable[Design], *, ink: str, background: str) -> tuple[str, ...]:
    """Return the palette a set of designs needs, background first.

    Shared across every frame of an animation rather than worked out per frame:
    a GIF has one global color table, and an index that meant crimson in one
    frame and black in the next would make the whole thing flicker.
    """
    palette = [background, ink]
    for design in designs:
        for style in (*styles_of(design), *point_styles_of(design)):
            if style is not None and style.stroke is not None and style.stroke not in palette:
                palette.append(style.stroke)
    return tuple(palette)

colours_in

colours_in(designs: Iterable[Design], *, ink: str, background: str) -> tuple[str, ...]

Keep the British spelling working while it is phased out.

:func:colors_in is the name of this function now; this alias exists so 1.1.0 callers keep working, and it warns that it will go away in a future major release.

Source code in src/geomotif/io/raster.py
def colours_in(designs: Iterable[Design], *, ink: str, background: str) -> tuple[str, ...]:
    """Keep the British spelling working while it is phased out.

    :func:`colors_in` is the name of this function now; this alias exists so
    1.1.0 callers keep working, and it warns that it will go away in a future
    major release.
    """
    warnings.warn(
        "colours_in is deprecated; use colors_in instead",
        DeprecationWarning,
        stacklevel=2,
    )
    return colors_in(designs, ink=ink, background=background)

quantize

quantize(frames: Sequence[Raster], *, seeds: Sequence[str] = (), max_colors: int = 256, dither: bool = True, transparent: bool = False) -> tuple[Raster, ...]

Shrink RGBA frames to one shared indexed palette of at most max_colors.

The palette is built once across every frame -- so an animation does not flicker as the index a color means changes -- and is seeded with the colors given in seeds first, background first, keeping them exact. Any remaining color budget is filled from the blends the antialiasing made, cut down with median-cut when there are more of them than budget. A seed color is never dropped: asking for more seeds than max_colors raises instead.

With transparent set, index 0 is reserved for empty space: a pixel whose alpha lies below the half point maps there instead of to a color, and the transparent slot does not count against the color budget.

Parameters:

Name Type Description Default
frames sequence of Raster

"rgba" frames, all the same size, to share one palette.

required
seeds sequence of str

colors that must survive exactly, background first, as #rrggbb or a name. These lead the palette, in order. The first -- the background -- is the reserved transparent slot when transparent is set.

()
max_colors int

The largest palette the output may hold. Must be >= 1.

256
dither bool

Whether to error-diffuse (Floyd-Steinberg) the rounding of each pixel onto its neighbours, which keeps antialiased gradients smooth within a small palette. On by default, since indexed output is where the color budget bites.

True
transparent bool

Reserve index 0 for empty pixels and drop the background from the color budget. Off by default.

False

Returns:

Type Description
tuple of Raster

One indexed :class:Raster per input frame, all sharing one palette.

Source code in src/geomotif/io/raster.py
def quantize(
    frames: Sequence[Raster],
    *,
    seeds: Sequence[str] = (),
    max_colors: int = 256,
    dither: bool = True,
    transparent: bool = False,
) -> tuple[Raster, ...]:
    """Shrink RGBA frames to one shared indexed palette of at most ``max_colors``.

    The palette is built once across every frame -- so an animation does not
    flicker as the index a color means changes -- and is **seeded** with the
    colors given in ``seeds`` first, background first, keeping them exact. Any
    remaining color budget is filled from the blends the antialiasing made,
    cut down with median-cut when there are more of them than budget. A seed
    color is never dropped: asking for more seeds than ``max_colors`` raises
    instead.

    With ``transparent`` set, index 0 is reserved for empty space: a pixel
    whose alpha lies below the half point maps there instead of to a color,
    and the transparent slot does not count against the color budget.

    Parameters
    ----------
    frames : sequence of Raster
        ``"rgba"`` frames, all the same size, to share one palette.
    seeds : sequence of str
        colors that must survive exactly, background first, as ``#rrggbb`` or
        a name. These lead the palette, in order. The first -- the background
        -- is the reserved transparent slot when ``transparent`` is set.
    max_colors : int
        The largest palette the output may hold. Must be >= 1.
    dither : bool
        Whether to error-diffuse (Floyd-Steinberg) the rounding of each pixel
        onto its neighbours, which keeps antialiased gradients smooth within
        a small palette. On by default, since indexed output is where the
        color budget bites.
    transparent : bool
        Reserve index 0 for empty pixels and drop the background from the
        color budget. Off by default.

    Returns
    -------
    tuple of Raster
        One indexed :class:`Raster` per input frame, all sharing one
        ``palette``.
    """
    if not frames:
        raise ValueError("cannot quantize no frames")
    if max_colors < 1:
        raise ValueError(f"max_colors must be >= 1, got {max_colors}")
    for frame in frames:
        if frame.mode != "rgba":
            raise ValueError(
                f"quantize needs 'rgba' frames, got {frame.mode!r}; render with rasterize_rgba"
            )
        if (frame.width, frame.height) != (frames[0].width, frames[0].height):
            raise ValueError("all frames must share one size to share one palette")

    seed_rgb = []
    for seed in seeds:
        parsed = _rgb(seed)
        if parsed not in seed_rgb:
            seed_rgb.append(parsed)

    if transparent:
        if not seed_rgb:
            raise ValueError("a transparent palette needs a background seed to reserve")
        background_rgb = seed_rgb[0]
        color_seeds = seed_rgb[1:]
        budget = max_colors - 1
        if len(color_seeds) > budget:
            raise ValueError(
                f"a palette of {max_colors} colors cannot hold the "
                f"{len(color_seeds) + 1} colors requested (index 0 is transparent); "
                f"restyle the design onto fewer, or raise the budget"
            )
    else:
        background_rgb = seed_rgb[0] if seed_rgb else (255, 255, 255)
        color_seeds = seed_rgb
        budget = max_colors
        if len(color_seeds) > budget:
            raise ValueError(
                f"a palette of {max_colors} colors cannot hold the {len(color_seeds)} "
                f"colors requested; restyle the design onto fewer, or raise the budget"
            )

    counts = Counter[tuple[int, int, int]]()
    for frame in frames:
        buffer = frame.pixels
        for at in range(0, len(buffer), 4):
            if transparent and buffer[at + 3] < _EMPTY_ALPHA:
                continue
            source = (buffer[at], buffer[at + 1], buffer[at + 2])
            if source not in color_seeds:
                counts[source] += 1

    palette = [background_rgb] if transparent else []
    palette.extend(color_seeds)
    pairs = sorted(counts.items(), key=lambda item: (-item[1], item[0]))
    room = budget - len(color_seeds)
    if room <= 0:
        tail: list[tuple[int, int, int]] = []
    elif len(pairs) <= room:
        tail = [color for color, _ in pairs]
    else:
        tail = _median_cut([color for color, _ in pairs], counts, room)
    palette.extend(tail)

    hex_palette = tuple(f"#{r:02x}{g:02x}{b:02x}" for r, g, b in palette)
    mapped = tuple(
        _map_to_palette(frame, palette, dither=dither, transparent=transparent) for frame in frames
    )
    return tuple(
        Raster(frame.width, frame.height, stride.pixels, hex_palette)
        for frame, stride in zip(frames, mapped, strict=True)
    )

rasterize

rasterize(design: Design, *, width: int = 480, height: int = 480, padding: float = 8.0, bounds: Bounds | None = None, palette: Sequence[str] | None = None, ink: str = '#0b0b0b', background: str = '#ffffff', thickness: int = 1, dot_radius: int | None = None) -> Raster

Draw a design into an indexed bitmap.

Parameters:

Name Type Description Default
design Design

What to draw.

required
width int

Canvas size in pixels.

480
height int

Canvas size in pixels.

480
padding float

Margin reserved on all four sides, in pixels.

8.0
bounds Bounds

The world rectangle to map onto the canvas. Defaults to the design's own, which is right for a single image and wrong for a frame of an animation -- there, pass the same bounds to every frame or the drawing will swim about as its extent changes.

None
palette sequence of str

colors the indices name, background first. Defaults to (background, ink) plus whatever the design's styles add.

None
ink str

Default stroke color and the color behind everything.

'#0b0b0b'
background str

Default stroke color and the color behind everything.

'#0b0b0b'
thickness int

Stroke width in pixels.

1
dot_radius int

Radius for loose points. Defaults to thickness.

None

Returns:

Type Description
Raster

Ready to hand to :func:geomotif.io.gif.to_gif.

Source code in src/geomotif/io/raster.py
def rasterize(
    design: Design,
    *,
    width: int = 480,
    height: int = 480,
    padding: float = 8.0,
    bounds: Bounds | None = None,
    palette: Sequence[str] | None = None,
    ink: str = "#0b0b0b",
    background: str = "#ffffff",
    thickness: int = 1,
    dot_radius: int | None = None,
) -> Raster:
    """Draw a design into an indexed bitmap.

    Parameters
    ----------
    design : Design
        What to draw.
    width, height : int
        Canvas size in pixels.
    padding : float
        Margin reserved on all four sides, in pixels.
    bounds : Bounds, optional
        The world rectangle to map onto the canvas. Defaults to the design's
        own, which is right for a single image and wrong for a frame of an
        animation -- there, pass the same bounds to every frame or the drawing
        will swim about as its extent changes.
    palette : sequence of str, optional
        colors the indices name, background first. Defaults to
        ``(background, ink)`` plus whatever the design's styles add.
    ink, background : str
        Default stroke color and the color behind everything.
    thickness : int
        Stroke width in pixels.
    dot_radius : int, optional
        Radius for loose points. Defaults to ``thickness``.

    Returns
    -------
    Raster
        Ready to hand to :func:`geomotif.io.gif.to_gif`.
    """
    if width < 1 or height < 1:
        raise ValueError(f"width and height must be >= 1, got {width}x{height}")
    if thickness < 1:
        raise ValueError(f"thickness must be >= 1, got {thickness}")
    if padding < 0:
        raise ValueError(f"padding must be >= 0, got {padding}")

    entries = (
        tuple(palette)
        if palette is not None
        else colors_in([design], ink=ink, background=background)
    )
    pixels = bytearray(width * height)
    radius = thickness if dot_radius is None else dot_radius
    place = _placement(bounds if bounds is not None else _bounds_of(design), width, height, padding)

    for path, style in zip(design.paths, styles_of(design), strict=True):
        index = _index_for(style, entries)
        pen = _pen(style, thickness)
        drawn = [place(p) for p in path.points]
        if path.closed and len(drawn) > 2:
            drawn.append(drawn[0])
        if len(drawn) == 1:
            _stamp(pixels, width, height, drawn[0][0], drawn[0][1], index, pen)
        for a, b in itertools.pairwise(drawn):
            _line(pixels, width, height, a, b, index, pen)

    for point, style in zip(design.points, point_styles_of(design), strict=True):
        index = _index_for(style, entries)
        _disc(pixels, width, height, place(point), _pen(style, radius), index)

    return Raster(width, height, bytes(pixels), entries)

rasterize_rgba

rasterize_rgba(design: Design, *, width: int = 480, height: int = 480, padding: float = 8.0, bounds: Bounds | None = None, ink: str = '#0b0b0b', background: str = '#ffffff', thickness: int = 1, dot_radius: int | None = None, scale: int = _AA_SCALE, aa_level: int | None = None, transparent: bool = False) -> Raster

Draw a design into a full-color RGBA frame, supersampled and antialiased.

Every pixel is a real (r, g, b, a) value rather than a palette index, so the edges can blend into the background -- which is the whole point of antialiasing. The design is painted scale times as large and each output pixel is the stroke color over the background weighted by the fraction of its sub-pixels the ink covered.

Parameters:

Name Type Description Default
design Design

What to draw.

required
width int

Canvas size in pixels.

480
height int

Canvas size in pixels.

480
padding float

Margin reserved on all four sides, in pixels.

8.0
bounds Bounds

The world rectangle to map onto the canvas. Pass the same bounds to every frame of an animation or the drawing will swim about.

None
ink str

Default stroke color and the color behind everything. When transparent is set, background is only what index 0 of an indexed result stands for -- it contributes no color at all.

'#0b0b0b'
background str

Default stroke color and the color behind everything. When transparent is set, background is only what index 0 of an indexed result stands for -- it contributes no color at all.

'#0b0b0b'
thickness int

Stroke width in pixels.

1
dot_radius int

Radius for loose points. Defaults to thickness.

None
scale int

Supersampling factor; 1 disables antialiasing. Must be >= 1.

_AA_SCALE
aa_level int

If given, each sub-pixel coverage fraction is rounded to one of aa_level steps before blending, so an indexed frame later sees at most aa_level blended shades per color pair. Leave None for a PNG or JPEG, which keep full color depth.

None
transparent bool

Leave the background empty rather than painting it. A pixel with no ink becomes alpha 0; an antialiased edge becomes the stroke color with the coverage as its alpha, which is the straight alpha a PNG stores and the mask an indexed GIF flags. Off by default.

False

Returns:

Type Description
Raster

An "rgba" frame, ready to hand to :func:quantize or an encoder.

Source code in src/geomotif/io/raster.py
def rasterize_rgba(
    design: Design,
    *,
    width: int = 480,
    height: int = 480,
    padding: float = 8.0,
    bounds: Bounds | None = None,
    ink: str = "#0b0b0b",
    background: str = "#ffffff",
    thickness: int = 1,
    dot_radius: int | None = None,
    scale: int = _AA_SCALE,
    aa_level: int | None = None,
    transparent: bool = False,
) -> Raster:
    """Draw a design into a full-color RGBA frame, supersampled and antialiased.

    Every pixel is a real (r, g, b, a) value rather than a palette index, so
    the edges can blend into the background -- which is the whole point of
    antialiasing. The design is painted ``scale`` times as large and each
    output pixel is the stroke color over the background weighted by the
    fraction of its sub-pixels the ink covered.

    Parameters
    ----------
    design : Design
        What to draw.
    width, height : int
        Canvas size in pixels.
    padding : float
        Margin reserved on all four sides, in pixels.
    bounds : Bounds, optional
        The world rectangle to map onto the canvas. Pass the same bounds to
        every frame of an animation or the drawing will swim about.
    ink, background : str
        Default stroke color and the color behind everything. When
        ``transparent`` is set, ``background`` is only what index 0 of an
        indexed result stands for -- it contributes no color at all.
    thickness : int
        Stroke width in pixels.
    dot_radius : int, optional
        Radius for loose points. Defaults to ``thickness``.
    scale : int
        Supersampling factor; ``1`` disables antialiasing. Must be >= 1.
    aa_level : int, optional
        If given, each sub-pixel coverage fraction is rounded to one of
        ``aa_level`` steps before blending, so an indexed frame later sees at
        most ``aa_level`` blended shades per color pair. Leave ``None`` for a
        PNG or JPEG, which keep full color depth.
    transparent : bool
        Leave the background empty rather than painting it. A pixel with no
        ink becomes ``alpha 0``; an antialiased edge becomes the stroke color
        with the coverage as its alpha, which is the straight alpha a PNG
        stores and the mask an indexed GIF flags. Off by default.

    Returns
    -------
    Raster
        An ``"rgba"`` frame, ready to hand to :func:`quantize` or an encoder.
    """
    if width < 1 or height < 1:
        raise ValueError(f"width and height must be >= 1, got {width}x{height}")
    if thickness < 1:
        raise ValueError(f"thickness must be >= 1, got {thickness}")
    if padding < 0:
        raise ValueError(f"padding must be >= 0, got {padding}")
    if scale < 1:
        raise ValueError(f"scale must be >= 1, got {scale}")

    entries = colors_in([design], ink=ink, background=background)
    entry_rgb = [_rgb(color) for color in entries]
    sub_w, sub_h = width * scale, height * scale
    sub = bytearray(sub_w * sub_h)
    radius = thickness if dot_radius is None else dot_radius
    place = _placement(
        bounds if bounds is not None else _bounds_of(design), sub_w, sub_h, padding * scale
    )

    for path, style in zip(design.paths, styles_of(design), strict=True):
        index = _index_for(style, entries)
        pen = max(1, round(_pen(style, thickness) * scale))
        drawn = [place(p) for p in path.points]
        if path.closed and len(drawn) > 2:
            drawn.append(drawn[0])
        if len(drawn) == 1:
            _stamp(sub, sub_w, sub_h, drawn[0][0], drawn[0][1], index, pen)
        for first, second in itertools.pairwise(drawn):
            _line(sub, sub_w, sub_h, first, second, index, pen)

    for point, style in zip(design.points, point_styles_of(design), strict=True):
        index = _index_for(style, entries)
        _disc(sub, sub_w, sub_h, place(point), max(1, round(_pen(style, radius) * scale)), index)

    block = scale * scale
    out = bytearray(width * height * 4)
    for oy in range(height):
        sub_row = oy * scale
        for ox in range(width):
            counts = [0] * len(entry_rgb)
            for sy in range(scale):
                base = (sub_row + sy) * sub_w + ox * scale
                for sx in range(scale):
                    counts[sub[base + sx]] += 1
            at4 = 4 * (oy * width + ox)
            if transparent:
                # Index 0 (the background) covers nothing; alpha is the share
                # of the block the strokes actually paint, and the color is
                # that ink alone -- straight alpha, background-free.
                red = green = blue = norm = 0.0
                for index, count in enumerate(counts):
                    if count == 0 or index == 0:
                        continue
                    fraction = count / block
                    if aa_level is not None:
                        fraction = round(fraction * aa_level) / aa_level
                    norm += fraction
                    r, g, b = entry_rgb[index]
                    red += r * fraction
                    green += g * fraction
                    blue += b * fraction
                if aa_level is not None:
                    norm = round(norm * aa_level) / aa_level
                if norm <= 0:
                    out[at4 : at4 + 4] = (0, 0, 0, 0)
                    continue
                out[at4] = round(red / norm)
                out[at4 + 1] = round(green / norm)
                out[at4 + 2] = round(blue / norm)
                out[at4 + 3] = round(norm * 255)
                continue
            red = green = blue = 0.0
            for index, count in enumerate(counts):
                if count == 0:
                    continue
                fraction = count / block
                if aa_level is not None:
                    fraction = round(fraction * aa_level) / aa_level
                r, g, b = entry_rgb[index]
                red += r * fraction
                green += g * fraction
                blue += b * fraction
            out[at4 : at4 + 3] = (round(red), round(green), round(blue))
            out[at4 + 3] = 255
    return Raster(width, height, bytes(out), mode="rgba")

from_spec

from_spec(data: Mapping[str, object]) -> Motif

Rebuild the motif a spec describes.

The version stamp is not consulted; a spec is data, and data that loaded once should keep loading.

Raises:

Type Description
ValueError

If the mapping names no motif, or names a value type this library will not import.

KeyError

If the motif name is not registered -- including when it belongs to a plugin that is not installed.

Source code in src/geomotif/io/spec.py
def from_spec(data: Mapping[str, object]) -> Motif:
    """Rebuild the motif a spec describes.

    The version stamp is not consulted; a spec is data, and data that loaded
    once should keep loading.

    Raises
    ------
    ValueError
        If the mapping names no motif, or names a value type this library will
        not import.
    KeyError
        If the motif name is not registered -- including when it belongs to a
        plugin that is not installed.
    """
    return _decode_spec(data, _importable_packages(), where="spec")

load_spec

load_spec(path: str | PathLike[str]) -> Motif

Read a spec file and return the motif it describes.

Returns:

Type Description
Motif

Ready to :meth:~geomotif.Motif.build or :meth:~geomotif.Motif.generate.

Source code in src/geomotif/io/spec.py
def load_spec(path: str | PathLike[str]) -> Motif:
    """Read a spec file and return the motif it describes.

    Returns
    -------
    Motif
        Ready to :meth:`~geomotif.Motif.build` or
        :meth:`~geomotif.Motif.generate`.
    """
    data = json.loads(pathlib.Path(path).read_text())
    if not isinstance(data, dict):
        raise ValueError(
            f"{path} is not a spec file: expected a JSON object, got {type(data).__name__}"
        )
    return from_spec(data)

save_spec

save_spec(source: SupportsBuild | Design, path: str | PathLike[str], *, indent: int | None = 2) -> Path

Write a motif's recipe to a JSON file and return the path written.

Parameters:

Name Type Description Default
source Motif or Design

What to describe; see :func:to_spec.

required
path str or path - like

Destination file.

required
indent int

Passed through to :func:json.dumps. Indented by default: a spec is a few hundred bytes and is meant to be opened and edited by hand.

2

Returns:

Type Description
Path

The file that was written.

Source code in src/geomotif/io/spec.py
def save_spec(
    source: SupportsBuild | Design,
    path: str | PathLike[str],
    *,
    indent: int | None = 2,
) -> pathlib.Path:
    """Write a motif's recipe to a JSON file and return the path written.

    Parameters
    ----------
    source : Motif or Design
        What to describe; see :func:`to_spec`.
    path : str or path-like
        Destination file.
    indent : int, optional
        Passed through to :func:`json.dumps`. Indented by default: a spec is a
        few hundred bytes and is meant to be opened and edited by hand.

    Returns
    -------
    pathlib.Path
        The file that was written.
    """
    target = pathlib.Path(path)
    target.write_text(json.dumps(to_spec(source), indent=indent) + "\n")
    return target

to_spec

to_spec(source: SupportsBuild | Design, *, animation: Mapping[str, object] | None = None) -> dict[str, object]

Return the JSON-ready recipe for a motif, or for the design it built.

Parameters:

Name Type Description Default
source Motif or Design

A motif, or any design whose :attr:~geomotif.Design.meta records the motif that produced it -- which every builtin motif's does.

required
animation mapping

An animation recipe to carry alongside the still, so a moving picture round-trips through the same file the CLI's --animation flag reads. The value is written verbatim under :data:ANIMATION_KEY; a still spec simply omits the key.

None

Returns:

Type Description
dict

{"geomotif": version, "motif": name, "params": {...}}, holding only JSON types and ready for :func:json.dumps. When animation is given, an "animation" key sits beside them.

Raises:

Type Description
ValueError

If source is a design with no motif recorded in its metadata.

TypeError

If a parameter cannot be written as data -- see the module docstring.

Examples:

>>> from geomotif.motifs import Star
>>> to_spec(Star(points=7))["motif"]
'star'
Source code in src/geomotif/io/spec.py
def to_spec(
    source: SupportsBuild | Design, *, animation: Mapping[str, object] | None = None
) -> dict[str, object]:
    """Return the JSON-ready recipe for a motif, or for the design it built.

    Parameters
    ----------
    source : Motif or Design
        A motif, or any design whose :attr:`~geomotif.Design.meta` records the
        motif that produced it -- which every builtin motif's does.
    animation : mapping, optional
        An animation recipe to carry alongside the still, so a moving picture
        round-trips through the same file the CLI's ``--animation`` flag reads.
        The value is written verbatim under :data:`ANIMATION_KEY`; a still
        spec simply omits the key.

    Returns
    -------
    dict
        ``{"geomotif": version, "motif": name, "params": {...}}``, holding only
        JSON types and ready for :func:`json.dumps`. When ``animation`` is
        given, an ``"animation"`` key sits beside them.

    Raises
    ------
    ValueError
        If ``source`` is a design with no motif recorded in its metadata.
    TypeError
        If a parameter cannot be written as data -- see the module docstring.

    Examples
    --------
    >>> from geomotif.motifs import Star
    >>> to_spec(Star(points=7))["motif"]
    'star'
    """
    live = _live_spec(source)
    name = live[registry.NAME_KEY]
    params = {
        key: value
        for key, value in live.items()
        if key != registry.NAME_KEY and key not in _STYLE_KEYS
    }
    # Imported at call time rather than at module scope: this module is part of
    # the package whose version it reads, so the two would import in a cycle.
    from .. import __version__

    blob: dict[str, object] = {
        VERSION_KEY: __version__,
        registry.NAME_KEY: name,
        PARAMS_KEY: _encode(params, where=str(name)),
    }
    # Styles sit beside the parameters rather than among them: they belong to
    # the design rather than to the motif, and feeding one back to a
    # constructor as a keyword argument would only raise.
    for key in _STYLE_KEYS:
        if key in live:
            blob[key] = _encode(live[key], where=key)
    if animation is not None:
        blob[ANIMATION_KEY] = dict(animation)
    return blob

save_svg

save_svg(design: Design, path: str | PathLike[str], **kwargs: Any) -> Path

Write a design to an SVG file and return the path written.

Keyword arguments are passed straight through to :func:to_svg.

Source code in src/geomotif/io/svg.py
def save_svg(design: Design, path: str | PathLike[str], **kwargs: Any) -> pathlib.Path:
    """Write a design to an SVG file and return the path written.

    Keyword arguments are passed straight through to :func:`to_svg`.
    """
    target = pathlib.Path(path)
    target.write_text(to_svg(design, **kwargs))
    return target

to_svg

to_svg(design: Design, *, width: float | None = None, height: float | None = None, padding: float = 8.0, stroke: str = '#0b0b0b', stroke_width: float = 1.0, fill: str = 'none', background: str | None = None, dot_radius: float | None = None, flip_y: bool = True, precision: int = 3, group_by_path: bool = True, title: str | None = None, units: str = '') -> str

Render a design as an SVG document.

Parameters:

Name Type Description Default
design Design

What to draw. Its strokes become <path> elements and its loose points become <circle> elements.

required
width float

Canvas size in user units. Give both to fit the design into exactly that rectangle; give one and the other follows from the design's own proportions; give neither and the design keeps its own measurements, with padding added around it.

None
height float

Canvas size in user units. Give both to fit the design into exactly that rectangle; give one and the other follows from the design's own proportions; give neither and the design keeps its own measurements, with padding added around it.

None
padding float

Margin reserved on all four sides.

8.0
stroke (str, float, str)

Applied to the group holding the strokes. fill="none" is the default because most of this catalog is line work; name a color to fill the closed paths instead.

'#0b0b0b'
stroke_width (str, float, str)

Applied to the group holding the strokes. fill="none" is the default because most of this catalog is line work; name a color to fill the closed paths instead.

'#0b0b0b'
fill (str, float, str)

Applied to the group holding the strokes. fill="none" is the default because most of this catalog is line work; name a color to fill the closed paths instead.

'#0b0b0b'
background str

Draw a filled rectangle behind everything. Omitted by default, which leaves the canvas transparent.

None
dot_radius float

Radius for the loose points. Defaults to stroke_width, so dots read about as heavy as lines; pass 0 to leave them out entirely.

None
flip_y bool

Mirror vertically, so a design drawn y-up appears the right way up in SVG's y-down space. On by default.

True
precision int

Decimal places for coordinates. Trailing zeros are dropped, so a whole number costs one character rather than five.

3
group_by_path bool

Give every stroke its own <path> element, so an editor treats them as separate objects. Turn it off to merge them into one element with several subpaths, which is smaller but arrives as a single shape.

True
title str

The document's <title>. Defaults to the motif recorded in the design's metadata, which is what makes a gallery file self-labelling.

None
units str

A physical unit for the document's width and height, from :data:UNITS -- "mm", "in", "pt" and the rest. The viewBox stays in plain numbers, so one user unit becomes one of these and the drawing has a real size on paper. Empty by default, which leaves the size in user units and is what anything on a screen wants. See :mod:geomotif.io.plotter.

''

Returns:

Type Description
str

A complete SVG document, ending in a newline.

Raises:

Type Description
ValueError

If the design has no points, padding leaves no room inside the canvas asked for, or units is not one of :data:UNITS.

Source code in src/geomotif/io/svg.py
def to_svg(
    design: Design,
    *,
    width: float | None = None,
    height: float | None = None,
    padding: float = 8.0,
    stroke: str = "#0b0b0b",
    stroke_width: float = 1.0,
    fill: str = "none",
    background: str | None = None,
    dot_radius: float | None = None,
    flip_y: bool = True,
    precision: int = 3,
    group_by_path: bool = True,
    title: str | None = None,
    units: str = "",
) -> str:
    """Render a design as an SVG document.

    Parameters
    ----------
    design : Design
        What to draw. Its strokes become ``<path>`` elements and its loose
        points become ``<circle>`` elements.
    width, height : float, optional
        Canvas size in user units. Give both to fit the design into exactly
        that rectangle; give one and the other follows from the design's own
        proportions; give neither and the design keeps its own measurements,
        with ``padding`` added around it.
    padding : float, optional
        Margin reserved on all four sides.
    stroke, stroke_width, fill : str, float, str, optional
        Applied to the group holding the strokes. ``fill="none"`` is the
        default because most of this catalog is line work; name a color to
        fill the closed paths instead.
    background : str, optional
        Draw a filled rectangle behind everything. Omitted by default, which
        leaves the canvas transparent.
    dot_radius : float, optional
        Radius for the loose points. Defaults to ``stroke_width``, so dots
        read about as heavy as lines; pass ``0`` to leave them out entirely.
    flip_y : bool, optional
        Mirror vertically, so a design drawn y-up appears the right way up in
        SVG's y-down space. On by default.
    precision : int, optional
        Decimal places for coordinates. Trailing zeros are dropped, so a whole
        number costs one character rather than five.
    group_by_path : bool, optional
        Give every stroke its own ``<path>`` element, so an editor treats them
        as separate objects. Turn it off to merge them into one element with
        several subpaths, which is smaller but arrives as a single shape.
    title : str, optional
        The document's ``<title>``. Defaults to the motif recorded in the
        design's metadata, which is what makes a gallery file self-labelling.
    units : str, optional
        A physical unit for the document's ``width`` and ``height``, from
        :data:`UNITS` -- ``"mm"``, ``"in"``, ``"pt"`` and the rest. The
        ``viewBox`` stays in plain numbers, so one user unit becomes one of
        these and the drawing has a real size on paper. Empty by default,
        which leaves the size in user units and is what anything on a screen
        wants. See :mod:`geomotif.io.plotter`.

    Returns
    -------
    str
        A complete SVG document, ending in a newline.

    Raises
    ------
    ValueError
        If the design has no points, ``padding`` leaves no room inside the
        canvas asked for, or ``units`` is not one of :data:`UNITS`.
    """
    if not len(design):
        raise ValueError("cannot write an empty design to SVG: there is nothing to draw")
    if padding < 0:
        raise ValueError(f"padding must be >= 0, got {padding}")
    if precision < 0:
        raise ValueError(f"precision must be >= 0, got {precision}")
    if units not in UNITS:
        raise ValueError(f"units must be one of {UNITS}, got {units!r}")

    canvas_w, canvas_h = _canvas(design, width, height, padding)
    placed = design.fit(canvas_w, canvas_h, padding=padding, flip_y=flip_y)
    radius = stroke_width if dot_radius is None else dot_radius
    layers = by_layer(placed) if layer_names(placed) else {None: placed}

    def num(value: float) -> str:
        return _num(value, precision)

    namespaces = f'xmlns="{SVG_NS}"'
    if any(name is not None for name in layers):
        namespaces += f' xmlns:inkscape="{INKSCAPE_NS}"'
    lines = [
        '<?xml version="1.0" encoding="UTF-8"?>',
        f'<svg {namespaces} width="{num(canvas_w)}{units}" '
        f'height="{num(canvas_h)}{units}" viewBox="0 0 {num(canvas_w)} {num(canvas_h)}">',
    ]

    label = title if title is not None else str(design.meta.get(NAME_KEY, "") or "")
    if label:
        lines.append(f"  <title>{escape(label)}</title>")
    if background is not None:
        lines.append(
            f'  <rect width="{num(canvas_w)}" height="{num(canvas_h)}" '
            f"fill={quoteattr(background)}/>"
        )

    defaults = _Ink(stroke=stroke, stroke_width=stroke_width, fill=fill, radius=radius)
    for name, part in layers.items():
        body = _elements(
            part,
            defaults,
            indent=1 if name is None else 2,
            precision=precision,
            group_by_path=group_by_path,
        )
        if name is None:
            lines.extend(body)
            continue
        # Inkscape's own attributes, which is also what vpype reads a layer
        # from. Anything else opens the file as a plain group and loses only
        # the name.
        lines.append(
            f'  <g inkscape:groupmode="layer" inkscape:label={quoteattr(name)} '
            f"id={quoteattr(name)}>"
        )
        lines.extend(body)
        lines.append("  </g>")

    lines.append("</svg>")
    return "\n".join(lines) + "\n"