diff --git a/Patro/Common/Xml/XmlFile.py b/Patro/Common/Xml/XmlFile.py index beb6d71d07430ad5d0c5f6cec07757d15d6b8473..5a9ea66e90e4fb14d15a571acc63a1038bec4816 100644 --- a/Patro/Common/Xml/XmlFile.py +++ b/Patro/Common/Xml/XmlFile.py @@ -39,8 +39,13 @@ class XmlFileMixin: ############################################## - def __init__(self, path): - self._path = Path(path) + def __init__(self, path, data=None): + + if path is not None: + self._path = Path(path) + else: + self._path = None + self._data = data ############################################## @@ -52,8 +57,17 @@ class XmlFileMixin: def parse(self): """Parse a XML file and return the etree""" - with open(str(self._path), 'rb') as f: - source = f.read() + + data = self._data + if data is None: + with open(str(self._path), 'rb') as f: + source = f.read() + else: + if isinstance(data, bytes): + source = data + else: + source = bytes(str(self._data).strip(), 'utf-8') + return etree.fromstring(source) ############################################## diff --git a/Patro/FileFormat/Svg/SvgFile.py b/Patro/FileFormat/Svg/SvgFile.py index 676f9d29acbeb4fb458da91468df97ac192781a6..5d630cd2444cdbccd650c74dedf55669dab1f377 100644 --- a/Patro/FileFormat/Svg/SvgFile.py +++ b/Patro/FileFormat/Svg/SvgFile.py @@ -36,6 +36,8 @@ import logging from lxml import etree +from IntervalArithmetic import Interval2D + from Patro.Common.Xml.XmlFile import XmlFileMixin from Patro.GeometryEngine.Transformation import AffineTransformation2D from . import SvgFormat @@ -308,9 +310,9 @@ class SvgFileInternal(XmlFileMixin, SvgFileMixin): ############################################## - def __init__(self, path=None): + def __init__(self, path, data=None): - super().__init__(path) + super().__init__(path, data) # Fixme: API # purpose of dispatcher, where must be state ??? @@ -330,10 +332,36 @@ class SvgFileInternal(XmlFileMixin, SvgFileMixin): # > tree = self.parse() + + svg_root = self._dispatcher.from_xml(tree) + self.on_svg_root(svg_root) + self._dispatcher.on_root(tree) ############################################## + @property + def view_box(self): + return self._view_box + + @property + def width(self): + return self._width + + @property + def height(self): + return self._height + + ############################################## + + def on_svg_root(self, svg_root): + x_inf, y_inf, x_sup, y_sup = svg_root.view_box + self._view_box = Interval2D((x_inf, x_sup), (y_inf, y_sup)) + self._width = svg_root.width + self._height = svg_root.height + + ############################################## + def on_group(self, group): self._logger.info('Group: {}\n{}'.format(group.id, group)) diff --git a/Patro/FileFormat/Svg/SvgFormat.py b/Patro/FileFormat/Svg/SvgFormat.py index 610d45ffcd7985e82deb604e47bafd2153809a18..cdefc1a5a5db419939e2b9cc6f07c0fa22a62444 100644 --- a/Patro/FileFormat/Svg/SvgFormat.py +++ b/Patro/FileFormat/Svg/SvgFormat.py @@ -486,7 +486,7 @@ class TransformAttribute(StringAttribute): elif name == 'scale': transform = AffineTransformation2D.Scale(*values) elif name == 'rotate': - angle, *vector = complete(values, 2) + angle, *vector = complete(values, 3) vector = Vector2D(vector) transform = AffineTransformation2D.RotationAt(vector, angle) elif name == 'skewX': @@ -958,7 +958,7 @@ class PathDataAttribute(StringAttribute): @classmethod def from_xml(cls, svg_path): - # cls._logger.debug('SVG path:\n'+ svg_path) + # cls._logger.info('SVG path:\n'+ svg_path) # Replace comma separator by space cleaned_svg_path = svg_path.replace(',', ' ') @@ -990,13 +990,17 @@ class PathDataAttribute(StringAttribute): i += 1 # move to first arg # else repeated instruction next_i = i + number_of_args - values = parts[i:next_i] - #! points = [Vector2D(values[2*i], values[2*i+1]) for i in range(number_of_args / 2)] - points = values - commands.append((command, points)) + args = parts[i:next_i] + commands.append((command, args)) i = next_i + # for implicit line to + if command == 'm': + command = 'l' + elif command == 'M': + command = 'L' # return commands + # Fixme: do later ??? return cls.to_geometry(commands) ############################################## @@ -1027,7 +1031,7 @@ class PathDataAttribute(StringAttribute): @classmethod def to_geometry(cls, commands): - cls._logger.info('Path:\n' + str(commands).replace('), ', '),\n ')) + # cls._logger.info('Path:\n' + str(commands).replace('), ', '),\n ')) path = None for command, args in commands: command_lower = command.lower() @@ -1038,15 +1042,18 @@ class PathDataAttribute(StringAttribute): if path is None: if command_lower != 'm': raise NameError('Path must start with m') - # Fixme: m ??? path = Path2D(args) # Vector2D() else: if command_lower == 'l': path.line_to(args, absolute=absolute) - elif command_lower == 'h': - path.horizontal_to(*args, absolute=absolute) + elif command == 'h': + path.horizontal_to(*args, absolute=False) + elif command == 'H': + path.absolute_horizontal_to(*args) elif command_lower == 'v': path.vertical_to(*args, absolute=absolute) + elif command == 'V': + path.absolute_vertical_to(*args) elif command_lower == 'c': path.cubic_to(*cls.as_vector(args), absolute=absolute) elif command_lower == 's': diff --git a/Patro/GeometryEngine/Path.py b/Patro/GeometryEngine/Path.py index d0863f2ed14a9e53d9dc554d0ef7d87ea3b9c044..6bde532155153118f6b69cc89661e1e28e462265 100644 --- a/Patro/GeometryEngine/Path.py +++ b/Patro/GeometryEngine/Path.py @@ -452,7 +452,124 @@ class PathSegment(OnePointMixin, LinearSegment): #################################################################################################### -class DirectionalSegment(LinearSegment): +class DirectionalSegmentMixin(LinearSegment): + + ############################################## + + def apply_transformation(self, transformation): + # Since a rotation will change the direction + # DirectionalSegment must be casted to PathSegment + raise NotImplementedError + + ############################################## + + @property + def geometry(self): + # Fixme: cache ??? + return Segment2D(self.start_point, self.stop_point) + +#################################################################################################### + +class AbsoluteHVSegment(DirectionalSegmentMixin): + + ############################################## + + def to_path_segment(self): + + # Fixme: duplicted + if self._index == 0 and self._radius is not None: + radius = None + close = True + else: + radius = self._radius + close = False + + path = PathSegment(self._path, self._index, self.stop_point, radius, absolute=True) + + if close: + path.close(self._radius) + + return path + +#################################################################################################### + +class AbsoluteHorizontalSegment(AbsoluteHVSegment): + + ############################################## + + def __init__(self, path, index, x, radius=None): + + super().__init__(path, index, radius) + self.x = x + self._init_absolute(False) # Fixme: mix + + ############################################## + + def __repr__(self): + return '{0}(@{1._index}, {1._x})'.format(self.__class__.__name__, self) + + ############################################## + + def clone(self, path): + return self.__class__(path, self._index, self._x, self._radius) + + ############################################## + + @property + def x(self): + return self._x + + @x.setter + def x(self, value): + self._x = float(value) + + ############################################## + + @property + def stop_point(self): + return Vector2D(self._x, self.start_point.y) + +#################################################################################################### + +class AbsoluteVerticalSegment(AbsoluteHVSegment): + + ############################################## + + def __init__(self, path, index, y, radius=None): + + super().__init__(path, index, radius) + self.y = y + self._init_absolute(False) # Fixme: mix + + ############################################## + + def __repr__(self): + return '{0}(@{1._index}, {1._y})'.format(self.__class__.__name__, self) + + ############################################## + + def clone(self, path): + return self.__class__(path, self._index, self._y, self._radius) + + ############################################## + + @property + def y(self): + return self._y + + @y.setter + def y(self, value): + self._y = float(value) + + ############################################## + + @property + def stop_point(self): + return Vector2D(self.start_point.x, self._y) + +#################################################################################################### + +class DirectionalSegment(DirectionalSegmentMixin): __angle__ = None @@ -469,13 +586,6 @@ class DirectionalSegment(LinearSegment): ############################################## - def apply_transformation(self, transformation): - # Since a rotation will change the direction - # DirectionalSegment must be casted to PathSegment - raise NotImplementedError - - ############################################## - def clone(self, path): return self.__class__(path, self._index, self._length, self._radius) @@ -503,15 +613,21 @@ class DirectionalSegment(LinearSegment): ############################################## - @property - def geometry(self): - # Fixme: cache ??? - return Segment2D(self.start_point, self.stop_point) + def to_path_segment(self): - ############################################## + if self._index == 0 and self._radius is not None: + radius = None + close = True + else: + radius = self._radius + close = False - def to_path_segment(self): - return PathSegment(self._path, self._index, self.offset, self._radius, absolute=False) + path = PathSegment(self._path, self._index, self.offset, radius, absolute=False) + + if close: + path.close(self._radius) + + return path #################################################################################################### @@ -829,7 +945,7 @@ class Path2D(Primitive2DMixin, Primitive1P): # print(part) if isinstance(part, PathSegment): part._reset_cache() - if isinstance(part, DirectionalSegment): + if isinstance(part, DirectionalSegmentMixin): # Since a rotation will change the direction # DirectionalSegment must be casted to PathSegment part = part.to_path_segment() @@ -869,47 +985,55 @@ class Path2D(Primitive2DMixin, Primitive1P): ############################################## - def horizontal_to(self, distance, radius=None, absolute=False): + def horizontal_to(self, length, radius=None, absolute=False): if absolute: - return self._add_part(PathSegment, self.__vector_cls__(distance, 0), radius, + return self._add_part(PathSegment, self.__vector_cls__(length, 0), radius, absolute=True) else: - return self._add_part(HorizontalSegment, distance, radius) + return self._add_part(HorizontalSegment, length, radius) ############################################## - def vertical_to(self, distance, radius=None, absolute=False): + def vertical_to(self, length, radius=None, absolute=False): if absolute: - return self._add_part(PathSegment, self.__vector_cls__(0, distance), radius, + return self._add_part(PathSegment, self.__vector_cls__(0, length), radius, absolute=True) else: - return self._add_part(VerticalSegment, distance, radius) + return self._add_part(VerticalSegment, length, radius) + + ############################################## + + def absolute_horizontal_to(self, x, radius=None): + return self._add_part(AbsoluteHorizontalSegment, x, radius) + + def absolute_vertical_to(self, y, radius=None): + return self._add_part(AbsoluteVerticalSegment, y, radius) ############################################## - def north_to(self, distance, radius=None): - return self._add_part(NorthSegment, distance, radius) + def north_to(self, length, radius=None): + return self._add_part(NorthSegment, length, radius) - def south_to(self, distance, radius=None): - return self._add_part(SouthSegment, distance, radius) + def south_to(self, length, radius=None): + return self._add_part(SouthSegment, length, radius) - def west_to(self, distance, radius=None): - return self._add_part(WestSegment, distance, radius) + def west_to(self, length, radius=None): + return self._add_part(WestSegment, length, radius) - def east_to(self, distance, radius=None): - return self._add_part(EastSegment, distance, radius) + def east_to(self, length, radius=None): + return self._add_part(EastSegment, length, radius) - def north_east_to(self, distance, radius=None): - return self._add_part(NorthEastSegment, distance, radius) + def north_east_to(self, length, radius=None): + return self._add_part(NorthEastSegment, length, radius) - def south_east_to(self, distance, radius=None): - return self._add_part(SouthEastSegment, distance, radius) + def south_east_to(self, length, radius=None): + return self._add_part(SouthEastSegment, length, radius) - def north_west_to(self, distance, radius=None): - return self._add_part(NorthWestSegment, distance, radius) + def north_west_to(self, length, radius=None): + return self._add_part(NorthWestSegment, length, radius) - def south_west_to(self, distance, radius=None): - return self._add_part(SouthWestSegment, distance, radius) + def south_west_to(self, length, radius=None): + return self._add_part(SouthWestSegment, length, radius) ############################################## diff --git a/examples/file-format/svg/test-svg-import.py b/examples/file-format/svg/test-svg-import.py index 52df5ebbc6440f9a21aeda208b047f009245a063..56f8baa27e270f1fc72ee300009a618104866eac 100644 --- a/examples/file-format/svg/test-svg-import.py +++ b/examples/file-format/svg/test-svg-import.py @@ -32,6 +32,7 @@ if not use_qt: from Patro.FileFormat.Svg import SvgFormat from Patro.FileFormat.Svg.SvgFile import SvgFile, SvgFileInternal +from Patro.GeometryEngine.Transformation import AffineTransformation2D from Patro.GraphicEngine.GraphicScene.GraphicStyle import GraphicPathStyle, GraphicBezierStyle from Patro.GraphicEngine.Painter.QtPainter import QtScene from Patro.GraphicStyle import Colors, StrokeStyle, CapStyle @@ -82,6 +83,12 @@ class SceneImporter(SvgFileInternal): ############################################## + def on_svg_root(self, svg_root): + super().on_svg_root(svg_root) + self._screen_transformation = AffineTransformation2D.Screen(self._view_box.y.sup) + + ############################################## + def on_group(self, group): # self._logger.info('Group: {}\n{}'.format(group.id, group)) pass @@ -90,6 +97,17 @@ class SceneImporter(SvgFileInternal): def on_graphic_item(self, item): + # if item.id not in ( + # 'path12061', + # 'path12069', + # 'path12077', + # 'path12085', + # ): + # return + # from Patro.FileFormat.Svg.SvgFormat import PathDataAttribute + # self._logger.info(str(item.id) + '\n' + str(item.path_data)) + # item.path_data = PathDataAttribute.to_geometry(item.path_data) + state = self._dispatcher.state.clone().merge(item) self._logger.info('Item: {}\n{}'.format(item.id, item)) # self._logger.info('Item State:\n' + str(state)) @@ -107,22 +125,26 @@ class SceneImporter(SvgFileInternal): # fill_color=fill_color, ) - transformation = state.transform + transformation = self._screen_transformation * state.transform self._logger.info('Sate Transform\n' + str(transformation)) if isinstance(item, SvgFormat.Path): # and state.stroke_dasharray is None path = item.path_data if path is not None: # Fixme: + # self._logger.info(str(item.id) + '\n' + str(path[0].geometry)) path = path.transform(transformation) for part in path: self._update_bounding_box(part) + # self._logger.info(str(item.id) + '\n' + str(path[0].geometry)) self._scene.add_path(path, path_style) elif isinstance(item, SvgFormat.Rect): path = item.geometry + path = path.transform(transformation) self._scene.add_path(path, path_style) #################################################################################################### +# svg_path = find_data_path('svg', 'basic-demo-2.by-hand.svg') svg_path = find_data_path('svg', 'demo.svg') # svg_path = find_data_path('patterns-svg', 'veravenus-little-bias-dress.pattern-a0.svg') # svg_path = find_data_path('patterns-svg', 'veravenus-little-bias-dress.pattern-a0.no-text-zaggy.svg') diff --git a/unit-test/FileFormat/test_svg.py b/unit-test/FileFormat/test_svg.py new file mode 100644 index 0000000000000000000000000000000000000000..0e1cc0a11d76266a8123009dd098ee38e46a89c5 --- /dev/null +++ b/unit-test/FileFormat/test_svg.py @@ -0,0 +1,220 @@ +#################################################################################################### +# +# Patro - A Python library to make patterns for fashion design +# Copyright (C) 2019 Salvaire Fabrice +# +# 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 3 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. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +#################################################################################################### + +#################################################################################################### + +import logging +import unittest +from pathlib import Path + +from Patro.Common.Logging import Logging +Logging.setup_logging() + +from IntervalArithmetic import Interval2D + +from Patro.FileFormat.Svg import SvgFormat +from Patro.FileFormat.Svg.SvgFile import SvgFile, SvgFileInternal +from Patro.GeometryEngine.Transformation import AffineTransformation2D +from Patro.GeometryEngine.Vector import Vector2D +from PatroExample import find_data_path + +#################################################################################################### + +_module_logger = logging.getLogger(__name__) + +#################################################################################################### + +svg_data = """ + + + SVG Basic Demo + + + + + + + + + + + + + + + + + + +""" + +#################################################################################################### + +class SceneImporter(SvgFileInternal): + + # Fixme: duplicated code + + _logger = _module_logger.getChild('SceneImporter') + + ############################################## + + def __init__(self, svg_path, data=None): + + self._scene = {} + self._bounding_box = None + + super().__init__(svg_path, data) + + ############################################## + + def __len__(self): + return len(self._scene) + + def __getitem__(self, name): + return self._scene[name] + + @property + def scene(self): + return self._scene + + @property + def bounding_box(self): + return self._bounding_box + + ############################################## + + def _add_to_scene(self, name, geometry): + self._scene[name] = geometry + + ############################################## + + def _update_bounding_box(self, item): + + interval = item.bounding_box + if self._bounding_box is None: + self._bounding_box = interval + else: + self._bounding_box |= interval + + ############################################## + + def on_svg_root(self, svg_root): + super().on_svg_root(svg_root) + self._screen_transformation = AffineTransformation2D.Screen(self._view_box.y.sup) + + ############################################## + + def on_group(self, group): + # self._logger.info('Group: {}\n{}'.format(group.id, group)) + pass + + ############################################## + + def on_graphic_item(self, item): + + state = self._dispatcher.state.clone().merge(item) + self._logger.info('Item: {}\n{}'.format(item.id, item)) + # self._logger.info('Item State:\n' + str(state)) + + transformation = state.transform + # transformation = self._screen_transformation * state.transform + self._logger.info('Sate Transform\n' + str(transformation)) + if isinstance(item, SvgFormat.Path): + path = item.path_data + if path is not None: # Fixme: + path = path.transform(transformation) + self._update_bounding_box(path) + self._add_to_scene(item.id, path) + elif isinstance(item, SvgFormat.Rect): + path = item.geometry + self._add_to_scene(item.id, path) + +#################################################################################################### + +def count_svg_tags(svg_data): + + tag_counter = {} + for line in svg_data.splitlines(): + line = line.strip() + if line.startswith('<'): + position = line.find(' ') + tag = line[1:position] + if tag[0].isalpha(): + tag_counter.setdefault(tag, 0) + tag_counter[tag] += 1 + + return tag_counter + +#################################################################################################### + +class TestLine2D(unittest.TestCase): + + ############################################## + + def test(self): + + svg_path = find_data_path('svg', 'basic-demo-2.by-hand.svg') + #data = None + data = svg_data + + scene_importer = SceneImporter(svg_path, data=data) + scene = scene_importer.scene + + interval = Interval2D((20, 120), (20, 120)) + self.assertEqual(scene_importer.bounding_box, interval) + + tag_counter = count_svg_tags(data) + number_of_items = sum([tag_counter[x] for x in ('path', 'rect')]) + self.assertEqual(len(scene_importer), number_of_items) + + # for name, item in scene.items(): + # print(name, item) + + origin = Vector2D(20, 20) + for name in ('path-x', 'path-y', 'path-45'): + self.assertEqual(scene[name].p0, origin) + + for name, p0 in ( + ('rect-0', (20, 20)), + ('rect-x1', (40, 20)), + ('rect-x2', (80, 20)), + ('rect-y1', (20, 40)), + ('rect-y2', (20, 80)), + ('rect-bisect', (50, 50)), + # + # ('rect-45-2', (50, 50)), + # ('rect-45', (70, 70)), + # ('rect-30', (80, 80)), + # ('rect-60', (90, 90)), + ): + self.assertEqual(scene[name].p0, Vector2D(p0)) + +#################################################################################################### + +if __name__ == '__main__': + + unittest.main()