From 36aa4e26427a6a896be85d5e27cd616d968b05be Mon Sep 17 00:00:00 2001 From: Suzanne Soy Date: Tue, 17 Aug 2021 01:21:19 +0100 Subject: [PATCH] WIP, XternalApps mostly works, currently investigating SearchTools --- AppCommand.py | 2 +- Embed.py | 14 +- InitGui.py | 4 +- ReloadCommand.py | 2 +- SearchTools/SearchTools.py | 106 +++++++ XternalAppsList.py | 2 +- XternalAppsParametricTool.py | 597 +++++++++++++++++++++++++---------- exampleTool.xforms | 70 ++++ myTool.xforms | 59 +--- run-inkscape-plugin.py | 91 ++++++ 10 files changed, 727 insertions(+), 220 deletions(-) create mode 100644 SearchTools/SearchTools.py create mode 100644 exampleTool.xforms create mode 100755 run-inkscape-plugin.py diff --git a/AppCommand.py b/AppCommand.py index a8091b0..ab14518 100644 --- a/AppCommand.py +++ b/AppCommand.py @@ -21,7 +21,7 @@ class AppCommand(): } def Activated(self): - p = Embed.ExternalAppInstance(self.appName) + p = Embed.XternalAppInstance(self.appName) p.waitForWindow() def IsActive(self): diff --git a/Embed.py b/Embed.py index 99a6163..c39c4ad 100644 --- a/Embed.py +++ b/Embed.py @@ -13,10 +13,10 @@ from MyX11Utils import * # def closeEvent class EmbeddedWindow(QtCore.QObject): - def __init__(self, app, externalAppInstance, processId, windowId): + def __init__(self, app, xternalAppInstance, processId, windowId): super(EmbeddedWindow, self).__init__() self.app = app - self.externalAppInstance = externalAppInstance + self.xternalAppInstance = xternalAppInstance self.processId = processId self.windowId = windowId self.mdi = Gui.getMainWindow().findChild(QtGui.QMdiArea) @@ -66,12 +66,12 @@ class EmbeddedWindow(QtCore.QObject): self.xwd.setParent(None) self.timer.stop() # remove from dictionary of found windows - self.externalAppInstance.foundWindows.pop(self.windowId, None) + self.xternalAppInstance.foundWindows.pop(self.windowId, None) # avoid GC - self.externalAppInstance.closedWindows[self.windowId] = self + self.xternalAppInstance.closedWindows[self.windowId] = self # re-attach in case it didn't close (confirmation dialog etc.) print('waitForWindow') - self.externalAppInstance.waitForWindow() + self.xternalAppInstance.waitForWindow() # try: # self.xw = QtGui.QWindow.fromWinId(self.windowId) # self.xwd = QtGui.QWidget.createWindowContainer(self.xw) @@ -119,9 +119,9 @@ def deleted(widget): except: return True -class ExternalAppInstance(QtCore.QObject): +class XternalAppInstance(QtCore.QObject): def __init__(self, appName): - super(ExternalAppInstance, self).__init__() + super(XternalAppInstance, self).__init__() self.app = XternalAppsList.apps[appName] # Start the application # TODO: popen_process shouldn't be exposed to in-document scripts, it would allow them to redirect output etc. diff --git a/InitGui.py b/InitGui.py index 9752545..92d760b 100644 --- a/InitGui.py +++ b/InitGui.py @@ -72,8 +72,8 @@ class XternalAppsWorkbench(Workbench): for toolName in XternalAppsList.apps[self.appName].Tools] # Create menus and toolbars - self.appendMenu("ExternalApplications", self.list) - self.appendToolbar("ExternalApplications", self.list) + self.appendMenu("XternalApplications", self.list) + self.appendToolbar("XternalApplications", self.list) def Activated(self): pass diff --git a/ReloadCommand.py b/ReloadCommand.py index 925ab72..24e19f9 100644 --- a/ReloadCommand.py +++ b/ReloadCommand.py @@ -26,7 +26,7 @@ class ReloadCommand(): def GetResources(self): return { 'Pixmap': os.path.dirname(__file__) + '/icons/' + "reload.svg", - 'Accel': "Shit+R", # R for Reload + 'Accel': "Ctrl+R", # R for Reload 'MenuText': "Reload XternalApps (developper tool)", 'ToolTip': "Reload some modules of the XternalApps workbenches, needed for development only.", } diff --git a/SearchTools/SearchTools.py b/SearchTools/SearchTools.py new file mode 100644 index 0000000..5bced34 --- /dev/null +++ b/SearchTools/SearchTools.py @@ -0,0 +1,106 @@ +if True: + from PySide import QtGui + mw = Gui.getMainWindow() + mdi = mw.findChild(QtGui.QMdiArea) + + mw.findChildren(QtGui.QToolBar, 'XternalApplications') + mw.findChildren(QtGui.QToolBar, 'XternalApplications')[0] + + wdg = QtGui.QWidget() + lay = QtGui.QGridLayout(wdg) + mwx = QtGui.QMainWindow() + + sea = QtGui.QLineEdit() + lay.addWidget(sea) + + lsv = QtGui.QListView() + sim = QtGui.QStandardItemModel() + flt = QtCore.QSortFilterProxyModel() + flt.setSourceModel(sim) + flt.setFilterCaseSensitivity(QtCore.Qt.CaseSensitivity.CaseInsensitive) + sea.textChanged.connect(flt.setFilterWildcard) + # make the QListView non-editable + lsv.setEditTriggers(QtGui.QAbstractItemView.NoEditTriggers) + lsv.setModel(flt) + lay.addWidget(lsv) + + mwx.setCentralWidget(wdg) + mdi.addSubWindow(mwx) + + all_tbs = set() + for wbname, workbench in Gui.listWorkbenches().items(): + try: + tbs = workbench.listToolbars() + except: + continue + # careful, tbs contains all the toolbars of the workbench, including shared toolbars + for tb in tbs: + all_tbs.add(tb) + + for toolbar_name in all_tbs: + for the_toolbar in mw.findChildren(QtGui.QToolBar, toolbar_name): + #header = QtGui.QPushButton(toolbar_name) + #lay.addWidget(header) + + #sim.insertRow(sim.rowCount()) + #sim.setData(sim.index(sim.rowCount() - 1, 0), toolbar_name) + sim.appendRow(QtGui.QStandardItem(toolbar_name)) + + for bt in the_toolbar.findChildren(QtGui.QToolButton): + text = bt.text() + if text != '': + print(text) + # TODO: there also is the tooltip + icon = bt.icon() + + # To preview the icon, assign it as the icon of a dummy button. + #but3 = QtGui.QPushButton(text) + #but3.setIcon(icon) + #lay.addWidget(but3) + + #slm.insertRow(slm.rowCount()) + #slm.setData(slm.index(slm.rowCount() - 1, 0), icon) + #slm.setData(slm.index(slm.rowCount() - 1, 1), text) + sim.appendRow(QtGui.QStandardItem(icon, text)) + + #mwx = QtGui.QMainWindow() + #mwx.show() + #mdi.addSubWindow(mwx) + #mdi.setWindowIcon(icon) # probably sets the default icon to use for windows without an icon? + mwx.setWindowIcon(icon) # untested + mwx.show() + + # for wbname, workbench in Gui.listWorkbenches().items(): + # try: + # tbs = workbench.listToolbars() + # # careful, tbs contains all the toolbars of the workbench, including shared toolbars + # for tb in mw.findChildren(QtGui.QToolBar, 'XternalApplications'): + # for bt in tb.findChildren(QtGui.QToolButton): + # text = bt.text() + # if text != '': + # # TODO: there also is the tooltip + # icon = bt.icon() + + # # To preview the icon, assign it as the icon of a dummy window. + # mdi.setWindowIcon(icon) # probably sets the default icon to use for windows without an icon? + # mwx.setWindowIcon(icon) # untested + # except: + # pass + + + +from PySide import QtGui +qwd = QtGui.QWidget() +but1 = QtGui.QPushButton("hi") +but2 = QtGui.QPushButton("hello") +lay = QtGui.QGridLayout(qwd) +lay.addWidget(but1) +lay.addWidget(but2) +mwx = QtGui.QMainWindow() +mwx.setCentralWidget(qwd) +mw = Gui.getMainWindow() +mdi = mw.findChild(QtGui.QMdiArea) +mdi.addSubWindow(mwx) +mwx.show() +but3 = QtGui.QPushButton("XXX") +lay.addWidget(but3) diff --git a/XternalAppsList.py b/XternalAppsList.py index 9350917..ba744db 100644 --- a/XternalAppsList.py +++ b/XternalAppsList.py @@ -30,7 +30,7 @@ class Tool(): return Tool(appName=appName, toolName = getSingletonFromXML(xml, './XternalApps:name').text, xForms = xForms, - toolTip = getSingletonFromXML(xml, './XternalApps:tooltip').text, + toolTip = getSingletonFromXML(xml, './XternalApps:tooltip').text or '', icon = os.path.dirname(__file__) + '/icons/' + appName + '/' + getSingletonFromXML(xml, './XternalApps:icon').text, extendedDescription = getSingletonFromXML(xml, './XternalApps:extended-description').text, openHelpFile = None) diff --git a/XternalAppsParametricTool.py b/XternalAppsParametricTool.py index df02ad1..ce3d9d7 100644 --- a/XternalAppsParametricTool.py +++ b/XternalAppsParametricTool.py @@ -12,12 +12,24 @@ import Part parser = etree.XMLParser(resolve_entities=False) -FreeCADType = namedtuple('FreeCADType', ['type', 'defaultForType', 'maybeEnumValues', 'maybeMIMEType']) +FreeCADType = namedtuple('FreeCADType', ['type', 'defaultForType', 'maybeEnumValues', 'maybeMIMEType', 'fromString']) XFormsInput = namedtuple('XFormsInput', ['modelElementPath', 'label', 'simpleName', 'maybeEnum', 'groupName', 'relevance']) #'type', 'input', 'InputValueToModelValue', 'ModelValueToInputValue' XFormsEnum = namedtuple('XFormsEnum', ['labels', 'values']) InterpretedXML = namedtuple('InterpretedXML', ['xml', 'types', 'inputs']) # Parsed XML, dictionary(modelElementPath) -> type, dictionary(formElementPath) -> XFormsInput +# Safe printing of unknown strings +# This does not aim to have an exact representation of the string, just enough to display in error messages +def safeErr(s): + s = str(s) + result = '' + for c in s: + if c in 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 :_-': + result += c + else: + result += ('\\u%04x' % + ord(c)) + return result + def CreateCommand(appName, toolName): App.ActiveDocument.openTransaction('Create parametric %s from %s'%(toolName, appName)) FreeCADGui.addModule("XternalAppsParametricTool") @@ -27,15 +39,17 @@ def CreateCommand(appName, toolName): def create(appName, toolName): sel = FreeCADGui.Selection.getSelection() name = appName + toolName - obj = App.ActiveDocument.addObject("App::DocumentObjectGroupPython", name) + #obj = App.ActiveDocument.addObject("App::DocumentObjectGroupPython", name) + obj = App.ActiveDocument.addObject("Part::FeaturePython", name) XternalAppsParametricTool(obj, appName, toolName, sel) return obj # TODO: read-only/immutable typeToFreeCADTypeDict = { # TODO:do an XML namespace lookup instead of comparing a constant. - 'xsd:decimal': FreeCADType(type='App::PropertyFloat', defaultForType=0.0, maybeEnumValues=None, maybeMIMEType=None), - 'xsd:string': FreeCADType(type='App::PropertyString', defaultForType='', maybeEnumValues=None, maybeMIMEType=None), + 'xsd:decimal': FreeCADType(type='App::PropertyFloat', defaultForType=0.0, maybeEnumValues=None, maybeMIMEType=None, fromString=float), + 'xsd:string': FreeCADType(type='App::PropertyString', defaultForType='', maybeEnumValues=None, maybeMIMEType=None, fromString=lambda x: x), + 'xsd:integer': FreeCADType(type='App::PropertyInteger', defaultForType=0, maybeEnumValues=None, maybeMIMEType=None, fromString=int), } def getShortPath(root, elem, root_needs_dot = True): @@ -69,7 +83,7 @@ def typeToFreeCADType(type, namespacesAtTypeElement, maybeSchema): schemaType = schemaTypes[0]; return schemaTypeToFreeCADType(schemaType) else: - raise ValueError('Unsupported XForms type') + raise ValueError('Unsupported XForms type ' + safeErr(type)) def schemaTypeToFreeCADType(schemaType): if schemaType.tag == "{http://www.w3.org/2001/XMLSchema}simpleType": @@ -82,13 +96,13 @@ def schemaTypeToFreeCADType(schemaType): raise ValueError('only restrictions of xsd:string (a.k.a. enums) are supported') enumCases = restriction.findall('./xsd:enumeration', ns) enumValues = [enumCase.attrib['value'] for enumCase in enumCases] - return FreeCADType(type = 'App::PropertyEnumeration', defaultForType = (enumValues[0] if len(enumValues) > 0 else None), maybeEnumValues = enumValues, maybeMIMEType=None) + return FreeCADType(type = 'App::PropertyEnumeration', defaultForType = (enumValues[0] if len(enumValues) > 0 else None), maybeEnumValues = enumValues, maybeMIMEType=None, fromString=lambda x: x) elif schemaType.tag == "{http://www.w3.org/2001/XMLSchema}complexType": return ValueError("Complex XML chema types are not supported") def MIMETypeToFreeCADType(MIMEType): if MIMEType == 'image/svg+xml': - return FreeCADType(type='App::PropertyLink', defaultForType=None, maybeEnumValues=None, maybeMIMEType = MIMEType) + return FreeCADType(type='App::PropertyLink', defaultForType=None, maybeEnumValues=None, maybeMIMEType = MIMEType, fromString=lambda x: x) else: raise ValueError('Unsupported MIME type') @@ -129,28 +143,134 @@ def exportSVG(obj, svgfile): # TODO: modify the SVG to set a fake Inkscape version, to avoid the pop-up dialog. +class XternalAppsParametricToolViewProvider(): + def __init__(self, vobj): + """ + Set this object to the proxy object of the actual view provider + """ + + vobj.Proxy = self + + def attach(self, vobj): + """ + Setup the scene sub-graph of the view provider, this method is mandatory + """ + self.ViewObject = vobj + self.Object = vobj.Object + + def updateData(self, fp, prop): + """ + If a property of the handled feature has changed we have the chance to handle this here + """ + print('VVVVVVVVVVVVVVVVVV', repr(fp), repr(prop)) + return + + def getDisplayModes(self,vobj): + """ + Return a list of display modes. + """ + return [] + + def getDefaultDisplayMode(self): + """ + Return the name of the default display mode. It must be defined in getDisplayModes. + """ + return "Shaded" + + def setDisplayMode(self,mode): + """ + Map the display mode defined in attach with those defined in getDisplayModes. + Since they have the same names nothing needs to be done. + This method is optional. + """ + return mode + + def onChanged(self, vp, prop): + """ + Print the name of the property that has changed + """ + App.Console.PrintMessage("Change property: " + str(prop) + "\n") + + def getIcon(self): + """ + Return the icon in XMP format which will appear in the tree view. This method is optional and if not defined a default icon is shown. + """ + + return """ + /* XPM */ + static const char * ViewProviderBox_xpm[] = { + "16 16 6 1", + " c None", + ". c #141010", + "+ c #615BD2", + "@ c #C39D55", + "# c #000000", + "$ c #57C355", + " ........", + " ......++..+..", + " .@@@@.++..++.", + " .@@@@.++..++.", + " .@@ .++++++.", + " ..@@ .++..++.", + "###@@@@ .++..++.", + "##$.@@$#.++++++.", + "#$#$.$$$........", + "#$$####### ", + "#$$#$$$$$# ", + "#$$#$$$$$# ", + "#$$#$$$$$# ", + " #$#$$$$$# ", + " ##$$$$$# ", + " ####### "}; + """ + + def claimChildren(self): + return self.Object.Proxy.getChildren(self.Object) + + def __getstate__(self): + """ + Called during document saving. + """ + return None + + def __setstate__(self,state): + """ + Called during document restore. + """ + print('AAAAAAAAAAAAAAAAAAAYYYYYYYYYYYYYYYYYYYYYYYYY', repr(self), repr(state)) + return None + + def onDocumentRestored(self, obj): + print('AAAAAAAAAAAAAAAAAAAXXXXXXXXXXXXXXXXXXXXXXXXX', repr(self), repr(obj)) class XternalAppsParametricTool(): - def __init__(self, obj, appName, toolName, sel=[]): + def init1(self, appName, toolName): self.Type = "XternalAppsParametricTool" self.AppName = appName self.ToolName = toolName - self.MonitorChanges = False; + self.MonitorChanges = False + + def init2(self, obj): + self.Object = obj obj.Proxy = self - + self.types = self.xmlTypesToPython(self.Tool.XForms) self.defaults = self.xmlDefaultsToPython(self.Tool.XForms, self.types) self.form = self.xmlFormToPython(self.Tool.XForms, self.types) - + + # TODO: on restore, reload the model instance from obj (or recompute it on the fly) self.ModelInstance = self.defaults - self.createProperties(obj, self.types, self.ModelInstance, self.form) self.oldExpressionEngine = obj.ExpressionEngine + def init3(self, obj): self.MonitorChanges = True - #pprint.pprint(self.types) - #pprint.pprint(self.form) + def __init__(self, obj, appName, toolName, sel=[]): + self.init1(appName, toolName) + self.init2(obj) + self.createProperties(obj, self.types, self.ModelInstance, self.form) + self.init3(obj) # Special treatment for the "primary" form field primary = [input for input in self.form.values() if input.relevance == 'primary'] @@ -160,30 +280,62 @@ class XternalAppsParametricTool(): # Display the contents of the primary form element as children in the tree view if type in ['App::PropertyLink', 'App::PropertyLinkList']: - self.form['FreeCADGroup'] = XFormsInput(modelElementPath=primary.modelElementPath, label='Group', simpleName='Group', maybeEnum=primary.maybeEnum, groupName='Base', relevance='primary') + #self.form['FreeCADGroup'] = XFormsInput(modelElementPath=primary.modelElementPath, label='Group', simpleName='Group', maybeEnum=primary.maybeEnum, groupName='Base', relevance='primary') + pass if type == 'App::PropertyLink' and len(sel) >= 1: setattr(obj, primary.simpleName, sel[0]) elif type == 'App::PropertyLinkList': setattr(obj, primary.simpleName, sel) - return + XternalAppsParametricToolViewProvider(obj.ViewObject) + self.execute(obj) + + def getChildren(self, obj): + primary = [input for input in self.form.values() if input.relevance == 'primary'] + if len(primary) == 1: + primary = primary[0] + type = self.types[primary.modelElementPath].type + if type == 'App::PropertyLink': + return [getattr(obj, primary.simpleName)] + elif type == 'App::PropertyLinkList': + return getattr(obj, primary.simpleName) + return [] def __getstate__(self): + return { "AppName": self.AppName, "ToolName": self.ToolName } + + print('XXX_GET_STATE') + print('self', repr(self)) copied = self.__dict__.copy() copied['ModelInstance'] = list(copied['ModelInstance'].items()) del copied['types'] del copied['defaults'] del copied['form'] + print('copied', repr(copied)) return copied def __setstate__(self, state): + print('XXX_SET_STATE') + print(repr(self), repr(state)) if state: - state['ModelInstance'] = dict(state['ModelInstance']) - self.__dict__ = state - self.types = self.xmlTypesToPython(self.Tool.XForms) - self.defaults = self.xmlDefaultsToPython(self.Tool.XForms, self.types) - self.form = self.xmlFormToPython(self.Tool.XForms, self.types) + # TODO: always sanitize data that is restored, for security reasons. + #self.AppName = str(state['AppName']) + #self.ToolName = str(state['ToolName']) + #self.__init__(obj, self.AppName, self.ToolName, sel=[]) + self.init1(str(state['AppName']), str(state['ToolName'])) + #state['ModelInstance'] = dict(state['ModelInstance']) + #self.__dict__ = state + #self.types = self.xmlTypesToPython(self.Tool.XForms) + #self.defaults = self.xmlDefaultsToPython(self.Tool.XForms, self.types) + #self.form = self.xmlFormToPython(self.Tool.XForms, self.types) + + def onDocumentRestored(self, obj): + print('XXX_ON_DOCUMENT_RESTORED') + self.init2(obj) + self.reloadProperties(obj, self.form) + self.init3(obj) + #self.__init__(obj, self.AppName, self.ToolName, sel=[]) def onChanged(self, obj, prop): import sys @@ -215,31 +367,37 @@ class XternalAppsParametricTool(): obj.setExpression(other.simpleName, None) self.oldExpressionEngine = set([k for k, v in obj.ExpressionEngine]) - _, input = lookup(self.form, lambda input: input.simpleName, prop) - if input: - newModelValue = getattr(obj, prop) - if input.maybeEnum: - newModelValue = input.maybeEnum[newModelValue] - # The Group property always contains a list, but we may use it to represent a link to a single element. - if prop == 'Group' and self.types[input.modelElementPath].type == 'App::PropertyLink': - if len(newModelValue) > 0: - newModelValue = newModelValue[0] - else: - newModelValue = None - self.ModelInstance[input.modelElementPath] = newModelValue - for other in self.form.values(): - if other.modelElementPath == input.modelElementPath and other.simpleName != input.simpleName: - newFormValue = newModelValue - #print('newModelValue', newModelValue) - if other.maybeEnum: - newFormValue = [f for f, m in other.maybeEnum.items() if m == newModelValue][0] - #print(prop, newFormValue, other.simpleName, dict(obj.ExpressionEngine).get(prop), dict(obj.ExpressionEngine).get(other.simpleName)) - #obj.setExpression(other.simpleName, dict(obj.ExpressionEngine).get(prop)) - #print(obj, other.simpleName, newFormValue) - setattr(obj, other.simpleName, newFormValue) + ####################################### + self.setModelFromInput(obj, prop) + ####################################### finally: #print('MonitorChanges = ' + str(restoreMonitorChanges)) self.MonitorChanges = restoreMonitorChanges + + def setModelFromInput(self, obj, prop): + _, input = lookup(self.form, lambda input: input.simpleName, prop) + if input: + newModelValue = getattr(obj, prop) + if input.maybeEnum: + newModelValue = input.maybeEnum[newModelValue] + # The Group property always contains a list, but we may use it to represent a link to a single element. + if prop == 'Group' and self.types[input.modelElementPath].type == 'App::PropertyLink': + if len(newModelValue) > 0: + newModelValue = newModelValue[0] + else: + newModelValue = None + print(self.ModelInstance, input.modelElementPath, newModelValue) + self.ModelInstance[input.modelElementPath] = newModelValue + for other in self.form.values(): + if other.modelElementPath == input.modelElementPath and other.simpleName != input.simpleName: + newFormValue = newModelValue + #print('newModelValue', newModelValue) + if other.maybeEnum: + newFormValue = [f for f, m in other.maybeEnum.items() if m == newModelValue][0] + #print(prop, newFormValue, other.simpleName, dict(obj.ExpressionEngine).get(prop), dict(obj.ExpressionEngine).get(other.simpleName)) + #obj.setExpression(other.simpleName, dict(obj.ExpressionEngine).get(prop)) + #print(obj, other.simpleName, newFormValue) + setattr(obj, other.simpleName, newFormValue) def interpretFormElement(self, xmlXFormsElement, xml, instanceDocument, types): # TODO: is it safe to pass input unprotected here? @@ -348,7 +506,7 @@ class XternalAppsParametricTool(): default = modelElement.text if default is None: default = types[path].defaultForType - defaults[path] = default + defaults[path] = types[path].fromString(default) return defaults def createProperties(self, obj, types, defaults, form): @@ -362,155 +520,264 @@ class XternalAppsParametricTool(): setattr(obj, simpleName, list(maybeEnum.keys())) # TODO: use a bidirectional dict default = [k for k, v in maybeEnum.items() if v == default][0] - setattr(obj, simpleName, default) + try: + setattr(obj, simpleName, default) + except: + raise ValueError('Could not set ' + safeErr(obj) + "." + safeErr(simpleName) + " = " + safeErr(repr(default))) + + def reloadProperties(self, obj, form): + for key, (modelElementPath, label, simpleName, maybeEnum, groupName, relevance) in form.items(): + self.setModelFromInput(obj, simpleName) @property def Tool(self): return XternalAppsList.apps[self.AppName].Tools[self.ToolName] - def xmlCommandToPython(self, document): + def xmlCommandToPython(self, obj, document): """Parse the .xml document, and return a pair of dictionaries accepts[model_path] = style and returns[model_path] = style.""" + print('A') + xml = etree.parse(self.Tool.XForms, parser=parser) model_root = xml.find('./xforms:model', ns) instanceDocument = etree.ElementTree(model_root.find('./xforms:instance/*', ns)) + print('B') command = xml.find('./XternalApps:command', ns) method = command.attrib['method'] commandName = command.attrib['name'] - accepts = command.find('./XternalApps:accepts', ns) - returns = command.find('./XternalApps:returns', ns) - - maybeDefault = accepts.findall('./XternalApps:default', ns) - if len(maybeDefault) == 1: - default_style = maybeDefault[0].attrib['style'] - elif len(maybeDefault) > 1: - raise ValueError('The accepts tag should contain at most one default tag') - else: - default_style = None - - styles = {} + print('C') + # Step 1: get the list of all fields optionNames = {} - # Put all the model fields in accepts[path] for modelElement in instanceDocument.findall('//*', ns): - path = instanceDocument.getelementpath(modelElement) - styles[path] = default_style - optionNames[path] = etree.QName(modelElement).localname - - style_is_default = {k: True for k in styles.keys()} - for exception in accepts.findall('./XternalApps:exception', ns): - ref = exception.attrib['ref'] - style = exception.attrib['style'] - for modelElement in instanceDocument.findall(ref, exception.nsmap): - path = instanceDocument.getelementpath(modelElement) - if style_is_default[path]: - style_is_default[path] = False - styles[path] = style - else: - ValueError('overlapping exceptions in command/accepts/exception') - - pipe_in = None - positionalArguments = {} - namedArguments = [] + # Put all the model fields in optionNames[path] = optionName + modelElementPath = instanceDocument.getelementpath(modelElement) + optionNames[modelElementPath] = etree.QName(modelElement).localname + + print('D') tempfiles = [] tempdirs = [] - for modelElementPath, value in self.ModelInstance.items(): - style = styles[modelElementPath] - type = self.types[modelElementPath] + try: + commandLine = [commandName] + default = None + pipeIn = None + # Step 2: generate most of the command-line, leaving a placeholder for the fields that use the default behaviour + print('E') + def formatTemplateElement(isInput, style, key, type, value, tempdirs, tempfiles): + print('K3X') + # Convert to the type expected by the tool + if type.type == 'App::PropertyLink' and type.maybeMIMEType == 'image/svg+xml': + print('K31') + import tempfile, os + d = tempfile.mkdtemp() + tempdirs += [d] + svgfile = os.path.join(d, "sketch.svg") + tempfiles += [svgfile] + print("exportSVG", repr(value), repr(svgfile)) + exportSVG(value, svgfile) + value = svgfile + else: + print('K32') + # TODO ################# convert the value from FreeCAD to what the program supports ################### + value = str(value) + + if style == 'value': + if isInput: + return [value] + else: + pass # TODO: e.g. ['temporary_output_file'] + elif style == 'double-dash': + if isInput: + return ['--' + key, value] + else: + pass # TODO: e.g. ['-o', 'temporary_output_file'] + elif style == 'pipe': + if isInput: + if pipeIn is not None: + raise ValueError('Only one parameter can be passed as a pipe') + pipeIn = value + else: + pass # TODO: output + return [] + elif style == 'exitcode': + if isInput: + raise ValueError('the exitcode style can only be used for the output direction') + else: + pass # TODO: output + else: + raise ValueError('Unsupported argument-passing or value-returning style') + print('F') + for templateElement in command.findall('./*', ns): + print('G') + direction = templateElement.attrib['direction'] + style = templateElement.attrib['style'] + tag = templateElement.tag + + print('H') + tagPrefix = '{'+ns['XternalApps']+'}' + if not tag.startswith(tagPrefix): + continue + tag = tag[len(tagPrefix):] + + print('I') + if direction == 'input': + isInput = True + elif direction == 'output': + isInput = False + else: + raise ValueError('Invalid value for direction attribute') + + print('J') + if tag == 'constant': + print('K1') + if isInput: + key = templateElement.attrib.get('key', None) + type = typeToFreeCADTypeDict['xsd:string'] + value = templateElement.attrib['value'] + commandLine += formatTemplateElement(isInput, style, key, type, value, tempdirs, tempfiles) + else: + raise ValueError('constant elements of a command-line input can only be part of the input, not of the output') + elif tag == 'default': + print('K2') + if isInput: + if default is not None: + raise ValueError('Only one default tag can be specified for a given direction') + default = {'style':style, 'position':len(commandLine)} + else: + pass # TODO: output + elif tag == 'exception': + print('K3') + ref = templateElement.attrib['ref'] + found = False + if isInput: + for modelElement in instanceDocument.findall(ref, templateElement.nsmap): + found = True + modelElementPath = instanceDocument.getelementpath(modelElement) + key = optionNames[modelElementPath] + value = self.ModelInstance[modelElementPath] + type = self.types[modelElementPath] + commandLine += formatTemplateElement(isInput, style, key, type, value, tempdirs, tempfiles) + if modelElementPath in optionNames: + del(optionNames[modelElementPath]) + else: + raise ValueError('In command-line template, the same field is referenced by two tags (e.g. exception and ignore)') + else: + found = True + # TODO: output + pass + if not found: + raise ValueError('Could not resolve reference in command-line template: ' + safeErr(ref)) + elif tag == 'ignore': + print('K4') + ref = templateElement.attrib['ref'] + found = False + if isInput: + for modelElement in instanceDocument.findall(ref, templateElement.nsmap): + found = True + modelElementPath = instanceDocument.getelementpath(modelElement) + if modelElementPath in optionNames: + del(optionNames[modelElementPath]) + else: + raise ValueError('In command-line template, the same field is referenced by two tags (e.g. exception and ignore)') + else: + found = True + # TODO: output + pass + if not found: + raise ValueError('Could not resolve reference in command-line template') + else: + print('K5') + raise ValueError('Unexpected tag in command-line template:' + safeErr(tag)) - # Convert to the type expected by the tool - if type.type == 'App::PropertyLink' and type.maybeMIMEType == 'image/svg+xml': - import tempfile, os - d = tempfile.mkdtemp() - svgfile = os.path.join(d, "sketch.svg") - tempfiles += [svgfile] - tempdirs += [d] - exportSVG(value, svgfile) - value = svgfile - else: - # TODO ################# convert the value from FreeCAD to what the program supports ################### - value = str(value) + # Step 3: replace the placeholder with the remaining input fields + print('L') + commandLineDefault = [] + for modelElementPath, key in optionNames.items(): + value = self.ModelInstance[modelElementPath] + type = self.types[modelElementPath] + if default is None: + raise ValueError('Some fields are not included in the command-line template, and no default is present. To ignore a field, use the ignore tag.') + style = default['style'] + commandLineDefault += formatTemplateElement(True, style, key, type, value, tempdirs, tempfiles) + if default is not None: + position = default['position'] + commandLine[position:position] = commandLineDefault + + # Step 4: call the command + #for modelElementPath, value in self.ModelInstance.items(): + # style = styles[modelElementPath] + # type = self.types[modelElementPath] + + pipeInHandle = None + if pipeIn is not None: + pipeInHandle = open(pipeIn) - if style == 'double-dash': - namedArguments += ['--' + optionNames[modelElementPath], value] - elif style == 'pipe': - if pipe_in != None: - raise ValueError('more then one option uses a "pipe" style') - pipe_in = value - elif style == 'positional': - pos = unknown_todo() ######################################### TODO ############################ - positionalArguments[pos] = value - elif style == 'exitcode': - raise ValueError('exitcode is supported only for the output of the command, not for its input') - else: - raise ValueError('unsupported argument-passing style') - - positionalArguments = [v for i, vs in sorted(positionalArguments.items()) for v in vs] - if pipe_in is not None: - pipe_in_handle = open(pipe_in) + # TODO: use the XML for this + import tempfile, os + d = tempfile.mkdtemp() + resultFilename = os.path.join(d, "result.svg") + pipeOut = resultFilename + tempfiles += [pipeOut] + tempdirs += [d] + pipeOutHandle = open(pipeOut, 'w') - # TODO: use the XML for this - d = tempfile.mkdtemp() - result_filename = os.path.join(d, "result.svg") - pipe_out = result_filename - tempfiles += [pipe_out] - tempdirs += [d] - pipe_out_handle = open(pipe_out, 'w') + import subprocess + #print(commandLine + ['stdin=' + str(pipeIn), 'stdout=' + str(pipeOut)]) + proc = subprocess.Popen(commandLine, stdin=pipeInHandle, stdout=pipeOutHandle) + proc.communicate() + exitcode = proc.returncode - import subprocess - print([commandName] + positionalArguments + namedArguments + ['stdin=' + pipe_in, 'stdout=' + pipe_out]) - proc = subprocess.Popen([commandName] + positionalArguments + namedArguments, stdin=pipe_in_handle, stdout=pipe_out_handle) - proc.communicate() - exitcode = proc.returncode + if pipeInHandle is not None: + pipeInHandle.close() + with open(resultFilename, 'rb') as resultFile: + result = resultFile.read() + pipeOutHandle.close() - pipe_in_handle.close() - with open(result_filename, 'rb') as result_file: - result = result_file.read() - pipe_out_handle.close() - - # Circumvent bug which leaves App.ActiveDocument to an incorrect value after the newDocument + closeDocument - oldActiveDocumentName = App.ActiveDocument.Name - tempDocument = App.newDocument('load_svg', hidden=True) - import importSVG - importSVG.insert(pipe_out,'load_svg') - solids = [] - for o in tempDocument.Objects: - shape = o.Shape - wire = Part.Wire(shape.Edges) - #face = Part.Face(wire) - solids += [wire] #face - p = Part.makeCompound(solids) - for obj in tempDocument.Objects: - print("remove:" + obj.Name) - tempDocument.removeObject(obj.Name) - Part.show(p) - for o in tempDocument.Objects: - o2 = document.copyObject(o, False, False) - App.closeDocument('load_svg') - App.setActiveDocument(oldActiveDocumentName) - - for tempfile in tempfiles: - os.remove(tempfile) - for tempdir in tempdirs: - os.rmdir(tempdir) + # Circumvent bug which leaves App.ActiveDocument to an incorrect value after the newDocument + closeDocument + oldActiveDocumentName = App.ActiveDocument.Name + tempDocument = App.newDocument('load_svg', hidden=True) + import importSVG + importSVG.insert(pipeOut,'load_svg') + solids = [] + for o in tempDocument.Objects: + shape = o.Shape + wire = Part.Wire(shape.Edges) + #face = Part.Face(wire) + solids += [wire] #face + p = Part.makeCompound(solids) + for o in tempDocument.Objects: + print("remove:" + o.Name) + tempDocument.removeObject(o.Name) + Part.show(p) + print("===============================================================") + for o in tempDocument.Objects: + #o2 = document.copyObject(o, False, False) + #print(o2.Name) + obj.Shape = o.Shape + obj.ViewObject.DisplayMode = o.ViewObject.DisplayMode + break + #document.removeObject(o2.Name) + print("===============================================================!!") + App.closeDocument('load_svg') + App.setActiveDocument(oldActiveDocumentName) + finally: + pass + #for tempfile in tempfiles: + # try: + # os.remove(tempfile) + # except: + # pass + #for tempdir in tempdirs: + # try: + # os.rmdir(tempdir) + # except: + # pass print(exitcode, result) def execute(self, obj): print("""This is called when the object is recomputed""") - self.xmlCommandToPython(obj.Document) - - # - # - # - # - - # - # - # - # - - + self.xmlCommandToPython(obj, obj.Document) diff --git a/exampleTool.xforms b/exampleTool.xforms new file mode 100644 index 0000000..c99ae2d --- /dev/null +++ b/exampleTool.xforms @@ -0,0 +1,70 @@ + + + Fractalize + + MyTool.svg + + + + + + + + default value + + bar + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Page 1 + + + + Input image + + + + + Page 2 + + + + + + + + + + + diff --git a/myTool.xforms b/myTool.xforms index 85269a2..9efc7f5 100644 --- a/myTool.xforms +++ b/myTool.xforms @@ -1,20 +1,17 @@ - MyTool - This tool is my tool, it is very useful in a toolset. + Fractalize + MyTool.svg - Lots of text, - blah blha bhal - default value - - bar + 6 + 4.0 - - - + + - - - - - - - - - - - - - - - - - + + + + + + - Page 1 - - + Fractalize + + - Input image + Input path - - Page 2 - - - - - - - - - - diff --git a/run-inkscape-plugin.py b/run-inkscape-plugin.py new file mode 100755 index 0000000..eacfb7a --- /dev/null +++ b/run-inkscape-plugin.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python + +# Does not work with inkscape 1.1 +#extension_directory="$(inkscape --extension-directory)" + +appImage = '/home/suzanne/perso/dl/software/dessin/Inkscape-16c8184-x86_64.AppImage' + +import sys, os, subprocess, signal, tempfile, re +mountProcess = subprocess.Popen([appImage, '--appimage-mount'], stdout=subprocess.PIPE) + +# os.path.join, but forbit .. and other tricks that would get out of the base directory. +def joinNoDotDot(base, rel): + rel = os.fsencode(rel) + absBase = os.path.abspath(base) + relRequested = os.path.relpath(os.path.join(absBase, rel), start=absBase) + absRequested = os.path.abspath(os.path.join(absBase, relRequested)) + commonPrefix = os.path.commonprefix([absRequested, absBase]) + if relRequested.startswith(os.fsencode(os.pardir)): + raise ValueError("security check failed: requested inkscape extension is outside of inkscape extension directory 1" + repr(relRequested)) + elif commonPrefix != absBase: + raise ValueError("security check failed: requested inkscape extension is outside of inkscape extension directory 2") + else: + return absRequested + +tempfiles=[] +tempdirs=[] +try: + d = tempfile.mkdtemp() + tempdirs += [d] + pathfile = os.path.join(d, "as_path.svg") + tempfiles += [pathfile] + + # Convert all objects (incl. circles) to paths and extract the IDs (not all FreeCAD objects in the SVG have an ID, converting to paths seems to assign them an ID) + processAsPath = subprocess.Popen([appImage, sys.argv[-1], '--actions=select-all;object-to-path;select-list', '--export-type=svg', '--export-filename=' + pathfile], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + asPath = processAsPath.communicate() + if processAsPath.returncode != 0: + # TODO: forward the stderr and use subprocess.CalledProcessError + raise ValueError("Child process failed: could not execute Inkscape to convert SVG objects to paths") + lines = asPath[0].split(b'\n') + if lines[0] == b'Run experimental bundle that bundles everything': + lines = lines[1:] + ids = [line.split(b' ')[0] for line in lines] + + try: + if mountProcess.poll() is None: + mountPoint = mountProcess.stdout.readline().rstrip(b'\r\n') # TODO: this is slightly wrong (removes multiple occurrences) + else: + raise "Could not mount AppImage" + + apprun = os.path.join(mountPoint, b'AppRun') + extensionDirectory = os.path.join(mountPoint, b'usr/share/inkscape/extensions') + e = os.environb.copy() + e['PYTHONPATH'] = e.get('PYTHONPATH', b'') + os.path.pathsep.encode('utf-8') + os.path.join(mountPoint, b'usr/lib/python3/dist-packages/') + extensionPy = joinNoDotDot(extensionDirectory, sys.argv[1]) + cmd = [apprun, 'python3.8', extensionPy] + [b'--id='+id for id in ids] + sys.argv[2:-1] + [pathfile] + process = subprocess.Popen(cmd, env=e, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + result = process.communicate()[0] + if process.returncode != 0: + # TODO: forward the stderr and use subprocess.CalledProcessError + raise ValueError("Child process failed: could not execute Inkscape extension with the given parameters") + lines = result.split(b'\n') + if lines[0] == b'Run experimental bundle that bundles everything': + lines = lines[1:] + # TODO: use lxml instead + with open('/tmp/fooaaa', 'w') as debug: + debug.write('trollollo') + debug.write('1' + str(lines[0])+'\n\n') + lines[0] = re.sub(rb' version="([.0-9]*)"', rb' version="\1" inkscape:version="\1"', lines[0]) + debug.write('2' + str(lines[0])+'\n\n') + #sed -i -e '1 s/ version="1.1"/ inkscape:version="1.1"&/' + sys.stdout.buffer.write(b'\n'.join(lines)) + # --id=rect815 --subdivs 6 --smooth 4.0 /tmp/a.svg 2>/dev/null | sed -e '/Run experimental bundle that bundles everything/,1 d' + + finally: + mountProcess.send_signal(signal.SIGINT) + + #LD_LIBRARY_PATH= PYTHONPATH="$extension_directory:$PYTHONPATH" python2 "$extension_directory"/fractalize.py "$@" + #/nix/store/zr4xihxd720pq3n0a58ixrqa243hx7an-python-2.7.17-env/bin/python2.7 + + #./perso/dl/software/dessin/Inkscape-3bc2e81-x86_64.AppImage --appimage-mount +finally: + for tempfile in tempfiles: + try: + os.remove(tempfile) + except: + pass + for tempdir in tempdirs: + try: + os.rmdir(tempdir) + except: + pass