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 @@
+
+
+
+
\ 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 @@
+
+
+
+
\ 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 @@
+
+
+
+
\ 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'')
+
+ 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()