From 796b05c9a5f04aba87e26c5a8384d84edee20b77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C5=99emysl=20Eric=20Janouch?= Date: Wed, 15 Mar 2023 03:31:30 +0100 Subject: [PATCH] Integrate online reverse image search This makes use of our image processing capabilities in order to turn arbitrary image files into normalized thumbnails, upload them to a temporary host, and pass the resulting URI to a search provider. In future, fiv should ideally run the upload itself, so that its status and any errors are obvious to the user, as well as to get rid of the script's dependency on jq. --- README.adoc | 7 ++- fiv-io.c | 84 ++++++++++++++++++++++++++++++++++- fiv-io.h | 4 +- fiv-reverse-search | 9 ++++ fiv-reverse-search.desktop.in | 10 +++++ fiv-thumbnail.c | 27 ++++++++++- fiv-thumbnail.h | 6 ++- fiv-update-desktop-files.in | 10 +++-- fiv.c | 47 ++++++++++++++++++-- meson.build | 35 +++++++++++++-- 10 files changed, 221 insertions(+), 18 deletions(-) create mode 100755 fiv-reverse-search create mode 100644 fiv-reverse-search.desktop.in 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'))