From fd48e57f33012823ee58e190041286e1fcf14ea3 Mon Sep 17 00:00:00 2001 From: Thomas Kalka Date: Sat, 23 Nov 2019 18:38:23 +0100 Subject: [PATCH] Implement new backend without cairo by providing SVGSurface, PSSurface and Context classes Disable post processing as we want to create proper files right away --- boxes/__init__.py | 12 +- boxes/drawing.py | 520 ++++++++++++++++++++++++++++++++++++++++++++++ boxes/extents.py | 44 ++++ boxes/formats.py | 20 +- boxes/svgutil.py | 1 + 5 files changed, 581 insertions(+), 16 deletions(-) create mode 100644 boxes/drawing.py create mode 100644 boxes/extents.py diff --git a/boxes/__init__.py b/boxes/__init__.py index b130ff0..d012b6f 100755 --- a/boxes/__init__.py +++ b/boxes/__init__.py @@ -14,13 +14,6 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -try: - import cairocffi - - cairocffi.install_as_pycairo() -except ImportError: - pass -import cairo import math import sys import argparse @@ -614,7 +607,9 @@ class Boxes: return self.ctx.stroke() + self.ctx = None + self.surface.flush() self.surface.finish() @@ -1093,6 +1088,9 @@ class Boxes: self.moveTo(x, 0) self.ctx.scale(-1, 1) self.moveTo(self.spacing / 2.0, self.spacing / 2.0) + + self.ctx.new_part() + return dontdraw @restore diff --git a/boxes/drawing.py b/boxes/drawing.py new file mode 100644 index 0000000..d999d79 --- /dev/null +++ b/boxes/drawing.py @@ -0,0 +1,520 @@ +import math +from affine import Affine +from boxes.extents import Extents + +EPS = 1e-4 +PADDING = 10 + +RANDOMIZE_COLORS = False # enable to ease check for continuity of pathes + + +def points_equal(x1, y1, x2, y2): + return abs(x1 - x2) < EPS and abs(y1 - y2) < EPS + + +def pdiff(p1, p2): + x1, y1 = p1 + x2, y2 = p2 + return (x1 - x2, y1 - y2) + + +class Surface: + def __init__(self, fname, width, height): + self._fname = fname + self.parts = [] + self._p = self.new_part("default") + + def flush(self): + pass + + def finish(self): + pass + + def render(self, renderer): + renderer.init(**self.args) + for p in self.parts: + p.render(renderer) + renderer.finish() + + def move_offset(self, dx, dy): + for p in self.parts: + p.move_offset(dx, dy) + + def new_part(self, name="part"): + if self.parts and len(self.parts[-1].pathes) == 0: + return self._p + p = Part(name) + self.parts.append(p) + self._p = p + return p + + def append(self, *path): + self._p.append(*path) + + def stroke(self, **params): + return self._p.stroke(**params) + + def move_to(self, *xy): + self._p.move_to(*xy) + + def extents(self): + if not self.parts: + return Extents() + return sum([p.extents() for p in self.parts]) + + +class Part: + def __init__(self, name): + self.pathes = [] + self.path = [] + + def extents(self): + if not self.pathes: + return Extents() + return sum([p.extents() for p in self.pathes]) + + def move_offset(self, dx, dy): + assert(not self.path) + for p in self.pathes: + p.move_offset(dx, dy) + + def append(self, *path): + self.path.append(list(path)) + + def stroke(self, **params): + if len(self.path) == 0: + return + # search for path ending at new start coordinates to append this path to + xy0 = self.path[0][1:3] + for p in reversed(self.pathes): + if self.path[0][0] == "T": + break + xy1 = p.path[-1][1:3] + if points_equal(*xy0, *xy1): + # todo: check for same color and linewidth + p.path.extend(self.path[1:]) + self.path = [] + return p + p = Path(self.path, params) + self.pathes.append(p) + self.path = [] + return p + + def move_to(self, *xy): + if len(self.path) == 0: + self.path.append(["M", *xy]) + elif self.path[-1][0] == "M": + self.path[-1] = ["M", *xy] + else: + xy0 = self.path[-1][1:3] + if not points_equal(*xy0, *xy): + self.path.append(["M", *xy]) + + +class Path: + def __init__(self, path, params): + self.path = path + self.params = params + # self._extents = None + + def __repr__(self): + l = len(self.path) + # x1,y1 = self.path[0][1:3] + x2, y2 = self.path[-1][1:3] + return f"Path[{l}] to ({x2:.2f},{y2:.2f})" + + def extents(self): + # if self._extents is not None: return self._extents + e = Extents() + for p in self.path: + e.add(*p[1:3]) + return e + + def move_offset(self, dx, dy): + for c in self.path: + C = c[0] + c[1] += dx + c[2] += dy + if C == 'C': + c[3] += dx + c[4] += dy + c[5] += dx + c[6] += dy + + def faster_edges(self): + for (i, p) in enumerate(self.path): + if p[0] == "C" and i > 1 and i < len(self.path) - 1: + if self.path[i - 1][0] == "L" and self.path[i + 1][0] == "L": + p11 = self.path[i - 2][1:3] + p12 = self.path[i - 1][1:3] + p21 = p[1:3] + p22 = self.path[i + 1][1:3] + if (((p12[0]-p21[0])**2 + (p12[1]-p21[1])**2) > + self.params["lw"]**2): + continue + lines_intersect, x, y = line_intersection((p11, p12), (p21, p22)) + if lines_intersect: + self.path[i - 1] = ("L", x, y) + self.path[i] = ("C", x, y, *p12, *p21) + + +class Context: + def __init__(self, surface, *al, **ad): + self._renderer = self._dwg = surface + + self._bounds = Extents() + self._padding = PADDING + + self._stack = [] + self._m = Affine.translation(0, 0) + self._xy = (0, 0) + self._mxy = self._m * self._xy + self._lw = 0 + self._rgb = (0, 0, 0) + self._ff = "sans-serif" + self._last_path = None + + def _update_bounds_(self, mx, my): + self._bounds.update(mx, my) + + def save(self): + self._stack.append( + (self._m, self._xy, self._lw, self._rgb, self._mxy, self._last_path) + ) + self._xy = (0, 0) + + def restore(self): + ( + self._m, + self._xy, + self._lw, + self._rgb, + self._mxy, + self._last_path, + ) = self._stack.pop() + + ## transformations + + def translate(self, x, y): + self._m *= Affine.translation(x, y) + self._xy = (0, 0) + + def scale(self, sx, sy): + self._m *= Affine.scale(sx, sy) + + def rotate(self, r): + self._m *= Affine.rotation(180 * r / math.pi) + + def set_line_width(self, lw): + self._lw = lw + + def set_source_rgb(self, r, g, b): + self._rgb = (r, g, b) + + ## path methods + + def _line_to(self, x, y): + self._add_move() + x1, y1 = self._mxy + self._xy = x, y + x2, y2 = self._mxy = self._m * self._xy + if not points_equal(x1, y1, x2, y2): + self._dwg.append("L", x2, y2) + + def _add_move(self): + self._dwg.move_to(*self._mxy) + + def move_to(self, x, y): + self._xy = (x, y) + self._mxy = self._m * self._xy + + def line_to(self, x, y): + self._line_to(x, y) + + def _arc(self, xc, yc, radius, angle1, angle2, direction): + x1, y1 = radius * math.cos(angle1) + xc, radius * math.sin(angle1) + yc + x4, y4 = radius * math.cos(angle2) + xc, radius * math.sin(angle2) + yc + + # XXX direction seems not needed for small arcs + ax = x1 - xc + ay = y1 - yc + bx = x4 - xc + by = y4 - yc + q1 = ax * ax + ay * ay + q2 = q1 + ax * bx + ay * by + k2 = 4/3 * ((2 * q1 * q2)**0.5 - q2) / (ax * by - ay * bx) + + x2 = xc + ax - k2 * ay + y2 = yc + ay + k2 * ax + x3 = xc + bx + k2 * by + y3 = yc + by - k2 * bx + + mx1, my1 = self._m * (x1, y1) + mx2, my2 = self._m * (x2, y2) + mx3, my3 = self._m * (x3, y3) + mx4, my4 = self._m * (x4, y4) + mxc, myc = self._m * (xc, yc) + + self._add_move() + self._dwg.append("C", mx4, my4, mx2, my2, mx3, my3) + self._xy = (x4, y4) + self._mxy = (mx4, my4) + + def arc(self, xc, yc, radius, angle1, angle2): + self._arc(xc, yc, radius, angle1, angle2, 1) + + def arc_negative(self, xc, yc, radius, angle1, angle2): + self._arc(xc, yc, radius, angle1, angle2, -1) + + def curve_to(self, x1, y1, x2, y2, x3, y3): + # mx0,my0 = self._m*self._xy + mx1, my1 = self._m * (x1, y1) + mx2, my2 = self._m * (x2, y2) + mx3, my3 = self._m * (x3, y3) + self._add_move() + self._dwg.append("C", mx3, my3, mx1, my1, mx2, my2) # destination first! + self._xy = (x3, y3) + + def stroke(self): + # print('stroke stack-level=',len(self._stack),'lastpath=',self._last_path,) + self._last_path = self._dwg.stroke(rgb=self._rgb, lw=self._lw) + self._xy = (0, 0) + + def fill(self): + self._xy = (0, 0) + raise NotImplementedError() + + def select_font_face(self, ff): + self._ff = ff + + def set_font_size(self, fs): + self._fs = fs + + def show_text(self, text, **args): + params = {"ff": self._ff, "fs": self._fs, "lw": self._lw, "rgb": self._rgb} + params.update(args) + mx0, my0 = self._m * self._xy + self._dwg.append("T", mx0, my0, text, params) + + def text_extents(self, text): + fs = self._fs + # XXX ugly hack! Fix Boxes.text() ! + return (0, 0, 0.6 * fs * len(text), 0.65 * fs, fs * 0.1, 0) + + def rectangle(self, x, y, width, height): + + # todo: better check for empty path? + self.stroke() + + self.move_to(x, y) + self.line_to(x + width, y) + self.line_to(x + width, y + height) + self.line_to(x, y + height) + self.line_to(x, y) + self.stroke() + + def get_current_point(self): + return self._xy + + def flush(self): + pass + # todo: check, if needed + # self.stroke() + + ## additional methods + def new_part(self): + self._dwg.new_part() + + +class SVGSurface(Surface): + def finish(self): + import svgwrite + + extents = self.extents() + + self.move_offset(-extents.xmin + PADDING, + -extents.ymin + PADDING) + w = extents.width + 2 * PADDING + h = extents.height + 2 * PADDING + + dwg = svgwrite.Drawing(filename=self._fname, debug=False) + # dwg.debug = False + + dwg["width"] = f"{w:.2f}mm" + dwg["height"] = f"{h:.2f}mm" + dwg["viewBox"] = f"0.0 0.0 {w:.2f} {h:.2f}" + + for i, part in enumerate(self.parts): + if not part.pathes: + continue + g = dwg.add( + dwg.g( + id=f"p-{i}", + style="fill:none;stroke-linecap:round;stroke-linejoin:round;", + ) + ) + for j, path in enumerate(part.pathes): + p = [] + x, y = 0, 0 + path.faster_edges() + for c in path.path: + x0, y0 = x, y + C, x, y = c[0:3] + if C == "M": + p.append(f"M {x:.3f} {y:.3f}") + elif C == "L": + p.append(f"L {x:.3f} {y:.3f}") + elif C == "C": + x1, y1, x2, y2 = c[3:] + p.append( + f"C {x1:.3f} {y1:.3f} {x2:.3f} {y2:.3f} {x:.3f} {y:.3f}" + ) + elif C == "T": + text, params = c[3:] + style = f"font: {params['ff']} ; fill: {rgb_to_svg_color(*params['rgb'])}" + g.add( + dwg.text( + text, + x=[x], + y=[y], + font_size=f"{params['fs']}px", + style=style, + ) + ) + else: + print("Unknown", c) + color = ( + random_svg_color() + if RANDOMIZE_COLORS + else rgb_to_svg_color(*path.params["rgb"]) + ) + if p: # todo: might be empty since text is not implemented yet + g.add( + dwg.path( + d=" ".join(p), stroke=color, stroke_width=path.params["lw"] + ) + ) + dwg.save(pretty=True) + + +class PSSurface(Surface): + + def finish(self): + + extents = self.extents() + + self.move_offset(-extents.xmin + PADDING, + -extents.ymin + PADDING) + + w = extents.width + 2 * PADDING + h = extents.height + 2 * PADDING + + f = open(self._fname, "w") + + f.write("%!PS-Adobe-2.0\n") + f.write( + f"%%BoundingBox: 0 0 {w:.0f} {h:.0f}\n\n" + ) + # f.write(f"%%DocumentMedia: \d+x\d+mm ((\d+) (\d+)) 0 \(" + # dwg['width']=f'{w:.2f}mm' + # dwg['height']=f'{h:.2f}mm' + + for i, part in enumerate(self.parts): + if not part.pathes: + continue + # g = dwg.add( dwg.g(id=f'p-{i}',style='fill:none;stroke-linecap:round;stroke-linejoin:round;') ) + for j, path in enumerate(part.pathes): + p = [] + x, y = 0, 0 + path.faster_edges() + + for c in path.path: + x0, y0 = x, y + C, x, y = c[0:3] + if C == "M": + p.append(f"{x:.3f} {y:.3f} moveto") + elif C == "L": + p.append(f"{x:.3f} {y:.3f} lineto") + elif C == "C": + x1, y1, x2, y2 = c[3:] + p.append( + f"{x1:.3f} {y1:.3f} {x2:.3f} {y2:.3f} {x:.3f} {y:.3f} curveto" + ) + elif C == "T": + text, params = c[3:] + text = text.replace("(", "r\(").replace(")", r"\)") + color = " ".join((f"{c:.2f}" + for c in params["rgb"])) + f.write(f"/{params['ff']} findfont\n") + f.write(f"{params['fs']*72 / 25.4} scalefont\n") + f.write("setfont\n") + f.write(f"{color} setrgbcolor\n") + f.write(f"{x:.3f} {y:.3f} moveto\n") + f.write(f"({text}) show\n\n") + else: + print("Unknown", c) + color = ( + random_svg_color() + if RANDOMIZE_COLORS + else rgb_to_svg_color(*path.params["rgb"]) + ) + if p: # todo: might be empty since text is not implemented yet + color = " ".join((f"{c:.2f}" + for c in path.params["rgb"])) + f.write("newpath\n") + f.write("\n".join(p)) + f.write("\n") + f.write(f"{path.params['lw']} setlinewidth\n") + f.write(f"{color} setrgbcolor\n") + f.write("stroke\n\n") + f.write( + """ +showpage +%%Trailer +%%EOF +""" + ) + f.close() + + +from random import random + + +def random_svg_color(): + r, g, b = random(), random(), random() + return f"rgb({r*255:.0f},{g*255:.0f},{b*255:.0f})" + + +def rgb_to_svg_color(r, g, b): + return f"rgb({r*255:.0f},{g*255:.0f},{b*255:.0f})" + + +def line_intersection(line1, line2): + + xdiff = (line1[0][0] - line1[1][0], line2[0][0] - line2[1][0]) + ydiff = (line1[0][1] - line1[1][1], line2[0][1] - line2[1][1]) + + def det(a, b): + return a[0] * b[1] - a[1] * b[0] + + div = det(xdiff, ydiff) + if div == 0: + # todo: deal with paralel line intersection / overlay + return False, None, None + + d = (det(*line1), det(*line2)) + x = det(d, xdiff) / div + y = det(d, ydiff) / div + + on_segments = ( + (x + EPS >= min(line1[0][0], line1[1][0])), + (x + EPS >= min(line2[0][0], line2[1][0])), + (x - EPS <= max(line1[0][0], line1[1][0])), + (x - EPS <= max(line2[0][0], line2[1][0])), + (y + EPS >= min(line1[0][1], line1[1][1])), + (y + EPS >= min(line2[0][1], line2[1][1])), + (y - EPS <= max(line1[0][1], line1[1][1])), + (y - EPS <= max(line2[0][1], line2[1][1])), + ) + + return min(on_segments), x, y diff --git a/boxes/extents.py b/boxes/extents.py new file mode 100644 index 0000000..2db2f1c --- /dev/null +++ b/boxes/extents.py @@ -0,0 +1,44 @@ +class Extents: + __slots__ = "xmin ymin xmax ymax".split() + + def __init__(self,xmin=float('inf'),ymin=float('inf'),xmax=float('-inf'),ymax=float('-inf')): + self.xmin = xmin + self.ymin = ymin + self.xmax = xmax + self.ymax = ymax + + def add(self,x,y): + self.xmin = min(self.xmin,x) + self.xmax = max(self.xmax,x) + self.ymin = min(self.ymin,y) + self.ymax = max(self.ymax,y) + + def extend(self,l): + for x,y in l: + self.add(x,y) + + def __add__(self,extent): + #todo: why can this happen? + if extent ==0: + return Extents(self.xmin,self.ymin,self.xmax,self.ymax) + return Extents( + min(self.xmin,extent.xmin),min(self.ymin,extent.ymin), + max(self.xmax,extent.xmax),max(self.ymax,extent.ymax) + ) + + def __radd__(self,extent): + if extent == 0: + return Extents(self.xmin,self.ymin,self.xmax,self.ymax) + return self.__add__(extent) + + def get_width(self): + return self.xmax-self.xmin + + def get_height(self): + return self.ymax-self.ymin + + width = property(get_width) + height = property(get_height) + + def __repr__(self): + return f'Extents ({self.xmin},{self.ymin})-({self.xmax},{self.ymax})' \ No newline at end of file diff --git a/boxes/formats.py b/boxes/formats.py index 29a62a4..a64ec7f 100644 --- a/boxes/formats.py +++ b/boxes/formats.py @@ -18,15 +18,14 @@ import subprocess import tempfile import os -import cairo -import re -from boxes import svgutil +from boxes.drawing import SVGSurface, PSSurface, Context class PSFile: def __init__(self, filename): self.filename = filename def adjustDocumentMedia(self): + return with open(self.filename, "r+") as f: s = f.read(1024) m = re.search(r"%%BoundingBox: (\d+) (\d+) (\d+) (\d+)", s) @@ -80,20 +79,23 @@ class Formats: def getSurface(self, fmt, filename): - width = height = 10000 # mm + width = height = 10000 # mm if fmt in ("svg", "svg_Ponoko"): - surface = cairo.SVGSurface(filename, width, height) + surface = SVGSurface(filename, width, height) mm2pt = 1.0 else: mm2pt = 72 / 25.4 width *= mm2pt height *= mm2pt # 3.543307 - surface = cairo.PSSurface(filename, width, height) + surface = PSSurface(filename, width, height) - ctx = cairo.Context(surface) - ctx.translate(0, height) - ctx.scale(mm2pt, -mm2pt) + ctx = Context(surface) + if fmt in ("svg", "svg_Ponoko"): + ctx.translate(0, height) + ctx.scale(mm2pt, -mm2pt) + else: + ctx.scale(mm2pt, mm2pt) return surface, ctx diff --git a/boxes/svgutil.py b/boxes/svgutil.py index 8139aa2..89a73a5 100755 --- a/boxes/svgutil.py +++ b/boxes/svgutil.py @@ -69,6 +69,7 @@ class SVGFile(object): self.symbol_extends = {} def fix(self, metadata=None): + return #print("Optimizations:", self.optimize(self.tree.getroot()) self.getEnvelope()