mirror of https://github.com/tuhiproject/tuhi.git
Add png export feature
parent
538480f022
commit
bbff179a4d
|
@ -7,7 +7,7 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
- run:
|
- run:
|
||||||
command: |
|
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
|
- checkout
|
||||||
- run:
|
- run:
|
||||||
command: |
|
command: |
|
||||||
|
|
|
@ -36,6 +36,7 @@ python_modules = [
|
||||||
'xdg',
|
'xdg',
|
||||||
'gi',
|
'gi',
|
||||||
'yaml',
|
'yaml',
|
||||||
|
'cairo',
|
||||||
]
|
]
|
||||||
if meson.version().version_compare('>=0.51')
|
if meson.version().version_compare('>=0.51')
|
||||||
py3 = pymod.find_installation(modules: python_modules)
|
py3 = pymod.find_installation(modules: python_modules)
|
||||||
|
|
|
@ -29,14 +29,14 @@ import configparser
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from tuhi.svg import JsonSvg
|
from tuhi.export import JsonSvg, JsonPng
|
||||||
import tuhi.dbusclient
|
import tuhi.dbusclient
|
||||||
except ModuleNotFoundError:
|
except ModuleNotFoundError:
|
||||||
# If PYTHONPATH isn't set up or we never installed Tuhi, the module
|
# 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
|
# 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".
|
# 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
|
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
|
import tuhi.dbusclient
|
||||||
|
|
||||||
|
|
||||||
|
@ -289,6 +289,7 @@ class Fetcher(Worker):
|
||||||
super(Fetcher, self).__init__(manager)
|
super(Fetcher, self).__init__(manager)
|
||||||
self.device = None
|
self.device = None
|
||||||
self.timestamps = None
|
self.timestamps = None
|
||||||
|
self.format = args.format
|
||||||
address = args.address
|
address = args.address
|
||||||
index = args.index
|
index = args.index
|
||||||
|
|
||||||
|
@ -326,8 +327,12 @@ class Fetcher(Worker):
|
||||||
data = json.loads(jsondata)
|
data = json.loads(jsondata)
|
||||||
t = time.localtime(data['timestamp'])
|
t = time.localtime(data['timestamp'])
|
||||||
t = time.strftime('%Y-%m-%d-%H-%M', t)
|
t = time.strftime('%Y-%m-%d-%H-%M', t)
|
||||||
path = f'{data["devicename"]}-{t}.svg'
|
if self.format == 'png':
|
||||||
JsonSvg(data, self.orientation, filename=path)
|
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}"')
|
logger.info(f'{data["devicename"]}: saved file "{path}"')
|
||||||
|
|
||||||
|
|
||||||
|
@ -699,6 +704,9 @@ class TuhiKeteShell(cmd.Cmd):
|
||||||
type=is_index_or_all,
|
type=is_index_or_all,
|
||||||
const='all', nargs='?', default='all',
|
const='all', nargs='?', default='all',
|
||||||
help='the index of the drawing to fetch or a literal "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:
|
try:
|
||||||
parsed_args = parser.parse_args(args.split())
|
parsed_args = parser.parse_args(args.split())
|
||||||
|
|
|
@ -31,7 +31,7 @@ sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)) + '/..') # noqa
|
||||||
from tuhi.util import flatten
|
from tuhi.util import flatten
|
||||||
from tuhi.drawing import Drawing
|
from tuhi.drawing import Drawing
|
||||||
from tuhi.protocol import StrokeFile
|
from tuhi.protocol import StrokeFile
|
||||||
from tuhi.svg import JsonSvg
|
from tuhi.export import JsonSvg, JsonPng
|
||||||
from tuhi.wacom import WacomProtocolSpark, WacomProtocolIntuosPro, WacomProtocolSlate
|
from tuhi.wacom import WacomProtocolSpark, WacomProtocolIntuosPro, WacomProtocolSlate
|
||||||
|
|
||||||
logging.basicConfig(format='%(asctime)s %(levelname)s: %(name)s: %(message)s',
|
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
|
# gotta convert to Drawings, then to json string, then to json, then
|
||||||
# to svg. ffs.
|
# to svg. ffs.
|
||||||
svgname = f'{stem}.svg'
|
svgname = f'{stem}.svg'
|
||||||
|
pngname = f'{stem}.png'
|
||||||
jsonname = f'{stem}.json'
|
jsonname = f'{stem}.json'
|
||||||
d = Drawing(svgname, (width * point_size, height * point_size), timestamp)
|
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:
|
with open(jsonname, 'w') as fd:
|
||||||
fd.write(d.to_json())
|
fd.write(d.to_json())
|
||||||
return
|
return
|
||||||
|
else:
|
||||||
from io import StringIO
|
from io import StringIO
|
||||||
js = json.load(StringIO(d.to_json()))
|
js = json.load(StringIO(d.to_json()))
|
||||||
JsonSvg(js, orientation, d.name)
|
if file_format == 'svg':
|
||||||
|
JsonSvg(js, orientation, d.name)
|
||||||
|
elif file_format == 'png':
|
||||||
|
JsonPng(js, orientation, pngname)
|
||||||
|
|
||||||
|
|
||||||
def fetch_files():
|
def fetch_files():
|
||||||
|
@ -142,7 +146,7 @@ def main(args=sys.argv):
|
||||||
parser.add_argument('--format',
|
parser.add_argument('--format',
|
||||||
help='The format to generate. Default: svg',
|
help='The format to generate. Default: svg',
|
||||||
default='svg',
|
default='svg',
|
||||||
choices=['svg', 'json'])
|
choices=['svg', 'png', 'json'])
|
||||||
|
|
||||||
ns = parser.parse_args(args[1:])
|
ns = parser.parse_args(args[1:])
|
||||||
if ns.verbose:
|
if ns.verbose:
|
||||||
|
|
|
@ -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)
|
|
@ -17,14 +17,16 @@ import xdg.BaseDirectory
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from .config import Config
|
from .config import Config
|
||||||
from tuhi.svg import JsonSvg
|
from tuhi.export import JsonSvg, JsonPng
|
||||||
|
|
||||||
import gi
|
import gi
|
||||||
gi.require_version("Gtk", "3.0")
|
gi.require_version("Gtk", "3.0")
|
||||||
from gi.repository import GObject, Gtk, GdkPixbuf, Gdk # NOQA
|
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')
|
@Gtk.Template(resource_path='/org/freedesktop/Tuhi/ui/Drawing.ui')
|
||||||
|
@ -40,7 +42,8 @@ class Drawing(Gtk.EventBox):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.orientation = Config().orientation
|
self.orientation = Config().orientation
|
||||||
Config().connect('notify::orientation', self._on_orientation_changed)
|
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.json_data = json_data
|
||||||
self._zoom = zoom
|
self._zoom = zoom
|
||||||
|
@ -56,8 +59,12 @@ class Drawing(Gtk.EventBox):
|
||||||
self.redraw()
|
self.redraw()
|
||||||
|
|
||||||
def process_svg(self):
|
def process_svg(self):
|
||||||
path = os.fspath(Path(DATA_PATH, f'{self.json_data["timestamp"]}.svg'))
|
path = os.fspath(Path(SVG_DATA_PATH, f'{self.json_data["timestamp"]}.svg'))
|
||||||
self.svg = JsonSvg(self.json_data, self.orientation, path)
|
self.svg = JsonSvg(
|
||||||
|
self.json_data,
|
||||||
|
self.orientation,
|
||||||
|
path
|
||||||
|
)
|
||||||
width, height = -1, -1
|
width, height = -1, -1
|
||||||
if 'portrait' in self.orientation:
|
if 'portrait' in self.orientation:
|
||||||
height = 1000
|
height = 1000
|
||||||
|
@ -68,6 +75,14 @@ class Drawing(Gtk.EventBox):
|
||||||
height=height,
|
height=height,
|
||||||
preserve_aspect_ratio=True)
|
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):
|
def redraw(self):
|
||||||
ratio = self.pixbuf.get_height() / self.pixbuf.get_width()
|
ratio = self.pixbuf.get_height() / self.pixbuf.get_width()
|
||||||
base = 250 + self.zoom * 50
|
base = 250 + self.zoom * 50
|
||||||
|
@ -115,21 +130,33 @@ class Drawing(Gtk.EventBox):
|
||||||
# Translators: filter to show svg files only
|
# Translators: filter to show svg files only
|
||||||
filter_svg.set_name(_('SVG files'))
|
filter_svg.set_name(_('SVG files'))
|
||||||
filter_svg.add_pattern('*.svg')
|
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_svg)
|
||||||
|
dialog.add_filter(filter_png)
|
||||||
dialog.add_filter(filter_any)
|
dialog.add_filter(filter_any)
|
||||||
|
|
||||||
response = dialog.run()
|
response = dialog.run()
|
||||||
if response == Gtk.ResponseType.OK:
|
if response == Gtk.ResponseType.OK:
|
||||||
import shutil
|
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()
|
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()
|
dialog.destroy()
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue