Skip to content

Commit a9c2336

Browse files
feat(render): typed text in read-only image + device-accurate layout & wrapping (#116)
* feat(render): show typed text in read-only remarkable_image render The content-cropped render path used by remarkable_image (and the merged-PDF annotation layer) only drew ink strokes, so typed text was invisible and text-only pages returned no image at all. Refactor _v6_text_svg_elements into _v6_text_elements_with_bounds, which also returns each line's bounding coordinates, and fold those into the cropped viewBox in _render_rm_v6_to_svg. Text is drawn under strokes to match device compositing, and a text-only page now renders instead of returning None. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(render): calibrate typed-text layout to device + wrap long lines The typed-text layout used rmc's rM2-derived constants (7pt font in a 72/226-DPI space, top offset -88). Validated against a reMarkable Paper Pro's own page thumbnail, that renders text noticeably smaller and ~50 units higher than the device actually draws it, so a circle drawn around a title in the canvas landed above the title on-device. Long paragraphs also overflowed the text box because SVG <text> does not wrap. - Recalibrate constants in raw stroke units against the device thumbnail: font 30, top offset -39, line height 70. First-line baseline and every body line now match the device within ~1px. - Add greedy word-wrapping (_wrap_text) to the text-box width, with continuation lines spaced tighter (~44 vs 70) like the device. The long 'Goliath frog' line now wraps at the same point the device wraps it. - Scale all metrics by the page's real height so they adapt to other geometries (Move, Paper Pro, classic) that normalize differently; identity for the standard 1404x1872 page. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test: guard typed-text metric scaling across page geometries Add test_typed_text_metrics_scale_with_page_height asserting that the typed-text line height and font size scale linearly with the page's normalized height. run_smoke only checks render PASS/FAIL, not pixel layout, so this is the regression guard for the Move/classic page-height scaling path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 9944020 commit a9c2336

2 files changed

Lines changed: 242 additions & 34 deletions

File tree

remarkable_mcp/extract.py

Lines changed: 105 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -578,86 +578,154 @@ def _v6_paths_from_blocks(blocks: list) -> Tuple[list, list]:
578578
return paths, all_coords
579579

580580

581-
# reMarkable typed-text layout, in stroke/screen units. Mirrors the rmc
582-
# exporter (github.com/ricklupton/rmc) so typed text (a RootTextBlock) renders
583-
# in the full-page view at the same place the device draws it. rmc lays text
584-
# out in a 72/226-DPI point space; our full-page SVG works in raw screen units,
585-
# so point font sizes are converted to screen units via _PT_TO_SCREEN.
586-
_TEXT_TOP_Y = -88
587-
_PT_TO_SCREEN = 226.0 / 72.0
581+
# reMarkable typed-text layout, in raw stroke units. Calibrated against
582+
# device-rendered page thumbnails (a reMarkable Paper Pro / "Ferrari", which
583+
# normalizes typed text into the same 1404x1872 coordinate space as the rM1/2).
584+
# These differ from the rmc exporter's constants (github.com/ricklupton/rmc):
585+
# rmc lays text out in a 72/226-DPI-scaled space and its plain font (7pt) and
586+
# top offset (-88) render text noticeably smaller and higher than the device
587+
# actually draws it. Values here are in the reference 1404x1872 space and are
588+
# scaled by the page's real height (see ``_TEXT_REF_PAGE_HEIGHT``) so they adapt
589+
# to other geometries (e.g. reMarkable Move) that normalize differently.
590+
_TEXT_REF_PAGE_HEIGHT = 1872.0
591+
_TEXT_TOP_Y = -39.0
592+
_TEXT_DEFAULT_LINE_HEIGHT = 70.0
593+
_TEXT_DEFAULT_FONT_SIZE = 30.0
594+
# Continuation lines of a wrapped paragraph are spaced tighter than the gap
595+
# between paragraphs (device uses ~44 vs ~70 units for plain text).
596+
_TEXT_WRAP_LINE_RATIO = 44.0 / 70.0
597+
# Average glyph advance as a fraction of font size, used only to wrap a
598+
# paragraph to the text-box width (SVG <text> does not wrap on its own).
599+
_TEXT_CHAR_ADVANCE = 0.5
600+
601+
602+
def _wrap_text(text: str, max_width: float, char_advance: float) -> List[str]:
603+
"""Greedily word-wrap ``text`` to ``max_width`` (stroke units).
604+
605+
SVG ``<text>`` does not wrap, so paragraphs wider than the text box are
606+
split here the way the device wraps them. Line width is approximated as
607+
``len(line) * char_advance`` -- good enough for a faithful preview without
608+
embedding the device font. A single word longer than the box is left on its
609+
own line rather than split. Returns ``[text]`` when no wrapping applies.
610+
"""
611+
if max_width <= 0 or char_advance <= 0:
612+
return [text]
613+
lines: List[str] = []
614+
cur = ""
615+
for word in text.split(" "):
616+
trial = word if not cur else f"{cur} {word}"
617+
if not cur or len(trial) * char_advance <= max_width:
618+
cur = trial
619+
else:
620+
lines.append(cur)
621+
cur = word
622+
if cur:
623+
lines.append(cur)
624+
return lines
588625

589626

590627
def _v6_text_svg_elements(blocks: list) -> list:
591628
"""Build SVG ``<text>`` strings for typed text (a RootTextBlock) on a page.
592629
593-
Returns an empty list when the page has no typed text (the common case for
630+
Thin wrapper around :func:`_v6_text_elements_with_bounds` for callers (e.g.
631+
the full-page render) that fix the viewBox to the page extent and so do not
632+
need the text's bounding coordinates.
633+
"""
634+
return _v6_text_elements_with_bounds(blocks)[0]
635+
636+
637+
def _v6_text_elements_with_bounds(blocks: list) -> Tuple[list, list]:
638+
"""Build SVG ``<text>`` strings plus their bounding coords for typed text.
639+
640+
Returns ``(elements, coords)`` where ``coords`` is a flat list of ``(x, y)``
641+
extent points (in the page's stroke/screen units, center-origin X) so a
642+
content-cropped render can size its viewBox to include the text. Returns
643+
``([], [])`` when the page has no typed text (the common case for
594644
handwritten notebooks) or when rmscene's text helpers are unavailable.
595-
Coordinates are in the page's own stroke/screen units (center-origin X),
596-
matching :func:`_svg_full_page`, so text lands where the device shows it.
597645
"""
598646
try:
599647
from rmscene.scene_items import ParagraphStyle, Text
600648
from rmscene.text import TextDocument
601649
except ImportError:
602-
return []
650+
return [], []
603651

604652
text_item = next(
605653
(b.value for b in blocks if isinstance(getattr(b, "value", None), Text)),
606654
None,
607655
)
608656
if text_item is None:
609-
return []
657+
return [], []
610658

611659
# Blank pages we synthesize carry an empty RootTextBlock; skip them so we
612660
# neither emit empty <text> nodes nor trigger rmscene's empty-item warning.
613661
try:
614662
if not any(isinstance(v, str) and v.strip() for v in text_item.items.values()):
615-
return []
663+
return [], []
616664
except Exception:
617665
pass
618666

619667
line_heights = {
620-
ParagraphStyle.PLAIN: 70,
621-
ParagraphStyle.HEADING: 150,
622-
ParagraphStyle.BOLD: 70,
623-
ParagraphStyle.BULLET: 35,
624-
ParagraphStyle.BULLET2: 35,
625-
ParagraphStyle.CHECKBOX: 35,
626-
ParagraphStyle.CHECKBOX_CHECKED: 35,
668+
ParagraphStyle.PLAIN: 70.0,
669+
ParagraphStyle.HEADING: 150.0,
670+
ParagraphStyle.BOLD: 70.0,
671+
ParagraphStyle.BULLET: 35.0,
672+
ParagraphStyle.BULLET2: 35.0,
673+
ParagraphStyle.CHECKBOX: 35.0,
674+
ParagraphStyle.CHECKBOX_CHECKED: 35.0,
627675
}
628676
font_sizes = {
629-
ParagraphStyle.HEADING: 14 * _PT_TO_SCREEN,
630-
ParagraphStyle.BOLD: 8 * _PT_TO_SCREEN,
677+
ParagraphStyle.HEADING: 60.0,
678+
ParagraphStyle.BOLD: 34.0,
631679
}
632-
default_font = 7 * _PT_TO_SCREEN
633680

634681
try:
635682
doc = TextDocument.from_scene_item(text_item)
636683
except Exception:
637-
return []
684+
return [], []
638685

639686
pos_x = float(getattr(text_item, "pos_x", 0.0) or 0.0)
640687
pos_y = float(getattr(text_item, "pos_y", 0.0) or 0.0)
688+
box_width = float(getattr(text_item, "width", 0.0) or 0.0)
689+
690+
# Scale the reference metrics to the page's real height so text on devices
691+
# that normalize to a different coordinate space (e.g. reMarkable Move)
692+
# stays proportional. Identity for the standard 1404x1872 page.
693+
_, paper_h = _v6_paper_size(blocks)
694+
scale = paper_h / _TEXT_REF_PAGE_HEIGHT if paper_h else 1.0
641695

642696
elements: list = []
643-
y_offset = _TEXT_TOP_Y
697+
coords: list = []
698+
y_offset = _TEXT_TOP_Y * scale
644699
for para in doc.contents:
645700
style = para.style.value if getattr(para, "style", None) is not None else None
646-
y_offset += line_heights.get(style, 70)
701+
line_height = line_heights.get(style, _TEXT_DEFAULT_LINE_HEIGHT) * scale
702+
size = font_sizes.get(style, _TEXT_DEFAULT_FONT_SIZE) * scale
647703
text = str(para).strip()
648704
if not text:
705+
# A blank paragraph still consumes a line (e.g. spacing under a title).
706+
y_offset += line_height
649707
continue
650-
size = font_sizes.get(style, default_font)
651708
family = "serif" if style == ParagraphStyle.HEADING else "sans-serif"
652709
weight = (
653710
' font-weight="bold"' if style in (ParagraphStyle.BOLD, ParagraphStyle.HEADING) else ""
654711
)
655-
elements.append(
656-
f'<text x="{pos_x:.1f}" y="{pos_y + y_offset:.1f}" '
657-
f'font-family="{family}" font-size="{size:.1f}"{weight} '
658-
f'fill="black" xml:space="preserve">{_xml_escape(text)}</text>'
659-
)
660-
return elements
712+
# Wrap long paragraphs to the text box; each wrapped line takes a line,
713+
# with continuation lines spaced tighter than a paragraph break.
714+
for idx, line in enumerate(_wrap_text(text, box_width, size * _TEXT_CHAR_ADVANCE)):
715+
y_offset += line_height if idx == 0 else line_height * _TEXT_WRAP_LINE_RATIO
716+
baseline = pos_y + y_offset
717+
elements.append(
718+
f'<text x="{pos_x:.1f}" y="{baseline:.1f}" '
719+
f'font-family="{family}" font-size="{size:.1f}"{weight} '
720+
f'fill="black" xml:space="preserve">{_xml_escape(line)}</text>'
721+
)
722+
# Estimate the line's extent for crop sizing, capped at the box width.
723+
est_width = len(line) * size * _TEXT_CHAR_ADVANCE
724+
if box_width > 0:
725+
est_width = min(est_width, box_width)
726+
coords.append((pos_x, baseline - size))
727+
coords.append((pos_x + est_width, baseline + size * 0.3))
728+
return elements, coords
661729

662730

663731
def _render_rm_v6_to_svg(rm_file_path: Path) -> Optional[str]:
@@ -674,7 +742,10 @@ def _render_rm_v6_to_svg(rm_file_path: Path) -> Optional[str]:
674742
return None
675743
try:
676744
paths, all_coords = _v6_paths_from_blocks(blocks)
677-
return _svg_from_paths(paths, all_coords)
745+
text_elements, text_coords = _v6_text_elements_with_bounds(blocks)
746+
# Typed text is drawn under strokes (handwriting layers on top), and its
747+
# extent is folded into the crop so a text-only page still renders.
748+
return _svg_from_paths(text_elements + paths, all_coords + text_coords)
678749
except Exception:
679750
return None
680751

test_server.py

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3877,6 +3877,143 @@ def test_typed_text_rasterizes_to_visible_pixels(self):
38773877
finally:
38783878
rm_path.unlink(missing_ok=True)
38793879

3880+
def test_typed_text_in_cropped_read_only_render(self):
3881+
"""remarkable_image's content-cropped render also draws typed text."""
3882+
from remarkable_mcp import notebooks as nb
3883+
from remarkable_mcp.extract import _render_rm_v6_to_svg
3884+
3885+
with tempfile.NamedTemporaryFile(suffix=".rm", delete=False) as rm_tmp:
3886+
# A text-only page has no strokes; the cropped render must still
3887+
# produce output (it previously returned None with no ink).
3888+
rm_tmp.write(nb.page_rm_bytes("Hello world\nSecond line"))
3889+
rm_path = Path(rm_tmp.name)
3890+
try:
3891+
svg = _render_rm_v6_to_svg(rm_path)
3892+
assert svg is not None
3893+
assert "<text " in svg
3894+
assert "Hello world" in svg
3895+
finally:
3896+
rm_path.unlink(missing_ok=True)
3897+
3898+
def test_typed_text_png_read_only_path_has_dark_pixels(self):
3899+
import io as _io
3900+
3901+
from PIL import Image
3902+
3903+
from remarkable_mcp import notebooks as nb
3904+
from remarkable_mcp.extract import render_rm_file_to_png
3905+
3906+
with tempfile.NamedTemporaryFile(suffix=".rm", delete=False) as rm_tmp:
3907+
rm_tmp.write(nb.page_rm_bytes("Hello world"))
3908+
rm_path = Path(rm_tmp.name)
3909+
try:
3910+
png = render_rm_file_to_png(rm_path, background_color="#FFFFFF")
3911+
assert png is not None
3912+
im = Image.open(_io.BytesIO(png)).convert("L")
3913+
assert sum(im.histogram()[:128]) > 0
3914+
finally:
3915+
rm_path.unlink(missing_ok=True)
3916+
3917+
def test_wrap_text_helper(self):
3918+
from remarkable_mcp.extract import _wrap_text
3919+
3920+
# No wrapping when the text fits or no width/advance is known.
3921+
assert _wrap_text("short line", 936, 15) == ["short line"]
3922+
assert _wrap_text("anything at all", 0, 15) == ["anything at all"]
3923+
# A long line wraps into multiple pieces, each within the width budget.
3924+
long = " ".join(["word"] * 60) # 60 words -> well past a 936-unit box
3925+
lines = _wrap_text(long, 936, 15)
3926+
assert len(lines) > 1
3927+
for line in lines:
3928+
assert len(line) * 15 <= 936
3929+
# Round-trips the words (wrapping only changes whitespace).
3930+
assert " ".join(lines).split() == long.split()
3931+
# A single over-long word is kept on its own line rather than split.
3932+
assert _wrap_text("supercalifragilistic", 50, 15) == ["supercalifragilistic"]
3933+
3934+
def test_long_paragraph_wraps_into_multiple_lines(self):
3935+
from remarkable_mcp import notebooks as nb
3936+
from remarkable_mcp.extract import _v6_blocks, _v6_text_svg_elements
3937+
3938+
long_para = (
3939+
"The smallest frog is under 8mm long; the largest, the Goliath "
3940+
"frog, can reach 32cm and is genuinely enormous for an amphibian."
3941+
)
3942+
with tempfile.NamedTemporaryFile(suffix=".rm", delete=False) as rm_tmp:
3943+
rm_tmp.write(nb.page_rm_bytes(long_para))
3944+
rm_path = Path(rm_tmp.name)
3945+
try:
3946+
elements = _v6_text_svg_elements(_v6_blocks(rm_path))
3947+
# One paragraph wider than the text box must emit more than one line.
3948+
assert len(elements) > 1
3949+
finally:
3950+
rm_path.unlink(missing_ok=True)
3951+
3952+
def test_typed_text_baseline_matches_device_offset(self):
3953+
"""First line sits where the device draws it (calibrated, not the old
3954+
rmc offset that rendered text ~50 units too high)."""
3955+
import re
3956+
3957+
from remarkable_mcp import notebooks as nb
3958+
from remarkable_mcp.extract import _v6_blocks, _v6_text_svg_elements
3959+
3960+
with tempfile.NamedTemporaryFile(suffix=".rm", delete=False) as rm_tmp:
3961+
rm_tmp.write(nb.page_rm_bytes("Frog Facts"))
3962+
rm_path = Path(rm_tmp.name)
3963+
try:
3964+
elements = _v6_text_svg_elements(_v6_blocks(rm_path))
3965+
y = float(re.search(r'y="([-\d.]+)"', elements[0]).group(1))
3966+
# pos_y(234) + TOP(-39) + line_height(70) == 265 for a 1404x1872 page;
3967+
# comfortably below the old value of 216.
3968+
assert 255 <= y <= 275
3969+
finally:
3970+
rm_path.unlink(missing_ok=True)
3971+
3972+
def test_typed_text_metrics_scale_with_page_height(self):
3973+
"""Layout metrics scale linearly with the page's normalized height so
3974+
typed text stays proportional on non-1872 geometries (e.g. reMarkable
3975+
Move/classic). run_smoke only asserts render PASS/FAIL, not pixel
3976+
layout, so this is the guard against a scaling regression."""
3977+
import re
3978+
from unittest.mock import patch
3979+
3980+
from remarkable_mcp import extract
3981+
from remarkable_mcp import notebooks as nb
3982+
from remarkable_mcp.extract import _v6_blocks, _v6_text_svg_elements
3983+
3984+
with tempfile.NamedTemporaryFile(suffix=".rm", delete=False) as rm_tmp:
3985+
# Two paragraphs so the line-to-line gap isolates the scaled
3986+
# line_height independent of the (unscaled) text-box position.
3987+
rm_tmp.write(nb.page_rm_bytes("First line\nSecond line"))
3988+
rm_path = Path(rm_tmp.name)
3989+
try:
3990+
blocks = _v6_blocks(rm_path)
3991+
width, _ = extract._v6_paper_size(blocks)
3992+
3993+
def metrics_at(page_h):
3994+
with patch.object(extract, "_v6_paper_size", return_value=(width, page_h)):
3995+
els = _v6_text_svg_elements(blocks)
3996+
y0 = float(re.search(r'y="([-\d.]+)"', els[0]).group(1))
3997+
y1 = float(re.search(r'y="([-\d.]+)"', els[1]).group(1))
3998+
size = float(re.search(r'font-size="([-\d.]+)"', els[0]).group(1))
3999+
return y1 - y0, size
4000+
4001+
# Reference 1872-tall page: full-size metrics.
4002+
gap_ref, size_ref = metrics_at(extract._TEXT_REF_PAGE_HEIGHT)
4003+
assert gap_ref == pytest.approx(extract._TEXT_DEFAULT_LINE_HEIGHT, abs=0.5)
4004+
assert size_ref == pytest.approx(extract._TEXT_DEFAULT_FONT_SIZE, abs=0.5)
4005+
4006+
# Half / double height -> metrics scale linearly.
4007+
gap_half, size_half = metrics_at(extract._TEXT_REF_PAGE_HEIGHT / 2)
4008+
assert gap_half == pytest.approx(gap_ref / 2, abs=0.5)
4009+
assert size_half == pytest.approx(size_ref / 2, abs=0.5)
4010+
4011+
gap_dbl, size_dbl = metrics_at(extract._TEXT_REF_PAGE_HEIGHT * 2)
4012+
assert gap_dbl == pytest.approx(gap_ref * 2, abs=0.5)
4013+
assert size_dbl == pytest.approx(size_ref * 2, abs=0.5)
4014+
finally:
4015+
rm_path.unlink(missing_ok=True)
4016+
38804017

38814018
class TestRenderCanvasPage:
38824019
"""Test the read-only canvas page renderer."""

0 commit comments

Comments
 (0)