#!/usr/bin/env python3
"""
A graphical frontend for lossless cropping of JPEG files using jpegtran.
"""
__author__ = "Martin J. Fiedler <keyj@emphy.de>"
__copyright__ = """
This software is published under the terms of KeyJ's Research License,
version 0.2. Usage of this software is subject to the following conditions:
0. There's no warranty whatsoever. The author(s) of this software can not
   be held liable for any damages that occur when using this software.
1. This software may be used freely for both non-commercial and commercial
   purposes.
2. This software may be redistributed freely as long as no fees are charged
   for the distribution and this license information is included.
3. This software may be modified freely except for this license information,
   which must not be changed in any way.
4. If anything other than configuration, indentation or comments have been
   altered in the code, the original author(s) must receive a copy of the
   modified code.
"""
__version__ = "1.2"
__history__ = """
1.0 [2016-01-09]
- first public release

1.1 [2017-08-29]
- can now crop PNG images too
- fixed erratic size modification when moving the crop rectangle with
  both aspect ratio and full-block constraints turned on

1.2 [2024-02-27]
- Python 3 port
"""
# to generate a Windows .exe file:
# > python -m PyInstaller --onefile --name JPEGcrop --windowed jpegcrop.py
# then add the following line to JPEGcrop.spec after Analysis():
# > a.datas += [("jpegtran.exe", "C:\\bin\\jpegtran.exe", "DATA")]
# then run PyInstaller on the .spec file (python -m PyInstaller JPEGcrop.spec)
import sys, os, shutil, subprocess, argparse, math
try:
    import FixTk  # explicit import required due to a bug in PyInstaller
except ImportError:
    pass
try:
    from tkinter import *
    import tkinter.messagebox as tkMessageBox
    import tkinter.filedialog as tkFileDialog
    from PIL import Image, ImageFile, ImageTk
except ImportError:
    print("FATAL: Need Tkinter as well as PIL (Python Imaging Library)", file=sys.stderr)
    print("       and its Tkinter bindings to work!", file=sys.stderr)
    sys.exit(7)
ImageFile.MAXBLOCK = 64*1024*1024

################################################################################

# constants / defaults

BACKGROUND_COLOR = "#404040"
BORDER_COLOR     = "#ff0000"

DEFAULT_ALIGN_SIZE = 0
DEFAULT_COPY_META  = 1
DEFAULT_OPTIMIZE   = 1

ASPECT_UNRESTRICTED = "Unrestricted"
ASPECT_FROM_IMAGE   = "Same as image"
ASPECT_FROM_CURRENT = "Keep current"
ASPECT_CUSTOM       = "Custom ..."
ASPECT_MENU_OPTIONS = [ASPECT_UNRESTRICTED, ASPECT_FROM_IMAGE, ASPECT_FROM_CURRENT] + \
                      ["1:1 (Square)", "5:4", "4:3", "3:2", "2:1", "16:9", "16:10", "21:9", "18:13"] + \
                      [ASPECT_CUSTOM]

FILEMODE_OVERWRITE    = "Overwrite Input"
FILEMODE_BACKUP       = "Overwrite + Backup"
FILEMODE_NEW          = "Create New File"
FILEMODE_EXPLICIT     = "Custom Filename"
FILEMODE_MENU_OPTIONS = [FILEMODE_OVERWRITE, FILEMODE_BACKUP, FILEMODE_NEW, FILEMODE_EXPLICIT]
FILEMODE_DEFAULT      = FILEMODE_NEW
DEFAULT_SUFFIX_NEW    = "_crop"
DEFAULT_SUFFIX_BACKUP = "_orig"

JPEGTRAN_WIN32_URL = "http://jpegclub.org/jpegtran.exe"

################################################################################

# misc tools or platform stuff

_tk_root = False
def _tk_init():
    global _tk_root
    if _tk_root:
        return _tk_root
    _tk_root = Tk()
    _tk_root.withdraw()
    if sys.platform == 'win32':
        winver = sys.getwindowsversion()
        if winver >= (6, 0):
            _tk_root.option_add("*Font", "segoe\\ ui 9")
        elif winver >= (5, 0):
            _tk_root.option_add("*Font", "tahoma 8")
    return _tk_root

def gcd(a, b):
    while b:
        a, b = b, a%b
    return a

def find_program(binary):
    if sys.platform == "win32":
        binary += ".exe"
    try:
        bundledirs = [sys._MEIPASS]  # find bundled .exe in PyInstaller mode
    except:
        bundledirs = []
    for path in [os.path.dirname(sys.argv[0]), "."] + bundledirs + os.getenv("PATH").split(os.path.pathsep):
        fullpath = os.path.join(path, binary)
        if os.path.isfile(fullpath) and os.access(fullpath, os.X_OK):
            return fullpath

def add_suffix(path, suffix):
    path, base = os.path.split(path)
    base, ext = os.path.splitext(base)
    return os.path.join(path, base + suffix + ext)

FILE_DIALOG_FILETYPES = [("JPEG image files", "*.jpg"), ("All files", "*.*" if (sys.platform == 'win32') else "*")]

################################################################################

# main JPEG handling code

JPEGTRAN_PATH = None

class NotJPEG(IOError):
    pass

class LoadJPEG(object):
    def __init__(self, filename):
        self.filename = filename
        try:
            # open the file and analyze it
            analyze_error = None
            self.xgrid, self.ygrid = 0, 0
            f = open(filename, 'rb')
            try:
                self.xgrid, self.ygrid = self.analyze_jpeg(f)
            except NotJPEG:
                pass
            except EnvironmentError as e:
                analyze_error = str(e)
            f.seek(0)

            # load the actual image
            self.img = Image.open(f)
            self.img.load()
            f.close()
        except EnvironmentError as e:
            _tk_init()
            tkMessageBox.showerror("Failed to open input file",
                                   f"Could not open input file\n\"{self.filename}\":\n\n{e.strerror or e}")
            sys.exit(1)
        if analyze_error:
            _tk_init()
            tkMessageBox.showwarning("Failed to analyze input file",
                                     f"Could not analyze macroblock structure of input file:\n{analyze_error}\n\nCropping will not be accurate.")

    def analyze_jpeg(self, f):
        if f.read(2) != b'\xff\xd8':
            raise NotJPEG("not a JPEG file")
        while True:
            marker = f.read(4)
            if not marker:
                raise IOError("unexpected EOF (truncated file?)")
            if (len(marker) != 4) or (marker[0] != 0xFF):
                raise IOError("invalid marker segment " + ' '.join("%02X"%c for c in marker[:2]))
            length = (marker[2] << 8) + marker[3] - 2
            marker = marker[1]
            if length < 1:
                raise IOError(f"invalid length {length} of marker segment {marker:02X}")
            if marker == 0xDA:
                raise IOError("no SOF marker found")
            elif ((marker & 0xF0) != 0xC0) or ((marker != 0xC0) and ((marker & 3) == 0)):
                f.read(length)
                continue

            # decode SOF marker
            data = f.read(length)
            if len(data) < 9:
                raise IOError("SOF marker truncated")
            ncomp = data[5]
            data = data[7 : 6 + ncomp * 3 : 3]
            if not data:
                raise IOError("no color components")
            return max(x >> 4 for x in data) << 3, max(x & 15 for x in data) << 3

def RunCrop(img, src, dest, rect, copy_metadata=True, optimize=True, is_jpeg=True):
    if not(is_jpeg) or not(os.path.splitext(dest)[-1].strip('.').lower() in ("jpg", "jpeg")):
        # non-JPEG mode
        try:
            img.crop(rect).save(dest)
        except IOError as e:
            return e
        return

    cmdline = [JPEGTRAN_PATH, "-copy", "all" if copy_metadata else "none",
               "-crop", f"{rect[2]-rect[0]}x{rect[3]-rect[1]}+{rect[0]}+{rect[1]}"]
    if optimize:
        cmdline += ["-optimize"]
    cmdline += ["-outfile", dest, src]
    try:
        if sys.platform == "win32":
            # extra shenanigans to work properly within PyInstaller
            si = subprocess.STARTUPINFO()
            si.dwFlags |= subprocess.STARTF_USESHOWWINDOW
            proc = subprocess.Popen(cmdline, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, startupinfo=si)
        else:
            proc = subprocess.Popen(cmdline, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
        err = proc.stdout.read().strip()
        res = proc.wait()
    except EnvironmentError as e:
        return e.strerror or str(e)
    if err:
        return err
    if res:
        return f"jpegtran failed with exit code {res}"
    if not os.path.isfile(dest):
        return "jpegtran succeeded, but generated to output file"

################################################################################

# edge/corner/cursor logic

EDGE_DISTANCE = 32

NO_EDGE     = 0
EDGE_X0     = 1
EDGE_Y0     = 2
EDGE_X1     = 4
EDGE_Y1     = 8
CORNER_X0Y0 = EDGE_X0 + EDGE_Y0
CORNER_X0Y1 = EDGE_X0 + EDGE_Y1
CORNER_X1Y0 = EDGE_X1 + EDGE_Y0
CORNER_X1Y1 = EDGE_X1 + EDGE_Y1
MOVE_RECT   = EDGE_X0 + EDGE_Y0 + EDGE_X1 + EDGE_Y1
EDGE_HORIZ  = EDGE_X0 + EDGE_X1
EDGE_VERT   = EDGE_Y0 + EDGE_Y1

CURSOR_MAP = [
    "arrow",
    "left_side",            # X0
    "top_side",             #    Y0
    "top_left_corner",      # X0 Y0
    "right_side",           #       X1
    "sb_h_double_arrow",    # X0    X1    (invalid)
    "top_right_corner",     #    Y0 X1
    "sb_up_arrow",          # X0 Y0 X1    (invalid)
    "bottom_side",          #          Y1
    "bottom_left_corner",   # X0       Y1
    "sb_v_double_arrow",    #    Y0    Y1 (invalid)
    "sb_left_arrow",        # X0 Y0    Y1 (invalid)
    "bottom_right_corner",  #       X1 Y1
    "sb_down_arrow",        # X0    X1 Y1 (invalid)
    "sb_right_arrow",       #    Y0 X1 Y1 (invalid)
    "fleur",                # X0 Y0 X1 Y1
]

def _get_partial_edge_code(x, x0, x1, dir_mask):
    assert x1 >= x0
    if (x < (x0 - EDGE_DISTANCE)) or (x > (x1 + EDGE_DISTANCE)):
        return NO_EDGE
    extent = x1 - x0
    if extent < (3 * EDGE_DISTANCE):
        extent //= 3
    else:
        extent = EDGE_DISTANCE
    if x < (x0 + extent):
        return CORNER_X0Y0 & dir_mask
    if x > (x1 - extent):
        return CORNER_X1Y1 & dir_mask
    return MOVE_RECT

def get_edge_code(cx, cy, x0, y0, x1, y1):
    eh = _get_partial_edge_code(cx, x0, x1, EDGE_HORIZ)
    ev = _get_partial_edge_code(cy, y0, y1, EDGE_VERT)
    if not(eh) or not(ev): return NO_EDGE
    if eh == MOVE_RECT:    return ev
    if ev == MOVE_RECT:    return eh
    return eh | ev

def invert_edge_code(code):
    if not code: return 0
    code = MOVE_RECT - code
    if (code & EDGE_HORIZ) == EDGE_HORIZ: code = code & ~EDGE_HORIZ
    if (code & EDGE_VERT)  == EDGE_VERT:  code = code & ~EDGE_VERT
    return code

################################################################################

# main UI

class MainForm(Toplevel):
    def __init__(self, filename, img=None, xgrid=0, ygrid=0, aspect_num=0, aspect_den=0):
        _tk_init()
        Toplevel.__init__(self, None)
        self.filename = filename
        self.src = filename
        self.can_crop = True
        if not img:
            img = Image.open(filename)
        if img.mode == '1':
            img = img.convert('L')
        elif img.mode in ('P', 'RGBA', 'CMYK'):
            img = img.convert('RGB')
        self.img = img
        self.grid = (max(1, xgrid), max(1, ygrid))
        self.is_jpeg = (min(self.grid) >= 8)
        self.crop = [0, 0] + list(img.size)
        self.isize     = (0, 0)        # scaled image size
        self.psize     = (0, 0)        # preview widget size
        self.poffs     = (0, 0)        # offset of scaled image in preview widget
        self.pcrop     = (0, 0, 0, 0)  # preview widget crop coordinates
        self.ref_mouse = (0, 0)        # reference mouse position while dragging
        self.ref_crop  = (0, 0, 0, 0)  # reference crop rectangle while dragging
        self.last_crop = tuple(self.crop)  # last executed crop
        self.last_output_file = self.filename
        self.suffix_new = DEFAULT_SUFFIX_NEW
        self.suffix_backup = DEFAULT_SUFFIX_BACKUP
        self.prev_file_mode = None
        self.dragging  = False
        self.edge = NO_EDGE
        self.aspect = None
        self.result = 0
        self.CreateUI()
        self.SetCropRect()

        if (aspect_num > 0) and (aspect_den > 0):
            m = ASPECT_CUSTOM
            search = f"{aspect_num}:{aspect_den}"
            for item in ASPECT_MENU_OPTIONS:
                if item.split()[0] == search:
                    m = item
                    break
            if m == ASPECT_CUSTOM:
                self.AspectXVar.set(str(aspect_num))
                self.AspectYVar.set(str(aspect_den))
            self.AspectMenuVar.set(m)
            if m == ASPECT_CUSTOM:
                self.OnCustomAspectChange()
            else:
                self.OnAspectMenuChange()

    def CreateUI(self):
        self.title("Lossless JPEG Cropping Helper - " + os.path.basename(self.filename))

        self.ControlPanel = Frame(self, padx=4)
        row = 0
        dyna_rows = []

        Label(self.ControlPanel, text="Aspect Ratio:").grid(row=row, column=0, columnspan=3, pady=(2,0), sticky=S)
        row += 1
        self.AspectMenuVar = StringVar()
        self.AspectMenu = OptionMenu(self.ControlPanel, self.AspectMenuVar, *ASPECT_MENU_OPTIONS)
        self.AspectMenuVar.set(ASPECT_MENU_OPTIONS[0])
        self.AspectMenuVar.trace('w', self.OnAspectMenuChange)
        self.AspectMenu.grid(sticky=E+W, row=row, column=0, columnspan=3)
        row += 1
        self.AspectXVar = StringVar()
        self.AspectX = Entry(self.ControlPanel, width=5, justify=CENTER, textvariable=self.AspectXVar, state=DISABLED)
        self.AspectX.bind("<Return>", self.OnCustomAspectChange)
        self.AspectX.bind("<FocusOut>", self.OnCustomAspectChange)
        self.AspectX.grid(sticky=E+W, row=row, column=0)
        self.AspectLabel = Label(self.ControlPanel, text=":", state=DISABLED)
        self.AspectLabel.grid(row=row, column=1)
        self.AspectYVar = StringVar()
        self.AspectY = Entry(self.ControlPanel, width=5, justify=CENTER, textvariable=self.AspectYVar, state=DISABLED)
        self.AspectY.bind("<Return>", self.OnCustomAspectChange)
        self.AspectY.bind("<FocusOut>", self.OnCustomAspectChange)
        self.AspectY.grid(sticky=E+W, row=row, column=2)
        row += 1
        self.AspectFlip = Button(self.ControlPanel, text="Flip Ratio", command=self.OnAspectFlipClick, state=DISABLED)
        self.AspectFlip.grid(sticky=E+W, row=row, column=0, columnspan=3)
        row += 1

        dyna_rows.append(row)
        Label(self.ControlPanel, text="Position:").grid(row=row, column=0, columnspan=3, pady=(16,0), sticky=S)
        row += 1
        self.OffsetXVar = StringVar()
        self.OffsetX = Spinbox(self.ControlPanel, from_=0, to=self.img.size[0] - (self.img.size[0] % self.grid[0]), increment=self.grid[0], width=5, justify=RIGHT, textvariable=self.OffsetXVar, command=self.OnCoordChange)
        self.OffsetX.bind("<Return>", self.OnCoordChange)
        self.OffsetX.bind("<FocusOut>", self.OnCoordChange)
        self.OffsetX.grid(sticky=E+W, row=row, column=0)
        Label(self.ControlPanel, text=",").grid(row=row, column=1)
        self.OffsetYVar = StringVar()
        self.OffsetY = Spinbox(self.ControlPanel, from_=0, to=self.img.size[1] - (self.img.size[1] % self.grid[1]), increment=self.grid[1], width=5, justify=RIGHT, textvariable=self.OffsetYVar, command=self.OnCoordChange)
        self.OffsetY.bind("<Return>", self.OnCoordChange)
        self.OffsetY.bind("<FocusOut>", self.OnCoordChange)
        self.OffsetY.grid(sticky=E+W, row=row, column=2)
        row += 1
        Label(self.ControlPanel, text="Size:").grid(row=row, column=0, columnspan=3)
        row += 1
        self.SizeXVar = StringVar()
        self.SizeX = Spinbox(self.ControlPanel, from_=0, to=self.img.size[0], increment=(self.grid[0] if DEFAULT_ALIGN_SIZE else 1), width=5, justify=RIGHT, textvariable=self.SizeXVar, command=self.OnCoordChange)
        self.SizeX.bind("<Return>", self.OnCoordChange)
        self.SizeX.bind("<FocusOut>", self.OnCoordChange)
        self.SizeX.grid(sticky=E+W, row=row, column=0)
        Label(self.ControlPanel, text="x").grid(row=row, column=1)
        self.SizeYVar = StringVar()
        self.SizeY = Spinbox(self.ControlPanel, from_=0, to=self.img.size[1], increment=(self.grid[1] if DEFAULT_ALIGN_SIZE else 1), width=5, justify=RIGHT, textvariable=self.SizeYVar, command=self.OnCoordChange)
        self.SizeY.bind("<Return>", self.OnCoordChange)
        self.SizeY.bind("<FocusOut>", self.OnCoordChange)
        self.SizeY.grid(sticky=E+W, row=row, column=2)
        row += 1
        self.AlignSizeVar = IntVar()
        self.AlignSizeVar.set(DEFAULT_ALIGN_SIZE)
        self.AlignSizeVar.trace('w', self.OnAlignSizeChange)
        if max(self.grid) > 1:
            self.AlignSizeEnable = Checkbutton(self.ControlPanel, text="Force Full Blocks", variable=self.AlignSizeVar, padx=0)
            self.AlignSizeEnable.grid(sticky=W, row=row, column=0, columnspan=3)
            row += 1
        self.ShadeEnableVar = IntVar()
        self.ShadeEnable = Checkbutton(self.ControlPanel, text="Shade Cropped Areas", variable=self.ShadeEnableVar, command=self.UpdateScene, padx=0)
        self.ShadeEnable.grid(sticky=W, row=row, column=0, columnspan=3)
        row += 1

        dyna_rows.append(row)
        Label(self.ControlPanel, text="Output Mode:").grid(row=row, column=0, columnspan=3, pady=(16,0), sticky=S)
        row += 1
        self.FileModeVar = StringVar()
        self.FileModeMenu = OptionMenu(self.ControlPanel, self.FileModeVar, *FILEMODE_MENU_OPTIONS)
        self.FileModeVar.set(FILEMODE_DEFAULT)
        self.FileModeVar.trace('w', self.OnFileModeChange)
        self.FileModeMenu.grid(sticky=E+W, row=row, column=0, columnspan=3)
        row += 1
        self.SuffixLabel = Label(self.ControlPanel, text="Filename suffix:")
        self.SuffixLabel.grid(row=row, column=0, columnspan=3)
        row += 1
        self.SuffixVar = StringVar()
        self.SuffixEntry = Entry(self.ControlPanel, width=20, textvariable=self.SuffixVar)
        self.SuffixEntry.grid(sticky=E+W, row=row, column=0, columnspan=3)
        self.OnFileModeChange()
        row += 1

        self.CopyMetaVar = IntVar()
        self.CopyMetaVar.set(DEFAULT_COPY_META)
        self.OptimizeVar = IntVar()
        self.OptimizeVar.set(DEFAULT_OPTIMIZE)
        if self.is_jpeg:
            dyna_rows.append(row)
            Label(self.ControlPanel, text="Output Options:").grid(row=row, column=0, columnspan=3, pady=(16,0), sticky=S)
            row += 1
            self.CopyMetaEnable = Checkbutton(self.ControlPanel, text="Keep Metadata", variable=self.CopyMetaVar, padx=0)
            self.CopyMetaEnable.grid(sticky=W, row=row, column=0, columnspan=3)
            row += 1
            self.OptimizeEnable = Checkbutton(self.ControlPanel, text="Optimize", variable=self.OptimizeVar, padx=0)
            self.OptimizeEnable.grid(sticky=W, row=row, column=0, columnspan=3)
            row += 1

        dyna_rows.append(row)
        self.CropQuitButton = Button(self.ControlPanel, text="Crop and Quit", command=self.OnCropQuitClick)
        self.CropQuitButton.grid(row=row, column=0, columnspan=3, pady=(16,0), sticky=E+S+W)
        row += 1
        self.CropButton = Button(self.ControlPanel, text="Crop", command=self.OnCropClick)
        self.CropButton.grid(row=row, column=0, pady=6, sticky=E+W)
        self.QuitButton = Button(self.ControlPanel, text="Quit", command=self.OnClose)
        self.QuitButton.grid(row=row, column=2, pady=6, sticky=E+W)
        row += 1

        self.ControlPanel.columnconfigure(0, weight=1)
        self.ControlPanel.columnconfigure(2, weight=1)
        for row in dyna_rows:
            self.ControlPanel.rowconfigure(row, weight=1)
        self.ControlPanel.grid(sticky=N+S, row=0, column=0)

        self.Preview = Canvas(self, width=1024, height=720, bg=BACKGROUND_COLOR)
        self.Preview.bind("<Configure>", self.OnResize)
        self.Preview.bind("<Motion>", self.OnMouseMove)
        self.Preview.bind("<ButtonPress-1>", self.OnMouseDown)
        self.Preview.bind("<ButtonRelease-1>", self.OnMouseUp)
        self.Preview.bind("<B1-Motion>", self.OnMouseDrag)
        self.Preview.bind("<ButtonPress-3>", self.OnMouseDown)
        self.Preview.bind("<ButtonRelease-3>", self.OnMouseUp)
        self.Preview.bind("<B3-Motion>", self.OnMouseDraw)
        self.Preview.grid(sticky=N+S+E+W, row=0, column=1, padx=0, pady=0, ipadx=0, ipady=0)

        self.Image = self.Preview.create_image(0, 0, anchor=N+W)
        self.ShadeRects = [self.Preview.create_rectangle(-1, -1, -1, -1, width=0, fill=BACKGROUND_COLOR) for i in range(4)]
        self.CropRect1 = self.Preview.create_rectangle(0, 0, 0, 0, outline="#ffffff")
        self.CropRect2 = self.Preview.create_rectangle(0, 0, 0, 0, outline="#000000", dash=(2,4))
        self.rect_anim = 0
        self.after(250, self.Animate)

        self.columnconfigure(1, weight=1)
        self.rowconfigure(0, weight=1)
        self.protocol('WM_DELETE_WINDOW', self.OnClose)

    def Animate(self):
        self.rect_anim += 1
        self.Preview.itemconfigure(self.CropRect2, dashoff=self.rect_anim)
        self.after(250, self.Animate)

    def OnClose(self, ev=None):
        if not(self.can_crop) or (tuple(self.crop) == self.last_crop) \
        or tkMessageBox.askyesno("Confirm Quit", "Really quit without cropping?", parent=self):
            self.quit()

    def OnResize(self, ev=None):
        self.UpdateScene(True)

    def UpdateScene(self, resized=False):
        if resized:
            self.psize = (self.Preview.winfo_width() - 4, self.Preview.winfo_height() - 4)
        if (self.psize[0] < 2) or (self.psize[1] < 2):
            return
        if resized:
            self.scale = min(1.0, min(sp / float(si) for sp, si in zip(self.psize, self.img.size)))
            self.isize = tuple(int(x * self.scale + 0.5) for x in self.img.size)
            self.poffs = tuple((sp - si) // 2 + 2 for sp, si in zip(self.psize, self.isize))
            self.photo = ImageTk.PhotoImage(self.img.resize(self.isize))
            self.Preview.coords(self.Image, *self.poffs)
            self.Preview.itemconfigure(self.Image, image=self.photo)
        self.pcrop = tuple(o + int(x * self.scale + 0.5) for x, o in zip(self.crop, 2 * self.poffs))
        self.Preview.coords(self.CropRect1, self.pcrop[0], self.pcrop[1], self.pcrop[2] - 1, self.pcrop[3] - 1)
        self.Preview.coords(self.CropRect2, self.pcrop[0], self.pcrop[1], self.pcrop[2] - 1, self.pcrop[3] - 1)
        if self.ShadeEnableVar.get():
            self.Preview.coords(self.ShadeRects[0], self.poffs[0], self.poffs[1], self.pcrop[2], self.pcrop[1])
            self.Preview.coords(self.ShadeRects[1], self.pcrop[2], self.poffs[1], self.poffs[0] + self.isize[0], self.pcrop[3])
            self.Preview.coords(self.ShadeRects[2], self.pcrop[0], self.pcrop[3], self.poffs[0] + self.isize[0], self.poffs[1] + self.isize[1])
            self.Preview.coords(self.ShadeRects[3], self.poffs[0], self.pcrop[1], self.pcrop[0], self.poffs[1] + self.isize[1])
        else:
            for rect in self.ShadeRects:
                self.Preview.coords(rect, -1, -1, -1, -1)

    def UpdateControls(self):
        self.OffsetXVar.set(str(self.crop[0]))
        self.OffsetYVar.set(str(self.crop[1]))
        self.SizeXVar.set(str(self.crop[2] - self.crop[0]))
        self.SizeYVar.set(str(self.crop[3] - self.crop[1]))

    def SetCropRect(self, crop_rect=None, anchor=NO_EDGE, aspect=-1, keep_size=False):
        # load state into local variables, store new aspect ratio (if changed)
        mx, my = self.img.size
        gx0, gy0 = self.grid
        if self.AlignSizeVar.get():
            gx1, gy1 = gx0, gy0
        else:
            gx1, gy1 = 1, 1
        if not crop_rect:
            crop_rect = self.crop
        if not(aspect) or (aspect < 0):
            aspect = self.aspect
        else:
            self.aspect = aspect
        x0, y0, x1, y1 = crop_rect

        # sanitize crop window winding and size
        if x1 < x0:
            x0, x1 = x1, x0
            if anchor & EDGE_HORIZ:
                anchor = anchor ^ EDGE_HORIZ
        if y1 < y0:
            y0, y1 = y1, y0
            if anchor & EDGE_VERT:
                anchor = anchor ^ EDGE_VERT
        sx = max(1, x1 - x0)
        sy = max(1, y1 - y0)

        # determine max size with regard to anchors
        if anchor & EDGE_X0:
            mx -= x0
        elif anchor & EDGE_X1:
            mx = x1
        if anchor & EDGE_Y0:
            my -= y0
        elif anchor & EDGE_Y1:
            my = y1

        # enforce aspect ratio and maximum size
        if aspect:
            ah = anchor & EDGE_HORIZ
            av = anchor & EDGE_VERT
            if ah and not(av):
                fsy = sx / aspect
            elif av and not(ah):
                fsy = sy
            else:  # use diagonal
                fsy = math.hypot(sx, sy) / math.sqrt(1 + aspect * aspect)
            fsy = min(fsy, my)
            fsx = fsy * aspect
            if fsx > mx:
                sx = mx
                sy = int(sx / aspect + 0.5)
            else:
                sx = int(fsx + 0.5)
                sy = int(fsy + 0.5)
        else:
            sx = min(sx, mx)
            sy = min(sy, my)
        if keep_size:
            if gx1 > 1: sx = min(mx, ((sx + gx1 // 2) // gx1) * gx1)
            if gy1 > 1: sy = min(my, ((sy + gy1 // 2) // gy1) * gy1)
        assert (sx <= mx) and (sy <= my)

        # recompute upper left coordinate
        if anchor & EDGE_X1:
            x0 = x1 - sx
        elif not(anchor & EDGE_X0):
            x0 = (x0 + x1 - sx) // 2
        if anchor & EDGE_Y1:
            y0 = y1 - sy
        elif not(anchor & EDGE_Y0):
            y0 = (y0 + y1 - sy) // 2

        # fit rectangle into image
        mx, my = self.img.size
        x0 = min(max(x0, 0), mx - sx)
        y0 = min(max(y0, 0), my - sy)

        # recompute lower right coordinate
        x1 = x0 + sx
        y1 = y0 + sy

        # align to grid
        if   gx0 > 1:   x0 = ((x0 + gx0 // 2) // gx0) * gx0
        if   keep_size: x1 = x0 + sx
        elif gx1 > 1:   x1 = ((x1 + gx1 // 2) // gx1) * gx1
        if x0 >= mx: x0 -= gx0
        x1 = min(max(x1, x0 + gx1), mx)
        if   gy0 > 1:   y0 = ((y0 + gy0 // 2) // gy0) * gy0
        if   keep_size: y1 = y0 + sy
        elif gy1 > 1:   y1 = ((y1 + gy1 // 2) // gy1) * gy1
        if y0 >= my: y0 -= gy0
        y1 = min(max(y1, y0 + gy1), my)

        # done!
        self.crop = [x0, y0, x1, y1]
        self.UpdateScene()
        self.UpdateControls()

    def OnMouseMove(self, ev):
        self.edge = get_edge_code(ev.x, ev.y, *self.pcrop)
        self.Preview.configure(cursor=CURSOR_MAP[self.edge])

    def OnMouseDown(self, ev):
        self.ref_mouse = (ev.x, ev.y)
        self.ref_crop = tuple(self.crop)
        self.dragging = True
        if ev.num == 3:
            self.Preview.configure(cursor="arrow")

    def OnMouseUp(self, ev=None):
        self.dragging = False

    def OnMouseDrag(self, ev):
        delta = tuple(int((pm - pr) / self.scale + 0.5) for pm, pr in zip((ev.x, ev.y), self.ref_mouse))
        newcrop = [self.ref_crop[i] + (delta[i&1] if (self.edge & (1 << i)) else 0) for i in range(4)]
        self.SetCropRect(newcrop, invert_edge_code(self.edge), keep_size=(self.edge == MOVE_RECT))

    def OnMouseDraw(self, ev):
        newcrop = (self.ref_mouse[0], self.ref_mouse[1], ev.x, ev.y)
        newcrop = [min(self.img.size[i&1], max(0, int((newcrop[i] - self.poffs[i&1]) / self.scale + 0.5))) for i in range(4)]
        self.SetCropRect(newcrop, CORNER_X0Y0)

    def OnCoordChange(self, ev=None):
        x0 = self.OffsetXVar.get()
        y0 = self.OffsetYVar.get()
        sx = self.SizeXVar.get()
        sy = self.SizeYVar.get()
        if  (x0 == str(self.crop[0])) \
        and (y0 == str(self.crop[1])) \
        and (sx == str(self.crop[2] - self.crop[0])) \
        and (sy == str(self.crop[3] - self.crop[1])):
            return  # no change
        try:
            x0 = int(x0, 10)
            y0 = int(y0, 10)
            sx = int(sx, 10)
            sy = int(sy, 10)
        except ValueError:
            return
        self.SetCropRect([x0, y0, x0 + sx, y0 + sy], CORNER_X0Y0)

    def OnAspectMenuChange(self, *args):
        value = self.AspectMenuVar.get()

        # enable/disable custom aspect ratio control and handle special cases
        uistate = (NORMAL if (value == ASPECT_CUSTOM) else DISABLED)
        self.AspectX.configure(state=uistate)
        self.AspectY.configure(state=uistate)
        self.AspectLabel.configure(state=uistate)
        if value == ASPECT_UNRESTRICTED:
            self.AspectFlip.configure(state=DISABLED)
            self.AspectXVar.set("")
            self.AspectYVar.set("")
            self.aspect = None
            return
        else:
            self.AspectFlip.configure(state=NORMAL)
        if value == ASPECT_CUSTOM:
            return

        # load prefab aspect ratio
        if value == ASPECT_FROM_IMAGE:
            ax, ay = self.img.size
        elif value == ASPECT_FROM_CURRENT:
            ax = self.crop[2] - self.crop[0]
            ay = self.crop[3] - self.crop[1]
        else:
            ax, ay = [int(x.strip().split(' ', 1)[0]) for x in value.split(':', 1)]
            # load preset -> flip orientation if necessary
            curr = (float(self.crop[2] - self.crop[0]) / (self.crop[3] - self.crop[1])) - 1.0
            if abs(curr) <= 1.0E-10:
                curr = (float(self.img.size[0]) / self.img.size[1]) - 1.0
            ref  = (float(ax) / ay) - 1.0
            if curr * ref < -1.0E-10:
                ax, ay = ay, ax
        assert ax and ay
        d = gcd(ax, ay)
        ax //= d
        ay //= d

        # apply new aspect ratio
        self.AspectXVar.set(str(ax))
        self.AspectYVar.set(str(ay))
        self.SetCropRect(aspect = float(ax) / ay)

    def OnCustomAspectChange(self, ev=None):
        try:
            ax = float(self.AspectXVar.get())
            ay = float(self.AspectYVar.get())
        except ValueError:
            return
        if (ax != ax) or (ay != ay) or (ax < 1.0E-10) or (ay < 1.0E-10):
            return
        self.SetCropRect(aspect = ax / ay)

    def OnAspectFlipClick(self, ev=None):
        if not self.aspect: return
        ax = self.AspectXVar.get()
        ay = self.AspectYVar.get()
        self.AspectXVar.set(ay)
        self.AspectYVar.set(ax)
        self.SetCropRect(aspect = 1.0 / self.aspect)

    def OnAlignSizeChange(self, *args):
        mode = self.AlignSizeVar.get()
        self.SizeX.configure(increment=(self.grid[0] if mode else 1))
        self.SizeY.configure(increment=(self.grid[1] if mode else 1))
        if mode:
            self.SetCropRect()

    def OnFileModeChange(self, *args):
        if self.prev_file_mode == FILEMODE_NEW:
            self.suffix_new = self.SuffixVar.get()
        elif self.prev_file_mode == FILEMODE_BACKUP:
            self.suffix_backup = self.SuffixVar.get()
        mode = self.FileModeVar.get()
        uistate = NORMAL
        if mode == FILEMODE_NEW:
            self.SuffixVar.set(self.suffix_new)
            self.SuffixLabel.configure(text="Filename suffix:")
        elif mode == FILEMODE_BACKUP:
            self.SuffixVar.set(self.suffix_backup)
            self.SuffixLabel.configure(text="Backup suffix:")
        else:
            uistate = DISABLED
        self.SuffixLabel.configure(state=uistate)
        self.SuffixEntry.configure(state=uistate)
        self.prev_file_mode = mode

    def DoCrop(self):
        if not self.can_crop:
            return False

        # resolve source and target file names
        mode = self.FileModeVar.get()
        if mode == FILEMODE_NEW:
            target = add_suffix(self.filename, self.SuffixVar.get())
            if os.path.exists(target) \
            and not tkMessageBox.askyesno("Confirm overwrite", f"The target file\n\"{target}\"\nalready exists.\n\nOverwrite?", parent=self):
                return False
        elif mode == FILEMODE_OVERWRITE:
            target = self.filename
            if tuple(self.crop) != (0, 0, self.img.size[0], self.img.size[1]):
                self.can_crop = False
        elif mode == FILEMODE_BACKUP:
            target = self.filename
            if self.src == self.filename:
                backup = add_suffix(self.filename, self.SuffixVar.get())
                if os.path.exists(backup):
                    if tkMessageBox.askyesno("Confirm overwrite", f"The backup file\n\"{backup}\"\nalready exists.\n\nOverwrite?", parent=self):
                        try:
                            os.unlink(backup)
                        except OSError as e:
                            tkMessageBox.showerror("Overwrite error", "Could not remove old backup file:\n" + e.strerror + "\n\nAborting.", parent=self)
                            return False
                    else:
                        return False
                try:
                    shutil.copy2(self.filename, backup)
                except EnvironmentError as e:
                    tkMessageBox.showerror("Backup error", "Could not save backup file:\n" + (e.strerror or str(e)))
                    return False
                self.src = backup
        elif mode == FILEMODE_EXPLICIT:
            ldir, lbase = os.path.split(os.path.abspath(self.last_output_file))
            target = tkFileDialog.asksaveasfilename(
                title="Set output file name",
                defaultextension=".jpg",
                filetypes=FILE_DIALOG_FILETYPES,
                initialdir=ldir,
                initialfile=lbase,
                parent=self)
            if not target:
                return False
        else:
            assert False
        self.last_output_file = target

        # run actual cropping
        err = RunCrop(self.img, self.src, target, self.crop, copy_metadata=self.CopyMetaVar.get(), optimize=self.OptimizeVar.get(), is_jpeg=self.is_jpeg)
        if err:
            tkMessageBox.showerror("Cropping failed", "Cropping failed:\n" + err, parent=self)
            if mode == FILEMODE_OVERWRITE:
                self.can_crop = False
        else:
            self.last_crop = tuple(self.crop)
        if not self.can_crop:
            self.CropButton.configure(state=DISABLED)
            self.CropQuitButton.configure(state=DISABLED)
        return not(err)

    def OnCropClick(self, ev=None):
        if self.DoCrop():
            tkMessageBox.showinfo("Cropping succeeded", "Cropping was successful.", parent=self)

    def OnCropQuitClick(self, ev=None):
        if self.DoCrop():
            self.quit()

################################################################################

if __name__ == "__main__":
    # command line parsing
    parser = argparse.ArgumentParser(description=__doc__.strip())
    parser.add_argument("infile", metavar="input.jpg", nargs='?',
                        help="the input file to crop")
    parser.add_argument("-a", "--aspect", metavar="X:Y",
                        help="set default aspect ratio")
    args = parser.parse_args()
    if args.aspect:
        try:
            aspect_num, aspect_den = map(int, args.aspect.split(':'))
        except ValueError:
            parser.error("invalid --aspect parameter value")
    else:
        aspect_num, aspect_den = 0, 0

    # acquire jpegtran
    JPEGTRAN_PATH = find_program("jpegtran")
    if not(JPEGTRAN_PATH) and (sys.platform == 'win32'):
        _tk_init()
        if tkMessageBox.askyesno("jpegtran not found",
                                 "Could not find a usable jpegtran executable.\n\n" + \
                                 "Shall I try to download it from " + JPEGTRAN_WIN32_URL + "?"):
            import urllib
            try:
                urllib.urlretrieve(JPEGTRAN_WIN32_URL, os.path.join(os.path.dirname(sys.argv[0]), "jpegtran.exe"))
                JPEGTRAN_PATH = find_program("jpegtran")
            except EnvironmentError as e:
                tkMessageBox.showerror("Download failed",
                                       "Downloading jpegtran failed:\n" + (e.strerror or str(e)))
    if not JPEGTRAN_PATH:
        msg = "Could not find a usable jpegtran executable.\n\n"
        if sys.platform == "win32":
            msg += "Please download it from " + JPEGTRAN_WIN32_URL + \
                   " and put it in the same directory as " + os.path.basename(sys.argv[0]) + \
                   " or somewhere in the PATH."
        else:
            msg += "Please install it using your package manager, e.g. on Debian/Ubuntu and derivates:\n" + \
                   "sudo apt-get install libjpeg-progs"
        _tk_init()
        tkMessageBox.showerror("jpegtran not found", msg)
        sys.exit(1)

    # request input filename if not specified on command line
    filename = args.infile
    if not filename:
        _tk_init()
        filename = tkFileDialog.askopenfilename(title="Select JPEG file to crop", filetypes=FILE_DIALOG_FILETYPES)
    if not filename:
        sys.exit(0)

    # main processing
    jpeg = LoadJPEG(filename)
    main = MainForm(filename=jpeg.filename, img=jpeg.img, xgrid=jpeg.xgrid, ygrid=jpeg.ygrid, aspect_num=aspect_num, aspect_den=aspect_den)
    main.mainloop()
    res = main.result
    del main
    sys.exit(res)
