mirror of https://github.com/google/pebble
486 lines
18 KiB
Python
486 lines
18 KiB
Python
# coding=utf-8
|
|
from exceptions import *
|
|
from StringIO import StringIO
|
|
from functools import partial
|
|
from lxml import etree
|
|
import cairosvg
|
|
from cairosvg.parser import Tree
|
|
from cairosvg.surface import size, node_format, normalize, gradient_or_pattern, color
|
|
from cairosvg.surface.helpers import point, paint
|
|
import io
|
|
from pdc import PathCommand, CircleCommand, extend_bounding_box, bounding_box_around_points
|
|
from annotation import Annotation, NS_ANNOTATION, PREFIX_ANNOTATION, TAG_HIGHLIGHT
|
|
from pebble_image_routines import truncate_color_to_pebble64_palette, rgba32_triplet_to_argb8
|
|
|
|
try:
|
|
import cairocffi as cairo
|
|
# OSError means cairocffi is installed,
|
|
# but could not load a cairo dynamic library.
|
|
# pycairo may still be available with a statically-linked cairo.
|
|
except (ImportError, OSError):
|
|
import cairo # pycairo
|
|
|
|
|
|
def cairo_from_png(path):
|
|
surface = cairo.ImageSurface.create_from_png(path)
|
|
return surface, cairo.Context(surface)
|
|
|
|
|
|
class PDCContext(cairo.Context):
|
|
def line_to(self, x, y):
|
|
super(PDCContext, self).line_to(x, y)
|
|
|
|
|
|
# http://effbot.org/zone/element-lib.htm#prettyprint
|
|
def indent(elem, level=0):
|
|
i = "\n" + level*" "
|
|
if len(elem):
|
|
if not elem.text or not elem.text.strip():
|
|
elem.text = i + " "
|
|
if not elem.tail or not elem.tail.strip():
|
|
elem.tail = i
|
|
for elem in elem:
|
|
indent(elem, level+1)
|
|
if not elem.tail or not elem.tail.strip():
|
|
elem.tail = i
|
|
else:
|
|
if level and (not elem.tail or not elem.tail.strip()):
|
|
elem.tail = i
|
|
|
|
|
|
class PDCSurface(cairosvg.surface.PNGSurface):
|
|
# noinspection PyMissingConstructor
|
|
def __init__(self, tree, output, dpi, parent_surface=None, approximate_bezier=False):
|
|
self.svg_tree = tree
|
|
self.cairo = None
|
|
self.cairosvg_tags = []
|
|
self.pdc_commands = []
|
|
self.approximate_bezier = approximate_bezier
|
|
self.stored_size = None
|
|
self.context_width, self.context_height = None, None
|
|
self.cursor_position = [0, 0]
|
|
self.cursor_d_position = [0, 0]
|
|
self.text_path_width = 0
|
|
self.tree_cache = {(tree.url, tree["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
|
|
else:
|
|
self.markers = {}
|
|
self.gradients = {}
|
|
self.patterns = {}
|
|
self.masks = {}
|
|
self.paths = {}
|
|
self.filters = {}
|
|
self.page_sizes = []
|
|
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
|
|
# we only support px
|
|
for unit in ["mm", "cm", "in", "pt", "pc"]:
|
|
for attr in ["width", "height"]:
|
|
value = tree.get(attr)
|
|
if value is not None and unit in value:
|
|
tree.pop(attr)
|
|
value = None
|
|
|
|
width, height, viewbox = node_format(self, tree)
|
|
# 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)
|
|
self.page_sizes.append((self.width, self.height))
|
|
self.context = PDCContext(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)
|
|
|
|
# SVG spec says "viewbox of size 0 means 'don't render'"
|
|
if viewbox is not None and viewbox[2] <= 0 and viewbox[3] <= 0:
|
|
return
|
|
|
|
# Initial, non-rounded dimensions
|
|
self.set_context_size(width, height, viewbox)
|
|
self.context.move_to(0, 0)
|
|
|
|
# register PDC namespace and add temporary fake attribute to propagate prefix
|
|
etree.register_namespace(PREFIX_ANNOTATION, NS_ANNOTATION)
|
|
ns_fake_attr = "{%s}foo" % NS_ANNOTATION
|
|
tree.node.set(ns_fake_attr, "bar")
|
|
|
|
# remove all PDC elements (annotations in case we're processing an annotated SVG)
|
|
def remove_pdc_elements(elem):
|
|
for child in elem:
|
|
if isinstance(child.tag, str) and child.tag.startswith("{%s}" % NS_ANNOTATION):
|
|
elem.remove(child)
|
|
else:
|
|
remove_pdc_elements(child)
|
|
|
|
remove_pdc_elements(tree.node)
|
|
self.draw_root(tree)
|
|
tree.node.attrib.pop(ns_fake_attr)
|
|
|
|
def size(self):
|
|
if self.stored_size is not None:
|
|
return self.stored_size
|
|
|
|
result = None
|
|
for command in self.pdc_commands:
|
|
result = extend_bounding_box(result, rect2=command.bounding_box())
|
|
|
|
if result is None:
|
|
return (0, 0)
|
|
|
|
# returned size is diagonal from (0, 0) to max_x/max_y
|
|
return (max(0, result[0] + result[2]), max(0, result[1] + result[3]))
|
|
|
|
def cairo_tag_func(self, tag):
|
|
return self.cairosvg_tags[0][tag]
|
|
|
|
def cairo_tags_push_and_wrap(self):
|
|
self.cairosvg_tags.append(cairosvg.surface.TAGS.copy())
|
|
custom_impl = {"polyline": polyline, "polygon": polygon, "line": line, "rect": rect, "circle": circle,
|
|
"path": path, "svg": svg}
|
|
for k,v in custom_impl.iteritems():
|
|
original = self.cairo_tag_func(k)
|
|
cairosvg.surface.TAGS[k] = partial(custom_impl[k], original=original)
|
|
|
|
def draw_root(self, node):
|
|
if node.get("display", "").upper() == 'NONE':
|
|
node.annotations = []
|
|
Annotation(node, 'Attribute display="none" for root element will be ignored.')
|
|
node.pop("display")
|
|
|
|
super(PDCSurface, self).draw_root(node)
|
|
|
|
def draw(self, node):
|
|
self.cairo_tags_push_and_wrap()
|
|
if not hasattr(node, "annotations"):
|
|
node.annotations = []
|
|
super(PDCSurface, self).draw(node)
|
|
cairosvg.surface.TAGS = self.cairosvg_tags.pop()
|
|
|
|
def element_tree(self):
|
|
indent(self.svg_tree.node)
|
|
# ensure that viewbox is always set
|
|
view_box = self.svg_tree.node.get("viewBox", "0 0 %d %d" % self.size())
|
|
self.svg_tree.node.set("viewBox", view_box)
|
|
|
|
return etree.ElementTree(self.svg_tree.node)
|
|
|
|
def render_annoations_on_top(self, png_path):
|
|
surface, ctx = cairo_from_png(png_path)
|
|
|
|
def iterate(elem):
|
|
if TAG_HIGHLIGHT == elem.tag:
|
|
args = [float(elem.get(k, "1")) for k in ["x", "y", "width", "height"]]
|
|
ctx.rectangle(*args)
|
|
ctx.set_source_rgb(1, 0, 0)
|
|
ctx.stroke()
|
|
|
|
for child in elem:
|
|
iterate(child)
|
|
|
|
iterate(self.svg_tree.node)
|
|
result = StringIO()
|
|
surface.write_to_png(result)
|
|
return result.getvalue()
|
|
|
|
|
|
def gcolor8_is_visible(color):
|
|
return (color & 0b11000000) != 0
|
|
|
|
|
|
def svg_color(surface, node, opacity, attribute, default=None):
|
|
paint_source, paint_color = paint(node.get(attribute, default))
|
|
if gradient_or_pattern(surface, node, paint_source):
|
|
return 0
|
|
(r, g, b, a) = color(paint_color, opacity)
|
|
color256 = truncate_color_to_pebble64_palette(int(r*255), int(g*255), int(b*255), int(a*255))
|
|
gcolor8 = rgba32_triplet_to_argb8(*color256)
|
|
return gcolor8 if gcolor8_is_visible(gcolor8) else 0
|
|
|
|
|
|
def svg(surface, node, original=None):
|
|
original(surface, node)
|
|
width, height, viewbox = node_format(surface, node)
|
|
if not "width" in node:
|
|
width = None if viewbox is None else viewbox[2]
|
|
if not "height" in node:
|
|
height = None if viewbox is None else viewbox[3]
|
|
if (width is not None) and (height is not None):
|
|
surface.stored_size = (width, height)
|
|
|
|
|
|
def line(surface, node, original=None):
|
|
x1, y1, x2, y2 = tuple(
|
|
size(surface, node.get(position), position[0])
|
|
for position in ("x1", "y1", "x2", "y2"))
|
|
points = [(x1, y1), (x2, y2)]
|
|
command = PathCommand(points, path_open=True, precise=True)
|
|
handle_command(surface, node, command)
|
|
|
|
return original(surface, node)
|
|
|
|
def polygon(surface, node, original=None):
|
|
return poly_element(surface, node, open=False, original=original)
|
|
|
|
|
|
def polyline(surface, node, original=None):
|
|
return poly_element(surface, node, open=True, original=original)
|
|
|
|
|
|
def poly_element(surface, node, open, original=None):
|
|
points_attr = normalize(node.get("points"))
|
|
points = []
|
|
while points_attr:
|
|
x, y, points_attr = point(surface, points_attr)
|
|
points.append((x, y))
|
|
command = PathCommand(points, path_open=True, precise=True)
|
|
command.open = open
|
|
handle_command(surface, node, command)
|
|
|
|
return original(surface, node)
|
|
|
|
|
|
def rect(surface, node, original=None):
|
|
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 not (rx == 0 and ry == 0):
|
|
annotation = Annotation(node, "Rounded rectangles are not supported.")
|
|
right = x + width - rx
|
|
bottom = y + height - ry
|
|
annotation.add_highlight(x, y, rx, ry)
|
|
annotation.add_highlight(right, y, rx, ry)
|
|
annotation.add_highlight(right, bottom, rx, ry)
|
|
annotation.add_highlight(x, bottom, rx, ry)
|
|
|
|
points = [(x, y), (x + width, y), (x + width, y + height), (x, y + height)]
|
|
command = PathCommand(points, path_open=False, precise=True)
|
|
handle_command(surface, node, command)
|
|
|
|
return original(surface, node)
|
|
|
|
|
|
def circle(surface, node, original=None):
|
|
r = size(surface, node.get("r"))
|
|
cx = size(surface, node.get("cx"), "x")
|
|
cy = size(surface, node.get("cy"), "y")
|
|
|
|
if r and cx is not None and cy is not None:
|
|
command = CircleCommand((cx, cy), r)
|
|
handle_command(surface, node, command)
|
|
|
|
return original(surface, node)
|
|
|
|
|
|
def cubicbezier_mid(p0, p1, p2, p3, min_dist, l, r):
|
|
t = (r[0] + l[0]) / 2
|
|
a = (1. - t)**3
|
|
b = 3. * t * (1. - t)**2
|
|
c = 3.0 * t**2 * (1.0 - t)
|
|
d = t**3
|
|
|
|
p = (a * p0[0] + b * p1[0] + c * p2[0] + d * p3[0],
|
|
a * p0[1] + b * p1[1] + c * p2[1] + d * p3[1])
|
|
|
|
def pt_dist(p1, p2):
|
|
return ((p1[0]-p2[0])**2 + (p1[1]-p2[1])**2)**0.5
|
|
|
|
if pt_dist(l[1], p) <= min_dist or pt_dist(r[1], p) <= min_dist:
|
|
return []
|
|
|
|
left = cubicbezier_mid(p0, p1, p2, p3, min_dist=min_dist, l=l, r=(t, p))
|
|
right = cubicbezier_mid(p0, p1, p2, p3, min_dist=min_dist, l=(t, p), r=r)
|
|
|
|
return left + [p] + right
|
|
|
|
def cubicbezier(p0, p1, p2, p3, min_dist):
|
|
return [p0] + cubicbezier_mid(p0, p1, p2, p3, min_dist, (0.0, p0), (1.0, p3)) + [p3]
|
|
|
|
|
|
class PathSurfaceContext(cairo.Context):
|
|
def __init__(self, target, node, approximate_bezier):
|
|
super(PathSurfaceContext, self).__init__(target)
|
|
self.points = []
|
|
self.path_open = True
|
|
self.node = node
|
|
self.approximate_bezier = approximate_bezier
|
|
self.grouped_annotations = {}
|
|
|
|
def get_grouped_description(self, description, *args, **kwargs):
|
|
if not description in self.grouped_annotations:
|
|
self.grouped_annotations[description] = self.add_annotation(description, *args, **kwargs)
|
|
|
|
return self.grouped_annotations[description]
|
|
|
|
def add_annotation(self, *args, **kwargs):
|
|
return Annotation(self.node, *args, **kwargs)
|
|
|
|
def add_current_point(self):
|
|
self.points.append(self.get_current_point())
|
|
|
|
def create_command(self):
|
|
return PathCommand(self.points, self.path_open, precise=True)
|
|
|
|
def move_to(self, x, y):
|
|
super(PathSurfaceContext, self).move_to(x, y)
|
|
self.add_current_point()
|
|
|
|
def line_to(self, x, y):
|
|
super(PathSurfaceContext, self).line_to(x, y)
|
|
self.add_current_point()
|
|
|
|
def rel_line_to(self, dx, dy):
|
|
super(PathSurfaceContext, self).rel_line_to(dx, dy)
|
|
self.add_current_point()
|
|
|
|
def curve_to(self, x1, y1, x2, y2, x3, y3):
|
|
first = self.get_current_point()
|
|
super(PathSurfaceContext, self).curve_to(x1, y1, x2, y2, x3, y3)
|
|
last = self.get_current_point()
|
|
|
|
# approximate bezier curve
|
|
points = cubicbezier(first, (x1, y1), (x2, y2), last, 5)
|
|
box = bounding_box_around_points(points)
|
|
|
|
if self.approximate_bezier:
|
|
description = "Curved command(s) were approximated."
|
|
self.points.extend(points[1:])
|
|
else:
|
|
description = "Element contains unsupported curved command(s)."
|
|
self.add_current_point()
|
|
|
|
link = "https://pebbletechnology.atlassian.net/wiki/display/DEV/Pebble+Draw+Commands#PebbleDrawCommands-issue-bezier"
|
|
self.get_grouped_description("Element contains unsupported curved command(s).", link=link).add_highlight(*box)
|
|
|
|
|
|
def rel_curve_to(self, dx1, dy1, dx2, dy2, dx3, dy3):
|
|
cur = self.get_current_point()
|
|
x1 = dx1 + cur[0]
|
|
y1 = dy1 + cur[1]
|
|
x2 = dx2 + cur[0]
|
|
y2 = dy2 + cur[1]
|
|
x3 = dx3 + cur[0]
|
|
y3 = dy3 + cur[1]
|
|
self.curve_to(x1, y1, x2, y2, x3, y3)
|
|
|
|
def arc(self, xc, yc, radius, angle1, angle2):
|
|
self.add_annotation_arc_unsupported(xc, yc, radius)
|
|
super(PathSurfaceContext, self).arc(xc, yc, radius, angle1, angle2)
|
|
|
|
def arc_negative(self, xc, yc, radius, angle1, angle2):
|
|
self.add_annotation_arc_unsupported(xc, yc, radius)
|
|
super(PathSurfaceContext, self).arc_negative(xc, yc, radius, angle1, angle2)
|
|
|
|
def close_path(self):
|
|
super(PathSurfaceContext, self).close_path()
|
|
self.path_open = False
|
|
|
|
def add_annotation_arc_unsupported(self, xc, yc, radius):
|
|
# caller uses context transforms to express arcs
|
|
points = [
|
|
(xc-radius, yc-radius), # top-left
|
|
(xc+radius, yc-radius), # top-right
|
|
(xc+radius, yc+radius), # bottom-right
|
|
(xc-radius, yc+radius), # bottom-left
|
|
]
|
|
box = bounding_box_around_points([self.user_to_device(*p) for p in points])
|
|
self.get_grouped_description("Element contains unsupported arc command(s).").add_highlight(*box)
|
|
|
|
|
|
class PathSurface:
|
|
def __init__(self, target, node, approximate_bezier):
|
|
self.context = PathSurfaceContext(target, node, approximate_bezier)
|
|
|
|
|
|
def path(surface, node, original=None):
|
|
# unfortunately, the original implementation has a side-effect on node
|
|
# but as it's rather complex, we'd rather reuse it and as it doesn't change the surface
|
|
# it's ok to call it with the fake surface we create here
|
|
collecting_surface = PathSurface(surface.cairo, node, surface.approximate_bezier)
|
|
result = original(collecting_surface, node)
|
|
command = collecting_surface.context.create_command()
|
|
if len(command.points) > 1:
|
|
handle_command(surface, node, command)
|
|
else:
|
|
Annotation(node, "Path needs at least two points.")
|
|
|
|
return result
|
|
|
|
|
|
class Transformer:
|
|
def __init__(self, cairo, node):
|
|
self.context = cairo
|
|
self.node = node
|
|
|
|
def transform_point(self, pt):
|
|
return self.context.user_to_device(pt[0], pt[1])
|
|
|
|
def transform_distance(self, dx, dy):
|
|
return self.context.user_to_device_distance(dx, dy)
|
|
|
|
def add_annotation(self, *args, **kwargs):
|
|
return Annotation(self.node, *args, **kwargs)
|
|
|
|
def handle_command(surface, node, command):
|
|
opacity = float(node.get("opacity", 1))
|
|
# Get stroke and fill opacity
|
|
stroke_opacity = float(node.get("stroke-opacity", 1))
|
|
fill_opacity = float(node.get("fill-opacity", 1))
|
|
if opacity < 1:
|
|
stroke_opacity *= opacity
|
|
fill_opacity *= opacity
|
|
default_fill = "black" if node.get("fill-rule") == "evenodd" else None
|
|
command.fill_color = svg_color(surface, node, fill_opacity, "fill", default_fill)
|
|
command.stroke_color = svg_color(surface, node, stroke_opacity, "stroke")
|
|
if gcolor8_is_visible(command.stroke_color):
|
|
command.stroke_width = int(size(surface, node.get("stroke-width")))
|
|
if command.stroke_width == 0:
|
|
command.stroke_color = 0
|
|
|
|
# transform
|
|
transformer = Transformer(surface.context, node)
|
|
command.transform(transformer)
|
|
if command.stroke_width and node.get("vector-effect") != "non-scaling-stroke":
|
|
transformed_stroke = transformer.transform_distance(command.stroke_width, 0)
|
|
transformed_stroke_width = (transformed_stroke[0]**2 + transformed_stroke[1]**2)**0.5
|
|
command.stroke_width = transformed_stroke_width
|
|
for annotation in node.annotations:
|
|
annotation.transform(transformer)
|
|
|
|
command.finalize(transformer)
|
|
|
|
# Manage display and visibility
|
|
display = node.get("display", "inline") != "none"
|
|
visible = display and (node.get("visibility", "visible") != "hidden") and \
|
|
(gcolor8_is_visible(command.fill_color) or gcolor8_is_visible(command.stroke_color))
|
|
|
|
if visible:
|
|
surface.pdc_commands.append(command)
|
|
|
|
|
|
def surface_from_svg(url=None, bytestring=None, approximate_bezier=False):
|
|
try:
|
|
tree = Tree(url=url, bytestring=bytestring)
|
|
except etree.XMLSyntaxError as e:
|
|
raise Svg2PdcFormatError(e.args[0])
|
|
output = io.BytesIO()
|
|
return PDCSurface(tree, output, 96, approximate_bezier=approximate_bezier)
|