From 433ede4bf189a0be3d2313c0a9d9138a71eaa446 Mon Sep 17 00:00:00 2001
From: Přemysl Eric Janouch
Date: Fri, 18 Feb 2022 18:10:19 +0100
Subject: Add a lossless JPEG cropper
This is more of an MVP, as metadata probably need adjustments.
---
README.adoc | 2 +
fiv-jpegcrop.c | 421 +++++++++++++++++++++++++++++++++++++++++++++++++++
fiv-jpegcrop.desktop | 11 ++
meson.build | 10 +-
4 files changed, 443 insertions(+), 1 deletion(-)
create mode 100644 fiv-jpegcrop.c
create mode 100644 fiv-jpegcrop.desktop
diff --git a/README.adoc b/README.adoc
index d303cee..53ea151 100644
--- a/README.adoc
+++ b/README.adoc
@@ -52,6 +52,8 @@ direct installations. To test the program, help it find its custom thumbnailer:
$ PATH=$(pwd):$PATH ./fiv
+The lossless JPEG cropper is intended to be invoked from a context menu.
+
Documentation
-------------
For information concerning usage, refer to link:docs/fiv.html[the user guide],
diff --git a/fiv-jpegcrop.c b/fiv-jpegcrop.c
new file mode 100644
index 0000000..4cf9d64
--- /dev/null
+++ b/fiv-jpegcrop.c
@@ -0,0 +1,421 @@
+//
+// 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 gchar *format, ...) G_GNUC_PRINTF(1, 2);
+
+static void
+exit_fatal(const gchar *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 *
+choose_filename(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_local_only(chooser, FALSE);
+ gtk_file_chooser_set_do_overwrite_confirmation(chooser, TRUE);
+ (void) gtk_file_chooser_set_file(chooser, g.location, NULL);
+
+ 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 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_filename();
+ 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 = MAX(0, (int) (x - 1)) / g.mcu_width * g.mcu_width;
+ g.top = MAX(0, (int) (y - 1)) / g.mcu_height * g.mcu_height;
+ update();
+ return TRUE;
+ case GDK_BUTTON_SECONDARY:
+ g.right = MIN(x, g.width); // Inclusive of pointer position.
+ g.bottom = MIN(y, g.height); // Inclusive of pointer position.
+ 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 gboolean
+open_jpeg(const gchar *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 **path_args = NULL;
+
+ const GOptionEntry options[] = {
+ {G_OPTION_REMAINING, 0, 0, G_OPTION_ARG_FILENAME_ARRAY, &path_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) {
+ printf("fiv-jpegcrop " PROJECT_VERSION "\n");
+ return 0;
+ }
+ if (!initialized)
+ exit_fatal("%s", error->message);
+
+ // 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 (!path_args || !path_args[0] || path_args[1])
+ exit_fatal("invalid arguments");
+
+ gtk_window_set_default_icon_name(PROJECT_NAME);
+
+ g.location = g_file_new_for_commandline_arg(path_args[0]);
+ 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_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);
+
+ // TODO(p): Track middle mouse button drags, adjust the adjustments.
+ 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);
+
+ 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.view);
+ 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;
+}
diff --git a/fiv-jpegcrop.desktop b/fiv-jpegcrop.desktop
new file mode 100644
index 0000000..f284643
--- /dev/null
+++ b/fiv-jpegcrop.desktop
@@ -0,0 +1,11 @@
+[Desktop Entry]
+Type=Application
+Name=fiv JPEG Cropper
+GenericName=Lossless JPEG Cropper
+Icon=fiv
+Exec=fiv-jpegcrop %u
+NoDisplay=true
+Terminal=false
+StartupNotify=true
+Categories=Graphics;2DGraphics;
+MimeType=image/jpeg;
diff --git a/meson.build b/meson.build
index 9ef54de..99cff0a 100644
--- a/meson.build
+++ b/meson.build
@@ -105,13 +105,21 @@ exe = executable('fiv', 'fiv.c', 'fiv-view.c', 'fiv-io.c',
'fiv-browser.c', 'fiv-sidebar.c', 'fiv-thumbnail.c', 'xdg.c', resources,
install : true,
dependencies : [dependencies])
-
if gdkpixbuf.found()
executable('io-benchmark', 'fiv-io-benchmark.c', 'fiv-io.c', 'xdg.c',
build_by_default : false,
dependencies : [dependencies, gdkpixbuf])
endif
+jpegcrop = executable('fiv-jpegcrop', 'fiv-jpegcrop.c',
+ install : true,
+ dependencies : [
+ dependency('gtk+-3.0'),
+ dependency('libturbojpeg'),
+ ])
+install_data('fiv-jpegcrop.desktop',
+ install_dir : get_option('datadir') / 'applications')
+
# XXX: With gdk-pixbuf, this even depends on currently installed modules.
if meson.is_cross_build()
install_data('fiv.desktop',
--
cgit v1.2.3-70-g09d2