rmink 2.2.0__tar.gz → 2.2.1__tar.gz

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.
Files changed (92) hide show
  1. {rmink-2.2.0 → rmink-2.2.1}/CHANGELOG.md +4 -0
  2. {rmink-2.2.0 → rmink-2.2.1}/PKG-INFO +1 -1
  3. {rmink-2.2.0 → rmink-2.2.1}/deploy/vercel/requirements.txt +1 -1
  4. {rmink-2.2.0 → rmink-2.2.1}/pyproject.toml +1 -1
  5. {rmink-2.2.0 → rmink-2.2.1}/src/rmink/api.py +21 -8
  6. {rmink-2.2.0 → rmink-2.2.1}/src/rmink/extract/raster.py +3 -2
  7. {rmink-2.2.0 → rmink-2.2.1}/src/rmink/render/preview.py +6 -1
  8. {rmink-2.2.0 → rmink-2.2.1}/src/rmink/web/static/index.html +7 -2
  9. {rmink-2.2.0 → rmink-2.2.1}/src/rmink/writer/rm_v6.py +9 -0
  10. {rmink-2.2.0 → rmink-2.2.1}/tests/test_shading.py +13 -2
  11. {rmink-2.2.0 → rmink-2.2.1}/.dockerignore +0 -0
  12. {rmink-2.2.0 → rmink-2.2.1}/.github/FUNDING.yml +0 -0
  13. {rmink-2.2.0 → rmink-2.2.1}/.github/workflows/ci.yml +0 -0
  14. {rmink-2.2.0 → rmink-2.2.1}/.github/workflows/release.yml +0 -0
  15. {rmink-2.2.0 → rmink-2.2.1}/.gitignore +0 -0
  16. {rmink-2.2.0 → rmink-2.2.1}/Dockerfile +0 -0
  17. {rmink-2.2.0 → rmink-2.2.1}/LICENSE +0 -0
  18. {rmink-2.2.0 → rmink-2.2.1}/README.md +0 -0
  19. {rmink-2.2.0 → rmink-2.2.1}/deploy/vercel/.python-version +0 -0
  20. {rmink-2.2.0 → rmink-2.2.1}/deploy/vercel/app.py +0 -0
  21. {rmink-2.2.0 → rmink-2.2.1}/deploy/vercel/bin/rmapi +0 -0
  22. {rmink-2.2.0 → rmink-2.2.1}/deploy/vercel/fetch-rmapi.sh +0 -0
  23. {rmink-2.2.0 → rmink-2.2.1}/deploy/vercel/public/og.jpg +0 -0
  24. {rmink-2.2.0 → rmink-2.2.1}/deploy/vercel/public/promo/og-en-7446af05.jpg +0 -0
  25. {rmink-2.2.0 → rmink-2.2.1}/deploy/vercel/public/promo/og-sv-92100a5e.jpg +0 -0
  26. {rmink-2.2.0 → rmink-2.2.1}/deploy/vercel/public/promo/poster-en.jpg +0 -0
  27. {rmink-2.2.0 → rmink-2.2.1}/deploy/vercel/public/promo/poster-sv.jpg +0 -0
  28. {rmink-2.2.0 → rmink-2.2.1}/deploy/vercel/public/promo/rmink-promo-en.mp4 +0 -0
  29. {rmink-2.2.0 → rmink-2.2.1}/deploy/vercel/public/promo/rmink-promo-sv.mp4 +0 -0
  30. {rmink-2.2.0 → rmink-2.2.1}/deploy/vercel/public/video-sv.html +0 -0
  31. {rmink-2.2.0 → rmink-2.2.1}/deploy/vercel/public/video.html +0 -0
  32. {rmink-2.2.0 → rmink-2.2.1}/deploy/vercel/vercel.json +0 -0
  33. {rmink-2.2.0 → rmink-2.2.1}/docs/device-acceptance.md +0 -0
  34. {rmink-2.2.0 → rmink-2.2.1}/docs/hosting.md +0 -0
  35. {rmink-2.2.0 → rmink-2.2.1}/docs/releasing.md +0 -0
  36. {rmink-2.2.0 → rmink-2.2.1}/examples/document.pdf +0 -0
  37. {rmink-2.2.0 → rmink-2.2.1}/examples/handwriting.jpg +0 -0
  38. {rmink-2.2.0 → rmink-2.2.1}/examples/logo.svg +0 -0
  39. {rmink-2.2.0 → rmink-2.2.1}/examples/make_examples.py +0 -0
  40. {rmink-2.2.0 → rmink-2.2.1}/promo/.gitignore +0 -0
  41. {rmink-2.2.0 → rmink-2.2.1}/promo/README.md +0 -0
  42. {rmink-2.2.0 → rmink-2.2.1}/promo/build_data.py +0 -0
  43. {rmink-2.2.0 → rmink-2.2.1}/promo/capture_desktop.py +0 -0
  44. {rmink-2.2.0 → rmink-2.2.1}/promo/i18n.json +0 -0
  45. {rmink-2.2.0 → rmink-2.2.1}/promo/make_face.py +0 -0
  46. {rmink-2.2.0 → rmink-2.2.1}/promo/make_note.py +0 -0
  47. {rmink-2.2.0 → rmink-2.2.1}/promo/make_shade.py +0 -0
  48. {rmink-2.2.0 → rmink-2.2.1}/promo/make_share.py +0 -0
  49. {rmink-2.2.0 → rmink-2.2.1}/promo/post-texts.md +0 -0
  50. {rmink-2.2.0 → rmink-2.2.1}/promo/render.py +0 -0
  51. {rmink-2.2.0 → rmink-2.2.1}/promo/timeline.html +0 -0
  52. {rmink-2.2.0 → rmink-2.2.1}/skills/rmink/SKILL.md +0 -0
  53. {rmink-2.2.0 → rmink-2.2.1}/src/rmink/__init__.py +0 -0
  54. {rmink-2.2.0 → rmink-2.2.1}/src/rmink/cli.py +0 -0
  55. {rmink-2.2.0 → rmink-2.2.1}/src/rmink/color/__init__.py +0 -0
  56. {rmink-2.2.0 → rmink-2.2.1}/src/rmink/color/palette.py +0 -0
  57. {rmink-2.2.0 → rmink-2.2.1}/src/rmink/devices.py +0 -0
  58. {rmink-2.2.0 → rmink-2.2.1}/src/rmink/extract/__init__.py +0 -0
  59. {rmink-2.2.0 → rmink-2.2.1}/src/rmink/extract/centerline.py +0 -0
  60. {rmink-2.2.0 → rmink-2.2.1}/src/rmink/extract/shade.py +0 -0
  61. {rmink-2.2.0 → rmink-2.2.1}/src/rmink/extract/tone.py +0 -0
  62. {rmink-2.2.0 → rmink-2.2.1}/src/rmink/extract/vector.py +0 -0
  63. {rmink-2.2.0 → rmink-2.2.1}/src/rmink/geometry/__init__.py +0 -0
  64. {rmink-2.2.0 → rmink-2.2.1}/src/rmink/geometry/hatch.py +0 -0
  65. {rmink-2.2.0 → rmink-2.2.1}/src/rmink/geometry/order.py +0 -0
  66. {rmink-2.2.0 → rmink-2.2.1}/src/rmink/geometry/simplify.py +0 -0
  67. {rmink-2.2.0 → rmink-2.2.1}/src/rmink/geometry/transform.py +0 -0
  68. {rmink-2.2.0 → rmink-2.2.1}/src/rmink/ingest/__init__.py +0 -0
  69. {rmink-2.2.0 → rmink-2.2.1}/src/rmink/ingest/image.py +0 -0
  70. {rmink-2.2.0 → rmink-2.2.1}/src/rmink/ingest/pdf.py +0 -0
  71. {rmink-2.2.0 → rmink-2.2.1}/src/rmink/ingest/svg.py +0 -0
  72. {rmink-2.2.0 → rmink-2.2.1}/src/rmink/mcp_server.py +0 -0
  73. {rmink-2.2.0 → rmink-2.2.1}/src/rmink/model.py +0 -0
  74. {rmink-2.2.0 → rmink-2.2.1}/src/rmink/options.py +0 -0
  75. {rmink-2.2.0 → rmink-2.2.1}/src/rmink/package/__init__.py +0 -0
  76. {rmink-2.2.0 → rmink-2.2.1}/src/rmink/package/rmdoc.py +0 -0
  77. {rmink-2.2.0 → rmink-2.2.1}/src/rmink/render/__init__.py +0 -0
  78. {rmink-2.2.0 → rmink-2.2.1}/src/rmink/support.py +0 -0
  79. {rmink-2.2.0 → rmink-2.2.1}/src/rmink/web/__init__.py +0 -0
  80. {rmink-2.2.0 → rmink-2.2.1}/src/rmink/web/app.py +0 -0
  81. {rmink-2.2.0 → rmink-2.2.1}/src/rmink/web/cloud.py +0 -0
  82. {rmink-2.2.0 → rmink-2.2.1}/src/rmink/web/static/og.jpg +0 -0
  83. {rmink-2.2.0 → rmink-2.2.1}/src/rmink/web/static/thanks.html +0 -0
  84. {rmink-2.2.0 → rmink-2.2.1}/src/rmink/writer/__init__.py +0 -0
  85. {rmink-2.2.0 → rmink-2.2.1}/tests/conftest.py +0 -0
  86. {rmink-2.2.0 → rmink-2.2.1}/tests/test_centerline.py +0 -0
  87. {rmink-2.2.0 → rmink-2.2.1}/tests/test_cloud.py +0 -0
  88. {rmink-2.2.0 → rmink-2.2.1}/tests/test_fidelity.py +0 -0
  89. {rmink-2.2.0 → rmink-2.2.1}/tests/test_geometry.py +0 -0
  90. {rmink-2.2.0 → rmink-2.2.1}/tests/test_interfaces.py +0 -0
  91. {rmink-2.2.0 → rmink-2.2.1}/tests/test_web.py +0 -0
  92. {rmink-2.2.0 → rmink-2.2.1}/tests/test_writer.py +0 -0
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.2.1 — 2026-10-24
4
+
5
+ - Web UI: the shading previews are about twice as fast, so they also appear for camera photos in Safari on iPhone. If they still fail, the strip says why instead of disappearing.
6
+
3
7
  ## 2.2.0 — 2026-10-24
4
8
 
5
9
  - Web UI: "Send to reMarkable" uploads the notebook straight to the reMarkable Cloud, so it syncs to the tablet without USB. It uses [rmapi](https://github.com/ddvk/rmapi) (set `RMINK_RMAPI` or have `rmapi` on the PATH; the button is hidden otherwise). The first time it asks for a one-time code from my.remarkable.com; the connection is kept only in the browser. Notebooks land in *My files*, and a taken name gets `_2`, `_3` and so on.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: rmink
3
- Version: 2.2.0
3
+ Version: 2.2.1
4
4
  Summary: Convert images, photos, SVGs and PDFs into native, editable reMarkable ink (.rm v6 / .rmdoc)
5
5
  Project-URL: Homepage, https://github.com/thagvall/rmink
6
6
  Project-URL: Source, https://github.com/thagvall/rmink
@@ -1,4 +1,4 @@
1
1
  # The hosted site runs a released rmink version from PyPI.
2
2
  # Bump this after each release to update the site.
3
- rmink[web]==2.1.1
3
+ rmink[web]==2.2.0
4
4
  fastapi
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "rmink"
7
- version = "2.2.0"
7
+ version = "2.2.1"
8
8
  description = "Convert images, photos, SVGs and PDFs into native, editable reMarkable ink (.rm v6 / .rmdoc)"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -21,11 +21,14 @@ from rmink.ingest import RasterSource, Source, VectorSource, load
21
21
  from rmink.model import Document, Page, Stroke
22
22
  from rmink.options import ConvertOptions, ConvertResult, FillMode, Mode, PageStats, Shading
23
23
  from rmink.package.rmdoc import RmdocPage, UuidFactory, write_rmdoc
24
- from rmink.render.preview import contact_sheet, render_rm, thumbnail_png
25
- from rmink.writer.rm_v6 import write_page
24
+ from rmink.render.preview import contact_sheet, render_lines, render_rm, thumbnail_png
25
+ from rmink.writer.rm_v6 import page_lines, write_page
26
26
 
27
27
  log = logging.getLogger(__name__)
28
28
 
29
+ # Style thumbnails extract lines at device resolution instead of 2x.
30
+ _STYLE_OVERSAMPLE = 1.0
31
+
29
32
 
30
33
  @dataclass
31
34
  class Rendered:
@@ -85,7 +88,11 @@ def _raster_placement(src: RasterSource, device: DeviceProfile, opts: ConvertOpt
85
88
 
86
89
 
87
90
  def _raster_lines(
88
- src: RasterSource, placement: Placement, device: DeviceProfile, opts: ConvertOptions
91
+ src: RasterSource,
92
+ placement: Placement,
93
+ device: DeviceProfile,
94
+ opts: ConvertOptions,
95
+ oversample: float | None = None,
89
96
  ) -> list[Stroke]:
90
97
  palette, clusters = _palette(device, opts.colors)
91
98
  # Photographed black ink reads as grey; gray is only offered when it is
@@ -94,7 +101,8 @@ def _raster_lines(
94
101
  if opts.shading != Shading.none and opts.fill == FillMode.hatch:
95
102
  # Shading draws the dark areas; even hatching would hide it.
96
103
  opts = opts.model_copy(update={"fill": FillMode.outline})
97
- return extract_raster(src, placement, device, raster_palette, clusters, opts)
104
+ extra = {} if oversample is None else {"oversample": oversample}
105
+ return extract_raster(src, placement, device, raster_palette, clusters, opts, **extra)
98
106
 
99
107
 
100
108
  def source_to_page(src: Source, device: DeviceProfile, opts: ConvertOptions) -> Page:
@@ -141,14 +149,17 @@ def style_previews(
141
149
  return []
142
150
  styles = styles or list(Shading)
143
151
  placement = _raster_placement(src, device, opts)
144
- plain = _raster_lines(src, placement, device, opts.model_copy(update={"shading": Shading.none}))
152
+ # Lines at device resolution instead of 2x: half the time, and the
153
+ # thumbnails are far too small to show the difference.
154
+ plain = _raster_lines(
155
+ src, placement, device, opts.model_copy(update={"shading": Shading.none}), _STYLE_OVERSAMPLE
156
+ )
145
157
  if opts.fill == FillMode.hatch:
146
158
  shaded = opts.model_copy(update={"shading": Shading.stipple})
147
- lines = _raster_lines(src, placement, device, shaded)
159
+ lines = _raster_lines(src, placement, device, shaded, _STYLE_OVERSAMPLE)
148
160
  else:
149
161
  lines = plain
150
162
  tone = device_tone(src, placement, opts.shade_contrast)
151
- author = UuidFactory(0)()
152
163
  out = []
153
164
  for style in styles:
154
165
  style_opts = opts.model_copy(update={"shading": style})
@@ -159,7 +170,9 @@ def style_previews(
159
170
  )
160
171
  strokes = enforce_budget(reading_order(strokes), opts.max_points, opts.simplify)
161
172
  page = Page(device.width, placement.page_height, strokes)
162
- image = render_rm(write_page(page, device, author), device, page.height)
173
+ # Straight from the lines: writing and re-reading the .rm file took two
174
+ # thirds of the time, and the main preview already round-trips the writer.
175
+ image = render_lines(page_lines(page, device), device, page.height)
163
176
  out.append(StylePreview(style, image, len(page.strokes), page.point_count))
164
177
  return out
165
178
 
@@ -139,10 +139,11 @@ def extract_raster(
139
139
  palette: tuple[PaletteEntry, ...],
140
140
  clusters: int | None,
141
141
  opts: ConvertOptions,
142
+ oversample: float = _OVERSAMPLE,
142
143
  ) -> list[Stroke]:
143
144
  rgb = src.image
144
- # Resample so one mask pixel is 1/_OVERSAMPLE device pixel.
145
- factor = min(placement.scale * _OVERSAMPLE, _MAX_UPSAMPLE)
145
+ # Resample so one mask pixel is 1/oversample device pixel.
146
+ factor = min(placement.scale * oversample, _MAX_UPSAMPLE)
146
147
  if abs(factor - 1) > 0.05:
147
148
  h, w = rgb.shape[:2]
148
149
  size = (max(1, round(w * factor)), max(1, round(h * factor)))
@@ -39,7 +39,12 @@ def _rgb(line: si.Line) -> tuple[int, int, int]:
39
39
 
40
40
 
41
41
  def render_rm(rm_bytes: bytes, device: DeviceProfile, height: int | None = None) -> Image.Image:
42
- lines = read_lines(rm_bytes)
42
+ return render_lines(read_lines(rm_bytes), device, height)
43
+
44
+
45
+ def render_lines(
46
+ lines: list[si.Line], device: DeviceProfile, height: int | None = None
47
+ ) -> Image.Image:
43
48
  s = device.ink_scale
44
49
  max_y = max((p.y for ln in lines for p in ln.points), default=0) / s
45
50
  h = max(height or device.height, int(max_y) + 1)
@@ -637,8 +637,13 @@ dialog img { display: block; max-width: 96vw; max-height: 92vh; background: #fff
637
637
  }));
638
638
  markStyles();
639
639
  } catch (e) {
640
- if (e.name === "AbortError") return;
641
- box.hidden = true;
640
+ if (e.name === "AbortError" || seq !== state.styleSeq) return;
641
+ // Say why instead of hiding the strip; phones are slow enough to hit timeouts.
642
+ box.hidden = false;
643
+ $("#style-cards").replaceChildren(Object.assign(document.createElement("p"), {
644
+ className: "notice err", style: "grid-column: 1 / -1",
645
+ textContent: `Could not make the shading previews: ${e.message}`,
646
+ }));
642
647
  }
643
648
  }
644
649
 
@@ -122,6 +122,15 @@ def page_blocks(page: Page, device: DeviceProfile, author_uuid: UUID) -> Iterato
122
122
  counter += 1
123
123
 
124
124
 
125
+ def page_lines(page: Page, device: DeviceProfile) -> list[si.Line]:
126
+ """The lines ``write_page`` would store, without serialising them."""
127
+ return [
128
+ b.item.value
129
+ for b in page_blocks(page, device, UUID(int=0))
130
+ if isinstance(b, SceneLineItemBlock)
131
+ ]
132
+
133
+
125
134
  def write_page(page: Page, device: DeviceProfile, author_uuid: UUID) -> bytes:
126
135
  buf = io.BytesIO()
127
136
  write_blocks(buf, page_blocks(page, device, author_uuid))
@@ -11,8 +11,8 @@ from rmink.extract.shade import MAX_SHADE_STROKES, shade
11
11
  from rmink.extract.tone import tone_map
12
12
  from rmink.model import Page, Stroke
13
13
  from rmink.options import ConvertOptions, Shading
14
- from rmink.render.preview import read_lines
15
- from rmink.writer.rm_v6 import write_page
14
+ from rmink.render.preview import read_lines, render_lines, render_rm
15
+ from rmink.writer.rm_v6 import page_lines, write_page
16
16
 
17
17
 
18
18
  def _ramp(w=600, h=200) -> np.ndarray:
@@ -106,3 +106,14 @@ def test_style_previews(tmp_path, examples):
106
106
  assert [p.style for p in previews] == list(Shading)
107
107
  assert all(p.image.size[0] == get_device("rm2").width for p in previews)
108
108
  assert api.style_previews(examples["svg"], ConvertOptions()) == []
109
+
110
+
111
+ def test_rendering_lines_matches_the_written_file(tmp_path):
112
+ # Style previews skip the .rm round trip; the picture must not change.
113
+ src = tmp_path / "ramp.png"
114
+ Image.fromarray(_ramp()).save(src)
115
+ device = get_device("rm2")
116
+ page = api.render([src], ConvertOptions(shading="pencil")).document.pages[0]
117
+ direct = np.asarray(render_lines(page_lines(page, device), device, page.height), float)
118
+ stored = render_rm(write_page(page, device, uuid.uuid4()), device, page.height)
119
+ assert np.abs(direct - np.asarray(stored, float)).mean() < 0.5
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes