// // fiv-jpegcrop.c: lossless JPEG cropper // // Copyright (c) 2022, Přemysl Eric Janouch // // Permission to use, copy, modify, and/or distribute this software for any // purpose with or without fee is hereby granted. // // THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES // WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF // MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY // SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES // WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION // OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN // CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. // #include #include #include "config.h" // --- Utilities --------------------------------------------------------------- static void exit_fatal(const char *format, ...) G_GNUC_PRINTF(1, 2); static void exit_fatal(const char *format, ...) { va_list ap; va_start(ap, format); gchar *format_nl = g_strdup_printf("%s\n", format); vfprintf(stderr, format_nl, ap); free(format_nl); va_end(ap); exit(EXIT_FAILURE); } // --- Main -------------------------------------------------------------------- struct { GFile *location; gchar *data; gsize len; int width, height, subsampling, colorspace; int mcu_width, mcu_height; cairo_surface_t *surface; int top, left, right, bottom; GtkWidget *label; GtkWidget *window; GtkWidget *scrolled; GtkWidget *view; } g; static void show_error_dialog(GError *error) { GtkWidget *dialog = gtk_message_dialog_new(GTK_WINDOW(g.window), GTK_DIALOG_MODAL, GTK_MESSAGE_ERROR, GTK_BUTTONS_CLOSE, "%s", error->message); gtk_dialog_run(GTK_DIALOG(dialog)); gtk_widget_destroy(dialog); g_error_free(error); } static gboolean on_draw(G_GNUC_UNUSED GtkWidget *widget, cairo_t *cr, G_GNUC_UNUSED gpointer user_data) { cairo_set_source_surface(cr, g.surface, 1, 1); cairo_paint(cr); cairo_rectangle(cr, 1 + g.left - 0.5, 1 + g.top - 0.5, g.right - g.left + 1, g.bottom - g.top + 1); cairo_set_source_rgb(cr, 1, 1, 1); cairo_set_line_width(cr, 1); cairo_set_operator(cr, CAIRO_OPERATOR_DIFFERENCE); cairo_stroke(cr); cairo_set_fill_rule(cr, CAIRO_FILL_RULE_EVEN_ODD); cairo_rectangle(cr, 1, 1, g.width, g.height); cairo_rectangle( cr, g.left, g.top, g.right - g.left + 2, g.bottom - g.top + 2); cairo_clip(cr); cairo_set_source_rgba(cr, 0, 0, 0, 0.5); cairo_set_operator(cr, CAIRO_OPERATOR_OVER); cairo_paint(cr); return TRUE; } static GFile * run_chooser(GtkWidget *dialog) { GtkFileChooser *chooser = GTK_FILE_CHOOSER(dialog); gtk_file_chooser_set_local_only(chooser, FALSE); GtkFileFilter *jpeg = gtk_file_filter_new(); gtk_file_filter_add_mime_type(jpeg, "image/jpeg"); gtk_file_filter_add_pattern(jpeg, "*.jpg"); gtk_file_filter_add_pattern(jpeg, "*.jpeg"); gtk_file_filter_add_pattern(jpeg, "*.jpe"); gtk_file_filter_set_name(jpeg, "JPEG"); gtk_file_chooser_add_filter(chooser, jpeg); GtkFileFilter *all = gtk_file_filter_new(); gtk_file_filter_add_pattern(all, "*"); gtk_file_filter_set_name(all, "All files"); gtk_file_chooser_add_filter(chooser, all); GFile *file = NULL; switch (gtk_dialog_run(GTK_DIALOG(dialog))) { default: gtk_widget_destroy(dialog); // Fall-through. case GTK_RESPONSE_NONE: return file; case GTK_RESPONSE_ACCEPT: file = gtk_file_chooser_get_file(chooser); gtk_widget_destroy(dialog); return file; } } static GFile * choose_file_to_open(void) { GtkWidget *dialog = gtk_file_chooser_dialog_new("Open image", NULL, GTK_FILE_CHOOSER_ACTION_OPEN, "_Cancel", GTK_RESPONSE_CANCEL, "_Open", GTK_RESPONSE_ACCEPT, NULL); return run_chooser(dialog); } static GFile * choose_file_to_save(void) { GtkWidget *dialog = gtk_file_chooser_dialog_new("Saved cropped image as", GTK_WINDOW(g.window), GTK_FILE_CHOOSER_ACTION_SAVE, "_Cancel", GTK_RESPONSE_CANCEL, "_Save", GTK_RESPONSE_ACCEPT, NULL); GtkFileChooser *chooser = GTK_FILE_CHOOSER(dialog); gtk_file_chooser_set_do_overwrite_confirmation(chooser, TRUE); (void) gtk_file_chooser_set_file(chooser, g.location, NULL); return run_chooser(dialog); } static void on_save_as(G_GNUC_UNUSED GtkButton *button, G_GNUC_UNUSED gpointer user_data) { tjhandle h = tjInitTransform(); if (!h) { show_error_dialog(g_error_new_literal( G_IO_ERROR, G_IO_ERROR_FAILED, tjGetErrorStr2(h))); return; } // Convert up front, because the target is in memory. tjtransform t = { .r.x = g.left, .r.y = g.top, .r.w = g.right - g.left, .r.h = g.bottom - g.top, .op = TJXOP_NONE, .options = TJXOPT_CROP | TJXOPT_PROGRESSIVE | TJXOPT_PERFECT, }; guchar *data = NULL; gulong len = 0; if (tjTransform(h, (const guchar *) g.data, g.len, 1, &data, &len, &t, 0)) { show_error_dialog(g_error_new_literal( G_IO_ERROR, G_IO_ERROR_FAILED, tjGetErrorStr2(h))); goto out; } GFile *file = choose_file_to_save(); GError *error = NULL; if (file && !g_file_replace_contents(file, (const char *) data, len, NULL, FALSE, G_FILE_CREATE_NONE, NULL, NULL, &error)) { show_error_dialog(error); goto out; } g_clear_object(&file); tjFree(data); out: if (tjDestroy(h)) { show_error_dialog(g_error_new_literal( G_IO_ERROR, G_IO_ERROR_FAILED, tjGetErrorStr2(h))); } } static void update_label(void) { gchar *text = g_strdup_printf("(%d, %d) × (%d, %d)", g.left, g.top, g.right - g.left, g.bottom - g.top); gtk_label_set_label(GTK_LABEL(g.label), text); g_free(text); } static void update(void) { update_label(); gtk_widget_queue_draw(g.view); } static void on_reset(G_GNUC_UNUSED GtkButton *button, G_GNUC_UNUSED gpointer user_data) { g.top = 0; g.left = 0; g.right = g.width; g.bottom = g.height; update(); } static gboolean on_mouse(guint state, guint button, gdouble x, gdouble y) { if (state != 0) return FALSE; switch (button) { case GDK_BUTTON_PRIMARY: g.left = CLAMP((int) (x - 1), 0, g.right) / g.mcu_width * g.mcu_width; g.top = CLAMP((int) (y - 1), 0, g.bottom) / g.mcu_height * g.mcu_height; update(); return TRUE; case GDK_BUTTON_SECONDARY: // Inclusive of pointer position. g.right = CLAMP(x, g.left, g.width); g.bottom = CLAMP(y, g.top, g.height); update(); return TRUE; default: return FALSE; } } static gboolean on_press(G_GNUC_UNUSED GtkWidget *self, GdkEventButton *event, G_GNUC_UNUSED gpointer user_data) { return on_mouse(event->state, event->button, event->x, event->y); } static gboolean on_motion(G_GNUC_UNUSED GtkWidget *self, GdkEventMotion *event, G_GNUC_UNUSED gpointer user_data) { switch (event->state) { case GDK_BUTTON1_MASK: return on_mouse(0, GDK_BUTTON_PRIMARY, event->x, event->y); case GDK_BUTTON3_MASK: return on_mouse(0, GDK_BUTTON_SECONDARY, event->x, event->y); } return FALSE; } static void on_drag_begin( GtkGestureDrag *self, gdouble start_x, gdouble start_y, gpointer user_data) { // The middle mouse button will never be triggered by touch screens, // so there is only the NULL sequence to care about. gtk_gesture_set_state(GTK_GESTURE(self), GTK_EVENT_SEQUENCE_CLAIMED); GdkWindow *window = gtk_widget_get_window(g.view); GdkCursor *cursor = gdk_cursor_new_from_name(gdk_window_get_display(window), "grabbing"); gdk_window_set_cursor(window, cursor); g_object_unref(cursor); double *last = user_data; last[0] = start_x; last[1] = start_y; } static void on_drag_update(GtkGestureDrag *self, gdouble offset_x, gdouble offset_y, gpointer user_data) { double start_x = 0, start_y = 0; gtk_gesture_drag_get_start_point(self, &start_x, &start_y); double *last = user_data, diff_x = (start_x + offset_x) - last[0], diff_y = (start_y + offset_y) - last[1]; last[0] = start_x + offset_x; last[1] = start_y + offset_y; GtkScrolledWindow *sw = GTK_SCROLLED_WINDOW(g.scrolled); GtkAdjustment *h = gtk_scrolled_window_get_hadjustment(sw); GtkAdjustment *v = gtk_scrolled_window_get_vadjustment(sw); if (diff_x) gtk_adjustment_set_value(h, gtk_adjustment_get_value(h) - diff_x); if (diff_y) gtk_adjustment_set_value(v, gtk_adjustment_get_value(v) - diff_y); } static void on_drag_end(G_GNUC_UNUSED GtkGestureDrag *self, G_GNUC_UNUSED gdouble start_x, G_GNUC_UNUSED gdouble start_y, G_GNUC_UNUSED gpointer user_data) { // Cursors follow the widget hierarchy. gdk_window_set_cursor(gtk_widget_get_window(g.view), NULL); } static gboolean open_jpeg(const char *data, gsize len, GError **error) { tjhandle h = tjInitDecompress(); if (!h) { g_set_error_literal( error, G_IO_ERROR, G_IO_ERROR_FAILED, tjGetErrorStr2(h)); return FALSE; } if (tjDecompressHeader3(h, (const guint8 *) data, len, &g.width, &g.height, &g.subsampling, &g.colorspace)) { g_set_error_literal( error, G_IO_ERROR, G_IO_ERROR_FAILED, tjGetErrorStr2(h)); tjDestroy(h); return FALSE; } g.top = 0; g.left = 0; g.right = g.width; g.bottom = g.height; g.mcu_width = tjMCUWidth[g.subsampling]; g.mcu_height = tjMCUHeight[g.subsampling]; if (tjDestroy(h)) { g_set_error_literal( error, G_IO_ERROR, G_IO_ERROR_FAILED, tjGetErrorStr2(h)); return FALSE; } // TODO(p): Eventually, convert to using fiv-io.c directly, // which will pull in most of fiv's dependencies, // but also enable correct color management, even for CMYK. // NOTE: It's possible to include this as a mode of the main binary. GInputStream *is = g_memory_input_stream_new_from_data(data, len, NULL); GdkPixbuf *pixbuf = gdk_pixbuf_new_from_stream(is, NULL, error); g_object_unref(is); if (!pixbuf) return FALSE; const char *orientation = gdk_pixbuf_get_option(pixbuf, "orientation"); if (orientation && strlen(orientation) == 1) { int n = *orientation - '0'; if (n >= 1 && n <= 8) { // TODO(p): Apply this to the view, somehow. } } g.surface = gdk_cairo_surface_create_from_pixbuf(pixbuf, 1, NULL); cairo_status_t surface_status = cairo_surface_status(g.surface); if (surface_status != CAIRO_STATUS_SUCCESS) { g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_FAILED, cairo_status_to_string(surface_status)); g_clear_pointer(&g.surface, cairo_surface_destroy); g_object_unref(pixbuf); return FALSE; } return TRUE; } int main(int argc, char *argv[]) { gboolean show_version = FALSE; gchar **args = NULL; const GOptionEntry options[] = { {G_OPTION_REMAINING, 0, 0, G_OPTION_ARG_FILENAME_ARRAY, &args, NULL, "[FILE | URI]"}, {"version", 'V', G_OPTION_FLAG_IN_MAIN, G_OPTION_ARG_NONE, &show_version, "Output version information and exit", NULL}, {}, }; GError *error = NULL; gboolean initialized = gtk_init_with_args( &argc, &argv, " - Lossless JPEG cropper", options, NULL, &error); if (show_version) { const char *version = PROJECT_VERSION; printf("%s %s\n", "fiv-jpegcrop", &version[*version == 'v']); return 0; } if (!initialized) exit_fatal("%s", error->message); gtk_window_set_default_icon_name(PROJECT_NAME); // TODO(p): Rather use G_OPTION_ARG_CALLBACK with G_OPTION_FLAG_FILENAME. // Alternatively, GOptionContext with gtk_get_option_group(TRUE). // Then we can show the help string here instead (in fiv as well). if (args && args[1]) exit_fatal("Too many arguments"); else if (args) g.location = g_file_new_for_commandline_arg(args[0]); else if (!(g.location = choose_file_to_open())) exit(EXIT_SUCCESS); g.window = gtk_window_new(GTK_WINDOW_TOPLEVEL); g_signal_connect(g.window, "destroy", G_CALLBACK(gtk_main_quit), NULL); GFileInfo *info = g_file_query_info(g.location, G_FILE_ATTRIBUTE_STANDARD_NAME "," G_FILE_ATTRIBUTE_STANDARD_DISPLAY_NAME, G_FILE_QUERY_INFO_NONE, NULL, &error); if (!info || !g_file_load_contents( g.location, NULL, &g.data, &g.len, NULL, &error) || !open_jpeg(g.data, g.len, &error)) { show_error_dialog(error); exit(EXIT_FAILURE); } GtkWidget *header = gtk_header_bar_new(); gtk_window_set_titlebar(GTK_WINDOW(g.window), header); gtk_header_bar_set_title( GTK_HEADER_BAR(header), g_file_info_get_display_name(info)); gtk_header_bar_set_subtitle(GTK_HEADER_BAR(header), "Use L/R mouse buttons to adjust the crop region."); gtk_header_bar_set_show_close_button(GTK_HEADER_BAR(header), TRUE); g.label = gtk_label_new(NULL); gtk_header_bar_pack_start(GTK_HEADER_BAR(header), g.label); update_label(); GtkWidget *save = gtk_button_new_from_icon_name( "document-save-as-symbolic", GTK_ICON_SIZE_BUTTON); gtk_widget_set_tooltip_text(save, "Save as..."); g_signal_connect(save, "clicked", G_CALLBACK(on_save_as), NULL); gtk_header_bar_pack_end(GTK_HEADER_BAR(header), save); GtkWidget *reset = gtk_button_new_with_mnemonic("_Reset"); gtk_widget_set_tooltip_text(reset, "Reset the crop region"); g_signal_connect(reset, "clicked", G_CALLBACK(on_reset), NULL); gtk_header_bar_pack_end(GTK_HEADER_BAR(header), reset); g.view = gtk_drawing_area_new(); gtk_widget_set_size_request(g.view, g.width + 2, g.height + 2); gtk_widget_add_events(g.view, GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK | GDK_POINTER_MOTION_MASK); g_signal_connect(g.view, "draw", G_CALLBACK(on_draw), NULL); g_signal_connect(g.view, "button-press-event", G_CALLBACK(on_press), NULL); g_signal_connect(g.view, "motion-notify-event", G_CALLBACK(on_motion), NULL); g.scrolled = gtk_scrolled_window_new(NULL, NULL); gtk_scrolled_window_set_overlay_scrolling( GTK_SCROLLED_WINDOW(g.scrolled), FALSE); gtk_scrolled_window_set_propagate_natural_width( GTK_SCROLLED_WINDOW(g.scrolled), TRUE); gtk_scrolled_window_set_propagate_natural_height( GTK_SCROLLED_WINDOW(g.scrolled), TRUE); GtkGesture *drag = gtk_gesture_drag_new(g.scrolled); gtk_event_controller_set_propagation_phase( GTK_EVENT_CONTROLLER(drag), GTK_PHASE_CAPTURE); gtk_gesture_single_set_button(GTK_GESTURE_SINGLE(drag), GDK_BUTTON_MIDDLE); double last_drag_point[2] = {}; g_signal_connect(drag, "drag-begin", G_CALLBACK(on_drag_begin), last_drag_point); g_signal_connect(drag, "drag-update", G_CALLBACK(on_drag_update), last_drag_point); g_signal_connect(drag, "drag-end", G_CALLBACK(on_drag_end), last_drag_point); gtk_container_add(GTK_CONTAINER(g.scrolled), g.view); gtk_container_add(GTK_CONTAINER(g.window), g.scrolled); gtk_window_set_default_size(GTK_WINDOW(g.window), 800, 600); gtk_widget_show_all(g.window); // It probably needs to be realized. GdkWindow *window = gtk_widget_get_window(g.scrolled); GdkCursor *cursor = gdk_cursor_new_from_name(gdk_window_get_display(window), "crosshair"); gdk_window_set_cursor(window, cursor); g_object_unref(cursor); gtk_main(); g_free(g.data); g_object_unref(g.location); g_object_unref(info); return 0; }