# Gimp-Python - allows the writing of GIMP plug-ins in Python. # Copyright (C) 1997 James Henstridge # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 3 of the License, or # (at your option) any later version. # # 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . """Simple interface for writing GIMP plug-ins in Python. Instead of worrying about all the user interaction, saving last used values and everything, the gimpfu module can take care of it for you. It provides a simple register() function that will register your plug-in if needed, and cause your plug-in function to be called when needed. Gimpfu will also handle showing a user interface for editing plug-in parameters if the plug-in is called interactively, and will also save the last used parameters, so the RUN_WITH_LAST_VALUES run_type will work correctly. It will also make sure that the displays are flushed on completion if the plug-in was run interactively. When registering the plug-in, you do not need to worry about specifying the run_type parameter. A typical gimpfu plug-in would look like this: from gimpfu import * def plugin_func(image, drawable, args): # do what plugins do best register( "plugin_func", "blurb", "help message", "author", "copyright", "year", "My plug-in", "*", [ (PF_IMAGE, "image", "Input image", None), (PF_DRAWABLE, "drawable", "Input drawable", None), (PF_STRING, "arg", "The argument", "default-value") ], [], plugin_func, menu="/Somewhere") main() The call to "from gimpfu import *" will import all the gimp constants into the plug-in namespace, and also import the symbols gimp, pdb, register and main. This should be just about all any plug-in needs. You can use any of the PF_* constants below as parameter types, and an appropriate user interface element will be displayed when the plug-in is run in interactive mode. Note that the the PF_SPINNER and PF_SLIDER types expect a fifth element in their description tuple -- a 3-tuple of the form (lower,upper,step), which defines the limits for the slider or spinner. If want to localize your plug-in, add an optional domain parameter to the register call. It can be the name of the translation domain or a tuple that consists of the translation domain and the directory where the translations are installed. """ import string as _string import math import gimp import gimpcolor from gimpenums import * pdb = gimp.pdb import gettext t = gettext.translation("gimp20-python", gimp.locale_directory, fallback=True) _ = t.ugettext class error(RuntimeError): pass class CancelError(RuntimeError): pass PF_INT8 = PDB_INT8 PF_INT16 = PDB_INT16 PF_INT32 = PDB_INT32 PF_INT = PF_INT32 PF_FLOAT = PDB_FLOAT PF_STRING = PDB_STRING PF_VALUE = PF_STRING #PF_INT8ARRAY = PDB_INT8ARRAY #PF_INT16ARRAY = PDB_INT16ARRAY #PF_INT32ARRAY = PDB_INT32ARRAY #PF_INTARRAY = PF_INT32ARRAY #PF_FLOATARRAY = PDB_FLOATARRAY #PF_STRINGARRAY = PDB_STRINGARRAY PF_COLOR = PDB_COLOR PF_COLOUR = PF_COLOR PF_ITEM = PDB_ITEM PF_DISPLAY = PDB_DISPLAY PF_IMAGE = PDB_IMAGE PF_LAYER = PDB_LAYER PF_CHANNEL = PDB_CHANNEL PF_DRAWABLE = PDB_DRAWABLE PF_VECTORS = PDB_VECTORS #PF_SELECTION = PDB_SELECTION #PF_BOUNDARY = PDB_BOUNDARY #PF_PATH = PDB_PATH #PF_STATUS = PDB_STATUS PF_TOGGLE = 1000 PF_BOOL = PF_TOGGLE PF_SLIDER = 1001 PF_SPINNER = 1002 PF_ADJUSTMENT = PF_SPINNER PF_FONT = 1003 PF_FILE = 1004 PF_BRUSH = 1005 PF_PATTERN = 1006 PF_GRADIENT = 1007 PF_RADIO = 1008 PF_TEXT = 1009 PF_PALETTE = 1010 PF_FILENAME = 1011 PF_DIRNAME = 1012 PF_OPTION = 1013 _type_mapping = { PF_INT8 : PDB_INT8, PF_INT16 : PDB_INT16, PF_INT32 : PDB_INT32, PF_FLOAT : PDB_FLOAT, PF_STRING : PDB_STRING, #PF_INT8ARRAY : PDB_INT8ARRAY, #PF_INT16ARRAY : PDB_INT16ARRAY, #PF_INT32ARRAY : PDB_INT32ARRAY, #PF_FLOATARRAY : PDB_FLOATARRAY, #PF_STRINGARRAY : PDB_STRINGARRAY, PF_COLOR : PDB_COLOR, PF_ITEM : PDB_ITEM, PF_DISPLAY : PDB_DISPLAY, PF_IMAGE : PDB_IMAGE, PF_LAYER : PDB_LAYER, PF_CHANNEL : PDB_CHANNEL, PF_DRAWABLE : PDB_DRAWABLE, PF_VECTORS : PDB_VECTORS, PF_TOGGLE : PDB_INT32, PF_SLIDER : PDB_FLOAT, PF_SPINNER : PDB_INT32, PF_FONT : PDB_STRING, PF_FILE : PDB_STRING, PF_BRUSH : PDB_STRING, PF_PATTERN : PDB_STRING, PF_GRADIENT : PDB_STRING, PF_RADIO : PDB_STRING, PF_TEXT : PDB_STRING, PF_PALETTE : PDB_STRING, PF_FILENAME : PDB_STRING, PF_DIRNAME : PDB_STRING, PF_OPTION : PDB_INT32, } _obj_mapping = { PF_INT8 : int, PF_INT16 : int, PF_INT32 : int, PF_FLOAT : float, PF_STRING : str, #PF_INT8ARRAY : list, #PF_INT16ARRAY : list, #PF_INT32ARRAY : list, #PF_FLOATARRAY : list, #PF_STRINGARRAY : list, PF_COLOR : gimpcolor.RGB, PF_ITEM : int, PF_DISPLAY : gimp.Display, PF_IMAGE : gimp.Image, PF_LAYER : gimp.Layer, PF_CHANNEL : gimp.Channel, PF_DRAWABLE : gimp.Drawable, PF_VECTORS : gimp.Vectors, PF_TOGGLE : bool, PF_SLIDER : float, PF_SPINNER : int, PF_FONT : str, PF_FILE : str, PF_BRUSH : str, PF_PATTERN : str, PF_GRADIENT : str, PF_RADIO : str, PF_TEXT : str, PF_PALETTE : str, PF_FILENAME : str, PF_DIRNAME : str, PF_OPTION : int, } _registered_plugins_ = {} def register(proc_name, blurb, help, author, copyright, date, label, imagetypes, params, results, function, menu=None, domain=None, on_query=None, on_run=None, run_mode_param=True): """This is called to register a new plug-in.""" # First perform some sanity checks on the data def letterCheck(str): allowed = _string.letters + _string.digits + "_" + "-" for ch in str: if not ch in allowed: return 0 else: return 1 if not letterCheck(proc_name): raise error, "procedure name contains illegal characters" for ent in params: if len(ent) < 4: raise error, ("parameter definition must contain at least 4 " "elements (%s given: %s)" % (len(ent), ent)) if type(ent[0]) != int: raise error, "parameter types must be integers" if not letterCheck(ent[1]): raise error, "parameter name contains illegal characters" for ent in results: if len(ent) < 3: raise error, ("result definition must contain at least 3 elements " "(%s given: %s)" % (len(ent), ent)) if type(ent[0]) != type(42): raise error, "result types must be integers" if not letterCheck(ent[1]): raise error, "result name contains illegal characters" plugin_type = PLUGIN if (not proc_name.startswith("python-") and not proc_name.startswith("python_") and not proc_name.startswith("extension-") and not proc_name.startswith("extension_") and not proc_name.startswith("plug-in-") and not proc_name.startswith("plug_in_") and not proc_name.startswith("file-") and not proc_name.startswith("file_")): proc_name = "python-fu-" + proc_name # if menu is not given, derive it from label need_compat_params = False if menu is None and label: fields = label.split("/") if fields: label = fields.pop() menu = "/".join(fields) need_compat_params = True import warnings message = ("%s: passing the full menu path for the menu label is " "deprecated, use the 'menu' parameter instead" % (proc_name)) warnings.warn(message, DeprecationWarning, 3) if need_compat_params and plugin_type == PLUGIN: file_params = [(PDB_STRING, "filename", "The name of the file", ""), (PDB_STRING, "raw-filename", "The name of the file", "")] if menu is None: pass elif menu.startswith(""): params[0:0] = file_params elif menu.startswith("") or menu.startswith(""): params.insert(0, (PDB_IMAGE, "image", "Input image", None)) params.insert(1, (PDB_DRAWABLE, "drawable", "Input drawable", None)) if menu.startswith(""): params[2:2] = file_params _registered_plugins_[proc_name] = (blurb, help, author, copyright, date, label, imagetypes, plugin_type, params, results, function, menu, domain, on_query, on_run, run_mode_param) def _query(): for plugin in _registered_plugins_.keys(): (blurb, help, author, copyright, date, label, imagetypes, plugin_type, params, results, function, menu, domain, on_query, on_run, has_param_run_mode) = _registered_plugins_[plugin] def make_params(params): return [(_type_mapping[x[0]], x[1], _string.replace(x[2], "_", "")) for x in params] params = make_params(params) # add the run mode argument ... if has_param_run_mode: params.insert(0, (PDB_INT32, "run-mode", "The run mode { RUN-INTERACTIVE (0), RUN-NONINTERACTIVE (1) }")) results = make_params(results) if domain: try: (domain, locale_dir) = domain gimp.domain_register(domain, locale_dir) except ValueError: gimp.domain_register(domain) gimp.install_procedure(plugin, blurb, help, author, copyright, date, label, imagetypes, plugin_type, params, results) if menu: gimp.menu_register(plugin, menu) if on_query: on_query() def _get_defaults(proc_name): import gimpshelf (blurb, help, author, copyright, date, label, imagetypes, plugin_type, params, results, function, menu, domain, on_query, on_run, has_run_mode) = _registered_plugins_[proc_name] key = "python-fu-save--" + proc_name if gimpshelf.shelf.has_key(key): return gimpshelf.shelf[key] else: # return the default values return [x[3] for x in params] def _set_defaults(proc_name, defaults): import gimpshelf key = "python-fu-save--" + proc_name gimpshelf.shelf[key] = defaults def _interact(proc_name, start_params): (blurb, help, author, copyright, date, label, imagetypes, plugin_type, params, results, function, menu, domain, on_query, on_run, has_run_mode) = _registered_plugins_[proc_name] def run_script(run_params): params = start_params + tuple(run_params) _set_defaults(proc_name, params) return apply(function, params) params = params[len(start_params):] # short circuit for no parameters ... if len(params) == 0: return run_script([]) import pygtk pygtk.require('2.0') import gimpui import gtk # import pango gimpui.gimp_ui_init () defaults = _get_defaults(proc_name) defaults = defaults[len(start_params):] class EntryValueError(Exception): pass def warning_dialog(parent, primary, secondary=None): dlg = gtk.MessageDialog(parent, gtk.DIALOG_DESTROY_WITH_PARENT, gtk.MESSAGE_WARNING, gtk.BUTTONS_CLOSE, primary) if secondary: dlg.format_secondary_text(secondary) dlg.run() dlg.destroy() def error_dialog(parent, proc_name): import sys, traceback exc_str = exc_only_str = _("Missing exception information") try: etype, value, tb = sys.exc_info() exc_str = "".join(traceback.format_exception(etype, value, tb)) exc_only_str = "".join(traceback.format_exception_only(etype, value)) finally: etype = value = tb = None title = _("An error occurred running %s") % proc_name dlg = gtk.MessageDialog(parent, gtk.DIALOG_DESTROY_WITH_PARENT, gtk.MESSAGE_ERROR, gtk.BUTTONS_CLOSE, title) dlg.format_secondary_text(exc_only_str) alignment = gtk.Alignment(0.0, 0.0, 1.0, 1.0) alignment.set_padding(0, 0, 12, 12) dlg.vbox.pack_start(alignment) alignment.show() expander = gtk.Expander(_("_More Information")); expander.set_use_underline(True) expander.set_spacing(6) alignment.add(expander) expander.show() scrolled = gtk.ScrolledWindow() scrolled.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC) scrolled.set_size_request(-1, 200) expander.add(scrolled) scrolled.show() label = gtk.Label(exc_str) label.set_alignment(0.0, 0.0) label.set_padding(6, 6) label.set_selectable(True) scrolled.add_with_viewport(label) label.show() def response(widget, id): widget.destroy() dlg.connect("response", response) dlg.set_resizable(True) dlg.show() # define a mapping of param types to edit objects ... class StringEntry(gtk.Entry): def __init__(self, default=""): gtk.Entry.__init__(self) self.set_text(str(default)) self.set_activates_default(True) def get_value(self): return self.get_text() class TextEntry(gtk.ScrolledWindow): def __init__ (self, default=""): gtk.ScrolledWindow.__init__(self) self.set_shadow_type(gtk.SHADOW_IN) self.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC) self.set_size_request(100, -1) self.view = gtk.TextView() self.add(self.view) self.view.show() self.buffer = self.view.get_buffer() self.set_value(str(default)) def set_value(self, text): self.buffer.set_text(text) def get_value(self): return self.buffer.get_text(self.buffer.get_start_iter(), self.buffer.get_end_iter()) class IntEntry(StringEntry): def get_value(self): try: return int(self.get_text()) except ValueError, e: raise EntryValueError, e.args class FloatEntry(StringEntry): def get_value(self): try: return float(self.get_text()) except ValueError, e: raise EntryValueError, e.args # class ArrayEntry(StringEntry): # def get_value(self): # return eval(self.get_text(), {}, {}) def precision(step): # calculate a reasonable precision from a given step size if math.fabs(step) >= 1.0 or step == 0.0: digits = 0 else: digits = abs(math.floor(math.log10(math.fabs(step)))); if digits > 20: digits = 20 return int(digits) class SliderEntry(gtk.HScale): # bounds is (upper, lower, step) def __init__(self, default=0, bounds=(0, 100, 5)): step = bounds[2] self.adj = gtk.Adjustment(default, bounds[0], bounds[1], step, 10 * step, 0) gtk.HScale.__init__(self, self.adj) self.set_digits(precision(step)) def get_value(self): return self.adj.value class SpinnerEntry(gtk.SpinButton): # bounds is (upper, lower, step) def __init__(self, default=0, bounds=(0, 100, 5)): step = bounds[2] self.adj = gtk.Adjustment(default, bounds[0], bounds[1], step, 10 * step, 0) gtk.SpinButton.__init__(self, self.adj, step, precision(step)) class ToggleEntry(gtk.ToggleButton): def __init__(self, default=0): gtk.ToggleButton.__init__(self) self.label = gtk.Label(_("No")) self.add(self.label) self.label.show() self.connect("toggled", self.changed) self.set_active(default) def changed(self, tog): if tog.get_active(): self.label.set_text(_("Yes")) else: self.label.set_text(_("No")) def get_value(self): return self.get_active() class RadioEntry(gtk.VBox): def __init__(self, default=0, items=((_("Yes"), 1), (_("No"), 0))): gtk.VBox.__init__(self, homogeneous=False, spacing=2) button = None for (label, value) in items: button = gtk.RadioButton(button, label) self.pack_start(button) button.show() button.connect("toggled", self.changed, value) if value == default: button.set_active(True) self.active_value = value def changed(self, radio, value): if radio.get_active(): self.active_value = value def get_value(self): return self.active_value class ComboEntry(gtk.ComboBox): def __init__(self, default=0, items=()): store = gtk.ListStore(str) for item in items: store.append([item]) gtk.ComboBox.__init__(self, model=store) cell = gtk.CellRendererText() self.pack_start(cell) self.set_attributes(cell, text=0) self.set_active(default) def get_value(self): return self.get_active() def FileSelector(default="", title=None): # FIXME: should this be os.path.separator? If not, perhaps explain why? if default and default.endswith("/"): if default == "/": default = "" return DirnameSelector(default) else: return FilenameSelector(default, title=title, save_mode=False) class FilenameSelector(gtk.HBox): #gimpfu.FileChooserButton def __init__(self, default, save_mode=True, title=None): super(FilenameSelector, self).__init__() if not title: self.title = _("Python-Fu File Selection") else: self.title = title self.save_mode = save_mode box = self self.entry = gtk.Entry() image = gtk.Image() image.set_from_stock(gtk.STOCK_FILE, gtk.ICON_SIZE_BUTTON) self.button = gtk.Button() self.button.set_image(image) box.pack_start(self.entry) box.pack_start(self.button, expand=False) self.button.connect("clicked", self.pick_file) if default: self.entry.set_text(default) def show(self): super(FilenameSelector, self).show() self.button.show() self.entry.show() def pick_file(self, widget): entry = self.entry dialog = gtk.FileChooserDialog( title=self.title, action=(gtk.FILE_CHOOSER_ACTION_SAVE if self.save_mode else gtk.FILE_CHOOSER_ACTION_OPEN), buttons=(gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL, gtk.STOCK_SAVE if self.save_mode else gtk.STOCK_OPEN, gtk.RESPONSE_OK) ) dialog.set_alternative_button_order ((gtk.RESPONSE_OK, gtk.RESPONSE_CANCEL)) dialog.show_all() response = dialog.run() if response == gtk.RESPONSE_OK: entry.set_text(dialog.get_filename()) dialog.destroy() def get_value(self): return self.entry.get_text() class DirnameSelector(gtk.FileChooserButton): def __init__(self, default=""): gtk.FileChooserButton.__init__(self, _("Python-Fu Folder Selection")) self.set_action(gtk.FILE_CHOOSER_ACTION_SELECT_FOLDER) if default: self.set_filename(default) def get_value(self): return self.get_filename() _edit_mapping = { PF_INT8 : IntEntry, PF_INT16 : IntEntry, PF_INT32 : IntEntry, PF_FLOAT : FloatEntry, PF_STRING : StringEntry, #PF_INT8ARRAY : ArrayEntry, #PF_INT16ARRAY : ArrayEntry, #PF_INT32ARRAY : ArrayEntry, #PF_FLOATARRAY : ArrayEntry, #PF_STRINGARRAY : ArrayEntry, PF_COLOR : gimpui.ColorSelector, PF_ITEM : IntEntry, # should handle differently ... PF_IMAGE : gimpui.ImageSelector, PF_LAYER : gimpui.LayerSelector, PF_CHANNEL : gimpui.ChannelSelector, PF_DRAWABLE : gimpui.DrawableSelector, PF_VECTORS : gimpui.VectorsSelector, PF_TOGGLE : ToggleEntry, PF_SLIDER : SliderEntry, PF_SPINNER : SpinnerEntry, PF_RADIO : RadioEntry, PF_OPTION : ComboEntry, PF_FONT : gimpui.FontSelector, PF_FILE : FileSelector, PF_FILENAME : FilenameSelector, PF_DIRNAME : DirnameSelector, PF_BRUSH : gimpui.BrushSelector, PF_PATTERN : gimpui.PatternSelector, PF_GRADIENT : gimpui.GradientSelector, PF_PALETTE : gimpui.PaletteSelector, PF_TEXT : TextEntry } if on_run: on_run() dialog = gimpui.Dialog(proc_name, "python-fu", None, 0, None, proc_name, (gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL, gtk.STOCK_OK, gtk.RESPONSE_OK)) dialog.set_alternative_button_order((gtk.RESPONSE_OK, gtk.RESPONSE_CANCEL)) dialog.set_transient() vbox = gtk.VBox(False, 12) vbox.set_border_width(12) dialog.vbox.pack_start(vbox) vbox.show() if blurb: if domain: try: (domain, locale_dir) = domain trans = gettext.translation(domain, locale_dir, fallback=True) except ValueError: trans = gettext.translation(domain, fallback=True) blurb = trans.ugettext(blurb) box = gimpui.HintBox(blurb) vbox.pack_start(box, expand=False) box.show() table = gtk.Table(len(params), 2, False) table.set_row_spacings(6) table.set_col_spacings(6) vbox.pack_start(table, expand=False) table.show() def response(dlg, id): if id == gtk.RESPONSE_OK: dlg.set_response_sensitive(gtk.RESPONSE_OK, False) dlg.set_response_sensitive(gtk.RESPONSE_CANCEL, False) params = [] try: for wid in edit_wids: params.append(wid.get_value()) except EntryValueError: warning_dialog(dialog, _("Invalid input for '%s'") % wid.desc) else: try: dialog.res = run_script(params) except CancelError: pass except Exception: dlg.set_response_sensitive(gtk.RESPONSE_CANCEL, True) error_dialog(dialog, proc_name) raise gtk.main_quit() dialog.connect("response", response) edit_wids = [] for i in range(len(params)): pf_type = params[i][0] name = params[i][1] desc = params[i][2] def_val = defaults[i] label = gtk.Label(desc) label.set_use_underline(True) label.set_alignment(0.0, 0.5) table.attach(label, 1, 2, i, i+1, xoptions=gtk.FILL) label.show() # Remove accelerator markers from tooltips tooltip_text = desc.replace("_", "") if pf_type in (PF_SPINNER, PF_SLIDER, PF_RADIO, PF_OPTION): wid = _edit_mapping[pf_type](def_val, params[i][4]) elif pf_type in (PF_FILE, PF_FILENAME): wid = _edit_mapping[pf_type](def_val, title= "%s - %s" % (proc_name, tooltip_text)) else: wid = _edit_mapping[pf_type](def_val) label.set_mnemonic_widget(wid) table.attach(wid, 2,3, i,i+1, yoptions=0) if pf_type != PF_TEXT: wid.set_tooltip_text(tooltip_text) else: # Attach tip to TextView, not to ScrolledWindow wid.view.set_tooltip_text(tooltip_text) wid.show() wid.desc = desc edit_wids.append(wid) progress_vbox = gtk.VBox(False, 6) vbox.pack_end(progress_vbox, expand=False) progress_vbox.show() progress = gimpui.ProgressBar() progress_vbox.pack_start(progress) progress.show() # progress_label = gtk.Label() # progress_label.set_alignment(0.0, 0.5) # progress_label.set_ellipsize(pango.ELLIPSIZE_MIDDLE) # attrs = pango.AttrList() # attrs.insert(pango.AttrStyle(pango.STYLE_ITALIC, 0, -1)) # progress_label.set_attributes(attrs) # progress_vbox.pack_start(progress_label) # progress_label.show() dialog.show() gtk.main() if hasattr(dialog, "res"): res = dialog.res dialog.destroy() return res else: dialog.destroy() raise CancelError def _run(proc_name, params): run_mode = params[0] func = _registered_plugins_[proc_name][10] if run_mode == RUN_NONINTERACTIVE: return apply(func, params[1:]) script_params = _registered_plugins_[proc_name][8] has_param_run_mode = _registered_plugins_[proc_name][15] min_args = 0 start_param_idx = 1 if has_param_run_mode else 0 if len(params) > start_param_idx: for i in range(start_param_idx, len(params)): param_type = _obj_mapping[script_params[i - start_param_idx][0]] if not isinstance(params[i], param_type): break min_args = i if len(script_params) > min_args: start_params = params[:min_args + 1] if run_mode == RUN_WITH_LAST_VALS: default_params = _get_defaults(proc_name) params = start_params + default_params[min_args:] else: params = start_params else: run_mode = RUN_NONINTERACTIVE if run_mode == RUN_INTERACTIVE: try: res = _interact(proc_name, params[start_param_idx:]) except CancelError: return else: res = apply(func, params[start_param_idx:]) gimp.displays_flush() return res def main(): """This should be called after registering the plug-in.""" gimp.main(None, None, _query, _run) def fail(msg): """Display an error message and quit""" gimp.message(msg) raise error, msg def N_(message): return message