sppoky
1#!/usr/bin/env python3
2"""
3glossa.py — Renders reply text as native ink strokes on reMarkable pages.
4
5Two modes:
6 1. File injection: append a new layer with reply strokes to a .rm page
7 2. JSON export: write strokes as JSON for the xovi uinject pipeline
8
9Uses centerline fonts extracted from TrueType handwriting fonts (see
10extract_centerline_font.py). The default font is CaveatCenterline,
11produced by skeletonizing Google's Caveat font.
12
13Usage:
14 # File-based injection (creates new layer in .rm file)
15 python glossa.py input.rm output.rm "reply text"
16
17 # JSON export for xovi uinject
18 python glossa.py --json /tmp/glossa_strokes.json "reply text"
19
20 # Preview (renders SVG for visual tuning)
21 python glossa.py --json /tmp/out.json --y 400 "reply text"
22
23 # Use a different font
24 python glossa.py --json out.json --font EMSAllure "text"
25"""
26
27import argparse
28import math
29import random
30import xml.etree.ElementTree as ET
31from io import BytesIO
32from pathlib import Path
33
34from rmscene import CrdtId, CrdtSequenceItem, LwwValue, read_blocks
35from rmscene import scene_items as si
36from rmscene import write_blocks
37from rmscene.scene_stream import (SceneGroupItemBlock, SceneLineItemBlock,
38 SceneTreeBlock, TreeNodeBlock)
39from svgpathtools import Line as SvgLine
40from svgpathtools import parse_path
41
42
43def parse_svg_font(path: str) -> tuple[dict, dict[str, float]]:
44 tree = ET.parse(path)
45 root_e = tree.getroot()
46 ns = {"svg": "http://www.w3.org/2000/svg"}
47 font_elem = root_e.find(".//svg:font", ns)
48 if font_elem is None:
49 font_elem = root_e.find(".//font")
50 if font_elem is None:
51 raise ValueError(f"Missing <font> in {path}")
52 default_adv = float(font_elem.attrib.get("horiz-adv-x", 500))
53 glyphs = {}
54 advances = {" ": default_adv}
55 for g in font_elem.findall("svg:glyph", ns) or font_elem.findall("glyph"):
56 uni = g.attrib.get("unicode")
57 if not uni:
58 continue
59 advances[uni] = float(g.attrib.get("horiz-adv-x", default_adv))
60 d = g.attrib.get("d", "")
61 if d:
62 glyphs[uni] = _parse_d(d)
63 else:
64 glyphs[uni] = []
65 return glyphs, advances
66
67
68def _parse_d(d: str, samples_per_curve: int = 8) -> list[list[tuple[float, float]]]:
69 """Parse glyph path data into one polyline per continuous subpath.
70
71 Each continuous run (between Move commands) becomes its own polyline =
72 its own .rm stroke. Curves are sampled into line segments.
73 """
74 try:
75 path = parse_path(d)
76 except Exception:
77 return []
78 polylines: list[list[tuple[float, float]]] = []
79 for sub in path.continuous_subpaths():
80 pts: list[tuple[float, float]] = []
81 for seg in sub:
82 n = 1 if isinstance(seg, SvgLine) else samples_per_curve
83 for k in range(n + 1):
84 z = seg.point(k / n)
85 pt = (z.real, z.imag)
86 if not pts or pt != pts[-1]:
87 pts.append(pt)
88 if len(pts) >= 2:
89 polylines.append(pts)
90 return polylines
91
92
93def _densify(
94 pts: list[tuple[float, float]], min_seg_len: float = 120.0
95) -> list[tuple[float, float]]:
96 """Subdivide a polyline so no segment is longer than min_seg_len.
97
98 Short subpaths render unreliably on device. This inserts evenly
99 spaced midpoints so every stroke has enough samples.
100 """
101 if len(pts) < 2:
102 return pts
103 out = [pts[0]]
104 for (x0, y0), (x1, y1) in zip(pts, pts[1:]):
105 dist = math.hypot(x1 - x0, y1 - y0)
106 steps = max(2, int(math.ceil(dist / min_seg_len)))
107 for s in range(1, steps + 1):
108 t = s / steps
109 out.append((x0 + (x1 - x0) * t, y0 + (y1 - y0) * t))
110 return out
111
112
113_FONT_CACHE: dict = {}
114DEFAULT_SCALE = 0.06
115DEFAULT_X = -550.0
116DEFAULT_Y = 200.0
117LINE_HEIGHT = 1.4
118MAX_LINE_WIDTH = 1100
119
120
121def load_font(name: str = "CaveatCenterline") -> tuple[dict, dict[str, float]]:
122 if name not in _FONT_CACHE:
123 font_dir = Path(__file__).parent / "fonts"
124 svg_path = font_dir / f"{name}.svg"
125 _FONT_CACHE[name] = parse_svg_font(str(svg_path))
126 return _FONT_CACHE[name]
127
128
129def text_to_strokes(
130 text: str,
131 origin_x: float,
132 origin_y: float,
133 scale: float = DEFAULT_SCALE,
134 max_width: float = MAX_LINE_WIDTH,
135 line_spacing: float = LINE_HEIGHT,
136 font: str = "CaveatCenterline",
137) -> list[si.Line]:
138 """Convert text to reMarkable stroke data.
139
140 For centerline fonts (extracted via extract_centerline_font.py),
141 wobble and slant are disabled to preserve the clean skeleton paths.
142 For legacy SVG fonts (EMSAllure, etc.), the original jitter/wobble
143 transforms are applied for a more organic look.
144 """
145 glyphs, advances = load_font(font)
146 is_centerline = "Centerline" in font or "centerline" in font
147
148 # Auto-scale centerline fonts to match EMS Allure visual size
149 if is_centerline:
150 ems_avg = 578
151 this_avg = sum(advances.values()) / max(len(advances), 1)
152 if this_avg > 0:
153 scale = scale * (ems_avg / this_avg)
154
155 result = []
156 cursor_x = origin_x
157 cursor_y = origin_y
158 rng = random.Random(text + str(origin_x))
159 space_adv = advances.get(" ", 500) * scale
160
161 # Centerline fonts: minimal jitter, no wobble/slant (skeleton is already clean)
162 # Legacy fonts: full organic transforms
163 if is_centerline:
164 x_jit_range, y_jit_range = 1.0, 0.5
165 slant_range, wobble_amp, densify_len = 0.0, 0.0, 60.0
166 else:
167 x_jit_range, y_jit_range = 2.0, 1.5
168 slant_range, wobble_amp, densify_len = 0.015, 0.8, 300.0
169
170 for word in text.split():
171 word_width = sum(advances.get(ch, 500) for ch in word) * scale
172 if cursor_x + word_width > origin_x + max_width and cursor_x > origin_x:
173 cursor_x = origin_x
174 cursor_y += 800 * scale * line_spacing
175
176 for ch in word:
177 glyph = glyphs.get(ch)
178 adv = advances.get(ch, 500) * scale
179 if glyph is None:
180 cursor_x += adv
181 continue
182
183 x_jitter = rng.uniform(-x_jit_range, x_jit_range)
184 y_jitter = rng.uniform(-y_jit_range, y_jit_range)
185 slant = rng.uniform(-slant_range, slant_range) if not is_centerline else 0.0
186
187 for polyline in glyph:
188 if len(polyline) < 2:
189 continue
190 dense = _densify(polyline, min_seg_len=densify_len)
191 points = []
192 for j, (gx, gy) in enumerate(dense):
193 sx = gx * scale
194 sy = -gy * scale
195 if slant != 0:
196 sx += sy * slant
197 wobble = math.sin(cursor_x * 0.01 + j * 0.3) * wobble_amp
198 px = cursor_x + sx + x_jitter
199 py = cursor_y + sy + y_jitter + wobble
200 points.append(
201 si.Point(
202 x=px,
203 y=py,
204 speed=4 + rng.randint(0, 8),
205 direction=80 + rng.randint(0, 30),
206 width=10 + rng.randint(0, 3),
207 pressure=160 + rng.randint(0, 30),
208 )
209 )
210 result.append(
211 si.Line(
212 color=si.PenColor.BLACK,
213 tool=si.Pen.BALLPOINT_2,
214 points=points,
215 thickness_scale=2.0,
216 starting_length=0.0,
217 )
218 )
219
220 cursor_x += adv + max(-2, x_jitter * 0.1) + abs(rng.uniform(-1, 1))
221 cursor_x += space_adv + rng.uniform(-2, 2)
222
223 return result
224
225
226def _find_content_bottom(data: bytes) -> float:
227 """Scan existing strokes and return the max Y coordinate + margin."""
228 max_y = 0.0
229 found = False
230 try:
231 with BytesIO(data) as f:
232 for block in read_blocks(f):
233 if isinstance(block, SceneLineItemBlock):
234 line = block.item.value
235 if hasattr(line, "points"):
236 for pt in line.points:
237 if pt.y > max_y:
238 max_y = pt.y
239 found = True
240 except Exception:
241 pass
242 if not found:
243 return DEFAULT_Y
244 return max_y + 120.0
245
246
247def _find_last_root_child(data: bytes) -> CrdtId:
248 """Find the last SceneGroupItemBlock value under root for left_id chaining."""
249 ROOT = CrdtId(0, 1)
250 last_value = CrdtId(0, 0)
251 try:
252 with BytesIO(data) as f:
253 for block in read_blocks(f):
254 if isinstance(block, SceneGroupItemBlock) and block.parent_id == ROOT:
255 last_value = block.item.value
256 except Exception:
257 pass
258 return last_value
259
260
261def splice_reply_new_layer(
262 input_path: str,
263 output_path: str,
264 reply_text: str,
265) -> None:
266 """Read .rm v6 page, APPEND a new layer with reply strokes.
267
268 Original blocks are untouched. Matches real xochitl layer structure.
269 """
270 with open(input_path, "rb") as f:
271 original_data = f.read()
272
273 reply_y = _find_content_bottom(original_data)
274 strokes = text_to_strokes(reply_text, DEFAULT_X, reply_y)
275
276 LAYER_TREE = CrdtId(2, 500)
277 ROOT = CrdtId(0, 1)
278 last_root_child = _find_last_root_child(original_data)
279
280 content_tree = SceneTreeBlock(
281 tree_id=LAYER_TREE,
282 node_id=CrdtId(0, 0),
283 is_update=True,
284 parent_id=ROOT,
285 )
286
287 label_block = TreeNodeBlock(
288 si.Group(
289 node_id=LAYER_TREE,
290 label=LwwValue(CrdtId(2, 501), "glossa"),
291 visible=LwwValue(CrdtId(2, 502), True),
292 )
293 )
294
295 group_item_block = SceneGroupItemBlock(
296 parent_id=ROOT,
297 item=CrdtSequenceItem(
298 item_id=CrdtId(2, 503),
299 left_id=last_root_child,
300 right_id=CrdtId(0, 0),
301 deleted_length=0,
302 value=LAYER_TREE,
303 ),
304 )
305
306 reply_blocks = [content_tree, label_block, group_item_block]
307
308 for i, line in enumerate(strokes):
309 reply_blocks.append(
310 SceneLineItemBlock(
311 parent_id=LAYER_TREE,
312 item=CrdtSequenceItem(
313 item_id=CrdtId(2, 600 + i),
314 left_id=CrdtId(0, 0) if i == 0 else CrdtId(2, 600 + i - 1),
315 right_id=CrdtId(0, 0),
316 deleted_length=0,
317 value=line,
318 ),
319 )
320 )
321
322 buf = BytesIO()
323 write_blocks(buf, reply_blocks, options={"version": "3.4"})
324 reply_bytes = buf.getvalue()
325
326 HEADER_SIZE = 43
327 if reply_bytes[:HEADER_SIZE] == original_data[:HEADER_SIZE]:
328 reply_bytes = reply_bytes[HEADER_SIZE:]
329
330 output_data = original_data + reply_bytes
331
332 with open(output_path, "wb") as f:
333 f.write(output_data)
334
335 print(
336 f"Wrote {output_path} ({len(strokes)} reply strokes, "
337 f"{len(original_data)}+{len(reply_bytes)}={len(output_data)} bytes)"
338 )
339
340
341def strokes_to_json(strokes: list[si.Line], output_path: str) -> None:
342 """Export strokes as JSON for the xovi uinject pipeline."""
343 import json
344
345 items = []
346 for line in strokes:
347 points = []
348 for pt in line.points:
349 points.append([pt.x, pt.y, pt.speed, pt.width, pt.direction, pt.pressure])
350 xs = [p[0] for p in points]
351 ys = [p[1] for p in points]
352 bounds = [min(xs), min(ys), max(xs) - min(xs), max(ys) - min(ys)]
353 items.append(
354 {
355 "points": points,
356 "rgba": 4278190080,
357 "color": line.color.value if hasattr(line.color, "value") else 0,
358 "bounds": bounds,
359 "tool": line.tool.value if hasattr(line.tool, "value") else 15,
360 "maskScale": line.thickness_scale,
361 "thickness": line.thickness_scale,
362 }
363 )
364 with open(output_path, "w") as f:
365 json.dump(items, f)
366 print(f"Wrote {output_path} ({len(items)} strokes)")
367
368
369if __name__ == "__main__":
370 parser = argparse.ArgumentParser(description="Render text as reMarkable ink strokes")
371 parser.add_argument("input", nargs="?", default=None,
372 help="Input .rm file (for file injection mode)")
373 parser.add_argument("output", nargs="?", default=None,
374 help="Output .rm file (for file injection mode)")
375 parser.add_argument("text", nargs="?", default=None,
376 help="Text to render")
377 parser.add_argument("--json", dest="json_output", default=None,
378 help="Export strokes as JSON for uinject")
379 parser.add_argument("--y", type=float, default=None,
380 help="Y coordinate to start rendering at")
381 parser.add_argument("--font", type=str, default="CaveatCenterline",
382 help="SVG font name (without .svg) in fonts/ directory")
383 args = parser.parse_args()
384
385 if args.json_output:
386 text = args.input or "Glossa says hello"
387 y = args.y if args.y is not None else DEFAULT_Y
388 strokes = text_to_strokes(text, DEFAULT_X, y, font=args.font)
389 strokes_to_json(strokes, args.json_output)
390 elif args.input:
391 text = args.text or "Glossa says hello"
392 output = args.output or args.input
393 splice_reply_new_layer(args.input, output, text)
394 else:
395 parser.error("Provide input.rm or --json output.json")