Commit eb2bf7f4 authored by Fabrice Salvaire's avatar Fabrice Salvaire

SVG: fixed H V, added unit test

parent 4e8b63a4
......@@ -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)
##############################################
......
......@@ -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):
# ></svg>
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))
......
......@@ -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':
......
......@@ -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)
##############################################
......
......@@ -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')
......
####################################################################################################
#
# 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 <http://www.gnu.org/licenses/>.
#
####################################################################################################
####################################################################################################
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 = """
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
version="1.1"
viewBox="0 0 140 140"
height="140mm"
width="140mm"
>
<title id="title1">SVG Basic Demo</title>
<g id="layer1" transform="translate(0,0)" style="stroke:#000000">
<path id="path-x" d="M 20,20 h 100" />
<path id="path-y" d="M 20,20 v 100" />
<path id="path-45" d="M 20,20 l 100,100" />
<rect id="rect-0" x="20" y="20" height="10" width="10" />
<rect id="rect-x1" x="40" y="20" height="10" width="10" />
<rect id="rect-x2" x="80" y="20" height="10" width="10" />
<rect id="rect-y1" x="20" y="40" height="10" width="10" />
<rect id="rect-y2" x="20" y="80" height="10" width="10" />
<rect id="rect-bisect" x="50" y="50" height="10" width="10" />
<rect id="rect-45-2" transform="rotate(45)" x="50" y="50" height="10" width="10" />
<rect id="rect-45" transform="rotate(45,75,75)" x="70" y="70" height="10" width="10" />
<rect id="rect-30" transform="rotate(30,85,85)" x="80" y="80" height="10" width="10" />
<rect id="rect-60" transform="rotate(60,95,95)" x="90" y="90" height="10" width="10" />
</g>
</svg>
"""
####################################################################################################
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()
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment