Add existing to tracked
This commit is contained in:
@@ -0,0 +1 @@
|
||||
2.8.2
|
||||
@@ -0,0 +1,108 @@
|
||||
"""
|
||||
CairoSVG - A simple SVG converter based on Cairo.
|
||||
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
if hasattr(sys, 'frozen'):
|
||||
if hasattr(sys, '_MEIPASS'):
|
||||
# Frozen with PyInstaller
|
||||
# See https://github.com/Kozea/WeasyPrint/pull/540
|
||||
ROOT = Path(sys._MEIPASS) / 'cairosvg'
|
||||
else:
|
||||
# Frozen with something else (py2exe, etc.)
|
||||
# See https://github.com/Kozea/WeasyPrint/pull/269
|
||||
ROOT = Path(sys.executable).parent
|
||||
else:
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
|
||||
VERSION = __version__ = (ROOT / 'VERSION').read_text().strip()
|
||||
|
||||
|
||||
# VERSION is used in the "url" module imported by "surface"
|
||||
from . import surface # noqa isort:skip
|
||||
|
||||
|
||||
SURFACES = {
|
||||
'PDF': surface.PDFSurface,
|
||||
'PNG': surface.PNGSurface,
|
||||
'PS': surface.PSSurface,
|
||||
'EPS': surface.EPSSurface,
|
||||
'SVG': surface.SVGSurface,
|
||||
}
|
||||
|
||||
|
||||
def svg2svg(bytestring=None, *, file_obj=None, url=None, dpi=96,
|
||||
parent_width=None, parent_height=None, scale=1, unsafe=False,
|
||||
background_color=None, negate_colors=False, invert_images=False,
|
||||
write_to=None, output_width=None, output_height=None):
|
||||
return surface.SVGSurface.convert(
|
||||
bytestring=bytestring, file_obj=file_obj, url=url, dpi=dpi,
|
||||
parent_width=parent_width, parent_height=parent_height, scale=scale,
|
||||
background_color=background_color,
|
||||
negate_colors=negate_colors, invert_images=invert_images,
|
||||
unsafe=unsafe, write_to=write_to, output_width=output_width,
|
||||
output_height=output_height)
|
||||
|
||||
|
||||
def svg2png(bytestring=None, *, file_obj=None, url=None, dpi=96,
|
||||
parent_width=None, parent_height=None, scale=1, unsafe=False,
|
||||
background_color=None, negate_colors=False, invert_images=False,
|
||||
write_to=None, output_width=None, output_height=None):
|
||||
return surface.PNGSurface.convert(
|
||||
bytestring=bytestring, file_obj=file_obj, url=url, dpi=dpi,
|
||||
parent_width=parent_width, parent_height=parent_height, scale=scale,
|
||||
background_color=background_color, negate_colors=negate_colors,
|
||||
invert_images=invert_images, unsafe=unsafe, write_to=write_to,
|
||||
output_width=output_width, output_height=output_height)
|
||||
|
||||
|
||||
def svg2pdf(bytestring=None, *, file_obj=None, url=None, dpi=96,
|
||||
parent_width=None, parent_height=None, scale=1, unsafe=False,
|
||||
background_color=None, negate_colors=False, invert_images=False,
|
||||
write_to=None, output_width=None, output_height=None):
|
||||
return surface.PDFSurface.convert(
|
||||
bytestring=bytestring, file_obj=file_obj, url=url, dpi=dpi,
|
||||
parent_width=parent_width, parent_height=parent_height, scale=scale,
|
||||
background_color=background_color, negate_colors=negate_colors,
|
||||
invert_images=invert_images, unsafe=unsafe, write_to=write_to,
|
||||
output_width=output_width, output_height=output_height)
|
||||
|
||||
|
||||
def svg2ps(bytestring=None, *, file_obj=None, url=None, dpi=96,
|
||||
parent_width=None, parent_height=None, scale=1, unsafe=False,
|
||||
background_color=None, negate_colors=False, invert_images=False,
|
||||
write_to=None, output_width=None, output_height=None):
|
||||
return surface.PSSurface.convert(
|
||||
bytestring=bytestring, file_obj=file_obj, url=url, dpi=dpi,
|
||||
parent_width=parent_width, parent_height=parent_height, scale=scale,
|
||||
background_color=background_color, negate_colors=negate_colors,
|
||||
invert_images=invert_images, unsafe=unsafe, write_to=write_to,
|
||||
output_width=output_width, output_height=output_height)
|
||||
|
||||
|
||||
def svg2eps(bytestring=None, *, file_obj=None, url=None, dpi=96,
|
||||
parent_width=None, parent_height=None, scale=1, unsafe=False,
|
||||
background_color=None, negate_colors=False, invert_images=False,
|
||||
write_to=None, output_width=None, output_height=None):
|
||||
return surface.EPSSurface.convert(
|
||||
bytestring=bytestring, file_obj=file_obj, url=url, dpi=dpi,
|
||||
parent_width=parent_width, parent_height=parent_height, scale=scale,
|
||||
background_color=background_color, negate_colors=negate_colors,
|
||||
invert_images=invert_images, unsafe=unsafe, write_to=write_to,
|
||||
output_width=output_width, output_height=output_height)
|
||||
|
||||
|
||||
if __debug__:
|
||||
svg2svg.__doc__ = surface.Surface.convert.__doc__.replace(
|
||||
'the format for this class', 'SVG')
|
||||
svg2png.__doc__ = surface.Surface.convert.__doc__.replace(
|
||||
'the format for this class', 'PNG')
|
||||
svg2pdf.__doc__ = surface.Surface.convert.__doc__.replace(
|
||||
'the format for this class', 'PDF')
|
||||
svg2ps.__doc__ = surface.Surface.convert.__doc__.replace(
|
||||
'the format for this class', 'PS')
|
||||
svg2eps.__doc__ = surface.Surface.convert.__doc__.replace(
|
||||
'the format for this class', 'EPS')
|
||||
@@ -0,0 +1,82 @@
|
||||
"""
|
||||
Command-line interface to CairoSVG.
|
||||
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from . import SURFACES, VERSION
|
||||
|
||||
|
||||
def main(argv=None, stdout=None, stdin=None):
|
||||
"""Entry-point of the executable."""
|
||||
# Get command-line options
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Convert SVG files to other formats')
|
||||
parser.add_argument('input', default='-', help='input filename or URL')
|
||||
parser.add_argument(
|
||||
'-v', '--version', action='version', version=VERSION)
|
||||
parser.add_argument(
|
||||
'-f', '--format', help='output format',
|
||||
choices=sorted([surface.lower() for surface in SURFACES]))
|
||||
parser.add_argument(
|
||||
'-d', '--dpi', default=96, type=float,
|
||||
help='ratio between 1 inch and 1 pixel')
|
||||
parser.add_argument(
|
||||
'-W', '--width', default=None, type=float,
|
||||
help='width of the parent container in pixels')
|
||||
parser.add_argument(
|
||||
'-H', '--height', default=None, type=float,
|
||||
help='height of the parent container in pixels')
|
||||
parser.add_argument(
|
||||
'-s', '--scale', default=1, type=float, help='output scaling factor')
|
||||
parser.add_argument(
|
||||
'-b', '--background', metavar='COLOR', help='output background color')
|
||||
parser.add_argument(
|
||||
'-n', '--negate-colors', action='store_true',
|
||||
help='replace every vector color with its complement')
|
||||
parser.add_argument(
|
||||
'-i', '--invert-images', action='store_true',
|
||||
help='replace every raster pixel with its complementary color')
|
||||
parser.add_argument(
|
||||
'-u', '--unsafe', action='store_true',
|
||||
help='fetch external files, resolve XML entities and allow very large '
|
||||
'files (WARNING: vulnerable to XXE attacks and various DoS)')
|
||||
parser.add_argument(
|
||||
'--output-width', default=None, type=float,
|
||||
help='desired output width in pixels')
|
||||
parser.add_argument(
|
||||
'--output-height', default=None, type=float,
|
||||
help='desired output height in pixels')
|
||||
|
||||
parser.add_argument('-o', '--output', default='-', help='output filename')
|
||||
|
||||
options = parser.parse_args(argv)
|
||||
kwargs = {
|
||||
'parent_width': options.width, 'parent_height': options.height,
|
||||
'dpi': options.dpi, 'scale': options.scale, 'unsafe': options.unsafe,
|
||||
'background_color': options.background,
|
||||
'negate_colors': options.negate_colors,
|
||||
'invert_images': options.invert_images,
|
||||
'output_width': options.output_width,
|
||||
'output_height': options.output_height}
|
||||
stdin = stdin or sys.stdin
|
||||
stdout = stdout or sys.stdout
|
||||
kwargs['write_to'] = (
|
||||
stdout.buffer if options.output == '-' else options.output)
|
||||
if options.input == '-':
|
||||
kwargs['file_obj'] = stdin.buffer
|
||||
else:
|
||||
kwargs['url'] = options.input
|
||||
output_format = (
|
||||
options.format or
|
||||
Path(options.output).suffix.lstrip('.') or
|
||||
'pdf').upper()
|
||||
|
||||
SURFACES[output_format.upper()].convert(**kwargs)
|
||||
|
||||
|
||||
if __name__ == '__main__': # pragma: no cover
|
||||
main()
|
||||
@@ -0,0 +1,398 @@
|
||||
"""
|
||||
Calculate bounding box for SVG shapes and paths.
|
||||
|
||||
A bounding box is a (minx, miny, width, height) tuple.
|
||||
|
||||
"""
|
||||
|
||||
from math import (
|
||||
acos, atan, copysign, cos, fmod, hypot, isinf, pi, radians, sin, sqrt, tan,
|
||||
tau)
|
||||
|
||||
from .features import match_features
|
||||
from .helpers import PATH_LETTERS, normalize, point, size
|
||||
from .parser import Tree
|
||||
from .url import parse_url
|
||||
|
||||
EMPTY_BOUNDING_BOX = float('inf'), float('inf'), 0, 0
|
||||
|
||||
|
||||
def calculate_bounding_box(surface, node):
|
||||
"""Calculate ``node``'s bounding box.
|
||||
|
||||
See https://www.w3.org/TR/SVG/coords.html#ObjectBoundingBox
|
||||
|
||||
"""
|
||||
if 'bounding_box' not in node and node.tag in BOUNDING_BOX_METHODS:
|
||||
bounding_box = BOUNDING_BOX_METHODS[node.tag](surface, node)
|
||||
if is_non_empty_bounding_box(bounding_box):
|
||||
node['bounding_box'] = bounding_box
|
||||
return node.get('bounding_box')
|
||||
|
||||
|
||||
def bounding_box_rect(surface, node):
|
||||
"""Get the bounding box of a ``rect`` node."""
|
||||
x, y = size(surface, node.get('x'), 'x'), size(surface, node.get('y'), 'y')
|
||||
width = size(surface, node.get('width'), 'x')
|
||||
height = size(surface, node.get('height'), 'y')
|
||||
return x, y, width, height
|
||||
|
||||
|
||||
def bounding_box_circle(surface, node):
|
||||
"""Get the bounding box of a ``circle`` node."""
|
||||
cx = size(surface, node.get('cx'), 'x')
|
||||
cy = size(surface, node.get('cy'), 'y')
|
||||
r = size(surface, node.get('r'))
|
||||
return cx - r, cy - r, 2 * r, 2 * r
|
||||
|
||||
|
||||
def bounding_box_ellipse(surface, node):
|
||||
"""Get the bounding box of an ``ellipse`` node."""
|
||||
rx = size(surface, node.get('rx'), 'x')
|
||||
ry = size(surface, node.get('ry'), 'y')
|
||||
cx = size(surface, node.get('cx'), 'x')
|
||||
cy = size(surface, node.get('cy'), 'y')
|
||||
return cx - rx, cy - ry, 2 * rx, 2 * ry
|
||||
|
||||
|
||||
def bounding_box_line(surface, node):
|
||||
"""Get the bounding box of a ``line`` node."""
|
||||
x1, y1, x2, y2 = tuple(
|
||||
size(surface, node.get(position), position[0])
|
||||
for position in ('x1', 'y1', 'x2', 'y2'))
|
||||
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(surface, node):
|
||||
"""Get the bounding box of a ``polyline`` or ``polygon`` node."""
|
||||
bounding_box = EMPTY_BOUNDING_BOX
|
||||
points = []
|
||||
normalized_points = normalize(node.get('points', ''))
|
||||
while normalized_points:
|
||||
x, y, normalized_points = point(surface, normalized_points)
|
||||
points.append((x, y))
|
||||
return extend_bounding_box(bounding_box, points)
|
||||
|
||||
|
||||
def bounding_box_path(surface, node):
|
||||
"""Get the bounding box of a ``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 = (path_data + ' ').split(' ', 1)
|
||||
|
||||
if letter in 'aA':
|
||||
# Elliptical arc curve
|
||||
rx, ry, path_data = point(None, path_data)
|
||||
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(None, path_data)
|
||||
|
||||
# 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(None, path_data)
|
||||
x2, y2, path_data = point(None, path_data)
|
||||
x, y, path_data = point(None, path_data)
|
||||
|
||||
# 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 = (path_data + ' ').split(' ', 1)
|
||||
x = size(surface, x, 'x')
|
||||
|
||||
# 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(None, path_data)
|
||||
|
||||
# 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(None, path_data)
|
||||
x, y, path_data = point(None, path_data)
|
||||
|
||||
# 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 = (path_data + ' ').split(' ', 1)
|
||||
y = size(surface, y, 'y')
|
||||
|
||||
# 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(surface, node):
|
||||
"""Get the bounding box of a ``text`` node."""
|
||||
return node.get('text_bounding_box')
|
||||
|
||||
|
||||
def angle(bx, by):
|
||||
"""Get the angle between vector (1,0) and vector (bx,by)."""
|
||||
return fmod(tau + copysign(acos(bx / hypot(bx, by)), by), tau)
|
||||
|
||||
|
||||
def bounding_box_elliptical_arc(x1, y1, rx, ry, phi, large, sweep, x, y):
|
||||
"""Get the bounding box of an elliptical arc described by the parameters.
|
||||
|
||||
See following website for original code:
|
||||
http://fridrich.blogspot.nl/2011/06/bounding-box-of-svg-elliptical-arc.html
|
||||
|
||||
"""
|
||||
rx, ry = abs(rx), abs(ry)
|
||||
if rx == 0 or ry == 0:
|
||||
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 = angle(-rx, 0)
|
||||
maxx = cx + rx
|
||||
tmaxx = angle(rx, 0)
|
||||
miny = cy - ry
|
||||
tminy = angle(0, -ry)
|
||||
maxy = cy + ry
|
||||
tmaxy = angle(0, ry)
|
||||
elif phi in (pi / 2, 3 * pi / 2):
|
||||
minx = cx - ry
|
||||
tminx = angle(-ry, 0)
|
||||
maxx = cx + ry
|
||||
tmaxx = angle(ry, 0)
|
||||
miny = cy - rx
|
||||
tminy = angle(0, -rx)
|
||||
maxy = cy + rx
|
||||
tmaxy = angle(0, rx)
|
||||
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 = angle(minx - cx, tmp_y - cy)
|
||||
tmp_y = cy + rx * cos(tmaxx) * sin(phi) + ry * sin(tmaxx) * cos(phi)
|
||||
tmaxx = angle(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 = angle(tmp_x - cx, miny - cy)
|
||||
tmp_x = cx + rx * cos(tmaxy) * cos(phi) - ry * sin(tmaxy) * sin(phi)
|
||||
tmaxy = angle(tmp_x - cx, maxy - cy)
|
||||
|
||||
angle1 = angle(x1 - cx, y1 - cy)
|
||||
angle2 = angle(x - cx, y - cy)
|
||||
|
||||
if not sweep:
|
||||
angle1, angle2 = angle2, angle1
|
||||
|
||||
other_arc = False
|
||||
if angle1 > angle2:
|
||||
angle1, angle2 = angle2, angle1
|
||||
other_arc = True
|
||||
|
||||
if other_arc == (angle1 <= tminx <= angle2):
|
||||
minx = min(x, x1)
|
||||
if other_arc == (angle1 <= tmaxx <= angle2):
|
||||
maxx = max(x, x1)
|
||||
if other_arc == (angle1 <= tminy <= angle2):
|
||||
miny = min(y, y1)
|
||||
if other_arc == (angle1 <= tmaxy <= angle2):
|
||||
maxy = max(y, y1)
|
||||
|
||||
return minx, miny, maxx - minx, maxy - miny
|
||||
|
||||
|
||||
def bounding_box_group(surface, node):
|
||||
"""Get the bounding box of a ``g`` node."""
|
||||
bounding_box = EMPTY_BOUNDING_BOX
|
||||
for child in node.children:
|
||||
bounding_box = combine_bounding_box(
|
||||
bounding_box, calculate_bounding_box(surface, child))
|
||||
return bounding_box
|
||||
|
||||
|
||||
def bounding_box_use(surface, node):
|
||||
"""Get the bounding box of a ``use`` node."""
|
||||
href = parse_url(node.get_href()).geturl()
|
||||
tree = Tree(
|
||||
url=href, url_fetcher=node.url_fetcher, parent=node,
|
||||
unsafe=node.unsafe)
|
||||
if not match_features(tree.xml_tree):
|
||||
return None
|
||||
return calculate_bounding_box(surface, tree)
|
||||
|
||||
|
||||
def extend_bounding_box(bounding_box, points):
|
||||
"""Extend the ``bounding_box`` by the points."""
|
||||
minx, miny, width, height = bounding_box
|
||||
maxx, maxy = (
|
||||
float('-inf') if isinf(minx) else minx + width,
|
||||
float('-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 combine_bounding_box(bounding_box, another_bounding_box):
|
||||
"""Combine the ``bounding_box`` with ``another_bounding_box``."""
|
||||
if is_valid_bounding_box(another_bounding_box):
|
||||
minx, miny, width, height = another_bounding_box
|
||||
maxx, maxy = minx + width, miny + height
|
||||
bounding_box = extend_bounding_box(
|
||||
bounding_box, ((minx, miny), (maxx, maxy)))
|
||||
return bounding_box
|
||||
|
||||
|
||||
def is_valid_bounding_box(bounding_box):
|
||||
"""Know whether bounding box has been initialized."""
|
||||
# If 'minx' or 'miny' is set, 'maxx' and 'maxy' will also be set (resulting
|
||||
# in a valid bounding box)
|
||||
return bounding_box and not isinf(bounding_box[0] + bounding_box[1])
|
||||
|
||||
|
||||
def is_non_empty_bounding_box(bounding_box):
|
||||
"""Know whether bounding box is valid and has a size."""
|
||||
return is_valid_bounding_box(bounding_box) and 0 not in bounding_box[2:]
|
||||
|
||||
|
||||
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,
|
||||
'text': bounding_box_text,
|
||||
'tspan': bounding_box_text,
|
||||
'textPath': bounding_box_text,
|
||||
'g': bounding_box_group,
|
||||
'use': bounding_box_use,
|
||||
'marker': bounding_box_group,
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
"""
|
||||
SVG colors.
|
||||
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
COLORS = {
|
||||
'aliceblue': (240 / 255, 248 / 255, 255 / 255, 1),
|
||||
'antiquewhite': (250 / 255, 235 / 255, 215 / 255, 1),
|
||||
'aqua': (0 / 255, 255 / 255, 255 / 255, 1),
|
||||
'aquamarine': (127 / 255, 255 / 255, 212 / 255, 1),
|
||||
'azure': (240 / 255, 255 / 255, 255 / 255, 1),
|
||||
'beige': (245 / 255, 245 / 255, 220 / 255, 1),
|
||||
'bisque': (255 / 255, 228 / 255, 196 / 255, 1),
|
||||
'black': (0 / 255, 0 / 255, 0 / 255, 1),
|
||||
'blanchedalmond': (255 / 255, 235 / 255, 205 / 255, 1),
|
||||
'blue': (0 / 255, 0 / 255, 255 / 255, 1),
|
||||
'blueviolet': (138 / 255, 43 / 255, 226 / 255, 1),
|
||||
'brown': (165 / 255, 42 / 255, 42 / 255, 1),
|
||||
'burlywood': (222 / 255, 184 / 255, 135 / 255, 1),
|
||||
'cadetblue': (95 / 255, 158 / 255, 160 / 255, 1),
|
||||
'chartreuse': (127 / 255, 255 / 255, 0 / 255, 1),
|
||||
'chocolate': (210 / 255, 105 / 255, 30 / 255, 1),
|
||||
'coral': (255 / 255, 127 / 255, 80 / 255, 1),
|
||||
'cornflowerblue': (100 / 255, 149 / 255, 237 / 255, 1),
|
||||
'cornsilk': (255 / 255, 248 / 255, 220 / 255, 1),
|
||||
'crimson': (220 / 255, 20 / 255, 60 / 255, 1),
|
||||
'cyan': (0 / 255, 255 / 255, 255 / 255, 1),
|
||||
'darkblue': (0 / 255, 0 / 255, 139 / 255, 1),
|
||||
'darkcyan': (0 / 255, 139 / 255, 139 / 255, 1),
|
||||
'darkgoldenrod': (184 / 255, 134 / 255, 11 / 255, 1),
|
||||
'darkgray': (169 / 255, 169 / 255, 169 / 255, 1),
|
||||
'darkgreen': (0 / 255, 100 / 255, 0 / 255, 1),
|
||||
'darkgrey': (169 / 255, 169 / 255, 169 / 255, 1),
|
||||
'darkkhaki': (189 / 255, 183 / 255, 107 / 255, 1),
|
||||
'darkmagenta': (139 / 255, 0 / 255, 139 / 255, 1),
|
||||
'darkolivegreen': (85 / 255, 107 / 255, 47 / 255, 1),
|
||||
'darkorange': (255 / 255, 140 / 255, 0 / 255, 1),
|
||||
'darkorchid': (153 / 255, 50 / 255, 204 / 255, 1),
|
||||
'darkred': (139 / 255, 0 / 255, 0 / 255, 1),
|
||||
'darksalmon': (233 / 255, 150 / 255, 122 / 255, 1),
|
||||
'darkseagreen': (143 / 255, 188 / 255, 143 / 255, 1),
|
||||
'darkslateblue': (72 / 255, 61 / 255, 139 / 255, 1),
|
||||
'darkslategray': (47 / 255, 79 / 255, 79 / 255, 1),
|
||||
'darkslategrey': (47 / 255, 79 / 255, 79 / 255, 1),
|
||||
'darkturquoise': (0 / 255, 206 / 255, 209 / 255, 1),
|
||||
'darkviolet': (148 / 255, 0 / 255, 211 / 255, 1),
|
||||
'deeppink': (255 / 255, 20 / 255, 147 / 255, 1),
|
||||
'deepskyblue': (0 / 255, 191 / 255, 255 / 255, 1),
|
||||
'dimgray': (105 / 255, 105 / 255, 105 / 255, 1),
|
||||
'dimgrey': (105 / 255, 105 / 255, 105 / 255, 1),
|
||||
'dodgerblue': (30 / 255, 144 / 255, 255 / 255, 1),
|
||||
'firebrick': (178 / 255, 34 / 255, 34 / 255, 1),
|
||||
'floralwhite': (255 / 255, 250 / 255, 240 / 255, 1),
|
||||
'forestgreen': (34 / 255, 139 / 255, 34 / 255, 1),
|
||||
'fuchsia': (255 / 255, 0 / 255, 255 / 255, 1),
|
||||
'gainsboro': (220 / 255, 220 / 255, 220 / 255, 1),
|
||||
'ghostwhite': (248 / 255, 248 / 255, 255 / 255, 1),
|
||||
'gold': (255 / 255, 215 / 255, 0 / 255, 1),
|
||||
'goldenrod': (218 / 255, 165 / 255, 32 / 255, 1),
|
||||
'gray': (128 / 255, 128 / 255, 128 / 255, 1),
|
||||
'grey': (128 / 255, 128 / 255, 128 / 255, 1),
|
||||
'green': (0 / 255, 128 / 255, 0 / 255, 1),
|
||||
'greenyellow': (173 / 255, 255 / 255, 47 / 255, 1),
|
||||
'honeydew': (240 / 255, 255 / 255, 240 / 255, 1),
|
||||
'hotpink': (255 / 255, 105 / 255, 180 / 255, 1),
|
||||
'indianred': (205 / 255, 92 / 255, 92 / 255, 1),
|
||||
'indigo': (75 / 255, 0 / 255, 130 / 255, 1),
|
||||
'ivory': (255 / 255, 255 / 255, 240 / 255, 1),
|
||||
'khaki': (240 / 255, 230 / 255, 140 / 255, 1),
|
||||
'lavender': (230 / 255, 230 / 255, 250 / 255, 1),
|
||||
'lavenderblush': (255 / 255, 240 / 255, 245 / 255, 1),
|
||||
'lawngreen': (124 / 255, 252 / 255, 0 / 255, 1),
|
||||
'lemonchiffon': (255 / 255, 250 / 255, 205 / 255, 1),
|
||||
'lightblue': (173 / 255, 216 / 255, 230 / 255, 1),
|
||||
'lightcoral': (240 / 255, 128 / 255, 128 / 255, 1),
|
||||
'lightcyan': (224 / 255, 255 / 255, 255 / 255, 1),
|
||||
'lightgoldenrodyellow': (250 / 255, 250 / 255, 210 / 255, 1),
|
||||
'lightgray': (211 / 255, 211 / 255, 211 / 255, 1),
|
||||
'lightgreen': (144 / 255, 238 / 255, 144 / 255, 1),
|
||||
'lightgrey': (211 / 255, 211 / 255, 211 / 255, 1),
|
||||
'lightpink': (255 / 255, 182 / 255, 193 / 255, 1),
|
||||
'lightsalmon': (255 / 255, 160 / 255, 122 / 255, 1),
|
||||
'lightseagreen': (32 / 255, 178 / 255, 170 / 255, 1),
|
||||
'lightskyblue': (135 / 255, 206 / 255, 250 / 255, 1),
|
||||
'lightslategray': (119 / 255, 136 / 255, 153 / 255, 1),
|
||||
'lightslategrey': (119 / 255, 136 / 255, 153 / 255, 1),
|
||||
'lightsteelblue': (176 / 255, 196 / 255, 222 / 255, 1),
|
||||
'lightyellow': (255 / 255, 255 / 255, 224 / 255, 1),
|
||||
'lime': (0 / 255, 255 / 255, 0 / 255, 1),
|
||||
'limegreen': (50 / 255, 205 / 255, 50 / 255, 1),
|
||||
'linen': (250 / 255, 240 / 255, 230 / 255, 1),
|
||||
'magenta': (255 / 255, 0 / 255, 255 / 255, 1),
|
||||
'maroon': (128 / 255, 0 / 255, 0 / 255, 1),
|
||||
'mediumaquamarine': (102 / 255, 205 / 255, 170 / 255, 1),
|
||||
'mediumblue': (0 / 255, 0 / 255, 205 / 255, 1),
|
||||
'mediumorchid': (186 / 255, 85 / 255, 211 / 255, 1),
|
||||
'mediumpurple': (147 / 255, 112 / 255, 219 / 255, 1),
|
||||
'mediumseagreen': (60 / 255, 179 / 255, 113 / 255, 1),
|
||||
'mediumslateblue': (123 / 255, 104 / 255, 238 / 255, 1),
|
||||
'mediumspringgreen': (0 / 255, 250 / 255, 154 / 255, 1),
|
||||
'mediumturquoise': (72 / 255, 209 / 255, 204 / 255, 1),
|
||||
'mediumvioletred': (199 / 255, 21 / 255, 133 / 255, 1),
|
||||
'midnightblue': (25 / 255, 25 / 255, 112 / 255, 1),
|
||||
'mintcream': (245 / 255, 255 / 255, 250 / 255, 1),
|
||||
'mistyrose': (255 / 255, 228 / 255, 225 / 255, 1),
|
||||
'moccasin': (255 / 255, 228 / 255, 181 / 255, 1),
|
||||
'navajowhite': (255 / 255, 222 / 255, 173 / 255, 1),
|
||||
'navy': (0 / 255, 0 / 255, 128 / 255, 1),
|
||||
'oldlace': (253 / 255, 245 / 255, 230 / 255, 1),
|
||||
'olive': (128 / 255, 128 / 255, 0 / 255, 1),
|
||||
'olivedrab': (107 / 255, 142 / 255, 35 / 255, 1),
|
||||
'orange': (255 / 255, 165 / 255, 0 / 255, 1),
|
||||
'orangered': (255 / 255, 69 / 255, 0 / 255, 1),
|
||||
'orchid': (218 / 255, 112 / 255, 214 / 255, 1),
|
||||
'palegoldenrod': (238 / 255, 232 / 255, 170 / 255, 1),
|
||||
'palegreen': (152 / 255, 251 / 255, 152 / 255, 1),
|
||||
'paleturquoise': (175 / 255, 238 / 255, 238 / 255, 1),
|
||||
'palevioletred': (219 / 255, 112 / 255, 147 / 255, 1),
|
||||
'papayawhip': (255 / 255, 239 / 255, 213 / 255, 1),
|
||||
'peachpuff': (255 / 255, 218 / 255, 185 / 255, 1),
|
||||
'peru': (205 / 255, 133 / 255, 63 / 255, 1),
|
||||
'pink': (255 / 255, 192 / 255, 203 / 255, 1),
|
||||
'plum': (221 / 255, 160 / 255, 221 / 255, 1),
|
||||
'powderblue': (176 / 255, 224 / 255, 230 / 255, 1),
|
||||
'purple': (128 / 255, 0 / 255, 128 / 255, 1),
|
||||
'red': (255 / 255, 0 / 255, 0 / 255, 1),
|
||||
'rosybrown': (188 / 255, 143 / 255, 143 / 255, 1),
|
||||
'royalblue': (65 / 255, 105 / 255, 225 / 255, 1),
|
||||
'saddlebrown': (139 / 255, 69 / 255, 19 / 255, 1),
|
||||
'salmon': (250 / 255, 128 / 255, 114 / 255, 1),
|
||||
'sandybrown': (244 / 255, 164 / 255, 96 / 255, 1),
|
||||
'seagreen': (46 / 255, 139 / 255, 87 / 255, 1),
|
||||
'seashell': (255 / 255, 245 / 255, 238 / 255, 1),
|
||||
'sienna': (160 / 255, 82 / 255, 45 / 255, 1),
|
||||
'silver': (192 / 255, 192 / 255, 192 / 255, 1),
|
||||
'skyblue': (135 / 255, 206 / 255, 235 / 255, 1),
|
||||
'slateblue': (106 / 255, 90 / 255, 205 / 255, 1),
|
||||
'slategray': (112 / 255, 128 / 255, 144 / 255, 1),
|
||||
'slategrey': (112 / 255, 128 / 255, 144 / 255, 1),
|
||||
'snow': (255 / 255, 250 / 255, 250 / 255, 1),
|
||||
'springgreen': (0 / 255, 255 / 255, 127 / 255, 1),
|
||||
'steelblue': (70 / 255, 130 / 255, 180 / 255, 1),
|
||||
'tan': (210 / 255, 180 / 255, 140 / 255, 1),
|
||||
'teal': (0 / 255, 128 / 255, 128 / 255, 1),
|
||||
'thistle': (216 / 255, 191 / 255, 216 / 255, 1),
|
||||
'tomato': (255 / 255, 99 / 255, 71 / 255, 1),
|
||||
'turquoise': (64 / 255, 224 / 255, 208 / 255, 1),
|
||||
'violet': (238 / 255, 130 / 255, 238 / 255, 1),
|
||||
'wheat': (245 / 255, 222 / 255, 179 / 255, 1),
|
||||
'white': (255 / 255, 255 / 255, 255 / 255, 1),
|
||||
'whitesmoke': (245 / 255, 245 / 255, 245 / 255, 1),
|
||||
'yellow': (255 / 255, 255 / 255, 0 / 255, 1),
|
||||
'yellowgreen': (154 / 255, 205 / 255, 50 / 255, 1),
|
||||
|
||||
'activeborder': (0, 0, 1, 1),
|
||||
'activecaption': (0, 0, 1, 1),
|
||||
'appworkspace': (1, 1, 1, 1),
|
||||
'background': (1, 1, 1, 1),
|
||||
'buttonface': (0, 0, 0, 1),
|
||||
'buttonhighlight': (0.8, 0.8, 0.8, 1),
|
||||
'buttonshadow': (0.2, 0.2, 0.2, 1),
|
||||
'buttontext': (0, 0, 0, 1),
|
||||
'captiontext': (0, 0, 0, 1),
|
||||
'graytext': (0.2, 0.2, 0.2, 1),
|
||||
'highlight': (0, 0, 1, 1),
|
||||
'highlighttext': (0.8, 0.8, 0.8, 1),
|
||||
'inactiveborder': (0.2, 0.2, 0.2, 1),
|
||||
'inactivecaption': (0.8, 0.8, 0.8, 1),
|
||||
'inactivecaptiontext': (0.2, 0.2, 0.2, 1),
|
||||
'infobackground': (0.8, 0.8, 0.8, 1),
|
||||
'infotext': (0, 0, 0, 1),
|
||||
'menu': (0.8, 0.8, 0.8, 1),
|
||||
'menutext': (0.2, 0.2, 0.2, 1),
|
||||
'scrollbar': (0.8, 0.8, 0.8, 1),
|
||||
'threeddarkshadow': (0.2, 0.2, 0.2, 1),
|
||||
'threedface': (0.8, 0.8, 0.8, 1),
|
||||
'threedhighlight': (1, 1, 1, 1),
|
||||
'threedlightshadow': (0.2, 0.2, 0.2, 1),
|
||||
'threedshadow': (0.2, 0.2, 0.2, 1),
|
||||
'window': (0.8, 0.8, 0.8, 1),
|
||||
'windowframe': (0.8, 0.8, 0.8, 1),
|
||||
'windowtext': (0, 0, 0, 1),
|
||||
|
||||
'none': (0, 0, 0, 0),
|
||||
'transparent': (0, 0, 0, 0),
|
||||
}
|
||||
|
||||
RGBA = re.compile(r'rgba\((.+?)\)')
|
||||
RGB = re.compile(r'rgb\((.+?)\)')
|
||||
HEX_RRGGBB = re.compile('#[0-9a-f]{6}')
|
||||
HEX_RGB = re.compile('#[0-9a-f]{3}')
|
||||
|
||||
|
||||
def color(string, opacity=1):
|
||||
"""Replace ``string`` representing a color by a RGBA tuple.
|
||||
|
||||
See http://www.w3.org/TR/SVG/types.html#DataTypeColor
|
||||
|
||||
"""
|
||||
if not string:
|
||||
return (0, 0, 0, 0)
|
||||
|
||||
string = string.strip().lower()
|
||||
|
||||
if string in COLORS:
|
||||
r, g, b, a = COLORS[string]
|
||||
return (r, g, b, a * opacity)
|
||||
|
||||
match = RGBA.search(string)
|
||||
if match:
|
||||
r, g, b, a = tuple(
|
||||
float(i.strip(' %')) / 100 if '%' in i else float(i) / 255
|
||||
for i in match.group(1).strip().split(','))
|
||||
return (r, g, b, a * 255 * opacity)
|
||||
|
||||
match = RGB.search(string)
|
||||
if match:
|
||||
r, g, b = tuple(
|
||||
float(i.strip(' %')) / 100 if '%' in i else float(i) / 255
|
||||
for i in match.group(1).strip().split(','))
|
||||
return (r, g, b, opacity)
|
||||
|
||||
match = HEX_RRGGBB.search(string)
|
||||
if match:
|
||||
plain_color = tuple(
|
||||
int(value, 16) / 255 for value in (
|
||||
string[1:3], string[3:5], string[5:7]))
|
||||
return plain_color + (opacity,)
|
||||
|
||||
match = HEX_RGB.search(string)
|
||||
if match:
|
||||
plain_color = tuple(
|
||||
int(value, 16) / 15 for value in (
|
||||
string[1], string[2], string[3]))
|
||||
return plain_color + (opacity,)
|
||||
|
||||
return (0, 0, 0, 1)
|
||||
|
||||
|
||||
def negate_color(rgba_tuple):
|
||||
"""Replace ``rgba_tuple`` with its complementary color."""
|
||||
r, g, b, a = rgba_tuple
|
||||
return (1 - r, 1 - g, 1 - b, a)
|
||||
@@ -0,0 +1,100 @@
|
||||
"""
|
||||
Handle CSS stylesheets.
|
||||
|
||||
"""
|
||||
|
||||
import cssselect2
|
||||
import tinycss2
|
||||
|
||||
from .url import parse_url
|
||||
|
||||
|
||||
def find_stylesheets(tree):
|
||||
"""Find the stylesheets included in ``tree``."""
|
||||
# TODO: support contentStyleType on <svg>
|
||||
default_type = 'text/css'
|
||||
for element in tree.xml_tree.iter():
|
||||
# http://www.w3.org/TR/SVG/styling.html#StyleElement
|
||||
if (element.tag == '{http://www.w3.org/2000/svg}style' and
|
||||
element.get('type', default_type) == 'text/css' and
|
||||
element.text):
|
||||
# TODO: pass href for relative URLs
|
||||
# TODO: support media types
|
||||
# TODO: what if <style> has children elements?
|
||||
yield tinycss2.parse_stylesheet(
|
||||
element.text, skip_comments=True, skip_whitespace=True)
|
||||
|
||||
|
||||
def find_stylesheets_rules(tree, stylesheet_rules, url):
|
||||
"""Find the rules in a stylesheet."""
|
||||
for rule in stylesheet_rules:
|
||||
if rule.type == 'at-rule':
|
||||
if rule.lower_at_keyword == 'import' and rule.content is None:
|
||||
# TODO: support media types in @import
|
||||
url_token = tinycss2.parse_one_component_value(rule.prelude)
|
||||
if url_token.type not in ('string', 'url'):
|
||||
continue
|
||||
css_url = parse_url(url_token.value, url)
|
||||
stylesheet = tinycss2.parse_stylesheet(
|
||||
tree.fetch_url(css_url, 'text/css').decode('utf-8'))
|
||||
for rule in find_stylesheets_rules(
|
||||
tree, stylesheet, css_url.geturl()):
|
||||
yield rule
|
||||
# TODO: support media types
|
||||
# if rule.lower_at_keyword == 'media':
|
||||
if rule.type == 'qualified-rule':
|
||||
yield rule
|
||||
# TODO: warn on error
|
||||
# if rule.type == 'error':
|
||||
|
||||
|
||||
def parse_declarations(input):
|
||||
normal_declarations = []
|
||||
important_declarations = []
|
||||
for declaration in tinycss2.parse_declaration_list(input):
|
||||
# TODO: warn on error
|
||||
# if declaration.type == 'error':
|
||||
if (declaration.type == 'declaration' and
|
||||
not declaration.name.startswith('-')):
|
||||
# Serializing perfectly good tokens just to re-parse them later :(
|
||||
value = tinycss2.serialize(declaration.value).strip()
|
||||
declarations = (
|
||||
important_declarations if declaration.important
|
||||
else normal_declarations)
|
||||
declarations.append((declaration.lower_name, value))
|
||||
return normal_declarations, important_declarations
|
||||
|
||||
|
||||
def parse_stylesheets(tree, url):
|
||||
"""Find and parse the stylesheets in ``tree``.
|
||||
|
||||
Return two :class:`cssselect2.Matcher` objects,
|
||||
for normal and !important declarations.
|
||||
|
||||
"""
|
||||
normal_matcher = cssselect2.Matcher()
|
||||
important_matcher = cssselect2.Matcher()
|
||||
for stylesheet in find_stylesheets(tree):
|
||||
for rule in find_stylesheets_rules(tree, stylesheet, url):
|
||||
normal_declarations, important_declarations = parse_declarations(
|
||||
rule.content)
|
||||
for selector in cssselect2.compile_selector_list(rule.prelude):
|
||||
if (selector.pseudo_element is None and
|
||||
not selector.never_matches):
|
||||
if normal_declarations:
|
||||
normal_matcher.add_selector(
|
||||
selector, normal_declarations)
|
||||
if important_declarations:
|
||||
important_matcher.add_selector(
|
||||
selector, important_declarations)
|
||||
return normal_matcher, important_matcher
|
||||
|
||||
|
||||
def get_declarations(rule):
|
||||
"""Get the declarations in ``rule``."""
|
||||
if rule.type == 'qualified-rule':
|
||||
for declaration in tinycss2.parse_declaration_list(
|
||||
rule.content, skip_comments=True, skip_whitespace=True):
|
||||
value = ''.join(part.serialize() for part in declaration.value)
|
||||
# TODO: filter out invalid values
|
||||
yield declaration.lower_name, value, declaration.important
|
||||
@@ -0,0 +1,371 @@
|
||||
"""
|
||||
Externally defined elements managers.
|
||||
|
||||
This module handles clips, gradients, masks, patterns and external nodes.
|
||||
|
||||
"""
|
||||
|
||||
from .bounding_box import calculate_bounding_box, is_non_empty_bounding_box
|
||||
from .features import match_features
|
||||
from .helpers import paint, size, transform
|
||||
from .parser import Tree
|
||||
from .shapes import rect
|
||||
from .surface import cairo
|
||||
from .url import parse_url
|
||||
|
||||
BLEND_OPERATORS = {
|
||||
'darken': cairo.OPERATOR_DARKEN,
|
||||
'lighten': cairo.OPERATOR_LIGHTEN,
|
||||
'multiply': cairo.OPERATOR_MULTIPLY,
|
||||
'normal': cairo.OPERATOR_OVER,
|
||||
'screen': cairo.OPERATOR_SCREEN,
|
||||
}
|
||||
|
||||
EXTEND_OPERATORS = {
|
||||
'none': cairo.EXTEND_NONE,
|
||||
'pad': cairo.EXTEND_PAD,
|
||||
'reflect': cairo.EXTEND_REFLECT,
|
||||
'repeat': cairo.EXTEND_REPEAT,
|
||||
}
|
||||
|
||||
|
||||
def update_def_href(surface, def_name, def_dict, chain=None):
|
||||
"""Update the attributes of the def according to its href attribute."""
|
||||
def_node = def_dict[def_name]
|
||||
href = parse_url(def_node.get_href()).fragment
|
||||
if chain is None:
|
||||
chain = set()
|
||||
if href in def_dict and href not in chain:
|
||||
chain.add(href)
|
||||
update_def_href(surface, href, def_dict, chain)
|
||||
href_node = def_dict[href]
|
||||
def_dict[def_name] = Tree(
|
||||
url=f'#{def_name}', url_fetcher=def_node.url_fetcher,
|
||||
parent=href_node, parent_children=(not def_node.children),
|
||||
tree_cache=surface.tree_cache, unsafe=def_node.unsafe)
|
||||
# Inherit attributes generally not inherited
|
||||
for key, value in href_node.items():
|
||||
if key not in def_dict[def_name]:
|
||||
def_dict[def_name][key] = value
|
||||
|
||||
|
||||
def parse_all_defs(surface, node):
|
||||
"""Recursively visit all child nodes and process definition elements."""
|
||||
|
||||
# Handle node
|
||||
parse_def(surface, node)
|
||||
|
||||
# Visit all children recursively
|
||||
if node.children:
|
||||
for child in node.children:
|
||||
parse_all_defs(surface, child)
|
||||
|
||||
|
||||
def parse_def(surface, node):
|
||||
"""Parse the SVG definitions."""
|
||||
for def_type in (
|
||||
'marker', 'gradient', 'pattern', 'path', 'mask', 'filter',
|
||||
'image'):
|
||||
if def_type in node.tag.lower() and 'id' in node:
|
||||
getattr(surface, f'{def_type}s')[node['id']] = node
|
||||
|
||||
|
||||
def gradient_or_pattern(surface, node, name, opacity):
|
||||
"""Gradient or pattern color."""
|
||||
if name in surface.gradients:
|
||||
update_def_href(surface, name, surface.gradients)
|
||||
return draw_gradient(surface, node, name, opacity)
|
||||
elif name in surface.patterns:
|
||||
update_def_href(surface, name, surface.patterns)
|
||||
return draw_pattern(surface, node, name, opacity)
|
||||
|
||||
|
||||
def marker(surface, node):
|
||||
"""Store a marker definition."""
|
||||
parse_def(surface, node)
|
||||
|
||||
|
||||
def mask(surface, node):
|
||||
"""Store a mask definition."""
|
||||
parse_def(surface, node)
|
||||
|
||||
|
||||
def filter_(surface, node):
|
||||
"""Store a filter definition."""
|
||||
parse_def(surface, node)
|
||||
|
||||
|
||||
def linear_gradient(surface, node):
|
||||
"""Store a linear gradient definition."""
|
||||
parse_def(surface, node)
|
||||
|
||||
|
||||
def radial_gradient(surface, node):
|
||||
"""Store a radial gradient definition."""
|
||||
parse_def(surface, node)
|
||||
|
||||
|
||||
def pattern(surface, node):
|
||||
"""Store a pattern definition."""
|
||||
parse_def(surface, node)
|
||||
|
||||
|
||||
def clip_path(surface, node):
|
||||
"""Store a clip path definition."""
|
||||
if 'id' in node:
|
||||
surface.paths[node['id']] = node
|
||||
|
||||
|
||||
def paint_mask(surface, node, name, opacity):
|
||||
"""Paint the mask of the current surface."""
|
||||
mask_node = surface.masks[name]
|
||||
mask_node.tag = 'g'
|
||||
mask_node['opacity'] = opacity
|
||||
|
||||
if mask_node.get('maskUnits') == 'userSpaceOnUse':
|
||||
width_ref, height_ref = 'x', 'y'
|
||||
else:
|
||||
x = size(surface, node.get('x'), 'x')
|
||||
y = size(surface, node.get('y'), 'y')
|
||||
width = size(surface, node.get('width'), 'x')
|
||||
height = size(surface, node.get('height'), 'y')
|
||||
width_ref = width or surface.width
|
||||
height_ref = height or surface.height
|
||||
|
||||
mask_node['x'] = size(surface, mask_node.get('x', '-10%'), width_ref)
|
||||
mask_node['y'] = size(surface, mask_node.get('y', '-10%'), height_ref)
|
||||
mask_node['height'] = size(
|
||||
surface, mask_node.get('height', '120%'), height_ref)
|
||||
mask_node['width'] = size(
|
||||
surface, mask_node.get('width', '120%'), width_ref)
|
||||
|
||||
if mask_node.get('maskUnits') == 'userSpaceOnUse':
|
||||
x = mask_node['x']
|
||||
y = mask_node['y']
|
||||
mask_node['viewBox'] = '{x} {y} {width} {height}'.format(**mask_node)
|
||||
|
||||
from .surface import SVGSurface # circular import
|
||||
mask_surface = SVGSurface(mask_node, None, surface.dpi, surface)
|
||||
surface.context.save()
|
||||
surface.context.translate(x, y)
|
||||
surface.context.scale(
|
||||
mask_node['width'] / mask_surface.width,
|
||||
mask_node['height'] / mask_surface.height)
|
||||
surface.context.mask_surface(mask_surface.cairo)
|
||||
surface.context.restore()
|
||||
|
||||
|
||||
def draw_gradient(surface, node, name, opacity):
|
||||
"""Gradients colors."""
|
||||
gradient_node = surface.gradients[name]
|
||||
|
||||
if gradient_node.get('gradientUnits') == 'userSpaceOnUse':
|
||||
width_ref, height_ref = 'x', 'y'
|
||||
diagonal_ref = 'xy'
|
||||
else:
|
||||
bounding_box = calculate_bounding_box(surface, node)
|
||||
if not is_non_empty_bounding_box(bounding_box):
|
||||
return False
|
||||
x = size(surface, bounding_box[0], 'x')
|
||||
y = size(surface, bounding_box[1], 'y')
|
||||
width = size(surface, bounding_box[2], 'x')
|
||||
height = size(surface, bounding_box[3], 'y')
|
||||
width_ref = height_ref = diagonal_ref = 1
|
||||
|
||||
if gradient_node.tag == 'linearGradient':
|
||||
x1 = size(surface, gradient_node.get('x1', '0%'), width_ref)
|
||||
x2 = size(surface, gradient_node.get('x2', '100%'), width_ref)
|
||||
y1 = size(surface, gradient_node.get('y1', '0%'), height_ref)
|
||||
y2 = size(surface, gradient_node.get('y2', '0%'), height_ref)
|
||||
gradient_pattern = cairo.LinearGradient(x1, y1, x2, y2)
|
||||
|
||||
elif gradient_node.tag == 'radialGradient':
|
||||
r = size(surface, gradient_node.get('r', '50%'), diagonal_ref)
|
||||
cx = size(surface, gradient_node.get('cx', '50%'), width_ref)
|
||||
cy = size(surface, gradient_node.get('cy', '50%'), height_ref)
|
||||
fx = size(surface, gradient_node.get('fx', str(cx)), width_ref)
|
||||
fy = size(surface, gradient_node.get('fy', str(cy)), height_ref)
|
||||
gradient_pattern = cairo.RadialGradient(fx, fy, 0, cx, cy, r)
|
||||
|
||||
else:
|
||||
return False
|
||||
|
||||
# Apply matrix to set coordinate system for gradient
|
||||
if gradient_node.get('gradientUnits') != 'userSpaceOnUse':
|
||||
gradient_pattern.set_matrix(cairo.Matrix(
|
||||
1 / width, 0, 0, 1 / height, - x / width, - y / height))
|
||||
|
||||
# Apply transform of gradient
|
||||
transform(
|
||||
surface, gradient_node.get('gradientTransform'), gradient_pattern)
|
||||
|
||||
# Apply gradient (<stop> by <stop>)
|
||||
offset = 0
|
||||
for child in gradient_node.children:
|
||||
offset = max(offset, size(surface, child.get('offset'), 1))
|
||||
stop_color = surface.map_color(
|
||||
child.get('stop-color', 'black'),
|
||||
float(child.get('stop-opacity', 1)) * opacity)
|
||||
gradient_pattern.add_color_stop_rgba(offset, *stop_color)
|
||||
|
||||
# Set spread method for gradient outside target bounds
|
||||
gradient_pattern.set_extend(EXTEND_OPERATORS.get(
|
||||
gradient_node.get('spreadMethod', 'pad'), EXTEND_OPERATORS['pad']))
|
||||
|
||||
surface.context.set_source(gradient_pattern)
|
||||
return True
|
||||
|
||||
|
||||
def draw_pattern(surface, node, name, opacity):
|
||||
"""Draw a pattern image."""
|
||||
pattern_node = surface.patterns[name]
|
||||
pattern_node['opacity'] = float(pattern_node.get('opacity', 1)) * opacity
|
||||
pattern_node.tag = 'g'
|
||||
transform(surface, pattern_node.get('patternTransform'))
|
||||
|
||||
if pattern_node.get('viewBox'):
|
||||
if not (size(surface, pattern_node.get('width', 1), 1) and
|
||||
size(surface, pattern_node.get('height', 1), 1)):
|
||||
return False
|
||||
else:
|
||||
if not (size(surface, pattern_node.get('width', 0), 1) and
|
||||
size(surface, pattern_node.get('height', 0), 1)):
|
||||
return False
|
||||
|
||||
if pattern_node.get('patternUnits') == 'userSpaceOnUse':
|
||||
x = size(surface, pattern_node.get('x'), 'x')
|
||||
y = size(surface, pattern_node.get('y'), 'y')
|
||||
pattern_width = size(surface, pattern_node.get('width', 0), 1)
|
||||
pattern_height = size(surface, pattern_node.get('height', 0), 1)
|
||||
else:
|
||||
_, _, width, height = calculate_bounding_box(surface, node)
|
||||
x = size(surface, pattern_node.get('x'), 1) * width
|
||||
y = size(surface, pattern_node.get('y'), 1) * height
|
||||
pattern_width = (
|
||||
size(surface, pattern_node.pop('width', '1'), 1) * width)
|
||||
pattern_height = (
|
||||
size(surface, pattern_node.pop('height', '1'), 1) * height)
|
||||
if 'viewBox' not in pattern_node:
|
||||
pattern_node['width'] = pattern_width
|
||||
pattern_node['height'] = pattern_height
|
||||
if pattern_node.get('patternContentUnits') == 'objectBoundingBox':
|
||||
pattern_node['transform'] = f'scale({width}, {height})'
|
||||
|
||||
# Fail if pattern has an invalid size
|
||||
if pattern_width == 0.0 or pattern_height == 0.0:
|
||||
return False
|
||||
|
||||
from .surface import SVGSurface # circular import
|
||||
pattern_surface = SVGSurface(pattern_node, None, surface.dpi, surface)
|
||||
pattern_pattern = cairo.SurfacePattern(pattern_surface.cairo)
|
||||
pattern_pattern.set_extend(cairo.EXTEND_REPEAT)
|
||||
pattern_pattern.set_matrix(cairo.Matrix(
|
||||
pattern_surface.width / pattern_width, 0, 0,
|
||||
pattern_surface.height / pattern_height, -x, -y))
|
||||
surface.context.set_source(pattern_pattern)
|
||||
return True
|
||||
|
||||
|
||||
def prepare_filter(surface, node, name):
|
||||
"""Apply a filter transforming the context."""
|
||||
if 'id' in node and node['id'] in surface.masks:
|
||||
return
|
||||
|
||||
if name in surface.filters:
|
||||
filter_node = surface.filters[name]
|
||||
for child in filter_node.children:
|
||||
# Offset
|
||||
if child.tag == 'feOffset':
|
||||
if filter_node.get('primitiveUnits') == 'objectBoundingBox':
|
||||
width = size(surface, node.get('width'), 'x')
|
||||
height = size(surface, node.get('height'), 'y')
|
||||
dx = size(surface, child.get('dx', 0), 1) * width
|
||||
dy = size(surface, child.get('dy', 0), 1) * height
|
||||
else:
|
||||
dx = size(surface, child.get('dx', 0), 1)
|
||||
dy = size(surface, child.get('dy', 0), 1)
|
||||
surface.context.translate(dx, dy)
|
||||
|
||||
|
||||
def apply_filter_before_painting(surface, node, name):
|
||||
"""Apply a filter transforming the painting operations."""
|
||||
if 'id' in node and node['id'] in surface.masks:
|
||||
return
|
||||
|
||||
if name in surface.filters:
|
||||
filter_node = surface.filters[name]
|
||||
for child in filter_node.children:
|
||||
# Blend
|
||||
if child.tag == 'feBlend':
|
||||
surface.context.set_operator(BLEND_OPERATORS.get(
|
||||
child.get('mode', 'normal'), BLEND_OPERATORS['normal']))
|
||||
|
||||
|
||||
def apply_filter_after_painting(surface, node, name):
|
||||
"""Apply a filter using the painted surface to transform the image."""
|
||||
if 'id' in node and node['id'] in surface.masks:
|
||||
return
|
||||
|
||||
if name in surface.filters:
|
||||
filter_node = surface.filters[name]
|
||||
for child in filter_node.children:
|
||||
# Flood
|
||||
if child.tag == 'feFlood':
|
||||
surface.context.save()
|
||||
surface.context.new_path()
|
||||
if filter_node.get('primitiveUnits') == 'objectBoundingBox':
|
||||
x = size(surface, node.get('x'), 'x')
|
||||
y = size(surface, node.get('y'), 'y')
|
||||
width = size(surface, node.get('width'), 'x')
|
||||
height = size(surface, node.get('height'), 'y')
|
||||
else:
|
||||
x, y, width, height = 0, 0, 1, 1
|
||||
x += size(surface, child.get('x', 0), 1)
|
||||
y += size(surface, child.get('y', 0), 1)
|
||||
width *= size(surface, child.get('width', 0), 1)
|
||||
height *= size(surface, child.get('height', 0), 1)
|
||||
rect(surface, dict(x=x, y=y, width=width, height=height))
|
||||
surface.context.set_source_rgba(*surface.map_color(
|
||||
paint(child.get('flood-color'))[1],
|
||||
float(child.get('flood-opacity', 1))))
|
||||
surface.context.fill()
|
||||
surface.context.restore()
|
||||
|
||||
|
||||
def use(surface, node):
|
||||
"""Draw the content of another SVG node."""
|
||||
surface.context.save()
|
||||
surface.context.translate(
|
||||
size(surface, node.get('x'), 'x'), size(surface, node.get('y'), 'y'))
|
||||
if 'x' in node:
|
||||
del node['x']
|
||||
if 'y' in node:
|
||||
del node['y']
|
||||
if 'viewBox' in node:
|
||||
del node['viewBox']
|
||||
if 'mask' in node:
|
||||
del node['mask']
|
||||
href = parse_url(node.get_href()).geturl()
|
||||
try:
|
||||
tree = Tree(
|
||||
url=href, url_fetcher=node.url_fetcher, parent=node,
|
||||
tree_cache=surface.tree_cache, unsafe=node.unsafe)
|
||||
except TypeError:
|
||||
surface.context.restore()
|
||||
return
|
||||
|
||||
if not match_features(tree.xml_tree):
|
||||
surface.context.restore()
|
||||
return
|
||||
|
||||
if tree.tag in ('svg', 'symbol'):
|
||||
# Explicitely specified
|
||||
# http://www.w3.org/TR/SVG11/struct.html#UseElement
|
||||
tree.tag = 'svg'
|
||||
if 'width' in node and 'height' in node:
|
||||
tree['width'], tree['height'] = node['width'], node['height']
|
||||
|
||||
surface.draw(tree)
|
||||
node.get('fill', None)
|
||||
node.get('stroke', None)
|
||||
surface.context.restore()
|
||||
@@ -0,0 +1,59 @@
|
||||
"""
|
||||
Helpers related to SVG conditional processing.
|
||||
|
||||
"""
|
||||
|
||||
import locale
|
||||
|
||||
ROOT = 'http://www.w3.org/TR/SVG11/feature'
|
||||
LOCALE = locale.getlocale()[0] or ''
|
||||
SUPPORTED_FEATURES = frozenset((
|
||||
ROOT + '#' + feature for feature in (
|
||||
'SVG',
|
||||
'SVG-static',
|
||||
'CoreAttribute',
|
||||
'Structure',
|
||||
'BasicStructure',
|
||||
'ConditionalProcessing',
|
||||
'Image',
|
||||
'Style',
|
||||
'ViewportAttribute',
|
||||
'Shape',
|
||||
'BasicText',
|
||||
'BasicPaintAttribute',
|
||||
'OpacityAttribute',
|
||||
'BasicGraphicsAttribute',
|
||||
'Marker',
|
||||
'Gradient',
|
||||
'Pattern',
|
||||
'Clip',
|
||||
'BasicClip',
|
||||
'Mask'
|
||||
)))
|
||||
|
||||
|
||||
def has_features(features):
|
||||
"""Check whether ``features`` are supported by CairoSVG."""
|
||||
return SUPPORTED_FEATURES >= set(features.strip().split(" "))
|
||||
|
||||
|
||||
def support_languages(languages):
|
||||
"""Check whether one of ``languages`` is part of the user locales."""
|
||||
for language in languages.split(','):
|
||||
language = language.strip()
|
||||
if language and LOCALE.startswith(language):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def match_features(node):
|
||||
"""Check the node match the conditional processing attributes."""
|
||||
features = node.attrib.get('requiredFeatures')
|
||||
languages = node.attrib.get('systemLanguage')
|
||||
if 'requiredExtensions' in node.attrib:
|
||||
return False
|
||||
if features is not None and not has_features(features):
|
||||
return False
|
||||
if languages is not None and not support_languages(languages):
|
||||
return False
|
||||
return True
|
||||
@@ -0,0 +1,390 @@
|
||||
"""
|
||||
Surface helpers.
|
||||
|
||||
"""
|
||||
|
||||
import re
|
||||
from math import atan2, cos, hypot, radians, sin, tan
|
||||
|
||||
from .surface import cairo
|
||||
from .url import parse_url
|
||||
|
||||
UNITS = {
|
||||
'mm': 1 / 25.4,
|
||||
'cm': 1 / 2.54,
|
||||
'in': 1,
|
||||
'pt': 1 / 72,
|
||||
'pc': 1 / 6,
|
||||
'px': None,
|
||||
}
|
||||
|
||||
PAINT_URL = re.compile(r'(url\(.+\)) *(.*)')
|
||||
PATH_LETTERS = 'achlmqstvzACHLMQSTVZ'
|
||||
RECT = re.compile(r'rect\( ?(.+?) ?\)')
|
||||
|
||||
|
||||
class PointError(Exception):
|
||||
"""Exception raised when parsing a point fails."""
|
||||
|
||||
|
||||
def distance(x1, y1, x2, y2):
|
||||
"""Get the distance between two points."""
|
||||
return hypot(x2 - x1, y2 - y1)
|
||||
|
||||
|
||||
def paint(value):
|
||||
"""Extract from value an uri and a color.
|
||||
|
||||
See http://www.w3.org/TR/SVG/painting.html#SpecifyingPaint
|
||||
|
||||
"""
|
||||
if not value:
|
||||
return None, None
|
||||
|
||||
value = value.strip()
|
||||
match = PAINT_URL.search(value)
|
||||
if match:
|
||||
source = parse_url(match.group(1)).fragment
|
||||
color = match.group(2) or None
|
||||
else:
|
||||
source = None
|
||||
color = value or None
|
||||
|
||||
return (source, color)
|
||||
|
||||
|
||||
def node_format(surface, node, reference=True):
|
||||
"""Return ``(width, height, viewbox)`` of ``node``.
|
||||
|
||||
If ``reference`` is ``True``, we can rely on surface size to resolve
|
||||
percentages.
|
||||
|
||||
"""
|
||||
reference_size = 'xy' if reference else (0, 0)
|
||||
width = size(surface, node.get('width', '100%'), reference_size[0])
|
||||
height = size(surface, node.get('height', '100%'), reference_size[1])
|
||||
viewbox = node.get('viewBox')
|
||||
if viewbox:
|
||||
viewbox = re.sub('[ \n\r\t,]+', ' ', viewbox)
|
||||
viewbox = tuple(float(position) for position in viewbox.split())
|
||||
width = width or viewbox[2]
|
||||
height = height or viewbox[3]
|
||||
return width, height, viewbox
|
||||
|
||||
|
||||
def normalize(string):
|
||||
"""Normalize a string corresponding to an array of various values."""
|
||||
string = string.replace('E', 'e')
|
||||
string = re.sub('(?<!e)-', ' -', string)
|
||||
string = re.sub('[ \n\r\t,]+', ' ', string)
|
||||
string = re.sub(r'(\.[0-9-]+)(?=\.)', r'\1 ', string)
|
||||
return string.strip()
|
||||
|
||||
|
||||
def point(surface, string):
|
||||
"""Return ``(x, y, trailing_text)`` from ``string``."""
|
||||
match = re.match('(.*?) (.*?)(?: |$)', string)
|
||||
if match:
|
||||
x, y = match.group(1, 2)
|
||||
string = string[match.end():]
|
||||
return (size(surface, x, 'x'), size(surface, y, 'y'), string)
|
||||
else:
|
||||
raise PointError
|
||||
|
||||
|
||||
def point_angle(cx, cy, px, py):
|
||||
"""Return angle between x axis and point knowing given center."""
|
||||
return atan2(py - cy, px - cx)
|
||||
|
||||
|
||||
def preserve_ratio(surface, node, width=None, height=None):
|
||||
"""Manage the ratio preservation."""
|
||||
if node.tag == 'marker':
|
||||
width = width or size(surface, node.get('markerWidth', '3'), 'x')
|
||||
height = height or size(surface, node.get('markerHeight', '3'), 'y')
|
||||
_, _, viewbox = node_format(surface, node)
|
||||
viewbox_width, viewbox_height = viewbox[2:]
|
||||
elif node.tag in ('svg', 'image', 'g'):
|
||||
node_width, node_height, _ = node_format(surface, node)
|
||||
width = width or node_width
|
||||
height = height or node_height
|
||||
viewbox_width, viewbox_height = node.image_width, node.image_height
|
||||
else:
|
||||
raise TypeError(
|
||||
f'Root node is {node.tag}. Should be one of '
|
||||
'marker, svg, image, or g.'
|
||||
)
|
||||
|
||||
translate_x = 0
|
||||
translate_y = 0
|
||||
scale_x = width / viewbox_width if viewbox_width > 0 else 1
|
||||
scale_y = height / viewbox_height if viewbox_height > 0 else 1
|
||||
|
||||
aspect_ratio = node.get('preserveAspectRatio', 'xMidYMid').split()
|
||||
align = aspect_ratio[0]
|
||||
if align == 'none':
|
||||
x_position = 'min'
|
||||
y_position = 'min'
|
||||
else:
|
||||
meet_or_slice = aspect_ratio[1] if len(aspect_ratio) > 1 else None
|
||||
if meet_or_slice == 'slice':
|
||||
scale_value = max(scale_x, scale_y)
|
||||
else:
|
||||
scale_value = min(scale_x, scale_y)
|
||||
scale_x = scale_y = scale_value
|
||||
x_position = align[1:4].lower()
|
||||
y_position = align[5:].lower()
|
||||
|
||||
if node.tag == 'marker':
|
||||
translate_x = -size(surface, node.get('refX', '0'), 'x')
|
||||
translate_y = -size(surface, node.get('refY', '0'), 'y')
|
||||
else:
|
||||
translate_x = 0
|
||||
if x_position == 'mid':
|
||||
translate_x = (width / scale_x - viewbox_width) / 2
|
||||
elif x_position == 'max':
|
||||
translate_x = width / scale_x - viewbox_width
|
||||
|
||||
translate_y = 0
|
||||
if y_position == 'mid':
|
||||
translate_y += (height / scale_y - viewbox_height) / 2
|
||||
elif y_position == 'max':
|
||||
translate_y += height / scale_y - viewbox_height
|
||||
|
||||
return scale_x, scale_y, translate_x, translate_y
|
||||
|
||||
|
||||
def clip_marker_box(surface, node, scale_x, scale_y):
|
||||
"""Get the clip ``(x, y, width, height)`` of the marker box."""
|
||||
width = size(surface, node.get('markerWidth', '3'), 'x')
|
||||
height = size(surface, node.get('markerHeight', '3'), 'y')
|
||||
_, _, viewbox = node_format(surface, node)
|
||||
viewbox_width, viewbox_height = viewbox[2:]
|
||||
|
||||
align = node.get('preserveAspectRatio', 'xMidYMid').split(' ')[0]
|
||||
x_position = 'min' if align == 'none' else align[1:4].lower()
|
||||
y_position = 'min' if align == 'none' else align[5:].lower()
|
||||
|
||||
clip_x = viewbox[0]
|
||||
if x_position == 'mid':
|
||||
clip_x += (viewbox_width - width / scale_x) / 2.
|
||||
elif x_position == 'max':
|
||||
clip_x += viewbox_width - width / scale_x
|
||||
|
||||
clip_y = viewbox[1]
|
||||
if y_position == 'mid':
|
||||
clip_y += (viewbox_height - height / scale_y) / 2.
|
||||
elif y_position == 'max':
|
||||
clip_y += viewbox_height - height / scale_y
|
||||
|
||||
return clip_x, clip_y, width / scale_x, height / scale_y
|
||||
|
||||
|
||||
def quadratic_points(x1, y1, x2, y2, x3, y3):
|
||||
"""Return the quadratic points to create quadratic curves."""
|
||||
xq1 = x2 * 2 / 3 + x1 / 3
|
||||
yq1 = y2 * 2 / 3 + y1 / 3
|
||||
xq2 = x2 * 2 / 3 + x3 / 3
|
||||
yq2 = y2 * 2 / 3 + y3 / 3
|
||||
return xq1, yq1, xq2, yq2, x3, y3
|
||||
|
||||
|
||||
def rotate(x, y, angle):
|
||||
"""Rotate a point of an angle around the origin point."""
|
||||
return x * cos(angle) - y * sin(angle), y * cos(angle) + x * sin(angle)
|
||||
|
||||
|
||||
def transform(surface, transform_string, gradient=None, transform_origin=None):
|
||||
"""Transform ``surface`` or ``gradient`` if supplied using ``string``.
|
||||
|
||||
See http://www.w3.org/TR/SVG/coords.html#TransformAttribute
|
||||
|
||||
"""
|
||||
if not transform_string:
|
||||
return
|
||||
|
||||
transformations = re.findall(
|
||||
r'(\w+) ?\( ?(.*?) ?\)', normalize(transform_string))
|
||||
matrix = cairo.Matrix()
|
||||
|
||||
if transform_origin:
|
||||
origin = transform_origin.split(' ')
|
||||
origin_x = origin[0]
|
||||
if len(origin) == 1:
|
||||
if origin_x in ('top', 'bottom'):
|
||||
origin_y = origin_x
|
||||
origin_x = surface.width / 2
|
||||
else:
|
||||
origin_y = surface.height / 2
|
||||
elif len(origin) > 1:
|
||||
if origin_x in ('top', 'bottom'):
|
||||
origin_y = origin_x
|
||||
origin_x = origin[1]
|
||||
else:
|
||||
origin_y = origin[1]
|
||||
else:
|
||||
return
|
||||
|
||||
if origin_x == 'center':
|
||||
origin_x = surface.width / 2
|
||||
elif origin_x == 'left':
|
||||
origin_x = 0
|
||||
elif origin_x == 'right':
|
||||
origin_x = surface.width
|
||||
else:
|
||||
origin_x = size(surface, origin_x, 'x')
|
||||
|
||||
if origin_y == 'center':
|
||||
origin_y = surface.height / 2
|
||||
elif origin_y == 'top':
|
||||
origin_y = 0
|
||||
elif origin_y == 'bottom':
|
||||
origin_y = surface.height
|
||||
else:
|
||||
origin_y = size(surface, origin_y, 'y')
|
||||
|
||||
matrix.translate(float(origin_x), float(origin_y))
|
||||
|
||||
for transformation_type, transformation in transformations:
|
||||
values = [size(surface, value) for value in transformation.split(' ')]
|
||||
if transformation_type == 'matrix':
|
||||
matrix = cairo.Matrix(*values).multiply(matrix)
|
||||
elif transformation_type == 'rotate':
|
||||
angle = radians(float(values.pop(0)))
|
||||
x, y = values or (0, 0)
|
||||
matrix.translate(x, y)
|
||||
matrix.rotate(angle)
|
||||
matrix.translate(-x, -y)
|
||||
elif transformation_type == 'skewX':
|
||||
tangent = tan(radians(float(values[0])))
|
||||
matrix = cairo.Matrix(1, 0, tangent, 1, 0, 0).multiply(matrix)
|
||||
elif transformation_type == 'skewY':
|
||||
tangent = tan(radians(float(values[0])))
|
||||
matrix = cairo.Matrix(1, tangent, 0, 1, 0, 0).multiply(matrix)
|
||||
elif transformation_type == 'translate':
|
||||
if len(values) == 1:
|
||||
values += (0,)
|
||||
matrix.translate(*values[:2])
|
||||
elif transformation_type == 'scale':
|
||||
if len(values) == 1:
|
||||
values = 2 * values
|
||||
matrix.scale(*values[:2])
|
||||
|
||||
if transform_origin:
|
||||
matrix.translate(-float(origin_x), -float(origin_y))
|
||||
|
||||
try:
|
||||
matrix.invert()
|
||||
except cairo.Error:
|
||||
# Matrix not invertible, clip the surface to an empty path
|
||||
active_path = surface.context.copy_path()
|
||||
surface.context.new_path()
|
||||
surface.context.clip()
|
||||
surface.context.append_path(active_path)
|
||||
else:
|
||||
if gradient:
|
||||
# When applied on gradient use already inverted matrix (mapping
|
||||
# from user space to gradient space)
|
||||
matrix_now = gradient.get_matrix()
|
||||
gradient.set_matrix(matrix_now.multiply(matrix))
|
||||
else:
|
||||
matrix.invert()
|
||||
surface.context.transform(matrix)
|
||||
|
||||
|
||||
def clip_rect(string):
|
||||
"""Parse the rect value of a clip."""
|
||||
match = RECT.search(normalize(string or ''))
|
||||
return match.group(1).split(' ') if match else []
|
||||
|
||||
|
||||
def rotations(node):
|
||||
"""Retrieves the original rotations of a `text` or `tspan` node."""
|
||||
if 'rotate' in node:
|
||||
original_rotate = [
|
||||
float(i) for i in normalize(node['rotate']).strip().split(' ')]
|
||||
return original_rotate
|
||||
return []
|
||||
|
||||
|
||||
def pop_rotation(node, original_rotate, rotate):
|
||||
"""Removes the rotations of a node that are already used."""
|
||||
node['rotate'] = ' '.join(
|
||||
str(rotate.pop(0) if rotate else original_rotate[-1])
|
||||
for i in range(len(node.text)))
|
||||
|
||||
|
||||
def zip_letters(xl, yl, dxl, dyl, rl, word):
|
||||
"""Returns a list with the current letter's positions (x, y and rotation).
|
||||
|
||||
E.g.: for letter 'L' with positions x = 10, y = 20 and rotation = 30:
|
||||
>>> [[10, 20, 30], 'L']
|
||||
|
||||
Store the last value of each position and pop the first one in order to
|
||||
avoid setting an x,y or rotation value that have already been used.
|
||||
|
||||
"""
|
||||
return (
|
||||
([pl.pop(0) if pl else None for pl in (xl, yl, dxl, dyl, rl)], char)
|
||||
for char in word)
|
||||
|
||||
|
||||
def flatten(node):
|
||||
"""Flatten the text of a node and its children."""
|
||||
flattened_text = [node.text or '']
|
||||
for child in list(node):
|
||||
flattened_text.append(flatten(child))
|
||||
flattened_text.append(child.tail or '')
|
||||
node.remove(child)
|
||||
return ''.join(flattened_text)
|
||||
|
||||
|
||||
def size(surface, string, reference='xy'):
|
||||
"""Replace a ``string`` with units by a float value.
|
||||
|
||||
If ``reference`` is a float, it is used as reference for percentages. If it
|
||||
is ``'x'``, we use the viewport width as reference. If it is ``'y'``, we
|
||||
use the viewport height as reference. If it is ``'xy'``, we use
|
||||
``hypot(viewport_width, viewport_height) / 2 ** .5`` as reference.
|
||||
|
||||
"""
|
||||
if not string:
|
||||
return 0
|
||||
|
||||
try:
|
||||
return float(string)
|
||||
except ValueError:
|
||||
# Not a float, try something else
|
||||
pass
|
||||
|
||||
# No surface (for parsing only)
|
||||
if surface is None:
|
||||
return 0
|
||||
|
||||
string = normalize(string).split(' ', 1)[0]
|
||||
if string.endswith('%'):
|
||||
if reference == 'x':
|
||||
reference = surface.context_width or 0
|
||||
elif reference == 'y':
|
||||
reference = surface.context_height or 0
|
||||
elif reference == 'xy':
|
||||
reference = (
|
||||
hypot(surface.context_width, surface.context_height) / 2 ** .5
|
||||
)
|
||||
return float(string[:-1]) * reference / 100
|
||||
elif string.endswith('em'):
|
||||
return surface.font_size * float(string[:-2])
|
||||
elif string.endswith('ex'):
|
||||
# Assume that 1em == 2ex
|
||||
return surface.font_size * float(string[:-2]) / 2
|
||||
elif string.endswith('ch'):
|
||||
# A '0' must be assumed to be 0.5em wide.
|
||||
return surface.font_size * float(string[:-2]) / 2
|
||||
|
||||
for unit, coefficient in UNITS.items():
|
||||
if string.endswith(unit):
|
||||
number = float(string[:-len(unit)])
|
||||
return number * (surface.dpi * coefficient if coefficient else 1)
|
||||
|
||||
# Unknown size
|
||||
return 0
|
||||
@@ -0,0 +1,123 @@
|
||||
"""
|
||||
Images manager.
|
||||
|
||||
"""
|
||||
|
||||
import os.path
|
||||
from io import BytesIO
|
||||
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
from .helpers import node_format, preserve_ratio, size
|
||||
from .parser import Tree
|
||||
from .surface import cairo
|
||||
from .url import parse_url
|
||||
|
||||
IMAGE_RENDERING = {
|
||||
'optimizeQuality': cairo.FILTER_BEST,
|
||||
'optimizeSpeed': cairo.FILTER_FAST,
|
||||
}
|
||||
|
||||
|
||||
def image(surface, node):
|
||||
"""Draw an image ``node``."""
|
||||
base_url = node.get('{http://www.w3.org/XML/1998/namespace}base')
|
||||
if not base_url and node.url:
|
||||
base_url = os.path.dirname(node.url) + '/'
|
||||
url = parse_url(node.get_href(), base_url)
|
||||
image_bytes = node.fetch_url(url, 'image/*')
|
||||
|
||||
if len(image_bytes) < 5:
|
||||
return
|
||||
|
||||
x, y = size(surface, node.get('x'), 'x'), size(surface, node.get('y'), 'y')
|
||||
width = size(surface, node.get('width'), 'x')
|
||||
height = size(surface, node.get('height'), 'y')
|
||||
|
||||
if image_bytes.startswith(b'\x89PNG') and not surface.map_image:
|
||||
png_file = BytesIO(image_bytes)
|
||||
elif (
|
||||
image_bytes.startswith((b'<svg ', b'<?xml', b'<!DOC', b'\x1f\x8b'))
|
||||
or b'<svg' in image_bytes
|
||||
):
|
||||
if 'x' in node:
|
||||
del node['x']
|
||||
if 'y' in node:
|
||||
del node['y']
|
||||
tree = Tree(
|
||||
url=url.geturl(), url_fetcher=node.url_fetcher,
|
||||
bytestring=image_bytes, tree_cache=surface.tree_cache,
|
||||
unsafe=node.unsafe)
|
||||
tree_width, tree_height, viewbox = node_format(
|
||||
surface, tree, reference=False)
|
||||
if viewbox:
|
||||
tree_scale_x = tree_width / viewbox[2]
|
||||
tree_scale_y = tree_height / viewbox[3]
|
||||
else:
|
||||
tree_width = tree['width'] = width
|
||||
tree_height = tree['height'] = height
|
||||
tree_scale_x = tree_scale_y = 1
|
||||
node.image_width = tree_width or width
|
||||
node.image_height = tree_height or height
|
||||
scale_x, scale_y, translate_x, translate_y = preserve_ratio(
|
||||
surface, node)
|
||||
|
||||
# Clip image region
|
||||
surface.context.rectangle(x, y, width, height)
|
||||
surface.context.clip()
|
||||
|
||||
# Draw image
|
||||
surface.context.save()
|
||||
surface.context.translate(x, y)
|
||||
surface.context.translate(*surface.context.get_current_point())
|
||||
surface.context.scale(scale_x * tree_scale_x, scale_y * tree_scale_y)
|
||||
surface.context.translate(translate_x, translate_y)
|
||||
surface.draw(tree)
|
||||
surface.context.restore()
|
||||
return
|
||||
else:
|
||||
png_file = BytesIO()
|
||||
image = ImageOps.exif_transpose(Image.open(BytesIO(image_bytes)))
|
||||
|
||||
if surface.map_image:
|
||||
image = surface.map_image(image)
|
||||
if image.mode == "CMYK":
|
||||
image = image.convert("RGB")
|
||||
image.save(png_file, 'PNG')
|
||||
png_file.seek(0)
|
||||
|
||||
image_surface = cairo.ImageSurface.create_from_png(png_file)
|
||||
image_surface.pattern = cairo.SurfacePattern(image_surface)
|
||||
image_surface.pattern.set_filter(IMAGE_RENDERING.get(
|
||||
node.get('image-rendering'), cairo.FILTER_GOOD))
|
||||
|
||||
node.image_width = image_surface.get_width()
|
||||
node.image_height = image_surface.get_height()
|
||||
width = width or node.image_width
|
||||
height = height or node.image_height
|
||||
scale_x, scale_y, translate_x, translate_y = preserve_ratio(
|
||||
surface, node, width, height)
|
||||
|
||||
# Clip image region (if necessary)
|
||||
if not (translate_x == 0 and
|
||||
translate_y == 0 and
|
||||
width == scale_x * node.image_width and
|
||||
height == scale_y * node.image_height):
|
||||
surface.context.rectangle(x, y, width, height)
|
||||
surface.context.clip()
|
||||
|
||||
# Paint raster image
|
||||
opacity = float(node.get('opacity', 1))
|
||||
surface.context.save()
|
||||
surface.context.translate(x, y)
|
||||
surface.context.scale(scale_x, scale_y)
|
||||
surface.context.translate(translate_x, translate_y)
|
||||
surface.context.set_source(image_surface.pattern)
|
||||
surface.context.paint_with_alpha(opacity)
|
||||
surface.context.restore()
|
||||
|
||||
|
||||
def invert_image(img):
|
||||
"""Invert the colors of an image."""
|
||||
*rgb, a = img.convert('RGBA').split()
|
||||
return Image.merge('RGBA', (*map(ImageOps.invert, rgb), a))
|
||||
@@ -0,0 +1,435 @@
|
||||
"""
|
||||
SVG Parser.
|
||||
|
||||
"""
|
||||
|
||||
import gzip
|
||||
import re
|
||||
from urllib.parse import urlunparse
|
||||
from xml.etree.ElementTree import Element
|
||||
|
||||
import cssselect2
|
||||
from defusedxml import ElementTree
|
||||
|
||||
from . import css
|
||||
from .features import match_features
|
||||
from .helpers import flatten, pop_rotation, rotations
|
||||
from .url import fetch, parse_url, read_url, safe_fetch
|
||||
|
||||
# 'display' is actually inherited but handled differently because some markers
|
||||
# are part of a none-displaying group (see test painting-marker-07-f.svg)
|
||||
NOT_INHERITED_ATTRIBUTES = frozenset((
|
||||
'clip',
|
||||
'clip-path',
|
||||
'display',
|
||||
'filter',
|
||||
'height',
|
||||
'id',
|
||||
'mask',
|
||||
'opacity',
|
||||
'overflow',
|
||||
'rotate',
|
||||
'stop-color',
|
||||
'stop-opacity',
|
||||
'style',
|
||||
'transform',
|
||||
'transform-origin',
|
||||
'viewBox',
|
||||
'width',
|
||||
'x',
|
||||
'y',
|
||||
'dx',
|
||||
'dy',
|
||||
'{http://www.w3.org/1999/xlink}href',
|
||||
'href',
|
||||
))
|
||||
|
||||
COLOR_ATTRIBUTES = frozenset((
|
||||
'fill',
|
||||
'flood-color',
|
||||
'lighting-color',
|
||||
'stop-color',
|
||||
'stroke',
|
||||
))
|
||||
|
||||
|
||||
def handle_white_spaces(string, preserve):
|
||||
"""Handle white spaces in text nodes.
|
||||
|
||||
See http://www.w3.org/TR/SVG/text.html#WhiteSpace
|
||||
|
||||
"""
|
||||
if not string:
|
||||
return ''
|
||||
if preserve:
|
||||
return re.sub('[\n\r\t]', ' ', string)
|
||||
else:
|
||||
string = re.sub('[\n\r]', '', string)
|
||||
string = re.sub('\t', ' ', string)
|
||||
return re.sub(' +', ' ', string)
|
||||
|
||||
|
||||
def normalize_style_declaration(name, value):
|
||||
"""Normalize style declaration consisting of name/value pair.
|
||||
|
||||
Names are always case insensitive, make all lowercase.
|
||||
Values are case insensitive in most cases. Adapt for 'specials':
|
||||
id - case sensitive identifier
|
||||
class - case sensitive identifier(s)
|
||||
font-family - case sensitive name(s)
|
||||
font - shorthand in which font-family is case sensitive
|
||||
any declaration with url in value - url is case sensitive
|
||||
|
||||
"""
|
||||
name = name.strip().lower()
|
||||
value = value.strip()
|
||||
if name in CASE_SENSITIVE_STYLE_METHODS:
|
||||
value = CASE_SENSITIVE_STYLE_METHODS[name](value)
|
||||
else:
|
||||
value = value.lower()
|
||||
|
||||
return name, value
|
||||
|
||||
|
||||
def normalize_noop_style_declaration(value):
|
||||
"""No-operation for normalization where value is case sensitive.
|
||||
|
||||
This is actually the exception to the rule. Normally value will be made
|
||||
lowercase (see normalize_style_declaration above).
|
||||
|
||||
"""
|
||||
return value
|
||||
|
||||
|
||||
def normalize_url_style_declaration(value):
|
||||
"""Normalize style declaration, but keep URL's as-is.
|
||||
|
||||
Lowercase everything except for the URL.
|
||||
|
||||
"""
|
||||
regex_style = re.compile(r"""
|
||||
(.*?) # non-URL part (will be normalized)
|
||||
(?:
|
||||
url\(\s* # url(<whitespace>
|
||||
(?:
|
||||
"(?:\\.|[^"])*" # "<url>"
|
||||
| \'(?:\\.|[^\'])*\' # '<url>'
|
||||
| (?:\\.|[^\)])* # <url>
|
||||
)
|
||||
\s*\) # <whitespace>)
|
||||
|$
|
||||
)
|
||||
""", re.IGNORECASE | re.VERBOSE)
|
||||
for match in regex_style.finditer(value):
|
||||
value_start = value[:match.start()] if match.start() > 0 else ''
|
||||
normalized_value = match.group(1).lower()
|
||||
value_end = value[match.start() + len(normalized_value):]
|
||||
value = value_start + normalized_value + value_end
|
||||
return value
|
||||
|
||||
|
||||
def normalize_font_style_declaration(value):
|
||||
"""Make first part of font style declaration lowercase (case insensitive).
|
||||
|
||||
Lowercase first part of declaration. Only the font name is case sensitive.
|
||||
The font name is at the end of the declaration and can be 'recognized'
|
||||
by being preceded by a size or line height. There can actually be multiple
|
||||
names. So the first part is 'calculated' by selecting everything up to and
|
||||
including the last valid token followed by a size or line height (both
|
||||
starting with a number). A valid token is either a size/length or an
|
||||
identifier.
|
||||
|
||||
See http://www.w3.org/TR/css-fonts-3/#font-prop
|
||||
|
||||
"""
|
||||
return re.sub(r"""
|
||||
^(
|
||||
(\d[^\s,]*|\w[^\s,]*) # <size>, <length> or <identifier>
|
||||
(\s+|\s*,\s*) # <whitespace> and/or comma
|
||||
)* # Repeat until last
|
||||
\d[^\s,]* # <size> or <line-height>
|
||||
""", lambda match: match.group().lower(), value, 0, re.VERBOSE)
|
||||
|
||||
|
||||
class Node(dict):
|
||||
"""SVG node with dict-like properties and children."""
|
||||
|
||||
def __init__(self, element, style, url_fetcher, parent=None,
|
||||
parent_children=False, url=None, unsafe=False):
|
||||
"""Create the Node from ElementTree ``node``, with ``parent`` Node."""
|
||||
super().__init__()
|
||||
self.children = ()
|
||||
|
||||
self.root = False
|
||||
|
||||
node = element.etree_element
|
||||
self.element = element
|
||||
self.style = style
|
||||
self.tag = (
|
||||
element.local_name
|
||||
if element.namespace_url in ('', 'http://www.w3.org/2000/svg') else
|
||||
f'{{{element.namespace_url}}}{element.local_name}')
|
||||
self.text = node.text
|
||||
self.url_fetcher = url_fetcher
|
||||
self.unsafe = unsafe
|
||||
|
||||
# Only set xml_tree if it's not been set before (ie. if node is a tree)
|
||||
self.xml_tree = getattr(self, 'xml_tree', node)
|
||||
|
||||
# Inherits from parent properties
|
||||
if parent is not None:
|
||||
self.update([
|
||||
(attribute, parent[attribute]) for attribute in parent
|
||||
if attribute not in NOT_INHERITED_ATTRIBUTES])
|
||||
self.url = url or parent.url
|
||||
self.parent = parent
|
||||
else:
|
||||
self.url = getattr(self, 'url', None)
|
||||
self.parent = getattr(self, 'parent', None)
|
||||
|
||||
self.update(self.xml_tree.attrib)
|
||||
|
||||
# Apply CSS rules
|
||||
style_attr = node.get('style')
|
||||
if style_attr:
|
||||
normal_attr, important_attr = css.parse_declarations(style_attr)
|
||||
else:
|
||||
normal_attr = []
|
||||
important_attr = []
|
||||
normal_matcher, important_matcher = style
|
||||
normal = [rule[-1] for rule in normal_matcher.match(element)]
|
||||
important = [rule[-1] for rule in important_matcher.match(element)]
|
||||
for declaration_lists in (
|
||||
normal, [normal_attr], important, [important_attr]):
|
||||
for declarations in declaration_lists:
|
||||
for name, value in declarations:
|
||||
self[name] = value.strip()
|
||||
|
||||
# Replace currentColor by a real color value
|
||||
for attribute in COLOR_ATTRIBUTES:
|
||||
if self.get(attribute) == 'currentColor':
|
||||
self[attribute] = self.get('color', 'black')
|
||||
|
||||
# Replace inherit by the parent value
|
||||
for attribute in [
|
||||
attribute for attribute in self
|
||||
if self[attribute] == 'inherit']:
|
||||
if parent is not None and attribute in parent:
|
||||
self[attribute] = parent.get(attribute)
|
||||
else:
|
||||
del self[attribute]
|
||||
|
||||
# Manage text by creating children
|
||||
if self.tag in ('text', 'textPath', 'a'):
|
||||
self.children, _ = self.text_children(
|
||||
element, trailing_space=True, text_root=True)
|
||||
|
||||
if parent_children:
|
||||
self.children = [
|
||||
Node(child.element, style, self.url_fetcher, parent=self,
|
||||
unsafe=self.unsafe)
|
||||
for child in parent.children]
|
||||
elif not self.children:
|
||||
self.children = []
|
||||
for child in element.iter_children():
|
||||
if match_features(child.etree_element):
|
||||
self.children.append(
|
||||
Node(child, style, self.url_fetcher, parent=self,
|
||||
unsafe=self.unsafe))
|
||||
if self.tag == 'switch':
|
||||
break
|
||||
|
||||
def fetch_url(self, url, resource_type):
|
||||
return read_url(url, self.url_fetcher, resource_type)
|
||||
|
||||
def text_children(self, element, trailing_space, text_root=False):
|
||||
"""Create children and return them."""
|
||||
children = []
|
||||
space = '{http://www.w3.org/XML/1998/namespace}space'
|
||||
preserve = self.get(space) == 'preserve'
|
||||
self.text = handle_white_spaces(element.etree_element.text, preserve)
|
||||
if trailing_space and not preserve:
|
||||
self.text = self.text.lstrip(' ')
|
||||
original_rotate = rotations(self)
|
||||
rotate = list(original_rotate)
|
||||
if original_rotate:
|
||||
pop_rotation(self, original_rotate, rotate)
|
||||
if self.text:
|
||||
trailing_space = self.text.endswith(' ')
|
||||
for child_element in element.iter_children():
|
||||
child = child_element.etree_element
|
||||
if child.tag in ('{http://www.w3.org/2000/svg}tref', 'tref'):
|
||||
href = child.get(
|
||||
'{http://www.w3.org/1999/xlink}href', child.get('href'))
|
||||
url = parse_url(href).geturl()
|
||||
child_tree = Tree(
|
||||
url=url, url_fetcher=self.url_fetcher, parent=self,
|
||||
unsafe=self.unsafe)
|
||||
child_tree.clear()
|
||||
child_tree.update(self)
|
||||
child_node = Node(
|
||||
child_element, self.style, self.url_fetcher,
|
||||
parent=child_tree, parent_children=True,
|
||||
unsafe=self.unsafe)
|
||||
child_node.tag = 'tspan'
|
||||
# Retrieve the referenced node and get its flattened text
|
||||
# and remove the node children.
|
||||
child = child_tree.xml_tree
|
||||
child.text = flatten(child)
|
||||
child_element = cssselect2.ElementWrapper.from_xml_root(child)
|
||||
else:
|
||||
child_node = Node(
|
||||
child_element, self.style, self.url_fetcher, parent=self,
|
||||
unsafe=self.unsafe)
|
||||
child_preserve = child_node.get(space) == 'preserve'
|
||||
child_node.text = handle_white_spaces(child.text, child_preserve)
|
||||
child_node.children, trailing_space = child_node.text_children(
|
||||
child_element, trailing_space)
|
||||
trailing_space = child_node.text.endswith(' ')
|
||||
if original_rotate and 'rotate' not in child_node:
|
||||
pop_rotation(child_node, original_rotate, rotate)
|
||||
children.append(child_node)
|
||||
if child.tail:
|
||||
anonymous_etree = Element('{http://www.w3.org/2000/svg}tspan')
|
||||
anonymous = Node(
|
||||
cssselect2.ElementWrapper.from_xml_root(anonymous_etree),
|
||||
self.style, self.url_fetcher, parent=self,
|
||||
unsafe=self.unsafe)
|
||||
anonymous.text = handle_white_spaces(child.tail, preserve)
|
||||
if original_rotate:
|
||||
pop_rotation(anonymous, original_rotate, rotate)
|
||||
if trailing_space and not preserve:
|
||||
anonymous.text = anonymous.text.lstrip(' ')
|
||||
if anonymous.text:
|
||||
trailing_space = anonymous.text.endswith(' ')
|
||||
children.append(anonymous)
|
||||
|
||||
if text_root and not children and not preserve:
|
||||
self.text = self.text.rstrip(' ')
|
||||
|
||||
return children, trailing_space
|
||||
|
||||
def get_href(self):
|
||||
return self.get('{http://www.w3.org/1999/xlink}href', self.get('href'))
|
||||
|
||||
|
||||
class Tree(Node):
|
||||
"""SVG tree."""
|
||||
def __new__(cls, **kwargs):
|
||||
tree_cache = kwargs.get('tree_cache')
|
||||
if tree_cache and kwargs.get('url'):
|
||||
parsed_url = parse_url(kwargs['url'])
|
||||
element_id = parsed_url.fragment
|
||||
parent = kwargs.get('parent')
|
||||
unsafe = kwargs.get('unsafe')
|
||||
if any(parsed_url[:-1]):
|
||||
url = urlunparse(parsed_url[:-1] + ('',))
|
||||
elif parent:
|
||||
url = parent.url
|
||||
else:
|
||||
url = None
|
||||
if url and (url, element_id) in tree_cache:
|
||||
cached_tree = tree_cache[(url, element_id)]
|
||||
new_tree = Node(
|
||||
cached_tree.element, cached_tree.style,
|
||||
cached_tree.url_fetcher, parent, unsafe=unsafe)
|
||||
new_tree.xml_tree = cached_tree.xml_tree
|
||||
new_tree.url = url
|
||||
new_tree.tag = cached_tree.tag
|
||||
new_tree.root = True
|
||||
return new_tree
|
||||
return super().__new__(cls)
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
"""Create the Tree from SVG ``text``."""
|
||||
bytestring = kwargs.get('bytestring')
|
||||
file_obj = kwargs.get('file_obj')
|
||||
url = kwargs.get('url')
|
||||
unsafe = kwargs.get('unsafe')
|
||||
parent = kwargs.get('parent')
|
||||
parent_children = kwargs.get('parent_children')
|
||||
tree_cache = kwargs.get('tree_cache')
|
||||
element_id = None
|
||||
|
||||
self.url_fetcher = kwargs.get('url_fetcher', fetch)
|
||||
|
||||
if bytestring is not None:
|
||||
self.url = url
|
||||
elif file_obj is not None:
|
||||
bytestring = file_obj.read()
|
||||
self.url = getattr(file_obj, 'name', None)
|
||||
if self.url == '<stdin>':
|
||||
self.url = None
|
||||
elif url is not None:
|
||||
parent_url = parent.url if parent else None
|
||||
parsed_url = parse_url(url, parent_url)
|
||||
if parsed_url.fragment:
|
||||
self.url = urlunparse(parsed_url[:-1] + ('',))
|
||||
element_id = parsed_url.fragment
|
||||
else:
|
||||
self.url = parsed_url.geturl()
|
||||
element_id = None
|
||||
self.url = self.url or None
|
||||
else:
|
||||
raise TypeError(
|
||||
'No input. Use one of bytestring, file_obj or url.')
|
||||
if isinstance(bytestring, str):
|
||||
bytestring = bytestring.encode()
|
||||
self_is_parent = (
|
||||
(parent and self.url == parent.url) or
|
||||
(url and url.startswith('#') and not self.url))
|
||||
if self_is_parent:
|
||||
root_parent = parent
|
||||
while root_parent.parent is not None:
|
||||
root_parent = root_parent.parent
|
||||
tree = root_parent.xml_tree
|
||||
else:
|
||||
if not bytestring:
|
||||
bytestring = self.fetch_url(
|
||||
parse_url(self.url), 'image/svg+xml')
|
||||
if bytestring.startswith(b'\x1f\x8b'):
|
||||
bytestring = gzip.decompress(bytestring)
|
||||
tree = ElementTree.fromstring(
|
||||
bytestring, forbid_entities=not unsafe,
|
||||
forbid_external=not unsafe)
|
||||
|
||||
# Don’t allow fetching external files unless explicitly asked for
|
||||
if 'url_fetcher' not in kwargs and not unsafe:
|
||||
self.url_fetcher = safe_fetch
|
||||
|
||||
self.xml_tree = tree
|
||||
root = cssselect2.ElementWrapper.from_xml_root(tree)
|
||||
style = parent.style if parent else css.parse_stylesheets(self, url)
|
||||
if element_id:
|
||||
for element in root.iter_subtree():
|
||||
if element.id == element_id:
|
||||
root = element
|
||||
self.xml_tree = element.etree_element
|
||||
break
|
||||
else:
|
||||
raise TypeError(
|
||||
f'No tag with id="{element_id}" found.')
|
||||
super().__init__(
|
||||
root, style, self.url_fetcher, parent, parent_children, self.url,
|
||||
unsafe)
|
||||
self.root = True
|
||||
if tree_cache is not None and self.url:
|
||||
tree_cache[(self.url, self.get('id'))] = self
|
||||
|
||||
|
||||
CASE_SENSITIVE_STYLE_METHODS = {
|
||||
'id': normalize_noop_style_declaration,
|
||||
'class': normalize_noop_style_declaration,
|
||||
'font-family': normalize_noop_style_declaration,
|
||||
'font': normalize_font_style_declaration,
|
||||
'clip-path': normalize_url_style_declaration,
|
||||
'color-profile': normalize_url_style_declaration,
|
||||
'cursor': normalize_url_style_declaration,
|
||||
'fill': normalize_url_style_declaration,
|
||||
'filter': normalize_url_style_declaration,
|
||||
'marker-start': normalize_url_style_declaration,
|
||||
'marker-mid': normalize_url_style_declaration,
|
||||
'marker-end': normalize_url_style_declaration,
|
||||
'mask': normalize_url_style_declaration,
|
||||
'stroke': normalize_url_style_declaration,
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
"""
|
||||
Paths manager.
|
||||
|
||||
"""
|
||||
|
||||
from math import copysign, hypot, pi, radians
|
||||
|
||||
from .bounding_box import calculate_bounding_box
|
||||
from .helpers import (
|
||||
PATH_LETTERS, clip_marker_box, node_format, normalize, point, point_angle,
|
||||
preserve_ratio, quadratic_points, rotate, size)
|
||||
from .url import parse_url
|
||||
|
||||
|
||||
def draw_markers(surface, node):
|
||||
"""Draw the markers attached to a path ``node``."""
|
||||
if not getattr(node, 'vertices', None):
|
||||
return
|
||||
|
||||
markers = {}
|
||||
common_marker = parse_url(node.get('marker', '')).fragment
|
||||
for position in ('start', 'mid', 'end'):
|
||||
attribute = f'marker-{position}'
|
||||
if attribute in node:
|
||||
markers[position] = parse_url(node[attribute]).fragment
|
||||
else:
|
||||
markers[position] = common_marker
|
||||
|
||||
angle1, angle2 = None, None
|
||||
position = 'start'
|
||||
|
||||
while node.vertices:
|
||||
# Calculate position and angle
|
||||
point = node.vertices.pop(0)
|
||||
angles = node.vertices.pop(0) if node.vertices else None
|
||||
if angles:
|
||||
if position == 'start':
|
||||
angle = pi - angles[0]
|
||||
else:
|
||||
angle = (angle2 + pi - angles[0]) / 2
|
||||
angle1, angle2 = angles
|
||||
else:
|
||||
angle = angle2
|
||||
position = 'end'
|
||||
|
||||
# Draw marker (if a marker exists for 'position')
|
||||
marker = markers[position]
|
||||
if marker:
|
||||
marker_node = surface.markers.get(marker)
|
||||
|
||||
# Calculate scale based on current stroke (if requested)
|
||||
if marker_node.get('markerUnits') == 'userSpaceOnUse':
|
||||
scale = 1
|
||||
else:
|
||||
scale = size(
|
||||
surface, surface.parent_node.get('stroke-width', '1'))
|
||||
|
||||
# Calculate position, (additional) scale and clipping based on
|
||||
# marker properties
|
||||
viewbox = node_format(surface, marker_node)[2]
|
||||
if viewbox:
|
||||
scale_x, scale_y, translate_x, translate_y = preserve_ratio(
|
||||
surface, marker_node)
|
||||
clip_box = clip_marker_box(
|
||||
surface, marker_node, scale_x, scale_y)
|
||||
else:
|
||||
# Calculate sizes
|
||||
marker_width = size(surface,
|
||||
marker_node.get('markerWidth', '3'), 'x')
|
||||
marker_height = size(surface,
|
||||
marker_node.get('markerHeight', '3'), 'y')
|
||||
bounding_box = calculate_bounding_box(surface, marker_node)
|
||||
|
||||
# Calculate position and scale (preserve aspect ratio)
|
||||
translate_x = -size(surface, marker_node.get('refX', '0'), 'x')
|
||||
translate_y = -size(surface, marker_node.get('refY', '0'), 'y')
|
||||
scale_x = scale_y = min(
|
||||
marker_width / bounding_box[2],
|
||||
marker_height / bounding_box[3])
|
||||
|
||||
# No clipping since viewbox is not present
|
||||
clip_box = None
|
||||
|
||||
# Add extra path for marker
|
||||
temp_path = surface.context.copy_path()
|
||||
surface.context.new_path()
|
||||
|
||||
# Override angle (if requested)
|
||||
node_angle = marker_node.get('orient', '0')
|
||||
if node_angle not in ('auto', 'auto-start-reverse'):
|
||||
angle = radians(float(node_angle))
|
||||
elif node_angle == 'auto-start-reverse' and position == 'start':
|
||||
angle += radians(180)
|
||||
|
||||
# Draw marker path
|
||||
# See http://www.w3.org/TR/SVG/painting.html#MarkerAlgorithm
|
||||
for child in marker_node.children:
|
||||
surface.context.save()
|
||||
surface.context.translate(*point)
|
||||
surface.context.rotate(angle)
|
||||
surface.context.scale(scale)
|
||||
surface.context.scale(scale_x, scale_y)
|
||||
surface.context.translate(translate_x, translate_y)
|
||||
|
||||
# Add clipping (if present and requested)
|
||||
overflow = marker_node.get('overflow', 'hidden')
|
||||
if clip_box and overflow in ('hidden', 'scroll'):
|
||||
surface.context.save()
|
||||
surface.context.rectangle(*clip_box)
|
||||
surface.context.restore()
|
||||
surface.context.clip()
|
||||
|
||||
surface.draw(child)
|
||||
surface.context.restore()
|
||||
|
||||
surface.context.append_path(temp_path)
|
||||
|
||||
position = 'mid' if angles else 'start'
|
||||
|
||||
|
||||
def path(surface, node):
|
||||
"""Draw a path ``node``."""
|
||||
string = node.get('d', '')
|
||||
|
||||
node.vertices = []
|
||||
|
||||
for letter in PATH_LETTERS:
|
||||
string = string.replace(letter, f' {letter} ')
|
||||
|
||||
last_letter = None
|
||||
string = normalize(string)
|
||||
|
||||
# Keep the current point because Cairo's get_current_point is not accurate
|
||||
# enough. See https://github.com/Kozea/CairoSVG/issues/111.
|
||||
if surface.context.has_current_point():
|
||||
current_point = surface.context.get_current_point()
|
||||
else:
|
||||
surface.context.move_to(0, 0)
|
||||
current_point = 0, 0
|
||||
|
||||
while string:
|
||||
string = string.strip()
|
||||
if string.split(' ', 1)[0] in PATH_LETTERS:
|
||||
letter, string = (string + ' ').split(' ', 1)
|
||||
if last_letter in (None, 'z', 'Z') and letter not in 'mM':
|
||||
node.vertices.append(current_point)
|
||||
first_path_point = current_point
|
||||
elif letter == 'M':
|
||||
letter = 'L'
|
||||
elif letter == 'm':
|
||||
letter = 'l'
|
||||
|
||||
if last_letter in (None, 'm', 'M', 'z', 'Z'):
|
||||
first_path_point = None
|
||||
if letter not in (None, 'm', 'M', 'z', 'Z') and (
|
||||
first_path_point is None):
|
||||
first_path_point = current_point
|
||||
|
||||
if letter in 'aA':
|
||||
# Elliptic curve
|
||||
surface.context.set_tolerance(0.00001)
|
||||
x1, y1 = current_point
|
||||
rx, ry, string = point(surface, string)
|
||||
rotation, string = string.split(' ', 1)
|
||||
rotation = radians(float(rotation))
|
||||
|
||||
# The large and sweep values are not always separated from the
|
||||
# following values. These flags can only be 0 or 1, so reading a
|
||||
# single digit suffices.
|
||||
large, string = string[0], string[1:].strip()
|
||||
sweep, string = string[0], string[1:].strip()
|
||||
|
||||
# Retrieve end point and set remainder (before checking flags)
|
||||
x3, y3, string = point(surface, string)
|
||||
|
||||
# Only allow 0 or 1 for flags
|
||||
large, sweep = int(large), int(sweep)
|
||||
if large not in (0, 1) or sweep not in (0, 1):
|
||||
continue
|
||||
large, sweep = bool(large), bool(sweep)
|
||||
|
||||
if letter == 'A':
|
||||
# Absolute x3 and y3, convert to relative
|
||||
x3 -= x1
|
||||
y3 -= y1
|
||||
|
||||
# rx=0 or ry=0 means straight line
|
||||
if not rx or not ry:
|
||||
if string and string[0] not in PATH_LETTERS:
|
||||
# As we replace the current operation by l, we must be sure
|
||||
# that the next letter is set to the real current letter (a
|
||||
# or A) in case it’s omitted
|
||||
next_letter = f'{letter} '
|
||||
else:
|
||||
next_letter = ''
|
||||
string = f'l {x3} {y3} {next_letter}{string}'
|
||||
continue
|
||||
|
||||
radii_ratio = ry / rx
|
||||
|
||||
# Cancel the rotation of the second point
|
||||
xe, ye = rotate(x3, y3, -rotation)
|
||||
ye /= radii_ratio
|
||||
|
||||
# Find the angle between the second point and the x axis
|
||||
angle = point_angle(0, 0, xe, ye)
|
||||
|
||||
# Put the second point onto the x axis
|
||||
xe = hypot(xe, ye)
|
||||
ye = 0
|
||||
|
||||
# Update the x radius if it is too small
|
||||
rx = max(rx, xe / 2)
|
||||
|
||||
# Find one circle centre
|
||||
xc = xe / 2
|
||||
yc = (rx ** 2 - xc ** 2) ** .5
|
||||
|
||||
# Choose between the two circles according to flags
|
||||
if not (large ^ sweep):
|
||||
yc = -yc
|
||||
|
||||
# Define the arc sweep
|
||||
arc = (
|
||||
surface.context.arc if sweep else surface.context.arc_negative)
|
||||
|
||||
# Put the second point and the center back to their positions
|
||||
xe, ye = rotate(xe, 0, angle)
|
||||
xc, yc = rotate(xc, yc, angle)
|
||||
|
||||
# Find the drawing angles
|
||||
angle1 = point_angle(xc, yc, 0, 0)
|
||||
angle2 = point_angle(xc, yc, xe, ye)
|
||||
|
||||
# Store the tangent angles
|
||||
node.vertices.append((-angle1, -angle2))
|
||||
|
||||
# Draw the arc
|
||||
surface.context.save()
|
||||
surface.context.translate(x1, y1)
|
||||
surface.context.rotate(rotation)
|
||||
surface.context.scale(1, radii_ratio)
|
||||
arc(xc, yc, rx, angle1, angle2)
|
||||
surface.context.restore()
|
||||
current_point = current_point[0] + x3, current_point[1] + y3
|
||||
|
||||
elif letter == 'c':
|
||||
# Relative curve
|
||||
x, y = current_point
|
||||
x1, y1, string = point(surface, string)
|
||||
x2, y2, string = point(surface, string)
|
||||
x3, y3, string = point(surface, string)
|
||||
node.vertices.append((
|
||||
point_angle(x2, y2, x1, y1), point_angle(x2, y2, x3, y3)))
|
||||
surface.context.rel_curve_to(x1, y1, x2, y2, x3, y3)
|
||||
current_point = current_point[0] + x3, current_point[1] + y3
|
||||
|
||||
# Save absolute values for x and y, useful if next letter is s or S
|
||||
x1 += x
|
||||
x2 += x
|
||||
x3 += x
|
||||
y1 += y
|
||||
y2 += y
|
||||
y3 += y
|
||||
|
||||
elif letter == 'C':
|
||||
# Curve
|
||||
x1, y1, string = point(surface, string)
|
||||
x2, y2, string = point(surface, string)
|
||||
x3, y3, string = point(surface, string)
|
||||
node.vertices.append((
|
||||
point_angle(x2, y2, x1, y1), point_angle(x2, y2, x3, y3)))
|
||||
surface.context.curve_to(x1, y1, x2, y2, x3, y3)
|
||||
current_point = x3, y3
|
||||
|
||||
elif letter == 'h':
|
||||
# Relative horizontal line
|
||||
x, string = (string + ' ').split(' ', 1)
|
||||
old_x, old_y = current_point
|
||||
angle = 0 if size(surface, x, 'x') > 0 else pi
|
||||
node.vertices.append((pi - angle, angle))
|
||||
x = size(surface, x, 'x')
|
||||
surface.context.rel_line_to(x, 0)
|
||||
current_point = current_point[0] + x, current_point[1]
|
||||
|
||||
elif letter == 'H':
|
||||
# Horizontal line
|
||||
x, string = (string + ' ').split(' ', 1)
|
||||
old_x, old_y = current_point
|
||||
angle = 0 if size(surface, x, 'x') > old_x else pi
|
||||
node.vertices.append((pi - angle, angle))
|
||||
x = size(surface, x, 'x')
|
||||
surface.context.line_to(x, old_y)
|
||||
current_point = x, current_point[1]
|
||||
|
||||
elif letter == 'l':
|
||||
# Relative straight line
|
||||
x, y, string = point(surface, string)
|
||||
angle = point_angle(0, 0, x, y)
|
||||
node.vertices.append((pi - angle, angle))
|
||||
surface.context.rel_line_to(x, y)
|
||||
current_point = current_point[0] + x, current_point[1] + y
|
||||
|
||||
elif letter == 'L':
|
||||
# Straight line
|
||||
x, y, string = point(surface, string)
|
||||
old_x, old_y = current_point
|
||||
angle = point_angle(old_x, old_y, x, y)
|
||||
node.vertices.append((pi - angle, angle))
|
||||
surface.context.line_to(x, y)
|
||||
current_point = x, y
|
||||
|
||||
elif letter == 'm':
|
||||
# Current point relative move
|
||||
x, y, string = point(surface, string)
|
||||
if last_letter and last_letter not in 'zZ':
|
||||
node.vertices.append(None)
|
||||
surface.context.rel_move_to(x, y)
|
||||
current_point = current_point[0] + x, current_point[1] + y
|
||||
|
||||
elif letter == 'M':
|
||||
# Current point move
|
||||
x, y, string = point(surface, string)
|
||||
if last_letter and last_letter not in 'zZ':
|
||||
node.vertices.append(None)
|
||||
surface.context.move_to(x, y)
|
||||
current_point = x, y
|
||||
|
||||
elif letter == 'q':
|
||||
# Relative quadratic curve
|
||||
x1, y1 = 0, 0
|
||||
x2, y2, string = point(surface, string)
|
||||
x3, y3, string = point(surface, string)
|
||||
xq1, yq1, xq2, yq2, xq3, yq3 = quadratic_points(
|
||||
x1, y1, x2, y2, x3, y3)
|
||||
surface.context.rel_curve_to(xq1, yq1, xq2, yq2, xq3, yq3)
|
||||
node.vertices.append((0, 0))
|
||||
current_point = current_point[0] + x3, current_point[1] + y3
|
||||
|
||||
elif letter == 'Q':
|
||||
# Quadratic curve
|
||||
x1, y1 = current_point
|
||||
x2, y2, string = point(surface, string)
|
||||
x3, y3, string = point(surface, string)
|
||||
xq1, yq1, xq2, yq2, xq3, yq3 = quadratic_points(
|
||||
x1, y1, x2, y2, x3, y3)
|
||||
surface.context.curve_to(xq1, yq1, xq2, yq2, xq3, yq3)
|
||||
node.vertices.append((0, 0))
|
||||
current_point = x3, y3
|
||||
|
||||
elif letter == 's':
|
||||
# Relative smooth curve
|
||||
x, y = current_point
|
||||
x1 = x3 - x2 if last_letter in 'csCS' else 0
|
||||
y1 = y3 - y2 if last_letter in 'csCS' else 0
|
||||
x2, y2, string = point(surface, string)
|
||||
x3, y3, string = point(surface, string)
|
||||
node.vertices.append((
|
||||
point_angle(x2, y2, x1, y1), point_angle(x2, y2, x3, y3)))
|
||||
surface.context.rel_curve_to(x1, y1, x2, y2, x3, y3)
|
||||
current_point = current_point[0] + x3, current_point[1] + y3
|
||||
|
||||
# Save absolute values for x and y, useful if next letter is s or S
|
||||
x1 += x
|
||||
x2 += x
|
||||
x3 += x
|
||||
y1 += y
|
||||
y2 += y
|
||||
y3 += y
|
||||
|
||||
elif letter == 'S':
|
||||
# Smooth curve
|
||||
x, y = current_point
|
||||
x1 = x3 + (x3 - x2) if last_letter in 'csCS' else x
|
||||
y1 = y3 + (y3 - y2) if last_letter in 'csCS' else y
|
||||
x2, y2, string = point(surface, string)
|
||||
x3, y3, string = point(surface, string)
|
||||
node.vertices.append((
|
||||
point_angle(x2, y2, x1, y1), point_angle(x2, y2, x3, y3)))
|
||||
surface.context.curve_to(x1, y1, x2, y2, x3, y3)
|
||||
current_point = x3, y3
|
||||
|
||||
elif letter == 't':
|
||||
# Relative quadratic curve end
|
||||
if last_letter not in 'QqTt':
|
||||
x2, y2, x3, y3 = 0, 0, 0, 0
|
||||
elif last_letter in 'QT':
|
||||
x2 -= x1
|
||||
y2 -= y1
|
||||
x3 -= x1
|
||||
y3 -= y1
|
||||
x2 = x3 - x2
|
||||
y2 = y3 - y2
|
||||
x1, y1 = 0, 0
|
||||
x3, y3, string = point(surface, string)
|
||||
xq1, yq1, xq2, yq2, xq3, yq3 = quadratic_points(
|
||||
x1, y1, x2, y2, x3, y3)
|
||||
node.vertices.append((0, 0))
|
||||
surface.context.rel_curve_to(xq1, yq1, xq2, yq2, xq3, yq3)
|
||||
current_point = current_point[0] + x3, current_point[1] + y3
|
||||
|
||||
elif letter == 'T':
|
||||
# Quadratic curve end
|
||||
abs_x, abs_y = current_point
|
||||
if last_letter not in 'QqTt':
|
||||
x2, y2, x3, y3 = abs_x, abs_y, abs_x, abs_y
|
||||
elif last_letter in 'qt':
|
||||
x2 += abs_x
|
||||
y2 += abs_y
|
||||
x3 += abs_x
|
||||
y3 += abs_y
|
||||
x2 = abs_x + (x3 - x2)
|
||||
y2 = abs_y + (y3 - y2)
|
||||
x1, y1 = abs_x, abs_y
|
||||
x3, y3, string = point(surface, string)
|
||||
xq1, yq1, xq2, yq2, xq3, yq3 = quadratic_points(
|
||||
x1, y1, x2, y2, x3, y3)
|
||||
node.vertices.append((0, 0))
|
||||
surface.context.curve_to(xq1, yq1, xq2, yq2, xq3, yq3)
|
||||
current_point = x3, y3
|
||||
|
||||
elif letter == 'v':
|
||||
# Relative vertical line
|
||||
y, string = (string + ' ').split(' ', 1)
|
||||
old_x, old_y = current_point
|
||||
angle = copysign(pi / 2, size(surface, y, 'y'))
|
||||
node.vertices.append((-angle, angle))
|
||||
y = size(surface, y, 'y')
|
||||
surface.context.rel_line_to(0, y)
|
||||
current_point = current_point[0], current_point[1] + y
|
||||
|
||||
elif letter == 'V':
|
||||
# Vertical line
|
||||
y, string = (string + ' ').split(' ', 1)
|
||||
old_x, old_y = current_point
|
||||
angle = copysign(pi / 2, size(surface, y, 'y') - old_y)
|
||||
node.vertices.append((-angle, angle))
|
||||
y = size(surface, y, 'y')
|
||||
surface.context.line_to(old_x, y)
|
||||
current_point = current_point[0], y
|
||||
|
||||
elif letter in 'zZ' and first_path_point:
|
||||
# End of path
|
||||
node.vertices.append(None)
|
||||
surface.context.close_path()
|
||||
current_point = first_path_point
|
||||
|
||||
if letter not in 'zZ':
|
||||
node.vertices.append(current_point)
|
||||
|
||||
string = string.strip()
|
||||
last_letter = letter
|
||||
@@ -0,0 +1,111 @@
|
||||
"""
|
||||
Shapes drawers.
|
||||
|
||||
"""
|
||||
|
||||
from math import pi
|
||||
|
||||
from .helpers import normalize, point, point_angle, size
|
||||
|
||||
|
||||
def circle(surface, node):
|
||||
"""Draw a circle ``node`` on ``surface``."""
|
||||
r = size(surface, node.get('r'))
|
||||
if not r:
|
||||
return
|
||||
cx = size(surface, node.get('cx'), 'x')
|
||||
cy = size(surface, node.get('cy'), 'y')
|
||||
|
||||
surface.context.new_sub_path()
|
||||
surface.context.arc(cx, cy, r, 0, 2 * pi)
|
||||
surface.context.close_path()
|
||||
|
||||
|
||||
def ellipse(surface, node):
|
||||
"""Draw an ellipse ``node`` on ``surface``."""
|
||||
rx = size(surface, node.get('rx'), 'x')
|
||||
ry = size(surface, node.get('ry'), 'y')
|
||||
if not rx or not ry:
|
||||
return
|
||||
cx = size(surface, node.get('cx'), 'x')
|
||||
cy = size(surface, node.get('cy'), 'y')
|
||||
|
||||
ratio = ry / rx
|
||||
surface.context.new_sub_path()
|
||||
surface.context.save()
|
||||
surface.context.scale(1, ratio)
|
||||
surface.context.arc(cx, cy / ratio, rx, 0, 2 * pi)
|
||||
surface.context.close_path()
|
||||
surface.context.restore()
|
||||
|
||||
|
||||
def line(surface, node):
|
||||
"""Draw a line ``node``."""
|
||||
x1, y1, x2, y2 = tuple(
|
||||
size(surface, node.get(position), position[0])
|
||||
for position in ('x1', 'y1', 'x2', 'y2'))
|
||||
surface.context.move_to(x1, y1)
|
||||
surface.context.line_to(x2, y2)
|
||||
angle = point_angle(x1, y1, x2, y2)
|
||||
node.vertices = [(x1, y1), (pi - angle, angle), (x2, y2)]
|
||||
|
||||
|
||||
def polygon(surface, node):
|
||||
"""Draw a polygon ``node`` on ``surface``."""
|
||||
polyline(surface, node)
|
||||
surface.context.close_path()
|
||||
|
||||
|
||||
def polyline(surface, node):
|
||||
"""Draw a polyline ``node``."""
|
||||
points = normalize(node.get('points', ''))
|
||||
if points:
|
||||
x, y, points = point(surface, points)
|
||||
surface.context.move_to(x, y)
|
||||
node.vertices = [(x, y)]
|
||||
while points:
|
||||
x_old, y_old = x, y
|
||||
x, y, points = point(surface, points)
|
||||
angle = point_angle(x_old, y_old, x, y)
|
||||
node.vertices.append((pi - angle, angle))
|
||||
surface.context.line_to(x, y)
|
||||
node.vertices.append((x, y))
|
||||
|
||||
|
||||
def rect(surface, node):
|
||||
"""Draw a rect ``node`` on ``surface``."""
|
||||
x, y = size(surface, node.get('x'), 'x'), size(surface, node.get('y'), 'y')
|
||||
width = size(surface, node.get('width'), 'x')
|
||||
height = size(surface, node.get('height'), 'y')
|
||||
rx = node.get('rx')
|
||||
ry = node.get('ry')
|
||||
if rx and ry is None:
|
||||
ry = rx
|
||||
elif ry and rx is None:
|
||||
rx = ry
|
||||
rx = size(surface, rx, 'x')
|
||||
ry = size(surface, ry, 'y')
|
||||
|
||||
if rx == 0 or ry == 0:
|
||||
surface.context.rectangle(x, y, width, height)
|
||||
else:
|
||||
rx = min(rx, width / 2)
|
||||
ry = min(ry, height / 2)
|
||||
|
||||
# Inspired by Cairo Cookbook
|
||||
# http://cairographics.org/cookbook/roundedrectangles/
|
||||
ARC_TO_BEZIER = 4 * (2 ** .5 - 1) / 3
|
||||
c1 = ARC_TO_BEZIER * rx
|
||||
c2 = ARC_TO_BEZIER * ry
|
||||
|
||||
surface.context.new_path()
|
||||
surface.context.move_to(x + rx, y)
|
||||
surface.context.rel_line_to(width - 2 * rx, 0)
|
||||
surface.context.rel_curve_to(c1, 0, rx, c2, rx, ry)
|
||||
surface.context.rel_line_to(0, height - 2 * ry)
|
||||
surface.context.rel_curve_to(0, c2, c1 - rx, ry, -rx, ry)
|
||||
surface.context.rel_line_to(-width + 2 * rx, 0)
|
||||
surface.context.rel_curve_to(-c1, 0, -rx, -c2, -rx, -ry)
|
||||
surface.context.rel_line_to(0, -height + 2 * ry)
|
||||
surface.context.rel_curve_to(0, -c2, rx - c1, -ry, rx, -ry)
|
||||
surface.context.close_path()
|
||||
@@ -0,0 +1,578 @@
|
||||
"""
|
||||
Cairo surface creators.
|
||||
|
||||
"""
|
||||
|
||||
import copy
|
||||
import io
|
||||
|
||||
import cairocffi as cairo
|
||||
|
||||
from .colors import color, negate_color
|
||||
from .defs import (
|
||||
apply_filter_after_painting, apply_filter_before_painting, clip_path,
|
||||
filter_, gradient_or_pattern, linear_gradient, marker, mask, paint_mask,
|
||||
parse_all_defs, pattern, prepare_filter, radial_gradient, use)
|
||||
from .helpers import (
|
||||
UNITS, PointError, clip_rect, node_format, normalize, paint,
|
||||
preserve_ratio, size, transform)
|
||||
from .image import image, invert_image
|
||||
from .parser import Tree
|
||||
from .path import draw_markers, path
|
||||
from .shapes import circle, ellipse, line, polygon, polyline, rect
|
||||
from .svg import svg
|
||||
from .text import text
|
||||
from .url import parse_url
|
||||
|
||||
SHAPE_ANTIALIAS = {
|
||||
'optimizeSpeed': cairo.ANTIALIAS_FAST,
|
||||
'crispEdges': cairo.ANTIALIAS_NONE,
|
||||
'geometricPrecision': cairo.ANTIALIAS_BEST,
|
||||
}
|
||||
|
||||
TEXT_ANTIALIAS = {
|
||||
'crispEdges': cairo.ANTIALIAS_NONE,
|
||||
'optimizeSpeed': cairo.ANTIALIAS_FAST,
|
||||
'optimizeLegibility': cairo.ANTIALIAS_GOOD,
|
||||
'geometricPrecision': cairo.ANTIALIAS_BEST,
|
||||
}
|
||||
|
||||
TEXT_HINT_STYLE = {
|
||||
'geometricPrecision': cairo.HINT_STYLE_NONE,
|
||||
'optimizeLegibility': cairo.HINT_STYLE_FULL,
|
||||
}
|
||||
|
||||
TEXT_HINT_METRICS = {
|
||||
'geometricPrecision': cairo.HINT_METRICS_OFF,
|
||||
'optimizeLegibility': cairo.HINT_METRICS_ON,
|
||||
}
|
||||
|
||||
TAGS = {
|
||||
'a': text,
|
||||
'circle': circle,
|
||||
'clipPath': clip_path,
|
||||
'ellipse': ellipse,
|
||||
'filter': filter_,
|
||||
'image': image,
|
||||
'line': line,
|
||||
'linearGradient': linear_gradient,
|
||||
'marker': marker,
|
||||
'mask': mask,
|
||||
'path': path,
|
||||
'pattern': pattern,
|
||||
'polyline': polyline,
|
||||
'polygon': polygon,
|
||||
'radialGradient': radial_gradient,
|
||||
'rect': rect,
|
||||
'svg': svg,
|
||||
'text': text,
|
||||
'textPath': text,
|
||||
'tspan': text,
|
||||
'use': use,
|
||||
}
|
||||
|
||||
PATH_TAGS = frozenset((
|
||||
'circle', 'ellipse', 'line', 'path', 'polygon', 'polyline', 'rect'))
|
||||
|
||||
INVISIBLE_TAGS = frozenset((
|
||||
'clipPath', 'filter', 'linearGradient', 'marker', 'mask', 'pattern',
|
||||
'radialGradient', 'symbol'))
|
||||
|
||||
|
||||
class Surface(object):
|
||||
"""Abstract base class for CairoSVG surfaces.
|
||||
|
||||
The ``width`` and ``height`` attributes are in device units (pixels for
|
||||
PNG, else points).
|
||||
|
||||
The ``context_width`` and ``context_height`` attributes are in user units
|
||||
(i.e. in pixels), they represent the size of the active viewport.
|
||||
|
||||
"""
|
||||
|
||||
# Subclasses must either define this or override _create_surface()
|
||||
surface_class = None
|
||||
|
||||
@classmethod
|
||||
def convert(cls, bytestring=None, *, file_obj=None, url=None, dpi=96,
|
||||
parent_width=None, parent_height=None, scale=1, unsafe=False,
|
||||
background_color=None, negate_colors=False,
|
||||
invert_images=False, write_to=None, output_width=None,
|
||||
output_height=None, **kwargs):
|
||||
"""Convert an SVG document to the format for this class.
|
||||
|
||||
Specify the input by passing one of these:
|
||||
|
||||
:param bytestring: The SVG source as a byte-string.
|
||||
:param file_obj: A file-like object.
|
||||
:param url: A filename.
|
||||
|
||||
Give some options:
|
||||
|
||||
:param dpi: The ratio between 1 inch and 1 pixel.
|
||||
:param parent_width: The width of the parent container in pixels.
|
||||
:param parent_height: The height of the parent container in pixels.
|
||||
:param scale: The ouptut scaling factor.
|
||||
:param unsafe: A boolean allowing external file access, XML entities
|
||||
and very large files
|
||||
(WARNING: vulnerable to XXE attacks and various DoS).
|
||||
|
||||
Specifiy the output with:
|
||||
|
||||
:param write_to: The filename of file-like object where to write the
|
||||
output. If None or not provided, return a byte string.
|
||||
|
||||
Only ``bytestring`` can be passed as a positional argument, other
|
||||
parameters are keyword-only.
|
||||
|
||||
"""
|
||||
tree = Tree(
|
||||
bytestring=bytestring, file_obj=file_obj, url=url, unsafe=unsafe,
|
||||
**kwargs)
|
||||
output = write_to or io.BytesIO()
|
||||
instance = cls(
|
||||
tree, output, dpi, None, parent_width, parent_height, scale,
|
||||
output_width, output_height, background_color,
|
||||
map_rgba=negate_color if negate_colors else None,
|
||||
map_image=invert_image if invert_images else None)
|
||||
instance.finish()
|
||||
if write_to is None:
|
||||
return output.getvalue()
|
||||
|
||||
def __init__(self, tree, output, dpi, parent_surface=None,
|
||||
parent_width=None, parent_height=None,
|
||||
scale=1, output_width=None, output_height=None,
|
||||
background_color=None, map_rgba=None, map_image=None):
|
||||
"""Create the surface from a filename or a file-like object.
|
||||
|
||||
The rendered content is written to ``output`` which can be a filename,
|
||||
a file-like object, ``None`` (render in memory but do not write
|
||||
anything) or the built-in ``bytes`` as a marker.
|
||||
|
||||
Call the ``.finish()`` method to make sure that the output is
|
||||
actually written.
|
||||
|
||||
"""
|
||||
self.cairo = None
|
||||
self.context_width, self.context_height = parent_width, parent_height
|
||||
self.cursor_position = [0, 0]
|
||||
self.cursor_d_position = [0, 0]
|
||||
self.text_path_width = 0
|
||||
self.tree_cache = {(tree.url, tree.get('id')): tree}
|
||||
if parent_surface:
|
||||
self.markers = parent_surface.markers
|
||||
self.gradients = parent_surface.gradients
|
||||
self.patterns = parent_surface.patterns
|
||||
self.masks = parent_surface.masks
|
||||
self.paths = parent_surface.paths
|
||||
self.filters = parent_surface.filters
|
||||
self.images = parent_surface.images
|
||||
else:
|
||||
self.markers = {}
|
||||
self.gradients = {}
|
||||
self.patterns = {}
|
||||
self.masks = {}
|
||||
self.paths = {}
|
||||
self.filters = {}
|
||||
self.images = {}
|
||||
self._old_parent_node = self.parent_node = None
|
||||
self.output = output
|
||||
self.dpi = dpi
|
||||
self.font_size = size(self, '12pt')
|
||||
self.stroke_and_fill = True
|
||||
width, height, viewbox = node_format(self, tree)
|
||||
if viewbox is None:
|
||||
viewbox = (0, 0, width, height)
|
||||
|
||||
if output_width and output_height:
|
||||
width, height = output_width, output_height
|
||||
elif output_width:
|
||||
if width:
|
||||
# Keep the aspect ratio
|
||||
height *= output_width / width
|
||||
width = output_width
|
||||
elif output_height:
|
||||
if height:
|
||||
# Keep the aspect ratio
|
||||
width *= output_height / height
|
||||
height = output_height
|
||||
else:
|
||||
width *= scale
|
||||
height *= scale
|
||||
|
||||
# Actual surface dimensions: may be rounded on raster surfaces types
|
||||
self.cairo, self.width, self.height = self._create_surface(
|
||||
width * self.device_units_per_user_units,
|
||||
height * self.device_units_per_user_units)
|
||||
|
||||
if 0 in (self.width, self.height):
|
||||
raise ValueError('The SVG size is undefined')
|
||||
|
||||
self.context = cairo.Context(self.cairo)
|
||||
# We must scale the context as the surface size is using physical units
|
||||
self.context.scale(
|
||||
self.device_units_per_user_units, self.device_units_per_user_units)
|
||||
# Initial, non-rounded dimensions
|
||||
self.set_context_size(width, height, viewbox, tree)
|
||||
self.context.move_to(0, 0)
|
||||
|
||||
if background_color:
|
||||
self.context.set_source_rgba(*color(background_color))
|
||||
self.context.paint()
|
||||
|
||||
self.map_rgba = map_rgba
|
||||
self.map_image = map_image
|
||||
self.draw(tree)
|
||||
|
||||
@property
|
||||
def points_per_pixel(self):
|
||||
"""Surface resolution."""
|
||||
return 1 / (self.dpi * UNITS['pt'])
|
||||
|
||||
@property
|
||||
def device_units_per_user_units(self):
|
||||
"""Ratio between Cairo device units and user units.
|
||||
|
||||
Device units are pixels for PNG and SVG, and points for everything
|
||||
else. User units are pixels.
|
||||
|
||||
"""
|
||||
return self.points_per_pixel
|
||||
|
||||
def _create_surface(self, width, height):
|
||||
"""Create and return ``(cairo_surface, width, height)``."""
|
||||
cairo_surface = self.surface_class(self.output, width, height)
|
||||
return cairo_surface, width, height
|
||||
|
||||
def set_context_size(self, width, height, viewbox, tree):
|
||||
"""Set the Cairo context size, set the SVG viewport size."""
|
||||
if viewbox:
|
||||
rect_x, rect_y = viewbox[0:2]
|
||||
tree.image_width = viewbox[2]
|
||||
tree.image_height = viewbox[3]
|
||||
else:
|
||||
rect_x, rect_y = 0, 0
|
||||
tree.image_width = width
|
||||
tree.image_height = height
|
||||
|
||||
scale_x, scale_y, translate_x, translate_y = preserve_ratio(
|
||||
self, tree, width, height)
|
||||
rect_x, rect_y = rect_x * scale_x, rect_y * scale_y
|
||||
rect_width, rect_height = width, height
|
||||
self.context.translate(*self.context.get_current_point())
|
||||
self.context.translate(-rect_x, -rect_y)
|
||||
if tree.get('overflow', 'hidden') != 'visible':
|
||||
self.context.rectangle(rect_x, rect_y, rect_width, rect_height)
|
||||
self.context.clip()
|
||||
self.context.scale(scale_x, scale_y)
|
||||
self.context.translate(translate_x, translate_y)
|
||||
self.context_width = rect_width / scale_x
|
||||
self.context_height = rect_height / scale_y
|
||||
|
||||
def finish(self):
|
||||
"""Read the surface content."""
|
||||
self.cairo.finish()
|
||||
|
||||
def map_color(self, string, opacity=1):
|
||||
"""Parse a color ``string`` and apply ``map_rgba`` function to it."""
|
||||
rgba = color(string, opacity)
|
||||
return self.map_rgba(rgba) if self.map_rgba else rgba
|
||||
|
||||
def draw(self, node):
|
||||
"""Draw ``node`` and its children."""
|
||||
|
||||
# Parse definitions first
|
||||
if node.tag == 'svg':
|
||||
parse_all_defs(self, node)
|
||||
|
||||
# Do not draw defs
|
||||
if node.tag == 'defs':
|
||||
return
|
||||
|
||||
# Do not draw elements with width or height of 0
|
||||
if (('width' in node and size(self, node['width']) == 0) or
|
||||
('height' in node and size(self, node['height']) == 0)):
|
||||
return
|
||||
|
||||
# Save context and related attributes
|
||||
old_parent_node = self.parent_node
|
||||
old_font_size = self.font_size
|
||||
old_context_size = self.context_width, self.context_height
|
||||
self.parent_node = node
|
||||
|
||||
if "font" in node:
|
||||
font = parse_font(node["font"])
|
||||
for att in font:
|
||||
if att not in node:
|
||||
node[att] = font[att]
|
||||
|
||||
self.font_size = size(self, node.get('font-size', '12pt'))
|
||||
self.context.save()
|
||||
|
||||
# Apply transformations
|
||||
transform(
|
||||
self, node.get('transform'),
|
||||
transform_origin=node.get('transform-origin'))
|
||||
|
||||
# Find and prepare opacity, masks and filters
|
||||
mask = parse_url(node.get('mask')).fragment
|
||||
filter_ = parse_url(node.get('filter')).fragment
|
||||
opacity = float(node.get('opacity', 1))
|
||||
|
||||
if filter_:
|
||||
prepare_filter(self, node, filter_)
|
||||
|
||||
if filter_ or mask or (opacity < 1 and node.children):
|
||||
self.context.push_group()
|
||||
|
||||
# Move to (node.x, node.y)
|
||||
self.context.move_to(
|
||||
size(self, node.get('x'), 'x'),
|
||||
size(self, node.get('y'), 'y'))
|
||||
|
||||
# Set node's drawing informations if the ``node.tag`` method exists
|
||||
line_cap = node.get('stroke-linecap')
|
||||
if line_cap == 'square':
|
||||
self.context.set_line_cap(cairo.LINE_CAP_SQUARE)
|
||||
if line_cap == 'round':
|
||||
self.context.set_line_cap(cairo.LINE_CAP_ROUND)
|
||||
|
||||
join_cap = node.get('stroke-linejoin')
|
||||
if join_cap == 'round':
|
||||
self.context.set_line_join(cairo.LINE_JOIN_ROUND)
|
||||
if join_cap == 'bevel':
|
||||
self.context.set_line_join(cairo.LINE_JOIN_BEVEL)
|
||||
|
||||
dash_array = normalize(node.get('stroke-dasharray', '')).split()
|
||||
if dash_array:
|
||||
dashes = [size(self, dash) for dash in dash_array]
|
||||
if sum(dashes):
|
||||
offset = size(self, node.get('stroke-dashoffset'))
|
||||
self.context.set_dash(dashes, offset)
|
||||
|
||||
miter_limit = float(node.get('stroke-miterlimit', 4))
|
||||
self.context.set_miter_limit(miter_limit)
|
||||
|
||||
# Clip
|
||||
rect_values = clip_rect(node.get('clip'))
|
||||
if len(rect_values) == 4:
|
||||
top = size(self, rect_values[0], 'y')
|
||||
right = size(self, rect_values[1], 'x')
|
||||
bottom = size(self, rect_values[2], 'y')
|
||||
left = size(self, rect_values[3], 'x')
|
||||
x = size(self, node.get('x'), 'x')
|
||||
y = size(self, node.get('y'), 'y')
|
||||
width = size(self, node.get('width'), 'x')
|
||||
height = size(self, node.get('height'), 'y')
|
||||
self.context.save()
|
||||
self.context.translate(x, y)
|
||||
self.context.rectangle(
|
||||
left, top, width - left - right, height - top - bottom)
|
||||
self.context.restore()
|
||||
self.context.clip()
|
||||
clip_path = parse_url(node.get('clip-path')).fragment
|
||||
if clip_path:
|
||||
path = self.paths.get(clip_path)
|
||||
if path:
|
||||
self.context.save()
|
||||
if path.get('clipPathUnits') == 'objectBoundingBox':
|
||||
x = size(self, node.get('x'), 'x')
|
||||
y = size(self, node.get('y'), 'y')
|
||||
width = size(self, node.get('width'), 'x')
|
||||
height = size(self, node.get('height'), 'y')
|
||||
self.context.translate(x, y)
|
||||
self.context.scale(width, height)
|
||||
path.tag = 'g'
|
||||
self.stroke_and_fill = False
|
||||
self.draw(path)
|
||||
self.stroke_and_fill = True
|
||||
path.tag = 'clipPath'
|
||||
self.context.restore()
|
||||
# TODO: fill rules are not handled by cairo for clips
|
||||
# if node.get('clip-rule') == 'evenodd':
|
||||
# self.context.set_fill_rule(cairo.FILL_RULE_EVEN_ODD)
|
||||
self.context.clip()
|
||||
self.context.set_fill_rule(cairo.FILL_RULE_WINDING)
|
||||
|
||||
save_cursor = copy.deepcopy(
|
||||
(self.cursor_position, self.cursor_d_position,
|
||||
self.text_path_width))
|
||||
|
||||
# Only draw known tags
|
||||
if node.tag in TAGS:
|
||||
try:
|
||||
TAGS[node.tag](self, node)
|
||||
except PointError:
|
||||
# Error in point parsing, do nothing
|
||||
pass
|
||||
|
||||
# Get stroke and fill opacity
|
||||
stroke_opacity = float(node.get('stroke-opacity', 1))
|
||||
fill_opacity = float(node.get('fill-opacity', 1))
|
||||
if opacity < 1 and not node.children:
|
||||
stroke_opacity *= opacity
|
||||
fill_opacity *= opacity
|
||||
|
||||
# Manage display and visibility
|
||||
display = node.get('display', 'inline') != 'none'
|
||||
visible = display and (node.get('visibility', 'visible') != 'hidden')
|
||||
|
||||
# Set font rendering properties
|
||||
self.context.set_antialias(SHAPE_ANTIALIAS.get(
|
||||
node.get('shape-rendering'), cairo.ANTIALIAS_DEFAULT))
|
||||
|
||||
font_options = self.context.get_font_options()
|
||||
font_options.set_antialias(TEXT_ANTIALIAS.get(
|
||||
node.get('text-rendering'), cairo.ANTIALIAS_DEFAULT))
|
||||
font_options.set_hint_style(TEXT_HINT_STYLE.get(
|
||||
node.get('text-rendering'), cairo.HINT_STYLE_DEFAULT))
|
||||
font_options.set_hint_metrics(TEXT_HINT_METRICS.get(
|
||||
node.get('text-rendering'), cairo.HINT_METRICS_DEFAULT))
|
||||
self.context.set_font_options(font_options)
|
||||
|
||||
# Fill and stroke
|
||||
if self.stroke_and_fill and visible and node.tag in TAGS:
|
||||
# Fill
|
||||
self.context.save()
|
||||
paint_source, paint_color = paint(node.get('fill', 'black'))
|
||||
if node.get('fill-rule') == 'evenodd':
|
||||
self.context.set_fill_rule(cairo.FILL_RULE_EVEN_ODD)
|
||||
if not gradient_or_pattern(self, node, paint_source, fill_opacity):
|
||||
self.context.set_source_rgba(
|
||||
*self.map_color(paint_color, fill_opacity))
|
||||
if TAGS[node.tag] == text:
|
||||
self.cursor_position = save_cursor[0]
|
||||
self.cursor_d_position = save_cursor[1]
|
||||
self.text_path_width = save_cursor[2]
|
||||
text(self, node, draw_as_text=True)
|
||||
else:
|
||||
self.context.fill_preserve()
|
||||
self.context.restore()
|
||||
|
||||
# Stroke
|
||||
self.context.save()
|
||||
self.context.set_line_width(
|
||||
size(self, node.get('stroke-width', '1')))
|
||||
paint_source, paint_color = paint(node.get('stroke'))
|
||||
if not gradient_or_pattern(
|
||||
self, node, paint_source, stroke_opacity):
|
||||
self.context.set_source_rgba(
|
||||
*self.map_color(paint_color, stroke_opacity))
|
||||
self.context.stroke()
|
||||
self.context.restore()
|
||||
elif not visible:
|
||||
self.context.new_path()
|
||||
|
||||
# Draw path markers
|
||||
draw_markers(self, node)
|
||||
|
||||
# Draw children
|
||||
if display and node.tag not in INVISIBLE_TAGS:
|
||||
for child in node.children:
|
||||
self.draw(child)
|
||||
|
||||
# Apply filter, mask and opacity
|
||||
if filter_ or mask or (opacity < 1 and node.children):
|
||||
self.context.pop_group_to_source()
|
||||
if filter_:
|
||||
apply_filter_before_painting(self, node, filter_)
|
||||
if mask in self.masks:
|
||||
paint_mask(self, node, mask, opacity)
|
||||
else:
|
||||
self.context.paint_with_alpha(opacity)
|
||||
if filter_:
|
||||
apply_filter_after_painting(self, node, filter_)
|
||||
|
||||
# Clean cursor's position after 'text' tags
|
||||
if node.tag == 'text':
|
||||
self.cursor_position = [0, 0]
|
||||
self.cursor_d_position = [0, 0]
|
||||
self.text_path_width = 0
|
||||
|
||||
self.context.restore()
|
||||
self.parent_node = old_parent_node
|
||||
self.font_size = old_font_size
|
||||
self.context_width, self.context_height = old_context_size
|
||||
|
||||
|
||||
class PDFSurface(Surface):
|
||||
"""A surface that writes in PDF format."""
|
||||
surface_class = cairo.PDFSurface
|
||||
|
||||
|
||||
class PSSurface(Surface):
|
||||
"""A surface that writes in PostScript format."""
|
||||
surface_class = cairo.PSSurface
|
||||
|
||||
|
||||
class EPSSurface(Surface):
|
||||
"""A surface that writes in Encapsulated PostScript format."""
|
||||
|
||||
def _create_surface(self, width, height):
|
||||
"""Create and return ``(cairo_surface, width, height)``."""
|
||||
cairo_surface = cairo.PSSurface(self.output, width, height)
|
||||
cairo_surface.set_eps(True)
|
||||
return cairo_surface, width, height
|
||||
|
||||
|
||||
class PNGSurface(Surface):
|
||||
"""A surface that writes in PNG format."""
|
||||
device_units_per_user_units = 1
|
||||
|
||||
def _create_surface(self, width, height):
|
||||
"""Create and return ``(cairo_surface, width, height)``."""
|
||||
width = int(round(width))
|
||||
height = int(round(height))
|
||||
cairo_surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, width, height)
|
||||
return cairo_surface, width, height
|
||||
|
||||
def finish(self):
|
||||
"""Read the PNG surface content."""
|
||||
if self.output is not None:
|
||||
self.cairo.write_to_png(self.output)
|
||||
return super().finish()
|
||||
|
||||
|
||||
class SVGSurface(Surface):
|
||||
"""A surface that writes in SVG format.
|
||||
|
||||
It may seem pointless to render SVG to SVG, but this can be used
|
||||
with ``output=None`` to get a vector-based single page cairo surface.
|
||||
|
||||
"""
|
||||
device_units_per_user_units = 1
|
||||
surface_class = cairo.SVGSurface
|
||||
|
||||
|
||||
def parse_font(value):
|
||||
ret = {"font-family": "", "font-size": "", "font-style": "normal",
|
||||
"font-variant": "normal", "font-weight": "normal",
|
||||
"line-height": "normal"}
|
||||
|
||||
font_styles = ["italic", "oblique"]
|
||||
font_variants = ["small-caps"]
|
||||
font_weights = ["bold", "bolder", "lighter", "100", "200", "300", "400",
|
||||
"500", "600", "700", "800", "900"]
|
||||
|
||||
for element in value.split():
|
||||
if element == "normal":
|
||||
continue
|
||||
elif ret["font-family"]:
|
||||
ret["font-family"] += " " + element
|
||||
elif element in font_styles:
|
||||
ret["font-style"] = element
|
||||
elif element in font_variants:
|
||||
ret["font-variant"] = element
|
||||
elif element in font_weights:
|
||||
ret["font-weight"] = element
|
||||
else:
|
||||
if not ret["font-size"]:
|
||||
parts = element.split("/")
|
||||
ret["font-size"] = parts[0]
|
||||
if len(parts) > 1:
|
||||
ret["line-height"] = parts[1]
|
||||
continue
|
||||
else:
|
||||
ret["font-family"] = element
|
||||
|
||||
return ret
|
||||
@@ -0,0 +1,13 @@
|
||||
"""
|
||||
Root tag drawer.
|
||||
|
||||
"""
|
||||
|
||||
from .helpers import node_format
|
||||
|
||||
|
||||
def svg(surface, node):
|
||||
"""Draw a svg ``node``."""
|
||||
if node.parent is not None:
|
||||
width, height, viewbox = node_format(surface, node)
|
||||
surface.set_context_size(width, height, viewbox, node)
|
||||
@@ -0,0 +1,190 @@
|
||||
"""
|
||||
CairoSVG API test suite.
|
||||
|
||||
These tests can be used as deployment tests.
|
||||
|
||||
"""
|
||||
|
||||
import io
|
||||
import sys
|
||||
|
||||
import cairocffi as cairo
|
||||
import pytest
|
||||
|
||||
from . import SURFACES, VERSION, parser, surface, svg2pdf, svg2png
|
||||
from .__main__ import main
|
||||
|
||||
MAGIC_NUMBERS = {
|
||||
'SVG': b'<?xml',
|
||||
'PNG': b'\211PNG\r\n\032\n',
|
||||
'PDF': b'%PDF',
|
||||
'PS': b'%!',
|
||||
}
|
||||
|
||||
SVG_SAMPLE = b'''\
|
||||
<?xml version="1.0"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="4in" height="5in">
|
||||
<rect x="5" y="10" width="13" height="15"
|
||||
fill="lime" stroke="black" stroke-width="1" />
|
||||
</svg>
|
||||
'''
|
||||
|
||||
|
||||
@pytest.mark.parametrize('format_name', MAGIC_NUMBERS)
|
||||
def test_formats(format_name):
|
||||
"""Convert to a given format and test that output looks right."""
|
||||
content = SURFACES[format_name].convert(SVG_SAMPLE)
|
||||
assert content.startswith(MAGIC_NUMBERS[format_name])
|
||||
|
||||
|
||||
def read_file(filename):
|
||||
"""Shortcut to return the whole content of a file as a byte string."""
|
||||
with open(filename, 'rb') as file_object:
|
||||
return file_object.read()
|
||||
|
||||
|
||||
def test_api(tmp_path):
|
||||
"""Test the Python API with various parameters."""
|
||||
expected_content = svg2png(SVG_SAMPLE)
|
||||
# Already tested above: just a sanity check:
|
||||
assert expected_content.startswith(MAGIC_NUMBERS['PNG'])
|
||||
|
||||
# Read from a byte string
|
||||
assert svg2png(SVG_SAMPLE) == expected_content
|
||||
assert svg2png(bytestring=SVG_SAMPLE) == expected_content
|
||||
|
||||
file_like = io.BytesIO(SVG_SAMPLE)
|
||||
# Read from a file-like object
|
||||
assert svg2png(file_obj=file_like) == expected_content
|
||||
|
||||
file_like = io.BytesIO()
|
||||
# Write to a file-like object
|
||||
svg2png(SVG_SAMPLE, write_to=file_like)
|
||||
assert file_like.getvalue() == expected_content
|
||||
|
||||
temp_0 = tmp_path / 'sample_0.svg'
|
||||
temp_0.write_bytes(SVG_SAMPLE)
|
||||
|
||||
# Read from a filename
|
||||
assert svg2png(url=str(temp_0)) == expected_content
|
||||
assert svg2png(url=f'file://{temp_0}') == expected_content
|
||||
|
||||
with temp_0.open('rb') as file_object:
|
||||
# Read from a real file object
|
||||
assert svg2png(file_obj=file_object) == expected_content
|
||||
|
||||
temp_1 = tmp_path / 'result_1.png'
|
||||
with temp_1.open('wb') as file_object:
|
||||
# Write to a real file object
|
||||
svg2png(SVG_SAMPLE, write_to=file_object)
|
||||
assert read_file(str(temp_1)) == expected_content
|
||||
|
||||
temp_2 = tmp_path / 'result_2.png'
|
||||
# Write to a filename
|
||||
svg2png(SVG_SAMPLE, write_to=str(temp_2))
|
||||
assert read_file(str(temp_2)) == expected_content
|
||||
|
||||
file_like = io.BytesIO()
|
||||
try:
|
||||
svg2png(write_to=file_like)
|
||||
except TypeError:
|
||||
pass
|
||||
else: # pragma: no cover
|
||||
raise Exception('TypeError not raised')
|
||||
|
||||
|
||||
def test_low_level_api():
|
||||
"""Test the low-level Python API with various parameters."""
|
||||
expected_content = svg2png(SVG_SAMPLE)
|
||||
|
||||
# Same as above, longer version
|
||||
tree = parser.Tree(bytestring=SVG_SAMPLE)
|
||||
file_like = io.BytesIO()
|
||||
png_surface = surface.PNGSurface(tree, file_like, 96)
|
||||
png_surface.finish()
|
||||
assert file_like.getvalue() == expected_content
|
||||
|
||||
png_result = cairo.ImageSurface.create_from_png(
|
||||
io.BytesIO(expected_content))
|
||||
expected_width = png_result.get_width()
|
||||
expected_height = png_result.get_height()
|
||||
|
||||
# Abstract surface
|
||||
png_surface = surface.PNGSurface(tree, None, 96)
|
||||
assert png_surface.width == expected_width
|
||||
assert png_surface.height == expected_height
|
||||
assert cairo.SurfacePattern(png_surface.cairo)
|
||||
|
||||
|
||||
def test_script(tmp_path):
|
||||
"""Test the ``cairosvg`` script and the ``main`` function."""
|
||||
expected_png = svg2png(SVG_SAMPLE)[:100]
|
||||
expected_pdf = svg2pdf(SVG_SAMPLE)[:100]
|
||||
|
||||
def test_main(args, exit_=False, input_=None, full=False):
|
||||
"""Test main called with given ``args``.
|
||||
|
||||
If ``exit_`` is ``True``, check that ``SystemExit`` is raised. We then
|
||||
assume that the program output is an unicode string.
|
||||
|
||||
If ``input_`` is given, use this stream as input stream.
|
||||
|
||||
"""
|
||||
sys.argv = ['cairosvg'] + args
|
||||
old_stdin, old_stdout = sys.stdin, sys.stdout
|
||||
|
||||
output_buffer = io.BytesIO()
|
||||
sys.stdout = io.TextIOWrapper(output_buffer)
|
||||
|
||||
if input_:
|
||||
sys.stdin = open(input_, 'rb')
|
||||
sys.stdin.buffer = sys.stdin
|
||||
|
||||
if exit_:
|
||||
try:
|
||||
main()
|
||||
except SystemExit:
|
||||
pass
|
||||
else: # pragma: no cover
|
||||
raise Exception('CairoSVG did not exit')
|
||||
else:
|
||||
main()
|
||||
|
||||
sys.stdout.flush()
|
||||
output = output_buffer.getvalue()
|
||||
sys.stdin, sys.stdout = old_stdin, old_stdout
|
||||
return output if full else output[:100]
|
||||
|
||||
svg_path = tmp_path / "test.svg"
|
||||
svg_path.write_bytes(SVG_SAMPLE)
|
||||
svg_filename = str(svg_path)
|
||||
|
||||
assert test_main(['--help'], exit_=True).startswith(b'usage: ')
|
||||
assert test_main(['--version'], exit_=True).strip() == (
|
||||
VERSION.encode('ascii'))
|
||||
|
||||
assert test_main([svg_filename]) == expected_pdf
|
||||
assert test_main([svg_filename, '-d', '96', '-f', 'pdf']) == expected_pdf
|
||||
assert test_main([svg_filename, '-f', 'png']) == expected_png
|
||||
assert test_main(['-'], input_=svg_filename) == expected_pdf
|
||||
|
||||
# Test DPI
|
||||
output = test_main([svg_filename, '-d', '10', '-f', 'png'], full=True)
|
||||
image = cairo.ImageSurface.create_from_png(io.BytesIO(output))
|
||||
assert image.get_width() == 40
|
||||
assert image.get_height() == 50
|
||||
|
||||
temp_1 = tmp_path / 'result_1'
|
||||
# Default to PDF
|
||||
assert not test_main([svg_filename, '-o', str(temp_1)])
|
||||
assert read_file(temp_1)[:100] == expected_pdf
|
||||
|
||||
temp_2 = tmp_path / 'result_2.png'
|
||||
# Guess from the file extension
|
||||
assert not test_main([svg_filename, '-o', str(temp_2)])
|
||||
assert read_file(temp_2)[:100] == expected_png
|
||||
|
||||
temp_3 = tmp_path / 'result_3.png'
|
||||
# Explicit -f wins
|
||||
assert not test_main([svg_filename, '-o', str(temp_3), '-f', 'pdf'])
|
||||
assert read_file(str(temp_3))[:100] == expected_pdf
|
||||
@@ -0,0 +1,232 @@
|
||||
"""
|
||||
Text drawers.
|
||||
|
||||
"""
|
||||
|
||||
from math import cos, radians, sin
|
||||
|
||||
from .bounding_box import (
|
||||
EMPTY_BOUNDING_BOX, extend_bounding_box, is_valid_bounding_box)
|
||||
from .helpers import distance, normalize, point_angle, size, zip_letters
|
||||
from .surface import cairo
|
||||
from .url import parse_url
|
||||
|
||||
|
||||
def path_length(path):
|
||||
"""Get the length of ``path``."""
|
||||
total_length = 0
|
||||
for item in path:
|
||||
if item[0] == cairo.PATH_MOVE_TO:
|
||||
old_point = item[1]
|
||||
elif item[0] == cairo.PATH_LINE_TO:
|
||||
new_point = item[1]
|
||||
length = distance(
|
||||
old_point[0], old_point[1], new_point[0], new_point[1])
|
||||
total_length += length
|
||||
old_point = new_point
|
||||
return total_length
|
||||
|
||||
|
||||
def point_following_path(path, width):
|
||||
"""Get the point at ``width`` distance on ``path``."""
|
||||
total_length = 0
|
||||
for item in path:
|
||||
if item[0] == cairo.PATH_MOVE_TO:
|
||||
old_point = item[1]
|
||||
elif item[0] == cairo.PATH_LINE_TO:
|
||||
new_point = item[1]
|
||||
length = distance(
|
||||
old_point[0], old_point[1], new_point[0], new_point[1])
|
||||
total_length += length
|
||||
if total_length < width:
|
||||
old_point = new_point
|
||||
else:
|
||||
length -= total_length - width
|
||||
angle = point_angle(
|
||||
old_point[0], old_point[1], new_point[0], new_point[1])
|
||||
x = cos(angle) * length + old_point[0]
|
||||
y = sin(angle) * length + old_point[1]
|
||||
return x, y
|
||||
|
||||
|
||||
def text(surface, node, draw_as_text=False):
|
||||
"""Draw a text ``node``."""
|
||||
font_family = (
|
||||
(node.get('font-family') or 'sans-serif').split(',')[0].strip('"\' '))
|
||||
font_style = getattr(
|
||||
cairo, f'font_slant_{node.get("font-style")}'.upper(),
|
||||
cairo.FONT_SLANT_NORMAL)
|
||||
node_font_weight = node.get('font-weight')
|
||||
if (node_font_weight and node_font_weight.isdigit()
|
||||
and int(node_font_weight) >= 550):
|
||||
node_font_weight = 'bold'
|
||||
font_weight = getattr(
|
||||
cairo, (f'font_weight_{node_font_weight}'.upper()),
|
||||
cairo.FONT_WEIGHT_NORMAL)
|
||||
surface.context.select_font_face(font_family, font_style, font_weight)
|
||||
surface.context.set_font_size(surface.font_size)
|
||||
ascent, descent, _, max_x_advance, max_y_advance = (
|
||||
surface.context.font_extents())
|
||||
|
||||
text_path_href = parse_url(node.get_href() or node.parent.get_href() or '')
|
||||
if text_path_href.fragment:
|
||||
text_path = surface.paths.get(text_path_href.fragment)
|
||||
else:
|
||||
text_path = None
|
||||
letter_spacing = size(surface, node.get('letter-spacing'))
|
||||
x_bearing, y_bearing, width, height = (
|
||||
surface.context.text_extents(node.text)[:4])
|
||||
|
||||
x, y, dx, dy, rotate = [], [], [], [], [0]
|
||||
if 'x' in node:
|
||||
x = [size(surface, i, 'x')
|
||||
for i in normalize(node['x']).strip().split(' ')]
|
||||
if 'y' in node:
|
||||
y = [size(surface, i, 'y')
|
||||
for i in normalize(node['y']).strip().split(' ')]
|
||||
if 'dx' in node:
|
||||
dx = [size(surface, i, 'x')
|
||||
for i in normalize(node['dx']).strip().split(' ')]
|
||||
if 'dy' in node:
|
||||
dy = [size(surface, i, 'y')
|
||||
for i in normalize(node['dy']).strip().split(' ')]
|
||||
if 'rotate' in node:
|
||||
rotate = [radians(float(i)) if i else 0
|
||||
for i in normalize(node['rotate']).strip().split(' ')]
|
||||
last_r = rotate[-1]
|
||||
letters_positions = zip_letters(x, y, dx, dy, rotate, node.text)
|
||||
|
||||
x_align = 0
|
||||
y_align = 0
|
||||
|
||||
text_anchor = node.get('text-anchor')
|
||||
if text_anchor == 'middle':
|
||||
x_align = - (width / 2 + x_bearing)
|
||||
if letter_spacing and node.text:
|
||||
x_align -= (len(node.text) - 1) * letter_spacing / 2
|
||||
elif text_anchor == 'end':
|
||||
x_align = - (width + x_bearing)
|
||||
if letter_spacing and node.text:
|
||||
x_align -= (len(node.text) - 1) * letter_spacing
|
||||
|
||||
# TODO: This is a hack. The rest of the baseline alignment tags of the SVG
|
||||
# 1.1 spec (section 10.9.2) are not supported. We only try to align things
|
||||
# that look like Western horizontal fonts.
|
||||
# Finally, we add a "display-anchor" attribute for aligning the specific
|
||||
# text rather than the font baseline.
|
||||
# Nonetheless, there are times when one needs to align text vertically, and
|
||||
# this will at least make that possible.
|
||||
if max_x_advance > 0 and max_y_advance == 0:
|
||||
display_anchor = node.get('display-anchor')
|
||||
alignment_baseline = (node.get('dominant-baseline') or
|
||||
node.get('alignment-baseline'))
|
||||
if display_anchor == 'middle':
|
||||
y_align = -height / 2 - y_bearing
|
||||
elif display_anchor == 'top':
|
||||
y_align = -y_bearing
|
||||
elif display_anchor == 'bottom':
|
||||
y_align = -height - y_bearing
|
||||
elif (alignment_baseline == 'central' or
|
||||
alignment_baseline == 'middle'):
|
||||
# TODO: This is wrong, Cairo gives no reasonable access to x-height
|
||||
# information, so we use font top-to-bottom
|
||||
y_align = (ascent + descent) / 2 - descent
|
||||
elif (alignment_baseline == 'text-before-edge' or
|
||||
alignment_baseline == 'before_edge' or
|
||||
alignment_baseline == 'top' or
|
||||
alignment_baseline == 'hanging' or
|
||||
alignment_baseline == 'text-top'):
|
||||
y_align = ascent
|
||||
elif (alignment_baseline == 'text-after-edge' or
|
||||
alignment_baseline == 'after_edge' or
|
||||
alignment_baseline == 'bottom' or
|
||||
alignment_baseline == 'text-bottom'):
|
||||
y_align = -descent
|
||||
|
||||
bounding_box = EMPTY_BOUNDING_BOX
|
||||
if text_path:
|
||||
surface.context.new_path()
|
||||
surface.stroke_and_fill = False
|
||||
surface.draw(text_path)
|
||||
surface.stroke_and_fill = True
|
||||
cairo_path = surface.context.copy_path_flat()
|
||||
surface.context.new_path()
|
||||
length = path_length(cairo_path) + x_bearing
|
||||
start_offset = size(surface, node.get('startOffset', 0), length)
|
||||
if node.tag == 'textPath':
|
||||
surface.text_path_width += start_offset
|
||||
surface.text_path_width += x_align
|
||||
bounding_box = extend_bounding_box(bounding_box, ((start_offset, 0),))
|
||||
|
||||
if node.text:
|
||||
for i, ((x, y, dx, dy, r), letter) in enumerate(letters_positions):
|
||||
if x:
|
||||
surface.cursor_d_position[0] = 0
|
||||
if y:
|
||||
surface.cursor_d_position[1] = 0
|
||||
surface.cursor_d_position[0] += dx or 0
|
||||
surface.cursor_d_position[1] += dy or 0
|
||||
text_extents = surface.context.text_extents(letter)
|
||||
extents = text_extents[4]
|
||||
if text_path:
|
||||
start = surface.text_path_width + surface.cursor_d_position[0]
|
||||
start_point = point_following_path(cairo_path, start)
|
||||
middle = start + extents / 2
|
||||
middle_point = point_following_path(cairo_path, middle)
|
||||
end = start + extents
|
||||
end_point = point_following_path(cairo_path, end)
|
||||
if i:
|
||||
extents += letter_spacing
|
||||
surface.text_path_width += extents
|
||||
if not all((start_point, middle_point, end_point)):
|
||||
continue
|
||||
if not 0 <= middle <= length:
|
||||
continue
|
||||
surface.context.save()
|
||||
surface.context.translate(*start_point)
|
||||
surface.context.rotate(point_angle(*(start_point + end_point)))
|
||||
surface.context.translate(0, surface.cursor_d_position[1])
|
||||
surface.context.move_to(0, 0)
|
||||
bounding_box = extend_bounding_box(
|
||||
bounding_box, ((end_point[0], text_extents[3]),))
|
||||
else:
|
||||
surface.context.save()
|
||||
x = surface.cursor_position[0] if x is None else x
|
||||
y = surface.cursor_position[1] if y is None else y
|
||||
if i:
|
||||
x += letter_spacing
|
||||
surface.context.move_to(x, y)
|
||||
cursor_position = x + extents, y
|
||||
surface.context.rel_move_to(*surface.cursor_d_position)
|
||||
surface.context.rel_move_to(x_align, y_align)
|
||||
surface.context.rotate(last_r if r is None else r)
|
||||
points = (
|
||||
(cursor_position[0] + x_align +
|
||||
surface.cursor_d_position[0],
|
||||
cursor_position[1] + y_align +
|
||||
surface.cursor_d_position[1]),
|
||||
(cursor_position[0] + x_align + text_extents[4] +
|
||||
surface.cursor_d_position[0],
|
||||
cursor_position[1] + y_align + text_extents[3] +
|
||||
surface.cursor_d_position[1]))
|
||||
bounding_box = extend_bounding_box(bounding_box, points)
|
||||
|
||||
# Only draw characters with 'content' (workaround for bug in cairo)
|
||||
if not letter.isspace():
|
||||
if draw_as_text:
|
||||
surface.context.show_text(letter)
|
||||
else:
|
||||
surface.context.text_path(letter)
|
||||
surface.context.restore()
|
||||
if not text_path:
|
||||
surface.cursor_position = cursor_position
|
||||
else:
|
||||
x = x[0] if x else surface.cursor_position[0]
|
||||
y = y[0] if y else surface.cursor_position[1]
|
||||
dx = dx[0] if dx else 0
|
||||
dy = dy[0] if dy else 0
|
||||
surface.cursor_position = (x + dx, y + dy)
|
||||
|
||||
# If a valid bounding box is calculated store it in the node
|
||||
if is_valid_bounding_box(bounding_box):
|
||||
node['text_bounding_box'] = bounding_box
|
||||
@@ -0,0 +1,154 @@
|
||||
"""
|
||||
Utils dealing with URLs.
|
||||
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from urllib.parse import urljoin, urlparse
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from . import VERSION
|
||||
|
||||
HTTP_HEADERS = {'User-Agent': f'CairoSVG {VERSION}'}
|
||||
|
||||
URL = re.compile(r'url\((.+)\)')
|
||||
|
||||
|
||||
def normalize_url(url):
|
||||
"""Normalize ``url`` for underlying NT/Unix operating systems.
|
||||
|
||||
The input ``url`` may look like the following:
|
||||
|
||||
- C:\\Directory\\zzz.svg
|
||||
- file://C:\\Directory\\zzz.svg
|
||||
- zzz.svg
|
||||
|
||||
The output ``url`` on NT systems would look like below:
|
||||
|
||||
- file:///C:/Directory/zzz.svg
|
||||
|
||||
"""
|
||||
if url and os.name == 'nt' and not url.startswith('data:'):
|
||||
# Match input ``url`` like the following:
|
||||
# - C:\\Directory\\zzz.svg
|
||||
# - Blah.svg
|
||||
if not url.startswith('file:') and os.path.isabs(url):
|
||||
url = os.path.abspath(url)
|
||||
if '#' in url:
|
||||
url, part = url.rsplit('#', 1)
|
||||
else:
|
||||
part = None
|
||||
url = Path(url).resolve().as_uri()
|
||||
if part is not None:
|
||||
url = url + '#' + part
|
||||
|
||||
# Match input ``url`` like the following:
|
||||
# - file://C:\\Directory\\zzz.svg
|
||||
elif re.match(
|
||||
'^file://[a-z]:', url,
|
||||
re.IGNORECASE | re.MULTILINE | re.DOTALL):
|
||||
url = url.replace('//', '///')
|
||||
url = url.replace('\\', '/')
|
||||
|
||||
return url
|
||||
|
||||
|
||||
def nt_compatible_path(path):
|
||||
"""Provide compatible NT file paths for ``os.path`` functions
|
||||
|
||||
``os.path`` expects NT paths with no ``/`` at the beginning. For
|
||||
example, ``/C:/Directory/zzz.svg`` would fail ``os.path.isfile()``,
|
||||
``os.path.isdir()`` etc. where the expected input for `os.path`
|
||||
functions is ``/C:/Directory/zzz.svg``.
|
||||
|
||||
Currently ``nt_compatible_path`` performs some basic checks and
|
||||
eliminates the unwanted ``/`` at the beginning.
|
||||
|
||||
"""
|
||||
if os.name == 'nt' and re.match(
|
||||
'^/[a-z]:/', path, re.IGNORECASE | re.MULTILINE | re.DOTALL):
|
||||
return re.sub('^/', '', path, re.IGNORECASE | re.MULTILINE | re.DOTALL)
|
||||
else:
|
||||
return path
|
||||
|
||||
|
||||
def fetch(url, resource_type):
|
||||
"""Fetch the content of ``url``.
|
||||
|
||||
``resource_type`` is the mimetype of the resource (currently one of
|
||||
image/*, image/svg+xml, text/css).
|
||||
|
||||
"""
|
||||
return urlopen(Request(url, headers=HTTP_HEADERS)).read()
|
||||
|
||||
|
||||
def safe_fetch(url, resource_type):
|
||||
"""Fetch the content of ``url`` only if it’s a data-URL.
|
||||
|
||||
Otherwise, return an empty SVG.
|
||||
|
||||
"""
|
||||
if url and url.startswith('data:'):
|
||||
return fetch(url, resource_type)
|
||||
return b'<svg width="1" height="1"></svg>'
|
||||
|
||||
|
||||
def parse_url(url, base=None):
|
||||
"""Parse an URL.
|
||||
|
||||
The URL can be surrounded by a ``url()`` string. If ``base`` is not `None`,
|
||||
the "folder" part of it is prepended to the URL.
|
||||
|
||||
"""
|
||||
if url:
|
||||
match = URL.search(url)
|
||||
if match:
|
||||
url = match.group(1)
|
||||
if base:
|
||||
parsed_base = urlparse(base)
|
||||
parsed_url = urlparse(url)
|
||||
if parsed_base.scheme in ('', 'file'):
|
||||
if parsed_url.scheme in ('', 'file'):
|
||||
parsed_base_path = nt_compatible_path(parsed_base.path)
|
||||
parsed_url_path = nt_compatible_path(parsed_url.path)
|
||||
# We are sure that `url` and `base` are both file-like URLs
|
||||
if os.path.isfile(parsed_base_path):
|
||||
if parsed_url_path:
|
||||
# Take the "folder" part of `base`, as
|
||||
# `os.path.join` doesn't strip the file name
|
||||
url = os.path.join(
|
||||
os.path.dirname(parsed_base_path),
|
||||
parsed_url_path)
|
||||
else:
|
||||
url = parsed_base_path
|
||||
elif os.path.isdir(parsed_base_path):
|
||||
if parsed_url_path:
|
||||
url = os.path.join(
|
||||
parsed_base_path, parsed_url_path)
|
||||
else:
|
||||
url = ''
|
||||
else:
|
||||
url = ''
|
||||
if parsed_url.fragment:
|
||||
url = f'{url}#{parsed_url.fragment}'
|
||||
elif parsed_url.scheme in ('', parsed_base.scheme):
|
||||
# `urljoin` automatically uses the "folder" part of `base`
|
||||
url = urljoin(base, url)
|
||||
url = normalize_url(url.strip('\'"'))
|
||||
return urlparse(url or '')
|
||||
|
||||
|
||||
def read_url(url, url_fetcher, resource_type):
|
||||
"""Get bytes in a parsed ``url`` using ``url_fetcher``.
|
||||
|
||||
If ``url_fetcher`` is None a default (no limitations) URLFetcher is used.
|
||||
"""
|
||||
if url.scheme:
|
||||
url = url.geturl()
|
||||
else:
|
||||
url = f'file://{os.path.abspath(url.geturl())}'
|
||||
url = normalize_url(url)
|
||||
|
||||
return url_fetcher(url, resource_type)
|
||||
Reference in New Issue
Block a user