#!/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()