Something went wrong. Try again.
sppoky
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396#!/usr/bin/env python3"""glossa.py — Renders reply text as native ink strokes on reMarkable pages.
Two modes: 1. File injection: append a new layer with reply strokes to a .rm page 2. JSON export: write strokes as JSON for the xovi uinject pipeline
Uses centerline fonts extracted from TrueType handwriting fonts (seeextract_centerline_font.py). The default font is CaveatCenterline,produced by skeletonizing Google's Caveat font.
Usage: # File-based injection (creates new layer in .rm file) python glossa.py input.rm output.rm "reply text"
# JSON export for xovi uinject python glossa.py --json /tmp/glossa_strokes.json "reply text"
# Preview (renders SVG for visual tuning) python glossa.py --json /tmp/out.json --y 400 "reply text"
# Use a different font python glossa.py --json out.json --font EMSAllure "text""""
import argparseimport mathimport randomimport xml.etree.ElementTree as ETfrom io import BytesIOfrom pathlib import Path
from rmscene import CrdtId, CrdtSequenceItem, LwwValue, read_blocksfrom rmscene import scene_items as sifrom rmscene import write_blocksfrom rmscene.scene_stream import (SceneGroupItemBlock, SceneLineItemBlock, SceneTreeBlock, TreeNodeBlock)from svgpathtools import Line as SvgLinefrom svgpathtools import parse_path
def parse_svg_font(path: str) -> tuple[dict, dict[str, float]]: tree = ET.parse(path) root_e = tree.getroot() ns = {"svg": "http://www.w3.org/2000/svg"} font_elem = root_e.find(".//svg:font", ns) if font_elem is None: font_elem = root_e.find(".//font") if font_elem is None: raise ValueError(f"Missing <font> in {path}") default_adv = float(font_elem.attrib.get("horiz-adv-x", 500)) glyphs = {} advances = {" ": default_adv} for g in font_elem.findall("svg:glyph", ns) or font_elem.findall("glyph"): uni = g.attrib.get("unicode") if not uni: continue advances[uni] = float(g.attrib.get("horiz-adv-x", default_adv)) d = g.attrib.get("d", "") if d: glyphs[uni] = _parse_d(d) else: glyphs[uni] = [] return glyphs, advances
def _parse_d(d: str, samples_per_curve: int = 8) -> list[list[tuple[float, float]]]: """Parse glyph path data into one polyline per continuous subpath.
Each continuous run (between Move commands) becomes its own polyline = its own .rm stroke. Curves are sampled into line segments. """ try: path = parse_path(d) except Exception: return [] polylines: list[list[tuple[float, float]]] = [] for sub in path.continuous_subpaths(): pts: list[tuple[float, float]] = [] for seg in sub: n = 1 if isinstance(seg, SvgLine) else samples_per_curve for k in range(n + 1): z = seg.point(k / n) pt = (z.real, z.imag) if not pts or pt != pts[-1]: pts.append(pt) if len(pts) >= 2: polylines.append(pts) return polylines
def _densify( pts: list[tuple[float, float]], min_seg_len: float = 120.0) -> list[tuple[float, float]]: """Subdivide a polyline so no segment is longer than min_seg_len.
Short subpaths render unreliably on device. This inserts evenly spaced midpoints so every stroke has enough samples. """ if len(pts) < 2: return pts out = [pts[0]] for (x0, y0), (x1, y1) in zip(pts, pts[1:]): dist = math.hypot(x1 - x0, y1 - y0) steps = max(2, int(math.ceil(dist / min_seg_len))) for s in range(1, steps + 1): t = s / steps out.append((x0 + (x1 - x0) * t, y0 + (y1 - y0) * t)) return out
_FONT_CACHE: dict = {}DEFAULT_SCALE = 0.06DEFAULT_X = -550.0DEFAULT_Y = 200.0LINE_HEIGHT = 1.4MAX_LINE_WIDTH = 1100
def load_font(name: str = "CaveatCenterline") -> tuple[dict, dict[str, float]]: if name not in _FONT_CACHE: font_dir = Path(__file__).parent / "fonts" svg_path = font_dir / f"{name}.svg" _FONT_CACHE[name] = parse_svg_font(str(svg_path)) return _FONT_CACHE[name]
def text_to_strokes( text: str, origin_x: float, origin_y: float, scale: float = DEFAULT_SCALE, max_width: float = MAX_LINE_WIDTH, line_spacing: float = LINE_HEIGHT, font: str = "CaveatCenterline",) -> list[si.Line]: """Convert text to reMarkable stroke data.
For centerline fonts (extracted via extract_centerline_font.py), wobble and slant are disabled to preserve the clean skeleton paths. For legacy SVG fonts (EMSAllure, etc.), the original jitter/wobble transforms are applied for a more organic look. """ glyphs, advances = load_font(font) is_centerline = "Centerline" in font or "centerline" in font
# Auto-scale centerline fonts to match EMS Allure visual size if is_centerline: ems_avg = 578 this_avg = sum(advances.values()) / max(len(advances), 1) if this_avg > 0: scale = scale * (ems_avg / this_avg)
result = [] cursor_x = origin_x cursor_y = origin_y rng = random.Random(text + str(origin_x)) space_adv = advances.get(" ", 500) * scale
# Centerline fonts: minimal jitter, no wobble/slant (skeleton is already clean) # Legacy fonts: full organic transforms if is_centerline: x_jit_range, y_jit_range = 1.0, 0.5 slant_range, wobble_amp, densify_len = 0.0, 0.0, 60.0 else: x_jit_range, y_jit_range = 2.0, 1.5 slant_range, wobble_amp, densify_len = 0.015, 0.8, 300.0
for word in text.split(): word_width = sum(advances.get(ch, 500) for ch in word) * scale if cursor_x + word_width > origin_x + max_width and cursor_x > origin_x: cursor_x = origin_x cursor_y += 800 * scale * line_spacing
for ch in word: glyph = glyphs.get(ch) adv = advances.get(ch, 500) * scale if glyph is None: cursor_x += adv continue
x_jitter = rng.uniform(-x_jit_range, x_jit_range) y_jitter = rng.uniform(-y_jit_range, y_jit_range) slant = rng.uniform(-slant_range, slant_range) if not is_centerline else 0.0
for polyline in glyph: if len(polyline) < 2: continue dense = _densify(polyline, min_seg_len=densify_len) points = [] for j, (gx, gy) in enumerate(dense): sx = gx * scale sy = -gy * scale if slant != 0: sx += sy * slant wobble = math.sin(cursor_x * 0.01 + j * 0.3) * wobble_amp px = cursor_x + sx + x_jitter py = cursor_y + sy + y_jitter + wobble points.append( si.Point( x=px, y=py, speed=4 + rng.randint(0, 8), direction=80 + rng.randint(0, 30), width=10 + rng.randint(0, 3), pressure=160 + rng.randint(0, 30), ) ) result.append( si.Line( color=si.PenColor.BLACK, tool=si.Pen.BALLPOINT_2, points=points, thickness_scale=2.0, starting_length=0.0, ) )
cursor_x += adv + max(-2, x_jitter * 0.1) + abs(rng.uniform(-1, 1)) cursor_x += space_adv + rng.uniform(-2, 2)
return result
def _find_content_bottom(data: bytes) -> float: """Scan existing strokes and return the max Y coordinate + margin.""" max_y = 0.0 found = False try: with BytesIO(data) as f: for block in read_blocks(f): if isinstance(block, SceneLineItemBlock): line = block.item.value if hasattr(line, "points"): for pt in line.points: if pt.y > max_y: max_y = pt.y found = True except Exception: pass if not found: return DEFAULT_Y return max_y + 120.0
def _find_last_root_child(data: bytes) -> CrdtId: """Find the last SceneGroupItemBlock value under root for left_id chaining.""" ROOT = CrdtId(0, 1) last_value = CrdtId(0, 0) try: with BytesIO(data) as f: for block in read_blocks(f): if isinstance(block, SceneGroupItemBlock) and block.parent_id == ROOT: last_value = block.item.value except Exception: pass return last_value
def splice_reply_new_layer( input_path: str, output_path: str, reply_text: str,) -> None: """Read .rm v6 page, APPEND a new layer with reply strokes.
Original blocks are untouched. Matches real xochitl layer structure. """ with open(input_path, "rb") as f: original_data = f.read()
reply_y = _find_content_bottom(original_data) strokes = text_to_strokes(reply_text, DEFAULT_X, reply_y)
LAYER_TREE = CrdtId(2, 500) ROOT = CrdtId(0, 1) last_root_child = _find_last_root_child(original_data)
content_tree = SceneTreeBlock( tree_id=LAYER_TREE, node_id=CrdtId(0, 0), is_update=True, parent_id=ROOT, )
label_block = TreeNodeBlock( si.Group( node_id=LAYER_TREE, label=LwwValue(CrdtId(2, 501), "glossa"), visible=LwwValue(CrdtId(2, 502), True), ) )
group_item_block = SceneGroupItemBlock( parent_id=ROOT, item=CrdtSequenceItem( item_id=CrdtId(2, 503), left_id=last_root_child, right_id=CrdtId(0, 0), deleted_length=0, value=LAYER_TREE, ), )
reply_blocks = [content_tree, label_block, group_item_block]
for i, line in enumerate(strokes): reply_blocks.append( SceneLineItemBlock( parent_id=LAYER_TREE, item=CrdtSequenceItem( item_id=CrdtId(2, 600 + i), left_id=CrdtId(0, 0) if i == 0 else CrdtId(2, 600 + i - 1), right_id=CrdtId(0, 0), deleted_length=0, value=line, ), ) )
buf = BytesIO() write_blocks(buf, reply_blocks, options={"version": "3.4"}) reply_bytes = buf.getvalue()
HEADER_SIZE = 43 if reply_bytes[:HEADER_SIZE] == original_data[:HEADER_SIZE]: reply_bytes = reply_bytes[HEADER_SIZE:]
output_data = original_data + reply_bytes
with open(output_path, "wb") as f: f.write(output_data)
print( f"Wrote {output_path} ({len(strokes)} reply strokes, " f"{len(original_data)}+{len(reply_bytes)}={len(output_data)} bytes)" )
def strokes_to_json(strokes: list[si.Line], output_path: str) -> None: """Export strokes as JSON for the xovi uinject pipeline.""" import json
items = [] for line in strokes: points = [] for pt in line.points: points.append([pt.x, pt.y, pt.speed, pt.width, pt.direction, pt.pressure]) xs = [p[0] for p in points] ys = [p[1] for p in points] bounds = [min(xs), min(ys), max(xs) - min(xs), max(ys) - min(ys)] items.append( { "points": points, "rgba": 4278190080, "color": line.color.value if hasattr(line.color, "value") else 0, "bounds": bounds, "tool": line.tool.value if hasattr(line.tool, "value") else 15, "maskScale": line.thickness_scale, "thickness": line.thickness_scale, } ) with open(output_path, "w") as f: json.dump(items, f) print(f"Wrote {output_path} ({len(items)} strokes)")
if __name__ == "__main__": parser = argparse.ArgumentParser(description="Render text as reMarkable ink strokes") parser.add_argument("input", nargs="?", default=None, help="Input .rm file (for file injection mode)") parser.add_argument("output", nargs="?", default=None, help="Output .rm file (for file injection mode)") parser.add_argument("text", nargs="?", default=None, help="Text to render") parser.add_argument("--json", dest="json_output", default=None, help="Export strokes as JSON for uinject") parser.add_argument("--y", type=float, default=None, help="Y coordinate to start rendering at") parser.add_argument("--font", type=str, default="CaveatCenterline", help="SVG font name (without .svg) in fonts/ directory") args = parser.parse_args()
if args.json_output: text = args.input or "Glossa says hello" y = args.y if args.y is not None else DEFAULT_Y strokes = text_to_strokes(text, DEFAULT_X, y, font=args.font) strokes_to_json(strokes, args.json_output) elif args.input: text = args.text or "Glossa says hello" output = args.output or args.input splice_reply_new_layer(args.input, output, text) else: parser.error("Provide input.rm or --json output.json")