#*************************************************************************** #* * #* Copyright (c) 2013 - Yorik van Havre * #* * #* This program is free software; you can redistribute it and/or modify * #* it under the terms of the GNU Lesser General Public License (LGPL) * #* as published by the Free Software Foundation; either version 2 of * #* the License, or (at your option) any later version. * #* for detail see the LICENCE text file. * #* * #* This program is distributed in the hope that it will be useful, * #* but WITHOUT ANY WARRANTY; without even the implied warranty of * #* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * #* GNU Library General Public License for more details. * #* * #* You should have received a copy of the GNU Library General Public * #* License along with this program; if not, write to the Free Software * #* Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * #* USA * #* * #*************************************************************************** import re, math, FreeCAD, FreeCADGui from PySide import QtCore,QtGui DEBUG = True # set to True to show debug messages if open.__module__ == '__builtin__': pyopen = open # because we'll redefine open below class MathParser: "A math expression parser" # code adapted from http://www.nerdparadise.com/tech/python/parsemath/ def __init__(self, string, vars={}): self.string = string self.index = 0 self.vars = { 'pi' : math.pi, 'e' : math.e } for var in vars.keys(): if self.vars.get(var) != None: raise Exception("Cannot redefine the value of " + var) self.vars[var] = vars[var] def getValue(self): value = self.parseExpression() self.skipWhitespace() if self.hasNext(): raise Exception( "Unexpected character found: '" + self.peek() + "' at index " + str(self.index)) return value def peek(self): return self.string[self.index:self.index + 1] def hasNext(self): return self.index < len(self.string) def skipWhitespace(self): while self.hasNext(): if self.peek() in ' \t\n\r': self.index += 1 else: return def parseExpression(self): return self.parseAddition() def parseAddition(self): values = [self.parseMultiplication()] while True: self.skipWhitespace() char = self.peek() if char == '+': self.index += 1 values.append(self.parseMultiplication()) elif char == '-': self.index += 1 values.append(-1 * self.parseMultiplication()) else: break return sum(values) def parseMultiplication(self): values = [self.parseParenthesis()] while True: self.skipWhitespace() char = self.peek() if char == '*': self.index += 1 values.append(self.parseParenthesis()) elif char == '/': div_index = self.index self.index += 1 denominator = self.parseParenthesis() if denominator == 0: raise Exception( "Division by 0 kills baby whales (occured at index " + str(div_index) + ")") values.append(1.0 / denominator) else: break value = 1.0 for factor in values: value *= factor return value def parseParenthesis(self): self.skipWhitespace() char = self.peek() if char == '(': self.index += 1 value = self.parseExpression() self.skipWhitespace() if self.peek() != ')': raise Exception( "No closing parenthesis found at character " + str(self.index)) self.index += 1 return value else: return self.parseNegative() def parseNegative(self): self.skipWhitespace() char = self.peek() if char == '-': self.index += 1 return -1 * self.parseParenthesis() else: return self.parseValue() def parseValue(self): self.skipWhitespace() char = self.peek() if char in '0123456789.': return self.parseNumber() else: return self.parseVariable() def parseVariable(self): self.skipWhitespace() var = '' while self.hasNext(): char = self.peek() if char.lower() in '_abcdefghijklmnopqrstuvwxyz0123456789': var += char self.index += 1 else: break value = self.vars.get(var, None) if value == None: raise Exception( "Unrecognized variable: '" + var + "'") return float(value) def parseNumber(self): self.skipWhitespace() strValue = '' decimal_found = False char = '' while self.hasNext(): char = self.peek() if char == '.': if decimal_found: raise Exception( "Found an extra period in a number at character " + str(self.index) + ". Are you European?") decimal_found = True strValue += '.' elif char in '0123456789': strValue += char else: break self.index += 1 if len(strValue) == 0: if char == '': raise Exception("Unexpected end found") else: raise Exception( "I was expecting to find a number at character " + str(self.index) + " but instead I found a '" + char + "'. What's up with that?") return float(strValue) class Spreadsheet: """An object representing a spreadsheet. Can be used as a FreeCAD object or as a standalone python object. Cells of the spreadsheet can be got/set as arguments, as: myspreadsheet = Spreadsheet() myspreadsheet.a1 = 54 print(myspreadsheet.a1) myspreadsheet.a2 = "My text" myspreadsheet.b1 = "=a1*3" print(myspreadsheet.b1) The cell names are case-insensitive (a1 = A1) """ def __init__(self,obj=None): if obj: obj.Proxy = self obj.addProperty("App::PropertyLinkList","Controllers","Base","Cell controllers of this object") self._cells = {} # this stores cell contents self._relations = {} # this stores relations - currently not used self.cols = [] # this stores filled columns self.rows = [] # this stores filed rows self.Type = "Spreadsheet" def __repr__(self): return "Spreadsheet object containing " + str(len(self._cells)) + " cells" def __setattr__(self, key, value): if self.isKey(key): key = key.lower() if DEBUG: print "Setting key ",key," to value ",value if (value == "") or (value == None): # remove cell if key in self._cells.keys(): del self._cells[key] else: # add cell self._cells[key] = value if value: if self.isFunction(value): self._updateDependencies(key,value) c,r = self.splitKey(key) if not c in self.cols: self.cols.append(c) self.cols.sort() if not r in self.rows: self.rows.append(r) self.rows.sort() else: self.__dict__.__setitem__(key,value) def __getattr__(self, key): if key.lower() in self._cells: key = key.lower() if self.isFunction(self._cells[key]): try: e = self.evaluate(key) except: print "Spreadsheet: Error evaluating formula" return None else: return e else: return self._cells[key] else: return self.__dict__.__getitem__(key) def __getstate__(self): self._cells["Type"] = self.Type return self._cells def __setstate__(self,state): if state: self._cells = state # extracting Type if "Type" in self._cells.keys(): self.Type = self._cells["Type"] del self._cells["Type"] # updating relation tables self.rows = [] self.cols = [] self._relations = {} for key in self._cells.keys(): c,r = self.splitKey(key) if not r in self.rows: self.rows.append(r) self.rows.sort() if not c in self.cols: self.cols.append(c) self.cols.sort() if self.isFunction(key): self._updateDependencies(key) def _updateDependencies(self,key,value=None): "search for ancestors in the value and updates the table" ancestors = [] if not value: value = self._cells[key] for v in re.findall(r"[\w']+",value): if self.isKey(v): ancestors.append(v) for a in ancestors: if a in self._relations: if not key in self._relations[a]: self._relations[a].append(key) else: self._relations[a] = [key] def execute(self,obj): self.setControlledCells(obj) def isFunction(self,key): "isFunction(cell): returns True if the given cell or value is a function" if str(key).lower() in self._cells: key = key.lower() if str(self._cells[key])[0] == "=": return True elif str(key)[0] == "=": return True else: return False def isNumeric(self,key): "isNumeric(cell): returns True if the given cell returns a number" key = key.lower() if self.isFunction(key): res = self.evaluate(key) else: res = self._cells[key] if isinstance(res,float) or isinstance(res,int): return True else: return False def isKey(self,value): "isKey(val): returns True if the given value is a valid cell number" allowMoreThanOneLetter = False al = False nu = False for v in value: if not v.isalnum(): return False elif not al: if v.isalpha(): al = True else: return False else: if not nu: # forbidden to set items at row 0 if v == "0": return False if v.isalpha(): if not allowMoreThanOneLetter: return False elif nu: return False elif v.isdigit(): nu = True if not nu: return False return True def splitKey(self,key): "splitKey(cell): splits a key between column and row" c = '' r = '' for ch in key: if ch.isalpha(): c += ch else: r += ch return c,r def getFunction(self,key): "getFunction(cell): returns the function contained in the given cell, instead of the value" key = key.lower() if key in self._cells: return self._cells[key] else: return None def getSize(self): "getSize(): returns a tuple with number of columns and rows of this spreadsheet" return (len(self.columns),len(self.rows)) def getCells(self,index): "getCells(index): returns the cells from the given column of row number" cells = {} for k in self._cells.keys(): c,r = self.splitKey(k) if index in [c,r]: cells[k] = self._cells[k] return cells def evaluate(self,key): "evaluate(key): evaluates the given formula" key = key.lower() elts = re.split(r'(\W+)',self._cells[key][1:]) result = "" for e in elts: if self.isKey(e): if self.isFunction(e): if self.isNumeric(e): result += str(self.evaluate(e)) else: print "Spreadsheet: Error evaluating formula" return elif self.isNumeric(e): result += str(self._cells[e.lower()]) else: result += e if DEBUG: print "Evaluating ",result try: p = MathParser(result) result = p.getValue() except Exception as (ex): msg = ex.message raise Exception(msg) return result def setControlledCells(self,obj): "Fills the cells that are controlled by a controller" if obj: if hasattr(obj,"Controllers"): for co in obj.Controllers: co.Proxy.setCells(co,obj) def getControlledCells(self,obj): "returns a list of cells managed by controllers" cells = [] if hasattr(obj,"Controllers"): for c in obj.Controllers: cells.extend(c.Proxy.getCells(c,obj)) return cells class ViewProviderSpreadsheet(object): def __init__(self, vobj): vobj.Proxy = self def getIcon(self): import Spreadsheet_rc return ":/icons/Spreadsheet.svg" def attach(self,vobj): self.Object = vobj.Object def setEdit(self,vobj,mode): if hasattr(self,"editor"): pass else: self.editor = SpreadsheetView(vobj.Object) addSpreadsheetView(self.editor) return True def unsetEdit(self,vobj,mode): return False def claimChildren(self): if hasattr(self,"Object"): if hasattr(self.Object,"Controllers"): return self.Object.Controllers def __getstate__(self): return None def __setstate__(self,state): return None class SpreadsheetController: "A spreadsheet cell controller object" def __init__(self,obj): obj.Proxy = self self.Type = "SpreadsheetController" obj.addProperty("App::PropertyEnumeration","FilterType","Filter","The type of filter to apply to the scene objects") obj.addProperty("App::PropertyString","Filter","Filter","The filter to apply to the scene objects") obj.addProperty("App::PropertyEnumeration","DataType","Data","The type of data to extract from the objects") obj.addProperty("App::PropertyString","Data","Data","The data to extract from the objects") obj.addProperty("App::PropertyString","BaseCell","Base","The starting cell of this controller") obj.addProperty("App::PropertyEnumeration","Direction","Base","The cells direction of this controller") obj.FilterType = ["Object Type","Object Name"] obj.DataType = ["Get Property","Count"] obj.Direction = ["Horizontal","Vertical"] def execute(self,obj): pass def __getstate__(self): return self.Type def __setstate__(self,state): if state: self.Type = state def onChanged(self,obj,prop): if prop == "DataType": if obj.DataType == "Count": obj.setEditorMode('Data',1) else: obj.setEditorMode('Data',0) def getDataSet(self,obj): "returns a list of objects to be considered by this controller" result = [] if hasattr(obj,"FilterType"): import Draft baseset = FreeCAD.ActiveDocument.Objects if obj.FilterType == "Object Type": for o in baseset: t = Draft.getType(o) if t == "Part": t = obj.TypeId if obj.Filter: if obj.Filter in t: result.append(obj) else: result.append(obj) elif obj.FilterType == "Object Name": for o in baseset: if obj.Filter: if obj.Filter in obl.Label: result.append(obj) else: result.append(obj) return result def getCells(self,obj,spreadsheet): "returns a list of cells controlled by this controller" cells = [] if obj.BaseCell: if obj.DataType == "Count": return obj.BaseCell for i in range(len(self.getDataSet())): # get the correct cell key c,r = spreadsheet.Proxy.splitKey(obj.BaseCell) if obj.Direction == "Horizontal": c = "abcdefghijklmnopqrstuvwxyz".index(c) c += i c = "abcdefghijklmnopqrstuvwxyz"[c] else: r = int(r) + i cells.append(c+str(r)) return cells def setCells(self,obj,spreadsheet): "Fills the controlled cells of the given spreadsheet" if obj.BaseCell: dataset = self.getDataSet() if obj.DataType == "Count": if spreadsheet.Proxy.isKey(obj.BaseCell): try: setattr(spreadsheet.Proxy,obj.BaseCell,len(dataset)) except: print "Spreadsheet: Error counting objects" elif obj.Data: for i in range(len(dataset)): # get the correct cell key c,r = spreadsheet.Proxy.splitKey(obj.BaseCell) if obj.Direction == "Horizontal": c = "abcdefghijklmnopqrstuvwxyz".index(c) c += i c = "abcdefghijklmnopqrstuvwxyz"[c] else: r = int(r) + i cell = c+str(r) if DEBUG: print "auto setting cell ",cell if spreadsheet.Proxy.isKey(cell): # get the contents args = obj.Data.split(".") value = dataset[i] for arg in args: if hasattr(value,arg): value = getattr(value,arg) try: setattr(spreadsheet.Proxy,cell,value) if DEBUG: print "setting cell ",cell," to value ",value except: print "Spreadsheet: Error retrieving property "+obj.Data+" from object "+dataset[i].Name class ViewProviderSpreadsheetController: "A view provider for the spreadsheet cell controller" def __init__(self,vobj): vobj.Proxy = self def getIcon(self): import Spreadsheet_rc return ":/icons/SpreadsheetController.svg" class SpreadsheetView(QtGui.QWidget): "A spreadsheet viewer for FreeCAD" def __init__(self,spreadsheet=None): from DraftTools import translate QtGui.QWidget.__init__(self) self.setWindowTitle(str(translate("Spreadsheet","Spreadsheet"))) self.setObjectName("Spreadsheet viewer") self.verticalLayout = QtGui.QVBoxLayout(self) self.doNotChange = False # add editor line self.horizontalLayout = QtGui.QHBoxLayout() self.label = QtGui.QLabel(self) self.label.setMinimumSize(QtCore.QSize(82, 0)) self.label.setText(str(translate("Spreadsheet","Cell"))+" A1 :") self.horizontalLayout.addWidget(self.label) self.lineEdit = QtGui.QLineEdit(self) self.horizontalLayout.addWidget(self.lineEdit) self.verticalLayout.addLayout(self.horizontalLayout) # add table self.table = QtGui.QTableWidget(30,26,self) for i in range(26): ch = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"[i] self.table.setHorizontalHeaderItem(i, QtGui.QTableWidgetItem(ch)) self.verticalLayout.addWidget(self.table) self.table.setCurrentCell(0,0) self.spreadsheet = spreadsheet self.update() QtCore.QObject.connect(self.table, QtCore.SIGNAL("cellChanged(int,int)"), self.changeCell) QtCore.QObject.connect(self.table, QtCore.SIGNAL("currentCellChanged(int,int,int,int)"), self.setEditLine) QtCore.QObject.connect(self.lineEdit, QtCore.SIGNAL("returnPressed()"), self.getEditLine) QtCore.QObject.connect(self, QtCore.SIGNAL("destroyed()"), self.destroy) def destroy(self): if DEBUG: print "Closing spreadsheet view" if self.spreadsheet: # before deleting this view, we remove the reference to it in the object if hasattr(self.spreadsheet,"ViewObject"): if self.spreadsheet.ViewObject: if hasattr(self.spreadsheet.ViewObject.Proxy,"editor"): del self.spreadsheet.ViewObject.Proxy.editor if FreeCADGui: if FreeCADGui.ActiveDocument: FreeCADGui.ActiveDocument.resetEdit() def update(self): "updates the cells with the contents of the spreadsheet" if self.spreadsheet: controlled = self.spreadsheet.Proxy.getControlledCells(self.spreadsheet) for cell in self.spreadsheet.Proxy._cells.keys(): if cell != "Type": c,r = self.spreadsheet.Proxy.splitKey(cell) c = "abcdefghijklmnopqrstuvwxyz".index(c) r = int(str(r))-1 content = getattr(self.spreadsheet.Proxy,cell) if self.spreadsheet.Proxy.isFunction(cell): self.doNotChange = True if content == None: content = "" if DEBUG: print "Updating ",cell," to ",content if self.table.item(r,c): self.table.item(r,c).setText(str(content)) else: self.table.setItem(r,c,QtGui.QTableWidgetItem(str(content))) if cell in controlled: brush = QtGui.QBrush(QtGui.QColor(255, 0, 0)) brush.setStyle(QtCore.Qt.Dense6Pattern) self.table.item(r,c).setBackground(brush) def changeCell(self,r,c,value=None): "changes the contens of a cell" if self.doNotChange: if DEBUG: print "DoNotChange flag is set" self.doNotChange = False elif self.spreadsheet: key = "abcdefghijklmnopqrstuvwxyz"[c]+str(r+1) if not value: value = self.table.item(r,c).text() if DEBUG: print "Changing "+key+" to "+value # store the entry as best as possible try: v = int(value) except: try: v = float(value) except: try: v = v = str(value) except: v = value setattr(self.spreadsheet.Proxy,key,v) self.update() # TODO do not update the whole spreadsheet when only one cell has changed: # use the _relations table and recursively update only cells based on this one self.setEditLine(r,c) def setEditLine(self,r,c,orr=None,orc=None): "copies the contents of the active cell to the edit line" if self.spreadsheet: c = "abcdefghijklmnopqrstuvwxyz"[c] r = r+1 if DEBUG: print "Active cell "+c+str(r) from DraftTools import translate self.label.setText(str(translate("Spreadsheet","Cell"))+" "+c.upper()+str(r)+" :") content = self.spreadsheet.Proxy.getFunction(c+str(r)) if content == None: content = "" self.lineEdit.setText(str(content)) def getEditLine(self): "called when something has been entered in the edit line" txt = str(self.lineEdit.text()) if DEBUG: print "Text edited ",txt if txt: r = self.table.currentRow() c = self.table.currentColumn() self.changeCell(r,c,txt) class _Command_Spreadsheet_Create: "the Spreadsheet_Create FreeCAD command" def GetResources(self): return {'Pixmap' : 'Spreadsheet', 'MenuText': QtCore.QT_TRANSLATE_NOOP("Spreadsheet_Create","Spreadsheet"), 'ToolTip': QtCore.QT_TRANSLATE_NOOP("Spreadsheet_Create","Adds a spreadsheet object to the active document")} def Activated(self): from DraftTools import translate FreeCAD.ActiveDocument.openTransaction(str(translate("Spreadsheet","Create Spreadsheet"))) FreeCADGui.doCommand("import Spreadsheet") FreeCADGui.doCommand("Spreadsheet.makeSpreadsheet()") FreeCAD.ActiveDocument.commitTransaction() FreeCAD.ActiveDocument.recompute() class _Command_Spreadsheet_Controller: "the Spreadsheet_Controller FreeCAD command" def GetResources(self): return {'Pixmap' : 'SpreadsheetController', 'MenuText': QtCore.QT_TRANSLATE_NOOP("Spreadsheet_Controller","Add controller"), 'ToolTip': QtCore.QT_TRANSLATE_NOOP("Spreadsheet_Controller","Adds a cell controller to a selected spreadsheet")} def IsActive(self): if FreeCADGui.Selection.getSelection(): return True else: return False def Activated(self): import Draft if Draft.getType(FreeCADGui.Selection.getSelection()[0]) == "Spreadsheet": from DraftTools import translate n = FreeCADGui.Selection.getSelection()[0].Name FreeCAD.ActiveDocument.openTransaction(str(translate("Spreadsheet","Add controller"))) FreeCADGui.doCommand("import Spreadsheet") FreeCADGui.doCommand("Spreadsheet.makeSpreadsheetController(FreeCAD.ActiveDocument."+n+")") FreeCAD.ActiveDocument.commitTransaction() FreeCAD.ActiveDocument.recompute() def makeSpreadsheet(): "makeSpreadsheet(): adds a spreadsheet object to the active document" obj = FreeCAD.ActiveDocument.addObject("App::FeaturePython","Spreadsheet") Spreadsheet(obj) if FreeCAD.GuiUp: ViewProviderSpreadsheet(obj.ViewObject) return obj def makeSpreadsheetController(spreadsheet,cell=None,direction=None): """makeSpreadsheetController(spreadsheet,[cell,direction]): adds a controller to the given spreadsheet. Call can be a starting cell such as "A5", and direction can be "Horizontal" or "Vertical".""" obj = FreeCAD.ActiveDocument.addObject("App::FeaturePython","CellController") SpreadsheetController(obj) if FreeCAD.GuiUp: ViewProviderSpreadsheetController(obj.ViewObject) conts = spreadsheet.Controllers conts.append(obj) spreadsheet.Controllers = conts if cell: obj.BaseCell = cell if direction: obj.Direction = direction return obj def addSpreadsheetView(view): "addSpreadsheetView(view): adds the given spreadsheet view to the FreeCAD MDI area" if FreeCAD.GuiUp: import Spreadsheet_rc mw = FreeCADGui.getMainWindow() mdi = mw.findChild(QtGui.QMdiArea) sw = mdi.addSubWindow(view) sw.setWindowIcon(QtGui.QIcon(":/icons/Spreadsheet.svg")) sw.show() mdi.setActiveSubWindow(sw) def open(filename): "called when freecad opens a csv file" import os docname = os.path.splitext(os.path.basename(filename))[0] doc = FreeCAD.newDocument(docname) FreeCAD.ActiveDocument = doc read(filename) doc.recompute() return doc def insert(filename,docname): "called when freecad wants to import a csv file" try: doc = FreeCAD.getDocument(docname) except: doc = FreeCAD.newDocument(docname) FreeCAD.ActiveDocument = doc read(filename) doc.recompute() return doc def read(filename): "creates a spreadsheet with the contents of a csv file" sp = makeSpreadsheet() import csv with pyopen(filename, 'rb') as csvfile: csvfile = csv.reader(csvfile) rn = 1 for row in csvfile: cn = 0 for c in row[:26]: cl = "abcdefghijklmnopqrstuvwxyz"[cn] #print "setting ",cl+str(rn)," ",c try: c = int(c) except: try: c = float(c) except: c = str(c) setattr(sp.Proxy,cl+str(rn),c) cn += 1 rn += 1 print "successfully imported ",filename def export(exportList,filename): "called when freecad exports a csv file" import csv, Draft if not exportList: print "Spreadsheet: Nothing to export" return obj = exportList[0] if Draft.getType(obj) != "Spreadsheet": print "Spreadhseet: The selected object is not a spreadsheet" return if not obj.Proxy._cells: print "Spreadsheet: The selected spreadsheet contains no cell" return numcols = ("abcdefghijklmnopqrstuvwxyz".index(str(obj.Proxy.cols[-1])))+1 numrows = int(obj.Proxy.rows[-1]) with pyopen(filename, 'wb') as csvfile: csvfile = csv.writer(csvfile) for i in range(numrows): r = [] for j in range(numcols): key = "abcdefghijklmnopqrstuvwxyz"[j]+str(i+1) if key in obj.Proxy._cells.keys(): r.append(str(obj.Proxy.getFunction(key))) else: r.append("") csvfile.writerow(r) print "successfully exported ",filename FreeCADGui.addCommand('Spreadsheet_Create',_Command_Spreadsheet_Create()) FreeCADGui.addCommand('Spreadsheet_Controller',_Command_Spreadsheet_Controller())