Source code for Goulib.drawing

#!/usr/bin/env python
# coding: utf8

"""
Read/Write and handle vector graphics in .dxf, .svg and .pdf formats

:requires:
* `svg.path <http://pypi.python.org/pypisvg.path/>`_ for svg input
* `matplotlib <http://pypi.python.org/pypi/matplotlib/>`_ for bitmap + svg and pdf output
* `dxfwrite <http://pypi.python.org/pypi/dxfwrite/>`_ for dxf output

:optional:
* `dxfgrabber <http://pypi.python.org/pypi/dxfgrabber/>`_ for dxf input
* `pdfminer.six <http://pypi.python.org/pypi/pdfminer.six/>`_ for pdf input
"""
from __future__ import division #"true division" everywhere

__author__ = "Philippe Guglielmetti"
__copyright__ = "Copyright 2014, Philippe Guglielmetti"
__credits__ = ['http://effbot.org/imagingbook/imagedraw.htm', 'http://images.autodesk.com/adsk/files/acad_dxf0.pdf']
__license__ = "LGPL"

from math import  radians, degrees, tan, atan
import logging, base64

from .itertools2 import split, filter2, subdict
from .geom import *
from .plot import Plot
from .colors import color_to_aci, aci_to_color
from .interval import Box
from .math2 import rint, isclose

from . import plot #set matplotlib backend
import matplotlib.pyplot as plt # after import .plot

[docs]def Trans(scale=1, offset=None, rotation=None): """ :param scale: float or (scalex,scaley) tuple of scale factor :param offset: :class:`~geom.Vector3` :param rotation: float angle in degrees :return: :class:`~geom.Matrix3` of generalized scale+offset+rotation """ res = Matrix3() if rotation: res = res.rotate(radians(rotation)) if scale != 1: try: res = res.scale(scale[0],scale[1]) except: res = res.scale(scale) if offset: res = res.translate(offset) return res
identity=Trans()
[docs]class BBox(Box): """bounding box"""
[docs] def __init__(self, p1=None, p2=None): """ :param pt1: :class:`~geom.Point2` first corner (any) :param pt2: :class:`~geom.Point2` opposite corner (any) """ super(BBox,self).__init__(2) if p1 is not None : self+=p1 if p2 is not None : self+=p2
@property def xmin(self): return self[0].start @property def ymin(self): return self[1].start @property def xmax(self): return self[0].end @property def ymax(self): return self[1].end @property def xmed(self): return (self.xmin+self.xmax)/2 @property def ymed(self): return (self.ymin+self.ymax)/2 @property def width(self): return self[0].size @property def height(self): return self[1].size @property def area(self): return self.width*self.height
[docs] def __contains__(self, other): """:return: True if other lies in bounding box.""" if isinstance(other,(Box,tuple)): return super(BBox,self).__contains__(other) #process simple geom entities without building Box objects if isinstance(other,Point2): return super(BBox,self).__contains__(other.xy) if isinstance(other,Segment2): return (super(BBox,self).__contains__(other.p1.xy) and super(BBox,self).__contains__(other.p2.xy)) #for more complex entites, get the box if isinstance(other,Entity): return super(BBox,self).__contains__(other.bbox()) #suppose other is an iterable return all(o in self for o in other)
[docs] def __iadd__(self, pt): """ enlarge box if required to contain specified point :param pt1: :class:`geom.Point2` point to add """ if pt is None: return self elif isinstance(pt,Point2): self+= pt.xy else: super(BBox,self).__iadd__(pt) return self
[docs] def __call__(self): """:return: list of flatten corners""" return list(self.start)+list(self.end)
[docs] def size(self): """:return: :class:`geom.Vector2` with xy sizes""" return Vector2(super(BBox,self).size)
[docs] def center(self): """:return: Pt center""" return Point2(super(BBox,self).center)
[docs] def trans(self, trans): """ :param trans: Xform :return: :class:`BBox` = self transformed by trans """ res = BBox() for i in range(4): # add all corners as they matter if we rotate the box res+=trans(Point2(self.corner(i))) return res
[docs]class Entity(plot.Plot): """Base class for all drawing entities""" color='black' # by default
[docs] def setattr(self, **kwargs): """ set (graphic) attributes to entity :param kwargs: dict of attributes copied to entity """ for key in kwargs: setattr(self, key, kwargs[key])
@property def start(self): return self.p @property def end(self): try: return self.p2 except AttributeError: #probably a Circle return self.p
[docs] def __repr__(self): return '%s(%s,%s)' % (self.__class__.__name__,self.start,self.end)
@property def center(self): return self.bbox().center
[docs] def bbox(self): """ :return: :class:`BBox` bounding box of Entity""" if isinstance(self,Point2): return BBox(self,self) elif isinstance(self,Segment2): return BBox(self.start,self.end) elif isinstance(self,Arc2): #http://stackoverflow.com/questions/1336663/2d-bounding-box-of-a-sector res=BBox(self.p, self.p2) p=self.c+Vector2(self.r,0) if p in self : res+=p p=self.c+Vector2(-self.r,0) if p in self : res+=p p=self.c+Vector2(0,self.r) if p in self : res+=p p=self.c+Vector2(0,-self.r) if p in self : res+=p return res elif isinstance(self,Circle): #must be after Arc2 case since Arc2 is a Circle too rr = Vector2(self.r, self.r) return BBox(self.c - rr, self.c + rr) else: # suppose Polygon2 or other iterable shape res = BBox() for p in self.p: res+=p return res raise NotImplementedError()
[docs] def isclosed(self): return self.end==self.start
[docs] def isline(self): return isinstance(self,Line2) #or derived
[docs] def isvertical(self,tol=0.01): return self.isline() and isclose(self.start.x,self.end.x,tol)
[docs] def ishorizontal(self,tol=0.01): return self.isline() and isclose(self.start.y,self.end.y,tol)
def _dxf_attr(self,attr): """ :return: dict of attributes for dxf.entity """ res={} res['color']=color_to_aci(attr.get('color',self.color)) if 'layer' in attr: res['layer']=attr.get('layer') else: try: res['layer']=self.layer except: pass return res
[docs] def to_dxf(self,**attr): """ :param attr: dict of attributes passed to the dxf entity, overriding those defined in self :return: dxf entity """ import dxfwrite.entities as dxf attr=self._dxf_attr(attr) if isinstance(self,Segment2): return dxf.Line(start=self.start.xy,end=self.end.xy, **attr) elif isinstance(self,Arc2): center=self.c.xy v=Vector2(Point2(self.start)-self.c) startangle=degrees(v.angle()) v=Vector2(Point2(self.end)-self.c) endangle=degrees(v.angle()) if self.dir<0: #DXF handling of ARC direction is somewhat exotic ... startangle,endangle = 180-startangle,180-endangle #start/end were already swapped extrusion_direction=(0,0,-1) center=(-center[0],center[1]) #negative extrusion on Z causes Y axis symmetry... strange... else: extrusion_direction=None #default return dxf.Arc(radius=self.r, center=center, startangle=startangle, endangle=endangle, extrusion_direction=extrusion_direction, **attr) elif isinstance(self,Circle): return dxf.Circle(center=self.c.xy, radius=self.r, **attr) elif isinstance(self,Spline): from dxfwrite import curves spline=curves.Bezier(**attr) spline.start(self.start.xy,(self.p[1]-self.start).xy) spline.append(self.end.xy,(self.p[2]-self.end).xy) return spline return curves.Spline(self.xy, **attr) # builds a POLYLINE3D (???) with 100 segments ... else: raise NotImplementedError
[docs] @staticmethod def from_svg(path,color): """ :param path: svg path :return: Entity of correct subtype """ return Chain.from_svg(path,color)
[docs] @staticmethod def from_pdf(path,trans,color): """ :param path: pdf path :return: Entity of correct subtype """ return Chain.from_pdf(path,trans,color)
[docs] @staticmethod def from_dxf(e,mat3): """ :param e: dxf.entity :param mat3: Matrix3 transform :return: Entity of correct subtype """ if e.dxftype=='LINE': start=Point2(e.start[:2]) end=Point2(e.end[:2]) res=Segment2(start,end) elif e.dxftype == 'ARC': c=Point2(e.center[:2]) startangle=radians(e.start_angle) start=c+Polar(e.radius,startangle) endangle=radians(e.end_angle) end=c+Polar(e.radius,endangle) res=Arc2(c,start,end) elif e.dxftype == 'CIRCLE': res=Circle(Point2(e.center[:2]), e.radius) elif e.dxftype == 'POLYLINE': res=Chain.from_dxf(e,mat3) elif e.dxftype == 'LWPOLYLINE': res=Chain.from_dxf(e,mat3) elif e.dxftype == 'SOLID': return None #TODO: implement elif e.dxftype == 'POINT': return None #TODO: implement elif e.dxftype == 'TEXT': p=Point2(e.insert[:2]) res=Text(e.text,p,size=e.height,rotation=e.rotation) else: logging.warning('unhandled entity type %s'%e.dxftype) return None res._apply_transform(mat3) if res.length==0: res=Point2(res.p) res.dxf=e #keep link to source entity res.color=aci_to_color(e.color) res.layer=e.layer return res
[docs] def patches(self, **kwargs): """ :return: list of (a single) :class:`~matplotlib.patches.Patch` corresponding to entity :note: this is the only method that needs to be overridden in descendants for draw, render and IPython _repr_xxx_ to work """ import matplotlib.patches as patches from matplotlib.path import Path kwargs.setdefault('color',self.color) try: kwargs.setdefault('linewidth',self.width) except: pass try: kwargs.setdefault('fill',self.fill) except: pass if isinstance(self,Segment2): path = Path((self.start.xy,self.end.xy),[Path.MOVETO, Path.LINETO]) return [patches.PathPatch(path, **kwargs)] if isinstance(self,Arc2): theta1=degrees(self.a) theta2=degrees(self.b) if self.dir<1 : #swap theta1,theta2=theta2,theta1 d=self.r*2 return [patches.Arc(self.c.xy,d,d,theta1=theta1,theta2=theta2,**kwargs)] #entities below may be filled, so let's handle the color first color=kwargs.pop('color') kwargs.setdefault('edgecolor',color) kwargs.setdefault('fill',isinstance(self,Point2)) if type(kwargs['fill']) is not bool: #assume it's the fill color kwargs.setdefault('facecolor',kwargs['fill']) kwargs['fill']=True kwargs.setdefault('facecolor',color) if isinstance(self,Point2): try: ms=self.width except: ms=0.01 kwargs.setdefault('clip_on',False) return [patches.Circle(self.xy,ms,**kwargs)] if isinstance(self,Spline): path = Path(self.xy, [Path.MOVETO, Path.CURVE4, Path.CURVE4, Path.CURVE4]) return [patches.PathPatch(path, **kwargs)] if isinstance(self,Ellipse): #must be after Arc2 and Ellipse return [patches.Ellipse(self.c.xy,2*self.r,2*self.r2,**kwargs)] if isinstance(self,Circle): #must be after Arc2 and Ellipse return [patches.Circle(self.c.xy,self.r,**kwargs)] if isinstance(self,Polygon): return [patches.Polygon(self.xy,**kwargs)] raise NotImplementedError
[docs] @staticmethod def figure(box, **kwargs): """ :param box: :class:`drawing.BBox` bounds and clipping box :param kwargs: parameters passed to `~matplotlib.pyplot.figure` :return: matplotlib axis suitable for drawing """ fig=plt.figure(**kwargs) # ax = fig.add_subplot(111) # unneeded # TODO: find why this doesn't work: # plt.gca().set_position([box.xmin,box.ymin,box.width, box.height]) #for some reason we have to plot something in order to size the window #TODO: find a better way... (found no other way top do it...) plt.plot((box.xmin,box.xmax),(box.ymin,box.ymax), alpha=0) #draw a transparent diagonal to size everything plt.axis('equal') import pylab pylab.axis('off') # turn off axis return fig
[docs] def draw(self, fig=None, **kwargs): """ draw entities :param fig: matplotlib figure where to draw. figure(g) is called if missing :return: fig,patch """ if fig is None: if not 'box' in kwargs: kwargs['box']=self.bbox() fig=self.figure(**kwargs) args=subdict(kwargs,('color','linewidth')) p=self.patches(**args) #some of which might be Annotations, which aren't patches but Artists... from matplotlib.patches import Patch patches,artists=filter2(p,lambda e:isinstance(e,Patch)) if patches: from matplotlib.collections import PatchCollection plt.gca().add_collection(PatchCollection(patches,match_original=True)) if artists: for e in artists: plt.gca().add_artist(e) plt.draw() return fig #, p
[docs] def render(self,fmt,**kwargs): """ render graph to bitmap stream :return: matplotlib figure as a byte stream in specified format """ transparent=kwargs.pop('transparent',True) facecolor=kwargs.pop('facecolor', kwargs.pop('background','white')) fig=self.draw(facecolor=facecolor, **kwargs) buffer = six.BytesIO() fig.savefig( buffer, format=fmt, transparent=transparent, facecolor=fig.get_facecolor(), ) res=buffer.getvalue() plt.close(fig) return res
#Python is FANTASTIC ! here we set Entity as base class of some classes previously defined in geom module ! Point2.__bases__ += (Entity,) Segment2.__bases__ += (Entity,) Circle.__bases__ += (Entity,) # adds it also to Arc2 Polygon.__bases__ += (Entity,) # adds it also to Arc2
[docs]class Spline(Entity, Geometry): """cubic spline segment"""
[docs] def __init__(self, points): """:param points: list of (x,y) tuples""" super(Spline,self).__init__() self.p=[Point2(xy) for xy in points]
@property def start(self): return self.p[0] @property def end(self): return self.p[-1] @property def xy(self): return [pt.xy for pt in self.p] @property def length(self): """:return: float (very) approximate length""" return sum((x.dist(self.p[i - 1]) for i, x in enumerate(self.p) if i>0))
[docs] def bbox(self): res=BBox() for p in self.p: res+=p return res
[docs] def swap(self): """ swap start and end""" self.p.reverse() #reverse in place
def _apply_transform(self, t): self.p=[t*p for p in self.p]
''' def Spline(pts): # uses http://www.charlespetzold.com/blog/2012/12/Bezier-Circles-and-Bezier-Ellipses.html # TODO understand http://itc.ktu.lt/itc354/Riskus354.pdf and implement Arc cleanly (p0,p1,p2,p3)=pts t0=Vector2(Point2(p1)-p0)/0.55 c=Point2(p0)-t0.cross() return Arc2(c,p0,p3) ''' class _Group(Entity, Geometry): """ abstract class for iterable Entities""" def bbox(self, filter=None): """ :param filter: optional function(entity):bool returning True if entity should be considered in box :return: :class:`BBox` bounding box of Entity """ res=BBox() for entity in self: # do not use sum() as it copies Boxes unnecessarily if filter is None or filter(entity): res+=entity.bbox() return res @property def length(self): return sum((entity.length for entity in self)) def intersect(self, other): """ :param other: `geom.Entity` :result: generate tuples (Point2,Entity_self) of intersections between other and each Entity """ # recurse in structures by swapping Entities to intersect # in order to ensure we call geom intersect methods only with # "atomic" Entities try: iter(other) except: recurse=False else: recurse=True for e in self: inter=other.intersect(e) if recurse else e.intersect(other) if inter is None: continue if isinstance(inter,Point2) : yield (inter,e) elif isinstance(inter,Segment2) : yield (inter.p,e) yield (inter.p2,e) elif isinstance(inter,list) : # list of multiple points for i in inter: yield (i,e) else: #recursive call for i,e2 in inter: e2.group=e yield (i,e2) def connect(self, other): for (inter, _) in self.intersect(other): if isinstance(inter,Point2): return Segment2(inter,inter) #segment of null length if isinstance(inter,Segment2): return Segment2(inter.p,inter.p) #segment of null length raise try: iter(other) except: recurse=False else: recurse=True return min((other.connect(e).swap() if recurse else e.connect(other) for e in self), key=lambda e:e.length) def patches(self, **kwargs): """:return: list of :class:`~matplotlib.patches.Patch` corresponding to group""" patches=[] for e in self: patches.extend(e.patches(**kwargs)) return patches def to_dxf(self, **kwargs): """:return: flatten list of dxf entities""" res=[] for e in self: r=e.to_dxf(**kwargs) if not isinstance(r,list): #flatten r=[r] res+=r return res
[docs]class Group(list, _Group): """group of Entities but it is a Geometry since we can intersect, connect and compute distances between Groups """ @property def color(self): return self[0].color @color.setter def color(self, c): for e in self: e.color=c @property def layer(self): return self[0].layer @layer.setter def layer(self, l): for e in self: e.layer=l
[docs] def append(self,entity, **kwargs): """ append entity to group :param entity: Entity :param kwargs: dict of attributes copied to entity :return: Group (or Chain) to which the entity was added, or None if entity was None """ if entity is None: return None #to show nothing was done entity.setattr(**kwargs) super(Group,self).append(entity) return self
[docs] def extend(self,entities,**kwargs): for entity in entities: self.append(entity,**kwargs)
[docs] def __copy__(self): #in fact it is a deepcopy... #TODO: make this clearer res=self.__class__() for e in self: res.append(copy(e)) return res
def _apply_transform(self,trans): for entity in self: entity._apply_transform(trans)
[docs] def swap(self): """ swap start and end""" super(Group,self).reverse() #reverse in place for e in self: e.swap()
[docs] def chainify(self, mergeable): """merge all possible entities into chains""" c=chains(self, mergeable=mergeable) del self[:] #clear self.extend(c)
[docs] def from_dxf(self, dxf, layers=None, only=[], ignore=['POINT'], trans=identity, flatten=False): """ :param dxf: dxf.entity :param layers: list of layer names to consider. entities not on these layers are ignored. default=None: all layers are read :param only: list of dxf entity types names that are read. default=[]: all are read :param ignore: list of dxf entity types names that are ignored. default=['POINT']: points and null length segments are ignored :param trans: :class:`Trans` optional transform matrix :parm flatten: bool flatten block structure :return: :class:`Entity` of correct subtype """ self.dxf=dxf for e in dxf: if layers and e.layer not in layers: continue elif e.dxftype in ignore: continue elif only and e.dxftype not in only: continue elif e.dxftype == 'INSERT': #TODO: improve insertion on correct layer t2 = trans*Trans(e.scale[:2], e.insert[:2], e.rotation) if flatten: self.from_dxf(self.block[e.name].dxf, layers=None, ignore=ignore, only=None, trans=t2, flatten=flatten) else: self.append(Instance.from_dxf(e, self.block, t2)) else: e=Entity.from_dxf(e, trans) if type(e) is Point2 and 'POINT' in ignore: continue self.append(e) return self
[docs]class Instance(_Group):
[docs] def __init__(self, group, trans): """ :param group: Group :param trans: optional mat3 of transformation """ self.group=group self.trans=trans
[docs] @staticmethod def from_dxf(e, blocks, mat3): """ :param e: dxf.entity :param blocks: dict of Groups indexed by name :param mat3: Matrix3 transform """ res=Instance(blocks[e.name],mat3) res.name=e.name # code below copied from Entity.from_dxf. TODO: merge res.dxf=e #keep link to source entity res.color=aci_to_color(e.color) res.layer=e.layer return res
[docs] def __repr__(self): return '%s %s' % (self.__class__.__name__, self.group)
[docs] def __iter__(self): #TODO: optimize when trans is identity for e in self.group: res=self.trans*e yield res
def _apply_transform(self,trans): self.trans=trans*self.trans
[docs]class Chain(Group): """ group of contiguous Entities (Polyline or similar)"""
[docs] def __init__(self,data=[]): Group.__init__(self) self.extend(data)
@property def start(self): return self[0].start @property def end(self): return self[-1].end
[docs] def __repr__(self): (s,e)=(self.start,self.end) if len(self)>0 else (None,None) return '%s(%s,%s,%d)' % (self.__class__.__name__,s,e,len(self))
[docs] def contiguous(self, edge, abs_tol=1E-6, allow_swap=True): """ check if edge can be appended to the chain :param edge: :class:`Entity` to append :param tol: float tolerance on contiguity :param allow_swap: if True (default), tries to swap edge or self to find contiguity :return: int,bool index where to append in chain, swap of edge required """ if len(self)==0: #init the chain with edge return -1,False if isclose(self.end.distance(edge.start),0,abs_tol=abs_tol): return -1,False #append if isclose(self.start.distance(edge.end),0,abs_tol=abs_tol): return 0,False #prepend if not allow_swap: return None,False if isclose(self.end.distance(edge.end),0,abs_tol=abs_tol): return -1,True #append swapped if isclose(self.start.distance(edge.start),0,abs_tol=abs_tol): return 0,True #prepend swapped return None, False
[docs] def append(self, entity, tol=1E-6, allow_swap=True, mergeable=None, **attrs): """ append entity to chain, ensuring contiguity :param entity: :class:`Entity` to append :param tol: float tolerance on contiguity :param allow_swap: if True (default), tries to swap edge or self to find contiguity :param mergeable: function of the form f(e1,e2) returning True if entities e1,e2 can be merged :param attrs: attributes passed to Group.append :return: self, or None if edge is not contiguous """ i,s=self.contiguous(entity, tol, allow_swap) if i is None or mergeable and not mergeable(self,entity): return None if s: entity.swap() if not isinstance(entity, Chain): entity=[entity] if i==-1: for e in entity: super(Chain,self).append(e,**attrs) return self if i==0: #prepend for e in reversed(entity): e.setattr(**attrs) self.insert(0,e) return self return None
[docs] @staticmethod def from_pdf(path,trans,color): """ :param path: pdf path :return: Entity of correct subtype :see: http://www.adobe.com/content/dam/Adobe/en/devnet/acrobat/pdfs/PDF32000_2008.pdf p. 132 """ def _pt(*args): return trans(Point2(*args)) chain=Chain() chain.color=color #TODO handle multicolor chains start=None # ensure exception if 'm' is not first for code in path: if code[0]=='m': if start: logging.error("multiple m's in pdf path %s"%path) break # return chain till here, ignore the rest home=_pt(code[1:3]) start=home entity=None continue elif code[0]=='l': #line end=_pt(code[1:3]) entity=Segment2(start,end) elif code[0]=='c': #Bezier 2 control points (no arcs in pdf!) x1,y1,x2,y2,x3,y3=code[1:] end=_pt(x3,y3) entity=Spline([start,_pt(x1,y1),_pt(x2,y2),end]) elif code[0]=='v': #Bezier 1 control point x2,y2,x3,y3=code[1:] end=_pt(x3,y3) entity=Spline([start,start,_pt(x2,y2),end]) elif code[0]=='y': #Bezier 0 control point end=_pt(code[1:3]) entity=Spline([start,start,end,end]) elif code[0]=='h': #close to home entity=Segment2(start,home) entity.color=chain.color else: logging.warning('unsupported path command %s'%code[0]) raise NotImplementedError entity.color=color chain.append(entity) start=end if len(chain)==1: return chain[0] #single entity return chain
[docs] @staticmethod def from_svg(path,color): """ :param path: svg path :return: Entity of correct subtype """ import svg.path chain=Chain() for seg in path._segments: if isinstance(seg,svg.path.Line): entity=Segment2(Point2(seg.start),Point2(seg.end)) elif isinstance(seg,svg.path.Arc): raise NotImplementedError elif isinstance(seg,svg.path.CubicBezier): entity=Spline([Point2(seg.start),Point2(seg.control1),Point2(seg.control2),Point2(seg.end)]) elif isinstance(seg,svg.path.QuadraticBezier): raise NotImplementedError else: #Move continue entity.color=color chain.append(entity) return chain
[docs] @staticmethod def from_dxf(e,mat3): """ :param e: dxf.entity :param mat3: Matrix3 transform :return: Entity of correct subtype """ def arc_from_bulge(start,end,bulge): #formula from http://www.afralisp.net/archive/lisp/Bulges1.htm theta=4*atan(bulge) chord=Segment2(start,end) c=chord.length s=bulge*c/2 r=(c*c/4+s*s)/(2*s) #radius (negative if clockwise) gamma=(pi-theta)/2 angle=chord.v.angle()+(gamma if bulge>=0 else -gamma) center=start+Polar(r,angle) return Arc2(center, start, end, # dir=1 if bulge>=0 else -1 ) if e.dxftype == 'POLYLINE': res=Chain() for i in range(1,len(e.vertices)): start=e.vertices[i-1].location[:2] #2D only end=e.vertices[i].location[:2] #2D only bulge=e.vertices[i-1].bulge if bulge==0: res.append(Segment2(start,end)) else: res.append(arc_from_bulge(start,end,bulge)) if e.is_closed: res.append( Segment2(e.vertices[-1].location[:2], e.vertices[0].location[:2]) ) elif e.dxftype == 'LWPOLYLINE': res=Chain() for i in range(1,len(e.points)): start=e.points[i-1] end=e.points[i] if len(end)==2: res.append(Segment2(start,end)) else: res.append(arc_from_bulge(start,end,bulge=end[2])) if e.is_closed: res.append(Segment2(e.points[-1],e.points[0])) else: logging.warning('unhandled entity type %s'%e.dxftype) return None res._apply_transform(mat3) res.dxf=e #keep link to source entity res.color=aci_to_color(e.color) res.layer=e.layer for edge in res: edge.dxf=e edge.color=res.color edge.layer=res.layer return res
[docs] def to_dxf(self, split=False, **attr): """ :param split: bool if True, each segment in Chain is saved separately :param attr: dict of graphic attributes :return: polyline or list of entities along the chain """ if split: #handle polylines as separate entities return super(Chain,self).to_dxf(**attr) if len(self)==1: return self[0].to_dxf(**attr) #if no color specified assume chain color is the same as the first element's color=attr.get('color', self.color) attr['color']=color_to_aci(color) try: attr.setdefault('layer',self.layer) except : pass #no layer defined from dxfwrite.entities import Polyline attr['flags']=1 if self.isclosed() else 0 res=Polyline(**attr) for e in self: if isinstance(e,Line2): res.add_vertex(e.start.xy) elif isinstance(e, Arc2): bulge=tan(e.angle()/4) res.add_vertex(e.start.xy,bulge=bulge) else: if attr.pop('R12',True): #R12 doesn't handle splines. attr['color']=color #otherwise it causes trouble del attr['flags'] return super(Chain,self).to_dxf(**attr) if not self.isclosed(): res.add_vertex(self.end.xy) return res
[docs]def chains(group, tol=1E-6, mergeable=None): """build chains from all possible segments in group :param mergeable: function(e1,e2) returning True if entities e1,e2 can be merged """ res=Group() changed=False #step 1 : add all entities in group to chains in res for e in group: if e is None or isclose(e.length,0,tol): continue #will not be present in res ok=False for c in res: # if c.isclosed(): continue #reopen closed chains might be good if c.append(e,tol=tol,mergeable=mergeable): ok=True break if not ok: if isinstance(e,Chain): res.append(e) else: res.append(Chain([e])) changed=changed or ok #step 2 : try to merge chains if changed: res=chains(res,tol,mergeable) return res
[docs]class Rect(Chain): """a rectangle starting at low/left and going trigowise through top/right"""
[docs] def __init__(self,*args): if isinstance(args[0], Rect): #copy constructor p1=Point2(args[0].p1) p2=Point2(args[0].p2) else: v1,v2 = Point2(args[0]),Point2(args[1]) p1=Point2(min(v1.x,v2.x),min(v1.y,v2.y)) p2=Point2(max(v1.x,v2.x),max(v1.y,v2.y)) self.append(Segment2(p1,(p2.x,p1.y))) self.append(Segment2((p2.x,p1.y),p2)) self.append(Segment2(p2,(p1.x,p2.y))) self.append(Segment2((p1.x,p2.y),p1))
@property def p1(self): return self[0].p @property def p2(self): return self[2].p
[docs] def __repr__(self): return '%s(%s,%s)' % (self.__class__.__name__,self.p1,self.p2)
dtp= 25.4/72 # one dtp point in mm https://en.wikipedia.org/wiki/Point_(typography)#Current_DTP_point_system
[docs]class Text(Entity):
[docs] def __init__( self, text, point, size=12, rotation=0): """ :param text: string :param point: Point2 :param size: size in points :param rotation: float angle in degrees trigowise """ self.p=Point2(point) self.text=text self.size=size # unit is dtp self.rotation=rotation
def _apply_transform(self, t): self.p=t*self.p self.rotation+=degrees(t.angle())
[docs] def bbox(self): #TODO: improve this very rough approximation return BBox(self.p,self.p+Vector2(0.8*len(self.text)*self.size*dtp,self.size*dtp))
@property def length(self): #TODO: improve this very rough approximation """:return: float length of the text contour in mm""" return len(self.text)*3.6*self.size*dtp
[docs] def intersect(self,other): return None #TODO implement
[docs] def to_dxf(self, **attr): from dxfwrite.entities import Text as Text_dxf return Text_dxf(insert=self.p.xy, text=self.text, height=self.size, **self._dxf_attr(attr))
[docs] def patches(self, **kwargs): """:return: list of (a single) :class:`~matplotlib.patches.Patch` corresponding to entity""" #http://matplotlib.org/api/text_api.html?highlight=text#module-matplotlib.text #entities below may be filled, so let's handle the color first if 'color' in kwargs: # color attribute refers to edgecolor for coherency kwargs.setdefault('edgecolor',kwargs.pop('color')) kwargs.setdefault('fill',False) kwargs.setdefault('family','sans-serif') # from matplotlib.text import Annotation from matplotlib.text import Text as Text_pdf return [Text_pdf(self.p.x,self.p.y, self.text, size=self.size, rotation=self.rotation,**kwargs)]
[docs]class Drawing(Group): """list of Entities representing a vector graphics drawing"""
[docs] def __init__(self, data=[], **kwargs): if isinstance(data,six.string_types): #filename self.load(data,**kwargs) else: Group.__init__(self,data)
[docs] def load(self,filename, **kwargs): ext=filename.split('.')[-1].lower() if ext=='dxf': self.read_dxf(filename, **kwargs) elif ext=='svg': self.read_svg(filename, **kwargs) elif ext=='pdf': self.read_pdf(filename, **kwargs) else: raise IOError("file format .%s not (yet) supported"%ext)
[docs] def read_pdf(self,filename,**kwargs): """ reads a vector graphics on a .pdf file only the first page is parsed """ from pdfminer.pdfparser import PDFParser from pdfminer.pdfdocument import PDFDocument from pdfminer.pdfpage import PDFPage from pdfminer.pdfinterp import PDFResourceManager from pdfminer.pdfinterp import PDFPageInterpreter from pdfminer.pdfdevice import PDFDevice # PDF's are fairly complex documents organized hierarchically # PDFMiner parses them using a stack and calls a "Device" to process entities # so here we define a Device that processes only "paths" one by one: me=self class _Device(PDFDevice): def paint_path(self, graphicstate, stroke, fill, evenodd, path): color=None try: color=stroke.color except: pass if not color: try: color=fill.color except: pass t=Matrix3() # geom.Matrix 3 has the following format: # a b c # e f g # i j k # so we read the components already available in self.ctm: t.a,t.b,t.e,t.f,t.c,t.g=tuple(self.ctm) for sub in split(path,lambda x:x[0]=='m',True): if not sub: #first sub is empty because 'm' occurs in first place continue e=Entity.from_pdf(sub,t,color) if e: me.append(e) elif len(sub)==1 and sub[0][0]=='m': pass # sometimes we have 2 consecutive 'moves' ... else: logging.warning('pdf path ignored %s'%sub) # the PDFPageInterpreter doesn't handle colors yet, so we patch it here: class _Interpreter(PDFPageInterpreter): # stroke def do_S(self): self.device.paint_path(self.graphicstate, self.scs, False, False, self.curpath) self.curpath = [] return # fill def do_f(self): self.device.paint_path(self.graphicstate, self.scs, self.ncs, False, self.curpath) self.curpath = [] return # setrgb-stroking def do_RG(self, r, g, b): from pdfminer.pdfcolor import LITERAL_DEVICE_RGB self.do_CS(LITERAL_DEVICE_RGB) r,g,b=rint(r*255),rint(g*255),rint(b*255) self.scs.color='#%02x%02x%02x' % (r,g,b) # setcolor stroking def do_sc(self): r,g,b=self.pop(self.scs.ncomponents) r,g,b=rint(r*255),rint(g*255),rint(b*255) self.scs.color='#%02x%02x%02x' % (r,g,b) # setcolor nonstroking def do_scn(self): try: r,g,b=self.pop(self.ncs.ncomponents) r,g,b=rint(r*255),rint(g*255),rint(b*255) self.ncs.color='#%02x%02x%02x' % (r,g,b) except: pass #then all we have to do is to launch PDFMiner's parser on the file fp = open(filename, 'rb') parser = PDFParser(fp) document = PDFDocument(parser, fallback=False) rsrcmgr = PDFResourceManager() device = _Device(rsrcmgr) interpreter = _Interpreter(rsrcmgr, device) for page in PDFPage.create_pages(document): interpreter.process_page(page) break #handle one page only return
[docs] def read_svg(self,content, **kwargs): """appends svg content to drawing :param content: string, either filename or svg content """ #from http://stackoverflow.com/questions/15857818/python-svg-parser from svg.path import parse_path import tinycss from xml.dom import minidom try: doc = minidom.parse(content) # parseString also exists except IOError: doc = minidom.parseString(content.encode('utf-8')) styles={} for css in doc.getElementsByTagName('style'): parser = tinycss.make_parser() sheet=parser.parse_stylesheet(css.firstChild.nodeValue) for rule in sheet.rules: style={} for d in rule.declarations: style[d.name]=str(d.value.as_css()) styles[rule.selector.as_css()]=style trans=Trans() trans.f=-1 #flip y axis for path in doc.getElementsByTagName('path'): attr={} klass=path.getAttribute('class') if klass: attr.update(styles['.'+klass]) def getAttribute(name,v=None): res=path.getAttribute(name) return res or attr.get(name,v) color=getAttribute('fill') #assign filling color to default stroke color color=getAttribute('stroke',color) alpha=getAttribute('opacity',0) style=getAttribute('style') if style: for s in style.split(';'): item=s.split(':') if item[0]=='opacity': alpha=item[1] elif item[0]=='stroke': color=item[1] if color is None : continue # process the path d=path.getAttribute('d') for m in d.split('M'): try: e=Entity.from_svg(parse_path('M'+m),color) except: continue # skip this ... e=trans*e self.append(e) doc.unlink() return self
[docs] def read_dxf(self, filename, options=None, **kwargs): """reads a .dxf file :param filename: string path to .dxf file to read :param options: passed to :class:`~dxfgrabber.drawing.Drawing`constructor :param kwargs: dict of optional parameters passed to :method:`Group.from_dxf` """ try: import dxfgrabber self.dxf = dxfgrabber.readfile(filename,options) except ImportError: logging.error('optional module dxfgrabber required') return except Exception as e: logging.error('could not read %s : %s'%(filename,e)) return self.name = filename #build dictionary of blocks self.block={} for block in self.dxf.blocks: self.block[block.name]=Group().from_dxf(block._entities, **kwargs) super(Drawing, self).from_dxf(self.dxf.entities, **kwargs) return self
[docs] def save(self,filename,**kwargs): """ save graph in various formats """ ext=filename.split('.')[-1].lower() if ext!='dxf': kwargs.setdefault('dpi',600) #force good quality open(filename,'wb').write(self.render(ext,**kwargs)) return # save as ASCII DXF V 12 from dxfwrite import DXFEngine as dxf drawing=dxf.drawing(filename) #remove some default tables that we don't need drawing.tables.layers.clear() drawing.tables.styles.clear() drawing.tables.viewports.clear() # self.drawing.tables.linetypes.clear() entities=self.to_dxf(**kwargs) for e in entities: drawing.add(e) """ for i,layer in enumerate(layers): if layer: self.drawing.add_layer(layer,color=i,linetype=linetypes[i]) """ drawing.save()