plotline 0.1.0.dev0__cp311-abi3-win_amd64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- plotline/__init__.py +30 -0
- plotline/_core.pyd +0 -0
- plotline/_core.pyi +58 -0
- plotline/cli/__init__.py +0 -0
- plotline/cli/main.py +294 -0
- plotline/mcp_server.py +313 -0
- plotline/py.typed +0 -0
- plotline/registry.py +16 -0
- plotline/session.py +400 -0
- plotline/types.py +130 -0
- plotline/web/THIRD-PARTY-LICENSES.txt +1492 -0
- plotline/web/assets/index-B3NFwj0a.css +1 -0
- plotline/web/assets/index-BcHuSLsL.js +1 -0
- plotline/web/assets/plotline_wasm_bg-BxSp_00A.wasm +0 -0
- plotline/web/assets/worker-DB9SFtUV.js +1 -0
- plotline/web/index.html +15 -0
- plotline/web/lucide-preact-LICENSE.txt +43 -0
- plotline/web/preact-LICENSE.txt +21 -0
- plotline-0.1.0.dev0.dist-info/METADATA +159 -0
- plotline-0.1.0.dev0.dist-info/RECORD +24 -0
- plotline-0.1.0.dev0.dist-info/WHEEL +4 -0
- plotline-0.1.0.dev0.dist-info/entry_points.txt +2 -0
- plotline-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
- plotline-0.1.0.dev0.dist-info/sboms/plotline-py.cyclonedx.json +2984 -0
plotline/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""plotline: turn images into drawings for pen plotters.
|
|
2
|
+
|
|
3
|
+
The engine is the extension module ``plotline._core``. This package is the Python host around it:
|
|
4
|
+
``Session`` for working with an image, the values it takes and gives, and the style registry.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from plotline._core import describe, inks
|
|
8
|
+
from plotline.registry import get_style, list_styles
|
|
9
|
+
from plotline.session import RunHandle, Session
|
|
10
|
+
from plotline.types import Cancelled, Canvas, Drawing, Frame, Pen, RunState, Speeds, Stats, Style
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"Cancelled",
|
|
14
|
+
"Canvas",
|
|
15
|
+
"Drawing",
|
|
16
|
+
"Frame",
|
|
17
|
+
"Pen",
|
|
18
|
+
"RunHandle",
|
|
19
|
+
"RunState",
|
|
20
|
+
"Session",
|
|
21
|
+
"Speeds",
|
|
22
|
+
"Stats",
|
|
23
|
+
"Style",
|
|
24
|
+
"describe",
|
|
25
|
+
"get_style",
|
|
26
|
+
"inks",
|
|
27
|
+
"list_styles",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
__version__ = "0.1.0.dev0"
|
plotline/_core.pyd
ADDED
|
Binary file
|
plotline/_core.pyi
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Types for ``plotline._core``, the Rust engine built as an extension module (crates/py)."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Literal
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from numpy.typing import NDArray
|
|
7
|
+
|
|
8
|
+
_State = Literal["idle", "running", "done", "cancelled", "error"]
|
|
9
|
+
|
|
10
|
+
class Run:
|
|
11
|
+
def next_frame(self) -> dict[str, Any] | None: ...
|
|
12
|
+
def cancel(self) -> None: ...
|
|
13
|
+
def state(self) -> _State: ...
|
|
14
|
+
def error(self) -> str | None: ...
|
|
15
|
+
|
|
16
|
+
class Session:
|
|
17
|
+
def __init__(self, cache_bytes: int = 536870912) -> None: ...
|
|
18
|
+
def set_image_bytes(self, data: bytes) -> None: ...
|
|
19
|
+
def set_image(self, array: NDArray[np.float32]) -> None: ...
|
|
20
|
+
def image(self) -> NDArray[np.float32] | None: ...
|
|
21
|
+
def canvas(self) -> dict[str, Any]: ...
|
|
22
|
+
def set_canvas(self, canvas: dict[str, Any]) -> None: ...
|
|
23
|
+
def pens(self) -> list[dict[str, Any]]: ...
|
|
24
|
+
def set_pens(self, pens: list[dict[str, Any]]) -> None: ...
|
|
25
|
+
def style_name(self) -> str: ...
|
|
26
|
+
def set_style(self, name: str) -> None: ...
|
|
27
|
+
def settings(self) -> dict[str, Any]: ...
|
|
28
|
+
def update(self, stage: str, patch: dict[str, Any]) -> None: ...
|
|
29
|
+
def speeds(self) -> dict[str, float]: ...
|
|
30
|
+
def set_speeds(self, speeds: dict[str, float]) -> None: ...
|
|
31
|
+
def start(self) -> Run: ...
|
|
32
|
+
def state(self) -> _State: ...
|
|
33
|
+
def error(self) -> str | None: ...
|
|
34
|
+
def drawing(self) -> dict[str, Any] | None: ...
|
|
35
|
+
def svg(self) -> str | None: ...
|
|
36
|
+
def json(self) -> dict[str, Any] | None: ...
|
|
37
|
+
def stats(self) -> dict[str, Any] | None: ...
|
|
38
|
+
def analysis(self) -> dict[str, Any] | None: ...
|
|
39
|
+
def preview_png(
|
|
40
|
+
self,
|
|
41
|
+
width_px: int,
|
|
42
|
+
travel: bool,
|
|
43
|
+
source: bool,
|
|
44
|
+
region: tuple[float, float, float, float] | None = None,
|
|
45
|
+
) -> bytes | None: ...
|
|
46
|
+
def preset(self) -> dict[str, Any]: ...
|
|
47
|
+
def load_preset(self, preset: dict[str, Any]) -> list[str]: ...
|
|
48
|
+
def cache_stats(self) -> dict[str, int]: ...
|
|
49
|
+
def clear_cache(self) -> None: ...
|
|
50
|
+
|
|
51
|
+
def list_styles() -> list[dict[str, str]]: ...
|
|
52
|
+
def describe(name: str) -> dict[str, Any]: ...
|
|
53
|
+
def inks() -> dict[str, str]: ...
|
|
54
|
+
def synthetic_gradient(w: int, h: int) -> NDArray[np.float32]: ...
|
|
55
|
+
def synthetic_checkerboard(w: int, h: int, cells: int) -> NDArray[np.float32]: ...
|
|
56
|
+
def synthetic_radial(w: int, h: int) -> NDArray[np.float32]: ...
|
|
57
|
+
def synthetic_noise(w: int, h: int, seed: int) -> NDArray[np.float32]: ...
|
|
58
|
+
def synthetic_flat(w: int, h: int, value: float) -> NDArray[np.float32]: ...
|
plotline/cli/__init__.py
ADDED
|
File without changes
|
plotline/cli/main.py
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""The command line interface: list the styles, describe one, analyse an image, run a style on it,
|
|
2
|
+
and serve the web interface and the MCP server.
|
|
3
|
+
|
|
4
|
+
``run`` sets a ``Session`` up from its options, runs the style and writes the outputs. ``analyse``
|
|
5
|
+
prints the session's analysis of an image. ``styles`` and ``describe`` read the style registry.
|
|
6
|
+
``gui`` serves the web bundle that a release wheel carries. ``mcp`` serves a session to an AI
|
|
7
|
+
assistant on stdio. The work all happens in the session layer, so this module holds no algorithm
|
|
8
|
+
code.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import http.server
|
|
12
|
+
import importlib.util
|
|
13
|
+
import json
|
|
14
|
+
import sys
|
|
15
|
+
import webbrowser
|
|
16
|
+
from collections.abc import Callable, Iterator
|
|
17
|
+
from contextlib import contextmanager
|
|
18
|
+
from dataclasses import replace
|
|
19
|
+
from functools import partial
|
|
20
|
+
from importlib import resources
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
import click
|
|
25
|
+
|
|
26
|
+
from plotline import inks
|
|
27
|
+
from plotline.registry import get_style, list_styles
|
|
28
|
+
from plotline.session import Session
|
|
29
|
+
from plotline.types import Pen
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def coerce(text: str) -> bool | int | float | str:
|
|
33
|
+
"""Read a ``--set`` value: ``true`` and ``false`` as booleans, then an integer, then a
|
|
34
|
+
float, and anything else as the text itself. The engine checks the value against the setting."""
|
|
35
|
+
if text in ("true", "false"):
|
|
36
|
+
return text == "true"
|
|
37
|
+
for kind in (int, float):
|
|
38
|
+
try:
|
|
39
|
+
return kind(text)
|
|
40
|
+
except ValueError:
|
|
41
|
+
pass
|
|
42
|
+
return text
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def parse_set(value: str) -> tuple[str, str, bool | int | float | str]:
|
|
46
|
+
"""Split ``STAGE.KEY=VALUE`` into ``(stage, key, value)``, with the value read by ``coerce``.
|
|
47
|
+
|
|
48
|
+
There must be exactly one ``.`` before the ``=`` and exactly one ``=``, and the stage and the
|
|
49
|
+
key must not be empty. Anything else raises ``click.BadParameter``.
|
|
50
|
+
"""
|
|
51
|
+
target, equals, text = value.partition("=")
|
|
52
|
+
stage, _, key = target.partition(".")
|
|
53
|
+
if not (equals and stage and key) or "." in key or "=" in text:
|
|
54
|
+
raise click.BadParameter(f"{value!r} must look like STAGE.KEY=VALUE")
|
|
55
|
+
return stage, key, coerce(text)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def parse_pen(value: str) -> Pen:
|
|
59
|
+
"""Split ``NAME:WIDTH_MM:COLOR`` into a ``Pen``, as in ``Red 0.5:0.5:#ff0000``.
|
|
60
|
+
|
|
61
|
+
Only the last two colons separate the fields, so the name may hold colons and the colour keeps
|
|
62
|
+
its ``#``. The colour may also be the name of one of the ``inks()``, in any case. A wrong
|
|
63
|
+
shape or a width that is not a number raise ``click.BadParameter``; the engine checks the
|
|
64
|
+
width and colour when the session is given the pen.
|
|
65
|
+
"""
|
|
66
|
+
parts = value.rsplit(":", 2)
|
|
67
|
+
if len(parts) != 3:
|
|
68
|
+
raise click.BadParameter(f"{value!r} must look like NAME:WIDTH_MM:COLOR")
|
|
69
|
+
name, width, color = parts
|
|
70
|
+
color = inks().get(color.lower(), color)
|
|
71
|
+
try:
|
|
72
|
+
return Pen(name, float(width), color)
|
|
73
|
+
except ValueError as error:
|
|
74
|
+
raise click.BadParameter(f"{value!r}: {error}") from error
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@contextmanager
|
|
78
|
+
def _errors() -> Iterator[None]:
|
|
79
|
+
"""Turn the errors that bad input causes in the session into a one-line CLI error. The engine
|
|
80
|
+
reports a refused value as ``ValueError``, and a file that cannot be read is an
|
|
81
|
+
``OSError``."""
|
|
82
|
+
try:
|
|
83
|
+
yield
|
|
84
|
+
except (ValueError, OSError) as error:
|
|
85
|
+
raise click.ClickException(str(error)) from error
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@click.group()
|
|
89
|
+
def main() -> None:
|
|
90
|
+
"""Turn images into line art for pen plotters."""
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@main.command()
|
|
94
|
+
def styles() -> None:
|
|
95
|
+
"""List the styles that can be run."""
|
|
96
|
+
for style in list_styles():
|
|
97
|
+
click.echo(f"{style.name} {style.label} {style.description}")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@main.command()
|
|
101
|
+
@click.argument("style")
|
|
102
|
+
@click.option("--json", "as_json", is_flag=True, help="Print the settings as JSON Schema.")
|
|
103
|
+
def describe(style: str, as_json: bool) -> None:
|
|
104
|
+
"""Show the stages of STYLE and the settings of each."""
|
|
105
|
+
with _errors():
|
|
106
|
+
schema = get_style(style).schema()
|
|
107
|
+
if as_json:
|
|
108
|
+
click.echo(json.dumps(schema, indent=2))
|
|
109
|
+
return
|
|
110
|
+
for i, stage in enumerate(schema["stages"]):
|
|
111
|
+
if i:
|
|
112
|
+
click.echo()
|
|
113
|
+
click.echo(f"{stage['name']} ({stage['label']})")
|
|
114
|
+
# A stage without settings has no properties in its schema.
|
|
115
|
+
for name, prop in stage["schema"].get("properties", {}).items():
|
|
116
|
+
fields = [name, prop.get("type", "any"), f"default {json.dumps(prop.get('default'))}"]
|
|
117
|
+
for word, key in (("min", "minimum"), ("max", "maximum")):
|
|
118
|
+
if key in prop:
|
|
119
|
+
fields.append(f"{word} {prop[key]}")
|
|
120
|
+
fields.append(prop.get("description", ""))
|
|
121
|
+
click.echo(" " + " ".join(fields))
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def page_options(command: Callable[..., Any]) -> Callable[..., Any]:
|
|
125
|
+
"""Give ``command`` the options that set the page, which ``set_page`` then applies."""
|
|
126
|
+
for option in (
|
|
127
|
+
click.option(
|
|
128
|
+
"--fit",
|
|
129
|
+
type=click.Choice(["fit", "fill"]),
|
|
130
|
+
help="Keep the whole image on the page (fit) or fill the drawing area with it (fill).",
|
|
131
|
+
),
|
|
132
|
+
click.option("--margin", type=float, metavar="MM", help="Margin around the drawing area."),
|
|
133
|
+
click.option("--height", type=float, metavar="MM", help="Page height."),
|
|
134
|
+
click.option("--width", type=float, metavar="MM", help="Page width."),
|
|
135
|
+
): # the last one applied is listed first
|
|
136
|
+
command = option(command)
|
|
137
|
+
return command
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def set_page(
|
|
141
|
+
session: Session,
|
|
142
|
+
width: float | None,
|
|
143
|
+
height: float | None,
|
|
144
|
+
margin: float | None,
|
|
145
|
+
fit: str | None,
|
|
146
|
+
) -> None:
|
|
147
|
+
"""Change the page of ``session`` by the options that were given; the rest stay as they are."""
|
|
148
|
+
page: dict[str, Any] = {
|
|
149
|
+
"width_mm": width,
|
|
150
|
+
"height_mm": height,
|
|
151
|
+
"margin_mm": margin,
|
|
152
|
+
"fit": fit,
|
|
153
|
+
}
|
|
154
|
+
changes = {key: value for key, value in page.items() if value is not None}
|
|
155
|
+
if changes:
|
|
156
|
+
session.set_canvas(replace(session.canvas, **changes))
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@main.command()
|
|
160
|
+
@click.argument("image", type=click.Path())
|
|
161
|
+
@page_options
|
|
162
|
+
def analyse(
|
|
163
|
+
image: str, width: float | None, height: float | None, margin: float | None, fit: str | None
|
|
164
|
+
) -> None:
|
|
165
|
+
"""Print what IMAGE looks like and the changes that would suit it, as JSON."""
|
|
166
|
+
session = Session()
|
|
167
|
+
with _errors():
|
|
168
|
+
session.load_image(image)
|
|
169
|
+
set_page(session, width, height, margin, fit)
|
|
170
|
+
click.echo(json.dumps(session.analysis(), indent=2))
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@main.command()
|
|
174
|
+
@click.argument("style")
|
|
175
|
+
@click.argument("image", type=click.Path())
|
|
176
|
+
@click.option("--out", required=True, type=click.Path(), help="Where to write the SVG.")
|
|
177
|
+
@click.option("--json", "json_out", type=click.Path(), help="Also write the paths as JSON.")
|
|
178
|
+
@click.option("--preview", type=click.Path(), help="Also write a PNG preview.")
|
|
179
|
+
@page_options
|
|
180
|
+
@click.option(
|
|
181
|
+
"--pen",
|
|
182
|
+
"pens",
|
|
183
|
+
multiple=True,
|
|
184
|
+
callback=lambda ctx, param, values: [parse_pen(v) for v in values],
|
|
185
|
+
metavar="NAME:WIDTH_MM:COLOR",
|
|
186
|
+
help=(
|
|
187
|
+
"A pen, such as 'Red:0.5:#ff0000' or 'Fine:0.3:blue'. The colour is #rrggbb or one of "
|
|
188
|
+
f"the inks {', '.join(inks())}. "
|
|
189
|
+
"Repeat the option to give more pens. By default only the first pen draws; "
|
|
190
|
+
"set split.mode to share the drawing between them."
|
|
191
|
+
),
|
|
192
|
+
)
|
|
193
|
+
@click.option(
|
|
194
|
+
"--set",
|
|
195
|
+
"sets",
|
|
196
|
+
multiple=True,
|
|
197
|
+
callback=lambda ctx, param, values: [parse_set(v) for v in values],
|
|
198
|
+
metavar="STAGE.KEY=VALUE",
|
|
199
|
+
help="Change a setting, such as tone.resolution=500. Repeat for more. See 'describe'.",
|
|
200
|
+
)
|
|
201
|
+
@click.option("--preset", type=click.Path(), help="Start from a saved preset.")
|
|
202
|
+
@click.option(
|
|
203
|
+
"--save-preset",
|
|
204
|
+
type=click.Path(),
|
|
205
|
+
help="Write the style, page, pens and settings used to a preset file.",
|
|
206
|
+
)
|
|
207
|
+
@click.option("--quiet", is_flag=True, help="Do not print progress.")
|
|
208
|
+
def run(
|
|
209
|
+
style: str,
|
|
210
|
+
image: str,
|
|
211
|
+
out: str,
|
|
212
|
+
json_out: str | None,
|
|
213
|
+
preview: str | None,
|
|
214
|
+
width: float | None,
|
|
215
|
+
height: float | None,
|
|
216
|
+
margin: float | None,
|
|
217
|
+
fit: str | None,
|
|
218
|
+
pens: list[Pen],
|
|
219
|
+
sets: list[tuple[str, str, bool | int | float | str]],
|
|
220
|
+
preset: str | None,
|
|
221
|
+
save_preset: str | None,
|
|
222
|
+
quiet: bool,
|
|
223
|
+
) -> None:
|
|
224
|
+
"""Run STYLE on IMAGE and write the drawing as an SVG."""
|
|
225
|
+
session = Session(max_fps=4)
|
|
226
|
+
if not quiet:
|
|
227
|
+
session.subscribe(lambda frame: click.echo(f"{frame.stage}: {frame.label}", err=True))
|
|
228
|
+
with _errors():
|
|
229
|
+
session.load_image(image)
|
|
230
|
+
if preset is not None:
|
|
231
|
+
session.load_preset(preset)
|
|
232
|
+
# A preset of the same style keeps its settings, which set_style would reset.
|
|
233
|
+
if session.style.name != style:
|
|
234
|
+
session.set_style(style)
|
|
235
|
+
set_page(session, width, height, margin, fit)
|
|
236
|
+
if pens:
|
|
237
|
+
session.set_pens(pens)
|
|
238
|
+
for stage, key, value in sets:
|
|
239
|
+
session.update(stage, **{key: value})
|
|
240
|
+
session.run_blocking()
|
|
241
|
+
session.export_svg(out)
|
|
242
|
+
if json_out is not None:
|
|
243
|
+
session.export_json(json_out)
|
|
244
|
+
if preview is not None:
|
|
245
|
+
Path(preview).write_bytes(session.preview_png())
|
|
246
|
+
if save_preset is not None:
|
|
247
|
+
session.save_preset(save_preset)
|
|
248
|
+
stats = session.stats()
|
|
249
|
+
click.echo(
|
|
250
|
+
f"paths: {stats.path_count} pen down: {stats.pen_down_mm:.1f} mm "
|
|
251
|
+
f"pen up: {stats.pen_up_mm:.1f} mm estimated: {stats.estimated_seconds:.1f} s"
|
|
252
|
+
)
|
|
253
|
+
if len(pens) > 1 and session.settings["split"]["mode"] == "one_pen":
|
|
254
|
+
click.echo(
|
|
255
|
+
"Pen use is set to one pen, so only the first pen draws. Add "
|
|
256
|
+
"--set split.mode=light_to_dark or --set split.mode=by_colour to use the others.",
|
|
257
|
+
err=True,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
@main.command()
|
|
262
|
+
@click.option("--port", type=int, default=8765, show_default=True, help="Port to serve on.")
|
|
263
|
+
def gui(port: int) -> None:
|
|
264
|
+
"""Serve the web interface on this computer and open it in the browser."""
|
|
265
|
+
web = resources.files("plotline") / "web"
|
|
266
|
+
if not (web / "index.html").is_file():
|
|
267
|
+
message = "This build has no GUI bundle; install a release wheel or build the web app."
|
|
268
|
+
click.echo(message, err=True)
|
|
269
|
+
sys.exit(1)
|
|
270
|
+
handler = partial(http.server.SimpleHTTPRequestHandler, directory=str(web))
|
|
271
|
+
with _errors(): # most often the port is taken
|
|
272
|
+
server = http.server.ThreadingHTTPServer(("127.0.0.1", port), handler)
|
|
273
|
+
with server:
|
|
274
|
+
url = f"http://127.0.0.1:{server.server_address[1]}/"
|
|
275
|
+
click.echo(f"Serving the GUI at {url}; press Ctrl-C to stop.")
|
|
276
|
+
webbrowser.open(url)
|
|
277
|
+
try:
|
|
278
|
+
server.serve_forever()
|
|
279
|
+
except KeyboardInterrupt:
|
|
280
|
+
pass
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
@main.command()
|
|
284
|
+
def mcp() -> None:
|
|
285
|
+
"""Serve the session to an AI assistant over MCP on stdio."""
|
|
286
|
+
# Asked first because the server imports other modules of the extra before the SDK; any other
|
|
287
|
+
# missing module is a bug, not a missing extra.
|
|
288
|
+
if importlib.util.find_spec("mcp") is None:
|
|
289
|
+
click.echo('The MCP server needs the mcp extra: pip install "plotline[mcp]"', err=True)
|
|
290
|
+
sys.exit(1)
|
|
291
|
+
# Imported here so the other commands do not load the SDK.
|
|
292
|
+
from plotline.mcp_server import build_server
|
|
293
|
+
|
|
294
|
+
build_server().run()
|