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 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()
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