diff --git a/README.adoc b/README.adoc index 79c3bf2..4404457 100644 --- a/README.adoc +++ b/README.adoc @@ -43,7 +43,9 @@ Build-only dependencies: Runtime dependencies: gtk+-3.0, glib>=2.64, pixman-1, shared-mime-info, libturbojpeg, libwebp + Optional dependencies: lcms2, LibRaw, librsvg-2.0, xcursor, libheif, libtiff, - ExifTool, resvg (unstable API, needs to be requested explicitly) + ExifTool, resvg (unstable API, needs to be requested explicitly) + +Runtime dependencies for reverse image search: + xdg-utils, cURL, jq $ git clone --recursive https://git.janouch.name/p/fiv.git $ meson builddir @@ -55,7 +57,8 @@ direct installations via `ninja install`. To test the program: $ meson devenv fiv -The lossless JPEG cropper is intended to be invoked from a context menu. +The lossless JPEG cropper and reverse image search are intended to be invoked +from a context menu. Windows ~~~~~~~ diff --git a/fiv-io.c b/fiv-io.c index 74dd56e..a995940 100644 --- a/fiv-io.c +++ b/fiv-io.c @@ -1,7 +1,7 @@ // // fiv-io.c: image operations // -// Copyright (c) 2021 - 2022, Přemysl Eric Janouch +// Copyright (c) 2021 - 2023, Přemysl Eric Janouch // // Permission to use, copy, modify, and/or distribute this software for any // purpose with or without fee is hereby granted. @@ -2958,6 +2958,88 @@ fiv_io_deserialize(GBytes *bytes, guint64 *user_data) return surface; } +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static cairo_status_t +write_to_byte_array( + void *closure, const unsigned char *data, unsigned int length) +{ + g_byte_array_append(closure, data, length); + return CAIRO_STATUS_SUCCESS; +} + +GBytes * +fiv_io_serialize_for_search(cairo_surface_t *surface, GError **error) +{ + g_return_val_if_fail( + surface && cairo_surface_get_type(surface) == CAIRO_SURFACE_TYPE_IMAGE, + NULL); + + cairo_format_t format = cairo_image_surface_get_format(surface); + if (format == CAIRO_FORMAT_ARGB32) { + const uint32_t *data = + (const uint32_t *) cairo_image_surface_get_data(surface); + + bool all_solid = true; + for (size_t len = cairo_image_surface_get_width(surface) * + cairo_image_surface_get_height(surface); len--; ) { + if ((data[len] >> 24) != 0xFF) + all_solid = false; + } + if (all_solid) + format = CAIRO_FORMAT_RGB24; + } + + if (format != CAIRO_FORMAT_RGB24) { +#if CAIRO_HAS_PNG_FUNCTIONS + GByteArray *ba = g_byte_array_new(); + cairo_status_t status = + cairo_surface_write_to_png_stream(surface, write_to_byte_array, ba); + if (status == CAIRO_STATUS_SUCCESS) + return g_byte_array_free_to_bytes(ba); + g_byte_array_unref(ba); +#endif + + // Last resort: remove transparency by painting over black. + cairo_surface_t *converted = + cairo_image_surface_create(CAIRO_FORMAT_RGB24, + cairo_image_surface_get_width(surface), + cairo_image_surface_get_height(surface)); + cairo_t *cr = cairo_create(converted); + cairo_set_source_surface(cr, surface, 0, 0); + cairo_set_operator(cr, CAIRO_OPERATOR_OVER); + cairo_paint(cr); + cairo_destroy(cr); + GBytes *result = fiv_io_serialize_for_search(converted, error); + cairo_surface_destroy(converted); + return result; + } + + tjhandle enc = tjInitCompress(); + if (!enc) { + set_error(error, tjGetErrorStr2(enc)); + return NULL; + } + + unsigned char *jpeg = NULL; + unsigned long length = 0; + if (tjCompress2(enc, cairo_image_surface_get_data(surface), + cairo_image_surface_get_width(surface), + cairo_image_surface_get_stride(surface), + cairo_image_surface_get_height(surface), + (G_BYTE_ORDER == G_LITTLE_ENDIAN ? TJPF_BGRX : TJPF_XRGB), + &jpeg, &length, TJSAMP_444, 90, 0)) { + set_error(error, tjGetErrorStr2(enc)); + tjFree(jpeg); + tjDestroy(enc); + return NULL; + } + + tjDestroy(enc); + return g_bytes_new_with_free_func( + jpeg, length, (GDestroyNotify) tjFree, jpeg); +} + // --- Filesystem -------------------------------------------------------------- #include "xdg.h" diff --git a/fiv-io.h b/fiv-io.h index 9cbe5d8..484e43b 100644 --- a/fiv-io.h +++ b/fiv-io.h @@ -1,7 +1,7 @@ // // fiv-io.h: image operations // -// Copyright (c) 2021 - 2022, Přemysl Eric Janouch +// Copyright (c) 2021 - 2023, Přemysl Eric Janouch // // Permission to use, copy, modify, and/or distribute this software for any // purpose with or without fee is hereby granted. @@ -107,6 +107,8 @@ enum { FIV_IO_SERIALIZE_LOW_QUALITY = 1 << 0 }; void fiv_io_serialize_to_stdout(cairo_surface_t *surface, guint64 user_data); cairo_surface_t *fiv_io_deserialize(GBytes *bytes, guint64 *user_data); +GBytes *fiv_io_serialize_for_search(cairo_surface_t *surface, GError **error); + // --- Filesystem -------------------------------------------------------------- typedef enum _FivIoModelSort { diff --git a/fiv-reverse-search b/fiv-reverse-search new file mode 100755 index 0000000..5210703 --- /dev/null +++ b/fiv-reverse-search @@ -0,0 +1,9 @@ +#!/bin/sh -e +if [ "$#" -ne 2 ]; then + echo "Usage: $0 SEARCH-ENGINE-URI-PREFIX {PATH | URI}" >&2 + exit 1 +fi + +xdg-open "$1$(fiv --thumbnail-for-search large "$2" \ + | curl --silent --show-error --upload-file - https://transfer.sh/image \ + | jq --slurp --raw-input --raw-output @uri)" diff --git a/fiv-reverse-search.desktop.in b/fiv-reverse-search.desktop.in new file mode 100644 index 0000000..49d5de3 --- /dev/null +++ b/fiv-reverse-search.desktop.in @@ -0,0 +1,10 @@ +[Desktop Entry] +Type=Application +Name=fiv @NAME@ Reverse Image Search +GenericName=@NAME@ Reverse Image Search +Icon=fiv +Exec=fiv-reverse-search "@URL@" %u +NoDisplay=true +Terminal=false +Categories=Graphics;2DGraphics; +MimeType=image/png;image/bmp;image/gif;image/x-tga;image/jpeg;image/webp; diff --git a/fiv-thumbnail.c b/fiv-thumbnail.c index d0ec91a..1f6897f 100644 --- a/fiv-thumbnail.c +++ b/fiv-thumbnail.c @@ -1,7 +1,7 @@ // // fiv-thumbnail.c: thumbnail management // -// Copyright (c) 2021 - 2022, Přemysl Eric Janouch +// Copyright (c) 2021 - 2023, Přemysl Eric Janouch // // Permission to use, copy, modify, and/or distribute this software for any // purpose with or without fee is hereby granted. @@ -421,6 +421,29 @@ save_thumbnail(cairo_surface_t *thumbnail, const char *path, GString *thum) WebPDataClear(&assembled); } +cairo_surface_t * +fiv_thumbnail_produce_for_search( + GFile *target, FivThumbnailSize max_size, GError **error) +{ + g_return_val_if_fail(max_size >= FIV_THUMBNAIL_SIZE_MIN && + max_size <= FIV_THUMBNAIL_SIZE_MAX, NULL); + + GBytes *data = g_file_load_bytes(target, NULL, NULL, error); + if (!data) + return NULL; + + gboolean color_managed = FALSE; + cairo_surface_t *surface = render(target, data, &color_managed, error); + if (!surface) + return NULL; + + // TODO(p): Might want to keep this a square. + cairo_surface_t *result = + adjust_thumbnail(surface, fiv_thumbnail_sizes[max_size].size); + cairo_surface_destroy(surface); + return result; +} + static cairo_surface_t * produce_fallback(GFile *target, FivThumbnailSize size, GError **error) { @@ -459,7 +482,7 @@ cairo_surface_t * fiv_thumbnail_produce(GFile *target, FivThumbnailSize max_size, GError **error) { g_return_val_if_fail(max_size >= FIV_THUMBNAIL_SIZE_MIN && - max_size <= FIV_THUMBNAIL_SIZE_MAX, FALSE); + max_size <= FIV_THUMBNAIL_SIZE_MAX, NULL); // Don't save thumbnails for FUSE mounts, such as sftp://. // Moreover, it doesn't make sense to save thumbnails of thumbnails. diff --git a/fiv-thumbnail.h b/fiv-thumbnail.h index 7f3360a..de5be51 100644 --- a/fiv-thumbnail.h +++ b/fiv-thumbnail.h @@ -1,7 +1,7 @@ // // fiv-thumbnail.h: thumbnail management // -// Copyright (c) 2021 - 2022, Přemysl Eric Janouch +// Copyright (c) 2021 - 2023, Přemysl Eric Janouch // // Permission to use, copy, modify, and/or distribute this software for any // purpose with or without fee is hereby granted. @@ -62,6 +62,10 @@ cairo_surface_t *fiv_thumbnail_extract( cairo_surface_t *fiv_thumbnail_produce( GFile *target, FivThumbnailSize max_size, GError **error); +/// Like fiv_thumbnail_produce(), but skips the cache. +cairo_surface_t *fiv_thumbnail_produce_for_search( + GFile *target, FivThumbnailSize max_size, GError **error); + /// Retrieves a thumbnail of the most appropriate quality and resolution /// for the target file. cairo_surface_t *fiv_thumbnail_lookup( diff --git a/fiv-update-desktop-files.in b/fiv-update-desktop-files.in index bbbe9a9..1c8568f 100755 --- a/fiv-update-desktop-files.in +++ b/fiv-update-desktop-files.in @@ -1,4 +1,8 @@ #!/bin/sh -e -sed -i "s|^MimeType=.*|MimeType=$( - "${DESTDIR:+$DESTDIR/}"'@EXE@' --list-supported-media-types | tr '\n' ';' -)|" "${DESTDIR:+$DESTDIR/}"'@DESKTOP@' +fiv=${DESTDIR:+$DESTDIR/}'@FIV@' +desktopdir=${DESTDIR:+$DESTDIR/}'@DESKTOPDIR@' + +types=$("$fiv" --list-supported-media-types | tr '\n' ';') +for desktop in @DESKTOPS@ +do sed -i "s|^MimeType=.*|MimeType=$types|" "$desktopdir"/"$desktop" +done diff --git a/fiv.c b/fiv.c index d58a5a5..3dfe058 100644 --- a/fiv.c +++ b/fiv.c @@ -1,7 +1,7 @@ // // fiv.c: fuck-if-I-know-how-to-name-it image browser and viewer // -// Copyright (c) 2021 - 2022, Přemysl Eric Janouch +// Copyright (c) 2021 - 2023, Přemysl Eric Janouch // // Permission to use, copy, modify, and/or distribute this software for any // purpose with or without fee is hereby granted. @@ -1934,8 +1934,8 @@ static const char stylesheet[] = "@define-color fiv-tile @content_view_bg; \ } \ .fiv-information label { padding: 0 4px; }"; -static void -output_thumbnail(gchar **uris, gboolean extract, const char *size_arg) +static FivThumbnailSize +output_thumbnail_prologue(gchar **uris, const char *size_arg) { if (!uris) exit_fatal("No path given"); @@ -1956,6 +1956,38 @@ output_thumbnail(gchar **uris, gboolean extract, const char *size_arg) #ifdef G_OS_WIN32 _setmode(fileno(stdout), _O_BINARY); #endif + return size; +} + +static void +output_thumbnail_for_search(gchar **uris, const char *size_arg) +{ + FivThumbnailSize size = output_thumbnail_prologue(uris, size_arg); + + GError *error = NULL; + GFile *file = g_file_new_for_uri(uris[0]); + cairo_surface_t *surface = NULL; + GBytes *bytes = NULL; + if ((surface = fiv_thumbnail_produce(file, size, &error)) && + (bytes = fiv_io_serialize_for_search(surface, &error))) { + fwrite( + g_bytes_get_data(bytes, NULL), 1, g_bytes_get_size(bytes), stdout); + g_bytes_unref(bytes); + } else { + g_assert(error != NULL); + } + + g_object_unref(file); + if (error) + exit_fatal("%s", error->message); + + cairo_surface_destroy(surface); +} + +static void +output_thumbnail(gchar **uris, gboolean extract, const char *size_arg) +{ + FivThumbnailSize size = output_thumbnail_prologue(uris, size_arg); GError *error = NULL; GFile *file = g_file_new_for_uri(uris[0]); @@ -1981,7 +2013,7 @@ main(int argc, char *argv[]) { gboolean show_version = FALSE, show_supported_media_types = FALSE, invalidate_cache = FALSE, browse = FALSE, extract_thumbnail = FALSE; - gchar **args = NULL, *thumbnail_size = NULL; + gchar **args = NULL, *thumbnail_size = NULL, *thumbnail_size_search = NULL; const GOptionEntry options[] = { {G_OPTION_REMAINING, 0, 0, G_OPTION_ARG_FILENAME_ARRAY, &args, NULL, "[PATH | URI]..."}, @@ -1991,6 +2023,9 @@ main(int argc, char *argv[]) {"browse", 0, G_OPTION_FLAG_IN_MAIN, G_OPTION_ARG_NONE, &browse, "Start in filesystem browsing mode", NULL}, + {"thumbnail-for-search", 0, G_OPTION_FLAG_IN_MAIN, + G_OPTION_ARG_STRING, &thumbnail_size_search, + "Output an image file suitable for searching by content", "SIZE"}, {"extract-thumbnail", 0, G_OPTION_FLAG_IN_MAIN, G_OPTION_ARG_NONE, &extract_thumbnail, "Output any embedded thumbnail (superseding --thumbnail)", NULL}, @@ -2032,6 +2067,10 @@ main(int argc, char *argv[]) args[i] = g_file_get_uri(resolved); g_object_unref(resolved); } + if (thumbnail_size_search) { + output_thumbnail_for_search(args, thumbnail_size_search); + return 0; + } if (extract_thumbnail || thumbnail_size) { output_thumbnail(args, extract_thumbnail, thumbnail_size); return 0; diff --git a/meson.build b/meson.build index 1be61ff..8571101 100644 --- a/meson.build +++ b/meson.build @@ -35,12 +35,12 @@ dependencies = [ dependency('gtk+-3.0'), dependency('pixman-1'), - # Wuffs is included as a submodule. dependency('libturbojpeg'), dependency('libwebp'), dependency('libwebpdemux'), dependency('libwebpdecoder', required : false), dependency('libwebpmux'), + # Wuffs is included as a submodule. lcms2, libjpegqs, @@ -251,6 +251,32 @@ if not win32 install_dir : get_option('datadir') / 'applications') endforeach + # TODO(p): Consider moving this to /usr/share or /usr/lib. + install_data('fiv-reverse-search', + install_dir : get_option('bindir')) + + # As usual, handling generated files in Meson is a fucking pain. + updatable_desktops = [application_ns + 'fiv.desktop'] + foreach name, uri : { + 'Google' : 'https://lens.google.com/uploadbyurl?url=', + 'Bing' : 'https://www.bing.com/images/searchbyimage?cbir=sbi&imgurl=', + 'Yandex' : 'https://yandex.com/images/search?rpt=imageview&url=', + 'TinEye' : 'https://tineye.com/search?url=', + 'SauceNAO' : 'https://saucenao.com/search.php?url=', + 'IQDB' : 'https://iqdb.org/?url=', + } + desktop = 'fiv-reverse-search-' + name.to_lower() + '.desktop' + updatable_desktops += application_ns + desktop + + test(desktop, dfv, args : configure_file( + input : 'fiv-reverse-search.desktop.in', + output : application_ns + desktop, + configuration : {'NAME' : name, 'URL' : uri}, + install : true, + install_dir : get_option('datadir') / 'applications', + )) + endforeach + # With gdk-pixbuf, fiv.desktop depends on currently installed modules; # the package manager needs to be told to run this script as necessary. dynamic_desktops = gdkpixbuf.found() @@ -259,9 +285,10 @@ if not win32 input : 'fiv-update-desktop-files.in', output : 'fiv-update-desktop-files', configuration : { - 'EXE' : get_option('prefix') / get_option('bindir') / exe.name(), - 'DESKTOP' : get_option('prefix') / get_option('datadir') \ - / 'applications' / application_ns + 'fiv.desktop', + 'FIV' : get_option('prefix') / get_option('bindir') / exe.name(), + 'DESKTOPDIR' : get_option('prefix') / + get_option('datadir') / 'applications', + 'DESKTOPS' : ' \\\n\t'.join(updatable_desktops), }, install : dynamic_desktops, install_dir : get_option('bindir'))