diff --git a/src/rmc/__init__.py b/src/rmc/__init__.py index 2f442ce..c9886c2 100644 --- a/src/rmc/__init__.py +++ b/src/rmc/__init__.py @@ -1,2 +1,3 @@ from .exporters.svg import blocks_to_svg, rm_to_svg from .exporters.pdf import rm_to_pdf +from .exporters.json import page_to_json, rm_to_json diff --git a/src/rmc/cli.py b/src/rmc/cli.py index bef5cf8..a9bf9f1 100644 --- a/src/rmc/cli.py +++ b/src/rmc/cli.py @@ -11,6 +11,7 @@ from .exporters.svg import blocks_to_svg from .exporters.pdf import svg_to_pdf from .exporters.markdown import print_text +from .exporters.json import page_to_json import logging @@ -25,7 +26,7 @@ def cli(verbose, from_, to, output, input): """Convert to/from reMarkable v6 files. - Available FORMATs are: `rm` (reMarkable file), `markdown`, `svg`, `pdf`, + Available FORMATs are: `rm` (reMarkable file), `markdown`, `svg`, `pdf`, `json` `blocks`, `blocks-data`. Formats `blocks` and `blocks-data` dump the internal structure of the `rm` @@ -88,6 +89,8 @@ def guess_format(p: Path): return "rm" if p.suffix == ".svg": return "svg" + elif p.suffix == ".json": + return "json" elif p.suffix == ".pdf": return "pdf" elif p.suffix == ".md" or p.suffix == ".markdown": @@ -107,6 +110,9 @@ def convert_rm(filename: Path, to, fout): elif to == "svg": blocks = read_blocks(f) blocks_to_svg(blocks, fout) + elif to == "json": + blocks = read_blocks(f) + page_to_json(blocks, fout) elif to == "pdf": buf = io.StringIO() blocks = read_blocks(f) diff --git a/src/rmc/exporters/json.py b/src/rmc/exporters/json.py new file mode 100644 index 0000000..dda44fd --- /dev/null +++ b/src/rmc/exporters/json.py @@ -0,0 +1,172 @@ +"""Convert blocks to json file. + +Code based on the SVG converter class . +""" + +import logging +import json +import decimal + +from typing import Iterable + +from dataclasses import dataclass + +from rmscene import ( + read_blocks, + Block, + RootTextBlock, + SceneLineItemBlock, +) + +from .writing_tools import ( + Pen, +) + +from .svg import ( + SvgDocInfo, + get_dimensions +) + +DECIMAL_PRECISION = 3 + +_logger = logging.getLogger(__name__) + +@dataclass +class Point: + x: float + y: float + + def toJSON(self): + return { + 'x': formatFloat(self.x), + 'y': formatFloat(self.y) + } + + +@dataclass +class PolyLine: + stroke: str + width: float + opacity: float + points: list[Point] + + def toJSON(self): + return { + 'stroke': self.stroke, + 'width': formatFloat(self.width), + 'opacity': formatFloat(self.opacity), + 'points': [point.toJSON() for point in self.points], + } + + +@dataclass +class TextElement(Point): + text: str + + def toJSON(self): + return { + 'x': formatFloat(self.x), + 'y': formatFloat(self.y), + 'text': self.text + } + + +@dataclass +class Page: + page_number: int + height: float + width: float + lines: list[PolyLine] + texts: list[TextElement] + + def toJSON(self): + return { + 'page_number': self.page_number, + 'height': formatFloat(self.height), + 'width': formatFloat(self.width), + 'lines': [line.toJSON() for line in self.lines], + 'texts': [text.toJSON() for text in self.texts], + } + + +def formatFloat(val: float) -> float: + return float(round(decimal.Decimal(val), DECIMAL_PRECISION)) + +def parse_line_block(block: SceneLineItemBlock, doc_info: SvgDocInfo) -> Iterable[PolyLine]: + # make sure the object is not empty + if block.value is None: + return + + # initiate the pen + pen = Pen.create(block.value.tool.value, block.value.color.value, block.value.thickness_scale) + + last_xpos: float = None + last_ypos: float = None + last_segment_width = 0. + polyline: PolyLine = None + + for point_id, point in enumerate(block.value.points): + # align the original position + xpos = point.x + doc_info.xpos_delta + ypos = point.y + doc_info.ypos_delta + + if point_id % pen.segment_length == 0: + segment_color = pen.get_segment_color(point.speed, point.direction, point.width, point.pressure, last_segment_width) + segment_width = pen.get_segment_width(point.speed, point.direction, point.width, point.pressure, last_segment_width) + segment_opacity = pen.get_segment_opacity(point.speed, point.direction, point.width, point.pressure, last_segment_width) + + if polyline != None: + yield polyline + + polyline = PolyLine(segment_color, segment_width, segment_opacity, []) + if last_xpos != None: + # Join to previous segment + polyline.points.append(Point(last_xpos, last_ypos)) + + # store the last position + last_xpos = xpos + last_ypos = ypos + last_segment_width = segment_width + + polyline.points.append(Point(last_xpos, last_ypos)) + + if polyline != None: + yield polyline + +def parse_text_block(block: RootTextBlock, doc_info: SvgDocInfo) -> Iterable[TextElement]: + xpos = block.pos_x + doc_info.width / 2. + ypos = block.pos_y + doc_info.height / 2. + + for text_item in block.text_items: + if text_item.text.strip(): + yield TextElement(xpos, ypos, text_item.text.strip()) + +def page_to_json(blocks: Iterable[Block], output, page_number = 0, debug=0): + """Convert Blocks to SVG.""" + + # we need to process the blocks twice to understand the dimensions, so + # let's put the iterable into a list + blocks = list(blocks) + + # get document dimensions + doc_info = get_dimensions(blocks, debug) + + # add json page info + page = Page(page_number, doc_info.height, doc_info.width, [], []) + + for block in blocks: + if isinstance(block, SceneLineItemBlock): + page.lines.extend(parse_line_block(block, doc_info)) + elif isinstance(block, RootTextBlock): + page.texts.extend(parse_text_block(block, doc_info)) + else: + if debug > 0: + print(f'warning: not converting block: {block.__class__}') + + json.dump(page.toJSON(), output) + +def rm_to_json(rm_path, json_path, debug=0): + """Convert `rm_path` to JSON at `json_path`.""" + with open(rm_path, "rb") as infile, open(json_path, "wt") as outfile: + blocks = read_blocks(infile) + page_to_json(blocks, outfile, debug) \ No newline at end of file