sppoky
0

Configure Feed

Select the types of activity you want to include in your feed.

glossa / glossa.py
14 kB 395 lines
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")