Add existing to tracked

This commit is contained in:
Jay
2026-08-11 09:53:42 -04:00
parent afe07f3055
commit ffd6e3d73c
8531 changed files with 4396230 additions and 0 deletions
@@ -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)
# Dont 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 its 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 its 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)