Glossa — Project context#
What follows is hard-won knowledge from building the glossa closed loop on reMarkable 2. Do not unlearn any of this.
Coordinates#
- Origin is top-center, not top-left. This is the single most important fact about the v6 format and it cost us an hour.
x = 0is the horizontal center of the page; negative values go left, positive go right. Range is roughly -702 to 701 on a standard page.y = 0is the top of the page, increasing downward. Range is 0 to 1871.
Stroke parameters (matching real ink)#
- Tool:
Pen.BALLPOINT_2(value 15) - Color:
PenColor.BLACK(value 0) thickness_scale: 2.0,starting_length: 0.0- Point
width: 10–13, Pointpressure: 160–190 - Point
speed: 4–12, Pointdirection: 80–110 - Natural variation: y_jitter (-1.5 to 0.5), slant (-0.015 to 0.015), wobble amplitude 0.8
Font rendering#
- Primary font: CaveatCenterline — extracted from Google's Caveat TTF
via centerline skeletonization (see
extract_centerline_font.py). - Pipeline: freetype-py renders each glyph at 600px → scikit-image
skeletonize()→ graph walk extracts ordered polylines → RDP simplification (tol=0.5) → SVG font file. - Short skeleton fragments below 25px are filtered as noise. Dot-like blobs (small bbox, roughly square) are replaced with clean circles for i, j, !, ?, and period dots.
trim_backtrack()removes wrong-branch detours where the skeleton walker takes a dead-end at a junction then reverses direction.- Centerline fonts get no wobble/slant/jitter in
text_to_strokes(the skeleton paths are already clean). Legacy EMS fonts keep the original organic transforms. - Auto-scaling: centerline fonts are scaled to match EMS Allure visual size based on average advance width ratio.
- Legacy font files:
fonts/EMSAllure.svg,EMSCasualHand.svg,EMSInvite.svg(still work via--font EMSAllure). - Render params:
DEFAULT_SCALE = 0.06,DEFAULT_X = -550.0,DEFAULT_Y = 200.0,LINE_HEIGHT = 1.4,MAX_LINE_WIDTH = 1100. - Word wrapping implemented. Preview:
python extract_centerline_font.py --font /path/to/caveat.ttf --preview "text". - To regenerate the font:
python3 extract_centerline_font.py --font /tmp/Caveat.ttf(outputs tofonts/CaveatCenterline.svg).
File format injection#
- Never re-serialize original blocks.
read_blocks()→write_blocks()loses data. Serialize only new blocks, strip 43-byte header, append to raw original bytes. - Reuse author 1 (always present). CRDT sequence IDs starting at 200+.
Layer-based injection (working)#
Real reMarkable layers require specific block structure:
SceneTreeBlock(tree_id=LAYER_ID, node_id=CrdtId(0,0), is_update=True, parent=ROOT)— content tree rootTreeNodeBlock(node_id=LAYER_ID, label="name", visible=True)— metadataSceneGroupItemBlock(parent=ROOT, value=LAYER_ID, left_id=LAST_CHILD)— links into sibling listSceneLineItemBlock(parent_id=LAYER_ID, ...)— strokes parented to tree_id
Key: left_id in SceneGroupItemBlock must chain to the previous root child
for correct ordering. Use _find_last_root_child() to scan existing blocks.
Device management#
- SSH:
root@10.11.99.1(config aliasremarkable) .contentfilesizeInBytesmust match.rmfile size or xochitl crashes.systemctl reset-failed xochitl && systemctl restart xochitlto avoid rate limiting.- Busybox limitations:
head -n Nsyntax, nobase64, limitedfind. - Page navigation reload: writing a modified
.rmto disk + fixingsizeInBytesis picked up when user navigates away and back. No restart needed. ~2s vs ~12s for full restart.
Test notebook#
- UUID:
5c81a1df-c36c-4eea-9289-978dfcc655a1 - Page:
5b66f39c-9d00-40a7-bce0-9fb3a044d446.rm - Path:
/home/root/.local/share/remarkable/xochitl/5c81a1df-c36c-4eea-9289-978dfcc655a1/
Xovi extension (xovi-ext/glossa-injector/)#
Native C++ extension that runs inside xochitl's process via LD_PRELOAD.
What it does#
- Registers
GlossaInjectorQML singleton atdev.glossa.injector 0.1 - Spawns a pthread that polls
/tmp/glossa_strokes.jsonevery 1s - On file change, uses
QMetaObject::invokeMethod(Qt::QueuedConnection)to call back into the main thread for stroke loading - Creates
SceneLineItemobjects using reverse-engineered structs from clipboard-injector (rm_SceneItem, rm_SceneLineItem, rm_Line)
Key learnings#
- QTimer does NOT work in xovi QML singletons. The object created by
qmlRegisterSingletonInstancedoesn't get timer events processed. Use pthread +QMetaObject::invokeMethodinstead. QCoreApplication::instance()returns null during_xovi_construct. Don't check app type in constructor. Filter workers in entry.c usingprogram_invocation_short_name.- Xovi activation: requires triple-tap (xovi-tripletap) or manual
xovi/startover SSH. Uses tmpfs systemd drop-in, lost on reboot.systemctl restart xochitlalone preserves the tmpfs if already mounted. - Extension .so is loaded once at xochitl startup. Updating the .so on disk requires restarting xochitl to pick up changes.
- Vtable cloning: SceneLineItems need a valid vtable pointer cloned from an existing xochitl-created item. Currently returns nil — needs fixing.
- QQmlEngine access: Can't find engine via
app->children()iteration. Need alternative approach for clipboard/scene injection.
Build & deploy workflow#
The unified build script at xovi-ext/build.sh compiles both the
extension .so and the uinject binary, then deploys both to the device.
cd xovi-ext
# First time: creates persistent Docker builder container
# Subsequent runs: incremental rebuild (~30s vs ~2min full build)
bash build.sh
# Then restart xochitl on device to load new .so:
ssh remarkable 'systemctl reset-failed xochitl && systemctl restart xochitl'
The build.sh script:
- Creates/reuses a persistent
glossa-builderDocker container with theeeems/remarkable-toolchain:latest-rm2image. Mounts the wholexovi-exttree at/xovi-ext(not justglossa-injector) so any sub-project can compile. - Builds the extension:
qmake6 && makeinside the container (incremental compilation). - Builds
uinject:$CC -o uinject uinject.c -O2 $CFLAGS $LDFLAGSafter sourcing the SDK environment-setup. Must use$CC+$CFLAGS, not a barearm-remarkable-linux-gnueabi-gcc, or the sysroot include paths (stdio.h etc.) won't resolve. - Copies both artifacts out and SCPs them to the device.
Deploy gotcha: SCP to /home/root/uinject fails with "dest open ...
Failure" if a uinject process is still running or the file is busy. Always
ssh remarkable 'killall uinject 2>/dev/null; rm -f /home/root/uinject'
before the SCP. The build script does this, but if deploying manually,
remember the kill+rm dance.
The .so is loaded once at xochitl startup. Updating it on disk requires
restarting xochitl. The uinject binary, by contrast, is exec'd fresh each
call, so a new uinject takes effect immediately — no restart needed.
xovi activation survives restart but not reboot#
The xovi tmpfs systemd drop-in (LD_PRELOAD injection) is lost on reboot.
After a reboot, re-run bash /home/root/xovi/start (or triple-tap) to
remount it. A plain systemctl restart xochitl preserves the tmpfs if it's
already mounted.
uinject CLI#
# Inject pen strokes (speed = ms delay between points, lower = faster)
uinject --speed 3 strokes.json
# Erase by retracing stroke paths with the eraser tool (6 offset passes)
uinject --speed 2 --erase-strokes strokes.json
# Erase a rectangular region (rm v6 coords)
uinject --erase x,y,w,h
Key uinject learnings:
- Pen input arrives as mouse + touch events, NOT Qt tablet events. The
reMarkable digitizer surfaces through
/dev/input/event1as a Wacom device. uinject writes raw evdev events there directly. - Stroke injection is real-time replay — uinject sleeps
--speedms between every point, so a long reply with thousands of points takes many seconds. Keep replies short and densify sparingly (see_densifymin_seg_len=300). Render is instant; the bottleneck is event replay. - There is no erase primitive. Erasing means retracing the ink path with
BTN_TOOL_RUBBER. The eraser tip is narrower than expected, so--erase-strokesmakes 6 offset passes (center + 4 cardinal ±10px + 1 diagonal) to fully cover stroke width. - JSON parser must handle nested brackets. The points array is
[[x,y,...],[x,y,...]]. A naive "stop at first]" only erases the first point of each stroke. Track bracket depth.
JSON stroke format (matches clipboard-injector)#
[{
"points": [[x, y, speed, width, direction, pressure], ...],
"rgba": 4278190080,
"color": 0,
"bounds": [minX, minY, width, height],
"tool": 15,
"maskScale": 2.0,
"thickness": 2.0
}]
Generate with: python glossa.py --json output.json "text"
On-device daemon (glossad.c)#
Single C binary that runs on the reMarkable and replaces the Python loop
for capture + inference + injection. Lives at xovi-ext/glossad/glossad.c.
What it does#
- Watches
/tmp/glossa_armedfor toggle state. - Accepts commands via
/tmp/glossa_cmd(JSON: draw, erase, swipe). - Captures framebuffer, crops, despeckles, masks known content, sends to vision API, renders reply with built-in font renderer, injects via evdev.
- All in one process. No socket, no separate daemon.
Image processing pipeline (in glossad.c)#
- Capture: trigger
/tmp/glossa_screenshot, read raw RGBA framebuffer. - Crop: drop top 80px (toolbar) and bottom 200px (swirl/safezone).
- Despeckle (
despeckle_crop): two passes on the grayscale crop.- Pass 1: isolated pixel removal. Dark pixels with fewer than 2 dark 8-neighbors are e-ink noise; blank them.
- Pass 2: connected-component analysis via flood fill. Components with fewer than 4 pixels are killed. Additionally, components of exactly 4 pixels fitting in a 2x2 bounding box are killed (this is the grid dot signature: paired dots on consecutive rows).
- Influence mask (
apply_influence_mask): pixels in known content regions (ornaments, previous replies) are blanked to white before the PNG is saved for the AI. Prevents the model from re-reading its own output or getting confused by decorative elements. - New content detection (
detect_new_content): scans the crop for dark pixels outside the influence mask. Returns whether new handwriting exists and the lowest row with new content (for reply positioning). Replaces the old separatepage_is_empty+find_content_bottom.
Influence map#
A CROP_HEIGHT x FB_WIDTH uint8 array (g_influence) tracking known
content regions. Zeroed at startup, cleared on page turn.
mark_stroke_region(): stamps a Gaussian blob (radius 30px, sigma 15.0) around each stroke point into the map. Points are converted from rm coordinates (origin top-center) to crop-space.mark_strokes_from_json(): parses uinject-format JSON and marks all stroke points. Called after every injection so the next capture knows where our ink lives.apply_influence_mask(): blanks pixels above threshold (128) to white in the grayscale crop before saving the AI PNG.- Constants:
INFLUENCE_RADIUS=30,INFLUENCE_SIGMA=15.0,INFLUENCE_THRESH=128,DARK_THRESHOLD=128.
Grid artifact filtering (IMPORTANT)#
The reMarkable framebuffer contains a uniform grid pattern: isolated 2px-tall rows every ~42px, each with exactly 68 dark pixels. This is a display/refresh artifact, NOT content. It is indistinguishable from ink by pixel value alone.
The old approach (counting dark pixels per row, filtering rows with
exactly 68) has been replaced by connected-component analysis in
despeckle_crop(). The grid dots survive as tiny 4-pixel components in
2x2 bounding boxes and are killed by the specific filter:
count == 4 && comp_w <= 2 && comp_h <= 2.
This works because real handwriting forms large connected blobs while grid artifacts are always tiny isolated clusters. Verified: 1286 grid dots killed, 22 real handwriting components preserved on a test page.
Build & deploy#
glossad is built by xovi-ext/build.sh alongside the extension and
uinject. It links against libpng and OpenSSL (for HTTPS API calls).
cd xovi-ext && bash build.sh
# Deploys glossad binary to device. Restart to pick up changes:
ssh remarkable 'systemctl restart glossad'
Local clangd diagnostics show errors for linux/input.h, png.h, etc.
These are expected: the code cross-compiles against the reMarkable
toolchain sysroot, not local macOS headers. Ignore them.
Closed-loop daemon (glossa_loop.py)#
Python version of the closed loop. Largely superseded by glossad.c but
still useful for development and testing.
python glossa_loop.py [--model MODEL] [--once]
Pipeline#
- Idle detection (xovi extension,
PenIdleWatcher): an event filter on QGuiApplication watches mouse/touch press+release. A debounce thread writes/tmp/glossa_idleafter the pen has been up forDEBOUNCE_MS(currently 2500ms). The Python daemon polls that file over persistent SSH. - Capture: trigger
/tmp/glossa_screenshot, the extension dumps the framebuffer to/tmp/glossa_fb.raw, pull it via SCP. - Crop: drop top 80px (toolbar) and bottom 200px (swirl zone).
- Blank check: skip the API entirely if no real content.
- Vision model: send the full page image; the model does OCR +
relevance filtering + reply in one call. Returns
[NO_NEW_TEXT]when there's nothing new for it. - Position + inject: find content bottom, render reply below it, SCP the JSON, run uinject.
Persistent SSH#
SSHSession uses ControlMaster multiplexing (one connection, reused for all
commands) to kill the ~1s handshake per call. Cleans up stale sockets on
open and uses ConnectTimeout=10 so it fails fast instead of hanging.
Vision model API#
Currently uses Charm's Hyper gateway:
- Endpoint:
https://hyper.charmcli.dev/v1/chat/completions - Auth:
HYPER_API_KEYfrom.env - Model:
kimi-k2.6-vercel - OpenAI-compatible format. Roles must be
user/assistant, notmodel(that's Gemini's format and Hyper rejects it with HTTP 400). - Images sent as
image_urlwithdata:image/png;base64,...URLs.
Conversation history is kept as (role, text) tuples and replayed each
turn for context. It resets on a real page change but NOT when our own
injection changes the framebuffer hash (detected via last_inject_y).
Thinking indicator#
While capture + inference run, a background thread draws a small curly
swirl in the bottom-left corner (rm coords ~x=-580, y=1750), then erases and
redraws it in a loop (1s hold after draw, 3s pause after erase). On
completion it does a final erase. The animation thread is stopped on every
exit path (success, skip, blank, error) via an Event, and the main thread
joins with a generous 15s timeout so the final erase finishes cleanly —
a short join timeout abandons the thread mid-erase and leaves ink behind.
Hard-won iteration loop#
When working on glossa, this is the tight loop that works:
- Python changes take effect on the next
glossa_loop.pyrun. No deploy needed. - uinject changes:
cd xovi-ext && bash build.shthen the kill+rm+scp dance. Effective immediately (exec'd per call). - Extension (.so) changes:
bash build.sh, then restart xochitl. Slowest loop — avoid unless touching idle detection or framebuffer hooks. - Debugging the extension: stderr is swallowed by xochitl. Write to a
file instead (
/tmp/glossa_ext.log) via aglossa_log()helper. Read it withssh remarkable 'cat /tmp/glossa_ext.log'. - Inspecting captures: pull
/tmp/glossa_fb.raw, load asImage.frombytes("RGBA", (1404, 1872), raw), annotate with PIL, save a PNG, and View it. This is how the grid artifact was found — always look at the actual pixels before trusting a heuristic.
Script usage (glossa.py)#
# File-based injection (creates new layer in .rm file)
python glossa.py input.rm output.rm "reply text"
# JSON export for xovi injector (--y sets start Y in device coords)
python glossa.py --json /tmp/glossa_strokes.json --y 400 "reply text"
# Preview (renders SVG for visual tuning)
python glossa.py --preview "text"
glossa.py is also importable as a library:
text_to_strokes(text, x, y) returns si.Line objects;
strokes_to_json(strokes, path) writes the uinject JSON. The loop uses it
this way for the thinking indicator instead of shelling out.