#!/usr/bin/env python # This is a plug-in intended to be bound to Ctrl-S, and to act as # a combination of Save, Save as..., Overwrite, and Export... # # It saves the image (whether its type would normally require Saving # or Exporting), and optionally allows for a copy of the image to be # exported to another file, possibly also scaled differently. # The copy must be saved to the same directory as the original # (because juggling two file choosers would make the dialog too huge # to fit on most screens!) # # Regardless of image format chosen, the image will be marked clean # so you won't get prompted for an unsaved image at quit or close time. # # The first time you save a file, you should be prompted for filename # (even if you opened an existing file). # After that, the plug-in will remember the filename you set. # You can always change it with Saver as... # # So you can have your XCF with high resolution and all layers, # and automatically export to a quarter-scale JPEG copy every time # you save the original. Or you can just overwrite your JPG if # all you're doing is a quick edit. # # The basic save filename follows img.filename. # The copy, if any, is saved in a parasite: # export-scaled: filename\npercent\nwidth\nheight # This plug-in is hosted at https://github.com/akkana/gimp-plugins/blob/master/saver.py # Copyright 2013,2014 by Akkana Peck, http://www.shallowsky.com/software/ # You may use and distribute this plug-in under the terms of the GPL v2 # or, at your option, any later GPL version. from gimpfu import * import gtk import os import collections # Image original size orig_width = 0 orig_height = 0 # The three gtk.Entry widgets we'll monitor: percent_e = None width_e = None height_e = None # A way to show errors to the user: warning_label = None # A way to turn off notifications while we're calculating busy = False def save_both(img, drawable, filename, copyname, width, height): '''Save the image, and also save the copy if appropriate, doing any duplicating or scaling that might be necessary. Returns None on success, else a nonempty string error message. ''' msg = "Saving " + filename if copyname: if width and height: msg += " and a scaled copy, " + copyname else: msg += " and a copy, " + copyname print msg # Don't use gimp_message -- it can pop up a dialog. # pdb.gimp_message_set_handler(MESSAGE_BOX) # pdb.gimp_message(msg) # Alternately, could use gimp_progress, though that's not ideal. # If you use gimp_progress, don't forget to call pdb.gimp_progress_end() #pdb.gimp_progress_set_text(msg) # Is there any point to pushing and popping the context? #gimp.context_pop() # Set up the parasite with information about the copied image, # so it will be saved with the main XCF image. if copyname: if (width and height): percent = width * 100.0 / img.width # XXX note that this does not guard against changed aspect ratios. parastring = '%s\n%d\n%d\n%d' % (copyname, percent, width, height) else: parastring = '%s\n100.0\nNone\nNone' % (copyname) print "Saving parasite", parastring para = img.attach_new_parasite('export-copy', 1, parastring) # The second argument is is_persistent # Also make sure that copyname is a full path (if filename is). copypath = os.path.split(copyname) if not copypath[0] or len(copypath) == 1: filepath = os.path.split(filename) if len(filepath) > 1 and filepath[0]: print "Turning", copyname, "into a full path:", copyname = os.path.join(filepath[0], copyname) print copyname # Now the parasite is safely attached, and we can save. # Alas, we can't attach the JPEG settings parasite until after # we've saved the copy, so that won't get saved with the main image # though it will be attached to the image and remembered in # this session, and the next time we save it should be remembered. def is_xcf(thefilename): base, ext = os.path.splitext(thefilename) ext = ext.lower() if ext == '.gz' or ext == '.bz2': base, ext = os.path.splitext(base) ext = ext.lower() return (ext == '.xcf') # With the new "crop doesn't really crop", gimp_file_save saves the # whole image, not the cropped version. # So detect cropping or anything else that might make layers # be sized differently from the image. def different_size_layer(img): for l in img.layers: if l.width != img.width: return True if l.height != img.height: return True return False # First, save the original image. if is_xcf(filename) or (len(img.layers) < 2 and not different_size_layer(img)): pdb.gimp_file_save(img, drawable, filename, filename) else: # It's not XCF and it has multiple layers, # or one layer that's been cropped. # We need to make a new image and flatten it. copyimg = pdb.gimp_image_duplicate(img) # Don't actually flatten since that will prevent saving transparent png. #copyimg.flatten() pdb.gimp_image_merge_visible_layers(copyimg, CLIP_TO_IMAGE) pdb.gimp_file_save(copyimg, copyimg.active_layer, filename, filename) gimp.delete(copyimg) # We've done the important part, so mark the image clean. img.filename = filename pdb.gimp_image_clean_all(img) # If we don't have to save a copy, return. if not copyname: return None # We'll need a new image if the copy is non-xcf and we have more # than one layer, or if we're scaling. if (width and height): # We're scaling! copyimg = pdb.gimp_image_duplicate(img) copyimg.scale(width, height) print "Scaling to", width, 'x', height print "Set parastring to", parastring, "(end of parastring)" elif len(img.layers) > 1 and not is_xcf(copyname): # We're not scaling, but we still need to flatten. copyimg = pdb.gimp_image_duplicate(img) # copyimg.flatten() pdb.gimp_image_merge_visible_layers(copyimg, CLIP_TO_IMAGE) print "Flattening but not scaling" else: copyimg = img print "Not scaling or flattening" # gimp-file-save insists on being passed a valid layer, # even if saving to a multilayer format such as XCF. Go figure. # I don't get it. What's the rule for whether gimp_file_save # will prompt for jpg parameters? Saving back to an existing # file doesn't seem to help. # run_mode=RUN_WITH_LAST_VALS is supposed to prevent prompting, # but actually seems to do nothing. Copying the -save-options # parasites is more effective. try: pdb.gimp_file_save(copyimg, copyimg.active_layer, copyname, copyname, run_mode=RUN_WITH_LAST_VALS) except RuntimeError, e: gimp.delete(copyimg) print "Runtime error -- didn't save" return "Runtime error -- didn't save" # Find any image type settings parasites (e.g. jpeg settings) # that got set during save, so we'll be able to use them # next time. def copy_settings_parasites(fromimg, toimg): for pname in fromimg.parasite_list(): if pname[-9:] == '-settings' or pname[-13:] == '-save-options': para = fromimg.parasite_find(pname) if para: toimg.attach_new_parasite(pname, para.flags, para.data) # Copy any settings parasites we may have saved from previous runs: copy_settings_parasites(copyimg, img) gimp.delete(copyimg) #gimp.Display(copyimg) return None def init_from_parasite(img): '''Returns copyname, percent, width, height.''' para = img.parasite_find('export-copy') if para: #copyname, percent, width, height = \ paravals = \ map(lambda x: 0 if x == 'None' or x == '' else x, para.data.split('\n')) copyname = paravals[0] percent = float(paravals[1]) width = int(paravals[2]) height = int(paravals[3]) print "Read parasite values", copyname, percent, width, height return copyname, percent, width, height else: return None, 100.0, img.width, img.height def python_fu_saver(img, drawable): # Figure out whether this image already has filenames chosen; # if so, just save and export; # if not, call python_fu_saver_as(img, drawable). # Figure out whether there's an export parasite # so we can pass width and height to save_both. copyname, export_percent, export_width, export_height = \ init_from_parasite(img) if img.filename: save_both(img, drawable, img.filename, copyname, export_width, export_height) else: # If we don't have a filename, either from the current session # or saved as a parasite, then we'd better prompt for one. python_fu_saver_as(img, drawable) def python_fu_saver_as(img, drawable): global percent_e, width_e, height_e, orig_width, orig_height, warning_label orig_filename = img.filename orig_width = img.width orig_height = img.height # Do we already have defaults? copyname, export_percent, export_width, export_height = \ init_from_parasite(img) chooser = gtk.FileChooserDialog(title=None, action=gtk.FILE_CHOOSER_ACTION_SAVE, buttons=(gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL, gtk.STOCK_SAVE, gtk.RESPONSE_OK)) # Set a current folder, since otherwise it'll be empty # and will nag the user to set one. if img.filename: chooser.set_current_name(os.path.basename(img.filename)) chooser.set_current_folder(os.path.dirname(img.filename)) else: # No directory set yet. So pick a likely directory # that's used by a lot of images currently open. counts = collections.Counter() for i in gimp.image_list() : if i.filename : counts[os.path.dirname(i.filename)] += 1 try : common = counts.most_common(1)[0][0] chooser.set_current_folder(common) except : # GIMP has no images open with pathnames associated. # So we can't guess at a directory; just leave it blank. pass copybox = gtk.Table(rows=3, columns=7) l = gtk.Label("Export a copy to:") l.set_alignment(1.0, 0.5) # Right align copybox.attach(l, 0, 1, 0, 1, xpadding=5, ypadding=5) copyname_e = gtk.Entry() if copyname: copyname_e.set_text(copyname) copybox.attach(copyname_e, 1, 7, 0, 1, xpadding=5, ypadding=5) l = gtk.Label("Scale the copy:") l.set_alignment(1.0, 0.5) copybox.attach(l, 0, 1, 1, 2, xpadding=5, ypadding=5) l = gtk.Label("Percent:") l.set_alignment(1.0, 0.5) copybox.attach(l, 1, 2, 1, 2, xpadding=5, ypadding=5) adj = gtk.Adjustment(int(export_percent), 1, 10000, 1, 10, 0) percent_e = gtk.SpinButton(adj, 0, 0) percent_e.connect("changed", entry_changed, 'p'); copybox.attach(percent_e, 2, 3, 1, 2, xpadding=5, ypadding=5) l = gtk.Label("Width:") l.set_alignment(1.0, 0.5) copybox.attach(l, 3, 4, 1, 2, xpadding=5, ypadding=5) adj = gtk.Adjustment(int(export_width), 1, 10000, 1, 10, 0) width_e = gtk.SpinButton(adj, 0, 0) width_e.connect("changed", entry_changed, 'w'); copybox.attach(width_e, 4, 5, 1, 2, xpadding=5, ypadding=5) l = gtk.Label("Height:") l.set_alignment(1.0, 0.5) copybox.attach(l, 5, 6, 1, 2, xpadding=5, ypadding=5) adj = gtk.Adjustment(int(export_height), 1, 10000, 1, 10, 0) height_e = gtk.SpinButton(adj, 0, 0) height_e.connect("changed", entry_changed, 'h'); copybox.attach(height_e, 6, 7, 1, 2, xpadding=5, ypadding=5) warning_label = gtk.Label("") copybox.attach(warning_label, 0, 7, 2, 3, xpadding=5, ypadding=5) copybox.show_all() chooser.set_extra_widget(copybox) # Oh, cool, we could have shortcuts to image folders, # and maybe remove the stupid fstab shortcuts GTK adds for us. #chooser.add_shortcut_folder(folder) #chooser.remove_shortcut_folder(folder) # Loop to catch errors/warnings: while True: response = chooser.run() if response != gtk.RESPONSE_OK: chooser.destroy() return try: percent = float(percent_e.get_text()) except ValueError: percent = None try: width = int(width_e.get_text()) except ValueError: width = None try: height = int(height_e.get_text()) except ValueError: height = None filename = chooser.get_filename() copyname = copyname_e.get_text() if copyname == orig_filename: warning_label.set_text("Change the name or the directory -- don't overwrite original file!") continue # Whew, it's not the original filename, so now we can save. dpy = chooser.get_display() chooser.hide() dpy.sync() err = save_both(img, drawable, filename, copyname, width, height) if err: markup = '' markup_end = '' warning_label.set_markup(markup + "Didn't save to " + filename + ":" + str(e) + markup_end) warning_label.set_text(err) print "Error:", err chooser.show() continue else: pdb.gimp_image_clean_all(img) # Otherwise, save_both was happy so we can continue. chooser.destroy() #gimp.context_pop() return # Doesn't work to compare entry against percent_e, etc. # Must pass that info in a separate arg, darnit. def entry_changed(entry, which): global percent_e, width_e, height_e, orig_width, orig_height, busy if busy: return # Can't use get_value() or get_value_as_int() because they # don't work before an update(), but update() will usually # overwrite what the user typed. So define a function: def get_num_value(spinbox): s = spinbox.get_text() if not s: return 0 try: p = int(s) return p except ValueError: try: p = float(s) return p except ValueError: return 0 if which == 'p': p = get_num_value(percent_e) if not p: return busy = True w = int(orig_width * p / 100.) width_e.set_text(str(w)) h = int(orig_height * p / 100.) height_e.set_text(str(h)) busy = False elif which == 'w': w = get_num_value(width_e) if not w: return busy = True p = w * 100. / orig_width percent_e.set_text(str(p)) h = int(orig_height * p / 100.) height_e.set_text(str(h)) busy = False elif which == 'h': h = get_num_value(height_e) if not h: return busy = True p = h * 100. / orig_height percent_e.set_text(str(p)) w = int(orig_width * p / 100.) width_e.set_text(str(w)) busy = False else: print "I'm confused -- not width, height or percentage" register( "python_fu_saver", "Save or export the current image, optionally also exporting a scaled version, prompting for filename only if needed.", "Save or export the current image, optionally also exporting a scaled version, prompting for filename only if needed.", "Akkana Peck", "Akkana Peck", "2014", "Saver", "*", [ (PF_IMAGE, "image", "Input image", None), (PF_DRAWABLE, "drawable", "Input drawable", None), ], [], python_fu_saver, menu = "/File/Save/" ) register( "python_fu_saver_as", "Prompt to save or export the current image, optionally also exporting a scaled version.", "Prompt to save or export the current image, optionally also exporting a scaled version.", "Akkana Peck", "Akkana Peck", "2014", "Saver as...", "*", [ (PF_IMAGE, "image", "Input image", None), (PF_DRAWABLE, "drawable", "Input drawable", None), ], [], python_fu_saver_as, menu = "/File/Save/" ) main()