357 lines
12 KiB
Python
357 lines
12 KiB
Python
"""Calculate bounding boxes of SVG tags."""
|
||
|
||
from math import atan, atan2, cos, inf, isinf, pi, radians, sin, sqrt, tan
|
||
|
||
from .path import PATH_LETTERS
|
||
from .utils import normalize, point
|
||
|
||
EMPTY_BOUNDING_BOX = inf, inf, 0, 0
|
||
|
||
|
||
def bounding_box(svg, node, font_size, stroke):
|
||
"""Bounding box for any node."""
|
||
if node.tag not in BOUNDING_BOX_METHODS:
|
||
return EMPTY_BOUNDING_BOX
|
||
box = BOUNDING_BOX_METHODS[node.tag](svg, node, font_size)
|
||
if not is_valid_bounding_box(box):
|
||
return EMPTY_BOUNDING_BOX
|
||
if stroke and node.tag != 'g' and any(svg.get_paint(node.get('stroke'))):
|
||
stroke_width = svg.length(node.get('stroke-width', '1px'), font_size)
|
||
box = (
|
||
box[0] - stroke_width / 2, box[1] - stroke_width / 2,
|
||
box[2] + stroke_width, box[3] + stroke_width)
|
||
return box
|
||
|
||
|
||
def bounding_box_rect(svg, node, font_size):
|
||
"""Bounding box for rect node."""
|
||
x, y = svg.point(node.get('x'), node.get('y'), font_size)
|
||
width, height = svg.point(
|
||
node.get('width'), node.get('height'), font_size)
|
||
return x, y, width, height
|
||
|
||
|
||
def bounding_box_circle(svg, node, font_size):
|
||
"""Bounding box for circle node."""
|
||
cx, cy = svg.point(node.get('cx'), node.get('cy'), font_size)
|
||
r = svg.length(node.get('r'), font_size)
|
||
return cx - r, cy - r, 2 * r, 2 * r
|
||
|
||
|
||
def bounding_box_ellipse(svg, node, font_size):
|
||
"""Bounding box for ellipse node."""
|
||
rx, ry = svg.point(node.get('rx'), node.get('ry'), font_size)
|
||
cx, cy = svg.point(node.get('cx'), node.get('cy'), font_size)
|
||
return cx - rx, cy - ry, 2 * rx, 2 * ry
|
||
|
||
|
||
def bounding_box_line(svg, node, font_size):
|
||
"""Bounding box for line node."""
|
||
x1, y1 = svg.point(node.get('x1'), node.get('y1'), font_size)
|
||
x2, y2 = svg.point(node.get('x2'), node.get('y2'), font_size)
|
||
x, y = min(x1, x2), min(y1, y2)
|
||
width, height = max(x1, x2) - x, max(y1, y2) - y
|
||
return x, y, width, height
|
||
|
||
|
||
def bounding_box_polyline(svg, node, font_size):
|
||
"""Bounding box for polyline node."""
|
||
bounding_box = EMPTY_BOUNDING_BOX
|
||
points = []
|
||
normalized_points = normalize(node.get('points', ''))
|
||
while normalized_points:
|
||
x, y, normalized_points = point(svg, normalized_points, font_size)
|
||
points.append((x, y))
|
||
return extend_bounding_box(bounding_box, points)
|
||
|
||
|
||
def bounding_box_path(svg, node, font_size):
|
||
"""Bounding box for path node."""
|
||
path_data = node.get('d', '')
|
||
|
||
# Normalize path data for correct parsing
|
||
for letter in PATH_LETTERS:
|
||
path_data = path_data.replace(letter, f' {letter} ')
|
||
path_data = normalize(path_data)
|
||
|
||
bounding_box = EMPTY_BOUNDING_BOX
|
||
previous_x = 0
|
||
previous_y = 0
|
||
letter = 'M' # Move as default
|
||
while path_data:
|
||
path_data = path_data.strip()
|
||
if path_data.split(' ', 1)[0] in PATH_LETTERS:
|
||
letter, path_data = (f'{path_data} ').split(' ', 1)
|
||
|
||
if letter in 'aA':
|
||
# Elliptical arc curve
|
||
rx, ry, path_data = point(svg, path_data, font_size)
|
||
rotation, path_data = path_data.split(' ', 1)
|
||
rotation = radians(float(rotation))
|
||
|
||
# The large and sweep values are not always separated from the
|
||
# following values, here is the crazy parser
|
||
large, path_data = path_data[0], path_data[1:].strip()
|
||
while not large[-1].isdigit():
|
||
large, path_data = large + path_data[0], path_data[1:].strip()
|
||
sweep, path_data = path_data[0], path_data[1:].strip()
|
||
while not sweep[-1].isdigit():
|
||
sweep, path_data = sweep + path_data[0], path_data[1:].strip()
|
||
|
||
large, sweep = bool(int(large)), bool(int(sweep))
|
||
|
||
x, y, path_data = point(svg, path_data, font_size)
|
||
|
||
# Relative coordinate, convert to absolute
|
||
if letter == 'a':
|
||
x += previous_x
|
||
y += previous_y
|
||
|
||
# Extend bounding box with start and end coordinates
|
||
arc_bounding_box = _bounding_box_elliptical_arc(
|
||
previous_x, previous_y, rx, ry, rotation, large, sweep, x, y)
|
||
x1, y1, width, height = arc_bounding_box
|
||
x2 = x1 + width
|
||
y2 = y1 + height
|
||
points = (x1, y1), (x2, y2)
|
||
bounding_box = extend_bounding_box(bounding_box, points)
|
||
previous_x = x
|
||
previous_y = y
|
||
|
||
elif letter in 'cC':
|
||
# Curve
|
||
x1, y1, path_data = point(svg, path_data, font_size)
|
||
x2, y2, path_data = point(svg, path_data, font_size)
|
||
x, y, path_data = point(svg, path_data, font_size)
|
||
|
||
# Relative coordinates, convert to absolute
|
||
if letter == 'c':
|
||
x1 += previous_x
|
||
y1 += previous_y
|
||
x2 += previous_x
|
||
y2 += previous_y
|
||
x += previous_x
|
||
y += previous_y
|
||
|
||
# Extend bounding box with all coordinates
|
||
bounding_box = extend_bounding_box(
|
||
bounding_box, ((x1, y1), (x2, y2), (x, y)))
|
||
previous_x = x
|
||
previous_y = y
|
||
|
||
elif letter in 'hH':
|
||
# Horizontal line
|
||
x, path_data = (f'{path_data} ').split(' ', 1)
|
||
x, _ = svg.point(x, 0, font_size)
|
||
|
||
# Relative coordinate, convert to absolute
|
||
if letter == 'h':
|
||
x += previous_x
|
||
|
||
# Extend bounding box with coordinate
|
||
bounding_box = extend_bounding_box(
|
||
bounding_box, ((x, previous_y),))
|
||
previous_x = x
|
||
|
||
elif letter in 'lLmMtT':
|
||
# Line/Move/Smooth quadratic curve
|
||
x, y, path_data = point(svg, path_data, font_size)
|
||
|
||
# Relative coordinate, convert to absolute
|
||
if letter in 'lmt':
|
||
x += previous_x
|
||
y += previous_y
|
||
|
||
# Extend bounding box with coordinate
|
||
bounding_box = extend_bounding_box(bounding_box, ((x, y),))
|
||
previous_x = x
|
||
previous_y = y
|
||
|
||
elif letter in 'qQsS':
|
||
# Quadratic curve/Smooth curve
|
||
x1, y1, path_data = point(svg, path_data, font_size)
|
||
x, y, path_data = point(svg, path_data, font_size)
|
||
|
||
# Relative coordinates, convert to absolute
|
||
if letter in 'qs':
|
||
x1 += previous_x
|
||
y1 += previous_y
|
||
x += previous_x
|
||
y += previous_y
|
||
|
||
# Extend bounding box with coordinates
|
||
bounding_box = extend_bounding_box(
|
||
bounding_box, ((x1, y1), (x, y)))
|
||
previous_x = x
|
||
previous_y = y
|
||
|
||
elif letter in 'vV':
|
||
# Vertical line
|
||
y, path_data = (f'{path_data} ').split(' ', 1)
|
||
_, y = svg.point(0, y, font_size)
|
||
|
||
# Relative coordinate, convert to absolute
|
||
if letter == 'v':
|
||
y += previous_y
|
||
|
||
# Extend bounding box with coordinate
|
||
bounding_box = extend_bounding_box(
|
||
bounding_box, ((previous_x, y),))
|
||
previous_y = y
|
||
|
||
path_data = path_data.strip()
|
||
|
||
return bounding_box
|
||
|
||
|
||
def bounding_box_text(svg, node, font_size):
|
||
"""Bounding box for text node."""
|
||
return node.get('text_bounding_box')
|
||
|
||
|
||
def bounding_box_g(svg, node, font_size):
|
||
"""Bounding box for g node."""
|
||
bounding_box = EMPTY_BOUNDING_BOX
|
||
for child in node:
|
||
child_bounding_box = svg.calculate_bounding_box(child, font_size)
|
||
if is_valid_bounding_box(child_bounding_box):
|
||
minx, miny, width, height = child_bounding_box
|
||
maxx, maxy = minx + width, miny + height
|
||
bounding_box = extend_bounding_box(
|
||
bounding_box, ((minx, miny), (maxx, maxy)))
|
||
return bounding_box
|
||
|
||
|
||
def _bounding_box_elliptical_arc(x1, y1, rx, ry, phi, large, sweep, x, y):
|
||
"""Bounding box of an elliptical arc in path node."""
|
||
rx, ry = abs(rx), abs(ry)
|
||
if 0 in (rx, ry):
|
||
return min(x, x1), min(y, y1), abs(x - x1), abs(y - y1)
|
||
|
||
x1prime = cos(phi) * (x1 - x) / 2 + sin(phi) * (y1 - y) / 2
|
||
y1prime = -sin(phi) * (x1 - x) / 2 + cos(phi) * (y1 - y) / 2
|
||
|
||
radicant = (
|
||
rx ** 2 * ry ** 2 - rx ** 2 * y1prime ** 2 - ry ** 2 * x1prime ** 2)
|
||
radicant /= rx ** 2 * y1prime ** 2 + ry ** 2 * x1prime ** 2
|
||
cxprime = cyprime = 0
|
||
|
||
if radicant < 0:
|
||
ratio = rx / ry
|
||
radicant = y1prime ** 2 + x1prime ** 2 / ratio ** 2
|
||
if radicant < 0:
|
||
return min(x, x1), min(y, y1), abs(x - x1), abs(y - y1)
|
||
ry = sqrt(radicant)
|
||
rx = ratio * ry
|
||
else:
|
||
factor = (-1 if large == sweep else 1) * sqrt(radicant)
|
||
|
||
cxprime = factor * rx * y1prime / ry
|
||
cyprime = -factor * ry * x1prime / rx
|
||
|
||
cx = cxprime * cos(phi) - cyprime * sin(phi) + (x1 + x) / 2
|
||
cy = cxprime * sin(phi) + cyprime * cos(phi) + (y1 + y) / 2
|
||
|
||
if phi in (0, pi):
|
||
minx = cx - rx
|
||
tminx = atan2(0, -rx)
|
||
maxx = cx + rx
|
||
tmaxx = atan2(0, rx)
|
||
miny = cy - ry
|
||
tminy = atan2(-ry, 0)
|
||
maxy = cy + ry
|
||
tmaxy = atan2(ry, 0)
|
||
elif phi in (pi / 2, 3 * pi / 2):
|
||
minx = cx - ry
|
||
tminx = atan2(0, -ry)
|
||
maxx = cx + ry
|
||
tmaxx = atan2(0, ry)
|
||
miny = cy - rx
|
||
tminy = atan2(-rx, 0)
|
||
maxy = cy + rx
|
||
tmaxy = atan2(rx, 0)
|
||
else:
|
||
tminx = -atan(ry * tan(phi) / rx)
|
||
tmaxx = pi - atan(ry * tan(phi) / rx)
|
||
minx = cx + rx * cos(tminx) * cos(phi) - ry * sin(tminx) * sin(phi)
|
||
maxx = cx + rx * cos(tmaxx) * cos(phi) - ry * sin(tmaxx) * sin(phi)
|
||
if minx > maxx:
|
||
minx, maxx = maxx, minx
|
||
tminx, tmaxx = tmaxx, tminx
|
||
tmp_y = cy + rx * cos(tminx) * sin(phi) + ry * sin(tminx) * cos(phi)
|
||
tminx = atan2(minx - cx, tmp_y - cy)
|
||
tmp_y = cy + rx * cos(tmaxx) * sin(phi) + ry * sin(tmaxx) * cos(phi)
|
||
tmaxx = atan2(maxx - cx, tmp_y - cy)
|
||
|
||
tminy = atan(ry / (tan(phi) * rx))
|
||
tmaxy = atan(ry / (tan(phi) * rx)) + pi
|
||
miny = cy + rx * cos(tminy) * sin(phi) + ry * sin(tminy) * cos(phi)
|
||
maxy = cy + rx * cos(tmaxy) * sin(phi) + ry * sin(tmaxy) * cos(phi)
|
||
if miny > maxy:
|
||
miny, maxy = maxy, miny
|
||
tminy, tmaxy = tmaxy, tminy
|
||
tmp_x = cx + rx * cos(tminy) * cos(phi) - ry * sin(tminy) * sin(phi)
|
||
tminy = atan2(tmp_x - cx, miny - cy)
|
||
tmp_x = cx + rx * cos(tmaxy) * cos(phi) - ry * sin(tmaxy) * sin(phi)
|
||
tmaxy = atan2(maxy - cy, tmp_x - cx)
|
||
|
||
angle1 = atan2(y1 - cy, x1 - cx)
|
||
angle2 = atan2(y - cy, x - cx)
|
||
|
||
if not sweep:
|
||
angle1, angle2 = angle2, angle1
|
||
|
||
other_arc = False
|
||
if angle1 > angle2:
|
||
angle1, angle2 = angle2, angle1
|
||
other_arc = True
|
||
|
||
if ((not other_arc and (angle1 > tminx or angle2 < tminx)) or
|
||
(other_arc and not (angle1 > tminx or angle2 < tminx))):
|
||
minx = min(x, x1)
|
||
if ((not other_arc and (angle1 > tmaxx or angle2 < tmaxx)) or
|
||
(other_arc and not (angle1 > tmaxx or angle2 < tmaxx))):
|
||
maxx = max(x, x1)
|
||
if ((not other_arc and (angle1 > tminy or angle2 < tminy)) or
|
||
(other_arc and not (angle1 > tminy or angle2 < tminy))):
|
||
miny = min(y, y1)
|
||
if ((not other_arc and (angle1 > tmaxy or angle2 < tmaxy)) or
|
||
(other_arc and not (angle1 > tmaxy or angle2 < tmaxy))):
|
||
maxy = max(y, y1)
|
||
|
||
return minx, miny, maxx - minx, maxy - miny
|
||
|
||
|
||
def extend_bounding_box(bounding_box, points):
|
||
"""Extend a bounding box to include given points."""
|
||
minx, miny, width, height = bounding_box
|
||
maxx, maxy = (
|
||
-inf if isinf(minx) else minx + width,
|
||
-inf if isinf(miny) else miny + height)
|
||
x_list, y_list = zip(*points)
|
||
minx, miny, maxx, maxy = (
|
||
min(minx, *x_list), min(miny, *y_list),
|
||
max(maxx, *x_list), max(maxy, *y_list))
|
||
return minx, miny, maxx - minx, maxy - miny
|
||
|
||
|
||
def is_valid_bounding_box(bounding_box):
|
||
"""Check that a bounding box doesn’t have infinite boundaries."""
|
||
return bounding_box and not isinf(bounding_box[0] + bounding_box[1])
|
||
|
||
|
||
BOUNDING_BOX_METHODS = {
|
||
'rect': bounding_box_rect,
|
||
'circle': bounding_box_circle,
|
||
'ellipse': bounding_box_ellipse,
|
||
'line': bounding_box_line,
|
||
'polyline': bounding_box_polyline,
|
||
'polygon': bounding_box_polyline,
|
||
'path': bounding_box_path,
|
||
'g': bounding_box_g,
|
||
'marker': bounding_box_g,
|
||
'text': bounding_box_text,
|
||
'tspan': bounding_box_text,
|
||
'textPath': bounding_box_text,
|
||
}
|