Improve and fix behaviour when collapsing straight paths segments (#146)

* Do not collapse straight path segments in paths that have intermediate markers (see #145). The intermediate nodes might be unnecessary for the shape of the path, but their markers would be lost.
* Collapse subpaths of moveto `m` and lineto `l` commands if they have the same direction (before we only collapsed horizontal/vertical `h`/`v` lineto commands)
* Attempt to collapse lineto `l` commands into a preceding moveto `m` command (these are then called "implicit lineto commands")
* Preserve empty path segments if they have `stroke-linecap` set to `round` or `square`. They render no visible line but a tiny dot or square.
This commit is contained in:
Eduard Braun 2017-05-18 00:53:25 +02:00 committed by GitHub
parent 75bacbc8e6
commit cc592c8e8a
6 changed files with 194 additions and 85 deletions

View file

@ -403,7 +403,16 @@ default_properties = { # excluded all properties with 'auto' as default
}
def isSameSign(a, b): return (a <= 0 and b <= 0) or (a >= 0 and b >= 0)
def is_same_sign(a, b):
return (a <= 0 and b <= 0) or (a >= 0 and b >= 0)
def is_same_direction(x1, y1, x2, y2):
if is_same_sign(x1, x2) and is_same_sign(y1, y2):
diff = y1/x1 - y2/x2
return scouringContext.plus(1 + diff) == 1
else:
return False
scinumber = re.compile(r"[-+]?(\d*\.?)?\d+[eE][-+]?\d+")
@ -2044,10 +2053,23 @@ def cleanPath(element, options):
# this gets the parser object from svg_regex.py
oldPathStr = element.getAttribute('d')
path = svg_parser.parse(oldPathStr)
style = _getStyle(element)
# This determines whether the stroke has round linecaps. If it does,
# we do not want to collapse empty segments, as they are actually rendered.
withRoundLineCaps = element.getAttribute('stroke-linecap') == 'round'
# This determines whether the stroke has round or square linecaps. If it does, we do not want to collapse empty
# segments, as they are actually rendered (as circles or squares with diameter/dimension matching the path-width).
has_round_or_square_linecaps = (
element.getAttribute('stroke-linecap') in ['round', 'square']
or 'stroke-linecap' in style and style['stroke-linecap'] in ['round', 'square']
)
# This determines whether the stroke has intermediate markers. If it does, we do not want to collapse
# straight segments running in the same direction, as markers are rendered on the intermediate nodes.
has_intermediate_markers = (
element.hasAttribute('marker')
or element.hasAttribute('marker-mid')
or 'marker' in style
or 'marker-mid' in style
)
# The first command must be a moveto, and whether it's relative (m)
# or absolute (M), the first set of coordinates *is* absolute. So
@ -2057,7 +2079,7 @@ def cleanPath(element, options):
# Reuse the data structure 'path', since we're not adding or removing subcommands.
# Also reuse the coordinate lists since we're not adding or removing any.
x = y = 0
for pathIndex in range(0, len(path)):
for pathIndex in range(len(path)):
cmd, data = path[pathIndex] # Changes to cmd don't get through to the data structure
i = 0
# adjust abs to rel
@ -2158,8 +2180,8 @@ def cleanPath(element, options):
# remove empty segments
# Reuse the data structure 'path' and the coordinate lists, even if we're
# deleting items, because these deletions are relatively cheap.
if not withRoundLineCaps:
for pathIndex in range(0, len(path)):
if not has_round_or_square_linecaps:
for pathIndex in range(len(path)):
cmd, data = path[pathIndex]
i = 0
if cmd in ['m', 'l', 't']:
@ -2253,26 +2275,25 @@ def cleanPath(element, options):
prevData = []
newPath = []
for (cmd, data) in path:
# flush the previous command if it is not the same type as the current command
if prevCmd != '':
if cmd != prevCmd or cmd == 'm':
newPath.append((prevCmd, prevData))
prevCmd = ''
prevData = []
# if the previous and current commands are the same type,
# or the previous command is moveto and the current is lineto, collapse,
# but only if they are not move commands (since move can contain implicit lineto commands)
if (cmd == prevCmd or (cmd == 'l' and prevCmd == 'm')) and cmd != 'm':
prevData.extend(data)
# save last command and data
else:
if prevCmd == '':
# initialize with current path cmd and data
prevCmd = cmd
prevData = data
else:
# collapse if
# - cmd is not moveto (explicit moveto commands are not drawn)
# - the previous and current commands are the same type,
# - the previous command is moveto and the current is lineto
# (subsequent moveto pairs are treated as implicit lineto commands)
if cmd != 'm' and (cmd == prevCmd or (cmd == 'l' and prevCmd == 'm')):
prevData.extend(data)
# else flush the previous command if it is not the same type as the current command
else:
newPath.append((prevCmd, prevData))
prevCmd = cmd
prevData = data
# flush last command and data
if prevCmd != '':
newPath.append((prevCmd, prevData))
newPath.append((prevCmd, prevData))
path = newPath
# convert to shorthand path segments where possible
@ -2396,22 +2417,52 @@ def cleanPath(element, options):
newPath.append((cmd, data))
path = newPath
# for each h or v, collapse unnecessary coordinates that run in the same direction
# i.e. "h-100-100" becomes "h-200" but "h300-100" does not change
# For each m, l, h or v, collapse unnecessary coordinates that run in the same direction
# i.e. "h-100-100" becomes "h-200" but "h300-100" does not change.
# If the path has intermediate markers we have to preserve intermediate nodes, though.
# Reuse the data structure 'path', since we're not adding or removing subcommands.
# Also reuse the coordinate lists, even if we're deleting items, because these
# deletions are relatively cheap.
for pathIndex in range(1, len(path)):
cmd, data = path[pathIndex]
if cmd in ['h', 'v'] and len(data) > 1:
coordIndex = 1
while coordIndex < len(data):
if isSameSign(data[coordIndex - 1], data[coordIndex]):
data[coordIndex - 1] += data[coordIndex]
del data[coordIndex]
_num_path_segments_removed += 1
else:
coordIndex += 1
if not has_intermediate_markers:
for pathIndex in range(len(path)):
cmd, data = path[pathIndex]
# h / v expects only one parameter and we start drawing with the first (so we need at least 2)
if cmd in ['h', 'v'] and len(data) >= 2:
coordIndex = 0
while coordIndex+1 < len(data):
if is_same_sign(data[coordIndex], data[coordIndex+1]):
data[coordIndex] += data[coordIndex+1]
del data[coordIndex+1]
_num_path_segments_removed += 1
else:
coordIndex += 1
# l expects two parameters and we start drawing with the first (so we need at least 4)
elif cmd == 'l' and len(data) >= 4:
coordIndex = 0
while coordIndex+2 < len(data):
if is_same_direction(*data[coordIndex:coordIndex+4]):
data[coordIndex] += data[coordIndex+2]
data[coordIndex+1] += data[coordIndex+3]
del data[coordIndex+2] # delete the next two elements
del data[coordIndex+2]
_num_path_segments_removed += 1
else:
coordIndex += 2
# m expects two parameters but we have to skip the first pair as it's not drawn (so we need at least 6)
elif cmd == 'm' and len(data) >= 6:
coordIndex = 2
while coordIndex+2 < len(data):
if is_same_direction(*data[coordIndex:coordIndex+4]):
data[coordIndex] += data[coordIndex+2]
data[coordIndex+1] += data[coordIndex+3]
del data[coordIndex+2] # delete the next two elements
del data[coordIndex+2]
_num_path_segments_removed += 1
else:
coordIndex += 2
# it is possible that we have consecutive h, v, c, t commands now
# so again collapse all consecutive commands of the same type into one command
@ -2542,7 +2593,7 @@ def controlPoints(cmd, data):
"""
cmd = cmd.lower()
if cmd in ['c', 's', 'q']:
indices = range(0, len(data))
indices = range(len(data))
if cmd == 'c': # c: (x1 y1 x2 y2 x y)+
return [(index % 6) < 4 for index in indices]
elif cmd in ['s', 'q']: # s: (x2 y2 x y)+ q: (x1 y1 x y)+