Skip to main content
Pre-release

This release is a pre-release and may not be stable for production use.

pyxpo-drawing-canvas

A finger and pen drawing surface for pyxpo apps written in Python, with undo, redo, clear, a pen and an eraser, and export to PNG or JPEG. iOS draws with Core Graphics (Apple Pencil works like a finger), Android with android.graphics.Canvas, and the web and the desktop window with an HTML canvas. Every platform uses the same stroke format and the same smoothing curve, so strokes saved on one platform look the same on another.

The drawing canvas on Android and iOS, each with ink on a white page

import pyxpo_drawing_canvas as DrawingCanvas

DrawingCanvas.DrawingCanvas(ref=canvas, stroke_color="#222222", stroke_width=6, style={"flex": 1})

Note on the screenshots: the Android picture shows five straight lines because the strokes were made with adb swipes. The iOS picture shows a curve that the demo app seeded with set_strokes_async. A finger draws smooth curves on both.

Features

  • Pen and eraser with any color and width. The eraser removes ink and leaves the page background.
  • Undo, redo and clear. clear() can be undone.
  • Smooth strokes. Quadratic curves through the midpoints of your touch points, drawn the same on every platform.
  • Save and restore. get_strokes_async() returns plain data and set_strokes_async() puts it back.
  • Export to PNG or JPEG, optionally trimmed to the ink, with a transparent background if you want.
  • Change events with the stroke count and whether undo or redo is possible, for your own buttons.
  • Same code on the web and the desktop window.
  • camelCase too: getStrokesAsync, toImageAsync, setStrokesAsync.

Contents

If you are... Start here
Adding a sketch pad Quick start
Choosing pen, eraser, color and width Props
Undo, redo, clear Handle methods
Saving a drawing and loading it later Strokes
Turning a drawing into an image Export
Need shapes, fill, a toolbar or a grid page Use pyxpo-whiteboard
Exact names API reference · Errors
A real use Recipe: sign here
Stuck Testing · Troubleshooting · FAQ

Platform support

Target What works
Android, API 24+ Finger and stylus. Drawn with Canvas.
iOS 15+ Finger and Apple Pencil. Drawn with Core Graphics.
Desktop window Mouse and pen through pointer events. Drawn on an HTML canvas.
Web page The same as the desktop window.

Requires Python 3.9+ and pyxpo>=0.1.0a2.

Install

pip install pyxpo-drawing-canvas
[tool.pyxpo]
modules = ["drawing-canvas"]

Rebuild the app. There are no permissions and no plugin options.

Quick start

from pyxpo import Button, Row, Text, View, component, use_async, use_ref, use_state
import pyxpo_drawing_canvas as DrawingCanvas


@component
def Sketch():
    canvas = use_ref(None)
    state, set_state = use_state({"stroke_count": 0, "can_undo": False, "can_redo": False})
    tool, set_tool = use_state("pen")
    exports, set_exports = use_state(0)
    saved, set_saved = use_state("")

    async def export():
        if exports > 0:
            image = await canvas.current.to_image_async(trim=True, padding=16)
            set_saved("Nothing to save" if image is None else "%d x %d px" % (image["width"], image["height"]))

    use_async(export, [exports])

    return View(
        DrawingCanvas.DrawingCanvas(ref=canvas, stroke_color="#222222", stroke_width=6, tool=tool,
                                    on_change=set_state, style={"flex": 1}),
        Text("%d strokes. %s" % (state["stroke_count"], saved)),
        Row(
            Button(title="Pen", on_press=lambda: set_tool("pen")),
            Button(title="Eraser", on_press=lambda: set_tool("eraser")),
            Button(title="Undo", disabled=not state["can_undo"], on_press=lambda: canvas.current.undo()),
            Button(title="Redo", disabled=not state["can_redo"], on_press=lambda: canvas.current.redo()),
            Button(title="Clear", on_press=lambda: canvas.current.clear()),
            Button(title="Export", on_press=lambda: set_exports(exports + 1)),
        ),
        style={"flex": 1},
    )

Tip: use_async(fn, deps) awaits only a real async def. Do not pass lambda: some_coroutine(): nothing awaits it.

Ink drawn on the canvas on Android and iOS

DrawingCanvas props

Prop Default
stroke_color "#000000" Any pyxpo color. Applies to the next stroke.
stroke_width 4 In pt/dp, greater than 0 and at most 500. The eraser uses it too.
tool "pen" "pen" or "eraser". The eraser removes ink and leaves the background.
background_color "#ffffff" Shown under the ink and used by to_image_async(background=True).
enabled True False ignores touches.
on_change None Called with {"stroke_count", "can_undo", "can_redo"} after each stroke, undo, redo, clear and set_strokes_async.
style Sizes the canvas, for example {"flex": 1}.

A prop change applies to the next stroke and never recolors ink already on the page. ref is a use_ref(None); it holds a DrawingCanvasHandle once the canvas is mounted.

ref.current methods

  • undo(), redo(): take back or apply again the last stroke or clear().
  • clear(): removes every stroke. undo() brings them back.
  • await get_strokes_async(): the visible strokes, oldest first.
  • await set_strokes_async(strokes): replaces the drawing and empties undo and redo.
  • await to_image_async(...): writes the drawing to an image file. See Export.

undo, redo and clear return at once and do not wait for the native side. Read the result from on_change.

Strokes

Each stroke is a dict:

Key Meaning
tool "pen" or "eraser".
color 0xAARRGGBB int.
width pt/dp, greater than 0.
points Flat [x0, y0, x1, y1, ...] in pt/dp from the view's top left.
strokes = await canvas.current.get_strokes_async()
# [{"tool": "pen", "color": 4280361249, "width": 6.0, "points": [12.0, 40.5, 14.2, 44.0, ...]}]

await canvas.current.set_strokes_async([
    {"tool": "pen", "color": "#e5322d", "width": 8, "points": [20, 20, 120, 80, 220, 20]},
])

set_strokes_async takes any pyxpo color for color and defaults tool to "pen", width to 4 and color to opaque black. It checks every stroke before it sends anything, so a bad one raises and leaves the drawing as it was. Points are in the canvas's own pt/dp, so a drawing saved on a phone shows at the same size and position on a canvas of the same size, but is not rescaled for a bigger one. Scale the points yourself if you need that.

Export

image = await canvas.current.to_image_async(format="png", trim=True, padding=16, background=False)
image["uri"]      # "file:///.../cache/....png"
image["width"], image["height"]     # pixels
image["x"], image["y"]              # the crop's top left in the view, pt/dp
Argument Default
format "png" "png" or "jpg".
trim False Crop to the ink plus padding. Returns None for an empty canvas.
padding 0 pt/dp around the ink when trimming.
scale the screen's Pixels per pt/dp, above 0 and at most 8.
background True False gives a transparent PNG. A JPEG gets white instead.
quality 1 0 to 1, for jpg only.

The file is written to the app cache. Share it with pyxpo-sharing, save it with pyxpo-media-library, or read it with pyxpo-file-system.

Errors

Error When What to do
ValueError A bad tool, format, stroke_width, padding, scale or quality. A stroke with an odd number of coordinates or none. Fix the value. The message names it.
TypeError set_strokes_async got something other than a list, or a stroke that is not a dict. Pass a list of dicts.
RuntimeError You used the handle before the canvas mounted. Call it from an event, not during render.
ModuleNotLinkedError The phone build lacks the module. Add "drawing-canvas" to modules and rebuild.
None from to_image_async trim=True and nothing is drawn. Check state["stroke_count"] first.

Config

There is nothing to configure. The module has no permissions, no plugin options and no Info.plist texts.

Web and desktop

The canvas is an HTML canvas driven by pointer events, so a mouse, a touch screen and a pen all work. The page encodes the image and Python writes it to ~/.cache/pyxpo/drawing-canvas/ (or $XDG_CACHE_HOME/pyxpo/drawing-canvas/), so uri is a file:// path on every platform.

Recipe: a signature pad

from pyxpo import Button, Row, Text, View, component, use_async, use_ref, use_state
import pyxpo_drawing_canvas as DrawingCanvas


@component
def SignHere(on_signed):
    canvas = use_ref(None)
    inked, set_inked = use_state(False)
    submits, set_submits = use_state(0)

    async def submit():
        if submits > 0:
            image = await canvas.current.to_image_async(trim=True, padding=12, background=False)
            if image is not None:
                on_signed(image["uri"])

    use_async(submit, [submits])

    return View(
        Text("Sign in the box"),
        DrawingCanvas.DrawingCanvas(
            ref=canvas, stroke_color="#0b2a6f", stroke_width=3,
            on_change=lambda s: set_inked(s["stroke_count"] > 0),
            style={"height": 180, "borderWidth": 1, "borderColor": "#999999"}),
        Row(
            Button(title="Clear", on_press=lambda: canvas.current.clear()),
            Button(title="Done", disabled=not inked, on_press=lambda: set_submits(submits + 1)),
        ),
    )

The transparent, trimmed PNG drops cleanly onto a PDF or a form.

Production checklist

  • The canvas has a size (style), because a drawing surface has no content to size itself from.
  • Undo and redo buttons follow can_undo and can_redo from on_change.
  • You handle None from to_image_async(trim=True) for an empty canvas.
  • Saved strokes record the canvas size, if the drawing may open on a different screen.
  • Exports are copied out of the cache (or shared) if you need them later. The system may clear the cache.
  • Tested with a finger on a real phone, and with Apple Pencil if you support it.

Testing

Fake the native call, the link check and the view tag to test your own code without a device:

import asyncio

import pytest

import pyxpo_drawing_canvas as dc


@pytest.fixture
def handle(monkeypatch):
    calls = []

    async def fake_call(module, method, *args, **kwargs):
        calls.append((method, args))
        if method == "getStrokes":
            return [{"tool": "pen", "color": 0xFF000000, "width": 4.0, "points": [0.0, 0.0, 5.0, 5.0]}]
        if method == "toImage":
            return {"uri": "file:///tmp/a.png", "width": 20, "height": 10, "x": 0, "y": 0}
        return None

    monkeypatch.setattr(dc, "call", fake_call)
    monkeypatch.setattr(dc, "require_linked", lambda *a, **k: None)
    monkeypatch.setattr(dc, "view_tag", lambda ref: 7)
    return dc.DrawingCanvasHandle(object()), calls


def test_strokes_round_trip(handle):
    h, calls = handle
    strokes = asyncio.run(h.get_strokes_async())
    assert strokes[0]["points"] == [0.0, 0.0, 5.0, 5.0]
    asyncio.run(h.set_strokes_async([{"points": [0, 0, 10, 10], "color": "#ff0000"}]))
    method, args = calls[-1]
    assert method == "setStrokes" and args[0] == 7
    assert args[1][0]["tool"] == "pen" and args[1][0]["width"] == 4.0


def test_bad_stroke_is_rejected_before_sending(handle):
    h, calls = handle
    with pytest.raises(ValueError):
        asyncio.run(h.set_strokes_async([{"points": [1, 2, 3]}]))
    assert calls == []


def test_export_options(handle):
    h, calls = handle
    image = asyncio.run(h.to_image_async(format="jpg", trim=True, padding=8, quality=0.8))
    assert image["width"] == 20
    opts = calls[-1][1][1]
    assert opts["format"] == "jpg" and opts["trim"] is True and opts["padding"] == 8.0
    with pytest.raises(ValueError):
        asyncio.run(h.to_image_async(format="gif"))

DrawingCanvas(...) itself only builds a native view description, so a test can call it and look at the props. A bad tool or stroke_width raises ValueError while building.

Troubleshooting

Symptom Cause and fix
Nothing shows and nothing draws The canvas has no height. Give it style={"flex": 1} or a height.
Strokes do not appear enabled=False, or something covers the canvas and takes the touches.
The eraser leaves white marks on a colored page Set background_color to the page color you want to show.
to_image_async returns None trim=True on an empty canvas.
The exported PNG has a white box background=True is the default. Pass background=False.
RuntimeError: the DrawingCanvas is not mounted You used ref.current before mount or after unmount.
ModuleNotLinkedError Add "drawing-canvas" to [tool.pyxpo] modules and rebuild.
A restored drawing sits in the wrong place Strokes are in pt/dp of the canvas they were drawn on. Scale the points for a different size.

FAQ

Can it draw shapes, fill areas or show a grid? Not here. pyxpo-whiteboard adds those, a marker, a color picker and a ready toolbar, and reads and writes the same stroke format.

Does it support pressure from a stylus? No. Width is fixed per stroke.

How do I change the color of a stroke I drew? You cannot. Read the strokes, change their color, and call set_strokes_async.

Is the drawing kept if the view unmounts? No. Keep the strokes yourself with get_strokes_async().

Can I draw on top of an image? Yes. Put the canvas over an Image in a View with background_color="transparent", and export with background=False.

API reference

Name Kind Notes
DrawingCanvas(...) component Props above. ref gets a DrawingCanvasHandle.
DrawingCanvasHandle class undo(), redo(), clear(), get_strokes_async(), set_strokes_async(strokes), to_image_async(...).
Tool class Tool.PEN, Tool.ERASER.
TOOLS tuple ("pen", "eraser").
IMAGE_FORMATS tuple ("png", "jpg").

to_image_async(format="png", trim=False, padding=0, scale=None, background=True, quality=1) returns {"uri", "width", "height", "x", "y"} or None.

License

MIT

Metadata

Release files for pyxpo-drawing-canvas 0.1.0a1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for pyxpo-drawing-canvas 0.1.0a1
File Size Uploaded Provenance
pyxpo_drawing_canvas-0.1.0a1.tar.gz 27.1 kB With provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for pyxpo-drawing-canvas 0.1.0a1
File Interpreter ABI Platform
pyxpo_drawing_canvas-0.1.0a1-py3-none-any.whl Python 3 none any Details

Total release size: 51.8 kB

Release files / pyxpo_drawing_canvas-0.1.0a1.tar.gz

Download URL pyxpo_drawing_canvas-0.1.0a1.tar.gz
Size 27.1 kB
Tags Source
SHA-256 checksum
How to use checksums
9868728a03dffc9684ede2eba976dd59f0ec6405d09f62bb9f03f0e10ef86850
BLAKE2b-256 checksum
How to use checksums
d19401e7325994edc2a2c738d5dc4a104805729d541d69f098ea44175b4a60b9
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 18, 2026.

Transparency log

Release files / pyxpo_drawing_canvas-0.1.0a1-py3-none-any.whl

Download URL pyxpo_drawing_canvas-0.1.0a1-py3-none-any.whl
Size 24.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
cec9683afc17380e899f348382200a7062836aa31e0169f56fd015bad231ab32
BLAKE2b-256 checksum
How to use checksums
4a437485683b4c22897e351bf54872e401d5f2d73fc2d4580248f27b3f2c160b
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 18, 2026.

Transparency log

Security for pyxpo-drawing-canvas 0.1.0a1

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for pyxpo_drawing_canvas-0.1.0a1.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 18, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

Release history Release notifications | RSS feed

This release
Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page