cropgui/cropgtk.py
Jeff Epler 41ca4c9c07
Merge pull request #125 from jepler/issue11
Return nonzero if no image was cropped
2024-09-24 09:14:27 -05:00

450 lines
17 KiB
Python
Executable file

#!/usr/bin/python3
# cropgui, a graphical front-end for lossless jpeg cropping
# Copyright (C) 2009 Jeff Epler <jepler@unpythonic.net>
# 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 2 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, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
from cropgui_common import *
from cropgui_common import _
import gi
#from gi.repository import GObject as gobject
gi.require_version('Gtk', '3.0')
from gi.repository import Gtk as gtk
from gi.repository import GLib
#import gtk.glade
from gi.repository import Gdk as gdk
import filechooser
from gi.repository import GdkPixbuf as GdkPixbuf
import argparse
import sys
import traceback
# otherwise, on hardy the user is shown spurious "[application] closed
# unexpectedly" messages but denied the ability to actually "report [the]
# problem"
def excepthook(exc_type, exc_obj, exc_tb):
try:
w = app['window1']
except NameError:
w = None
lines = traceback.format_exception(exc_type, exc_obj, exc_tb)
print("".join(lines))
m = gtk.MessageDialog(w,
gtk.DialogFlags.MODAL | gtk.DialogFlags.DESTROY_WITH_PARENT,
gtk.MessageType.ERROR, gtk.ButtonsType.OK,
_("Stepconf encountered an error. The following "
"information may be useful in troubleshooting:\n\n")
+ "".join(lines))
m.show()
m.run()
m.destroy()
sys.excepthook = excepthook
import cropgui_common
gladefile = os.path.join(os.path.dirname(cropgui_common.__file__),
"cropgui.glade")
class DragManager(DragManagerBase):
def __init__(self, g):
self.g = g
self.idle = None
self.busy = False
DragManagerBase.__init__(self)
w = g['window1']
i = g['eventbox1']
i.connect('button-press-event', self.press)
i.connect('motion-notify-event', self.motion)
i.connect('button-release-event', self.release)
w.connect('delete-event', self.close)
w.connect('key-press-event', self.key)
g['toolbutton1'].connect('clicked', self.done)
g['toolbutton2'].connect('clicked', self.escape)
g['toolbutton3'].connect('clicked', self.ccw)
g['toolbutton4'].connect('clicked', self.cw)
def ccw(self, event):
self.rotate_ccw()
def cw(self, event):
self.rotate_cw()
def coords(self, event):
return event.x, event.y
def press(self, w, event):
if event.type == gdk.EventType._2BUTTON_PRESS:
return self.done()
x, y = self.coords(event)
self.drag_start(x, y, event.state & gdk.ModifierType.SHIFT_MASK)
def motion(self, w, event):
x, y = self.coords(event)
if event.state & gdk.ModifierType.BUTTON1_MASK:
self.drag_continue(x, y)
else:
self.idle_motion(x, y)
idle_cursor = gdk.Cursor(gdk.CursorType.WATCH)
cursor_map = {
DRAG_TL: gdk.Cursor(gdk.CursorType.TOP_LEFT_CORNER),
DRAG_L: gdk.Cursor(gdk.CursorType.LEFT_SIDE),
DRAG_BL: gdk.Cursor(gdk.CursorType.BOTTOM_LEFT_CORNER),
DRAG_TR: gdk.Cursor(gdk.CursorType.TOP_RIGHT_CORNER),
DRAG_R: gdk.Cursor(gdk.CursorType.RIGHT_SIDE),
DRAG_BR: gdk.Cursor(gdk.CursorType.BOTTOM_RIGHT_CORNER),
DRAG_T: gdk.Cursor(gdk.CursorType.TOP_SIDE),
DRAG_B: gdk.Cursor(gdk.CursorType.BOTTOM_SIDE),
DRAG_C: gdk.Cursor(gdk.CursorType.FLEUR)}
def idle_motion(self, x, y):
i = self.g['image1']
if not i: return
if self.busy: cursor = self.idle_cursor
else:
what = self.classify(x, y)
cursor = self.cursor_map.get(what, None)
# i.window.set_cursor(cursor)
def release(self, w, event):
x, y = self.coords(event)
self.drag_end(x, y)
def done(self, *args):
self.result = 1
self.loop.quit()
def escape(self, *args):
self.result = 0
self.loop.quit()
def save_and_stay(self, *args):
self.result = 2
self.loop.quit()
def close(self, *args):
self.result = -1
self.loop.quit()
# This does zoom in, in the sense that the GTK window gets twice as big.
# What I really want to do is to either
# (a) crop the thumbnail but map the coords back to the original image;
# (b) show the image in a scrolled window (see, for example,
# Gtk.ScrolledWindow); or
# (c) see if I can define a viewport to display just the uncropped part
# of the image.
# But this is still useful, in that
# (a) in some cases the initial zoom factor could be bigger and still fit, and
# (b) the user can move the overly-large window around using the window manager
# to get at any edge.
# TODO: although the GUI window automagically increases in size when
# zoom("in") is called, the window doesn't shrink on zoom("out").
# This should probably be fixed.
# TODO: it is possible the original image has disappeared. Open in try/except block.
def zoom(self, in_out):
if in_out == "in":
if self.scale > 1:
new_scale = self.scale // 2;
else:
return
else:
new_scale = self.scale * 2;
# These values get reset below; save and restore them explicitly.
t, l, r, b = self.top, self.left, self.right, self.bottom
# In tests, using a copy of the original saved in run() just didn't work
# for (at least) rotated images. So re-open the file.
image = self.copy_of_original
# Must reset .w and .h since those may have been "rotated".
self.w, self.h = image.size
thumbnail = image.copy()
thumbnail.thumbnail((self.w // new_scale, self.h // new_scale))
self.image = thumbnail
self.rotation = 1
rotation = self.original_rotation
if rotation in (3,6,8):
while self.rotation != rotation:
self.rotate_ccw()
self.scale = new_scale
self.set_crop(t, l, r, b)
# TODO: should the coords be limited to [min..max] here or in set_crop?
def key(self, w, e):
if e.keyval == gdk.KEY_Escape: self.escape()
elif e.keyval == gdk.KEY_Return: self.done()
elif e.string:
if self.round_right_and_bottom:
b_delta = self.round_y
r_delta = self.round_x
else:
b_delta = r_delta = 1
if e.string == 'n': self.escape()
elif e.string == 'q': self.close()
elif e.string == 's': self.save_and_stay()
elif e.string in ',<': self.rotate_ccw()
elif e.string in '.>': self.rotate_cw()
elif e.string in 'h': self.set_crop(self.top, max(0, self.left - self.round_x), self.right, self.bottom)
elif e.string in 'j': self.set_crop(min(self.h, self.top + self.round_y), self.left, self.right, self.bottom)
elif e.string in 'k': self.set_crop(max(0, self.top - self.round_y), self.left, self.right, self.bottom)
elif e.string in 'l': self.set_crop(self.top, min(self.h, self.left + self.round_x), self.right, self.bottom)
elif e.string in 'H': self.set_crop(self.top, self.left, max(0, self.right - r_delta), self.bottom)
elif e.string in 'J': self.set_crop(self.top, self.left, self.right, min(self.h, self.bottom + b_delta))
elif e.string in 'K': self.set_crop(self.top, self.left, self.right, max(0, self.bottom - b_delta))
elif e.string in 'L': self.set_crop(self.top, self.left, min(self.w, self.right + r_delta), self.bottom)
elif e.string == 'z': self.zoom("in")
elif e.string == 'Z': self.zoom("out")
# Don't know whether other event handlers need it too, but if
# this doesn't return True (True prevents further handlers from
# being invoked), a return somehow double-triggers self.done(),
# skipping the next image in a multi-file invocation. Not clear
# what's going on, but this stops it.
return True
def image_set(self):
self.render()
def render(self):
if self.idle is None:
self.idle = GLib.idle_add(self.do_render)
def do_render(self):
if not self.idle:
return
self.idle = None
g = self.g
i = g['image1']
if not i: # app shutting down
return
if self.image is None:
pixbuf = GdkPixbuf.Pixbuf.new_from_data('\0\0\0',
GdkPixbuf.Colorspace.RGB, 0, 8, 1, 1, 3)
i.set_from_pixbuf(pixbuf)
g['pos_left'].set_text('---')
g['pos_right'].set_text('---')
g['pos_top'].set_text('---')
g['pos_bottom'].set_text('---')
g['pos_width'].set_text('---')
g['pos_height'].set_text('---')
g['pos_ratio'].set_text('---')
else:
rendered = self.rendered()
rendered = rendered.convert('RGB')
i.set_size_request(*rendered.size)
try:
image_data = rendered.tostring()
except:
image_data = rendered.tobytes()
pixbuf = GdkPixbuf.Pixbuf.new_from_data(image_data,
GdkPixbuf.Colorspace.RGB, 0, 8,
rendered.size[0], rendered.size[1], 3*rendered.size[0])
tt, ll, rr, bb = self.get_corners()
ratio = self.describe_ratio()
g['pos_left'].set_text('%d' % ll)
g['pos_right'].set_text('%d' % rr)
g['pos_top'].set_text('%d' % tt)
g['pos_bottom'].set_text('%d' % bb)
g['pos_width'].set_text('%d' % (rr-ll))
g['pos_height'].set_text('%d' % (bb-tt))
g['pos_ratio'].set_text(self.describe_ratio())
i.set_from_pixbuf(pixbuf)
return False
def wait(self):
self.loop = GLib.MainLoop()
self.result = -1
self.loop.run()
return self.result
display = gdk.Display().get_default()
# TODO: get the monitor where the mouse is, not necessarily #0.
wa = display.get_monitor(0).get_workarea()
max_h = wa.height - 192
max_w = wa.width - 64
def get_pointer(widget):
window = widget.get_window()
if window is None:
return None
display = window.get_display()
pointer = display.get_default_seat().get_pointer()
return window.get_device_position(pointer)
class App:
def __init__(self, *, round_right_and_bottom=False, files=[]):
self.round_right_and_bottom = round_right_and_bottom
self.files = files
self.builder = gtk.Builder()
self.builder.add_from_file(gladefile)
#self.glade = gtk.glade.XML(gladefile)
self.drag = DragManager(self)
self.task = CropTask(self)
self.dirchooser = None
self['window1'].set_title(_("CropGTK"))
def __getitem__(self, name):
return self.builder.get_object(name)
def log(self, msg):
s = self['statusbar1']
if s:
s.pop(0)
s.push(0, msg)
progress = log
def set_busy(self, is_busy=True):
self.drag.busy = is_busy
i = self['image1']
if i:
pointer = get_pointer(i)
if pointer is not None:
self.drag.idle_motion(pointer.x, pointer.y)
def run(self):
drag = self.drag
task = self.task
prev_name = None
cropped_any_image = False
for image_name in self.image_names():
drag.save_prev_crop()
drag.round_right_and_bottom = self.round_right_and_bottom
self['window1'].set_title(
_("%s - CropGTK") % os.path.basename(image_name))
self.set_busy()
try:
image = Image.open(image_name)
drag.copy_of_original = image.copy() # Needed by zoom()
drag.round_x, drag.round_y = image_round(image)
drag.w, drag.h = image.size
scale = 1
scale = max (scale, nextPowerOf2((drag.w-1)/(max_w+1)))
scale = max (scale, nextPowerOf2((drag.h-1)/(max_h+1)))
thumbnail = image.copy()
thumbnail.thumbnail((drag.w//scale, drag.h//scale))
except (IOError,) as detail:
m = gtk.MessageDialog(self['window1'],
gtk.DialogFlags.MODAL | gtk.DialogFlags.DESTROY_WITH_PARENT,
gtk.MessageType.ERROR, gtk.ButtonsType.OK,
"Could not open %s: %s" % (image_name, detail))
m.show()
m.run()
m.destroy()
continue
image_type = image.format.lower() if image.format else "png"
drag.image = thumbnail
drag.rotation = 1
rotation = image_rotation(image)
drag.original_rotation = rotation # Needed by zoom()
if rotation in (3,6,8):
while drag.rotation != rotation:
drag.rotate_ccw()
drag.scale = scale
v = 2
while v == 2:
self.set_busy(0)
v = self.drag.wait()
self.set_busy()
if v == -1: break # user closed app
if v == 0:
self.log("Skipped %s" % os.path.basename(image_name))
continue # user hit "next" / escape
if v == 2: # save but stick with this image
target = self.output_name(image_name,image_type,True,prev_name)
prev_name = target
else:
target = self.output_name(image_name,image_type)
if not target:
self.log("Skipped %s" % os.path.basename(image_name))
continue # user hit "cancel" on save dialog
task.add(CropRequest(
image=image,
image_name=image_name,
corners=drag.get_corners(),
rotation=drag.rotation,
target=target,
))
cropped_any_image = True
if v == -1: break # user closed app
if not cropped_any_image:
raise SystemExit(1)
def image_names(self):
if self.files:
yield from self.files
else:
c = filechooser.Chooser(_("Select images to crop"), self['window1'])
lastdir = None
while 1:
files = c.run(lastdir)
if not files: break
for i in files:
lastdir = os.path.dirname(i)
yield i
def output_name(self, image_name, image_type, chooser=False, prev_name=None):
image_name = os.path.abspath(image_name)
i = os.path.basename(image_name)
if chooser and prev_name is not None:
d = os.path.dirname(prev_name)
j = os.path.basename(prev_name)
else:
d = os.path.dirname(image_name)
j = os.path.splitext(i)[0]
if j.endswith('-crop'): j += os.path.splitext(i)[1]
else: j += "-crop" + os.path.splitext(i)[1]
if os.access(d, os.W_OK) and not chooser: return os.path.join(d, j)
title = _('Save cropped version of %s') % i
if self.dirchooser is None:
self.dirchooser = filechooser.DirChooser(title, self['window1'])
else:
self.dirchooser.set_title(title)
self.dirchooser.set_current_folder(d if os.access(d, os.W_OK) else desktop_name())
self.dirchooser.set_current_name(j)
r = self.dirchooser.run()
if not r: return ''
r = r[0]
e = os.path.splitext(r)[1]
if image_type == "jpeg":
if e.lower() in ['.jpg', '.jpeg']: return r
return e + ".jpg"
elif e.lower() == "." + image_type: return r
else: return e + "." + image_type
parser = argparse.ArgumentParser(description="Losslessly crop images")
parser.add_argument("-round-rb", default=False, action="store_true", dest="round_right_and_bottom", help="Round the right and bottom coordinates to MCU boundaries")
parser.add_argument('files', metavar='FILE', nargs='*', type=str, help="Files to be cropped")
args = parser.parse_args()
app = App(round_right_and_bottom=args.round_right_and_bottom, files=args.files)
try:
app.run()
finally:
app.task.done()
del app.task
del app.drag