Skip to main content
Pre-release

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

pyxpo-whiteboard

A whiteboard for pyxpo apps written in Python: pen, marker, eraser, fill bucket, color picker and nine shapes, solid or dashed, on a plain, grid, dot or lined page, with undo, redo, clear and export to PNG or JPEG. It comes with a ready toolbar and with the single toolbar pieces, so you can use it as is or lay the controls out yourself. iOS draws with Core Graphics, Android with android.graphics.Canvas, and the web and the desktop window with an HTML canvas.

The whiteboard with its toolbar on Android and iOS: a blue curve and, on iOS, a red rectangle

import pyxpo_whiteboard as WB
from pyxpo import View, component


@component
def Sketch():
    board = WB.use_whiteboard(color="#222222", width=6)
    return View(WB.Whiteboard(board=board, style={"flex": 1}), WB.WhiteboardToolbar(board=board), style={"flex": 1})

Note on the screenshots: the Android picture shows a curve drawn with a synthetic adb swipe. The iOS picture has a curve and a rectangle that the demo seeded. On the Android emulator, adb-injected touches drew with the pen but did not draw the rectangle and ellipse tools, so no shape is shown on Android. It is not known whether that is the synthetic input or a bug, so test the shape tools with a real finger on your Android device.

Features

  • Pen, marker, eraser, fill bucket and color picker. The marker is always see-through.
  • Nine shapes: line, arrow, rectangle, ellipse, triangle, star, diamond, hexagon and heart, outlined or filled.
  • Solid or dashed lines, width, color and opacity.
  • Page patterns: plain, grid, dots or lined. They show on screen and are left out of exported images.
  • Undo, redo and clear. clear() can be undone.
  • A ready toolbar (WhiteboardToolbar) and its pieces: ToolPicker, ShapePicker, ColorPalette, ColorGrid, WidthPicker, OpacitySlider, ShapeFillToggle, DashToggle, PatternPicker, HistoryButtons.
  • Save and restore the drawing as a list of plain elements.
  • Export to PNG or JPEG, optionally trimmed to the ink, with a transparent background if you want.
  • Geometry helpers (shape_outline, to_strokes, transform, bounds) that work without a device.
  • camelCase too: getElementsAsync, toImageAsync.

Contents

If you are... Start here
Adding a whiteboard with a toolbar Quick start
Laying out the controls yourself Toolbar pieces · Using props without a board
Choosing tools, colors and the page Tools · Page pattern
Saving and loading a drawing Elements
Making an image Export
Exact names API reference · Errors
A real use Recipe: a lesson board
Stuck Testing · Troubleshooting · FAQ

Platform support

Target What works
Android, API 24+ Finger and stylus. Drawn with Canvas. Shape tools are not verified yet with a real finger: see the note above.
iOS 15+ Finger and Apple Pencil. Drawn with Core Graphics. Pen, shapes, fill and toolbar verified on the Simulator.
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-whiteboard
[tool.pyxpo]
modules = ["whiteboard"]

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

Quick start

from pyxpo import Button, Text, View, component, use_async, use_state
import pyxpo_whiteboard as WB


@component
def Sketch():
    board = WB.use_whiteboard(color="#1F1A24", width=6)
    saves, set_saves = use_state(0)
    note, set_note = use_state("")

    async def save():
        if saves > 0:
            image = await board.to_image_async(trim=True, padding=16)
            set_note("Nothing to save" if image is None else image["uri"])

    use_async(save, [saves])

    return View(
        WB.Whiteboard(board=board, style={"flex": 1, "minHeight": 240}),
        WB.WhiteboardToolbar(board=board),
        Text("%d elements. %s" % (board.element_count, note)),
        Button(title="Save", disabled=board.is_empty, on_press=lambda: set_saves(saves + 1)),
        style={"flex": 1},
    )

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

use_whiteboard() holds the tool, color, width, opacity, shape fill, dashes and page pattern, plus the board's state (element_count, can_undo, can_redo, drawing). Give the same board to Whiteboard and to every toolbar piece, and they stay in step.

The whiteboard page and toolbar on Android and iOS

Tools and settings

use_whiteboard(tool="pen", color="#1F1A24", width=4, opacity=1, fill_shapes=False, dash=False, pattern="none", palette=PALETTE, widths=WIDTHS, theme=None) sets the starting values. A Board has these fields and setters:

Field Setter Meaning
board.tool set_tool(tool) One of TOOLS: pen, marker, eraser, fill, picker and the nine shapes. The picker remembers the tool before it and returns to it after one pick.
board.color set_color(color) Any pyxpo color. Picking one while the eraser is on switches back to the pen.
board.width set_width(width) pt/dp, above 0 and at most 500. Eraser and shape outlines use it too.
board.opacity set_opacity(opacity) Above 0 up to 1, for the pen, shapes and fill bucket. The marker is always at 40 %.
board.fill_shapes set_fill_shapes(bool) Shapes filled with the current color, or outline only.
board.dash set_dash(bool) Dashed pen, marker and shape outlines. The eraser is always solid.
board.pattern set_pattern(name) "none", "grid", "dots" or "lines".
board.element_count, can_undo, can_redo State from the board.
board.drawing, board.is_empty A touch is in progress. is_empty is true with no elements and no touch: the time for a "draw here" hint.

board.undo(), board.redo(), board.clear(), await board.get_elements_async(), await board.set_elements_async(elements) and await board.to_image_async(...) call the mounted board and raise RuntimeError if it is not mounted.

The fill bucket floods the region under your finger with the current color. It is stored as a small fill element and redrawn each time, so it follows edits made before it. fill_tolerance (0 to 255, default 32, a Whiteboard prop) sets how much a color may differ from the tapped one and still be filled. The picker draws nothing: a tap sets the board's color to the color under the finger (ink over the background).

Toolbar pieces

WhiteboardToolbar(board, on_clear=None, shapes=True, opacity=True, history=True, patterns=True, spectrum=False, style=None) shows everything in wrapping rows, so narrow screens get more rows. spectrum=True adds the full ColorGrid. Each piece also works alone:

Piece Shows
ToolPicker(board, tools=DRAW_TOOLS) Pen, marker, eraser, fill bucket, color picker.
ShapePicker(board, shapes=SHAPES) The nine shape tools.
ColorPalette(board, colors=None, size=None) Swatches (the board's palette). The picked one has a ring and a tick.
ColorGrid(board, hues=12, shades=5, size=None) A full spectrum plus a row of grays.
WidthPicker(board, widths=None) A dot per width in the current color.
OpacitySlider(board, minimum=0.1, label=True) Opacity 10 to 100 %.
ShapeFillToggle(board) Outline or filled shapes.
DashToggle(board) Solid or dashed lines.
PatternPicker(board, patterns=PATTERNS) Plain, grid, dots, lined page.
HistoryButtons(board, on_clear=None, clear=True) Undo, redo, clear. on_clear replaces the clear, for example to ask first.

A layout of your own:

from pyxpo import Row, View, component
import pyxpo_whiteboard as WB


@component
def Compact():
    board = WB.use_whiteboard(tool="pen", palette=["#1F1A24", "#E5322D", "#2F6FE4"], widths=[2, 6, 14],
                              theme={"accent": "#C7E3FF", "button": 40})
    return View(
        Row(WB.ToolPicker(board=board, tools=("pen", "marker", "eraser")), WB.HistoryButtons(board=board, clear=False)),
        WB.Whiteboard(board=board, style={"flex": 1}),
        Row(WB.ColorPalette(board=board), WB.WidthPicker(board=board)),
        style={"flex": 1},
    )

theme overrides keys of WB.THEME: ink, muted, surface, soft, border, accent, on_accent, track, button (button size), swatch (swatch size) and gap. ToolIcon(name, size=24, color=..., weight=2.2, fill=None) draws any icon in ICONS.

Page pattern

board.set_pattern("grid")   # "none", "grid", "dots" or "lines"

The lines or dots are 24 pt/dp apart in a faint gray (pattern_color on a bare Whiteboard changes it). They are drawn on screen only and never appear in an exported image, so a grid page exports as a clean drawing.

Using props without a board

Whiteboard works alone with plain props and a ref:

from pyxpo import View, component, use_ref
import pyxpo_whiteboard as WB


@component
def Plain():
    ref = use_ref(None)
    return View(
        WB.Whiteboard(ref=ref, tool="marker", color="#E5322D", width=14, pattern="lines",
                      on_change=lambda s: print(s["element_count"], s["can_undo"]),
                      style={"flex": 1}),
    )
Prop Default
board None A use_whiteboard() board. It supplies the tool settings and its ref.
tool "pen" Any of TOOLS.
color "#000000" Pen, marker and shape outline color.
width 4 pt/dp, above 0 and at most 500.
opacity 1 Above 0 up to 1.
fill_color None Fills shapes; None is outline only.
background_color "#ffffff" The page, used when exporting with a background.
enabled True False ignores touches.
fill_tolerance 32 0 to 255, for the fill bucket.
dash False Dashed lines (never the eraser).
pattern, pattern_color "none", faint gray The page pattern.
on_change None {"element_count", "can_undo", "can_redo"} after each element, undo, redo, clear, set_elements_async and a touch that drew nothing.
on_draw_start None {"tool"} when a touch starts drawing.
on_pick None {"color"} (0xAARRGGBB) from the picker tool.
style Sizes the board.

Elements

The drawing is a list of elements, oldest first, in pt/dp from the view's top left:

{"type": "stroke", "tool": "pen" | "marker" | "eraser", "color": 0xFF1F1A24, "width": 6.0, "points": [x0, y0, x1, y1, ...]}
{"type": "shape", "shape": "rect", "color": 0xFFE5322D, "width": 4.0, "fill": 0, "x0": 40, "y0": 40, "x1": 200, "y1": 120}
{"type": "fill", "x": 100, "y": 80, "color": 0xFFF7C51E}

Strokes and shapes may also have "dash": True. A shape fill of 0 means outline only. A dict with no "type" is a stroke, so strokes saved from pyxpo-drawing-canvas load as they are.

elements = await board.get_elements_async()
await board.set_elements_async(elements + [
    {"type": "shape", "shape": "star", "color": "#F7C51E", "fill": "#F7C51E", "width": 3,
     "x0": 60, "y0": 60, "x1": 160, "y1": 160},
])

set_elements_async accepts any pyxpo color, checks every element before it sends anything (so a bad one raises and leaves the drawing as it was) and empties undo and redo.

Helpers (pure Python, no device needed):

Function Returns
shape_outline(shape, x0, y0, x1, y1, width=0, segments=48) The polylines a shape draws: one closed loop, or a line plus two arrow-head lines.
to_strokes(elements) Plain {"tool", "color", "width", "points"} strokes: shapes become outlines, the marker draws like the pen, fills are left out.
transform(elements, scale, dx=0, dy=0) Every element moved to p * scale + (dx, dy), widths scaled too. Use it to fit a drawing to a bigger board.
bounds(elements) (x0, y0, x1, y1) of lines and shapes (half a line width out) or None. Fills and the eraser do not count.
color_grid(hues=12, shades=5) Rows of "#RRGGBB" colors.

Export

image = await board.to_image_async(format="png", trim=True, padding=16, background=False)
image["uri"], image["width"], image["height"], image["x"], image["y"]
Argument Default
format "png" "png" or "jpg".
trim False Crop to the ink plus padding. Returns None when there is none.
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.
quality 1 0 to 1, for jpg.

The file goes to the app cache and uri is a file:// path on every platform. The page pattern is never drawn into it.

Errors

Error When What to do
ValueError A bad tool, shape, pattern, format, width, opacity, scale or quality, a stroke with an odd number of coordinates, an element with an unknown type. Fix the value. The message names it.
TypeError set_elements_async got a non-list, or an element that is not a dict. Pass a list of dicts.
RuntimeError You used the board or handle before the Whiteboard mounted. Call it from an event, not during render.
ModuleNotLinkedError The phone build lacks the module. Add "whiteboard" to modules and rebuild.
None from to_image_async trim=True and there is no ink. Check board.is_empty first.

Config

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

Web and desktop

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

Recipe: a lesson board

from pyxpo import Button, Row, Text, View, component, use_async, use_state
import pyxpo_whiteboard as WB

TEMPLATE = [{"type": "shape", "shape": "rect", "color": "#8A8A8A", "width": 2, "fill": 0,
             "x0": 20, "y0": 20, "x1": 220, "y1": 120, "dash": True}]


@component
def Lesson(on_done):
    board = WB.use_whiteboard(color="#2F6FE4", width=4, pattern="grid")
    loads, set_loads = use_state(0)
    sends, set_sends = use_state(0)

    async def load_template():
        if loads > 0:
            await board.set_elements_async(TEMPLATE)

    async def send():
        if sends > 0:
            image = await board.to_image_async(format="jpg", quality=0.85, padding=8, trim=True)
            if image is not None:
                on_done(image["uri"])

    use_async(load_template, [loads])
    use_async(send, [sends])

    return View(
        WB.Whiteboard(board=board, style={"flex": 1}),
        WB.WhiteboardToolbar(board=board, on_clear=lambda: board.clear()),
        Text("Draw your answer" if board.is_empty else "%d marks" % board.element_count),
        Row(Button(title="Start from template", on_press=lambda: set_loads(loads + 1)),
            Button(title="Send", disabled=board.is_empty, on_press=lambda: set_sends(sends + 1))),
        style={"flex": 1},
    )

Production checklist

  • The Whiteboard 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, and clear asks first when the work matters (on_clear).
  • You keep the elements (get_elements_async()) if the drawing must outlive the screen. The board does not keep them.
  • A saved drawing records the board size. Use transform to fit it to another screen.
  • You handle None from to_image_async(trim=True) for an empty board.
  • Exports are copied out of the cache (or shared) if you need them later.
  • Shape tools are tested with a finger on a real Android device and on iOS.

Testing

The geometry helpers need no fakes. Fake call, require_linked and view_tag for the handle:

import asyncio

import pytest

import pyxpo_whiteboard as wb


def test_shapes_are_closed_loops():
    (loop,) = wb.shape_outline("rect", 0, 0, 10, 20)
    assert loop[:2] == loop[-2:] == [0, 0]
    lines = wb.shape_outline("arrow", 0, 0, 100, 0, width=4)
    assert len(lines) == 3 and lines[0] == [0, 0, 100, 0]


def test_bounds_and_transform():
    elements = [{"type": "shape", "shape": "rect", "color": 0xFF000000, "width": 2, "x0": 10, "y0": 10, "x1": 30, "y1": 20}]
    assert wb.bounds(elements) == (9.0, 9.0, 31.0, 21.0)
    moved = wb.transform(elements, 2, dx=5)
    assert (moved[0]["x0"], moved[0]["x1"], moved[0]["width"]) == (25, 65, 4.0)
    assert wb.bounds([]) is None


def test_to_strokes_leaves_fills_out():
    drawing = [{"type": "fill", "x": 1, "y": 1, "color": 1},
               {"type": "stroke", "tool": "marker", "color": 1, "width": 3, "points": [0, 0, 1, 1]}]
    assert [s["tool"] for s in wb.to_strokes(drawing)] == ["pen"]


def test_set_elements_checks_first(monkeypatch):
    calls = []

    async def fake_call(module, method, *args, **kwargs):
        calls.append((method, args))

    monkeypatch.setattr(wb, "call", fake_call)
    monkeypatch.setattr(wb, "require_linked", lambda *a, **k: None)
    monkeypatch.setattr(wb, "view_tag", lambda ref: 9)
    handle = wb.WhiteboardHandle(object())
    with pytest.raises(ValueError):
        asyncio.run(handle.set_elements_async([{"type": "shape", "shape": "cube", "x0": 0, "y0": 0, "x1": 1, "y1": 1}]))
    assert calls == []
    asyncio.run(handle.set_elements_async([{"points": [0, 0, 4, 4]}]))
    assert calls[0][0] == "setElements" and calls[0][1][1][0]["type"] == "stroke"

WB.Whiteboard(...) and the toolbar components only build view descriptions, and they validate their props while building (for example a bad tool raises ValueError), so a test can call them directly.

Troubleshooting

Symptom Cause and fix
Nothing shows and nothing draws The board has no height. Give it style={"flex": 1} or a height.
Toolbar buttons do nothing The toolbar and the Whiteboard got different boards. Pass one board to both.
Shape tools draw nothing on Android Not confirmed as a bug: it was seen with synthetic adb touches. Try a real finger, and tell us your result with the device and API level.
The marker looks too light It is the color at 40 % by design. Use the pen with opacity for another strength.
The grid is missing in the exported image By design: the page pattern is screen only.
to_image_async returns None trim=True on an empty board.
The eraser leaves a white mark on a colored page The eraser removes ink and shows background_color. Set it to the page color.
RuntimeError: the Whiteboard is not mounted You used the board before mount or after unmount.
A restored drawing sits in the wrong place Elements are in pt/dp of the board they were drawn on. Use transform for a different size.
ModuleNotLinkedError Add "whiteboard" to [tool.pyxpo] modules and rebuild.

FAQ

Is it the same format as pyxpo-drawing-canvas? Strokes are. A drawing-canvas stroke list loads here as it is, and to_strokes() gives one back from a whiteboard drawing.

Can I move or resize a shape after I draw it? No. Undo it and draw it again, or edit the elements and call set_elements_async.

Can I add text? No. Put a Text over the board.

Does it do pressure? No. Width is fixed per element.

Can two people draw together? There is no sync. Send the elements through your own server and apply them with set_elements_async.

Can I use my own toolbar? Yes. Skip WhiteboardToolbar and call board.set_tool(...), board.set_color(...) and the other setters from your own buttons.

API reference

Name Kind Notes
Whiteboard(...) component Props above. ref gets a WhiteboardHandle.
use_whiteboard(...) hook Returns a Board.
Board class Settings, setters, state and the handle methods.
WhiteboardHandle class undo(), redo(), clear(), get_elements_async(), set_elements_async(elements), to_image_async(...).
WhiteboardToolbar, ToolPicker, ShapePicker, ColorPalette, ColorGrid, WidthPicker, OpacitySlider, ShapeFillToggle, DashToggle, PatternPicker, HistoryButtons components Take board= and an optional style.
ToolIcon, ICONS, THEME, TOOL_LABELS, PATTERN_LABELS icons and theme
shape_outline, to_strokes, transform, bounds, color_grid functions See Elements.
Tool class Tool.PEN, MARKER, ERASER, FILL, PICKER, LINE, ARROW, RECT, ELLIPSE, TRIANGLE, STAR, DIAMOND, HEXAGON, HEART.
TOOLS, DRAW_TOOLS, SHAPES, PATTERNS, IMAGE_FORMATS tuples The allowed values.
PALETTE, WIDTHS, MARKER_ALPHA constants 12 colors, (2, 4, 8, 14, 24), 0.4.

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-whiteboard 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-whiteboard 0.1.0a1
File Size Uploaded Provenance
pyxpo_whiteboard-0.1.0a1.tar.gz 52.4 kB With provenance Details

Built distribution (wheel)

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

Total release size: 101.0 kB

Release files / pyxpo_whiteboard-0.1.0a1.tar.gz

Download URL pyxpo_whiteboard-0.1.0a1.tar.gz
Size 52.4 kB
Tags Source
SHA-256 checksum
How to use checksums
8278f22f6ae0728f17eb12b1fb72e77662374bb0f6889300a66c3de758c2199d
BLAKE2b-256 checksum
How to use checksums
1d8a71f9490061c3e6dfd1ce13272dabe7a352ad4055f731c728093d6ebe7298
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_whiteboard-0.1.0a1-py3-none-any.whl

Download URL pyxpo_whiteboard-0.1.0a1-py3-none-any.whl
Size 48.5 kB
Tags Python 3
SHA-256 checksum
How to use checksums
500261ee32a9bb4402a1aa3070341a2d4ee09622741ca5e80b16018befd5b0f2
BLAKE2b-256 checksum
How to use checksums
215e47abd816d3c65b060e52f93edc347af9ca718f27cc5d3cedc052389855c1
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-whiteboard 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_whiteboard-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