SVG: Refactor, move utilities to module

Also cover with unit test.
This commit is contained in:
Sergey Sharybin 2019-09-19 12:50:09 +02:00
parent 9d54d44eb9
commit 7f97ceb050
3 changed files with 177 additions and 88 deletions

View File

@ -26,93 +26,17 @@ import bpy
from mathutils import Vector, Matrix
from . import svg_colors
from .svg_util import (srgb_to_linearrgb,
from .svg_util import (units,
srgb_to_linearrgb,
check_points_equal,
parse_array_of_floats)
parse_array_of_floats,
read_float)
#### Common utilities ####
# TODO: "em" and "ex" aren't actually supported
SVGUnits = {"": 1.0,
"px": 1.0,
"in": 90.0,
"mm": 90.0 / 25.4,
"cm": 90.0 / 2.54,
"pt": 1.25,
"pc": 15.0,
"em": 1.0,
"ex": 1.0,
"INVALID": 1.0, # some DocBook files contain this
}
SVGEmptyStyles = {'useFill': None,
'fill': None}
def SVGParseFloat(s, i=0):
"""
Parse first float value from string
Returns value as string
"""
start = i
n = len(s)
token = ''
# Skip leading whitespace characters
while i < n and (s[i].isspace() or s[i] == ','):
i += 1
if i == n:
return None, i
# Read sign
if s[i] == '-':
token += '-'
i += 1
elif s[i] == '+':
i += 1
# Read integer part
if s[i].isdigit():
while i < n and s[i].isdigit():
token += s[i]
i += 1
# Fractional part
if i < n and s[i] == '.':
token += '.'
i += 1
if s[i].isdigit():
while i < n and s[i].isdigit():
token += s[i]
i += 1
elif s[i].isspace() or s[i] == ',':
# Inkscape sometimes uses weird float format with missed
# fractional part after dot. Suppose zero fractional part
# for this case
pass
else:
raise Exception('Invalid float value near ' + s[start:start + 10])
# Degree
if i < n and (s[i] == 'e' or s[i] == 'E'):
token += s[i]
i += 1
if s[i] == '+' or s[i] == '-':
token += s[i]
i += 1
if s[i].isdigit():
while i < n and s[i].isdigit():
token += s[i]
i += 1
else:
raise Exception('Invalid float value near ' + s[start:start + 10])
return token, i
def SVGCreateCurve(context):
"""
@ -153,14 +77,14 @@ def SVGParseCoord(coord, size):
Needed to handle coordinates set in cm, mm, inches.
"""
token, last_char = SVGParseFloat(coord)
token, last_char = read_float(coord)
val = float(token)
unit = coord[last_char:].strip() # strip() in case there is a space
if unit == '%':
return float(size) / 100.0 * val
else:
return val * SVGUnits[unit]
return val * units[unit]
return val
@ -493,7 +417,7 @@ class SVGPathData:
elif c.lower() in commands:
tokens.append(c)
elif c in ['-', '.'] or c.isdigit():
token, last_char = SVGParseFloat(d, i)
token, last_char = read_float(d, i)
tokens.append(token)
# in most cases len(token) and (last_char - i) are the same
@ -1824,7 +1748,7 @@ class SVGGeometrySVG(SVGGeometryContainer):
if self._node.getAttribute('height'):
raw_height = self._node.getAttribute('height')
token, last_char = SVGParseFloat(raw_height)
token, last_char = read_float(raw_height)
document_height = float(token)
unit = raw_height[last_char:].strip()
@ -1837,7 +1761,7 @@ class SVGGeometrySVG(SVGGeometryContainer):
unitscale = document_height / (viewbox[3] - viewbox[1])
#convert units to BU:
unitscale = unitscale * SVGUnits[unit] / 90 * 1000 / 39.3701
unitscale = unitscale * units[unit] / 90 * 1000 / 39.3701
#apply blender unit scale:
unitscale = unitscale / bpy.context.scene.unit_settings.scale_length

View File

@ -20,6 +20,20 @@
import re
units = {"": 1.0,
"px": 1.0,
"in": 90.0,
"mm": 90.0 / 25.4,
"cm": 90.0 / 2.54,
"pt": 1.25,
"pc": 15.0,
"em": 1.0,
"ex": 1.0,
"INVALID": 1.0, # some DocBook files contain this
}
def srgb_to_linearrgb(c):
if c < 0.04045:
return 0.0 if c < 0.0 else c * (1.0 / 12.92)
@ -48,6 +62,90 @@ def parse_array_of_floats(text):
return [value_to_float(v[0]) for v in elements]
def read_float(s: str, i: int = 0):
"""
Reads floating point value from a string. Parsing starts at the given index.
Returns the value itself (as a string) and index of first character after the value.
"""
start = i
n = len(s)
token = ''
# Skip leading whitespace characters
while i < n and (s[i].isspace() or s[i] == ','):
i += 1
if i == n:
return "0", i
# Read sign
if s[i] == '-':
token += '-'
i += 1
elif s[i] == '+':
i += 1
# Read integer part
if s[i].isdigit():
while i < n and s[i].isdigit():
token += s[i]
i += 1
# Fractional part
if i < n and s[i] == '.':
token += '.'
i += 1
if i < n and s[i].isdigit():
while i < n and s[i].isdigit():
token += s[i]
i += 1
elif i == n or s[i].isspace() or s[i] == ',':
# Inkscape sometimes uses weird float format with missed
# fractional part after dot. Suppose zero fractional part
# for this case
pass
else:
raise Exception('Invalid float value near ' + s[start:start + 10])
# Degree
if i < n and (s[i] == 'e' or s[i] == 'E'):
token += s[i]
i += 1
if s[i] == '+' or s[i] == '-':
token += s[i]
i += 1
if s[i].isdigit():
while i < n and s[i].isdigit():
token += s[i]
i += 1
else:
raise Exception('Invalid float value near ' + s[start:start + 10])
return token, i
def parse_coord(coord, size):
"""
Parse coordinate component to common basis
Needed to handle coordinates set in cm, mm, inches.
"""
token, last_char = read_float(coord)
val = float(token)
unit = coord[last_char:].strip() # strip() in case there is a space
if unit == '%':
return float(size) / 100.0 * val
else:
return val * units[unit]
return val
def value_to_float(value_encoded: str):
"""
A simple wrapper around float() which supports empty strings (which are converted to 0).
@ -55,4 +153,3 @@ def value_to_float(value_encoded: str):
if len(value_encoded) == 0:
return 0
return float(value_encoded)

View File

@ -24,9 +24,9 @@
# XXX Not really nice, but that hack is needed to allow execution of that test
# from both automated CTest and by directly running the file manually...
if __name__ == '__main__':
from svg_util import parse_array_of_floats
from svg_util import (parse_array_of_floats, read_float, parse_coord,)
else:
from .svg_util import parse_array_of_floats
from .svg_util import (parse_array_of_floats, read_float, parse_coord,)
import unittest
@ -79,5 +79,73 @@ class ParseArrayOfFloatsTest(unittest.TestCase):
self.assertEqual(parse_array_of_floats("2.75,8.5"), [2.75, 8.5])
class ReadFloatTest(unittest.TestCase):
def test_empty(self):
value, endptr = read_float("", 0)
self.assertEqual(value, "0")
self.assertEqual(endptr, 0)
def test_empty_spaces(self):
value, endptr = read_float(" ", 0)
self.assertEqual(value, "0")
self.assertEqual(endptr, 4)
def test_single_value(self):
value, endptr = read_float("1.2", 0)
self.assertEqual(value, "1.2")
self.assertEqual(endptr, 3)
def test_scientific_value(self):
value, endptr = read_float("1.2e+3", 0)
self.assertEqual(value, "1.2e+3")
self.assertEqual(endptr, 6)
def test_scientific_value_no_sign(self):
value, endptr = read_float("1.2e3", 0)
self.assertEqual(value, "1.2e3")
self.assertEqual(endptr, 5)
def test_middle(self):
value, endptr = read_float("1.2 3.4 5.6", 3)
self.assertEqual(value, "3.4")
self.assertEqual(endptr, 8)
def test_comma(self):
value, endptr = read_float("1.2 ,,3.4 5.6", 3)
self.assertEqual(value, "3.4")
self.assertEqual(endptr, 10)
def test_not_a_number(self):
# TODO(sergey): Make this more concrete.
with self.assertRaises(Exception):
value, endptr = read_float("1.2eV", 3)
def test_missing_fractional(self):
value, endptr = read_float("1.", 0)
self.assertEqual(value, "1.")
self.assertEqual(endptr, 2)
value, endptr = read_float("2. 3", 0)
self.assertEqual(value, "2.")
self.assertEqual(endptr, 2)
class ParseCoordTest(unittest.TestCase):
def test_empty(self):
self.assertEqual(parse_coord("", 200), 0)
def test_empty_spaces(self):
self.assertEqual(parse_coord(" ", 200), 0)
def test_no_units(self):
self.assertEqual(parse_coord("1.2", 200), 1.2)
def test_unit_cm(self):
self.assertAlmostEqual(parse_coord("1.2cm", 200), 42.51968503937008)
def test_unit_percentage(self):
self.assertEqual(parse_coord("1.2%", 200), 2.4)
if __name__ == '__main__':
unittest.main(verbosity=2)