From bbff179a4dad61a5e60f6047d445eceffb933612 Mon Sep 17 00:00:00 2001 From: Ishak BELAHMAR Date: Sun, 29 Sep 2019 21:41:34 +0200 Subject: [PATCH] Add png export feature --- .circleci/config.yml | 2 +- meson.build | 1 + tools/kete.py | 16 +++-- tools/raw-log-converter.py | 16 +++-- tuhi/export.py | 144 +++++++++++++++++++++++++++++++++++++ tuhi/gui/drawing.py | 51 +++++++++---- 6 files changed, 207 insertions(+), 23 deletions(-) create mode 100644 tuhi/export.py diff --git a/.circleci/config.yml b/.circleci/config.yml index fcc0e63..c9a1094 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -7,7 +7,7 @@ jobs: steps: - run: command: | - dnf install -y meson gettext python3-devel pygobject3-devel python3-flake8 desktop-file-utils libappstream-glib python3-pytest python3-pyxdg python3-pyyaml python3-svgwrite + dnf install -y meson gettext python3-devel pygobject3-devel python3-flake8 desktop-file-utils libappstream-glib python3-pytest python3-pyxdg python3-pyyaml python3-svgwrite python3-cairo - checkout - run: command: | diff --git a/meson.build b/meson.build index 3b2db56..bea7190 100644 --- a/meson.build +++ b/meson.build @@ -36,6 +36,7 @@ python_modules = [ 'xdg', 'gi', 'yaml', + 'cairo', ] if meson.version().version_compare('>=0.51') py3 = pymod.find_installation(modules: python_modules) diff --git a/tools/kete.py b/tools/kete.py index 16e5815..8cae589 100755 --- a/tools/kete.py +++ b/tools/kete.py @@ -29,14 +29,14 @@ import configparser from pathlib import Path try: - from tuhi.svg import JsonSvg + from tuhi.export import JsonSvg, JsonPng import tuhi.dbusclient except ModuleNotFoundError: # If PYTHONPATH isn't set up or we never installed Tuhi, the module # isn't available. And since we don't install kete, we can assume that # we're still in the git repo, so messing with the path is "fine". sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)) + '/..') # noqa - from tuhi.svg import JsonSvg + from tuhi.export import JsonSvg, JsonPng import tuhi.dbusclient @@ -289,6 +289,7 @@ class Fetcher(Worker): super(Fetcher, self).__init__(manager) self.device = None self.timestamps = None + self.format = args.format address = args.address index = args.index @@ -326,8 +327,12 @@ class Fetcher(Worker): data = json.loads(jsondata) t = time.localtime(data['timestamp']) t = time.strftime('%Y-%m-%d-%H-%M', t) - path = f'{data["devicename"]}-{t}.svg' - JsonSvg(data, self.orientation, filename=path) + if self.format == 'png': + path = f'{data["devicename"]}-{t}.png' + JsonPng(data, self.orientation, filename=path) + else: + path = f'{data["devicename"]}-{t}.svg' + JsonSvg(data, self.orientation, filename=path) logger.info(f'{data["devicename"]}: saved file "{path}"') @@ -699,6 +704,9 @@ class TuhiKeteShell(cmd.Cmd): type=is_index_or_all, const='all', nargs='?', default='all', help='the index of the drawing to fetch or a literal "all"') + parser.add_argument('--format', metavar='{svg|png}', + default='svg', + help='output file format') try: parsed_args = parser.parse_args(args.split()) diff --git a/tools/raw-log-converter.py b/tools/raw-log-converter.py index 860971b..4389583 100755 --- a/tools/raw-log-converter.py +++ b/tools/raw-log-converter.py @@ -31,7 +31,7 @@ sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)) + '/..') # noqa from tuhi.util import flatten from tuhi.drawing import Drawing from tuhi.protocol import StrokeFile -from tuhi.svg import JsonSvg +from tuhi.export import JsonSvg, JsonPng from tuhi.wacom import WacomProtocolSpark, WacomProtocolIntuosPro, WacomProtocolSlate logging.basicConfig(format='%(asctime)s %(levelname)s: %(name)s: %(message)s', @@ -68,6 +68,7 @@ def parse_file(filename, file_format, tablet_model, orientation): # gotta convert to Drawings, then to json string, then to json, then # to svg. ffs. svgname = f'{stem}.svg' + pngname = f'{stem}.png' jsonname = f'{stem}.json' d = Drawing(svgname, (width * point_size, height * point_size), timestamp) @@ -85,10 +86,13 @@ def parse_file(filename, file_format, tablet_model, orientation): with open(jsonname, 'w') as fd: fd.write(d.to_json()) return - - from io import StringIO - js = json.load(StringIO(d.to_json())) - JsonSvg(js, orientation, d.name) + else: + from io import StringIO + js = json.load(StringIO(d.to_json())) + if file_format == 'svg': + JsonSvg(js, orientation, d.name) + elif file_format == 'png': + JsonPng(js, orientation, pngname) def fetch_files(): @@ -142,7 +146,7 @@ def main(args=sys.argv): parser.add_argument('--format', help='The format to generate. Default: svg', default='svg', - choices=['svg', 'json']) + choices=['svg', 'png', 'json']) ns = parser.parse_args(args[1:]) if ns.verbose: diff --git a/tuhi/export.py b/tuhi/export.py new file mode 100644 index 0000000..8d7c740 --- /dev/null +++ b/tuhi/export.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# + +import os.path +from gi.repository import GObject +import svgwrite +from svgwrite import mm +import cairo + + +class ImageExportBase(GObject.Object): + + def __init__(self, json, orientation, filename, *args, **kwargs): + super().__init__(*args, **kwargs) + self.json = json + self.timestamp = json['timestamp'] + self.filename = filename + self.orientation = orientation.lower() + self._convert() + + @property + def output_dimensions(self): + dimensions = self.json['dimensions'] + if dimensions == [0, 0]: + width, height = 100, 100 + else: + # Original dimensions are too big for most Standards + # so we scale them down + width = dimensions[0] / self._output_scaling_factor + height = dimensions[1] / self._output_scaling_factor + + if self.orientation in ['portrait', 'reverse-portrait']: + return height, width + else: + return width, height + + @property + def output_strokes(self): + + width, height = self.output_dimensions + strokes = [] + + for s in self.json['strokes']: + points_with_sk_width = [] + + for p in s['points']: + + x, y = p['position'] + # Scaling coordinates + x = x / self._output_scaling_factor + y = y / self._output_scaling_factor + + if self.orientation == 'reverse-portrait': + x, y = y, height - x + elif self.orientation == 'portrait': + x, y = width - y, x + elif self.orientation == 'reverse-landscape': + x, y = width - x, height - y + + # Pressure normalized range is [0, 0xffff] + delta = (p['pressure'] - 0x8000) / 0x8000 + stroke_width = self._base_pen_width + self._pen_pressure_width_factor * delta + points_with_sk_width.append((x, y, stroke_width)) + + strokes.append(points_with_sk_width) + + return strokes + + +class JsonSvg(ImageExportBase): + + _output_scaling_factor = 1000 + _base_pen_width = 0.4 + _pen_pressure_width_factor = 0.2 + + def _convert(self): + + width, height = self.output_dimensions + size = width * mm, height * mm + svg = svgwrite.Drawing(filename=self.filename, size=size) + + g = svgwrite.container.Group(id='layer0') + for sk_num, stroke_points in enumerate(self.output_strokes): + lines = svgwrite.container.Group(id=f'sk_{sk_num}', stroke='black') + for i, (x, y, stroke_width) in enumerate(stroke_points): + if i != 0: + xp, yp, stroke_width_p = stroke_points[i - 1] + lines.add( + svg.line( + start=(xp * mm, yp * mm), + end=(x * mm, y * mm), + stroke_width=stroke_width, + style='fill:none' + ) + ) + g.add(lines) + + svg.add(g) + svg.save() + + +class JsonPng(ImageExportBase): + + _output_scaling_factor = 100 + _base_pen_width = 3 + _pen_pressure_width_factor = 1 + + def _convert(self): + + width, height = self.output_dimensions + width, height = int(width), int(height) + surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, width, height) + ctx = cairo.Context(surface) + + # Paint a transparent background + ctx.set_source_rgba(0, 0, 0, 0) + ctx.paint() + + ctx.set_antialias(cairo.Antialias.DEFAULT) + ctx.set_line_join(cairo.LINE_JOIN_ROUND) + ctx.set_source_rgb(0, 0, 0) + + for sk_num, stroke_points in enumerate(self.output_strokes): + for i, (x, y, stroke_width) in enumerate(stroke_points): + ctx.set_line_width(stroke_width) + + if i == 0: + ctx.move_to(x, y) + else: + ctx.line_to(x, y) + + ctx.stroke() + + surface.write_to_png(self.filename) diff --git a/tuhi/gui/drawing.py b/tuhi/gui/drawing.py index a2018da..79da826 100644 --- a/tuhi/gui/drawing.py +++ b/tuhi/gui/drawing.py @@ -17,14 +17,16 @@ import xdg.BaseDirectory import os from pathlib import Path from .config import Config -from tuhi.svg import JsonSvg +from tuhi.export import JsonSvg, JsonPng import gi gi.require_version("Gtk", "3.0") from gi.repository import GObject, Gtk, GdkPixbuf, Gdk # NOQA -DATA_PATH = Path(xdg.BaseDirectory.xdg_cache_home, 'tuhi', 'svg') +DATA_PATH = Path(xdg.BaseDirectory.xdg_cache_home, 'tuhi') +SVG_DATA_PATH = Path(DATA_PATH, 'svg') +PNG_DATA_PATH = Path(DATA_PATH, 'png') @Gtk.Template(resource_path='/org/freedesktop/Tuhi/ui/Drawing.ui') @@ -40,7 +42,8 @@ class Drawing(Gtk.EventBox): super().__init__() self.orientation = Config().orientation Config().connect('notify::orientation', self._on_orientation_changed) - DATA_PATH.mkdir(parents=True, exist_ok=True) + SVG_DATA_PATH.mkdir(parents=True, exist_ok=True) + PNG_DATA_PATH.mkdir(parents=True, exist_ok=True) self.json_data = json_data self._zoom = zoom @@ -56,8 +59,12 @@ class Drawing(Gtk.EventBox): self.redraw() def process_svg(self): - path = os.fspath(Path(DATA_PATH, f'{self.json_data["timestamp"]}.svg')) - self.svg = JsonSvg(self.json_data, self.orientation, path) + path = os.fspath(Path(SVG_DATA_PATH, f'{self.json_data["timestamp"]}.svg')) + self.svg = JsonSvg( + self.json_data, + self.orientation, + path + ) width, height = -1, -1 if 'portrait' in self.orientation: height = 1000 @@ -68,6 +75,14 @@ class Drawing(Gtk.EventBox): height=height, preserve_aspect_ratio=True) + def process_png(self): + path = os.fspath(Path(PNG_DATA_PATH, f'{self.json_data["timestamp"]}.png')) + self.png = JsonPng( + self.json_data, + self.orientation, + path + ) + def redraw(self): ratio = self.pixbuf.get_height() / self.pixbuf.get_width() base = 250 + self.zoom * 50 @@ -115,21 +130,33 @@ class Drawing(Gtk.EventBox): # Translators: filter to show svg files only filter_svg.set_name(_('SVG files')) filter_svg.add_pattern('*.svg') + filter_png = Gtk.FileFilter() + # Translators: filter to show png files only + filter_png.set_name(_('PNG files')) + filter_png.add_pattern('*.png') dialog.add_filter(filter_svg) + dialog.add_filter(filter_png) dialog.add_filter(filter_any) response = dialog.run() if response == Gtk.ResponseType.OK: import shutil - # regenerate the SVG based on the current rotation. - # where we used the orientation buttons, we haven't updated the - # file itself. - self.process_svg() - file = dialog.get_filename() - shutil.copyfile(self.svg.filename, file) - # FIXME: error handling + + if file.lower().endswith('.png'): + # regenerate the PNG based on the current rotation. + # where we used the orientation buttons, we haven't updated the + # file itself. + self.process_png() + shutil.move(self.png.filename, file) + else: + # regenerate the SVG based on the current rotation. + # where we used the orientation buttons, we haven't updated the + # file itself. + self.process_svg() + shutil.copyfile(self.svg.filename, file) + # FIXME: error handling dialog.destroy()