diff options
| -rw-r--r-- | README.adoc | 2 | ||||
| -rw-r--r-- | fiv-jpegcrop.c | 421 | ||||
| -rw-r--r-- | fiv-jpegcrop.desktop | 11 | ||||
| -rw-r--r-- | meson.build | 10 | 
4 files changed, 443 insertions, 1 deletions
| 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 <p@janouch.name> +// +// 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 <gtk/gtk.h> +#include <turbojpeg.h> + +#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', | 
