From 6752802d3175b95da7b6bd32939fb7d127db7dd7 Mon Sep 17 00:00:00 2001 From: Kieran Klukas Date: Thu, 4 Jun 2026 22:38:00 -0400 Subject: [PATCH] feat: nice font --- fonts/EMSAllure.svg | 245 ++++++++++++++++++++++++++ fonts/EMSCasualHand.svg | 245 ++++++++++++++++++++++++++ fonts/EMSInvite.svg | 245 ++++++++++++++++++++++++++ grimoire.py | 370 +++++++++++++++++++++++++--------------- 4 files changed, 966 insertions(+), 139 deletions(-) create mode 100644 fonts/EMSAllure.svg create mode 100644 fonts/EMSCasualHand.svg create mode 100644 fonts/EMSInvite.svg diff --git a/fonts/EMSAllure.svg b/fonts/EMSAllure.svg new file mode 100644 index 0000000..8c24bee --- /dev/null +++ b/fonts/EMSAllure.svg @@ -0,0 +1,245 @@ + + + + + + +Font name: EMS Allure +License: SIL Open Font License http://scripts.sil.org/OFL +Created by: Sheldon B. Michaels +SVG font conversion by: Windell H. Oskay +A derivative of: Allura +Designer: Rob Leuschke, TypeSETit +Link: http://www.typesetit.com +Google font page: https://fonts.google.com/specimen/Allura + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/fonts/EMSCasualHand.svg b/fonts/EMSCasualHand.svg new file mode 100644 index 0000000..a10593c --- /dev/null +++ b/fonts/EMSCasualHand.svg @@ -0,0 +1,245 @@ + + + + + + +Font name: EMS Casual Hand +License: SIL Open Font License http://scripts.sil.org/OFL +Created by: Sheldon B. Michaels +SVG font conversion by: Windell H. Oskay +A derivative of: Covered By Your Grace +Designer: Kimberly Geswein, Kimberly Geswein Fonts +Link: http://www.kimberlygeswein.com/ +Google font page: https://fonts.google.com/specimen/Covered+By+Your+Grace + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/fonts/EMSInvite.svg b/fonts/EMSInvite.svg new file mode 100644 index 0000000..acd8a0f --- /dev/null +++ b/fonts/EMSInvite.svg @@ -0,0 +1,245 @@ + + + + + + +Font name: EMS Invite +License: SIL Open Font License http://scripts.sil.org/OFL +Created by: Sheldon B. Michaels +SVG font conversion by: Windell H. Oskay +A derivative of: Tangerine +Designer: Toshi Omagari +Link: http://tosche.net/about +Google font page: https://fonts.google.com/specimen/Tangerine + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/grimoire.py b/grimoire.py index 0325c16..82a1a22 100644 --- a/grimoire.py +++ b/grimoire.py @@ -1,30 +1,24 @@ #!/usr/bin/env python3 """ -Inject reply text as native ink into reMarkable .rm v6 pages. +Grimoire — inject handwriting-style text as native ink into reMarkable .rm v6 pages. -Reads an existing .rm v6 page and appends Hershey-rendered text strokes -directly into Layer 1 as SceneLineItemBlocks. Original blocks are never -re-serialized — replies are appended to the raw bytes, so data that -rmscene can't fully parse survives untouched. - -M0 proof for the Grimoire project. Depth 1 (file + reload). -See spec.md for the full architecture. - -v6 coordinate system: origin is top-center of page. - x = 0 -> center; negative -> left; positive -> right. - y = 0 -> top; positive -> down. +Reads an existing .rm v6 page, renders text using an SVG single-stroke font, +and appends SceneLineItemBlocks into Layer 1. Original blocks are untouched. Usage: - python grimoire.py input.rm output.rm # writes to output.rm - python grimoire.py input.rm # overwrites input - -Deploy to device: - scp replied.rm root@remarkable:/path/to/page.rm - ssh root@remarkable "sed -i 's/\"sizeInBytes\": \"[0-9]*\"/\"sizeInBytes\": \"3959\"/' /path/to/doc.content && systemctl reset-failed xochitl && systemctl restart xochitl" + python grimoire.py input.rm output.rm # default text + python grimoire.py input.rm output.rm "hello" # custom text + python grimoire.py --preview "Sample" # SVG preview """ +import argparse +import math +import random import sys +import xml.etree.ElementTree as ET from io import BytesIO +from pathlib import Path +from typing import Optional from rmscene import ( CrdtId, @@ -38,114 +32,184 @@ from rmscene.scene_stream import ( ) -# -- Hershey simplex font (subset) ---------------------------------------- -# Each glyph is a list of polylines; each polyline is [(x,y), ...]. -# Coordinates are in a 0..21 x 0..32 grid (width x height). -# Source: derived from Hershey Simplex Roman, normalized. - -HERSHEY: dict[str, list[list[tuple[float, float]]]] = { - "A": [[(0, 32), (10, 0), (21, 32)], [(4, 20), (17, 20)]], - "B": [[(0, 0), (0, 32), (14, 32), (19, 28), (19, 22), (14, 18), (0, 18)], - [(14, 18), (19, 14), (19, 6), (14, 0), (0, 0)]], - "C": [[(19, 28), (14, 32), (5, 32), (0, 28), (0, 4), (5, 0), (14, 0), (19, 4)]], - "D": [[(0, 0), (0, 32), (12, 32), (19, 26), (19, 6), (12, 0), (0, 0)]], - "E": [[(19, 0), (0, 0), (0, 32), (19, 32)], [(0, 16), (14, 16)]], - "F": [[(19, 0), (0, 0), (0, 32), (19, 32)], [(0, 16), (14, 16)]], - "G": [[(19, 28), (14, 32), (5, 32), (0, 28), (0, 4), (5, 0), (14, 0), (19, 4), (19, 16), (12, 16)]], - "H": [[(0, 0), (0, 32)], [(21, 0), (21, 32)], [(0, 16), (21, 16)]], - "I": [[(5, 0), (16, 0)], [(10, 0), (10, 32)], [(5, 32), (16, 32)]], - "K": [[(0, 0), (0, 32)], [(19, 32), (0, 14), (19, 0)]], - "L": [[(0, 0), (0, 32), (19, 32)]], - "M": [[(0, 32), (0, 0), (10, 16), (21, 0), (21, 32)]], - "N": [[(0, 32), (0, 0), (21, 32), (21, 0)]], - "O": [[(5, 0), (0, 4), (0, 28), (5, 32), (16, 32), (21, 28), (21, 4), (16, 0), (5, 0)]], - "P": [[(0, 32), (0, 0), (14, 0), (19, 4), (19, 14), (14, 18), (0, 18)]], - "R": [[(0, 32), (0, 0), (14, 0), (19, 4), (19, 14), (14, 18), (0, 18)], - [(14, 18), (19, 32)]], - "S": [[(19, 4), (14, 0), (5, 0), (0, 4), (0, 12), (5, 16), (14, 16), (19, 20), (19, 28), (14, 32), (5, 32), (0, 28)]], - "T": [[(0, 0), (21, 0)], [(10, 0), (10, 32)]], - "U": [[(0, 0), (0, 28), (5, 32), (16, 32), (21, 28), (21, 0)]], - "W": [[(0, 0), (4, 32), (10, 16), (16, 32), (21, 0)]], - "Y": [[(0, 0), (10, 16), (21, 0)], [(10, 16), (10, 32)]], - " ": [], - "!": [[(10, 8), (10, 32)], [(10, 0), (10, 4)]], - "?": [[(0, 4), (5, 0), (16, 0), (21, 4), (21, 10), (10, 18), (10, 22)], - [(10, 28), (10, 32)]], - ".": [[(10, 30), (10, 32)]], - ",": [[(8, 30), (10, 34)]], - "-": [[(0, 16), (21, 16)]], -} - -GLYPH_WIDTH = 21 -GLYPH_SPACING = 8 +# ───────────────────── Font loading ───────────────────── + +def parse_svg_font(path: str) -> tuple[dict, dict[str, float]]: + """Parse SVG single-stroke font. Returns (glyphs, advances).""" + tree = ET.parse(path) + root_e = tree.getroot() + ns = {'svg': 'http://www.w3.org/2000/svg'} + font_elem = root_e.find('.//svg:font', ns) or root_e.find('.//font') + if font_elem is None: + raise ValueError(f"No 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) -> list[list[tuple[float, float]]]: + """Parse M/L path into polylines. Bezier commands are skipped.""" + polylines = [] + current = [] + tokens = d.replace(',', ' ').split() + i = 0 + while i < len(tokens): + cmd = tokens[i] + if cmd in ('M', 'L'): + i += 1 + if i + 1 >= len(tokens): + break + x, y = float(tokens[i]), float(tokens[i+1]) + i += 2 + if cmd == 'M': + if len(current) >= 2: + polylines.append(current) + current = [(x, y)] + else: + current.append((x, y)) + elif cmd in ('C', 'Q'): + i += 1 + while i < len(tokens) and tokens[i] not in ('M', 'L', 'C', 'Q', 'Z', 'z'): + i += 1 + elif cmd in ('Z', 'z'): + if current and len(current) >= 2: + current.append(current[0]) + i += 1 + else: + i += 1 + if len(current) >= 2: + polylines.append(current) + return polylines + + +# ─── Global font cache +_FONT_CACHE: Optional[tuple[dict, dict[str, float]]] = None + + +def load_font(name: str = "EMSAllure") -> tuple[dict, dict[str, float]]: + global _FONT_CACHE + if _FONT_CACHE is not None: + return _FONT_CACHE + font_dir = Path(__file__).parent / "fonts" + svg_path = font_dir / f"{name}.svg" + if not svg_path.exists(): + raise FileNotFoundError(f"Font not found: {svg_path}") + _FONT_CACHE = parse_svg_font(str(svg_path)) + return _FONT_CACHE + + +# ───────────────────── Text → strokes ───────────────────── + +DEFAULT_SCALE = 0.065 +DEFAULT_X = -550.0 # 80% of -702..701 +DEFAULT_Y = 750.0 +LINE_HEIGHT = 1.4 # multiplier on EM ascent (~800 units) +MAX_LINE_WIDTH = 1100 # 80% of 1404 = ~1120 def text_to_strokes( text: str, origin_x: float, origin_y: float, - scale: float = 1.0, + scale: float = DEFAULT_SCALE, + max_width: float = MAX_LINE_WIDTH, + line_spacing: float = LINE_HEIGHT, ) -> list[si.Line]: - """Render text as a list of Line scene items using Hershey glyphs.""" - lines: list[si.Line] = [] + """Render text as reMarkable Line objects with word wrapping.""" + glyphs, advances = load_font() + result = [] cursor_x = origin_x + cursor_y = origin_y + rng = random.Random(text + str(origin_x)) - for ch in text.upper(): - glyph = HERSHEY.get(ch) - if glyph is None: - cursor_x += (GLYPH_WIDTH + GLYPH_SPACING) * scale - continue + space_adv = advances.get(' ', 500) * scale - for polyline in glyph: - if len(polyline) < 2: + for word in text.split(): + # measure word width + word_width = sum(advances.get(ch, 500) for ch in word) * scale + + # wrap if needed + 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 - points = [] - for j, (gx, gy) in enumerate(polyline): - px = cursor_x + gx * scale - py = origin_y + gy * scale - points.append(si.Point( - x=px, y=py, - speed=5 + (j % 10), - direction=90 + (j * 7) % 60, - width=9, - pressure=150 + (j % 30), + + x_jitter = rng.uniform(-2, 2) + y_jitter = rng.uniform(-1.5, 0.5) + slant = rng.uniform(-0.015, 0.015) + + for polyline in glyph: + if len(polyline) < 2: + continue + points = [] + for j, (gx, gy) in enumerate(polyline): + sx = gx * scale + sy = -gy * scale # SVG Y-up → rm Y-down + sx += sy * slant + wobble = math.sin(cursor_x * 0.01 + j * 0.3) * 0.8 + 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, )) - lines.append(si.Line( - color=si.PenColor.BLACK, - tool=si.Pen.BALLPOINT_2, - points=points, - thickness_scale=2.0, - starting_length=0.0, - )) - cursor_x += (GLYPH_WIDTH + GLYPH_SPACING) * scale + cursor_x += adv + max(-2, x_jitter * 0.1) + abs(rng.uniform(-1, 1)) - return lines + # space between words + cursor_x += space_adv + rng.uniform(-2, 2) + return result -def make_reply_blocks( - text: str, - origin_x: float, - origin_y: float, - scale: float = 1.1, - author_id: int = 1, -) -> list: - """ - Build SceneLineItemBlocks for reply text, anchored to existing Layer 1. - - Layer 1 is always node_id CrdtId(0, 11) in valid .rm v6 files. - No SceneTreeBlock or TreeNodeBlock needed — we inject into the - existing scene tree. - """ + +# ───────────────────── .rm injection ───────────────────── + +def splice_reply( + input_path: str, + output_path: str, + reply_text: str, +) -> None: + """Read .rm v6 page, append reply strokes, write to output.""" LAYER_1 = CrdtId(0, 11) - blocks = [] + with open(input_path, "rb") as f: + original_data = f.read() - strokes = text_to_strokes(text, origin_x, origin_y, scale) + strokes = text_to_strokes(reply_text, DEFAULT_X, DEFAULT_Y) + blocks = [] for i, line in enumerate(strokes): blocks.append(SceneLineItemBlock( parent_id=LAYER_1, item=CrdtSequenceItem( - item_id=CrdtId(author_id, 200 + i), + item_id=CrdtId(1, 200 + i), left_id=CrdtId(0, 0), right_id=CrdtId(0, 0), deleted_length=0, @@ -153,52 +217,80 @@ def make_reply_blocks( ), )) - return blocks - + buf = BytesIO() + write_blocks(buf, blocks, options={"version": "3.4"}) + reply_bytes = buf.getvalue() -def splice_reply(input_path: str, output_path: str, reply_text: str) -> None: - """ - Read an .rm v6 page, append reply strokes, write to output. - - Original blocks are left untouched. Reply blocks are serialized - independently and appended to the raw bytes. - """ - with open(input_path, "rb") as f: - original_data = f.read() - - reply_blocks = make_reply_blocks( - reply_text, - origin_x=-300.0, # v6 origin is top-center; negative = left half - origin_y=700.0, - scale=1.1, - ) - - # Serialize only the reply blocks - reply_buf = BytesIO() - write_blocks(reply_buf, reply_blocks, options={"version": "3.4"}) - reply_bytes = reply_buf.getvalue() - - # Strip the redundant 43-byte header that write_blocks always emits 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(reply_blocks)} blocks, " + print(f"Wrote {output_path} ({len(strokes)} strokes, " f"{len(original_data)}+{len(reply_bytes)}={len(output_data)} bytes)") -if __name__ == "__main__": - if len(sys.argv) < 2: - print(__doc__) - sys.exit(1) +# ───────────────────── SVG preview ───────────────────── + +def preview_svg(text: str, out_path: str = "preview.svg") -> None: + strokes = text_to_strokes(text, DEFAULT_X, DEFAULT_Y) + all_x = [p.x for line in strokes for p in line.points] + all_y = [p.y for line in strokes for p in line.points] + if not all_x: + return + min_x, max_x = min(all_x), max(all_x) + min_y, max_y = min(all_y), max(all_y) + pad = 20 + w, h = max_x - min_x + 2*pad, max_y - min_y + 2*pad + + svg = [ + f'', + '', + f'', + ] + for line in strokes: + pts = line.points + d = f'M{pts[0].x:.1f},{pts[0].y:.1f}' + for p in pts[1:]: + d += f'L{p.x:.1f},{p.y:.1f}' + svg.append( + f'' + ) + svg.append('') + + with open(out_path, "w") as f: + f.write('\n'.join(svg)) + print(f"Preview {out_path} {len(strokes)} strokes") + + +# ───────────────────── CLI ───────────────────── + +def main(): + parser = argparse.ArgumentParser(description="Grimoire — inject text as ink into .rm v6 pages") + parser.add_argument("input", nargs="?", help="Input .rm file") + parser.add_argument("output", nargs="?", help="Output .rm file (default: overwrite input)") + parser.add_argument("text", nargs="?", default="Grimoire says hello", + help="Text to render") + parser.add_argument("--preview", nargs="?", const="preview.svg", + help="Render SVG preview instead") + args = parser.parse_args() + + if args.preview: + out = args.preview if isinstance(args.preview, str) else "preview.svg" + preview_svg(args.text, out) + return + + if not args.input: + parser.print_help() + return + + splice_reply(args.input, args.output or args.input, args.text) - input_path = sys.argv[1] - output_path = sys.argv[2] if len(sys.argv) > 2 else input_path - reply = "GRIMOIRE SAYS HELLO" - splice_reply(input_path, output_path, reply) +if __name__ == "__main__": + main() -- 2.51.2