rmink 2.0.0__py3-none-any.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.
- rmink/__init__.py +0 -0
- rmink/api.py +258 -0
- rmink/cli.py +193 -0
- rmink/color/__init__.py +0 -0
- rmink/color/palette.py +35 -0
- rmink/devices.py +110 -0
- rmink/extract/__init__.py +0 -0
- rmink/extract/centerline.py +158 -0
- rmink/extract/raster.py +188 -0
- rmink/extract/vector.py +69 -0
- rmink/geometry/__init__.py +0 -0
- rmink/geometry/hatch.py +91 -0
- rmink/geometry/order.py +124 -0
- rmink/geometry/simplify.py +72 -0
- rmink/geometry/transform.py +60 -0
- rmink/ingest/__init__.py +78 -0
- rmink/ingest/image.py +36 -0
- rmink/ingest/pdf.py +61 -0
- rmink/ingest/svg.py +93 -0
- rmink/mcp_server.py +146 -0
- rmink/model.py +49 -0
- rmink/options.py +82 -0
- rmink/package/__init__.py +0 -0
- rmink/package/rmdoc.py +183 -0
- rmink/render/__init__.py +0 -0
- rmink/render/preview.py +86 -0
- rmink/support.py +22 -0
- rmink/web/__init__.py +15 -0
- rmink/web/app.py +231 -0
- rmink/web/static/index.html +534 -0
- rmink/web/static/thanks.html +38 -0
- rmink/writer/__init__.py +0 -0
- rmink/writer/rm_v6.py +123 -0
- rmink-2.0.0.dist-info/METADATA +197 -0
- rmink-2.0.0.dist-info/RECORD +38 -0
- rmink-2.0.0.dist-info/WHEEL +4 -0
- rmink-2.0.0.dist-info/entry_points.txt +2 -0
- rmink-2.0.0.dist-info/licenses/LICENSE +235 -0
rmink/__init__.py
ADDED
|
File without changes
|
rmink/api.py
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""Public entry point: inputs -> ``.rmdoc``. The CLI, MCP server and web UI are
|
|
2
|
+
thin wrappers around :func:`convert`."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from uuid import UUID
|
|
10
|
+
|
|
11
|
+
from PIL import Image
|
|
12
|
+
|
|
13
|
+
from rmink.devices import MONO_PALETTE, DeviceProfile, get_device
|
|
14
|
+
from rmink.extract.raster import extract_raster
|
|
15
|
+
from rmink.extract.vector import extract_vector
|
|
16
|
+
from rmink.geometry.order import enforce_budget, reading_order
|
|
17
|
+
from rmink.geometry.transform import Fit, place
|
|
18
|
+
from rmink.ingest import RasterSource, Source, VectorSource, load
|
|
19
|
+
from rmink.model import Document, Page
|
|
20
|
+
from rmink.options import ConvertOptions, ConvertResult, Mode, PageStats
|
|
21
|
+
from rmink.package.rmdoc import RmdocPage, UuidFactory, write_rmdoc
|
|
22
|
+
from rmink.render.preview import contact_sheet, render_rm, thumbnail_png
|
|
23
|
+
from rmink.writer.rm_v6 import write_page
|
|
24
|
+
|
|
25
|
+
log = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class Rendered:
|
|
30
|
+
"""A converted document before packaging."""
|
|
31
|
+
|
|
32
|
+
document: Document
|
|
33
|
+
labels: list[str]
|
|
34
|
+
rm_pages: list[bytes]
|
|
35
|
+
images: list[Image.Image]
|
|
36
|
+
warnings: list[str]
|
|
37
|
+
device: DeviceProfile
|
|
38
|
+
uuids: UuidFactory
|
|
39
|
+
author: UUID # must match .content cPages.uuids[0].first
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _palette(device: DeviceProfile, colors: str):
|
|
43
|
+
if colors == "mono":
|
|
44
|
+
return MONO_PALETTE[:1], None
|
|
45
|
+
clusters = int(colors) if colors.isdigit() else None
|
|
46
|
+
return device.palette, clusters
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def source_to_page(src: Source, device: DeviceProfile, opts: ConvertOptions) -> Page:
|
|
50
|
+
palette, clusters = _palette(device, opts.colors)
|
|
51
|
+
if isinstance(src, VectorSource):
|
|
52
|
+
placement = place(
|
|
53
|
+
src.width,
|
|
54
|
+
src.height,
|
|
55
|
+
device,
|
|
56
|
+
opts.fit,
|
|
57
|
+
opts.margin_mm,
|
|
58
|
+
physical_px_per_unit=src.px_per_unit_at_dpi * device.dpi,
|
|
59
|
+
)
|
|
60
|
+
strokes = extract_vector(src, placement, device, palette, opts)
|
|
61
|
+
else:
|
|
62
|
+
h, w = src.image.shape[:2]
|
|
63
|
+
true_size = device.dpi / src.dpi if src.dpi else 1.0
|
|
64
|
+
placement = place(w, h, device, opts.fit, opts.margin_mm, physical_px_per_unit=true_size)
|
|
65
|
+
# Photographed black ink reads as grey; gray is only offered when it is
|
|
66
|
+
# an exact source colour (vectors).
|
|
67
|
+
raster_palette = tuple(e for e in palette if e.name != "gray") or palette
|
|
68
|
+
strokes = extract_raster(src, placement, device, raster_palette, clusters, opts)
|
|
69
|
+
|
|
70
|
+
strokes = enforce_budget(reading_order(strokes), opts.max_points, opts.simplify)
|
|
71
|
+
return Page(device.width, placement.page_height, strokes)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def render(inputs: list[Path], opts: ConvertOptions) -> Rendered:
|
|
75
|
+
device = get_device(opts.device)
|
|
76
|
+
if opts.mode == Mode.vector and any(p.suffix.lower() not in (".pdf", ".svg") for p in inputs):
|
|
77
|
+
raise ValueError("--mode vector only works for PDF and SVG inputs")
|
|
78
|
+
|
|
79
|
+
sources: list[Source] = []
|
|
80
|
+
for path in inputs:
|
|
81
|
+
if not path.is_file():
|
|
82
|
+
raise FileNotFoundError(path)
|
|
83
|
+
sources.extend(load(path, opts.mode.value, opts.pages))
|
|
84
|
+
if not sources:
|
|
85
|
+
raise ValueError("No pages to convert")
|
|
86
|
+
|
|
87
|
+
warnings = [w for s in sources for w in s.warnings]
|
|
88
|
+
if not device.verified:
|
|
89
|
+
warnings.append(
|
|
90
|
+
f"{device.name}: coordinate scale is not yet verified on hardware; "
|
|
91
|
+
"print `rmink calibrate` and check the ruler."
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
uuids = UuidFactory(opts.seed)
|
|
95
|
+
author = uuids()
|
|
96
|
+
doc = Document(name=opts.name or inputs[0].stem)
|
|
97
|
+
rm_pages, images, labels = [], [], []
|
|
98
|
+
for src in sources:
|
|
99
|
+
log.info("Converting %s", src.label)
|
|
100
|
+
page = source_to_page(src, device, opts)
|
|
101
|
+
if not page.strokes:
|
|
102
|
+
warnings.append(f"{src.label}: no ink found")
|
|
103
|
+
data = write_page(page, device, author)
|
|
104
|
+
doc.pages.append(page)
|
|
105
|
+
rm_pages.append(data)
|
|
106
|
+
images.append(render_rm(data, device, page.height))
|
|
107
|
+
labels.append(src.label)
|
|
108
|
+
if isinstance(src, RasterSource) and opts.fit == Fit.none and not src.dpi:
|
|
109
|
+
warnings.append(f"{src.label}: image has no DPI; assumed 1 px = 1 device px")
|
|
110
|
+
return Rendered(doc, labels, rm_pages, images, warnings, device, uuids, author)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def convert(
|
|
114
|
+
inputs: list[Path],
|
|
115
|
+
output: Path | None = None,
|
|
116
|
+
opts: ConvertOptions | None = None,
|
|
117
|
+
preview: Path | None = None,
|
|
118
|
+
) -> ConvertResult:
|
|
119
|
+
opts = opts or ConvertOptions()
|
|
120
|
+
inputs = [Path(p) for p in inputs]
|
|
121
|
+
rendered = render(inputs, opts)
|
|
122
|
+
name = rendered.document.name
|
|
123
|
+
output = Path(output) if output else inputs[0].with_name(f"{inputs[0].stem}.rmdoc")
|
|
124
|
+
if output.suffix.lower() != ".rmdoc":
|
|
125
|
+
output = output.with_suffix(".rmdoc")
|
|
126
|
+
|
|
127
|
+
doc_id = str(rendered.uuids())
|
|
128
|
+
page_ids = [str(rendered.uuids()) for _ in rendered.rm_pages]
|
|
129
|
+
pages = [
|
|
130
|
+
RmdocPage(data, thumbnail_png(img))
|
|
131
|
+
for data, img in zip(rendered.rm_pages, rendered.images, strict=True)
|
|
132
|
+
]
|
|
133
|
+
result = write_rmdoc(
|
|
134
|
+
output,
|
|
135
|
+
name,
|
|
136
|
+
pages,
|
|
137
|
+
page_ids,
|
|
138
|
+
rendered.author,
|
|
139
|
+
rendered.device,
|
|
140
|
+
doc_id,
|
|
141
|
+
compress=opts.compress,
|
|
142
|
+
now_ms=0 if opts.seed is not None else None,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
if preview is not None:
|
|
146
|
+
save_preview(rendered.images, Path(preview))
|
|
147
|
+
|
|
148
|
+
return ConvertResult(
|
|
149
|
+
path=result.path,
|
|
150
|
+
name=name,
|
|
151
|
+
device=rendered.device.key,
|
|
152
|
+
size_bytes=result.size_bytes,
|
|
153
|
+
pages=[
|
|
154
|
+
PageStats(label=lbl, strokes=len(p.strokes), points=p.point_count, height_px=p.height)
|
|
155
|
+
for lbl, p in zip(rendered.labels, rendered.document.pages, strict=True)
|
|
156
|
+
],
|
|
157
|
+
warnings=rendered.warnings + result.warnings,
|
|
158
|
+
preview_path=Path(preview) if preview else None,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def save_preview(images: list[Image.Image], path: Path) -> Path:
|
|
163
|
+
img = images[0] if len(images) == 1 else contact_sheet(images)
|
|
164
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
165
|
+
img.save(path)
|
|
166
|
+
return path
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def calibration_page(device: DeviceProfile) -> Page:
|
|
170
|
+
"""A page with millimetre rulers along two edges and a 50 mm square, for
|
|
171
|
+
checking the coordinate scale on real hardware."""
|
|
172
|
+
from rmink.model import Stroke
|
|
173
|
+
|
|
174
|
+
mm = device.px_per_mm
|
|
175
|
+
x0, y0 = 10 * mm, 10 * mm
|
|
176
|
+
strokes = []
|
|
177
|
+
length_x = int((device.width - 2 * x0) / mm)
|
|
178
|
+
length_y = int((device.height - 2 * y0) / mm)
|
|
179
|
+
strokes.append(Stroke([[x0, y0], [x0 + length_x * mm, y0]], width=2))
|
|
180
|
+
strokes.append(Stroke([[x0, y0], [x0, y0 + length_y * mm]], width=2))
|
|
181
|
+
for i in range(length_x + 1):
|
|
182
|
+
tick = 6 if i % 10 == 0 else (4 if i % 5 == 0 else 2)
|
|
183
|
+
strokes.append(Stroke([[x0 + i * mm, y0], [x0 + i * mm, y0 + tick * mm]], width=1.5))
|
|
184
|
+
for i in range(length_y + 1):
|
|
185
|
+
tick = 6 if i % 10 == 0 else (4 if i % 5 == 0 else 2)
|
|
186
|
+
strokes.append(Stroke([[x0, y0 + i * mm], [x0 + tick * mm, y0 + i * mm]], width=1.5))
|
|
187
|
+
cx, cy, s = device.width / 2, device.height / 2, 25 * mm
|
|
188
|
+
corners = [(-1, -1), (1, -1), (1, 1), (-1, 1), (-1, -1)]
|
|
189
|
+
strokes.append(Stroke([[cx + dx * s, cy + dy * s] for dx, dy in corners], width=3))
|
|
190
|
+
return Page(device.width, device.height, strokes)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def calibration_sheet(device_key: str, output: Path, seed: int | None = None) -> Path:
|
|
194
|
+
device = get_device(device_key)
|
|
195
|
+
uuids = UuidFactory(seed)
|
|
196
|
+
author = uuids()
|
|
197
|
+
page = calibration_page(device)
|
|
198
|
+
data = write_page(page, device, author)
|
|
199
|
+
image = render_rm(data, device)
|
|
200
|
+
write_rmdoc(
|
|
201
|
+
Path(output),
|
|
202
|
+
f"rmink calibration ({device.key})",
|
|
203
|
+
[RmdocPage(data, thumbnail_png(image))],
|
|
204
|
+
[str(uuids())],
|
|
205
|
+
author,
|
|
206
|
+
device,
|
|
207
|
+
str(uuids()),
|
|
208
|
+
)
|
|
209
|
+
return Path(output)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def inspect(path: Path) -> dict:
|
|
213
|
+
"""Summarise a ``.rmdoc`` archive or a single ``.rm`` page."""
|
|
214
|
+
import json
|
|
215
|
+
import zipfile
|
|
216
|
+
from collections import Counter
|
|
217
|
+
|
|
218
|
+
from rmink.render.preview import read_lines
|
|
219
|
+
|
|
220
|
+
def page_summary(name: str, data: bytes) -> dict:
|
|
221
|
+
header = data[:43].decode("ascii", "replace").strip()
|
|
222
|
+
lines = read_lines(data) if "version=6" in header else []
|
|
223
|
+
return {
|
|
224
|
+
"page": name,
|
|
225
|
+
"header": header,
|
|
226
|
+
"strokes": len(lines),
|
|
227
|
+
"points": sum(len(ln.points) for ln in lines),
|
|
228
|
+
"pens": dict(Counter(ln.tool.name for ln in lines)),
|
|
229
|
+
"colors": dict(Counter(ln.color.name for ln in lines)),
|
|
230
|
+
"bytes": len(data),
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
path = Path(path)
|
|
234
|
+
if path.suffix.lower() == ".rm":
|
|
235
|
+
return {"file": str(path), "pages": [page_summary(path.name, path.read_bytes())]}
|
|
236
|
+
|
|
237
|
+
with zipfile.ZipFile(path) as zf:
|
|
238
|
+
names = zf.namelist()
|
|
239
|
+
content_name = next(n for n in names if n.endswith(".content"))
|
|
240
|
+
meta_name = next((n for n in names if n.endswith(".metadata")), None)
|
|
241
|
+
content = json.loads(zf.read(content_name))
|
|
242
|
+
doc_id = content_name.removesuffix(".content")
|
|
243
|
+
pages = content.get("cPages", {}).get("pages") or [
|
|
244
|
+
{"id": pid} for pid in content.get("pages", [])
|
|
245
|
+
]
|
|
246
|
+
summaries = []
|
|
247
|
+
for p in pages:
|
|
248
|
+
rm_name = f"{doc_id}/{p['id']}.rm"
|
|
249
|
+
if rm_name in names:
|
|
250
|
+
summaries.append(page_summary(p["id"], zf.read(rm_name)))
|
|
251
|
+
meta = json.loads(zf.read(meta_name)) if meta_name else {}
|
|
252
|
+
return {
|
|
253
|
+
"file": str(path),
|
|
254
|
+
"name": meta.get("visibleName"),
|
|
255
|
+
"size_bytes": path.stat().st_size,
|
|
256
|
+
"page_count": len(pages),
|
|
257
|
+
"pages": summaries,
|
|
258
|
+
}
|
rmink/cli.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Command line interface: ``rmink convert|preview|inspect|devices|calibrate|mcp``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Annotated
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from rmink import api
|
|
13
|
+
from rmink.devices import all_devices
|
|
14
|
+
from rmink.geometry.transform import Fit
|
|
15
|
+
from rmink.options import ConvertOptions, FillMode, Mode, PenChoice
|
|
16
|
+
from rmink.support import support_url
|
|
17
|
+
|
|
18
|
+
app = typer.Typer(
|
|
19
|
+
help="Convert images, photos, SVGs and PDFs into native, editable reMarkable ink.",
|
|
20
|
+
no_args_is_help=True,
|
|
21
|
+
add_completion=False,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
Inputs = Annotated[list[Path], typer.Argument(help="PDF, SVG or image files", exists=True)]
|
|
25
|
+
Device = Annotated[str, typer.Option("--device", "-d", help="rm2 | pure | pro | move")]
|
|
26
|
+
ModeOpt = Annotated[Mode, typer.Option(help="How ink is extracted")]
|
|
27
|
+
Colors = Annotated[str, typer.Option(help="auto | mono | number of colour clusters")]
|
|
28
|
+
PenOpt = Annotated[PenChoice, typer.Option(help="Tablet pen used for the strokes")]
|
|
29
|
+
Width = Annotated[float, typer.Option(help="Outline/hatch pen width in device px")]
|
|
30
|
+
FillOpt = Annotated[FillMode, typer.Option(help="How filled areas are drawn")]
|
|
31
|
+
FitOpt = Annotated[Fit, typer.Option(help="page: fit page | width: fit width | none: true size")]
|
|
32
|
+
Margin = Annotated[float, typer.Option(help="Page margin in mm")]
|
|
33
|
+
Threshold = Annotated[str, typer.Option(help="auto or a 0-255 ink threshold for rasters")]
|
|
34
|
+
Simplify = Annotated[float, typer.Option(help="Simplification tolerance in device px")]
|
|
35
|
+
ThickPx = Annotated[float, typer.Option(help="Raster ink wider than this is outlined")]
|
|
36
|
+
MaxPoints = Annotated[int, typer.Option(help="Point budget per page")]
|
|
37
|
+
Pages = Annotated[str | None, typer.Option(help="PDF pages, e.g. 1-3,7")]
|
|
38
|
+
Seed = Annotated[int | None, typer.Option(help="Deterministic UUIDs (for tests)")]
|
|
39
|
+
Verbose = Annotated[bool, typer.Option("--verbose", "-v")]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _setup_logging(verbose: bool) -> None:
|
|
43
|
+
logging.basicConfig(
|
|
44
|
+
level=logging.DEBUG if verbose else logging.WARNING, format="%(levelname)s %(message)s"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _options(**kw) -> ConvertOptions:
|
|
49
|
+
return ConvertOptions(**{k: v for k, v in kw.items() if v is not None})
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@app.command()
|
|
53
|
+
def convert(
|
|
54
|
+
inputs: Inputs,
|
|
55
|
+
output: Annotated[Path | None, typer.Option("--output", "-o", help="Output .rmdoc")] = None,
|
|
56
|
+
name: Annotated[str | None, typer.Option(help="Notebook name on the tablet")] = None,
|
|
57
|
+
device: Device = "rm2",
|
|
58
|
+
mode: ModeOpt = Mode.auto,
|
|
59
|
+
colors: Colors = "auto",
|
|
60
|
+
pen: PenOpt = PenChoice.fineliner,
|
|
61
|
+
width: Width = 2.0,
|
|
62
|
+
fill: FillOpt = FillMode.hatch,
|
|
63
|
+
fit: FitOpt = Fit.page,
|
|
64
|
+
margin: Margin = 5.0,
|
|
65
|
+
threshold: Threshold = "auto",
|
|
66
|
+
simplify: Simplify = 0.6,
|
|
67
|
+
thick_px: ThickPx = 9.0,
|
|
68
|
+
max_points: MaxPoints = 150_000,
|
|
69
|
+
pages: Pages = None,
|
|
70
|
+
preview: Annotated[Path | None, typer.Option(help="Also write a PNG preview")] = None,
|
|
71
|
+
compress: Annotated[bool, typer.Option(help="Deflate the archive")] = False,
|
|
72
|
+
seed: Seed = None,
|
|
73
|
+
verbose: Verbose = False,
|
|
74
|
+
) -> None:
|
|
75
|
+
"""Convert inputs into one .rmdoc notebook (one page per image / PDF page)."""
|
|
76
|
+
_setup_logging(verbose)
|
|
77
|
+
opts = _options(
|
|
78
|
+
device=device, mode=mode, colors=colors, pen=pen, width=width, fill=fill, fit=fit,
|
|
79
|
+
margin_mm=margin, threshold=threshold, simplify=simplify, thick_px=thick_px,
|
|
80
|
+
max_points=max_points, pages=pages, name=name, compress=compress, seed=seed,
|
|
81
|
+
) # fmt: skip
|
|
82
|
+
try:
|
|
83
|
+
result = api.convert(inputs, output, opts, preview=preview)
|
|
84
|
+
except (ValueError, FileNotFoundError, RuntimeError) as e:
|
|
85
|
+
typer.secho(f"error: {e}", fg="red", err=True)
|
|
86
|
+
raise typer.Exit(1) from e
|
|
87
|
+
for w in result.warnings:
|
|
88
|
+
typer.secho(f"warning: {w}", fg="yellow", err=True)
|
|
89
|
+
total = sum(p.strokes for p in result.pages)
|
|
90
|
+
typer.echo(
|
|
91
|
+
f"{result.path} ({len(result.pages)} pages, {total} strokes, "
|
|
92
|
+
f"{result.size_bytes / 1000:.0f} kB, {result.device})"
|
|
93
|
+
)
|
|
94
|
+
if result.preview_path:
|
|
95
|
+
typer.echo(f"preview: {result.preview_path}")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@app.command()
|
|
99
|
+
def preview(
|
|
100
|
+
inputs: Inputs,
|
|
101
|
+
output: Annotated[Path, typer.Option("--output", "-o")] = Path("preview.png"),
|
|
102
|
+
device: Device = "rm2",
|
|
103
|
+
mode: ModeOpt = Mode.auto,
|
|
104
|
+
colors: Colors = "auto",
|
|
105
|
+
width: Width = 2.0,
|
|
106
|
+
fill: FillOpt = FillMode.hatch,
|
|
107
|
+
fit: FitOpt = Fit.page,
|
|
108
|
+
threshold: Threshold = "auto",
|
|
109
|
+
simplify: Simplify = 0.6,
|
|
110
|
+
pages: Pages = None,
|
|
111
|
+
verbose: Verbose = False,
|
|
112
|
+
) -> None:
|
|
113
|
+
"""Render what the converted ink will look like, without writing a notebook."""
|
|
114
|
+
_setup_logging(verbose)
|
|
115
|
+
opts = _options(
|
|
116
|
+
device=device, mode=mode, colors=colors, width=width, fill=fill, fit=fit,
|
|
117
|
+
threshold=threshold, simplify=simplify, pages=pages,
|
|
118
|
+
) # fmt: skip
|
|
119
|
+
rendered = api.render(inputs, opts)
|
|
120
|
+
api.save_preview(rendered.images, output)
|
|
121
|
+
for w in rendered.warnings:
|
|
122
|
+
typer.secho(f"warning: {w}", fg="yellow", err=True)
|
|
123
|
+
typer.echo(str(output))
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@app.command()
|
|
127
|
+
def inspect(path: Annotated[Path, typer.Argument(exists=True)]) -> None:
|
|
128
|
+
"""Show pages, strokes, pens and colours of a .rmdoc or .rm file."""
|
|
129
|
+
typer.echo(json.dumps(api.inspect(path), indent=2))
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@app.command()
|
|
133
|
+
def devices() -> None:
|
|
134
|
+
"""List device profiles."""
|
|
135
|
+
for d in all_devices().values():
|
|
136
|
+
status = "verified" if d.verified else "beta"
|
|
137
|
+
colour = "colour" if d.color else "mono"
|
|
138
|
+
typer.echo(f"{d.key:6} {d.name:28} {d.width}x{d.height} {d.dpi:g} dpi {colour:6} {status}")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@app.command()
|
|
142
|
+
def calibrate(
|
|
143
|
+
device: Device = "rm2",
|
|
144
|
+
output: Annotated[Path, typer.Option("--output", "-o")] = Path("rmink-calibration.rmdoc"),
|
|
145
|
+
) -> None:
|
|
146
|
+
"""Write a ruler page; measure it on the tablet to verify the scale."""
|
|
147
|
+
path = api.calibration_sheet(device, output)
|
|
148
|
+
typer.echo(
|
|
149
|
+
f"{path}\nOpen it on the tablet: the long ticks are 10 mm apart and the square is "
|
|
150
|
+
"50 mm. If they are off, set ink_scale for this device in "
|
|
151
|
+
"~/.config/rmink/devices.toml."
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@app.command()
|
|
156
|
+
def web(
|
|
157
|
+
host: Annotated[str, typer.Option(help="Interface to bind")] = "127.0.0.1",
|
|
158
|
+
port: Annotated[int, typer.Option(help="Port")] = 8765,
|
|
159
|
+
open_browser: Annotated[bool, typer.Option("--open/--no-open")] = True,
|
|
160
|
+
) -> None:
|
|
161
|
+
"""Start the web UI: drop files, preview the ink, download the notebook."""
|
|
162
|
+
try:
|
|
163
|
+
import uvicorn
|
|
164
|
+
|
|
165
|
+
from rmink.web import create_app
|
|
166
|
+
except ImportError as e:
|
|
167
|
+
typer.secho("The web UI needs: pip install 'rmink[web]'", fg="red", err=True)
|
|
168
|
+
raise typer.Exit(1) from e
|
|
169
|
+
url = f"http://{'localhost' if host in ('127.0.0.1', '0.0.0.0') else host}:{port}"
|
|
170
|
+
typer.echo(f"rmink web UI on {url} (Ctrl+C to stop)")
|
|
171
|
+
if support := support_url():
|
|
172
|
+
typer.echo(f"rmink is free. If it is useful to you, you can support it: {support}")
|
|
173
|
+
if open_browser:
|
|
174
|
+
import threading
|
|
175
|
+
import webbrowser
|
|
176
|
+
|
|
177
|
+
threading.Timer(1.0, webbrowser.open, args=(url,)).start()
|
|
178
|
+
uvicorn.run(create_app(), host=host, port=port, log_level="warning")
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@app.command()
|
|
182
|
+
def mcp() -> None:
|
|
183
|
+
"""Run the MCP server on stdio (for Claude and other MCP clients)."""
|
|
184
|
+
try:
|
|
185
|
+
from rmink.mcp_server import main
|
|
186
|
+
except ImportError as e:
|
|
187
|
+
typer.secho("The MCP server needs: pip install 'rmink[mcp]'", fg="red", err=True)
|
|
188
|
+
raise typer.Exit(1) from e
|
|
189
|
+
main()
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
if __name__ == "__main__":
|
|
193
|
+
app()
|
rmink/color/__init__.py
ADDED
|
File without changes
|
rmink/color/palette.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Mapping arbitrary colours to the device pen palette."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from skimage.color import rgb2lab
|
|
7
|
+
|
|
8
|
+
from rmink.devices import PaletteEntry
|
|
9
|
+
|
|
10
|
+
# Colours lighter than this (CIE L*) with little chroma count as background.
|
|
11
|
+
_WHITE_L = 92.0
|
|
12
|
+
_WHITE_CHROMA = 12.0
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def to_lab(rgb: np.ndarray) -> np.ndarray:
|
|
16
|
+
arr = np.asarray(rgb, dtype=np.float64).reshape(-1, 1, 3) / 255.0
|
|
17
|
+
return rgb2lab(arr).reshape(-1, 3)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def is_background(rgb: tuple[int, int, int]) -> bool:
|
|
21
|
+
L, a, b = to_lab(np.array(rgb))[0]
|
|
22
|
+
return L > _WHITE_L and np.hypot(a, b) < _WHITE_CHROMA
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def nearest(rgb: tuple[int, int, int], palette: tuple[PaletteEntry, ...]) -> PaletteEntry:
|
|
26
|
+
return palette[int(nearest_indices(np.array([rgb]), palette)[0])]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def nearest_indices(rgbs: np.ndarray, palette: tuple[PaletteEntry, ...]) -> np.ndarray:
|
|
30
|
+
lab = to_lab(rgbs)
|
|
31
|
+
pal = to_lab(np.array([e.rgb for e in palette]))
|
|
32
|
+
# Weight lightness less so dark saturated ink maps to its hue, not black.
|
|
33
|
+
w = np.array([0.6, 1.0, 1.0])
|
|
34
|
+
d = (((lab[:, None, :] - pal[None, :, :]) * w) ** 2).sum(axis=2)
|
|
35
|
+
return np.argmin(d, axis=1)
|
rmink/devices.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""reMarkable device profiles.
|
|
2
|
+
|
|
3
|
+
Coordinates in `.rm` v6 files are expressed in "scene units". On the
|
|
4
|
+
reMarkable 2 one scene unit equals one screen pixel (226 DPI), with x measured
|
|
5
|
+
from the horizontal centre of the page and y from the top. Other devices are
|
|
6
|
+
believed to use the same unit with a larger page, but this is not documented;
|
|
7
|
+
`ink_scale` (scene units per device pixel) lets a calibration correct it
|
|
8
|
+
without a new release. Profiles can be overridden in
|
|
9
|
+
``~/.config/rmink/devices.toml``::
|
|
10
|
+
|
|
11
|
+
[pro]
|
|
12
|
+
ink_scale = 0.987
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
import tomllib
|
|
19
|
+
from dataclasses import dataclass, replace
|
|
20
|
+
from functools import cache
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from rmscene.scene_items import PenColor
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class PaletteEntry:
|
|
28
|
+
name: str
|
|
29
|
+
color: PenColor
|
|
30
|
+
rgb: tuple[int, int, int]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
MONO_PALETTE: tuple[PaletteEntry, ...] = (
|
|
34
|
+
PaletteEntry("black", PenColor.BLACK, (0, 0, 0)),
|
|
35
|
+
PaletteEntry("gray", PenColor.GRAY, (125, 125, 125)),
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
# Approximate on-screen colours of the Paper Pro (Gallery 3) pen palette.
|
|
39
|
+
COLOR_PALETTE: tuple[PaletteEntry, ...] = MONO_PALETTE + (
|
|
40
|
+
PaletteEntry("blue", PenColor.BLUE, (40, 80, 200)),
|
|
41
|
+
PaletteEntry("red", PenColor.RED, (200, 40, 40)),
|
|
42
|
+
PaletteEntry("green", PenColor.GREEN, (40, 150, 60)),
|
|
43
|
+
PaletteEntry("yellow", PenColor.YELLOW, (240, 210, 40)),
|
|
44
|
+
PaletteEntry("pink", PenColor.PINK, (230, 110, 170)),
|
|
45
|
+
PaletteEntry("cyan", PenColor.CYAN, (60, 180, 220)),
|
|
46
|
+
PaletteEntry("magenta", PenColor.MAGENTA, (170, 70, 180)),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class DeviceProfile:
|
|
52
|
+
key: str
|
|
53
|
+
name: str
|
|
54
|
+
width: int # portrait canvas, device px
|
|
55
|
+
height: int
|
|
56
|
+
dpi: float
|
|
57
|
+
color: bool
|
|
58
|
+
ink_scale: float = 1.0 # scene units per device px
|
|
59
|
+
paper_size: tuple[int, int] | None = None # written to SceneInfo when set
|
|
60
|
+
verified: bool = False
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def palette(self) -> tuple[PaletteEntry, ...]:
|
|
64
|
+
return COLOR_PALETTE if self.color else MONO_PALETTE
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def px_per_mm(self) -> float:
|
|
68
|
+
return self.dpi / 25.4
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
_BUILTIN: dict[str, DeviceProfile] = {
|
|
72
|
+
p.key: p
|
|
73
|
+
for p in (
|
|
74
|
+
DeviceProfile("rm2", "reMarkable 2", 1404, 1872, 226, False, verified=True),
|
|
75
|
+
DeviceProfile("pure", "reMarkable Paper Pure", 1404, 1872, 226, False),
|
|
76
|
+
DeviceProfile(
|
|
77
|
+
"pro", "reMarkable Paper Pro", 1620, 2160, 229, True, paper_size=(1620, 2160)
|
|
78
|
+
),
|
|
79
|
+
DeviceProfile(
|
|
80
|
+
"move", "reMarkable Paper Pro Move", 954, 1696, 264, True, paper_size=(954, 1696)
|
|
81
|
+
),
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _config_path() -> Path:
|
|
87
|
+
base = os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config"
|
|
88
|
+
return Path(base) / "rmink" / "devices.toml"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@cache
|
|
92
|
+
def all_devices() -> dict[str, DeviceProfile]:
|
|
93
|
+
devices = dict(_BUILTIN)
|
|
94
|
+
path = _config_path()
|
|
95
|
+
if path.is_file():
|
|
96
|
+
overrides = tomllib.loads(path.read_text())
|
|
97
|
+
for key, values in overrides.items():
|
|
98
|
+
if key in devices:
|
|
99
|
+
devices[key] = replace(devices[key], **values)
|
|
100
|
+
else:
|
|
101
|
+
devices[key] = DeviceProfile(key=key, **values)
|
|
102
|
+
return devices
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def get_device(key: str) -> DeviceProfile:
|
|
106
|
+
try:
|
|
107
|
+
return all_devices()[key]
|
|
108
|
+
except KeyError:
|
|
109
|
+
known = ", ".join(all_devices())
|
|
110
|
+
raise ValueError(f"Unknown device {key!r}; choose one of: {known}") from None
|
|
File without changes
|