diff --git a/mathics/builtin/drawing/graphics3d.py b/mathics/builtin/drawing/graphics3d.py index f7e9df4477..4d0a970a3c 100644 --- a/mathics/builtin/drawing/graphics3d.py +++ b/mathics/builtin/drawing/graphics3d.py @@ -764,8 +764,6 @@ def total_extent_3d(extents): class Graphics3DElements(_GraphicsElements): - coords = Coords3D - def __init__(self, content, evaluation, neg_y=False): super(Graphics3DElements, self).__init__(content, evaluation) self.neg_y = neg_y @@ -774,6 +772,10 @@ def __init__(self, content, evaluation, neg_y=False): ) = ( self.pixel_width ) = self.pixel_height = self.extent_width = self.extent_height = None + self.local_to_screen = None + + def make_coords(self, points): + return [Coords3D(self, p) for p in points] def extent(self, completely_visible_only=False): return total_extent_3d([element.extent() for element in self.elements]) @@ -835,7 +837,11 @@ def to_asy(self): return "".join( "path3 g={0}--cycle;dot(g, {1});".format( - "--".join("(%.5g,%.5g,%.5g)" % coords.pos()[0] for coords in line), pen + "--".join( + "(%.5g,%.5g,%.5g)" % tuple(float(x) for x in coords.pos()[0]) + for coords in line + ), + pen, ) for line in self.lines ) diff --git a/mathics/builtin/drawing/image.py b/mathics/builtin/drawing/image.py index 861156c409..c97af88cd4 100644 --- a/mathics/builtin/drawing/image.py +++ b/mathics/builtin/drawing/image.py @@ -25,6 +25,7 @@ convert as convert_color, colorspaces as known_colorspaces, ) +from mathics.layout.client import WebEngineError import base64 import functools @@ -2561,3 +2562,48 @@ def color_func( image = wc.to_image() return Image(numpy.array(image), "RGB") + + +class Rasterize(Builtin): + requires = _image_requires + + options = { + "RasterSize": "300", + } + + def apply(self, expr, evaluation, options): + "Rasterize[expr_, OptionsPattern[%(name)s]]" + + raster_size = self.get_option(options, "RasterSize", evaluation) + if isinstance(raster_size, Integer): + s = raster_size.get_int_value() + py_raster_size = (s, s) + elif raster_size.has_form("List", 2) and all( + isinstance(s, Integer) for s in raster_size.leaves + ): + py_raster_size = tuple(s.get_int_value for s in raster_size.leaves) + else: + return + + mathml = evaluation.format_output(expr, "xml") + try: + svg = evaluation.output.mathml_to_svg(mathml) + png = evaluation.output.rasterize(svg, py_raster_size) + + stream = BytesIO() + stream.write(png) + stream.seek(0) + im = PIL.Image.open(stream) + # note that we need to get these pixels as long as stream is still open, + # otherwise PIL will generate an IO error. + pixels = numpy.array(im) + stream.close() + + return Image(pixels, "RGB") + except WebEngineError as e: + evaluation.message( + "General", + "nowebeng", + "Rasterize[] did not succeed: " + str(e), + once=True, + ) diff --git a/mathics/builtin/drawing/plot.py b/mathics/builtin/drawing/plot.py index 0c93460549..bf58c0207e 100644 --- a/mathics/builtin/drawing/plot.py +++ b/mathics/builtin/drawing/plot.py @@ -40,6 +40,7 @@ except ImportError: has_compile = False + def gradient_palette(color_function, n, evaluation): # always returns RGB values if isinstance(color_function, String): color_data = Expression("ColorData", color_function).evaluate(evaluation) @@ -696,6 +697,15 @@ def find_excl(excl): Expression("Point", Expression(SymbolList, *meshpoints)) ) + # We need the PrecomputeTransformations option here. To understand why, try Plot[1+x*0.000001, {x, 0, 1}] + # without it. in Graphics[], we set up a transformation that scales a very tiny area to a very large area. + # unfortunately, most browsers seem to have problems with scaling stroke width properly. since we scale a + # very tiny area, we specify a very small stroke width (e.g. 1e-6) which is then scaled. but most browsers + # simply round this stroke width to 0 before scaling, so we end up with an empty plot. in order to fix this, + # Transformation -> Precomputed simply gets rid of the SVG transformations and passes the scaled coordinates + # into the SVG. this also has the advantage that we can precompute with arbitrary precision using mpmath. + options["System`Transformation"] = String("Precomputed") + return Expression( "Graphics", Expression(SymbolList, *graphics), *options_to_rules(options) ) diff --git a/mathics/builtin/graphics.py b/mathics/builtin/graphics.py index fc0c9a0302..6fb6645d96 100644 --- a/mathics/builtin/graphics.py +++ b/mathics/builtin/graphics.py @@ -6,10 +6,12 @@ """ -from math import floor, ceil, log10, sin, cos, pi, sqrt, atan2, degrees, radians, exp +from math import floor, ceil, log10, sin, cos, pi, sqrt, atan2, radians, exp +import re import json import base64 from itertools import chain +from sympy.matrices import Matrix from mathics.version import __version__ # noqa used in loading to check consistency. from mathics.builtin.base import ( @@ -19,7 +21,9 @@ BoxConstructError, ) from mathics.builtin.options import options_to_rules +from mathics.layout.client import WebEngineUnavailable from mathics.core.expression import ( + BoxError, Expression, Integer, Rational, @@ -36,6 +40,7 @@ from mathics.builtin.drawing.colors import convert as convert_color from mathics.core.numbers import machine_epsilon + GRAPHICS_OPTIONS = { "AspectRatio": "Automatic", "Axes": "False", @@ -47,6 +52,10 @@ "PlotRangePadding": "Automatic", "TicksStyle": "{}", "$OptionSyntax": "Ignore", + "Transformation": "Automatic", # Mathics specific; used internally to enable stuff like + # Plot[x + 1e-20 * x, {x, 0, 1}] that without precomputing transformations inside Mathics + # will hit SVGs numerical accuracy abilities in some browsers as strokes with width < 1e-6 + # will get rounded to 0 and thus won't get scale transformed in SVG and vanish. } @@ -71,51 +80,28 @@ def get_class(name): # like return globals().get(name) -def coords(value): - if value.has_form("List", 2): - x, y = value.leaves[0].round_to_float(), value.leaves[1].round_to_float() - if x is None or y is None: - raise CoordinatesError - return (x, y) - raise CoordinatesError - +def expr_to_coords(value): + if not value.has_form("List", 2): + raise CoordinatesError + x, y = value.leaves[0].to_mpmath(), value.leaves[1].to_mpmath() + if x is None or y is None: + raise CoordinatesError + return x, y -class Coords(object): - def __init__(self, graphics, expr=None, pos=None, d=None): - self.graphics = graphics - self.p = pos - self.d = d - if expr is not None: - if expr.has_form("Offset", 1, 2): - self.d = coords(expr.leaves[0]) - if len(expr.leaves) > 1: - self.p = coords(expr.leaves[1]) - else: - self.p = None - else: - self.p = coords(expr) - - def pos(self): - p = self.graphics.translate(self.p) - p = (cut(p[0]), cut(p[1])) - if self.d is not None: - d = self.graphics.translate_absolute(self.d) - return (p[0] + d[0], p[1] + d[1]) - return p - def add(self, x, y): - p = (self.p[0] + x, self.p[1] + y) - return Coords(self.graphics, pos=p, d=self.d) +def add_coords(a, b): + x1, y1 = a + x2, y2 = b + return x1 + x2, y1 + y2 -def cut(value): - "Cut values in graphics primitives (not displayed otherwise in SVG)" - border = 10 ** 8 - if value < -border: - value = -border - elif value > border: - value = border - return value +def axis_coords(graphics, pos, d=None): + p = graphics.translate(pos) + if d is not None: + d = graphics.translate_absolute_in_pixels(d) + return p[0] + d[0], p[1] + d[1] + else: + return p def create_css( @@ -148,10 +134,13 @@ def asy_number(value): def _to_float(x): - x = x.round_to_float() - if x is None: - raise BoxConstructError - return x + if isinstance(x, Integer): + return x.get_int_value() + else: + y = x.round_to_float() + if y is None: + raise BoxConstructError + return y def create_pens( @@ -296,96 +285,153 @@ def _CMC_distance(lab1, lab2, l, c): def _extract_graphics(graphics, format, evaluation): graphics_box = Expression(SymbolMakeBoxes, graphics).evaluate(evaluation) - # builtin = GraphicsBox(expression=False) elements, calc_dimensions = graphics_box._prepare_elements( - graphics_box.leaves, {"evaluation": evaluation}, neg_y=True + graphics_box._leaves, {"evaluation": evaluation}, neg_y=True ) - xmin, xmax, ymin, ymax, _, _, _, _ = calc_dimensions() - - # xmin, xmax have always been moved to 0 here. the untransformed - # and unscaled bounds are found in elements.xmin, elements.ymin, - # elements.extent_width, elements.extent_height. - - # now compute the position of origin (0, 0) in the transformed - # coordinate space. - ex = elements.extent_width - ey = elements.extent_height + if not isinstance(elements.elements[0], GeometricTransformationBox): + raise ValueError("expected GeometricTransformationBox") - sx = (xmax - xmin) / ex - sy = (ymax - ymin) / ey - - ox = -elements.xmin * sx + xmin - oy = -elements.ymin * sy + ymin + # contents = elements.elements[0].contents # generate code for svg or asy. if format == "asy": - code = "\n".join(element.to_asy() for element in elements.elements) + code = "\n".join(element.to_asy(None) for element in elements.elements) elif format == "svg": code = elements.to_svg() else: raise NotImplementedError - return xmin, xmax, ymin, ymax, ox, oy, ex, ey, code + return code -class _SVGTransform: - def __init__(self): - self.transforms = [] +class _Transform: + def __init__(self, f): + if not isinstance(f, Expression): + self.matrix = f + return - def matrix(self, a, b, c, d, e, f): - # a c e - # b d f - # 0 0 1 - self.transforms.append("matrix(%f, %f, %f, %f, %f, %f)" % (a, b, c, d, e, f)) + if f.get_head_name() != "System`TransformationFunction": + raise BoxConstructError - def translate(self, x, y): - self.transforms.append("translate(%f, %f)" % (x, y)) + if len(f.leaves) != 1 or f.leaves[0].get_head_name() != "System`List": + raise BoxConstructError - def scale(self, x, y): - self.transforms.append("scale(%f, %f)" % (x, y)) + rows = f.leaves[0].leaves + if len(rows) != 3: + raise BoxConstructError + if any(row.get_head_name() != "System`List" for row in rows): + raise BoxConstructError + if any(len(row.leaves) != 3 for row in rows): + raise BoxConstructError - def rotate(self, x): - self.transforms.append("rotate(%f)" % x) + self.matrix = [[_to_float(x) for x in row.leaves] for row in rows] - def apply(self, svg): - return '%s' % (" ".join(self.transforms), svg) + def combine(self, transform0): + if isinstance(transform0, _Transform): + return self.multiply(transform0) + else: + t = self + def combined(*p, w=1): + return transform0(*t(*p, w=w), w=w) -class _ASYTransform: - _template = """ - add(%s * (new picture() { - picture saved = currentpicture; - picture transformed = new picture; - currentpicture = transformed; - %s - currentpicture = saved; - return transformed; - })()); - """ + return combined + + def inverse(self): + return _Transform(Matrix(self.matrix).inv().tolist()) + + def multiply(self, other): + a = self.matrix + b = other.matrix + return _Transform( + [ + [sum(a[i][k] * b[k][j] for k in range(3)) for j in range(3)] + for i in range(3) + ] + ) - def __init__(self): - self.transforms = [] + def __call__(self, *p, w=1): + m = self.matrix + + m11 = m[0][0] + m12 = m[0][1] + m13 = m[0][2] + + m21 = m[1][0] + m22 = m[1][1] + m23 = m[1][2] + + if w == 1: + for x, y in p: + yield m11 * x + m12 * y + m13, m21 * x + m22 * y + m23 + elif w == 0: + for x, y in p: + yield m11 * x + m12 * y, m21 * x + m22 * y + else: + raise NotImplementedError("w not in (0, 1)") + + def to_svg(self, svg): + m = self.matrix + + a = m[0][0] + b = m[1][0] + c = m[0][1] + d = m[1][1] + e = m[0][2] + f = m[1][2] + + if m[2][0] != 0.0 or m[2][1] != 0.0 or m[2][2] != 1.0: + raise BoxConstructError - def matrix(self, a, b, c, d, e, f): # a c e # b d f # 0 0 1 - # see http://asymptote.sourceforge.net/doc/Transforms.html#Transforms - self.transforms.append("(%f, %f, %f, %f, %f, %f)" % (e, f, a, c, b, d)) - def translate(self, x, y): - self.transforms.append("shift(%f, %f)" % (x, y)) + t = "matrix(%s, %s, %s, %s, %s, %s)" % ( + str(a), + str(b), + str(c), + str(d), + str(e), + str(f), + ) + return '%s' % (t, svg) + + def to_asy(self, asy): + m = self.matrix + + a = m[0][0] + b = m[1][0] + c = m[0][1] + d = m[1][1] + e = m[0][2] + f = m[1][2] + + if m[2][0] != 0.0 or m[2][1] != 0.0 or m[2][2] != 1.0: + raise BoxConstructError - def scale(self, x, y): - self.transforms.append("scale(%f, %f)" % (x, y)) + # a c e + # b d f + # 0 0 1 + # see http://asymptote.sourceforge.net/doc/Transforms.html#Transforms + t = ",".join(map(asy_number, (e, f, a, c, b, d))) + + return "".join( + ( + "add((", + t, + ")*(new picture(){", + "picture s=currentpicture,t=new picture;currentpicture=t;", + asy, + "currentpicture=s;return t;})());", + ) + ) - def rotate(self, x): - self.transforms.append("rotate(%f)" % x) - def apply(self, asy): - return self._template % (" * ".join(self.transforms), asy) +def _no_transform(*p, w=None): + return p class Show(Builtin): @@ -411,7 +457,7 @@ def apply(self, graphics, evaluation, options): new_leaves = [] options_set = set(options.keys()) for leaf in graphics.leaves: - new_leaf = leaf + # new_leaf = leaf leaf_name = leaf.get_head_name() if leaf_name == "System`Rule" and str(leaf.leaves[0]) in options_set: continue @@ -464,9 +510,13 @@ class Graphics(Builtin): . \begin{asy} . usepackage("amsmath"); . size(5.8556cm, 5.8333cm); - . draw(ellipse((175,175),175,175), rgb(0, 0, 0)+linewidth(0.66667)); + . add((175,175,175,0,0,175)*(new picture(){picture s=currentpicture,t=new picture;currentpicture=t;draw(ellipse((0,0),1,1), rgb(0, 0, 0)+linewidth(0.0038095));currentpicture=s;return t;})()); . clip(box((-0.33333,0.33333), (350.33,349.67))); . \end{asy} + + Invalid graphics directives yield invalid box structures: + >> Graphics[Circle[{a, b}]] + : GraphicsBox[CircleBox[List[a, b]], Rule[$OptionSyntax, Ignore], Rule[AspectRatio, Automatic], Rule[Axes, False], Rule[AxesStyle, List[]], Rule[Background, Automatic], Rule[ImageSize, Automatic], Rule[LabelStyle, List[]], Rule[PlotRange, Automatic], Rule[PlotRangePadding, Automatic], Rule[TicksStyle, List[]], Rule[Transformation, Automatic]] is not a valid box structure. """ options = GRAPHICS_OPTIONS @@ -482,11 +532,17 @@ def convert(content): if head == "System`List": return Expression( - SymbolList, *[convert(item) for item in content.leaves] + SymbolList, *[convert(item) for item in content._leaves] ) elif head == "System`Style": return Expression( - "StyleBox", *[convert(item) for item in content.leaves] + "StyleBox", *[convert(item) for item in content._leaves] + ) + elif head == "System`GeometricTransformation" and len(content._leaves) == 2: + return Expression( + "GeometricTransformationBox", + convert(content._leaves[0]), + content._leaves[1], ) if head in element_heads: @@ -909,7 +965,7 @@ def apply(self, c1, c2, evaluation, options): # If numpy is not installed, 100 * c1.to_color_space returns # a list of 100 x 3 elements, instead of doing elementwise multiplication try: - import numpy as np + import numpy as np # noqa just for check that the library is installed... except: raise RuntimeError("NumPy needs to be installed for ColorDistance") @@ -1100,7 +1156,7 @@ class PointSize(_Size): """ def get_size(self): - return self.graphics.view_width * self.value + return self.graphics.extent_width * self.value class FontColor(Builtin): @@ -1114,6 +1170,56 @@ class FontColor(Builtin): pass +class FontSize(_GraphicsElement): + """ +
+
'FontSize[$s$]' +
sets the font size to $s$ printer's points. +
+ """ + + def init(self, graphics, item=None, value=None): + super(FontSize, self).init(graphics, item) + + self.scaled = False + if item is not None and len(item.leaves) == 1: + if item.leaves[0].get_head_name() == "System`Scaled": + scaled = item.leaves[0] + if len(scaled.leaves) == 1: + self.scaled = True + self.value = scaled.leaves[0].round_to_float() + + if self.scaled: + pass + elif item is not None: + self.value = item.leaves[0].round_to_float() + elif value is not None: + self.value = value + else: + raise BoxConstructError + + if self.value < 0: + raise BoxConstructError + + def get_size(self): + if self.scaled: + if self.graphics.extent_width is None: + return 1.0 + else: + return self.graphics.extent_width * self.value + else: + if self.graphics.extent_width is None or self.graphics.pixel_width is None: + return 1.0 + else: + return (96.0 / 72.0) * ( + self.value * self.graphics.extent_width / self.graphics.pixel_width + ) + + +class Scaled(Builtin): + pass + + class Offset(Builtin): pass @@ -1214,58 +1320,64 @@ def init(self, graphics, style, item): super(RectangleBox, self).init(graphics, item, style) if len(item.leaves) not in (1, 2): raise BoxConstructError + self.edge_color, self.face_color = style.get_style(_Color, face_element=True) - self.p1 = Coords(graphics, item.leaves[0]) + self.p1 = expr_to_coords(item._leaves[0]) if len(item.leaves) == 1: - self.p2 = self.p1.add(1, 1) + self.p2 = add_coords(self.p1, (1, 1)) elif len(item.leaves) == 2: - self.p2 = Coords(graphics, item.leaves[1]) + self.p2 = expr_to_coords(item.leaves[1]) def extent(self): l = self.style.get_line_width(face_element=True) / 2 result = [] - for p in [self.p1, self.p2]: - x, y = p.pos() - result.extend( - [(x - l, y - l), (x - l, y + l), (x + l, y - l), (x + l, y + l)] - ) + + tx1, ty1 = self.p1 + tx2, ty2 = self.p2 + + x1 = min(tx1, tx2) - l + x2 = max(tx1, tx2) + l + y1 = min(ty1, ty2) - l + y2 = max(ty1, ty2) + l + + result.extend([(x1, y1), (x1, y2), (x2, y1), (x2, y2)]) + return result - def to_svg(self, offset=None): + def to_svg(self, transform): l = self.style.get_line_width(face_element=True) - x1, y1 = self.p1.pos() - x2, y2 = self.p2.pos() + p1, p2 = transform(self.p1, self.p2) + x1, y1 = p1 + x2, y2 = p2 xmin = min(x1, x2) ymin = min(y1, y2) w = max(x1, x2) - xmin h = max(y1, y2) - ymin - if offset: - x1, x2 = x1 + offset[0], x2 + offset[0] - y1, y2 = y1 + offset[1], y2 + offset[1] style = create_css(self.edge_color, self.face_color, l) - return '' % ( - xmin, - ymin, - w, - h, + return '' % ( + str(xmin), + str(ymin), + str(w), + str(h), style, ) - def to_asy(self): + def to_asy(self, transform): l = self.style.get_line_width(face_element=True) - x1, y1 = self.p1.pos() - x2, y2 = self.p2.pos() + p1, p2 = transform(self.p1, self.p2) + x1, y1 = p1 + x2, y2 = p2 pens = create_pens(self.edge_color, self.face_color, l, is_face_element=True) x1, x2, y1, y2 = asy_number(x1), asy_number(x2), asy_number(y1), asy_number(y2) return "filldraw((%s,%s)--(%s,%s)--(%s,%s)--(%s,%s)--cycle, %s);" % ( - x1, - y1, - x2, - y1, - x2, - y2, - x1, - y2, + str(x1), + str(y1), + str(x2), + str(y1), + str(x2), + str(y2), + str(x1), + str(y2), pens, ) @@ -1280,7 +1392,7 @@ def init(self, graphics, style, item): self.edge_color, self.face_color = style.get_style( _Color, face_element=self.face_element ) - self.c = Coords(graphics, item.leaves[0]) + self.c = expr_to_coords(item.leaves[0]) if len(item.leaves) == 1: rx = ry = 1 elif len(item.leaves) == 2: @@ -1290,38 +1402,43 @@ def init(self, graphics, style, item): ry = r.leaves[1].round_to_float() else: rx = ry = r.round_to_float() - self.r = self.c.add(rx, ry) + self.r = add_coords(self.c, (rx, ry)) def extent(self): l = self.style.get_line_width(face_element=self.face_element) / 2 - x, y = self.c.pos() - rx, ry = self.r.pos() + x, y = self.c + rx, ry = self.r rx -= x ry = y - ry rx += l ry += l return [(x - rx, y - ry), (x - rx, y + ry), (x + rx, y - ry), (x + rx, y + ry)] - def to_svg(self, offset=None): - x, y = self.c.pos() - rx, ry = self.r.pos() + def to_svg(self, transform): + c, r = transform(self.c, self.r) + x, y = c + rx, ry = r rx -= x - ry = y - ry + ry = abs(y - ry) l = self.style.get_line_width(face_element=self.face_element) style = create_css(self.edge_color, self.face_color, stroke_width=l) - return '' % ( - x, - y, - rx, - ry, + return '' % ( + str(x), + str(y), + str(rx), + str(ry), style, ) - def to_asy(self): - x, y = self.c.pos() - rx, ry = self.r.pos() + def to_asy(self, transform): + if transform: + c, r = transform(self.c, self.r) + else: + c, r = self.c, self.r + x, y = c + rx, ry = r rx -= x - ry -= y + ry = abs(ry - y) l = self.style.get_line_width(face_element=self.face_element) pen = create_pens( edge_color=self.edge_color, @@ -1367,9 +1484,10 @@ def init(self, graphics, style, item): self.arc = None super(_ArcBox, self).init(graphics, style, item) - def _arc_params(self): - x, y = self.c.pos() - rx, ry = self.r.pos() + def _arc_params(self, transform): + c, r = transform(self.c, self.r) + x, y = c + rx, ry = r rx -= x ry -= y @@ -1389,20 +1507,26 @@ def _arc_params(self): return x, y, abs(rx), abs(ry), sx, sy, ex, ey, large_arc - def to_svg(self, offset=None): + def to_svg(self, transform): if self.arc is None: - return super(_ArcBox, self).to_svg(offset) + return super(_ArcBox, self).to_svg(transform) - x, y, rx, ry, sx, sy, ex, ey, large_arc = self._arc_params() + x, y, rx, ry, sx, sy, ex, ey, large_arc = self._arc_params(transform) def path(closed): if closed: - yield "M %f,%f" % (x, y) - yield "L %f,%f" % (sx, sy) + yield "M %s,%s" % (str(x), str(y)) + yield "L %s,%s" % (str(sx), str(sy)) else: - yield "M %f,%f" % (sx, sy) - - yield "A %f,%f,0,%d,0,%f,%f" % (rx, ry, large_arc, ex, ey) + yield "M %s,%s" % (str(sx), str(sy)) + + yield "A %s,%s,0,%d,0,%s,%s" % ( + str(rx), + str(ry), + large_arc, + str(ex), + str(ey), + ) if closed: yield "Z" @@ -1411,11 +1535,11 @@ def path(closed): style = create_css(self.edge_color, self.face_color, stroke_width=l) return '' % (" ".join(path(self.face_element)), style) - def to_asy(self): + def to_asy(self, transform): if self.arc is None: - return super(_ArcBox, self).to_asy() + return super(_ArcBox, self).to_asy(transform) - x, y, rx, ry, sx, sy, ex, ey, large_arc = self._arc_params() + x, y, rx, ry, sx, sy, ex, ey, large_arc = self._arc_params(transform) def path(closed): if closed: @@ -1469,16 +1593,15 @@ def do_init(self, graphics, points): lines.append(leaf.leaves) else: raise BoxConstructError - self.lines = [ - [graphics.coords(graphics, point) for point in line] for line in lines - ] + make_coords = graphics.make_coords # for Graphics and Graphics3D support + self.lines = [make_coords(line) for line in lines] def extent(self): l = self.style.get_line_width(face_element=False) result = [] for line in self.lines: for c in line: - x, y = c.pos() + x, y = c result.extend( [(x - l, y - l), (x - l, y + l), (x + l, y - l), (x + l, y + l)] ) @@ -1522,33 +1645,42 @@ def init(self, graphics, style, item=None): else: raise BoxConstructError - def to_svg(self, offset=None): + def to_svg(self, transform): point_size, _ = self.style.get_style(PointSize, face_element=False) if point_size is None: point_size = PointSize(self.graphics, value=0.005) size = point_size.get_size() + graphics = self.graphics + size_x = size + size_y = ( + size_x + * (graphics.extent_height / graphics.extent_width) + * (graphics.pixel_width / graphics.pixel_height) + ) + style = create_css( edge_color=self.edge_color, stroke_width=0, face_color=self.face_color ) svg = "" for line in self.lines: - for coords in line: - svg += '' % ( - coords.pos()[0], - coords.pos()[1], - size, + for x, y in transform(*line): + svg += '' % ( + float(x), + float(y), + float(size_x), + float(size_y), style, ) return svg - def to_asy(self): + def to_asy(self, transform): pen = create_pens(face_color=self.face_color, is_face_element=False) asy = "" for line in self.lines: - for coords in line: - asy += "dot(%s, %s);" % (coords.pos(), pen) + for x, y in transform(*line): + asy += "dot(%s, %s);" % ((float(x), float(y)), pen) return asy @@ -1586,24 +1718,32 @@ def init(self, graphics, style, item=None, lines=None): else: raise BoxConstructError - def to_svg(self, offset=None): + def to_svg(self, transform): l = self.style.get_line_width(face_element=False) + l = list(transform((l, l), w=0))[0][0] style = create_css(edge_color=self.edge_color, stroke_width=l) + svg = "" for line in self.lines: - svg += '' % ( - " ".join(["%f,%f" % coords.pos() for coords in line]), - style, + path = " ".join( + ["%f,%f" % tuple(float(cc) for cc in c) for c in transform(*line)] ) + svg += '' % (path, style) + return svg - def to_asy(self): + def to_asy(self, transform): l = self.style.get_line_width(face_element=False) + l = list(transform((l, l), w=0))[0][0] pen = create_pens(edge_color=self.edge_color, stroke_width=l) + asy = "" for line in self.lines: - path = "--".join(["(%.5g,%5g)" % coords.pos() for coords in line]) + path = "--".join( + ["(%.5g,%5g)" % tuple(float(cc) for cc in c) for c in transform(*line)] + ) asy += "draw(%s, %s);" % (path, pen) + return asy @@ -1625,11 +1765,13 @@ def path(max_degree, p): n = min(max_degree, len(p)) # 1, 2, or 3 if n < 1: raise BoxConstructError - yield forms[n - 1] + " ".join("%f,%f" % xy for xy in p[:n]) + yield forms[n - 1] + " ".join( + "%f,%f" % tuple(float(cc) for cc in xy) for xy in p[:n] + ) p = p[n:] k, p = segments[0] - yield "M%f,%f" % p[0] + yield "M%f,%f" % tuple(float(cc) for cc in p[0]) for s in path(k, p[1:]): yield s @@ -1741,25 +1883,23 @@ def init(self, graphics, style, item, options): raise BoxConstructError self.spline_degree = spline_degree.get_int_value() - def to_svg(self, offset=None): + def to_svg(self, transform): l = self.style.get_line_width(face_element=False) style = create_css(edge_color=self.edge_color, stroke_width=l) svg = "" for line in self.lines: - s = " ".join(_svg_bezier((self.spline_degree, [xy.pos() for xy in line]))) + s = " ".join(_svg_bezier((self.spline_degree, transform(*line)))) svg += '' % (s, style) return svg - def to_asy(self): + def to_asy(self, transform): l = self.style.get_line_width(face_element=False) pen = create_pens(edge_color=self.edge_color, stroke_width=l) asy = "" for line in self.lines: - for path in _asy_bezier((self.spline_degree, [xy.pos() for xy in line])): - if path[:2] == "..": - path = "(0.,0.)" + path + for path in _asy_bezier((self.spline_degree, transform(*line))): asy += "draw(%s, %s);" % (path, pen) return asy @@ -1810,16 +1950,13 @@ def parse_component(segments): else: raise BoxConstructError - coords = [] - + c = [] for part in parts: if part.get_head_name() != "System`List": raise BoxConstructError - coords.extend( - [graphics.coords(graphics, xy) for xy in part.leaves] - ) + c.extend([expr_to_coords(xy) for xy in part.leaves]) - yield k, coords + yield k, c if all(x.get_head_name() == "System`List" for x in leaves): self.components = [list(parse_component(x)) for x in leaves] @@ -1828,7 +1965,7 @@ def parse_component(segments): else: raise BoxConstructError - def to_svg(self, offset=None): + def to_svg(self, transform): l = self.style.get_line_width(face_element=False) style = create_css( edge_color=self.edge_color, face_color=self.face_color, stroke_width=l @@ -1836,7 +1973,7 @@ def to_svg(self, offset=None): def components(): for component in self.components: - transformed = [(k, [xy.pos() for xy in p]) for k, p in component] + transformed = [(k, transform(*p)) for k, p in component] yield " ".join(_svg_bezier(*transformed)) + " Z" return '' % ( @@ -1844,7 +1981,7 @@ def components(): style, ) - def to_asy(self): + def to_asy(self, transform): l = self.style.get_line_width(face_element=False) pen = create_pens(edge_color=self.edge_color, stroke_width=l) @@ -1853,7 +1990,7 @@ def to_asy(self): def components(): for component in self.components: - transformed = [(k, [xy.pos() for xy in p]) for k, p in component] + transformed = [(k, transform(*p)) for k, p in component] yield "fill(%s--cycle, %s);" % ("".join(_asy_bezier(*transformed)), pen) return "".join(components()) @@ -1864,7 +2001,7 @@ def extent(self): for component in self.components: for _, points in component: for p in points: - x, y = p.pos() + x, y = p result.extend( [(x - l, y - l), (x - l, y + l), (x + l, y - l), (x + l, y + l)] ) @@ -1933,7 +2070,7 @@ def process_option(self, name, value): else: raise BoxConstructError - def to_svg(self, offset=None): + def to_svg(self, transform): l = self.style.get_line_width(face_element=True) if self.vertex_colors is None: face_color = self.face_color @@ -1947,19 +2084,23 @@ def to_svg(self, offset=None): mesh = [] for index, line in enumerate(self.lines): data = [ - [coords.pos(), color.to_js()] - for coords, color in zip(line, self.vertex_colors[index]) + [[float(c) for c in coords], color.to_js()] + for coords, color in zip( + transform(*line), self.vertex_colors[index] + ) ] mesh.append(data) svg += '' % json.dumps(mesh) for line in self.lines: svg += '' % ( - " ".join("%f,%f" % coords.pos() for coords in line), + " ".join( + "%f,%f" % tuple(float(cc) for cc in c) for c in transform(*line) + ), style, ) return svg - def to_asy(self): + def to_asy(self, transform): l = self.style.get_line_width(face_element=True) if self.vertex_colors is None: face_color = self.face_color @@ -1978,8 +2119,7 @@ def to_asy(self): edges = [] for index, line in enumerate(self.lines): paths.append( - "--".join(["(%.5g,%.5g)" % coords.pos() for coords in line]) - + "--cycle" + "--".join(["(%.5g,%.5g)" % c for c in transform(*line)]) + "--cycle" ) # ignore opacity @@ -1999,8 +2139,7 @@ def to_asy(self): if pens and pens != "nullpen": for line in self.lines: path = ( - "--".join(["(%.5g,%.5g)" % coords.pos() for coords in line]) - + "--cycle" + "--".join(["(%.5g,%.5g)" % c for c in transform(*line)]) + "--cycle" ) asy += "filldraw(%s, %s);" % (path, pens) return asy @@ -2460,9 +2599,9 @@ def shrink_one_end(line, s): while s > 0.0: if len(line) < 2: return [] - xy, length = setback(line[0].p, line[1].p, s) + xy, length = setback(line[0], line[1], s) if xy is not None: - line[0] = line[0].add(*xy) + line[0] = add_coords(line[0], xy) else: line = line[1:] s -= length @@ -2482,7 +2621,7 @@ def shrink(line, s1, s2): # note that shrinking needs to happen in the Graphics[] coordinate space, whereas the # subsequent position calculation needs to happen in pixel space. - transformed_points = [xy.pos() for xy in shrink(line, *self.setback)] + transformed_points = shrink(line, *self.setback) for s in polyline(transformed_points): yield s @@ -2490,31 +2629,40 @@ def shrink(line, s1, s2): for s in self.curve.arrows(transformed_points, heads): yield s - def _custom_arrow(self, format, format_transform): + def _custom_arrow(self, format, transform): def make(graphics): - xmin, xmax, ymin, ymax, ox, oy, ex, ey, code = _extract_graphics( - graphics, format, self.graphics.evaluation - ) - boxw = xmax - xmin - boxh = ymax - ymin + code = _extract_graphics(graphics, format, self.graphics.evaluation) + + half_pi = pi / 2.0 def draw(px, py, vx, vy, t1, s): t0 = t1 - cx = px + t0 * vx - cy = py + t0 * vy - transform = format_transform() - transform.translate(cx, cy) - transform.scale(-s / boxw * ex, -s / boxh * ey) - transform.rotate(90 + degrees(atan2(vy, vx))) - transform.translate(-ox, -oy) - yield transform.apply(code) + tx = px + t0 * vx + ty = py + t0 * vy + + r = half_pi + atan2(vy, vx) + + s = -s + + cos_r = cos(r) + sin_r = sin(r) + + # see TranslationTransform[{tx,ty}].ScalingTransform[{s,s}].RotationTransform[r] + yield transform( + [ + [s * cos_r, -s * sin_r, tx], + [s * sin_r, s * cos_r, ty], + [0, 0, 1], + ], + code, + ) return draw return make - def to_svg(self, offset=None): + def to_svg(self, transform): width = self.style.get_line_width(face_element=False) style = create_css(edge_color=self.edge_color, stroke_width=width) polyline = self.curve.make_draw_svg(style) @@ -2523,15 +2671,18 @@ def to_svg(self, offset=None): def polygon(points): yield '' % arrow_style - extent = self.graphics.view_width or 0 + def svg_transform(m, code): + return _Transform(m).to_svg(code) + + extent = self.graphics.extent_width or 0 default_arrow = self._default_arrow(polygon) - custom_arrow = self._custom_arrow("svg", _SVGTransform) + custom_arrow = self._custom_arrow("svg", svg_transform) return "".join(self._draw(polyline, default_arrow, custom_arrow, extent)) - def to_asy(self): + def to_asy(self, transform): width = self.style.get_line_width(face_element=False) pen = create_pens(edge_color=self.edge_color, stroke_width=width) polyline = self.curve.make_draw_asy(pen) @@ -2540,12 +2691,15 @@ def to_asy(self): def polygon(points): yield "filldraw(" - yield "--".join(["(%.5g,%5g)" % xy for xy in points]) + yield "--".join(["(%.5g,%5g)" % xy for xy in transform(*points)]) yield "--cycle, % s);" % arrow_pen - extent = self.graphics.view_width or 0 + def asy_transform(m, code): + return _Transform(m).to_asy(code) + + extent = self.graphics.extent_width or 0 default_arrow = self._default_arrow(polygon) - custom_arrow = self._custom_arrow("asy", _ASYTransform) + custom_arrow = self._custom_arrow("asy", asy_transform) return "".join(self._draw(polyline, default_arrow, custom_arrow, extent)) def extent(self): @@ -2569,6 +2723,183 @@ def default_arrow(px, py, vx, vy, t1, s): return list(self._draw(polyline, default_arrow, None, 0)) +class TransformationFunction(Builtin): + """ + >> RotationTransform[Pi].TranslationTransform[{1, -1}] + = TransformationFunction[{{-1, 0, -1}, {0, -1, 1}, {0, 0, 1}}] + + >> TranslationTransform[{1, -1}].RotationTransform[Pi] + = TransformationFunction[{{-1, 0, 1}, {0, -1, -1}, {0, 0, 1}}] + """ + + rules = { + "Dot[TransformationFunction[a_], TransformationFunction[b_]]": "TransformationFunction[a . b]", + "TransformationFunction[m_][v_]": "Take[m . Join[v, {0}], Length[v]]", + } + + +class TranslationTransform(Builtin): + """ +
+
'TranslationTransform[v]' +
gives the translation by the vector $v$. +
+ + >> TranslationTransform[{1, 2}] + = TransformationFunction[{{1, 0, 1}, {0, 1, 2}, {0, 0, 1}}] + """ + + rules = { + "TranslationTransform[v_]": "TransformationFunction[IdentityMatrix[Length[v] + 1] + " + "(Join[ConstantArray[0, Length[v]], {#}]& /@ Join[v, {0}])]", + } + + +class RotationTransform(Builtin): + rules = { + "RotationTransform[phi_]": "TransformationFunction[{{Cos[phi], -Sin[phi], 0}, {Sin[phi], Cos[phi], 0}, {0, 0, 1}}]", + "RotationTransform[phi_, p_]": "TranslationTransform[-p] . RotationTransform[phi] . TranslationTransform[p]", + } + + +class ScalingTransform(Builtin): + rules = { + "ScalingTransform[v_]": "TransformationFunction[DiagonalMatrix[Join[v, {1}]]]", + "ScalingTransform[v_, p_]": "TranslationTransform[-p] . ScalingTransform[v] . TranslationTransform[p]", + } + + +class Translate(Builtin): + """ +
+
'Translate[g, {x, y}]' +
translates an object by the specified amount. +
'Translate[g, {{x1, y1}, {x2, y2}, ...}]' +
creates multiple instances of object translated by the specified amounts. +
+ + >> Graphics[{Circle[], Translate[Circle[], {1, 0}]}] + = -Graphics- + """ + + rules = { + "Translate[g_, v_?(Depth[#] > 2&)]": "GeometricTransformation[g, TranslationTransform /@ v]", + "Translate[g_, v_?(Depth[#] == 2&)]": "GeometricTransformation[g, TranslationTransform[v]]", + } + + +class Rotate(Builtin): + """ +
+
'Rotate[g, phi]' +
rotates an object by the specified amount. +
+ + >> Graphics[Rotate[Rectangle[], Pi / 3]] + = -Graphics- + + >> Graphics[{Rotate[Rectangle[{0, 0}, {0.2, 0.2}], 1.2, {0.1, 0.1}], Red, Disk[{0.1, 0.1}, 0.05]}] + = -Graphics- + + >> Graphics[Table[Rotate[Scale[{RGBColor[i,1-i,1],Rectangle[],Black,Text["ABC",{0.5,0.5}]},1-i],Pi*i], {i,0,1,0.2}]] + = -Graphics- + """ + + rules = { + "Rotate[g_, phi_]": "GeometricTransformation[g, RotationTransform[phi]]", + "Rotate[g_, phi_, p_]": "GeometricTransformation[g, RotationTransform[phi, p]]", + } + + +class Scale(Builtin): + """ +
+
'Scale[g, phi]' +
scales an object by the specified amount. +
+ + >> Graphics[Rotate[Rectangle[], Pi / 3]] + = -Graphics- + + >> Graphics[{Scale[Rectangle[{0, 0}, {0.2, 0.2}], 3, {0.1, 0.1}], Red, Disk[{0.1, 0.1}, 0.05]}] + = -Graphics- + """ + + rules = { + "Scale[g_, s_?ListQ]": "GeometricTransformation[g, ScalingTransform[s]]", + "Scale[g_, s_]": "GeometricTransformation[g, ScalingTransform[{s, s}]]", + "Scale[g_, s_?ListQ, p_]": "GeometricTransformation[g, ScalingTransform[s, p]]", + "Scale[g_, s_, p_]": "GeometricTransformation[g, ScalingTransform[{s, s}, p]]", + } + + +class GeometricTransformation(Builtin): + """ +
+
'GeometricTransformation[$g$, $tfm$]' +
transforms an object $g$ with the transformation $tfm$. +
+ """ + + pass + + +class GeometricTransformationBox(_GraphicsElement): + def init(self, graphics, style, contents, transform): + super(GeometricTransformationBox, self).init(graphics, None, style) + self.contents = contents + if transform.get_head_name() == "System`List": + functions = transform.leaves + else: + functions = [transform] + evaluation = graphics.evaluation + self.transforms = [ + _Transform(Expression("N", f).evaluate(evaluation)) for f in functions + ] + self.precompute = graphics.precompute_transformations + + def patch_transforms(self, transforms): + self.transforms = transforms + + def extent(self): + def points(): + for content in self.contents: + p = content.extent() + for transform in self.transforms: + for q in transform(*p): + yield q + + return list(points()) + + def to_svg(self, transform0): + if self.precompute: + + def instances(): + for transform in self.transforms: + t = transform.combine(transform0) + for content in self.contents: + yield content.to_svg(t) + + else: + + def instances(): + for content in self.contents: + content_svg = content.to_svg(transform0) + for transform in self.transforms: + yield transform.to_svg(content_svg) + + return "".join(instances()) + + def to_asy(self, transform0): + def instances(): + for content in self.contents: + content_asy = content.to_asy(transform0) + for transform in self.transforms: + yield transform.to_asy(content_asy) + + return "".join(instances()) + + class InsetBox(_GraphicsElement): def init( self, @@ -2579,6 +2910,8 @@ def init( pos=None, opos=(0, 0), opacity=1.0, + font_size=None, + is_absolute=False, ): super(InsetBox, self).init(graphics, item, style) @@ -2587,58 +2920,177 @@ def init( self.color, _ = style.get_style(_Color, face_element=False) self.opacity = opacity + if font_size is not None: + self.font_size = FontSize(self.graphics, value=font_size) + else: + self.font_size, _ = self.style.get_style(FontSize, face_element=False) + if self.font_size is None: + self.font_size = FontSize(self.graphics, value=10.0) + if item is not None: if len(item.leaves) not in (1, 2, 3): raise BoxConstructError content = item.leaves[0] self.content = content.format(graphics.evaluation, "TraditionalForm") if len(item.leaves) > 1: - self.pos = Coords(graphics, item.leaves[1]) + self.pos = expr_to_coords(item.leaves[1]) else: - self.pos = Coords(graphics, pos=(0, 0)) + self.pos = (0, 0) if len(item.leaves) > 2: - self.opos = coords(item.leaves[2]) + self.opos = expr_to_coords(item.leaves[2]) else: self.opos = (0, 0) else: self.content = content self.pos = pos self.opos = opos - self.content_text = self.content.boxes_to_text( - evaluation=self.graphics.evaluation - ) + + self.is_absolute = is_absolute + + try: + self._prepare_text_svg() + except WebEngineUnavailable as e: + self.svg = None + + self.content_text = self.content.boxes_to_text( + evaluation=self.graphics.evaluation + ) + + if self.graphics.evaluation.output.warn_about_web_engine(): + self.graphics.evaluation.message( + "General", "nowebeng", str(e), once=True + ) + except Exception as e: + self.svg = None + + self.graphics.evaluation.message("General", "nowebeng", str(e), once=True) def extent(self): - p = self.pos.pos() - h = 25 - w = len(self.content_text) * 7 # rough approximation by numbers of characters + p = self.pos + + if not self.svg: + h = 25 + w = ( + len(self.content_text) * 7 + ) # rough approximation by numbers of characters + else: + _, w, h = self.svg + scale = self._text_svg_scale(h) + w *= scale + h *= scale + opos = self.opos x = p[0] - w / 2.0 - opos[0] * w / 2.0 y = p[1] - h / 2.0 + opos[1] * h / 2.0 return [(x, y), (x + w, y + h)] - def to_svg(self, offset=None): - x, y = self.pos.pos() - if offset: - x = x + offset[0] - y = y + offset[1] + def _prepare_text_svg(self): + self.graphics.evaluation.output.assume_web_engine() + + content = self.content.boxes_to_mathml(evaluation=self.graphics.evaluation) + + svg = self.graphics.evaluation.output.mathml_to_svg("%s" % content) + + svg = svg.replace("style", "data-style", 1) # HACK + + # we could parse the svg and edit it. using regexps here should be + # a lot faster though. + + def extract_dimension(svg, name): + values = [0.0] + + def replace(m): + value = m.group(1) + values.append(float(value)) + return '%s="%s"' % (name, value) + + svg = re.sub(name + r'="([0-9\.]+)ex"', replace, svg, 1) + return svg, values[-1] + + svg, width = extract_dimension(svg, "width") + svg, height = extract_dimension(svg, "height") - if hasattr(self.content, "to_svg"): - content = self.content.to_svg(noheader=True, offset=(x, y)) - svg = "\n" + content + "\n" + self.svg = (svg, width, height) + + def _text_svg_scale(self, height): + size = self.font_size.get_size() + return size / height + + def _text_svg_xml(self, style, x, y, absolute): + svg, width, height = self.svg + svg = re.sub(r"%s' + % ( + float(x), + float(y), + float(scale * tx), + float(scale * ty), + float(-width / 2 - ox * width / 2), + float(-height / 2 + oy * height / 2), + svg, + ) + ) + + def to_svg(self, transform): + evaluation = self.graphics.evaluation + x, y = transform(self.pos)[0] + is_absolute = self.is_absolute + content = self.content.boxes_to_mathml(evaluation=evaluation) + style = create_css( + font_color=self.color, + edge_color=self.color, + face_color=self.color, + opacity=self.opacity, + ) + + if not self.svg: + if not is_absolute: + x, y = list(self.graphics.local_to_screen((x, y)))[0] + + svg = ( + '' + "%s" + ) % ( + float(x), + float(y), + float(self.opos[0]), + float(self.opos[1]), + style, + content, ) - text_pos_opts = f'x="{x}" y="{y}" ox="{self.opos[0]}" oy="{self.opos[1]}"' - # FIXME: don't hard code text_style_opts, but allow these to be adjustable. - text_style_opts = "text-anchor:middle; dominant-baseline:middle;" - content = self.content.boxes_to_text(evaluation=self.graphics.evaluation) - svg = f'{content}' + if not is_absolute: + svg = self.graphics.inverse_local_to_screen.to_svg(svg) + else: + svg = self._text_svg_xml(style, x, y, is_absolute) + + # TODO: reimplement this? + # if hasattr(self.content, "to_svg"): + # content = self.content.to_svg(noheader=True, offset=(x, y)) + # svg = "\n" + content + "\n" + # else: + # css_style = create_css( + # font_color=self.color, + # edge_color=self.color, + # face_color=self.color, opacity=self.opacity + # ) + # text_pos_opts = f'x="{x}" y="{y}" ox="{self.opos[0]}" oy="{self.opos[1]}"' + # # FIXME: don't hard code text_style_opts, but allow these to be adjustable. + # text_style_opts = "text-anchor:middle; dominant-baseline:middle;" + # content = self.content.boxes_to_text(evaluation=self.graphics.evaluation) + # svg = ( + # f'{content}' + # ) # content = self.content.boxes_to_mathml(evaluation=self.graphics.evaluation) # style = create_css(font_color=self.color) # svg = ( @@ -2647,14 +3099,14 @@ def to_svg(self, offset=None): return svg - def to_asy(self): - x, y = self.pos.pos() + def to_asy(self, transform): + x, y = transform(self.pos)[0] content = self.content.boxes_to_tex(evaluation=self.graphics.evaluation) pen = create_pens(edge_color=self.color) asy = 'label("$%s$", (%s,%s), (%s,%s), %s);' % ( content, - x, - y, + float(x), + float(y), -self.opos[0], -self.opos[1], pen, @@ -2785,7 +3237,7 @@ def get_option(self, name): return self.options.get(name, None) def get_line_width(self, face_element=True): - if self.graphics.pixel_width is None: + if self.graphics.local_to_screen is None: return 0 edge_style, _ = self.get_style( _Thickness, default_to_faces=face_element, consider_forms=face_element @@ -2794,6 +3246,19 @@ def get_line_width(self, face_element=True): return 0 return edge_style.get_thickness() + def to_axis_style(self): + return AxisStyle(self) + + +class AxisStyle(Style): + def __init__(self, style): + super(AxisStyle, self).__init__(style.graphics, style.edge, style.face) + self.styles = style.styles + self.options = style.options + + def get_line_width(self, face_element=True): + return 0.5 + def _flatten(leaves): for leaf in leaves: @@ -2811,7 +3276,7 @@ def _flatten(leaves): class _GraphicsElements(object): def __init__(self, content, evaluation): self.evaluation = evaluation - self.elements = [] + self.web_engine_warning_issued = False builtins = evaluation.definitions.builtin @@ -2861,6 +3326,13 @@ def convert(content, style): item.leaves[0], stylebox_style(style, item.leaves[1:]) ): yield element + elif head == "System`GeometricTransformationBox": + yield GeometricTransformationBox( + self, + style, + list(convert(item.leaves[0], style)), + item.leaves[1], + ) elif head[-3:] == "Box": # and head[:-3] in element_heads: element_class = get_class(head) if element_class is not None: @@ -2882,6 +3354,9 @@ def convert(content, style): self.elements = list(convert(content, self.get_style_class()(self))) + def make_coords(self, points): # overriden by Graphics3DElements + return [expr_to_coords(p) for p in points] + def create_style(self, expr): style = self.get_style_class()(self) @@ -2900,41 +3375,81 @@ def get_style_class(self): class GraphicsElements(_GraphicsElements): - coords = Coords - - def __init__(self, content, evaluation, neg_y=False): + def __init__( + self, content, evaluation, neg_y=False, precompute_transformations=False + ): + self.precompute_transformations = precompute_transformations super(GraphicsElements, self).__init__(content, evaluation) self.neg_y = neg_y - self.xmin = self.ymin = self.pixel_width = None - self.pixel_height = self.extent_width = self.extent_height = None - self.view_width = None + self.pixel_width = None + self.extent_width = self.extent_height = None + self.local_to_screen = None + + def set_size( + self, xmin, ymin, extent_width, extent_height, pixel_width, pixel_height + ): + self.pixel_width = pixel_width + self.pixel_height = pixel_height + self.extent_width = extent_width + self.extent_height = extent_height + + tx = -xmin + ty = -ymin + + w = extent_width if extent_width > 0 else 1 + h = extent_height if extent_height > 0 else 1 + + sx = pixel_width / w + sy = pixel_height / h + + qx = 0 + if self.neg_y: + sy = -sy + qy = pixel_height + else: + qy = 0 + + # now build a transform matrix that mimics what used to happen in GraphicsElements.translate(). + # m = TranslationTransform[{qx, qy}].ScalingTransform[{sx, sy}].TranslationTransform[{tx, ty}] + + m = [[sx, 0, sx * tx + qx], [0, sy, sy * ty + qy], [0, 0, 1]] + transform = _Transform(m) + + # update the GeometricTransformationBox, that always has to be the root element. + + self.elements[0].patch_transforms([transform]) + self.local_to_screen = transform + self.inverse_local_to_screen = transform.inverse() + self.text_rescale = (1.0, -1.0 if self.neg_y else 1.0) + + def add_axis_element(self, e): + # axis elements are added after the GeometricTransformationBox and are thus not + # subject to the transformation from local to pixel space. + self.elements.append(e) def translate(self, coords): - if self.pixel_width is not None: - w = self.extent_width if self.extent_width > 0 else 1 - h = self.extent_height if self.extent_height > 0 else 1 - result = [ - (coords[0] - self.xmin) * self.pixel_width / w, - (coords[1] - self.ymin) * self.pixel_height / h, - ] - if self.neg_y: - result[1] = self.pixel_height - result[1] - return tuple(result) + if self.local_to_screen: + return list(self.local_to_screen(coords))[0] else: - return (coords[0], coords[1]) + return coords[0], coords[1] def translate_absolute(self, d): - if self.pixel_width is None: - return (0, 0) + s = self.extent_width / self.pixel_width + x, y = self.translate_absolute_in_pixels(d) + return x * s, y * s + + def translate_absolute_in_pixels(self, d): + if self.local_to_screen is None: + return 0, 0 else: - l = 96.0 / 72 - return (d[0] * l, (-1 if self.neg_y else 1) * d[1] * l) + l = 96.0 / 72 # d is measured in printer's points + return d[0] * l, (-1 if self.neg_y else 1) * d[1] * l def translate_relative(self, x): - if self.pixel_width is None: + if self.local_to_screen is None: return 0 else: - return x * self.pixel_width + return x * self.extent_width def extent(self, completely_visible_only=False): if completely_visible_only: @@ -2960,19 +3475,20 @@ def extent(self, completely_visible_only=False): ymax *= 2 return xmin, xmax, ymin, ymax - def to_svg(self, offset=None): - return "\n".join(element.to_svg(offset) for element in self.elements) + def to_svg(self): + border = 10 ** 8 - def to_asy(self): - return "\n".join(element.to_asy() for element in self.elements) + def cut_coords(xy): + "Cut values in graphics primitives (not displayed otherwise in SVG)" + return min(max(xy[0], -border), border), min(max(xy[1], -border), border) - def set_size( - self, xmin, ymin, extent_width, extent_height, pixel_width, pixel_height - ): + def cut(*p, w=None): + return [cut_coords(q) for q in p] + + return "\n".join(element.to_svg(cut) for element in self.elements) - self.xmin, self.ymin = xmin, ymin - self.extent_width, self.extent_height = extent_width, extent_height - self.pixel_width, self.pixel_height = pixel_width, pixel_height + def to_asy(self): + return "\n".join(element.to_asy(_no_transform) for element in self.elements) class GraphicsBox(BoxConstruct): @@ -2988,8 +3504,10 @@ def __new__(cls, *leaves, **kwargs): def boxes_to_text(self, leaves=None, **options): if not leaves: leaves = self._leaves - - self._prepare_elements(leaves, options) # to test for Box errors + try: + self._prepare_elements(leaves, options) # to test for Box errors + except Exception: + raise BoxError(Expression("GraphicsBox", self._leaves), "text") return "-Graphics-" def _get_image_size(self, options, graphics_options, max_width): @@ -3068,7 +3586,23 @@ def _prepare_elements(self, leaves, options, neg_y=False, max_width=None): evaluation = options.get("evaluation", None) if evaluation is None: evaluation = self.evaluation - elements = GraphicsElements(leaves[0], evaluation, neg_y) + + transformation = Expression( + "System`TransformationFunction", [[1, 0, 0], [0, 1, 0], [0, 0, 1]] + ) + + precompute_transformations = ( + graphics_options["System`Transformation"].get_string_value() + == "Precomputed" + ) + + elements = GraphicsElements( + Expression("System`GeometricTransformationBox", leaves[0], transformation), + options["evaluation"], + neg_y, + precompute_transformations, + ) + axes = [] # to be filled further down def calc_dimensions(final_pass=True): @@ -3198,21 +3732,22 @@ def get_range(min, max): def boxes_to_tex(self, leaves=None, **options): if not leaves: leaves = self._leaves - elements, calc_dimensions = self._prepare_elements( - leaves, options, max_width=450 - ) - + try: + elements, calc_dimensions = self._prepare_elements( + leaves, options, max_width=450 + ) + except: + raise BoxError(Expression("GraphicsBox", self._leaves), "tex") xmin, xmax, ymin, ymax, w, h, width, height = calc_dimensions() - elements.view_width = w asy_completely_visible = "\n".join( - element.to_asy() + element.to_asy(_no_transform) for element in elements.elements if element.is_completely_visible ) asy_regular = "\n".join( - element.to_asy() + element.to_asy(_no_transform) for element in elements.elements if not element.is_completely_visible ) @@ -3258,21 +3793,24 @@ def to_svg(self, leaves=None, **options): if data: elements, xmin, xmax, ymin, ymax, w, h, width, height = data else: - elements, calc_dimensions = self._prepare_elements( - leaves, options, neg_y=True - ) + try: + elements, calc_dimensions = self._prepare_elements( + leaves, options, neg_y=True + ) + except Exception: + raise BoxError(Expression("GraphicsBox", self._leaves), "svg") xmin, xmax, ymin, ymax, w, h, width, height = calc_dimensions() elements.view_width = w - svg = elements.to_svg(offset=options.get("offset", None)) + svg = elements.to_svg() if self.background_color is not None: svg = '%s' % ( - xmin, - ymin, - w, - h, + float(xmin), + float(ymin), + float(w), + float(h), self.background_color.to_css()[0], svg, ) @@ -3285,14 +3823,14 @@ def to_svg(self, leaves=None, **options): if options.get("noheader", False): return svg svg_xml = """ - - %s - + + %s + """ % ( - " ".join("%f" % t for t in (xmin, ymin, w, h)), + " ".join("%f" % float(t) for t in (xmin, ymin, w, h)), svg, ) return svg_xml # , width, height @@ -3300,8 +3838,13 @@ def to_svg(self, leaves=None, **options): def boxes_to_mathml(self, leaves=None, **options): if not leaves: leaves = self._leaves + try: + elements, calc_dimensions = self._prepare_elements( + leaves, options, neg_y=True + ) + except: + raise BoxError(Expression("GraphicsBox", self._leaves), "MathML") - elements, calc_dimensions = self._prepare_elements(leaves, options, neg_y=True) xmin, xmax, ymin, ymax, w, h, width, height = calc_dimensions() data = (elements, xmin, xmax, ymin, ymax, w, h, width, height) @@ -3404,12 +3947,17 @@ def create_axes(self, elements, graphics_options, xmin, xmax, ymin, ymax): ticks_style = [elements.create_style(s) for s in ticks_style] axes_style = [elements.create_style(s) for s in axes_style] label_style = elements.create_style(label_style) + + ticks_style = [s.to_axis_style() for s in ticks_style] + axes_style = [s.to_axis_style() for s in axes_style] + label_style = label_style.to_axis_style() + ticks_style[0].extend(axes_style[0]) ticks_style[1].extend(axes_style[1]) def add_element(element): element.is_completely_visible = True - elements.elements.append(element) + elements.add_axis_element(element) ticks_x, ticks_x_small, origin_x = self.axis_ticks(xmin, xmax) ticks_y, ticks_y_small, origin_y = self.axis_ticks(ymin, ymax) @@ -3419,12 +3967,17 @@ def add_element(element): tick_large_size = 5 tick_label_d = 2 + # hack: work around the local to screen scaling in class FontSize + font_size = ( + tick_large_size * 2.0 / (elements.extent_width / elements.pixel_width) + ) + ticks_x_int = all(floor(x) == x for x in ticks_x) ticks_y_int = all(floor(x) == x for x in ticks_y) for ( index, - (min, max, p_self0, p_other0, p_origin, ticks, ticks_small, ticks_int), + (min, max, p_self0, p_other0, p_origin, ticks, ticks_small, ticks_int,), ) in enumerate( [ ( @@ -3456,10 +4009,10 @@ def add_element(element): axes_style[index], lines=[ [ - Coords( + axis_coords( elements, pos=p_origin(min), d=p_other0(-axes_extra) ), - Coords( + axis_coords( elements, pos=p_origin(max), d=p_other0(axes_extra) ), ] @@ -3472,8 +4025,8 @@ def add_element(element): for x in ticks: ticks_lines.append( [ - Coords(elements, pos=p_origin(x)), - Coords( + axis_coords(elements, pos=p_origin(x)), + axis_coords( elements, pos=p_origin(x), d=p_self0(tick_large_size) ), ] @@ -3489,10 +4042,12 @@ def add_element(element): elements, tick_label_style, content=content, - pos=Coords( + pos=axis_coords( elements, pos=p_origin(x), d=p_self0(-tick_label_d) ), opos=p_self0(1), + font_size=font_size, + is_absolute=True, opacity=0.5, ) ) @@ -3500,8 +4055,8 @@ def add_element(element): pos = p_origin(x) ticks_lines.append( [ - Coords(elements, pos=pos), - Coords(elements, pos=pos, d=p_self0(tick_small_size)), + axis_coords(elements, pos=pos), + axis_coords(elements, pos=pos, d=p_self0(tick_small_size)), ] ) add_element(LineBox(elements, axes_style[0], lines=ticks_lines)) @@ -3760,6 +4315,7 @@ class Large(Builtin): "Thick": Thick, "Thin": Thin, "PointSize": PointSize, + "FontSize": FontSize, "Arrowheads": Arrowheads, } ) diff --git a/mathics/builtin/inout.py b/mathics/builtin/inout.py index 9930eacb70..40dd7bb110 100644 --- a/mathics/builtin/inout.py +++ b/mathics/builtin/inout.py @@ -36,7 +36,7 @@ PrecisionReal, SymbolList, SymbolMakeBoxes, - SymbolRule + SymbolRule, ) from mathics.core.numbers import ( dps, @@ -123,7 +123,7 @@ def make_boxes_infix(leaves, ops, precedence, grouping, form): def real_to_s_exp(expr, n): if expr.is_zero: s = "0" - sign_prefix = "" + # sign_prefix = "" if expr.is_machine_precision(): exp = 0 else: @@ -1931,6 +1931,7 @@ class General(Builtin): "invalidargs": "Invalid arguments.", "notboxes": "`1` is not a valid box structure.", "pyimport": '`1`[] is not available. Your Python installation misses the "`2`" module.', + "nowebeng": "Web Engine is not available: `1`", } @@ -2071,7 +2072,10 @@ def apply_mathml(self, expr, evaluation) -> Expression: Expression("FullForm", boxes).evaluate(evaluation), ) xml = "" - is_a_picture = xml[:6] == " 6 and xml[:6] == " Expression: boxes = MakeBoxes(expr).evaluate(evaluation) try: tex = boxes.boxes_to_tex(evaluation=evaluation) +# if tex is None: +# return Expression("RowBox", Expression(SymbolList, String(""))) # Replace multiple newlines by a single one e.g. between asy-blocks tex = MULTI_NEWLINE_RE.sub("\n", tex) diff --git a/mathics/builtin/optiondoc.py b/mathics/builtin/optiondoc.py index 8f3cf10df4..f80b83b744 100644 --- a/mathics/builtin/optiondoc.py +++ b/mathics/builtin/optiondoc.py @@ -30,9 +30,10 @@ class Automatic(Builtin): graphical options: >> Cases[Options[Plot], HoldPattern[_ :> Automatic]] - = {Background :> Automatic, Exclusions :> Automatic, ImageSize :> Automatic, MaxRecursion :> Automatic, PlotRange :> Automatic, PlotRangePadding :> Automatic} + = {Background :> Automatic, Exclusions :> Automatic, ImageSize :> Automatic, MaxRecursion :> Automatic, PlotRange :> Automatic, PlotRangePadding :> Automatic, Transformation :> Automatic} """ + class Axes(Builtin): """
diff --git a/mathics/builtin/tensors.py b/mathics/builtin/tensors.py index 771222c3ff..72ecfcb261 100644 --- a/mathics/builtin/tensors.py +++ b/mathics/builtin/tensors.py @@ -7,7 +7,14 @@ from mathics.version import __version__ # noqa used in loading to check consistency. from mathics.builtin.base import Builtin, BinaryOperator -from mathics.core.expression import Expression, Integer, Integer0, String, SymbolTrue, SymbolFalse +from mathics.core.expression import ( + Expression, + Integer, + Integer0, + String, + SymbolTrue, + SymbolFalse, +) from mathics.core.rules import Pattern from mathics.builtin.lists import get_part @@ -486,3 +493,91 @@ def is_boolean(x): return "ColorDistance" return None + + +class TransformationFunction(Builtin): + """ +
+
'TransformationFunction[$m$]' +
represents a transformation. +
+ + >> RotationTransform[Pi].TranslationTransform[{1, -1}] + = TransformationFunction[{{-1, 0, -1}, {0, -1, 1}, {0, 0, 1}}] + + >> TranslationTransform[{1, -1}].RotationTransform[Pi] + = TransformationFunction[{{-1, 0, 1}, {0, -1, -1}, {0, 0, 1}}] + """ + + rules = { + "Dot[TransformationFunction[a_], TransformationFunction[b_]]": "TransformationFunction[a . b]", + "TransformationFunction[m_][v_]": "Take[m . Join[v, {1}], Length[v]]", + } + + +class TranslationTransform(Builtin): + """ +
+
'TranslationTransform[$v$]' +
gives the translation by the vector $v$. +
+ + >> TranslationTransform[{1, 2}] + = TransformationFunction[{{1, 0, 1}, {0, 1, 2}, {0, 0, 1}}] + """ + + rules = { + "TranslationTransform[v_]": "TransformationFunction[IdentityMatrix[Length[v] + 1] + " + "(Join[ConstantArray[0, Length[v]], {#}]& /@ Join[v, {0}])]", + } + + +class RotationTransform(Builtin): + """ +
+
'RotationTransform[$phi$]' +
gives a rotation by $phi$. +
'RotationTransform[$phi$, $p$]' +
gives a rotation by $phi$ around the point $p$. +
+ """ + + rules = { + "RotationTransform[phi_]": "TransformationFunction[{{Cos[phi], -Sin[phi], 0}, {Sin[phi], Cos[phi], 0}, {0, 0, 1}}]", + "RotationTransform[phi_, p_]": "TranslationTransform[p] . RotationTransform[phi] . TranslationTransform[-p]", + } + + +class ScalingTransform(Builtin): + """ +
+
'ScalingTransform[$v$]' +
gives a scaling transform of $v$. $v$ may be a scalar or a vector. +
'ScalingTransform[$phi$, $p$]' +
gives a scaling transform of $v$ that is centered at the point $p$. +
+ """ + + rules = { + "ScalingTransform[v_]": "TransformationFunction[DiagonalMatrix[Join[v, {1}]]]", + "ScalingTransform[v_, p_]": "TranslationTransform[p] . ScalingTransform[v] . TranslationTransform[-p]", + } + + +class ShearingTransform(Builtin): + """ +
+
'ShearingTransform[$phi$, {1, 0}, {0, 1}]' +
gives a horizontal shear by the angle $phi$. +
'ShearingTransform[$phi$, {0, 1}, {1, 0}]' +
gives a vertical shear by the angle $phi$. +
'ShearingTransform[$phi$, $u$, $u$, $p$]' +
gives a shear centered at the point $p$. +
+ """ + + rules = { + "ShearingTransform[phi_, {1, 0}, {0, 1}]": "TransformationFunction[{{1, Tan[phi], 0}, {0, 1, 0}, {0, 0, 1}}]", + "ShearingTransform[phi_, {0, 1}, {1, 0}]": "TransformationFunction[{{1, 0, 0}, {Tan[phi], 1, 0}, {0, 0, 1}}]", + "ShearingTransform[phi_, u_, v_, p_]": "TranslationTransform[p] . ShearingTransform[phi, u, v] . TranslationTransform[-p]", + } diff --git a/mathics/core/evaluation.py b/mathics/core/evaluation.py index 5f183bd712..fde5951703 100644 --- a/mathics/core/evaluation.py +++ b/mathics/core/evaluation.py @@ -1,7 +1,6 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- -import pickle from queue import Queue import os @@ -13,7 +12,15 @@ from mathics_scanner import TranslateError from mathics import settings -from mathics.core.expression import ensure_context, KeyComparable, SymbolAborted, SymbolList, SymbolNull + +from mathics.layout.client import NoWebEngine +from mathics.core.expression import ( + ensure_context, + KeyComparable, + SymbolAborted, + SymbolList, + SymbolNull, +) FORMATS = [ "StandardForm", @@ -213,6 +220,9 @@ def get_data(self): class Output(object): + def __init__(self, web_engine=NoWebEngine()): + self.web_engine = web_engine + def max_stored_size(self, settings) -> int: return settings.MAX_STORED_SIZE @@ -225,6 +235,18 @@ def clear(self, wait): def display(self, data, metadata): raise NotImplementedError + def warn_about_web_engine(self): + return False + + def assume_web_engine(self): + return self.web_engine.assume_is_available() + + def mathml_to_svg(self, mathml): + return self.web_engine.mathml_to_svg(mathml) + + def rasterize(self, svg, *args, **kwargs): + return self.web_engine.rasterize(svg, *args, **kwargs) + class Evaluation(object): def __init__( @@ -249,6 +271,7 @@ def __init__( self.quiet_all = False self.format = format self.catch_interrupt = catch_interrupt + self.once_messages = set() self.SymbolNull = SymbolNull @@ -477,7 +500,7 @@ def get_quiet_messages(self): return [] return value.leaves - def message(self, symbol, tag, *args) -> None: + def message(self, symbol, tag, *args, **kwargs) -> None: from mathics.core.expression import String, Symbol, Expression, from_python # Allow evaluation.message('MyBuiltin', ...) (assume @@ -487,6 +510,11 @@ def message(self, symbol, tag, *args) -> None: pattern = Expression("MessageName", Symbol(symbol), String(tag)) + if kwargs.get("once", False): + if pattern in self.once_messages: + return + self.once_messages.add(pattern) + if pattern in quiet_messages or self.quiet_all: return diff --git a/mathics/layout/__init__.py b/mathics/layout/__init__.py new file mode 100644 index 0000000000..faa18be5bb --- /dev/null +++ b/mathics/layout/__init__.py @@ -0,0 +1,2 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- diff --git a/mathics/layout/client.py b/mathics/layout/client.py new file mode 100644 index 0000000000..9fe2b7f346 --- /dev/null +++ b/mathics/layout/client.py @@ -0,0 +1,255 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# Your installation of nodejs with the following packages: mathjax-node svg2png (install them using +# npm). + +# Tips for troubleshooting: +# https://docs.npmjs.com/resolving-eacces-permissions-errors-when-installing-packages-globally +# export NODE_PATH=$(npm root --quiet -g) + +import subprocess +from subprocess import Popen +import os + +import socket +import json +import struct + + +class WebEngineError(RuntimeError): + pass + + +class WebEngineUnavailable(WebEngineError): + pass + + +class Pipe: + def __init__(self, sock): + self.sock = sock + + # the following three functions are taken from + # http://stackoverflow.com/questions/17667903/python-socket-receive-large-amount-of-data + + def _recvall(self, n): + # Helper function to recv n bytes or return None if EOF is hit + data = b"" + sock = self.sock + while len(data) < n: + packet = sock.recv(n - len(data)) + if not packet: + return None + data += packet + return data + + def put(self, msg): + msg = json.dumps(msg).encode("utf8") + # Prefix each message with a 4-byte length (network byte order) + msg = struct.pack(">I", len(msg)) + msg + self.sock.sendall(msg) + + def get(self): + # Read message length and unpack it into an integer + raw_msglen = self._recvall(4) + if not raw_msglen: + return None + msglen = struct.unpack(">I", raw_msglen)[0] + # Read the message data + return json.loads(self._recvall(msglen).decode("utf8")) + + +class RemoteMethod: + def __init__(self, socket, name): + self.pipe = Pipe(socket) + self.name = name + + def __call__(self, *args): + self.pipe.put({"call": self.name, "args": args}) + reply = self.pipe.get() + + error = reply.get("error") + if error: + raise WebEngineError(str(error)) + else: + return reply.get("data") + + +class Client: + def __init__(self, ip, port): + self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.socket.connect((ip, port)) + + def __getattr__(self, name): + return RemoteMethod(self.socket, name) + + def close(self): + return self.socket.close() + + +# Why WebEngine? Well, QT calls its class for similar stuff "web engine", an engine +# that "provides functionality for rendering regions of dynamic web content". This +# is not about web servers but layout (http://doc.qt.io/qt-5/qtwebengine-index.html). + + +class NoWebEngine: + def assume_is_available(self): + raise WebEngineUnavailable + + def mathml_to_svg(self, mathml): + raise WebEngineUnavailable + + def rasterize(self, svg, *args, **kwargs): + raise WebEngineUnavailable + + +def _normalize_svg(svg): + import xml.etree.ElementTree as ET + import base64 + import re + + ET.register_namespace("", "http://www.w3.org/2000/svg") + root = ET.fromstring(svg) + prefix = "data:image/svg+xml;base64," + + def rewrite(up): + changes = [] + + for i, node in enumerate(up): + if node.tag == "{http://www.w3.org/2000/svg}image": + src = node.attrib.get("src", "") + if src.startswith(prefix): + attrib = node.attrib + + if "width" in attrib and "height" in attrib: + target_width = float(attrib["width"]) + target_height = float(attrib["height"]) + target_transform = attrib.get("transform", "") + + image_svg = _normalize_svg(base64.b64decode(src[len(prefix) :])) + root = ET.fromstring(image_svg) + + view_box = re.split("\s+", root.attrib.get("viewBox", "")) + + if len(view_box) == 4: + x, y, w, h = (float(t) for t in view_box) + root.tag = "{http://www.w3.org/2000/svg}g" + root.attrib = { + "transform": "%s scale(%f, %f) translate(%f, %f)" + % ( + target_transform, + target_width / w, + target_height / h, + -x, + -y, + ) + } + + changes.append((i, node, root)) + else: + rewrite(node) + + for i, node, new_node in reversed(changes): + up.remove(node) + up.insert(i, new_node) + + rewrite(root) + + return ET.tostring(root, "utf8").decode("utf8") + + +class WebEngine: + def __init__(self): + self.process = None + self.client = None + self.unavailable = None + + def _create_client(self): + try: + popen_env = os.environ.copy() + + server_path = os.path.join( + os.path.dirname(os.path.realpath(__file__)), "server.js" + ) + + if False: + # fixes problems on Windows network drives + import tempfile + + fd, copied_path = tempfile.mkstemp(suffix="js") + with open(server_path, "rb") as f: + os.write(fd, f.read()) + os.fsync(fd) + server_path = copied_path + + def abort(message): + error_text = "\n".join( + [ + "", + "Node.js failed to start Mathics server.", + "You might need to run: npm install -g mathjax-node svg2png", + "", + "", + ] + ) + raise WebEngineUnavailable(error_text + message) + + process = Popen( + ["node", server_path], stdout=subprocess.PIPE, env=popen_env + ) + + hello = "HELLO:" # agreed upon "all ok" hello message. + + status = process.stdout.readline().decode("utf8").strip() + if not status.startswith(hello): + error = "" + while True: + line = process.stdout.readline().decode("utf8") + if not line: + break + error += " " + line + + process.terminate() + abort(error) + + port = int(status[len(hello) :]) + except OSError as e: + abort(str(e)) + + try: + self.client = Client("127.0.0.1", port) + self.process = process + except Exception as e: + self.client = None + self.process = None + process.terminate() + abort(str(e)) + + def _ensure_client(self): + if not self.client: + if self.unavailable is not None: + raise WebEngineUnavailable(self.unavailable) + try: + self._create_client() + except WebEngineUnavailable as e: + self.unavailable = str(e) + raise e + + return self.client + + def assume_is_available(self): + if self.unavailable is not None: + raise WebEngineUnavailable(self.unavailable) + + def mathml_to_svg(self, mathml): + return self._ensure_client().mathml_to_svg(mathml) + + def rasterize(self, svg, size): + buffer = self._ensure_client().rasterize(_normalize_svg(svg), size) + return bytearray(buffer["data"]) + + def terminate(self): + if self.process: + self.process.terminate() + self.process = None + self.client = None diff --git a/mathics/layout/server.js b/mathics/layout/server.js new file mode 100644 index 0000000000..5a33f4be63 --- /dev/null +++ b/mathics/layout/server.js @@ -0,0 +1,114 @@ +// to install: npm install -g mathjax-node svg2png + +try { + function server(methods) { + net = require('net'); + + var uint32 = { + parse: function(buffer) { + return (buffer[0] << 24) | + (buffer[1] << 16) | + (buffer[2] << 8) | + (buffer[3] << 0); + }, + make: function(x) { + var buffer = new Buffer(4); + buffer[0] = x >> 24; + buffer[1] = x >> 16; + buffer[2] = x >> 8; + buffer[3] = x >> 0; + return buffer; + } + }; + + var server = net.createServer(function (socket) { + function write(data) { + var json = JSON.stringify(data); + var size = json.length; + socket.write(Buffer.concat([uint32.make(size), new Buffer(json)])); + } + + var state = { + buffer: new Buffer(0) + }; + + function rpc(size) { + var json = JSON.parse(state.buffer.slice(4, size + 4)); + state.buffer = state.buffer.slice(size + 4) + var method = methods[json.call]; + if (method) { + try { + method.apply(null, json.args.concat([write])); + } catch(e) { + write({error: e.toString() + '; ' + e.stack}); + } + } + } + + socket.on('close', function() { + // means our Python client has lost us. quit. + process.exit(); + }); + + socket.on('data', function(data) { + state.buffer = Buffer.concat( + [state.buffer, data]); + + if (state.buffer.length >= 4) { + var buffer = state.buffer; + var size = uint32.parse(buffer); + if (buffer.length >= size + 4) { + rpc(size); + } + } + }); + }); + + server.on('listening', function() { + var port = server.address().port; + process.stdout.write('HELLO:' + port.toString() + '\n'); + }); + + server.listen(0); // pick a free port + } + + var mathjax = require("mathjax-node"); + mathjax.config({ + MathJax: { + // traditional MathJax configuration + } + }); + mathjax.start(); + + server({ + mathml_to_svg: function(mathml, reply) { + mathjax.typeset({ + math: mathml, + format: "MathML", + svg: true + }, function (data) { + if (!data.errors) { + reply({data: data.svg}); + } else { + reply({error: data.errors}); + } + }); + }, + rasterize: function(svg, size, reply) { + var svg2png = require("svg2png"); + + svg2png(Buffer.from(svg, 'utf8'), { + width: size[0], + height: size[1] + }) + .then(function(buffer) { + reply({data: buffer}); + }) + .catch(function(e) { + reply({error: e.toString()}); + }); + } + }); +} catch (ex) { + process.stdout.write('FAIL.' + '\n' + ex.toString() + '\n'); +} diff --git a/mathics/main.py b/mathics/main.py index c211ca8366..37dc6f64e8 100755 --- a/mathics/main.py +++ b/mathics/main.py @@ -192,6 +192,7 @@ def max_stored_size(self, settings): return None def __init__(self, shell): + super(TerminalOutput, self).__init__() self.shell = shell def out(self, out): diff --git a/setup.py b/setup.py index 6dfc62621e..9aafafccee 100644 --- a/setup.py +++ b/setup.py @@ -134,6 +134,7 @@ def subdirs(root, file="*.*", depth=10): "mathics.builtin.pympler", "mathics.builtin.specialfns", "mathics.doc", + "mathics.layout", ], install_requires=INSTALL_REQUIRES, dependency_links=DEPENDENCY_LINKS, @@ -154,6 +155,7 @@ def subdirs(root, file="*.*", depth=10): ], "mathics.doc": ["documentation/*.mdoc", "xml/data"], "mathics.builtin.pymimesniffer": ["mimetypes.xml"], + "mathics.layout": ["server.js"], "pymathics": ["doc/documentation/*.mdoc", "doc/xml/data"], }, entry_points={